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<int> 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<Shape*>& vs, int r)
{
for_each(vs.begin(),vs.end(), [](Shape* p) { p->rotate(r); }); // повернуть все элементы 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<typename Iter>
void rotate_and_draw(Iter first, Iter last, int r)
{
for_each(first,last,[](auto p) { p->rotate(r); }); // повернуть все элементы [first:last)
for (auto p = first; p!=last; ++p) p->draw(); // нарисовать все элементы [first:last)
}
Это работает с любой последовательностью. Это стиль алгоритмов стандартных библиотек. Я использовал auto, чтобы не называть типы интерфейса объектов. Это возможность С++11, означающая «использовать тип выражения, который был использован при инициализации», поэтому для p тип будет тот же, что и у first.
Ещё пример:
void user(list<unique_ptr<Shape>>& lus, Container<Blob>& 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<typename T> struct Iter {
T* current;
Container<T>& c;
};
template<typename T> Iter<T> begin(Container<T>& c) { return Iter<T>{c.first(),c}; }
template<typename T> Iter<T> end(Container<T>& c) { return Iter<T>{nullptr,c}; }
template<typename T> Iter<T> operator++(Iter<T> p) { p.current = p.c.next(); return p; }
template<typename T> T* operator*(Iter<T> 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 и деструктор (потому что у сборщиков мусора редко есть деструкторы и лучше их избегать, потому что они могут привести к алгоритмическим проблемам и негативно влиять на быстродействие). Сборщик мусора может очистить всю память, но нам нужно закрыть файлы и вернуть все ресурсы, не связанные с памятью (блокировки), а связанные с потоками. Получается, что память автоматически возвращается, но управление другими ресурсами осуществляется вручную, поэтому подвержено утечкам и ошибкам.
Общепринятый и рекомендуемый подход в С++ — полагаться на деструкторы, чтобы удостовериться, что ресурсы возвращены. Обычно ресурсы забирают в конструкторах, что даёт этой технике имя «Получение ресурсов – это инициализация» (“Resource Acquisition Is Initialization”, RAII)). В user() деструктор flt неявно вызывает деструкторы потоков is и os. Они, в свою очередь, закрывают файлы и выпускают ресурсы, связанные с потоками. delete сделал бы то же самое для *p.
Опытные пользователи современного С++ заметят, что user() неуклюж и подвержен ошибкам. Так было бы лучше:
void user2()
{
Filter flt {“books”,”authors”};
unique_ptr<Filter> 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<Filter>(“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<X> make_X();
Но зачем вообще использовать указатель? Часто он не нужен, часто он отвлекает от обычного использования объекта. К примеру, функция сложения Matrix создаёт новый объект, сумму, из двух аргументов, но возврат указателя привёл бы к странному коду:
unique_ptr<Matrix> 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<nr*nc; ++i) elem[i]=0; // инициализация
}
Matrix(const Matrix&); // Конструктор копирования
Matrix operator=(const Matrix&); // Копирование присваиванием
Matrix(Matrix&&); // конструктор перемещения
Matrix operator=(Matrix&&); // конструктор присваивания
~Matrix() { delete[] elem; } // деструктор: освобождает элементы
// …
};
Операция копирования распознаётся по &. Операция перемещения – по &&. Операция перемещения должна «украсть» представление и оставить позади «пустой объект». Для Matrix это означает нечто вроде:
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<double> 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) за комментариями по её реализации.
Часть 2