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

в 19:40, , рубрики: c++, c++11, C++14, мифы

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 *this; } 
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 и деструктор (потому что у сборщиков мусора редко есть деструкторы и лучше их избегать, потому что они могут привести к алгоритмическим проблемам и негативно влиять на быстродействие). Сборщик мусора может очистить всю память, но нам нужно закрыть файлы и вернуть все ресурсы, не связанные с памятью (залочки), а связанные с потоками. Получается, что память автоматически возвращается, но управление другими ресурсами осуществляется вручную, поэтому подвержено утечкам и ошибкам.

Общепринятый и рекомендуемый подход в С++ — полагаться на деструкторы, чтобы удостовериться, что ресурсы возвращены. Обычно ресурсы забирают в конструкторах, что даёт этой технике имя «Получение ресурсов – это инициализация». В 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) за комментариями по её реализации.

Автор: SLY_G

Источник

* - обязательные к заполнению поля