Аннотация к «Effective Modern C++» Скотта Майерса. Часть 2
Продолжение предыдущего поста.В этой части мы будем рассматривать не столько технические изменения в С++, сколько новые подходы к разработке и возможности которые дают новые средства языка. Предыдущий пост был с моей точки зрения просто затянувшимся вступлением, тогда как здесь можно вволю подискутировать.Лямбда-выражения — вишенка на тортеКак ни удивительно это звучит, но лямбда-выражения не принесли в язык новой функциональности (в оригинале — expressive power). Тем не менее, их все более широкое применение стремительно меняет стиль языка, легкость создания обьектов-функций на лету вдохновляет и осталось лишь дождаться повсеместного распространения C++14 (который как-бы уже есть, но как-бы еще и не совсем), где лямбды достигли полного расцвета. Начиная с С++14 лямбда-выражения предполагаются абсолютной заменой std: bind, не остается ни одной реальной причины его использовать. Во-первых, и это самое главное, лямбды легче читаются и яснее выражают мысль автора. Я не буду приводить здесь достаточно громоздкий код для иллюстрации, в оригинале у Майерса его предостаточно. Во-вторых, лямбда-выражения как правило работают быстрее. Дело в том что std: bind захватывает и хранит указатель на функцию, поэтому компилятор имеет мало шансов встроить (inline) ее, тогда как согласно стандарту оператор вызова функции в замыкании (closure) содержащем лямбда-выражение обязан быть встроенным, поэтому компилятору остается совсем немного работы чтобы встроить все лямбда-выражение в точке вызова. Есть еще пара менее значимых причин, но они сводятся в основном к недостаткам std: bind и я их опущу.Главная опасность при работе с лямбда-выражениями — способы захвата переменных (capture mode). Наверное излишне говорить что вот такой код потенциально опасен
[&](…) { … };
Если лямбда-замыкание переживет любую из захваченных локальных переменных, мы получаем висячую ссылку (dangling reference) и в результате undefined behavior. Это настолько очевидно, что я даже примеры кода приводить не буду. Стилистически чуть-чуть лучше вот такой вариант:
[&localVar](…) { … };
мы по крайней мере контролируем какие именно переменные захвачены, а также имеем напоминание перед глазами. Но проблемы это никоим образом не решает.Код где лямбда генерируется на лету
std: all_of (container.begin (), container.end (), [&]() { … });
конечно безопасен, хотя Майерс даже тут предупреждает об опасности копипаста. В любом случае хорошей привычкой будет всегда явно перечислять переменные захватываемые по ссылке и не использовать [&].Но это еще не конец, давайте захватывать все по значению
[=]() { *ptr=… };
Опаньки, указатель захватился по значению и что, нам от этого легче? Но и это еще не все…
std: vector
class Widget { … void addFilter () const { filters.emplace_back ([=](int value) { return value % divisor == 0; }); } private: int divisor; }; ну здесь-то все совершенно безопасно надеюсь? Зря надеетесь. Wrong. Completely wrong. Horribly wrong. Fatally wrong. (@ScottMeyers)
Дело в том что лямбда захватывает локальные переменные в области видимости, ей нет никакого дела до того что divisor принадлежит классу Widget, нет в области видимости — захвачен не будет. Вот такой код для сравнения вообще не компилируется:
…
void addFilter () const {
filters.emplace_back ([divisor](int value) { return value % divisor == 0; });
…
};
Так что же захватывается? Ответ прост, захватывается this, divisor в коде на самом деле трактуется компилятором как this→divisor и если Widget выйдет из области видимости мы возвращаемся к предыдущему примеру с повисшим указателем. По счастью, для этой проблемы решение есть:
std: vector
class Widget { … void addFilter () const { auto localCopy=divisor; filters.emplace_back ([=](int value) { return value % localCopy == 0; }); } private: int divisor; }; сделав локальную копию переменной класса, мы позволяем нашей лямбде захватить ее по значению.Возможно вы будете плакать, но и это еще не все! Немного ранее я упоминал что лямбды захватывают локальные переменные в области видимости, они могу также использовать (т.е. зависеть от) статических обьектов (static storage duration), однако они их не захватывают. Пример:
static int divisor=…;
filters.emplace_back ([=](int value) { return value % divisor == 0; });
++divisor; //, а вот после этого начнутся чудеса Лямбда не захватывает статическую переменную divisor, а ссылается на нее, можно сказать (хоть это и не совсем корректно) что статическая переменная захватывается по ссылке. Все бы ничего, но значок [=] в определении лямбды нам кагбэ намекал что все захватывается по значению, полученное лямбда замыкание самодостаточно, его можно хранить тысячу лет и передавать из функции в функцию и оно будет работать как новенькое… Обидно получилось. А знаете какой из этого вывод? Не надо злоупотреблять значком [=] точно так же как и [&], не ленитесь перечислять все переменные и будет вам счастье.Вот теперь можете смеяться, на этот раз все…Причем действительно все, больше про лямбда-выражения сказать по сути нечего, можно брать и пользоваться. Тем не менее я расскажу в оставшейся части о дополнениях которые принес С++14, эта область еще слабо документирована и это одно из немногих мест где изменения действительно глубокие.Одна вещь, которая меня с самого начала безумно раздражала в C++11 лямбда-выражениях — отсутствие возможности переместить (move) переменную внутрь замыкания. С подачи ТР1 и boost мы внезапно осознали что мир вокруг нас полон обьектов которые нельзя копировать, std: unique_ptr<>, std: atomic<>, boost: asio: socket, std: thread, std: future — число таких обьектов стремительно растет после того как была осознана простая идея: то что не поддается естественному копированию копировать и не надо, зато переместить можно всегда. И вдруг такое жестокое разочарование, новый инструмент языка эту конструкцию не поддерживает.
Конечно, этому существует разумное обьяснение А в какой момент осуществлять само перемещение? А как быть с копированием самого замыкания? etc
однако осадочек остается. И вот, наконец появляется C++14 который эти проблемы решает неожиданным и элегантным способом: захват с инициализацией (init capture).
class Widget { … };
auto wptr=std: make_unique
auto func=[wptr=std: move (wptr)]{ return wptr→…(); };
func ();
Мы создаем в заголовке лямбды новую локальную переменную в которую и перемещаем требуемый параметр. Обратите внимание на два интересных момента. Первый — имена переменных совпадают, это не обязательно, но удобно и безопасно, потому что их области видимости не пресекаются. Переменная слева от знака = определена только внутри тела лямбды, тогда как переменная в выражении справа от = определена извне и не определена внутри. Второй — мы заодно получили возможность захватывать целые выражения, а не только переменные как раньше. Вполне законно и разумно написать вот так:
auto func=[wptr=std: make_unique
auto f=[](auto x) { return func (x); };
используя auto в декларации параметров мы получаем возможность передавать произвольные значения в лямбда-замыкание. А как оно устроено под капотом? Ничего магического
class PseudoLambda {
…
template
Умные указатели, Smart pointers Опасная тема, на эту тему исписаны горы бумаги, тысячи юных комментаторов с горящими глазами безжалостно забанены на всевозможных форумах. Однако доверимся Майерсу, он обещает, дословно «Я сосредоточусь на информации которой часто нет в документации API, заслуживающих внимания примерах использования, анализу скорости исполнения, etc. Владение этой информацией означает разницу между использованием и эффективным использованием умных указателей»
Под такие гарантии я пожалуй рискну сунуться в этот бушующий холивар.В современном языке начиная с C++11 существует три вида умных указателей, std: unique_ptr, std: shared_ptr<> и std: weak_ptr<>, все они работают с обьектами размещенными на куче, но каждый из них реализует свою модель управления своими данными.std: unique_ptr<> единолично владеет своим обьектом и убивает его когда умирает сам. Да, он может быть только один. std: shared_ptr<> разделяет владение с данными с другими собратьями, обьект живет до тех пор пока жив хотя бы один из указателей. std: weak_ptr<> сравнительно малоизвестен, он расширяет std: shared_ptr<> используя более тонкие механизмы управления. Кратко, он ссылается на обьект не захватывая его, пользуется, но не владеет. std: shared_ptr<> самый известный из этой триады, однако, поскольку он использует внутренние счетчики ссылок на обьект, он заметно проигрывает по эффективности обычным указателям. К счастью, благодаря одновременному появлению атомарных переменных, операции с std: shared_ptr<> абсолютно потокопезопасны и почти так же быстры как с обычными указателями. Тем не менее, при создании умного указателя память на куче должна быть выделена не только для хранения самого обьекта, но и для управляющего блока, в котором хранятся счетчики ссылок и ссылка на деаллокатор. Выделение этой памяти сильно влияет на скорость исполнения и это очень веская причина использовать std: make_shared<>(), а не создавать указатель руками, последняя функция выделяет память и для обьекта и для управляющего блока за один раз и поэтому сильно выигрывает по скорости. Тем не менее по размеру std: shared_ptr<> занимает естественно больше в два раза чем простой указатель, не считая выделенной на куче памяти.std: shared_ptr<> также поддерживает нестандартные деаллокаторы памяти (обычный delete по умолчанию), и такой приятный дизайн: тип указателя не зависит от наличия деаллокатор и его сигнатуры.
std: shared_ptr
auto del=[](base_type* p) { …; delete p; };
template
// пользователь однако хочет делиться этим обьектом
// ну и на здоровье
std: shared_ptr
std: unique_ptr
auto sp=std: make_shared
Универсальные ссылки На эту тему Майерс непрерывно пишет в блоге и читает лекции последние два года. Сама концепция настолько удивительна что меняет привычные приемы программирования и работы с обьектами. Тем не менее, есть в ней что-то такое что плохо укладывается в голове, по крайней мере в моей, поэтому я прошу разрешения у сообщества начать с самого начала, от элементарных основ. Заодно может и свои мысли в порядок приведу.Вспомним что такое lvalue и rvalue, термины которым чуть ли не больше лет чем C++. lvalue определить сравнительно просто: это все что может стоять слева от знака присвоения '='. Например, все имена автоматически являются lvalue. А вот с rvalue гораздо туманнее, это как бы все что не является lvalue, то есть может стоять справа от '=', но не может стоять слева. А что не может стоять слева? Огласите весь список пожалуйста: ну во-первых естественно литералы, а во-вторых результаты выражений не присвоенные никакой переменной, тот промежуточный результат в выражении х=a+b; который был вычислен и будет присвоен х (это легче осознать если думать об х не как о целом, а как о сложном классе, очевидно сначала правая часть вычисляется и только потом вызывается оператор присвоения ее х). Однако, помните: имя — всегда lvalue, это действительно важно.Дальше произошло осознание (уже довольно давно, но уже не в такие доисторические времена) что с временными обьектами можно не церемониться во время копирования, жить им все равно осталось пару машинных тактов, а так же то что на этом можно сильно сэкономить. Например, копирование std: map — черезвычайно долгая операция, однако std: swap обменяет содержимое двух обьектов практически мгновенно, несмотря на то что ему технически надо для этого выполнить ! три! копирования На самом деле все stl контейнеры хранят указатели на внутренние данные, так что все сводится к обмену указателей. Однако для нас это сейчас не важно, достаточно знать что std: swap работает быстро.
Таким образом, если некая функция возвращает std: map и мы хотим присвоить это значение другой std: map, это будет долгая операция в случае копирования, однако если бы в операторе присвоения внутренне бы вызывался std: swap, возвращение из функции прошло бы мгновенно. Да, в этом случае исходный (временный) обьект остался бы с каким-то неопределенным содержимым, ну и что? Осталась только одна проблема — средствами языка обозначить такие временные обьекты. Так родились rvalue references обозначаеые значком &&. Выражение type&& является отдельным типом, отличным от type, так же как type& и type*, в частности, можно перегружать функции для каждого типа, т.е. создавать отдельный вариант для параметра по значению, по ссылке и по перемещающей ссылке. int x1=0; int&& x2=0; // assigning lvalue to rvalue int&& x3=x1; // error: cannot bind «int» lvalue to «int&&» int&& x4=std: move (x1); // x4 is a name, so it is lvalue here int&& x5=x4; // error: cannot bind «int» lvalue to «int&&» auto&& x6=0; auto&& x7=x1; эти простые примеры легко понять и означают они все одно — type&& означает перемещающую ссылку (rvalue reference) на type. На самом деле я нагло вру Если вам кажется что это все тривиально, скажите в каком примере это не так, просто для самоконтроля.
Однако все снова осложняется, выражение type&& не всегда означает rvalue reference, в выражениях где присутствуют выведение типов (type deduction), то есть или в шаблонах или в выраженях с auto они могут быть как перемещающими ссылками, так и обычными ссылками, Майерс предпочитает называть их универсальными ссылками (universal references).
template
class Widget {
std: string name;
public:
…
Widget (Widget&& x)
: name (std: move (x.name))
{}
template
void add (const std: string& name) { … names.insert (name); }
std: string name («Виктор Иванович»);
add (name); // 1 pass lvalue std: string
add (std: string («Вася»)); // 2 pass rvalue std: string
add («Тузик»); // 3 pass string literal
В первом случае эта функция вызывается оптимально, строка переданная по константной ссылке копируется в контейнер и ничего улучшить здесь невозможно. Во втором вызове мы могли бы переместить временную строку в контейнер, что на порядок эффективнее копирования. В третьем случае идиотизм зашкаливает — мы передаем const char* указатель, который не является строкой, но валидным типом для создания строки, которая и создается. После этого эта временная строка копируется в контейнер. Таким образом мы совершенно напрасно вызываем конструктор и оператор копирования.Теперь посмотрим что нам предлагает новый стандарт взамен:
template
std: string name («Виктор Иванович»); add (name); // 1 pass lvalue std: string add (std: string («Вася»)); // 2 pass rvalue std: string add («Тузик»); // 3 pass string literal В первом вызове мы точно так же копируем параметр, во втором мы вызываем перемещение вместо копирования, а в третьем вообще просто передаем параметр для создания строки в std: set: emplace (). Этот маленький пример показывает насколько более эффективным может быть код при переходе на новый стандарт.Да, в новом стандарте по-прежнему немало подводных камней, в частности приведенный код становится плохо управляемым если мы перегружаем функцию с другим параметром, особенно острой проблема становится при перегрузке конструкторов и идеальной передаче параметров (perfect forwarding). Тем не менее прекрасно что C++ остается динамично развивающимся языком который активно вбирает в себя все новое и хорошее. Про тот же std: move в одной из первых о нем публикаций кто-то из маститых осторожно заметил: «видимо это останется фишкой для разработчиков системных библиотек и не пригодится обычным пользователям языка» (цитата примерная, полагаюсь на память). Однако по накалу обсуждений и числу публикаций видно что C++ не превратился в язык где умное меньшинство разрабатывает инструменты для бессловесного большинства. Так же как и в далеких 19…-х, C++ сообщество активно сует нос и прикладывает руки везде куда можно и куда нельзя, возможно это и определяет современный статус языка лучше всего. Так давайте же поднимем за это Долой пафос, похоже пора закругляться.Многопоточное API Вот про эту главу я пожалуй писать ничего не стану, мне она просто не понравилась. Возможно я нашел у Майерса слабое место, возможно я сам чего-то недопонимаю, но мне гораздо больше нравится другая книга: C++ Concurrency in Action. Читайте сами и решайте.В общем я постарался как мог передать содержание, но до оригинала мне конечно далеко. Скоро выйдет русский перевод, всем советую.