top of page
Поиск

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

  • Фото автора: Илья Лавренов
    Илья Лавренов
  • 21 янв. 2015 г.
  • 10 мин. чтения

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) за комментариями по её реализации.

 
 
 

Недавние посты

Смотреть все
Спецификатор constexpr в C++11 и в C++14

Одна из новых возможностей C++11 — спецификатор constexpr. С помощью него можно создавать переменные, функции и даже объекты, которые...

 
 
 

Comments


Мы в соцсетях

© 2011-2017 «Программирование. Помощь студентам».

bottom of page