Анотація до «Effective Modern C++» Скотта Майерса


Пару місяців тому Скотт Майєрс (Scott Meyers) випустив нову книгу Effective Modern C++. Останні роки він безумовно є письменником №1 «про це», крім того він блискучий лектор і кожна його нова книга просто приречена бути прочитана пишуть на С++. Більше того, саме таку книгу я чекав давно, вийшов стандарт С++11, за ним С++14, вже видніється попереду С++17, мова стрімко змінюється, однак ніде так і не були описані всі зміни в цілому, взаємозв'язку між ними, небезпечні місця і рекомендовані патерни.
Тим не менш, регулярно переглядаючи Хабр, я так і не знайшов публікації про нову книгу, схоже, доведеться писати самому. На повноцінний переклад мене звичайно не вистачить, тому я вирішив зробити коротку витримку, скромно назвавши її анотацією. Ще я взяв на себе сміливість перегрупувати матеріал, мені здається для короткого переказу такий порядок підходить краще. Всі приклади коду взяті прямо з книги, зрідка з моїми доповненнями.

Одне попередження: Майерс не описує синтакс, передбачається, що читач знає ключові слова, як написати лямбда-вираз і т. д. Так що якщо хтось вирішить почати вивчення С++11/14 з цієї книги, йому доведеться використовувати додаткові матеріали для довідки. Втім, це не проблема, все гуглится в один клік.

Від С++98 до С++11/14. Галопом по всім новинкам

auto — на перший погляд просто величезна ложка синтаксичного цукру, яка проте здатна змінити якщо не суть то вид З++ коду. Виявляється Страуструп передбачав ввести ключове слово (певне, але марне в) у нинішньому значенні ще в 1983 р., але відмовився від цієї ідеї під тиском З-спільноти. Подивіться, наскільки це змінює код:
template < typename It>
void dwim(It b, It e) {
while(b != e) {
typename std::iterator_traits<It>::value_type
value=*b;
....
}
}
template < typename It>
void dwim(It b, It e) {
while(b != e) {
auto value=*b;
...
}
}

Другий приклад не просто коротше, він ховає тут абсолютно непотрібний точний тип виразу *b, між іншим, у точній відповідності з канонами класичного, ще дошаблонного, ООП. Більш того, по суті вираз std::iterator_traits<It>::value_type — не більш ніж геніальний милицю, придуманий на зорі STL для визначення типу виходить при разіменуванні ітератора, перший варіант буде працювати тільки з типом для якого визначена спеціалізація iterator_traits<>, а от для другого потрібен лише operator*(). Геть милиці!
Не переконує? От ще приклад, на мій погляд, просто вбивчий:
std::unorderd_map<std::string,int> m;
for(std::pair<std::string,int>& p : m) { ... }

Цей код компілюється,
пруфauto1.cc:8:38: error: invalid initialization of reference type of std::pair<std::basic_string<char>, int>& from expression of type std::pair<const std::basic_string<char<, int>
, справа в тому що правильний тип для std::unordered_map<std::string,int> це std::pair<const std::string,int>, очевидно що ключ зобов'язаний бути константою, але набагато простіше використовувати auto тримати точний тип вираження в голові.
Ще кілька моментів, які надають строгості мови:
int x1=1; //1 коректно
int x2; //2 а инициализовать забули!
auto x3=1; //3 коректно
auto x4; //4 помилка! компілятор не пропустить
std::vector < int v>;
insigned x5=v.size(); //5 повинно бути size_t, можлива втрата даних
auto x6=v.size(); //6 коректно
int f();
int x7=f(); //7 а якщо сигнатура f() зміниться?
auto x8=f(); //8 коректно

Як видно з цих прикладів, систематичне використання auto може заощадити чимало нервів при налагодженні.
І, нарешті, там де без auto просто не можна, лямбда-вирази:
auto derefUPLess=
[](const std::unique_ptr<Widget>& p1,
const std::unique_ptr<Widget>& p2)
{ return *p1 < *p2; };

У цьому випадку точний тип derefUPLess відомий тільки компілятор, його просто неможливо зберегти в змінної не використовуючи auto. Звичайно можливо написати так:
std::function<bool (const std::unique_ptr<Widget>&,
const std::unique_ptr<Widget>&)>
derefUPLess=
[](const std::unique_ptr<Widget>& p1,
const std::unique_ptr<Widget>& p2)
{ return *p1 < *p2; };

однак std::function<> і лямбда не один і той ж тип, значить буде викликатися конструктор, можливо з виділенням пам'яті на купі, крім того виклик std::function<> гарантовано дорожче ніж виклик лямбда-функції непосредатвенно.
І наостанок ложка дьогтю, auto працює по іншому при ініціалізації через фігурні дужки:
int x1=1;
int x2(1);
int x3{1};
int x4={1};

всі ці вирази абсолютно еквівалентні, однак:
auto x1=1;
auto x2(1);
auto x3{1};
auto x4={1};

x1 і x2 будуть мати тип int, проте x3 і x4 будуть мати інший тип, std::initializer_list<int>. Як тільки auto зустрічає {} инициализатор, вона повертає внутрішній тип С++ для таких конструкцій — std::initializer_list<>. Чому це так, навіть Майерс зізнається, що не знає, я тим більше гадати не буду.
decltype — тут все більш-менш просто, ця конструкція була додана щоб зручніше писати шаблони, зокрема функції з її обчислене типом залежних від параметра шаблону:
template < typename Container, typename Index>
auto access(Container& c, Index i) -> decltype(c[i])
{
....
return c[i];
}

Тут auto просто вказує що повертається тип буде вказаний після ім'я функції, а decltype() визначає тип значення, що повертається, як правило посилання на i-ий елемент контейнера, однак в загальному випадку саме те що повертає c[i], що б це не було.
uniform initialization — як видно з назви в новому стандарті намагалися ввести універсальний спосіб ініціалізації змінних, і це прекрасно, наприклад, тепер можна писати так:
std::vector < int> v{1,2,3};
// або навіть так
sockaddr_in sa={AF_INET, htons(80), inet_addr("127.0.0.1")};

більш того, використовуючи фігурні дужки можна навіть инициализовать нестатические члени класу (звичайні дужки не працюють):
class Widget {
...
int x{0};
int y{0};
int z{0};
};

Ще це нарешті ховає в скриню вічнозелені граблі які вічно валялися під ногами, особливо докучаючи розробникам шаблонів:
Widget w1(); // це не виклик конструктора без параметрів,
// це декларація функції 

Widget w2{}; // а ось це саме те що я мав на увазі

І ще один крок до строгості мови, нова ініціалізація запобігає пребразование типів з втратою точності (narrowing conversion):
double a=1, b=2;
int x=a+b; // fine
int y={a+b}; // помилка

Однак ..., все одно не покидає відчуття що щось пішло не так. По-перше, там де задіяні фігурні дужки, ініціалізація завжди відбувається через внутрішній тип std::initializer_list<>, але, з незрозумілої причини, якщо клас визначає один з конструкторів з таким параметром, цей конструктор завжди предпочитается компілятором. Наприклад:
class Widget {
Widget(int, int);
Widget(std::initializer_list<double>);
};

Widget w1(0, 0); // calls ctor #1
Widget w2{0, 0}; // calls ctor #2 !?

Всупереч будь-якій очевидності у другому випадку компиоятор проігнорує ідеально підходить конструктор_1 і викличе конструктор_2, перетворивши int у double. До речі, якщо поміняти місцями типи int та double у визначенні класу, то код взагалі перестане компілюватися тому що конверсія { double, double } std::initializer_list<int> відбувається з втратою точності.
Ця колізія може статися з будь-яким кодом вже зараз, за правилами З++11
std::vector(10, 20) створює об'єкт з 10 елементів, тоді як
std::vector{10, 20} створює об'єкт лише з двох елементів.
Зверху це все прикрасимо гілочкою кропу — для copy-конструкторів і move-конструкторів це правило не працює:
class Widget {
Widget();
Widget(const Widget&);
Widget(Widget&&);
Widget(std::initializer_list<int>);

operator int() const;
};

Widget w1{};
Widget w2{w1};
Widget w3{std::move(w1)};

Буквально слідуючи букві закону слід було б очікувати, що компілятор вибере конструктор з параметром std::initializer_list а фактичні параметри будуть перетворені через оператор int(), так адже немає! В даному випадку (copy/move constructor) викликаються саме конструктори копій.
Загалом рекомендація завжди використовувати якийсь один тип дужок, круглі або фігурні, рішуче не працює. Майерс радить дотримуватися одного способу, застосовуючи інший тільки там де необхідно, сам він схиляється до круглим дужкам, в чому я з ним згоден. Однак залишається проблема з шаблонами, де те що повинно бути викликано визначається параметрами шаблону… Ну, принаймні З++ залишається ненудним мовою.
nullptr — тут навіть говорити особливо немає про що, очевидно що NULL так само як значення 0 не є покажчиками, що призводить до численних помилок при виклику перевантажених функцій і реалізації шаблонів. При цьому nullptr покажчиком і ні до яких помилок не призводить.
alias declaration проти typedef
Замість звичного оголошення типів
typedef std::unique_ptr<std::unordered_map<std::string,std::string>> UPtrMapSS;

пропонується використовувати ось таку конструкцію
using UPtrMapSS=std::unique_ptr<std::unordered_map<std::string,std::string>>;

ці два вирази абсолютно еквівалентні, проте історія на цьому не закінчується, синоніми (aliases) можуть використовуватися як шаблони (alias templates) і це додає їм додаткову гнучкість
template < typename T>
using MyAllocList=std::list<T, MyAlloc<T>>;

MyAllocList<Widget> lw;

В C++98 для створення такої конструкції MyAllocList довелося б оголосити шаблонної структурою, продекларувати тип всередині неї і використовувати ось так:
MyAllocList<Widget>::type lw;

але історія триває. Якщо ми використовуємо тип обьявленный через typedef як залежний тип всередині шаблонного класу, нам доводиться використовувати додаткові ключові слова
template < typename T>
class Widget {
typename MyAllocList<T>::type lw;
...

у новому синтаксе все набагато простіше
template < typename T>
class Widget {
MyAllocList<T> lw;
...

Загалом, метапрограмування обіцяє бути набагато більш легким з цієї синтаксичною конструкцією. Більш того, починаючи з С++14 <type_traits> вводяться відповідні синоніми, тобто замість звичного
typename remove_const<...>::type
// можна писати
remove_const_t<...>

Використання синонімів — вкрай корисна звичка, яку варто почати в собі культивувати прямо зараз. У свій час typedef безжально розправився з макросами, ми не забудемо, не пробачимо і відплатимо йому тією ж монетою.
scoped enums — ще один крок до внутрішньої стрункості мови. Справа в тому що класичні перерахування (enums) обьявлялись усередині блоку, однак їх видимість (scope) залишалася глобальної.
enum Color { black, white, red };

black, white та red видимымы в тому ж блоці що і Color, що призводить до конфліктів і засмічення простору імен. Новий синтакс:
enum class Color { black, white, red };
Color c=Color: white;

виглядає набагато елегантніше. Тільки одне але — одночасно прибрали автоматичне приведення перерахувань до цілим типами
int x=Color::red; // помилка
int y=static_cast<int>(Color: white); // ok

до строгості мови це безумовно тільки додає, однак в переважній більшості коду який я бачив enums так чи інакше конвертуються в int, хоча б для переачи switch або виводу std::cout.
override, delete і default — нові корисні слова при оголошень функцій.
override сигналізує компілятору що дана віртуальна функція-член класу повинна перекрити (override) певну функцію базового класу і, якщо відповідного варіанту не знаходиться, він люб'язно повідомить нам про помилку. Всі напевно стикалися з ситуацією, коли випадкова помилка або зміна сигнатури перетворює віртуальну функцію в звичайну, найнеприємніше що все прекрасно складений, але працює якось не так. Так от, цього більше не буде. Рішуче рекомендується до використання.
delete — покликане замінити старий (і красивий) трюк з приватним обьявлением конструктора за замовчуванням і оператора присвоєння. Виглядає більш послідовно, але не тільки. Цей прийом можна застосовувати і до вільним функціям щоб заборонити небажані перетворення аргументів
bool isLucky(int);
bool isLucky(char) =delete;
bool isLucky(bool) =delete;
bool isLucky(double) =delete;

isLucky('a'); // помилка
isLucky(true); // помилка
isLucky(3.5); // помилка

цей же прийом можна використовувати і для шаблонів
template < typename T> void processPointer(T*);
template<> void processPointer(void*) =delete;
template<> void processPointer(char*) =delete;

дві останні декларації забороняють генерацію функцій для деяких типів аргументу.
default — цей модифікатор змушує компілятор генерувати автоматичні функції класу, причому його дійсно доводиться використовувати. Автоматично генеруються функцій в С++98 ставилися конструктор без параметрів, деструктор, що копіює конструктор і оператор присвоювання, всі вони створювалися по відомим правилам у разі необхідності. В С++11 додалися переміщає конструктор і оператор присвоювання, але не тільки, змінилися самі правила створення автоматичних функцій. Логіка проста, автоматичний деструктор викликає по черзі деструктори членів класу і базових класів, копіюючи/переміщає конструктор викликає по черзі відповідні конструктори своїх членів і т. д. Однак, якщо ми раптом вирішуємо визначити будь-яку з цих функцій вручну, значить нас це розумне поведінка не влаштовує і компілятор відмовляється розуміти наші мотиви, в такому випадку переміщують конструктор і оператор присвоєння автоматично створюватися не будуть. Зрозуміло до копіювала парі ця логіка теж застосовується, але вирішено [поки] залишити як було для зворотної сумісності. Тобто в С++11 має сенс писати як-то ось так:
class Widget {
public:
Widget() =default;
~Widget() =default;
Widget(const Widget&) =default;
Widget(Widget&&) =default;
Widget& operator=(const Widget&) =default;
Widget& operator=(Widget&&) =default;
...
};

Якщо пізніше ви вирішите визначити деструктор нічого не зміниться, в іншому випадку переміщують функції просто зникли. Код продовжував би компілюватися, однак викликалися б копіюють аналоги.
noexept — нарешті стандарт визнав що існуюча в С++98 специфікація винятків неефективна, визнав її використання небажаним (deprecated) і поставив замість один великий червоний прапорець noexcept, який декларує що функція ніколи не викидає винятків. Якщо виняток все-таки кинуто, програма гарантовано завершиться, при цьому, на відміну від throw(), навіть стек не обов'язково буде розкручений. Сам прапорець залишений з міркувань ефективності, мало того що стек не потрібно тримати готовим до розкручування, ще й сам генерується компілятором код може відрізнятися. Ось приклад:
Widget w;
std::vector<Widget> v;
...
v.push_back(w);

При додаванні нового елементу до вектора рано чи пізно виникає ситуація коли весь внутрішній буфер треба перемістити в пам'яті, в С++98 елементи по черзі копіює. В новому стандарті було б логічно елементи вектора переміщати, це на порядок ефективніше, але є один нюанс… Якщо в процесі копіювання якийсь з елементів викине виняток, новий елемент природно вставлений не буде, але сам вектор залишиться в нормальному стані. Якщо ж ми елементи переміщали, то частина з них вже в новому буфері, частина ще в старому, і відновити пам'ять в робочий стан вже неможливо. Вихід простий, якщо в класі Widget переміщає оператор присвоєння продекларований noexcept, об'єкти будуть переміщатися, якщо немає — копіюватися.
На цьому закінчимо цей тривалий огляд новинок сезонуЯ свідомо опустив кілька пунктів — constexpr, std::cbegin() і т. д. Вони досить прості і говорити особливо немає про що. Ось що хотілося б обговорити, так це теза про те що константные функції-члени повинні бути потокобезопасны, але це навпаки виходить за рамки простого додавання до синтаксу, може бути в коментарях вийде.


Типи, їх виведення і всі з цим пов'язане

Виведення типів (type deduction) у З++98 використовувалося виключно в реалізації шаблонів, новий стандарт додав універсальні посилання, ключові слова auto decltype. У більшості випадків виведення інтуїтивно зрозуміло, однак конфлікти трапляються і тоді розуміння механізмів роботи дуже виручає. Візьмемо ось такий псевдокод:
template < typename T>
void f(ParamType param);

f(expr);

Головне тут те, що Т і ParamType в загальному випадку два різних типу, наприклад ParamType може бути const T&. Точний тип Т виводиться при реалізації шаблону як з фактичного типу expr, так і з виду ParamType, можливі кілька варіантів.
  • найпростіший випадок коли ParamType не є покажчиком, ні посиланням, тоді вираз у функцію передається за значенням, з expr забираються всі посилання, const модифікатори, залишається чистий тип
    template < typename T>
    void f(T param);
    
    int x=1;
    const int cx=x;
    const int& rx=x;
    
    f(x); // у всіх виклики значення Т і param - int
    f(cx);
    f(rx);
    

  • Якщо ParamType — покажчик або звичайна (не універсальна) посилання то при виведенні типу Т посилання прибирається, але зберігаються const/volatile модифікатори
    template < typename T>
    void f(T& param);
    
    int x=1;
    const int cx=x;
    const int& rx=x;
    
    f(x); // значення Т - int, param - int&
    f(cx); // значення Т - const int, param - const int&
    f(rx); // значення Т - const int, param - const int&
    

    інтуїтивно все абсолютно прозоро, ми передаємо значення по посиланню як зазначено в шаблоні, але зберігаємо модифікатори на читання/запис щоб не порушити права доступу до переданому об'єкту.
  • Якщо ParamType — універсальна посилання той тип виразу залежить від типу expr. Якщо це lvalue то обидва Т і ParamType трактуються як посилання, а якщо expr rvalue то застосовуються правила аналогічні звичайним посиланнями:
    template < typename T>
    void f(T&& param);
    
    int x=1;
    const int cx=x;
    const int& rx=x;
    
    // всі параметри тут - lvalue
    f(x); // значення Т - int&, param - int&
    f(cx); // значення Т - const int&, param - const int&
    f(rx); // значення Т - const int&, param - const int&
    // однак
    f(1); // значення Т - int, param - int&&
    

Для auto правила виведення типів точно такі ж, в цьому випадку auto грає роль параметра Т, за одним винятком, яке я вже згадував, якщо auto бачить вираз у фігурних дужках відображається тип std::initializer_list.
У разі decltype майже завжди повертається саме той тип, який йому передали, зрештою, саме для цього його і придумали. Проте один нюанс все-таки існує — decltype повертає посилання для всіх виразів відмінних від імені, тобто:
int x=1;

decltype(x); // x-ім'я, повертається тип int
decltype((x)); // (x) - вираз, повертається тип int&

але навряд чи це когось зачепить крім бібліотек активно використовують макроси.


Перечитав написане, що багато виходить. А адже найцікавіше ще попереду, мабуть, краще розбити на два пости. Продовження випливає.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.