lambda, auto, decltype – Дубль два или auto наступает

Прошло уже 3 года с тех пор, как был принят стандарт, известный в народе как C++11. Много нового было представлено в нём, но что еще более важно – новый стандарт привлёк немалое число людей в стан C++. Всё это вылилось в увеличение числа людей, которые работают над новшествами в C++, как в части самого языка, так и его библиотеки. Одной из вех на пути к обновленному C++ должен стать еще один стандарт, который должен быть принят в этом году(насколько я знаю, этому нет никаких препятствий). И, хотя стандарт ещё не принят, я считаю, что о некоторых его частях говорить можно уже сейчас, т.к. никаких других изменений они получить уже не должны. Так, продолжая старую статью о lamda, auto и decltype, в этой заметке, я бы хотел представить вам те изменения, которые ждут нас в C++14. А изменения эти весьма приятны.

Обновлённая лямбда

За прошедшие 3 года лямбда плотно вошла в обиход C++-программистов. Я, к примеру, не могу больше представить себе как можно писать C++-код без лямбд и когда приходится писать код на старом-добром C++03,- я всегда использую Boost.LocalFunction. Используя лямбды, помимо восторга, приходило и понимание, что их синтаксис несколько перегружен, что некоторые вещи можно было бы сделать проще и т.п. В целом, эти разговоры появились ещё до выхода C++11, но никаких изменений в C++11 не последовало – правильно, нужно было уже выпускать стандарт, тянуть дальше не было никакого смысла. Поэтому, соединив разговоры ходившие до принятия C++11, с отзывами тех кто активно использовал лямбду после его выхода – мы получили новую, обобщенную лямбду, т.е. лямбду тип аргумента которой на этапе создания оной не известен. Вот как это выглядит:

auto print = [](auto value)
{
    std::cout << value << "\n"; 
};

Как кто-то удачно пошутил, C++ уже близок к “идеальной” конструкции:

auto auto = [](auto auto)
{
    auto;
};

Чтобы было легче понять, как работает эта, обобщенная, лямбда вспомним, что компилятор преобразует лямбду в простой класс-функтор. С обобщенной лямбдой всё абсолютно точно так же, просто класс-функтор имеет шаблонный operator(). Вот как может выглядеть, сгенерированный компилятором, класс для нашей лямбды:

class GenereicLambdaClass
{
public:
    template<typename T>
    auto operator()(T value) const
    {
        std::cout << value << "\n";
    }
};

Как вы видите всё довольно просто. Т.к. auto в обобщенной лямбде это просто некий тип(параметр шаблона) не известный до момента вызова, то к auto могут быть применены все квалификаторы, которые применимы к любым другим типам: const, volatile, &, &&, * и т.д. Кроме того, количество auto в аргументах лямбды не ограничено:

auto print = [](auto&& first, const auto& second, volatile auto third)
{
    std::cout << first << second << third << "\n";
};
print(1, 2, 3);

Где каждый новый аргумент auto добавляет новый параметр шаблона в operator().

Кром�� того, никто не запрещает смешивать auto с не-auto аргументами:

auto print = [](auto&& first, double second, volatile auto third)
{
    std::cout << first << second << third << "\n";
};
print(1, 2.5, 3);

В целом, обобщенная лямбда должна быть интуитивна понятна и никаких проблем с её использованием возникнуть не должно. Единственным ограничением выступает тот факт, что вы не можете сохранить обобщённую лямбду для дальнейшего использования, не потеряв свойства обобщенности(универсальности). Это вполне логично и понятно – шаблоны это сущности этапа компиляции и их тип всегда должен быть известен и выставлен явно(либо выведен из аргументов). Нет никакой возможности создать объект, в который может быть погружена обобщенная лямбда без использования других шаблонов. Это несколько сужает круг её использования.

К примеру, мы можем сохранить нашу обобщенную лямбду следующим образом:

std::function<void(int)> print = [](auto value)
{
    std::cout << value << "\n";
};
print(1);
print("one");// Ошибка!

Здесь происходит инстанциация шаблона(лямбды) c auto = int. После этого не может быть и речи об использовании print с каким-либо типом, который не может быть конвертирован в int. Таким образом мы теряем свойство универсальности.

Другое дело, что auto в аргументах лямбды, в большинстве случаев, будет использоваться чтобы меньше писать, а не потому, что нам реально нужна универсальная лямбда. А с этим новая лямбда справляется почти великолепно. Таким образом, комитет по стандартизации сделал ещё один шаг, по направлению к пользователю, упростив создание лямбд. Правда, им есть куда расти и я, всё же, надеюсь мы сможем дойти до синтаксиса следующего вида:

[](value)
{
    std::cout << value << "n";
}

Где value имеет тип auto&&. Можно еще подумать об избавлении от фигурных скобок для однострочных лямбд, и отказа от [] если нам нечего захватывать – но это уже маловероятно. Увидим ли мы такое в C++? Посмотрим, ведь C++ сейчас развивается поистине семимильными шагами. По крайней мере я надеюсь, что писанину в лямбде сведут к абсолютно возможному минимуму.

На введении синтаксиса обобщённой лямбды комитет не остановился, и добавил еще одно новшество

Новый список захвата

Я думаю, что многие, кто активно использовал C++11 в последние годы сталкивался с тем, что ему необходимо передать в лямбду объект, который не имеет оператора копирования, но, в то же время, имеет оператор перемещения. В интернете вы может найти несколько решений этой проблемы, но все они, на мой взгляд, это простые костыли, которые люди используют из-за несовершенства C++. К примеру, я очень часто нуждался в передаче std::promise в лямбду, но т.к. такой возможности не было, мне приходилось оборачивать promise в std::shared_ptr.

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

auto print = [value = 1]
{
    std::cout << value << "\n";
};

Если псевдокодом, то код выше означает следующее:

auto print = [auto value = 1]
{
    std::cout << value << "\n";
};

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

//...
std::promise<void> completionPromise;
auto future = completionPromise.get_future();
auto asyncAction = [promise = std::move(completionPromise)]
{
    promise.set_value();
};
//...

А что нового в auto и decltype?

Изменений в auto в новом стандарте ровно ноль, и вправду, что там менять? Это настолько самодостаточный и простой примитив, что к нему добавить уже нечего. Зато его добавить можно много куда ещё. Как мы уже видели, auto пробралось в аргументы лямбда-функций. Другим местом, куда auto протянуло свои цепкие пальцы являются функции(всех типов). Ранее мы уже видели auto вместо типа возвращаемого значения функции, но существовало и ограничение: если auto использовалось в качестве типа возвращаемого значения, тогда всё-равно необходимо было указывать результирующий тип, но в другом месте сигнатуры функции:

auto increaseCounter() -> int
{
    static int i = 0;
    return i;
}

Начиная с C++14 мы получаем унифицированный подход, который распространяется на все виды функций(включая лямбды): больше нет нужды указывать –> return_type. Поэтому следующий код полностью правомочен и самодостаточен:

auto increaseCounter()
{
    static int i = 0;
    return i;
}

Это, в частности, снимает ограничение с лямбда-функций,- теперь компилятор обязан самостоятельно выводить тип возвращаемого значения, даже если лямбда состоит не только из одного return. Хотя это и мало кто заметит, т.к. насколько мне известно этого ограничения не было ни в одном популярном компиляторе. Это как раз тот случай, когда разработчики компиляторов сделали благо, отклонившись от буквы стандарта.

Но кроме линейного использования функций есть еще и рекурсивное. Как совместить auto, в качестве возвращаемого значения, с рекурсией?

В этом случае вводится дополнительное условие, которое: во-первых, абсолютно логично, во-вторых, его можно было бы и не вводить, т.к. по другому рабочую функцию всё равно не написать. Это условия звучит так: при использовании auto в качестве типа возвращаемого значения, функция должна содержать return, который не содержит рекурсивного вызова.

Т.е. следующий код некорректен:

auto factorial(int number)
{
    return factorial(number - 1)*number;
}

Но этот код был бы некорректен и с явным указанием типа возвращаемого значения, он просто бы упал во время исполнения. С auto дела обстоят лучше – компилятор не даст вам добраться до падения, он предупредит вас заранее.

Правильный вариант, естественно, должен содержать return с каким-то выражением, из которого компилятор сможет вывести тип возвращаемого значения:

auto factorial(int number)
{
    if(number == 1)
        return number;
    return factorial(number - 1)*number;
}

Разумеется, что функция, которая возвращает 2(или более) объекта совершенно разных типов, которые не могут быть сведены к некому единому,- не будет скомпилирована успешно.

Хочется надеяться, что с появлением такой возможности, люди не начнут лепить auto просто потому, что им лень написать std::string или long long, к примеру. Всё таки нужно различать, когда стоит использовать auto, а когда нет. В любом случае, можно сказать спасибо комитету за упрощения правил – это действительно доставляет удобства в некоторых ситуациях(как например очень длинное имя типа, со всеми квалификаторами)

decltype

Удивительное дело, но изменения связанные с decltype тоже завязаны на auto! И прежде чем рассмотреть, что же нового здесь ввели, давайте рассмотрим какую проблему необходимо было решить: Т.к правила для auto, практически идентичны правилам для параметров шаблона, то auto всегда “превращаетс��” в тип без const/volatile и ссылочных модификаторов &[&]. Таким образом определяя auto something, вы всегда лишались константности и “ссылочности”. С другой стороны, как и шаблоны, auto&& всегда даёт ссылочный тип в результате. Правила для decltype несколько иные и decltype всегда даёт результат в виде типа переданного ему в качестве аргумента(слишком грубо, конечно, но главное тут, что он не “обрезает” типы, в отличии от auto) . Дабы исправить недостаток auto ввели новую возможность вывода типа: decltype(auto).

Зачем это нужно? Я вижу только одно назначение: меньше писанины. Если у вас есть какое-то длинное выражение, тип которого вы бы хотели сохранить в точности, то быстрее написать decltype(auto), чем decltype(FancyNamespace::Namespace::getMeThisFuncyObjectFromTheStore() + getAnotherFancyObjectFromTheStore()). В тексте предложения данной функциональности в стандарт, указывается, что это необходимо для функций-передатчиков(forwarders), хотя лично я не вижу почему auto&& не достаточно, для совершенной передачи(perfect forwarding)

 

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