Думаю мы все можем согласиться с идеей о том, что С++ разработчику достаточть легко в последствии освоить C#, Java или еще какой-то более современный язык. Но вот обратное — не совсем так. Многие разработчики начинают плеваться как только слышат про «ручное управление памятью» и подобные вещи. Суть этого поста — попытаться донести для разработчиков во-первых зачем им нужен С++, ну и показать как можно на нем делать те же вещи, к которым они возможно привыкли в других языка. А также объективно рассказать про те проблемы, которые в С++ до сих пор не решены.
Зачем нужен С++
Сегодня существуют три основных индустрии, которым нужен С++. Это
-
Игровая индустрия
-
Embedded development
-
Quant finance
В играх нужна производительность, поэтому С++ помогает ее достичь будучи ближе к железу чем языки основанные на виртуальных машинах. В С++ можно работать с памятью напрямую и даже вставлять в код куски Assembler’а.
В разработке для встроенных устройств тоже важна производительность, а также там ограничения по памяти которые порой не дают развернуть управляемую среду. Поэтому С и С++ все еще там популярны, хотя были и есть попытки запускать и C# (.NET) и Java на таких устройствах.
У нас в quant finance, С++ остался по историческим причинам. Финансовая индустрия весьма консервативна, а современные веяния высокочастотной торговли требуют максимум производительности. На уровне рассчетов моделей есть популярная библиотека QuantLib, но большинство алгоритмов пишут in-house, хотя тоже часто на С++.
Помимо производительности, есть еще несколько причин писать на С++. Одна из них это портативность (внезапно, да?) — на С++ вполне можно писать полностью портативный код. Я вот например использую Intel C++ Compiler (по фичам он «не очень»), который существует как на Windows так и на Linux.
Еще одна причина использовать С++ заключается в том, что это единственный способ амортизировать мощные аппаратные платформы, такие как CUDA и Intel Xeon Phi. Но это в основном для тех кто любит гоняться за пиковой производительностью.
Целочисленные типы
Когда я пишу в C# что-то вроде int x;
я четко знаю две вещи:
-
Переменная
x
– это 32-битное целое число со знаком -
Изначально,
x
содержит значение0
(ноль)
В С++, мы не можем быть уверены ни в одном из этих утверждений. Тип int
не определен с той строгостью какой бы хотелось (подразумевалось, что на других платформах — например embedded — у него может быть другой размер), а его дефолтное значение в большинстве случаев не определено, и там может быть что угодно.
Для решения первой проблемы, можно использовать С++ заголовок <cstdint>
, который определяет такие типы как uint8_t
, int32_t
и так далее. Это существенно улучшает портативность кода.
Вторая проблема толком не решена, поэтому вам придется писать int32_t x{0};
. К счастью, в С++ с последнее время наконец-то можно инициализировать поля класса прямо в теле, вот так:
class Foo { int32_t bar{0}; uint32_t baz = 42; };
Статические поля так инициализировать, к сожалению, все ещё нельзя.
Строки
Со строками в С++ беда. Во-первых, изначально в С++, как и в С, строка была просто указателем на массив байтов (да-да, байтов а не code point’ов) с нулевым байтом \0
на конце. Это значит что вычисление длины строки — это O(n) операция.
Далее в C++ появился тип string
, но с ним есть масса проблем. Например, у этого типа есть функции size()
и length()
, которые делают одно и тоже, но из API это совсем не очевидно. Но еще обиднее что там нет таких очевидных вещей как to_lower/upper()
или split()
, в результате чего приходится пользоватся сторонними библиотеками вроде Boost.
С++ весьма амбивалентентен в плане поддержки Unicode. Под Windows, кодировка строки типа string
— ANSI, а вовсе не UTF-8. Есть также тип wstring
, который использует UTF16 (ту же кодировку, что C# и Java).
Массивы
Изначально, массивы достались С++ из С, и это означает что массив — это просто указатель на первый элемент, и всё. То есть массив сам не знает какой он длины, и чтобы написать функцию которая обрабатывает массив, нужно 2 аргумента: указатель и длина.
Сейчас у нас есть такие типы как array
и vector
. Эти типы позволяют хранить либо массивы фиксированной длины (например array<int,3>
) или, в случае с vector
, иметь динамический массив, который расширяется и сужается по мере добавления в него элементов.
Многомерные массивы в С++ (как и в С) тоже выглядят плохо, т.к. являются всего лишь массивами указателей. Для них тоже стоит использовать array/vector
, а еще лучше, если требуются вычисления, использовать специализированную библиотеку вроде Eigen.
Выделение памяти
Адресное пространство поделено на условно 2 области — стэк и куча. Стэк предназначен для временных данных, и имеет ограничения по размеру, в то время как куча — это ваша оперативная память, занимайте хоть всю.
В связи с этим, в отличии от языков со «сборкой мусора», в С++ есть два способа выделения (аллокации) памяти: на стэке и в куче.
На стэке можно выделить память так, как показано ниже. Освобождать ее собственноручно не надо.
int i; Person person;
Другое дело — выделение в куче. Она для более долгосрочного хранения данных, и ответственность за очистку этой памяти лежит на вас. Если забудете, будет «утечка памяти», что очень плохо.
int* i = new int; Person* p = new Person; // а потом не забываем удалить delete i; delete p;
Как видите, для выделения в куче, мы используем оператор new
, и он нам возвращает указатель на выделенную память. Чтобы освободить эту память, нужно использовать оператор delete
.
Значения, указатели и ссылки
Давайте начнем с опеределений. Вот есть у вас переменная int32_t x{42};
— под нее выделено 4 байта, и каждый раз когда вы «берете» x
, вы получаете значение 42 (ну, пока вы его не поменяли). Запись x = 11;
запишет в эту память значение 11.
У нас также есть такая штука как указатель — это переменная, которая содержит адрес другой переменной. То есть можно написать вот так:
int32_t x{42}; int32_t* y = &x;
Тип этой переменной — со звездочкой, и звездочка означает что это указатель. Ее значение — это адрес переменной x
, а чтобы взять адрес чего-либо в С++ нужно посдавить амперсанд, вот так: &x
.
Хитрость тут в том, что через адрес переменной можно манипулировать самой переменной. То есть вместо x = 42
можно написать *y = 42
. Заметьте что тут звездочка стоит перед именем переменной, и означает «следовать за». То есть мы идем туда, куда указывает y
и в том месте памяти меняем значение.
Наконец, третий способ доступа к переменной — это ссылка. Ссылка действует так же как и указатель, только синтаксис другой. Вот смотрите:
int32_t x{42}; int32_t& z = x; z = 11;
В отличии от указателей, тип ссылки заканчивается на &
, и ссылке не требуется адрес для присваивания — можно просто подсунуть ей саму переменную. Для доступа по ссылке, в отличии от указателей, тоже не нужно никакого префикса: можно просто использовать ссылку как обычную переменную.
Также, в отличии от C#/Java, в С++ ссылка не может быть пустой (null). Это значит что все ссылки должны быть инициализированы, т.е. ссылаться на что-то. Это с одной стороны хорошо (в управляемых языках многие мечтают о non-nullable objects), но с другой стороны создает много проблем — например, если в классе есть поле-ссылка, вы обязаны инициализировать ее в конструкторе.
Умные указатели
К сожалению, ни один из типов приведенных ранее не ведет себя как ссылка в C# или Java, т.к. любой объект, выделенный в куче, нужно удалять вручную. Но как это сделать если ты не знаешь, кому, когда и на сколько времени понадобится твой объект? Для этого есть умные указатели.
Умный указатель содержит в себе указатель на объект в куче и счетчик использований. Как только счетчик доходит до нуля, объект уничтожается. Выглядит это вот как-то так:
shared_ptr<Person> person = make_shared<Person>("Dmitri", 123);
Такой объект можно передавать куда угодно и не бояться его использовать, и он — пожалуй лучший аналог ссылок в C#/Java, хотя и с ограничениями. Одно из ограничений — невозможность рекурсии, то есть нельзя писать вот так:
struct Parent { shared_ptr<Child> child; } struct Child { shared_ptr<Parent> parent; // так нельзя }
Поскольку нет никакого централизованного учета ссылок, подобная конструкция не приведет ни к чему хорошему — в Child
вместо shared_ptr
придется использовать weak_ptr
ну или обычную ссылку.
shared_ptr<>
также решает проблему хранения ссылок в таких коллекциях как vector
. Проблема в том, что нельзя сделать vector<Foo&>
, но вполне можно сделать vector<shared_ptr<Foo>>
.
Помимо shared_ptr<>
есть несколько других типов умных указателей.
Алгоритмы
С++ не обладает никаким «интерфейсом» который символизирует перечисляемые объекты. Вместо этого он использует итераторы, которые умеют ходить по той или иной структуре. Любой перечисляемый тип может определить функции begin()
и end()
который возвращают итераторы на первый и за-последним элементы.
С++ также определяет цикл for
для обхода коллекций, то есть можно написать:
vector v{1,2,3}; for (auto x : v) cout << c << endl;
Код выше также использует ключевое слово auto
(аналог var
в C#) для вывода типов.
В целом, алгоритмы в С++ это глобальные функции с такими красочными названиями как count_if
и всякими прелестями вроде того факта, что remove
на контейнере вовсе не удаляет объекты из контейнера, а только сваливает их в конец, а собственно удаляет их функция erase()
(очень неинтуитивно).
У контейнеров нет функций-членов с алгоритмами вроде sort()
или search()
. И добавить их в контейнер нельзя (за исключением наследования) т.к. в С++ нет функций расширения. Алгоритмы специально спроектированы так, чтобы работать на всех возможных контейнерах путем итерирования.
Заключение
Сейчас С++ становится лучше. Улучшается синтаксис языка, добавляются новые библиотеки, но стоит признать, что некоторые проблемы так и не решены, и есть множество недоработок в стандартной библиотеке, некоторые из которых кроме как переписыванием не починить.
В плане компиляторов тоже все лучше и лучше. Есть компилятор Clang (дает вменяемые ошибки при компиляции вместо километров ада), интерпретатор Cling (REPL-среда на базе Clang, рекомендую!), C++ компилятор от Microsoft поддерживает инкрементальную и Edit & Continue компиляцию, а еще скоро процесс компиляции существенно ускорится путем введения модулей.
Что касается инструментов, то ReSharper C++ (если вы пишете под Visual Studio) и CLion (кросс-платформенная IDE) позволяют комфортно писать на этом языке. А такие технологии как CUDA Toolkit и Intel Parallel Studio позволяют амортизировать популярные аппаратные платформы CUDA и Intel Xeon Phi. ■
Оставить комментарий