Языковые новшества C++17. Часть 3. Порядок и спокойствие

Продолжаем знакомиться с языковыми новшествами стандарта C++17. В настоящей статье мы рассмотрим то, что я бы назвал продолжением облагораживания языка. Т.е. никаких совершенно новых, с точки зрения функционала, вещей мы тут не увидим — скорее приведение старого функционала в более приемлемое состояние. Здесь мы рассмотрим: что изменилось с порядком исполнения подвыражений, какие новые гарантии появились в части исключения необязательного копирования, а также что нового добавили в лямбды.

Наводим порядок

Многие C++-программисты сталкивались с «интересными» задачами, в которых приводится некоторый спорный код и спрашивается: «Что будет выведено?». Одним из распространённых примеров подобного кода является следующий пример:

int i = 0;
i = i++ + i++;

Такой «умный» код встречается как в сети, так и на собеседованиях. Целью таких вопросов является выяснение того, насколько отвечающий знаком с особенностями порядка исполнения выражений в C++.

По крайней мере это декларируемая цель. Правда, я полагаю, что в большинстве случаев человек, задающий такие вопросы, просто хочет потешить своё самолюбие. Знание того, что выведет подобный код совершенно необязательно, ведь такой код просто напросто нельзя писать. А раз нельзя писать, то зачем о подобном спрашивать у соискателя? Такие вопросы уместны для «курилок», где знакомые программисты обсуждают пограничные случаи; они не уместны для собеседований. Рекомендую ознакомится с мыслями Реймонда Чена на эту тему: «Do people write insane code with multiple overlapping side effects with a straight face?»

Но это патологический случай, который виден невооружённым взглядом, и, как я уже упоминал, нормальный программист так никогда не напишет. Но есть и менее очевидные случаи, которые могут написать даже опытные программисты. Давайте рассмотрим такой кусок кода:

void f2()
{
    std::string s = "but I have heard it works even if you don't believe in it";
    s.replace(0, 4, "")
       .replace(s.find("even"), 4, "only")
       .replace(s.find(" don't"), 6, "");
    assert(s == "I have heard it works only if you believe in it");
}

Этот код представлен в последней книге Страуструпа «The C++ Programming Language 4th edition», в разделе 36.3.6, и на первый взгляд выглядит вполне пригодным и правильном. Но это только на первый взгляд, на самом деле нет никаких гарантий, что вышеприведённый код сформирует ожидаемую строку, и, соответственно, assert не сработает.

Как мы видим, даже создатель C++ допустил ошибку в таком небольшом куске кода. О чём это говорит? В первую очередь о том, что не нужно напихивать кучу кода в одно выражение, в котором происходит масса всяких разных вещей. Первый вариант этого кода, который представлен на той же странице книги куда проще и лучше:

void f()
{
    std::string s = "but I have heard it works even if you don't believe in it";
    s.replace(0, 4, "");
    s.replace(s.find("even"), 4, "only");
    s.replace(s.find(" don't"), 6, "");
    assert(s == "I have heard it works only if you believe in it");
}

Этот вариант не только правильный с точки зрения хода исполнения программы, он ещё и легче читается. Но это не единственный вывод, который мы должны сделать, есть ещё один, который за нас уже сделали авторы предложения P0145R3: с порядком исполнения подвыражений выражений в C++ что-то не так.

Старый порядок

Прежде чем мы перейдём к самому предложению и изменениям, к которым принятие оного привело, предлагаю вспомнить правила текущие. Это поможет освежить в памяти (а кому-то узнать) почему 2 приведённых ранее примера являются плохим C++ кодом (сугубо с точки зрения языка, не эстетики). Итак, в отличие от многих других языков программирования, в C++ порядок исполнения подвыражений в выражениях не определяется стандартом и остаётся на откуп компилятору. Конечно, определённый порядок всё же есть, но я не буду здесь описывать все тонкости, т.к. их достаточно много. Важно понимать, что, как правило, 2 подвыражения одного большого выражения исполняются независимо друг от друга в неопределённом порядке (большим исключением из этого правила является оператор запятая «,»).

К примеру, давайте возьмём наш первый пример: i = i++ + i++;. В большом выражении есть 4 малых подвыражения: i, i++, i++ и i++ + i++. Что гарантирует стандарт C++14? Он гарантирует (expr.ass), что оба выражения i++ будут вычислены до того, как будет вычислена их сумма, а также то, что выражение i будет вычислено до того, как ему будет присвоен результат суммы. Так же напоминаю, что выражение i++ возвращает старое значение i, а затем уже увеличивает i на единицу (инкрементирует). Это, в свою очередь, означает, что выражение считается вычисленным тогда, когда получено старое значение i.

Таким образом получается, что компилятор может выбрать несколько путей вычисления полного выражения: он не ограничен в том, когда эффект от ++ должен быть применён к i. В результате мы можем получить разные значения в i, что, разумеется, никуда не годится, т.к. программа должна давать предсказуемый результат, который не зависит от прихотей компилятора. Например, порядок может быть такой:

  1. Вычисляем первый i, он равен 0.

  2. Вычисляем второй i, он равен 0.

  3. Записываем результат второго инкремента, получаем i == 1.

  4. Записываем результат первого инкремента, получаем i == 2.

  5. Вычисляем i слева от знака равенства.

  6. Вычисляем сумму: 0 + 0 == 0.

  7. Записываем результат суммы в i.

  8. Возвращаем результат полного выражения, т.е. i, который равен 0.

Выполнять вышеприведённые шаги можно в любом порядке, который не нарушает гарантий предоставленных стандартом, и в результате будут получаться разные ответы.

Кстати, можно рассмотреть вариант и попроще: i = ++i + i++;. Здесь сразу видно, что результат будет разным в зависимости от того, что будет вычислено первым ++i или i++, т.к. у первого выражения побочные эффекты (инкрементирование i на единицу) происходят до его вычисления.

Хотя второй вариант и более нагляден, оба они дают на выходе так называемое неопределённое поведение (НП, англ. undefined behavior). Все матёрые C++ программисты знакомы с этим термином, но вряд ли многие знают все места языка C++, где такое поведение может проявляться. Это широкая и достаточно интересная тема, которой можно посветить не одну статью, поэтому останавливаться подробнее на этом я не буду. На самом же деле такой детальный анализ выражения был не нужен, т.к. согласно стандарту (intro.execution/p15) наше выражение является НП уже потому, что в одном выражении присутствуют два подвыражения, которые модифицируют один и тот же скалярный объект, и при этом порядок изменений не определён. Зачем тогда я приводил этот анализ? Я попытался показать почему НП проявляется, исходя из текущих ограничений на исполнение выражений, т.е. целью было показать, что с текущими правилами у стандарта нет другого выхода, как развести руками.

Теперь перейдём к нашему второму примеру, и разберёмся, что не так с ним. Для того, чтобы упростить понимание, я сокращу этот примеру до такого выражения: s.replace(s.find("even"), 4, "only"). Что тут у нас есть? Есть объект s, есть вызов функции-члена std::string::replace, ещё одной функции std::string::find, а также аргументы для этих функций. Какие гарантии даёт нам стандарт? Стандарт гарантирует, что аргументы функции будут вычислены до того, как функция будет вызвана. Также он гарантирует, что объект, для которого функция выполняется, должен быть вычислен до того, как функция для него будет вызвана. Всё это понятно и логично. Правда, никаких других гарантий у нас нет: нет гарантии, что s будет вычислен до того, как аргументы функции replace будут вычислены, а также нет никаких гарантий касательно порядка вычисления этих самых аргументов. Поэтому, мы можем получить такой порядок вычисления: s.find("even"), "only", 4, s, s.replace(...). Либо же любой другой, который не нарушает ранее обозначенных гарантий стандарта.

Из вышеприведённого текста нужно вычленить 2 главных момента: 1) выражения слева и справа от точки могут быть вычислены в любом порядке, 2) аргументы функции могут быть вычислены в любом порядке. Исходя из этого, теперь должно быть понятно, почему код из книги Страуструпа неверен. В выражении:

s.replace(0, 4, "")
   .replace(s.find("even"), 4, "only")
   .replace(s.find(" don't"), 6, "");

Оба вызова find могут закончится до того, как предыдущие (в коде) replace будут выполнены. А могут и после. Ещё первый может до, а второй позже — не известно, т.к. порядок не определён. В результате, этот код даёт непредсказуемый результат, хотя и не является НП. Однако, как я уже сказал, подобный код грамотный программист писать не станет, а то, что он находится в книге Страуструпа не значит, что он бы так стал писать — он просто приводил пример цепочки вызовов.

К тому же, цепочка вызовов может быть и не такой явной. К примеру, вот такой код:

std::cout << first << second;

Это тоже цепочка вызовов, которая может быть такой:

std::cout.operator<<(first).operator<<(second);

или такой:

operator<<(operator<<(std::cout, first), second);

Разница не принципиальна. Если вдруг выражения first и second каким-то образом ссылаются на один объект, причем одно из этих выражений модифицирует этот объект, то высок шанс, что на выходе мы получим нестабильный код или же НП.

Ещё один интересный пример из вышеупомянутого предложения:

std::map<int, size_t> dictionary;
dictionary[0] = dictionary.size();

Да, код выглядит бессмысленно, но что он даст в результате? Даже бессмысленный код должен давать предсказуемый результат. К сожалению, C++ вида 2014 года лишь пожимает плечами — не знаю, мол.

Функции и операторы

Когда мы рассматривали цепочку вызовов, мы затронули ещё один интересный момент: во что на самом деле превращается вызов std::cout << first << second;. Как мы уже видели, в зависимости от того, чем являются first и second, мы можем получить либо цепочку вызовов функций-членов, либо же вложенные вызовы свободных функций. Но ведь в изначальном варианте записи у нас есть три выражения и 2 оператора <<, у нас нет вообще никаких функций!

Вряд ли этот код вызывал у C++ программистов проблемы: все мы рано или поздно узнаём о перегрузке операторов и принимаем всё это как должное, но в этой перегрузке есть один нюанс. Чтобы этот нюанс показать, давайте напишем вот такой шаблон функции:

template <typename T>
bool cleverFun(T& value)
{
    return (cout << "first\n", value++) &&
        (cout << "second\n", value++);
}

Полный код этого примера находится в этой фиксации.

Да, шаблон не из самых полезных и примечательных, зато, как мы сейчас увидим, является весьма показательным. Давайте вызовем функцию cleverFun с аргументом типа int, что приведёт к инстанциации такой функции:

bool cleverFun(int& value)
{
    return (cout << "first\n", value++) &&
        (cout << "second\n", value++);
}

При вызове этой функции вывод гарантировано будет таким:

first

если первый value++ вернёт 0, в противном случ��е он будет таким:

first
second

И никаким другим, что очевидно: для оператора && есть строгая гарантия короткого замыкания (КЗ, англ. short-circuit) и выполнения левой части до правой. С другой стороны, если мы создадим некий тип Int для которого переопределим и постфиксный operator++, и operator&&, а затем инстанциируем с ним наш шаблон, то получим такую функцию:

Int cleverFun(Int& value)
{
    return (cout << "first\n", value.operator++(0))
       .operator&&((cout << "second\n", value.operator++(0)));
}

Я не стал раскрывать то, во что превратиться вызов cout, чтобы не захламлять и так не слишком легко читаемый код ещё больше. Исходя из ранее рассмотренного, вас не должно удивить то, что вывод этого кода будет отличаться от полученного для обычного int. Тут так же можно получить 2 варианта, но они будут другие:

first
second

или

second
first

Очевидно, что вариант с одним first мы получить не можем в силу того, что КЗ для переопределённых операторов не работает. Если вы внимательно посмотрите на этот пример, то должны понять почему: чтобы выполнить переопределённый operator&& для него должен быть вычислен аргумент (т.е. прощай КЗ), кроме того, КЗ работает только тогда, когда выражение слева является bool, чего в случае переопределённого оператора быть не может. Таким образом, по поводу КЗ никаких иллюзий быть не может — его для переопределённых операторов нет и не будет.

Хорошо, КЗ быть не может, поэтому первого варианта вывода (только first) мы получить не можем, но даже вариант с двумя строками вывода может отличаться, а может и нет! Только вдумайтесь: мы имеем один и тот же код внутри шаблона функции, который при одних аргументах шаблона выполняется по одним правилам, а для других по совершенно иным.

Всё это происходит потому, что в С++14 гарантии для операторов и их операндов отличаются в зависимости от того, чем являются операнды. Согласно стандарту, для интегральных типов все гарантии операторов работают так, как они описаны для них в стандарте, а вот для переопределённых операторов уже работают правила которые управляют вызовом функций. Т.е. для переопределённых операторов выражение «переписывается» компилятором на цепочку вызова функций, и уже после этого применяются правила из стандарта, которые определены для такой цепочки. Никакие гарантии операторов из стандарта на переопределённые операторы не действуют.

Всё ранее описанное рисует весьма безрадостную картину: слишком много в C++ хаоса, в части вычисления выражений. Немудрено, что мириться с подобным людям надоело, а вечные заявления о том, что всё это нужно для каких-то мифических оптимизаций и не должно быть изменено, перестали считаться достаточным оправданием. Здравый смысл восторжествовал, и C++17 получил немного изменений в части наведения порядка в этом бардаке. А что это за изменения мы сейчас и рассмотри.

Новый порядок

Первым изменением, принесенным C++17, является упорядочивание выполнения постфиксных операторов, операторов присвоения, а также операторов побитового сдвига. Теперь все постфиксные операторы, а также операторы побитового сдвига, выполняется слева направо, в то время как операторы присвоения — наоборот, справа налево. Под «выполнением», в данном контексте, я имею в виду то, что выражение является вычисленным (т.е. его результат возвращён), и все ассоциированные с ним побочные эффекты зафиксированы (committed).

Для пояснения того, как же теперь упорядочены выражения, возьмём пример из предложения (в примере ниже сначала выполняется выражение a, затем b):

a.b
a->b
a->*b
a(b1, b2, b3)
b @= a
a[b]
a << b
a >> b

Где @ является любым допустимым в этом контексте оператором (например +). Таким образом, исходя из новых правил, пример, приведённый в книге Страуструпа по C++11, в C++17 наконец становится правильным и всегда будет выдавать корректный и ожидаемый результат. Как вы можете видеть, новые правила не коснулись порядка выполнения аргументов функции относительно друг друга: они по прежнему могут быть выполнены в любом порядке, но их выполнение не может пересекаться (interleave) между собой. Другими словами, они упорядочены относительно друг друга, но порядок не регламентирован.

Теперь давайте рассмотрим несколько «интересных» примеров, где в C++14 мы имели НП, а в C++17 оно пропало. Я привожу эти примеры исключительно для собственного потребления, заклинаю вас не мучить ими людей на собеседованиях.

i = i++;
f(++i, ++i)
f(i++, i++)
array[i++] = i++
i << i++
cout << i++ << i++

А вот эти примеры так и остаются НП в новом стандарте:

i = i++ + i++
i = ++i * i++

Потому что никаких правил, регулирующих порядок выполнения подвыражений арифметических операторов, не добавили. Но тот факт, что НП пропало из этих примеров вовсе не означает, что пора насыщать свой код подобным — нет. Каждый из этих примеров требует внимательности и доказательства, что это не НП. Т.е. любой программист, увидевший подобный код, будет вынужден остановиться, вспомнить (или залезть в стандарт) и удостоверится, что он видит перед собой верный код. Код должен быть не «умным», код должен быть понятным. Тем более, что подобное совмещение выражений мало что даёт, на самом деле.

Кстати, внимательный читатель наверняка заметил строчку cout << i++ << i++ в вышеприведённых примерах, и если он не знает обо всех правилах и поверил автору, то он наверняка воспользовался такой логикой: пример переписывается как

cout.operator<<(i++).operator<<(i++)

после чего к нему применяются новые правила для ., поэтому в коде нет НП. Подобные рассуждения кажутся логичными, но не совсем верны. На самом деле всё проще: пример действительно «переписывается» компилятором на тот, который я привел, но порядок исполнения выстраивается до переписывания! Т.е. согласно новым правилам, перегруженные операторы подчиняются правилам исполнения для встроенных операторов, по крайней мере в части порядка вычисления подвыражений. Поэтому исходя из того, что левый операнд оператора << вычисляется до правого у нас и нет НП в коде.

Получается, что у нас больше нет разночтения в том, в каком порядке будут выполнятся выражения для встроенных и перегруженных операторов, и наш пример из прошлого раздела:

template <typename T>
bool cleverFun(T& value)
{
    return (cout << "first\n", value++) &&
        (cout << "second\n", value++);
}

для любого типа всегда выведет сначала first, а потом second. Обратный порядок вывода теперь исключен стандартом. Это, безусловно, очень важное нововведение, которое позволяет рассуждать над тем кодом, который написан, а не тем, что будет сгенерирован из оного. Интересно заметить, что это нововведение породило разницу между явным и неявным вызовом перегруженного оператора. Рассмотрим пример:

#include <iostream>

using namespace std;

class SomeClass 
{
    friend int operator<<(const SomeClass& obj, int&);
public:
    SomeClass(int var):
        m_Var{var}
    {  
    }
private:
    int m_Var;
};

int operator<<(const SomeClass& obj, int& shift)
{
    return obj.m_Var << shift;
}


int main()
{
    int i = 0;
    int result = SomeClass{i = 1} << (i = 2);
    cout << "First result: " << result << "\n";
    result = operator<<(SomeClass{i = 1}, i = 2);
    cout << "Second result: " << result << "\n";
};

Первый результат гарантировано будет 4, тогда как второй может быть как 2, так и 4. Этот пример хорошо показывает разницу между явным и неявным вызовом перегруженного оператора в C++17.

Очевидно, что с введением нового порядка появилось много различных сложных выражений, которые давали НП в прошлых стандартах, а теперь являются допустимыми, но это не означает, что они должны начать массово появляться в коде. Этого не должно быть просто потому, что они являются сложными, а всё что сложно для понимания следует избегать. Но новые правила дают нам не только возможность вызова функций типа f(i++, i++), без страха получить неработоспособную программу. Новые правила предают коду на C++ больше строгости и порядка, благодаря которому, в том числе, мы теперь можем писать надёжный код с цепочкой вызовов (явной или неявной — не важно).

Хотя я и высказал некоторое «фи» касательно кода из книги Страуструпа, я далеко не противник цепочки вызовов, и если посмотреть на современный код, написанный с использованием императивных языков, то мы можем увидеть, что он содержит всё больше цепочек (к примеру LINQ и Task+ContinueWith из C#, или Lodash/underscore и Promise+then из JS). C++ тоже идёт в этом направлении, и вскоре мы сможем увидеть аналоги вышеозначенных примеров в виде Range-v3 и future+then в будущих стандартах C++. Но и до выхода новых стандартов мы можем использовать различные библиотеки, интерфейс которых поощряет использование цепочки вызовов.

В общем и целом, на мой взгляд, изменение правил порядка вычисления выражений является одним из наиболее важных нововведений C++17, которое мало кто заметит, потому что всё (или почти всё) будет просто работать так, как должно работать согласно здравому смыслу. А здравого смысла в стандарте C++ с каждым днём становится всё больше.

Минимизируем копирование

Одним из первых шагов в обучении C++, является изучение копирующего конструктора. Ведь с помощью него можно легко определить, что и когда копируется. Т.е. мы пишем свой класс, добавляем туда копирующий конструктор, в котором прописываем вывод через cout, и наслаждаемся выводом, по результатом которого мы выясняем сколько же копий у нас создаётся.

С появлением семантики перемещения ситуация несколько усложнилась, так что для полноты картины теперь нужно создавать ещё и перемещающий конструктор. Но для данного раздела это неважно, т.к. все ниженаписанное справедливо как для копирования, так и для перемещения.

К примеру, давайте напишем такой код:

#include <iostream>

using namespace std;

class SomeClass 
{
public:
    SomeClass() = default;
    SomeClass(const SomeClass&)
    {
        cout << "Copy ctor called.\n";
    }
};

SomeClass meReturn()
{
    return SomeClass{};
}

int main()
{
    auto some = meReturn();
};

Сколько раз на экране появится фраза «Copy ctor called.», если собрать этот код на компиляторе, реализующем C++14 и запустить программу? Ноль, один, или может быть два раза? Правильный ответ: неизвестно.

Те, для кого ответ стал неожиданностью, заслужили объяснения, к которому мы и переходим. Итак, для начала давайте расчехлим стандарт и рассмотрим какое максимальное количество копий здесь может быть создано. Наибольшим числом возможных копий здесь является число 2: первая копия создаётся при выполнении оператора return, а вторая копия создаётся при конструировании объекта some. Но если вы запустите этот код на более-менее современном компиляторе (без дополнительных ключей!), вы вряд ли увидите двойной вывод; более вероятный исход это либо одна строка, либо же вообще никакого вывода не будет. Теперь немного видоизменим код нашей функции, это будет второй вариант:

SomeClass meReturn()
{
    SomeClass some{};
    return some;
}

Если мы выполним этот код на популярных компиляторах, то вывод может измениться, а может и нет (на MSVC 2017 меняется, в отладочном режиме). Наконец, мы ещё немного изменим код функции, только в этот раз вывод гарантированно измениться (относительно первого варианта и с учётом текущего положения вещей с компиляторами):

SomeClass meReturn()
{
    SomeClass some{};
    if (false)
        return SomeClass{};
    return some;
}

Итак, функция, в сущности, во всех вариантах одинаковая, а поведение отличается — что вообще тут происходит? Начнём с начала. Согласно стандарту C++, в некоторых случаях компилятор может не выполнять копирования объекта; такая ситуация получила название пропуск копирования (ПК, англ. copy elision). Полный список (довольно короткий) признаков, по которым можно определить разрешен ли пропуск копирования, описан в class.copy/p31. Нас интересуют две похожие, но всё же различные ситуации.

В изначальном примере наша функция возвращает временный безымянный объект. В такой ситуации компилятор имеет право опустить оба копирования и просто создать объект прямиком в some. В народе эта ситуация получила название оптимизация возвращаемого значения (ОВЗ, англ. return value optimization, RVO). Если мы посмотрим на gcc/clang/MSVC, то увидим, что для такой функции они избавляются от обеих копий и, следовательно, вывод будет пуст.

Подобная оптимизация разрешена не только для return, но и для других мест, где происходит инициализация временным, безымянным объектом. Так, если у вас есть функция void meAccept(SomeClass), которая вызывается как meAccept(SomeClass{}), то компилятор имеет права опустить избыточное копирование.

Теперь перейдём ко второму варианту, где мы создали именованный объект на стеке. Вывод для gcc/clang не изменился, а вот для MSVC (в отладочном режиме) появилась одна строчка в выводе, очевидно, что в этом случае MSVC избавился лишь от второй копии. Исходя из вышесказанного, становится понятно, что компилятор тоже применяет ПК, но тут это происходит согласно немного другому критерию: он имеет право избавиться от копирования именованного объекта на стеке, который возвращается из функции. Подобная оптимизация получила в народе название оптимизация именованного возвращаемого значения (ОИВЗ, англ. named return value optimization, NRVO).

Такую оптимизацию компилятору выполнить сложнее, что мы и видим в третьем варианте, где мы добавили абсолютно бесполезный if, который заставил все три основных компилятора спасовать и сделать копию. Таким образом, ОИВЗ является более «хрупкой» оптимизацией, чем простая ОВЗ, и, как правило, она отключается, когда в коде имеется несколько различных return. Это является одним из доводов, почему в функции должен быть только один return (не могу сказать, что довод сильно убедителен).

Интересным фактом является то, что вышеописанная оптимизация применяется в компиляторах даже тогда, когда мы компилируем с отключенной оптимизацией (-O0, /Od). Более того, только gcc и clang можно заставить создавать все копии. Для этого нужно использовать ключ -fno-elide-constructors, а MSVC ни при каких обстоятельствах две копии не создаст, и никаких [публичных] ключей для отключения этого поведения нет.

Есть и другой момент, который следует упомянуть. Хотя в C++14 компилятор и может убрать обе копии, тем самым не выполнив конструктор копирования ни разу, он должен выдать ошибку компиляции, если такого конструктора нет. Т.е. если мы вместо имеющегося конструктора копирования напишем такой: SomeClass(const SomeClass&) = delete, то программа не соберётся даже тогда, когда компиляторы могут вполне законно от копирования избавиться — конструктор всё равно должен быть.

Ну и наконец момент третий: перемещение. Если компилятор может опустить копирование, то может опустить и перемещение. Т.е. в этом плане они абсолютно эквивалентны. В связи с этим, кстати, связана одна интересная ситуация. Многие программисты (вывод о многих делаю на основании того кода, что я видел в сети) не совсем понимают семантику перемещения и пишут код сродни этому: return std::move(someObject). С виду код абсолютно безобидный и работает как того ожидает написавший его, только вот такой код гарантировано отключает ОИВЗ. Как по вашему, что лучше: выполнить один дешёвый перемещающий конструктор, или вообще ничего не выполнять?

Новая реальность

Теперь пришла пора рассмотреть, что же такого изменилось в C++17 касательно ПК. Все изменения, частью которых является и то, что мы будем обсуждать в этом разделе, можно обнаружить в оригинальном предложении P0135R1. Если вы загляните в этот документ, то увидите, что в нём описаны многочисленные правки стандарта в части категории выражений (в большей степени prvalue), а также различные правки уточняющие где нужно явно выполнять прямую (direct-) и копирующую (copy-) инициализации (initialization). В контексте данного раздела, наиболее интересным для нас является изменение, которое описано в stmt.return/p2.

Итак, согласно вышеупомянутому нововведению, возврат из функции временного безымянного объекта того же типа (т.е. не требуется конвертация), что и возвращаемый тип функции, выполняет копирующую инициализацию результата (что, согласно dcl.init/p(17.6.1) позволяет пропустить копирование). Написанное в предложение выше является, в сущности, той же ОВЗ, только в этот раз обязательной. Т.е. если в C++14 компилятор мог избавиться от копирования/перемещения в таком случае, то теперь он обязан это сделать. Что нам это даёт, ведь мы уже видели, что компилятор и сам прекрасно справляется? А даёт нам это следующее, имея такой код:

SomeClass meReturn()
{
    return SomeClass{};
}

Мы можем вообще не иметь копирующего и перемещающего конструкторов, и это всё равно будет компилироваться. Важно заметить, что и��менился только случай, когда из временного безымянного объекта создаётся другой объект, если же мы возвращаем именованный объект (ОИВЗ), то даже если компилятор и может пропустить копирование, наличие соответствующего конструктора обязательно.

Есть ещё один момент, который уже связан с передачей аргументов, а не возвращаемым значением. Если у нас есть вот такой код:

void meAccept([[maybe_unused]] SomeClass s)
{  
}

То при вызове функции meAccept(SomeClass{}) тоже не будет никакого копирования и это снова больше не оптимизация, а требование стандарта. Это происходит из-за изменений в определении prvalue (basic.lval) и тем, что за собой это изменение влечёт. Давайте разберём эту строчку: meAccept(SomeClass{}). Если говорить в терминах старого prvalue, то SomeClass{} является временным объектом, который затем копируется в параметр функции. Но новое определение prvalue заключается в том, что это больше не объект, но выражение, вычисление которого является инициализацией объекта. Что это значит для нас? Это значит, что в рассматриваемом нами выражении, SomeClass{} является не временным объектом, а выражением инициализации параметра функции. Здесь включается уже упомянутое нами ранее правило описанное в dcl.init/p(17.6.1), и никакого копирования не происходит — инициализация выполняется напрямую.

На первый взгляд это довольно незначительное нововведение, ведь раньше происходило всё то же самое, просто компиляторы были не обязаны этого делать. Тем не менее, это нововведение изменило саму суть понятия prvalue, поэтому незначительным его считать не стоит. Да и с чисто практической точки зрения знать об этом изменении нужно, ведь при изучении языка мы познаём его эмпирически, и в этом процессе очень часто встречаются эксперименты с копирующим/перемещающим конструкторами. Так вот, начиная с C++17 вы никаким образом не можете заставить компилятор сделать копию в ранее описанных примерах. Не помогут никакие флаги, если программа скомпилирована для C++17, и компилятор его действительно поддерживает. Что же касается повседневного кода, то данное нововведение позволяет создавать функции-фабрики, возвращающие объекты, которые не имеют конструкторов копирования/перемещения. Насколько это нужно? Время покажет.

Лямбды

Комитет продолжает показывать лямбдам свою любовь, добавляю к ним что-то новое в каждой новой редакции стандарта. 2017 год не стал исключением, и лямбды получили свою порцию нововведений. Хотя я продолжаю ждать короткого синтаксиса (наподобие C#-ного x => x) и считаю новшества этого стандарта незначительными, обойти их стороной я всё же не могу.

Захватывая this

Итак, нововведение первое. Теперь в список захвата можно передавать копию объекта с помощью указателя this. До C++17, если мы хотели передать копию текущего объекта в лямбду, мы были вынуждены писать что-то такое:

#include <iostream>

using namespace std;

class SomeClass
{
public:
    SomeClass(size_t value):
        m_Value{value}
    {
    }
    
    void someMethod()
    {
        auto lambda = [_this = *this]
        {
            for(size_t i = 0; i < _this.m_Value; ++i)
                cout << "This is lambda!!!\n";
        };
        lambda();
    }
private:
    size_t m_Value;
};


int main()
{
    SomeClass some{3};
    some.someMethod();
};

Основным недостатком подобного подхода является необходимость явного указания имени объекта, в который мы скопировали *this, при каждом обращении к оному. C++17 исправляет данный недостаток, позволяя писать так:

auto lambda = [*this]
{
    for(size_t i = 0; i < m_Value; ++i)
        cout << "This is lambda!!!\n";
};

Т.е. доступ к членам объекта осуществляется ровно так же, как если бы мы создавали лямбду с таким списком захвата [this], но при это в лямбду передаётся не текущий объект (т.е. this-указатель), а его копия. Хочу отметить, что мне подобный код писать не приходилось, поэтому оценить полезность нововведения мне сложно, но кому-то явно станет жить легче. Мне остаётся лишь порадоваться за них и перейти к следующему нововведению.

Нужно больше константности!

Ещё одно изменение, которое напрашивалось давно, это добавление возможности использования лямбд в константных выражениях. Разумеется, такие лямбды тоже должны быть константными. Например:

auto eleven = [] { return 11; };
array<int, eleven()> arr;

Как вы видите, в определении лямбды ничего не изменилось, но её вызов использован в контексте, где обязательно использование константы времени компиляции. Т.к. этот код успешно компилируется, любой внимательный программист может сделать следующий вывод: operator() класса, сгенерированного из лямбды, является constexpr членом и этот вывод, без сомнения, верный. Начиная с C++17, все лямбда выражения по умолчанию являются constexpr, тогда как до C++17 они были просто const. Но они будут низведены до const, если тело лямбда функции не соответствует хотя бы одному критерию, которым подчиняются все constexpr функции (критерии описаны в dcl.constexpr). Внесём минимальное изменение в наш код, и лямбда перестанет быть constexpr:

auto eleven = [] { int x; return 11; };

С такой лямбдой код создания массива даст ошибку компиляции (чего мы, собственно, и добивались), но само создание лямбды ошибки не даст. Однако, мы можем закрутить гайки и потребовать от лямбды иметь тело, подчиняющееся вышеупомянутым правилам:

auto eleven = []() constexpr { int x; return 11; };

Обратите внимание, что нам пришлось добавить как очевидный constexpr, так и (), который не несёт никакой функциональной нагрузки и служит лишь только прихотям стандарта. Таким образом, мы можем создавать лямбда функции, которые гарантировано можно использовать в constexpr контексте.

Это изменение давно напрашивалось, и не должно стать ни для кого неожиданностью: простые функции могут быть constexpr, функции-члены — тоже, чем лямбды хуже? Насколько нужны constexpr лямбды? Это уже вопрос поинтереснее. Думаю, что они нужны constexpr коду настолько же, насколько они нужны простому коду. Сейчас в C++ наблюдается бум constexpr: люди соревнуются кто пойдёт дальше в переносе работы из времени выполнения, во время компиляции.

Доходят до написания парсера JSON и даже до исполнения регулярных выражений (кому интересно, посмотрите видео с CppCon2017: «constexpr ALL the Things!»). Кроме того, всё больше стандартных (и не очень) алгоритмов становятся constexpr, что порождает самое очевидное использование лямбд, ведь они просто созданы для алгоритмов. Поэтому, на мой взгляд, добавление constexpr это хороший шаг вперёд, который позволит писать больше кода, который будет исполняться во время компиляции.

С другой стороны, действительно ли нам нужно так много всего переносить на этап компиляции? Безусловно, когда что-то можно перенести с многократного динамического исполнения, на однократное исполнение во время компиляции — это несомненный плюс. Или нет? Это зависит от задачи и выгоды, что мы получаем во время исполнения. Пусть мы написали парсер JSON, который потребляет массу ОЗУ и увеличивает время компиляции (посмотрите хотя бы последние 3 минуты вышеупомянутого видео), что нам это даёт? Да, теперь мы можем разобрать конфигурацию во время компиляции и использовать её в коде. Но ведь мы могли сделать это и раньше, не используя JSON, и это тоже была бы нулевая нагрузка на время исполнения (просто набор флагов в заголовке, к примеру). Это напоминает мне бородатый анекдот:

Два друга встречаются:

— Я тут слышал, ты машину купил?

— Да! И как раньше жил! Теперь все успеваю! Вчера за день успел сменить масло, купить новые шины, съездил на авторынок за крыльями, тут же смотался в автосервис и поменял их, еще и в магазин за незамерзайкой заехал. И как бы я все это без машины успел?!

Мне могут возразить, мол, JSON удобнее. Пусть так. Тогда давайте добавим скрипт на том же Python (или вообще CMake), который будет нам генерировать объект конфигурации из JSON. Да, нам придётся добавить ещё один шаг к сборке нашего проекта, но разве это сложнее написания кода на C++, который разбирает JSON? Да и время компиляции никто не отменял (и я считаю эту причину куда более существенной): если код будет компилироваться долго, тогда разработка превратится в ад. Поэтому я совершенно не вижу смысла в переносе сложных вещей на constexpr рельсы. На мой взгляд, это лишнее усложнение, которое можно показывать на конференциях, но совершенно не нужно в реальном коде. Использование вычислений во время компиляции должно быть обосновано, а не просто потому что «мы теперь можем!».

Два последних абзаца могут дать неверное представление о моём отношении к этому нововведению: я не против него, я просто против забива��ия гвоздей микроскопом, вот и всё. Пример последнего хорошо виден в видео с CppCon, но само появление constexpr лямбд это, безусловно, хорошая новость, ведь лямбды, функционально, не должны ни чем отличаться от обычных функций — у них должны быть все те же возможности, и, если память мне не изменяет, осталось добавить только одно: именованные шаблонные параметры для лямбд. Ждём их в C++20?


Остальные статьи цикла:

Часть 1. Свёртка и выведение

Часть 2. Constexpr и привязки

Часть 4. Сборная солянка