[Перевод] Пять популярных мифов про C++, часть 1

1. Введение В этой статье я попытаюсь исследовать и развенчать пять популярных мифов про C++:1. Чтобы понять С++, сначала нужно выучить С2. С++ — это объектно-ориентированный язык программирования3. В надёжных программах необходима сборка мусора4. Для достижения эффективности необходимо писать низкоуровневый код5. С++ подходит только для больших и сложных программ

Если вы или ваши коллеги верите в эти мифы — эта статья для вас. Некоторые мифы правдивы для кого-то, для какой-то задачи в какой-то момент времени. Тем не менее, сегодняшний C++, использующий компиляторы ISO C++ 2011, делает эти утверждения мифами.

Мне они кажутся популярными, потому что я их часто слышу. Иногда их аргументировано доказывают, но чаще используют как аксиомы. Часто их используют, чтобы отмести С++ как один из возможных вариантов решения какой-либо задачи.

Каждому мифу можно посвятить книгу, но я ограничусь простой констатацией и кратким изложением своих аргументов против них.

2. Миф 1: Чтобы понять С++, сначала нужно выучить С Нет. В С++ проще изучать основы программирования, чем в С. С — это почти подмножество С++, но не лучшее из них, потому что у С нет типобезопасности и удобных библиотек, которые есть у С++ и которые облегчают выполнение простых задач. Рассмотрим простой пример создания емейл-адреса: string compose (const string& name, const string& domain) { return name+'@'+domain; } Используется он так:

string addr = compose («gre», «research.att.com»); Естественно, в реальной программе не все аргументы будут строками.

В С-версии необходимо напрямую работать с символами и памятью:

char* compose (const char* name, const char* domain) { char* res = malloc (strlen (name)+strlen (domain)+2); // место для строк, '@', и 0 char* p = strcpy (res, name); p += strlen (name); *p = '@'; strcpy (p+1, domain); return res; } Используется он так:

char* addr = compose («gre», «research.att.com»); // … free (addr); // по окончанию освободить память Какой вариант легче преподавать? Какой легче использовать? Не напутал ли я чего в С-версии? Точно? Почему?

И, наконец, какая из версий compose () более эффективная? С++ — потому что ей не надо подсчитывать символы в аргументах и она не использует динамическую память для коротких строк.

2.1 Изучение С++ Это не какой-нибудь странный экзотический пример. По-моему, он типичен. Так почему множество преподавателей проповедуют подход «Сначала С»? Потому, что: — они так всегда делали— того требует учебная программа— что они сами так учились— раз С меньше С++, значит, он должен быть проще— студентам всё равно, рано или поздно, придётся выучить С

Но С — не самое простое или полезное подмножество С++. Зная достаточно С++, вам будет легко выучить С. Изучая С перед С++ вы столкнётесь со множеством ошибок, которых легко избежать в С++, и вы будете тратить время на изучение того, как их избежать. Для правильного подхода к изучению С++ посмотрите мою книгу Programming: Principles and Practice Using C++. В конце есть даже глава про то, как использовать С. Она с успехом применялась в обучении множества студентов. Для упрощения изучения её второе издание использует С++11 и С++14.

Благодаря С++11, С++ стал более дружественным для новичков. К примеру, вот вектор из стандартной библиотеки, инициализированный последовательностью элементов:

vector v = {1,2,3,5,8,13}; В C++98 мы могли инициализировать списками только массивы. В С++11 мы можем задать конструктор, принимающий список {} для любого типа. Мы можем пройти по вектору циклом:

For (int x: v) test (x); test () будет вызвана для каждого элемента v.

Цикл for может проходить по любой последовательности, поэтому мы могли бы просто написать:

for (int x: {1,2,3,5,8,13}) test (x); В С++11 старались сделать простые вещи простыми. Естественно, без ущерба быстродействию.

3. Миф 2: С++ — это объектно-ориентированный язык программирования Нет. С++ поддерживает ООП и другие стили, но он не ограничен специально. Он поддерживает синтез программных стилей, включая ООП и обобщённое программирование. Чаще, лучшим решением задачи будет использование нескольких стилей. Лучшим — значит, более коротким, самым понятным, эффективным, обслуживаемым, и т.п.Этот миф приводит людей к выводу, что С++ им не нужен (по сравнению с С), если только им не нужны большие иерархии классов со всякими виртуальными функциями. Уверившись в мифе, С++ укоряют за то, что он не чисто объектно-ориентирован. Если вы приравниваете «хороший» к «ООП», тогда С++, содержащий много всего, не относящегося к ООП, автоматически становится «нехорошим». В любом случае, этот миф является отговоркой, чтобы не учить С++.

Пример:

void rotate_and_draw (vector& vs, int r) { for_each (vs.begin (), vs.end (), [](Shape* p) { p→rotate®; }); // повернуть все элементы vs for (Shape* p: vs) p→draw (); // нарисовать все элементы vs } Это ООП? Конечно — тут есть иерархия классов и виртуальные функции. Это обобщённое программирование? Конечно, тут есть параметризованный контейнер (вектор) и обычная функция.

for_each. Это функциональное программирование? Что-то вроде того. Используется лямбда (конструкция []). И что же это за стиль? Это современный стиль С++11.

Я использовал и стандартный цикл for, и библиотечный алгоритм for_each, просто для демонстрации возможностей. В настоящем коде я бы использовал только один цикл, любой из них.

3.1 Обобщённое программирование. Хотите более обобщённого кода? В конце концов, он работает только с векторами указателей на Shapes. Как насчёт списков и встроенных массивов? Что насчёт «умных указателей», типа shared_ptr и unique_ptr? А объекты, которые называются не Shape, но которые можно draw () и rotate ()? Внемлите: template void rotate_and_draw (Iter first, Iter last, int r) { for_each (first, last,[](auto p) { p→rotate®; }); // повернуть все элементы [first: last) for (auto p = first; p!=last; ++p) p→draw (); // нарисовать все элементы [first: last) } Это работает с любой последовательностью. Это стиль алгоритмов стандартных библиотек. Я использовал auto, чтобы не называть типа интерфейса объектов. Это возможность С++11, означающая «использовать тип выражения, который был использован при инициализации», поэтому для p тип будет тот же, что и у first.

Ещё пример:

void user (list>& lus, Container& vb) { rotate_and_draw (lus.begin (), lus.end ()); rotate_and_draw (begin (vb), end (vb)); } Здесь Blob — некий графический тип, имеющий операции draw () и rotate (), а Container — тип некоего контейнера. У списка из стандартной библиотеки (std: list) есть методы begin () и end (), которые помогают проходить по последовательности. Это красивое классическое ООП. Но что, если Container не поддерживает стандартную запись итераций по полуоткрытым последовательностям, [b: e)? Если отсутствуют методы begin () и end ()? Ну, я никогда не встречал чего-либо вроде контейнера, по которому нельзя проходить, поэтому мы можем определить отдельные begin () и end (). Стандартная библиотека предоставляет такую возможность для массивов С-стиля, поэтому если Container — массив из С, проблема решена.

3.2 Адаптация Случай посложнее: что, если Container содержит указатели на объекты, и у него другая модель для доступа и прохода? К примеру, к нему надо обращаться так: for (auto p = c.first (); p!=nullptr; p=c.next ()) { /* сделать что-либо с *p */} Такой стиль не редок. Его можно привести к виду последовательности [b: e) вот так:

template struct Iter { T* current; Container& c; }; template Iter begin (Container& c) { return Iter{c.first (), c}; } template Iter end (Container& c) { return Iter{nullptr, c}; } template Iter operator++(Iter p) { p.current = p.c.next (); return *this; } template T* operator*(Iter p) { return p.current; } Такая модификация неагрессивна: мне не пришлось изменять Container или иерархию его классов, чтобы привести его к модели прохода, поддерживаемой стандартной библиотекой С++. Это адаптация, а не рефакторинг. Я выбрал этот пример для демонстрации того, что такие техники обобщённое программирование не ограничено стандартной библиотекой. Кроме того, они не попадают под определение «ОО».

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

4. Миф 3: В надёжных программах необходима сборка мусора Сборка мусора хорошо, но не идеально, справляется с возвратом неиспользуемой памяти. Это не панацея. Память может сохраняться не напрямую, а множество ресурсов не являются только лишь памятью. Пример: class Filter { // принять ввод из файла iname и вывести результат в файл oname public: Filter (const string& iname, const string& oname); // конструктор ~Filter (); // деструктор // … private: ifstream is; ofstream os; // … }; Конструктор Filter открывает два файла. После этого выполняется некая задача, принимается ввод из файла и выводится результат в другой файл. Можно захардкодить задачу в Filter и использовать его как лямбду, или его можно использовать как функцию, которую предоставляет наследуемый класс, перегружающий виртуальную функцию. Для управления ресурсами это неважно. Можно определить Filter так:

void user () { Filter flt {«books», «authors»}; Filter* p = new Filter{«novels», «favorites»}; // использовать flt и *p delete p; } С точки зрения управления ресурсами, проблема в том, как гарантировать, что файлы закрыты и ресурсы, связанные с двумя потоками, правильно возвращены для дальнейшего использования.

Обычным решением в системах, полагающихся на сборщики мусора, будет убрать delete и деструктор (потому что у сборщиков мусора редко есть деструкторы и лучше их избегать, потому что они могут привести к алгоритмическим проблемам и негативно влиять на быстродействие). Сборщик мусора может очистить всю память, но нам нужно закрыть файлы и вернуть все ресурсы, не связанные с памятью (залочки), а связанные с потоками. Получается, что память автоматически возвращается, но управление другими ресурсами осуществляется вручную, поэтому подвержено утечкам и ошибкам.

Общепринятый и рекомендуемый подход в С++ — полагаться на деструкторы, чтобы удостовериться, что ресурсы возвращены. Обычно ресурсы забирают в конструкторах, что даёт этой технике имя «Получение ресурсов — это инициализация». В user () деструктор flt неявно вызывает деструкторы потоков is и os. Они, в свою очередь, закрывают файлы и выпускают ресурсы, связанные с потоками. delete сделал бы то же самое для *p.

Опытные пользователи современного С++ заметят, что user () неуклюж и подвержен ошибкам. Так было бы лучше:

void user2() { Filter flt {«books», «authors»}; unique_ptr p {new Filter{«novels», «favorites»}}; // используем flt и *p } Теперь по выходу из user () *p автоматически освобождается. Программист не забудет этого сделать. unique_ptr — класс стандартной библиотеки, который удостоверяется, что ресурсы освобождены, без потери в производительности и памяти, по сравнению со встроенными указателями.

Хотя и это решение чересчур многословно (Filter повторяется), и разделение конструктора обычного указателя (new) и умного (unique_ptr) требует оптимизации. Можно улучшить это через вспомогательную функцию С++14 make_unique, которая создаёт объект заданного типа и возвращает указывающий на него unique_ptr:

void user3() { Filter flt {«books», «authors»}; auto p = make_unique(«novels», «favorites»); // используем flt и *p } Или ещё лучший вариант, поскольку нам не особенно нужен второй Filter:

void user4() { Filter flt {«books», «authors»}; Filter flt2 {«novels», «favorites»}; // используем flt и flt2 } Короче, проще, понятнее, и быстрее.

Но что делает деструктор Filter? Освобождает ресурсы Filter — закрывает файлы (вызывая их деструкторы). Это делается неявно, поэтому если от Filter более ничего не нужно, можно избавиться от упоминания его деструктора и дать компилятору сделать всё самому. Поэтому, всего-навсего нужно написать:

class Filter { // принять ввод из файла iname и вывести результат в файл oname public: Filter (const string& iname, const string& oname); // … private: ifstream is; ofstream os; // … };

void user3() { Filter flt {«books», «authors»}; Filter flt2 {«novels», «favorites»}; // используем flt и flt2 } Эта запись проще большинства записей из языков с автоматической сборкой мусора (Java, C#), и в ней нет утечек из-за забывчивости. Она также быстрее очевидных альтернатив.

Это — мой идеал управления ресурсами. Он управляет не только памятью, но и другими ресурсами — файлы, потоки, залочки. Но на самом ли деле он всеобъемлющий? Что насчёт объектов, у которых нет одного очевидного владельца?

4.1 Передача владельца: move Рассмотрим проблему передачи объектов между областями видимости. Вопрос в том, как вывести кучу информации из области видимости, без ненужного копирования или подверженного ошибкам использования указателей. Традиционно используется указатель: X* make_X () { X* p = new X: // … заполнить X… return p; } void user () { X* q = make_X (); // … использовать *q… delete q; } И кто ответственный за удаление объекта? В нашем простом случае — тот, кто вызывает make_X (), но в общем ответ не так очевиден. Что, если make_X () кеширует объекты для минимизации использования памяти? Если user () передал указатель на other_user ()? Много где можно запутаться и при таком стиле программирования утечки нередки. Можно было бы воспользоваться shared_ptr или unique_ptr для непосредственного определения владельца объекта:

unique_ptr make_X (); Но зачем вообще использовать указатель? Часто он не нужен, часто он отвлекает от обычного использования объекта. К примеру, функция сложения Matrix создаёт новый объект, сумму, из двух аргументов, но возврат указателя привёл бы к странному коду:

unique_ptr operator+(const Matrix& a, const Matrix& b); Matrix res = *(a+b); * нужна для получения суммы, а не указателя. Что мне реально нужно — объект, а не указатель на него. Мелкие объекты быстро копируются и я не стал бы использовать указатель:

double sqrt (double); // функция квадратного корня double s2 = sqrt (2); // получить квадратный корень из двух С другой стороны, объекты, содержащие кучу данных, обычно являются обработчиками этих данных. istream, string, vector, list и thread — это несколько слов данных для доступа к большим данным. Вернёмся к сложению Matrix. Что нам нужно:

Matrix operator+(const Matrix& a, const Matrix& b); // вернуть сумму a и b Matrix r = x+y; Легко:

Matrix operator+(const Matrix& a, const Matrix& b) { Matrix res; // … заполняет res суммами … return res; } По умолчанию, происходит копирования элементов res в r, но так как res будет удалён и его память освобождается, их копировать не нужно: можно «украсть» элементы. Это можно было сделать с первых дней С++, но это было сложно реализовать и технику понимал не каждый. С++11 поддерживает «воровство представления» напрямую, в виде операций move, передающих владение объектом. Рассмотрим простую двумерную матрицу из элементов типа double:

class Matrix { double* elem; // указатель на элементы int nrow; // количество строк int ncol; // количество столбцов public: Matrix (int nr, int nc) // конструктор: разместить элементы : elem{new double[nr*nc]}, nrow{nr}, ncol{nc} { for (int i=0; i

Matrix: Matrix (Matrix&& a) // переместить конструктор : nrow{a.nrow}, ncol{a.ncol}, elem{a.elem} // «украсть» представление { a.elem = nullptr; // ничего не оставить позади } Вот и всё. Когда компилятор видит return res; он понимает, что res скоро будет уничтожен. Он не будет использоваться после return. Тогда он применяет конструктор перемещения вместо копирования для передачи возвращаемого значения. Для

Matrix r = a+b; res внутри operator+() становится пустым. Деструктор остаётся сделать всего ничего, а элементами res теперь владеет r. Мы получили элементы результата (это могли быть мегабайты памяти) из функции (operator+()) в переменную. И сделали это с минимальными затратами.

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

Кроме того, семантика перемещений работает и для присваивания, поэтому в случае

r = a+b; мы получаем оптимизацию перемещением для оператора присваивания. Оптимизировать присваивание компилятору сложнее.

Частенько нам даже не придётся определять все эти операции копирования и перемещения. Если класс состоит из членов, которые ведут себя, как положено, мы можем просто положиться на операции по умолчанию. Пример:

class Matrix { vector elem; // элементы int nrow; // количество строк int ncol; // количество столбцов public: Matrix (int nr, int nc) // constructor: allocate elements : elem (nr*nc), nrow{nr}, ncol{nc} { } // … }; Этот вариант ведёт себя так же, как предыдущий, кроме того, что он лучше обрабатывает ошибки и занимает чуть больше места (вектор — это обычно три слова).

Как насчёт объектов, которые не являются обработчиками? Если они небольшие, типа int или complex, не беспокойтесь. В ином случае, сделайте их обработчиками или возвращайте их через умные указатели unique_ptr и shared_ptr. Не пользуйтесь «голыми» операциями new и delete. К сожалению, Matrix из примера не входит в стандартную библиотеку ISO C++, но для него есть несколько библиотек. Например, поищите «Origin Matrix Sutton» и обратитесь к 29 главе книги The C++ Programming Language (Fourth Edition) за комментариями по её реализации.

© Habrahabr.ru