[Перевод] Пять популярных мифов про 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
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
for_each. Это функциональное программирование? Что-то вроде того. Используется лямбда (конструкция []). И что же это за стиль? Это современный стиль С++11.
Я использовал и стандартный цикл for, и библиотечный алгоритм for_each, просто для демонстрации возможностей. В настоящем коде я бы использовал только один цикл, любой из них.
3.1 Обобщённое программирование.
Хотите более обобщённого кода? В конце концов, он работает только с векторами указателей на Shapes. Как насчёт списков и встроенных массивов? Что насчёт «умных указателей», типа shared_ptr и unique_ptr? А объекты, которые называются не Shape, но которые можно draw () и rotate ()? Внемлите:
template
Ещё пример:
void user (list
3.2 Адаптация Случай посложнее: что, если Container содержит указатели на объекты, и у него другая модель для доступа и прохода? К примеру, к нему надо обращаться так: for (auto p = c.first (); p!=nullptr; p=c.next ()) { /* сделать что-либо с *p */} Такой стиль не редок. Его можно привести к виду последовательности [b: e) вот так:
template
Представление, что код С++ обязан быть ОО (везде использовать иерархии и виртуальные функции), пагубно сказывается на быстродействии программ. Если вам нужно анализировать набор типов во время выполнения, это хороший подход, и я его часто использую. Однако, он довольно негибкий (не все типы умещаются в иерархию), и вызов виртуальной функции препятствует инлайнингу, что может раз в 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
Хотя и это решение чересчур многословно (Filter повторяется), и разделение конструктора обычного указателя (new) и умного (unique_ptr) требует оптимизации. Можно улучшить это через вспомогательную функцию С++14 make_unique, которая создаёт объект заданного типа и возвращает указывающий на него unique_ptr:
void user3()
{
Filter flt {«books», «authors»};
auto p = make_unique
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
unique_ptr
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 Как насчёт объектов, которые не являются обработчиками? Если они небольшие, типа int или complex, не беспокойтесь. В ином случае, сделайте их обработчиками или возвращайте их через умные указатели unique_ptr и shared_ptr. Не пользуйтесь «голыми» операциями new и delete. К сожалению, Matrix из примера не входит в стандартную библиотеку ISO C++, но для него есть несколько библиотек. Например, поищите «Origin Matrix Sutton» и обратитесь к 29 главе книги The C++ Programming Language (Fourth Edition) за комментариями по её реализации.