Почему C++ не устаревает

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Привет, Хабр! Меня зовут Георгий Осипов. Я работаю в МГУ и компании Яндекс, а также в команде курса «Разработчик С++» Яндекс.Практикума. В этой статье я поделюсь своими мыслями о том, почему немолодой язык С++ до сих пор не теряет актуальности.


Кажется, что первое доказательство — новость 2022 года, когда компания Google анонсировала новый язык Carbon. Он должен стать альтернативой C++. Первая версия Carbon выйдет только через 2-3 года, но уже сейчас понятно — если C++ языку ищут замену, значит, её нет.


Разберёмся, что же делает язык с 40-летней историей таким популярным и почему сегодня он только укрепляет позиции: в 2022 году C++ занял первое место среди быстрорастущих языков по версии TIOBE.



C++ и его стандарты


C++ проделал немалый путь. Родившись надстройкой над более простым языком C, он пережил несколько крупных обновлений, которые изменили его до неузнаваемости. Эти обновления сделали C++ современным языком, учитывающим новейшие тенденции программирования.
Новый Стандарт языка выходит каждые три года. Особенность в том, как именно принимаются изменения. Каждое нововведение проходит через обсуждения и голосования в международном комитете. В итоге в стандарт попадают только тщательно выверенные изменения.
Следующее крупное обновление запланировано уже на конец текущего года. Можно сказать, что C++ действительно отставал от некоторых современных языков в плане возможностей, но верно нагоняет их. Многие претензии, которые высказывали к C++, потеряли актуальность.


Рассмотрим некоторые претензии, которые часто предъявляются к C++


Претензия 1: C++ имеет слабую стандартную библиотеку.


Отчасти эта претензия правомерна. Но ситуация улучшается.
Чтобы показать это, обратимся к другому популярному языку — Python. Рассмотрим одну из его замечательных возможностей — генератор списка (англ. list comprehension). Он позволяет одним выражением выбрать из списка все четные элементы и поделить их на два. Делается это так:


# смысл — положить в новый список x // 2 (половина x)
# для всех x из списка list, если x делится на 2
[x // 2 for x in list if x % 2 == 0]

Ещё несколько лет назад в C++ ничего подобного не было. Но сейчас можно использовать std::ranges:


namespace view = std::views;
auto even = [](int i) { return i % 2 == 0; };
auto half = [](int i) { return i / 2; };
auto range = view::all(list) | 
             view::filter(even) |
             view::transform(half);

Немного сложнее, но смысл передаётся так же хорошо. Эта возможность была добавлена в стандартную библиотеку в 2020 году.
Как правило, Python не рассматривают в качестве конкурента C++, эти языки используются для разных целей. Но пример показывает, как растёт C++, впитывая лучшее из разных языков. Также в стандартной библиотеке появились средства для синхронизации потоков, работы регулярными выражениями, календарём и часами, файловой системой, многопоточными алгоритмами.
Одна из самых ожидаемых возможностей C++ — работа с сетью. Сетевые приложения в C++ можно написать, только используя сторонние библиотеки. Комитет по стандартизации упорно работает, но пока не удаётся преодолеть все проблемы, чтобы построить идеальный сетевой фреймворк.


Претензия 2: в C++ много избыточных копирований.


Объекты в программировании часто передаются, сохраняются, возвращаются.


Как сохраняет объект C++: копирует объект.
Как сохраняют объекты другие языки: сохраняют не сам объект, а ссылку на объект.


Для больших объектов операция копирования может быть очень затратной.
Такое поведение в C++ имеет свои причины. Главные из которых — использование стека и принципиальное отсутствие сборщика мусора. В C++ есть целый ряд техник, способных решить эту проблему. Это специальные оптимизации, ссылки, умные указатели, и самая интересная — перемещение.
Перемещение — это лёгкая операция, появившаяся в C++ в 2011 году, которая часто может заменить копирование. Перемещение применимо в случаях, когда нужна копия объекта, а оригинал больше не понадобится. Оказывается, это подавляющее большинство случаев.


class Schoolmates {
public:
    Schoolmates(std::vector<Students> students)
        // перемещаем вектор, вместо того, чтобы копировать его
        : students_(std::move(students)) {}

private:
    std::vector<Students> students_;
};


Перемещение в сочетании с умными указателями позволяет решить проблему копирований даже для объектов, не поддерживающих перемещение, делая претензию к C++ неактуальной.


Претензия 3: в C++ отсутствует сборщик мусора, приходится вручную управлять памятью


Претензия состоит из двух частей:


  • В C++ отсутствует сборщик мусора — это правда.
  • Приходится вручную управлять памятью — это неправда.

Современный программист на C++ имеет множество инструментов, позволяющих контролировать память автоматически. Среди них контейнеры, автоматические переменные и умные указатели. Они, конечно, имеют некоторые недостатки, но недостатки потенциального сборщика мусора сильно бы перевесили. Например, сборщик мусора иногда хочет «остановить мир» — заблокировать выполнение сразу всех потоков программы, чтобы безопасно собрать неиспользуемые объекты. Со сборщиком мусора была бы невозможна популярная идиома RTII — благодаря ей автоматически и в нужный момент выполняется освобождение ресурсов.
Например, рассмотрим логирующий класс для записи информации в файл на Python:


class Logger:
    def __init__(self, file):
        self.file_obj = open(file, "w")

    def log(self, message):
        current_time = datetime.datetime.now()
        formatted_timestamp = current_time.strftime("%Y-%m-%d %H:%M:%S")
        self.file_obj.write(f"{formatted_timestamp}: {message}\n")

    def close(self):
        self.file_obj.close()

А теперь рассмотрим похожий класс на C++:


class Logger {
public:
    Logger(std::filesystem::path file_name) : file_obj(file_name) {
    }

    void log(std::string_view message) {
        auto current_time = std::chrono::system_clock::now();
        file_obj << std::format("{:%Y-%m-%d %H:%M:%S} {}\n", current_time, message);
}

private:
    std::ofstream file_obj;
};

Главное отличие в методе close — в C++ он не нужен. В Python вы обязаны самостоятельно позаботиться о закрытии файла. Иначе он будет занимать системный идентификатор даже после того, как лог перестал использоваться, и до тех пор, пока сборщик мусора не решит удалить этот объект. А это может произойти относительно нескоро.
В C++ закрытие произойдёт автоматически, потому что момент удаления объекта строго определён. Это достигается благодаря отсутствию сборщика мусора.


Претензия 4: в C++ ужасный ввод-вывод.


Действительно, в Python вы можете написать так:


print(f"x = {x}, y = {y}, x + y = {x + y}");

В C++ ничего подобного нет. Но если вы не в курсе последних новостей, то, вероятно, не знаете, что 38-летие язык отмечает с добавлением стандартной функции print, имеющей замечательные возможности:


std::print("x = {}, y = {}, x + y = {}", x, y, x + y);

Шаблон будет разобран на этапе компиляции. Ещё одно приятное дополнение — эта функция корректно работает с кодировкой UTF-8, а значит, консольные приложения наконец-то смогут выводить текст на русском или любом другом языке.


Претензия 5: в C++ вы потратите часы на поиск, подключение и сборку нужных зависимостей


Вероятно, это одна из самых серьёзных и обоснованных претензий. Нельзя написать что-то подобное команде pip install opencv-python и затем свободно использовать пакет opencv в любом проекте, просто импортировав его. Традиционный способ установки пакетов в C++:


  • найти репозиторий,
  • подобрать нужные настройки и конфигурировать пакет,
  • собрать библиотеку,
  • сделать библиотеку видимой в нужном проекте.

Такой способ не просто сложен. Главный недостаток — его неудобно автоматизировать. Придётся повторять все эти шаги при обновлении библиотеки.
Но и тут время не стоит на месте. В C++ появляются менеджеры пакетов. Например, conan. Он позволяет автоматизировать эти шаги. Но сам по себе требует определённой сноровки и в удобстве пока уступает pip. Однако даже в этой области ситуация постепенно улучшается.


Претензия 6: C++ сложен в изучении. Даже не беритесь за него, если у вас нет диплома в области математики или программирования


И снова претензия из двух частей:


  • C++ сложен в изучении — это правда.
  • Доступен только дипломированным специалистам — это неправда.

Опыт Яндекс Практикума показывает, что стать C++-разработчиком может каждый. Но стоит признать: порог входа в язык достаточно высок. C++ растёт и усложняется, и у этого процесса есть две стороны. Язык становится больше, и его труднее учить. С другой стороны, он становится выразительнее, многие основные операции описываются более наглядно и понятно.


Однако даже в сложности языка есть преимущество. Освоив C++ вам будет проще перейти на другой язык.


Вывод: претензии к С++ теряют актуальность, потому что язык всё время обновляется.


Сильные стороны С++:


Рассмотрим несколько особенностей C++, выделяющих его на фоне большинства других языков.


Нулевой оверхед


C++ славится высокой производительностью. Проверим, так ли он хорош. В качестве конкурента рассмотрим популярный язык Java. Но вместо того, чтобы сравнивать C++ и Java друг с другом, сравним эти два языка с самими собой.
Для примера возьмём популярную конструкцию — цикл по диапазону. Он позволяет обработать массив из множества элементов. Такой вид циклов есть во всех современных языках. Не стали исключением C++ и Java.
Рассмотрим цикл, который суммирует элементы массива. В C++ и Java он записывается одинаково:


// C++ #1
// Java #1
for(int i : array) {
    sum += i;
}

Если отказаться от синтаксического сахара, то цикл можно переписать, используя более примитивные конструкции:


// C++ #2
for (int j = 0; j < array.size(); j++) {
    sum += array[j];
}

// Java #2
for (int j = 0; j < array.size(); j++) {
    sum += array.get(j);
}

Такой вариант сложнее писать и проще допустить ошибку. Но что с производительностью? Сравним производительность этих вариантов в каждом языке. Для Java возьмём следующий пример. Запустив его, увидим подобную картину:


Цикл по диапазону — 46 ms
Простой счётчик — 7 ms


Простой счётчик работает в 6 раз быстрее. Это говорит о том, что в Java за удобство нужно платить. Программисты в таких случаях говорят, что цикл по диапазону в Java имеет оверхед.
Теперь рассмотрим C++. Запустим бенчмарк на специальном сервисе. В нём мы добавили ещё два варианта и получили такой результат:



Все варианты цикла работают одинаково быстро!


Вот описание версий, которые мы сравнили:


  • ForEach — цикл по диапазону. Аналог цикла, работающего медленно в Java.
  • WithSize — простой цикл со счётчиком.
  • WithIterators — сложный цикл, в котором вместо счётчика используются итераторы. Это то, как работает цикл по диапазону, но без синтаксического сахара.
  • RawMemory — цикл, в котором используется прямой доступ к памяти. Ничего лишнего, он должен быть самым быстрым.

C++ смог свести весь оверхед к нулю и уравнять производительность самой сложной и самой простой версий. Удивительно, что сложные операции создания и перемещения итераторов ничего не стоят.
Секрет в мощном оптимизаторе. C++ выполняет огромное количество вычислений на этапе компиляции. При этом он разворачивает функции, убирает лишние переменные и вычисления.
В результате сложные и элегантные языковые конструкции при компиляции превращаются в простой и эффективный машинный код. В этом сила C++.


Шаблонное программирование


Сильные стороны C++ — вычисления времени компиляции и шаблонное программирование. Чем больше вычислит компилятор, тем меньше придётся вычислять у пользователя, тем быстрее будет работать программа.



Шаблонное программирование позволяет использовать общий код — шаблон — который может настраиваться на разные параметры. Это не только удобно, но и крайне эффективно: будет генерироваться очень быстрый машинный код, учитывающий специфику конкретного случая.



Один и тот же шаблон может сгенерировать несколько вариантов кода, оптимально работающих в разных ситуациях


Настоящие чудеса начинаются, когда мы используем функциональный объект как параметр шаблона. Рассмотрим пример. Два варианта одного и того же цикла на Python:


import time

# запускаем обычный цикл
million = range(1000000)
target = []
start_time = time.time()
for x in million:
    if x % 2 == 0:
        target.append(x // 2)
elapsed_time = time.time() - start_time
print(f"Loop time: {elapsed_time:.6f} seconds")

# используем list comprehension с функциями
million = range(1000000)
start_time = time.time()
even = lambda x: x % 2 == 0
half = lambda x: x // 2
target = [half(x) for x in million if even(x)]
elapsed_time = time.time() - start_time
print(f"Comprehension time: {elapsed_time:.6f} seconds")

В первом варианте мы используем обычный цикл. Во втором list comprehension в сочетании с лямбда-функциями. Python-программист, скорее всего, сразу скажет: разумеется, второй вариант будет работать дольше, ведь вызов функции — дорогая операция! И окажется прав. Запустим этот код и увидим подобный результат:


Loop time: 0.119847 seconds
Comprehension time: 0.179655 seconds


C++ не исключение. В нём тоже вызов функции требует накладных расходов. Но попробуем запустить бенчмарк, аналогичный примеру из Python’а. Удивительно, но версия, миллион раз вызывающая функции, не уступает варианту без них. Дело в том, что C++ полностью исключил вызовы функций при оптимизации. И это стало возможно именно благодаря шаблонной магии. Компилятор выполнял настройку автоматически выведенных типов в таком выражении:


   view::filter(even) |
   view::transform(half)

Каждая из этих функций возвращает сложный обобщённый объект из библиотеки ranges. Затем эти объекты комбинируются операцией “|”. При этом компилятор знает, какие три действия выполняются, и даже то, что именно делают вызываемые лямбда-функции. Благодаря этому стала возможной комплексная оптимизация. Таким образом, из трёх отдельных операций и двух лямбда-функций компилятор чудесным образом построил код, вообще не имеющий вызовов функций.


Заключение


Мы не преследовали цель показать, что C++ лучше Python или C++ лучше Java. Каждый из языков имеет свою нишу и своё назначение. C++ пока что не превзойдён во многих отраслях, особенно требующих высокопроизводительных вычислений. У него появляются конкуренты: такие языки, как Go, Rust, тоже способны показывать высокую производительность. Но C++ растёт высокими темпами и осваивает новые рубежи. Например, компания Яндекс активно развивает фреймворк userver и переводит на C++ свои сервисы. Всё это позволяет сделать вывод: C++ хоть и старый язык, но вовсе не устаревший.

Источник: https://habr.com/ru/companies/yandex_praktikum/articles/758744/


Интересные статьи

Интересные статьи

Привет! Я Настя, и вот уже более 10 лет я работаю с текстами. Сначала трудилась на литературной плантации в провинциальном агентстве, потом писала для свадебного журнала, после – создавала тексты акци...
Собрал список частых причин, как делать не надо. Некоторые из них основаны и на личном опыте, о других вы могли слышать, или даже использовали сами.Все пункты перечислены не в порядке значимости или ч...
В этой статье рассказывается о том почему я бросил разработку своего прошлого языка программирования, как новый язык решает проблемы старого и показываются тесты программ...
«В 1665 году Кембриджский университет закрылся из-за эпидемии чумы. Исааку Ньютону пришлось работать из дома. Он открыл дифференциальное и интегральное исчисление, а также закон всемирного тяготе...
Дошли руки до книги Чеда Фаулера «Программист-фанатик». Я решил написать конспект книги, отжав из нее всю воду, а воды было предостаточно. Конспект позволит тем, кто не читал книгу ранее, позн...