В C++20 нужно сохранить все аргументы шаблонной функции внутри одной лямбды. Как разворачивается пакет в in...

В C++20 нужно сохранить все аргументы шаблонной функции внутри одной лямбды. Как разворачивается пакет в init-capture и что напечатает программа?

#include <iostream>
#include <utility>

template<class... Ts>
void print(Ts&&... xs) {
    auto lambda = [...ys = std::forward<Ts>(xs)] {
        return (ys + ...);
    };
    std::cout << lambda();
}

int main() {
    print(1, 2, 3);
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Программа напечатает 6. Запись [...ys = std::forward<Ts>(xs)] создаёт отдельный захват ys для каждого элемента пакета xs, поэтому лямбда хранит три значения: 1, 2 и 3.

Затем выражение (ys + ...) разворачивается в свёртку ys1 + (ys2 + ys3). Механизм разворачивания пакета в init-capture доступен начиная с C++20.

Исторический контекст

До C++20 пакет параметров нельзя было непосредственно развернуть в списке init-capture лямбды. Для сохранения набора аргументов обычно создавали вспомогательный объект, например std::tuple, а затем обращались к его элементам через std::index_sequence.

C++20 добавил специальную форму pack expansion для init-capture. Она устраняет вспомогательную обвязку и позволяет непосредственно сформировать набор полей объекта-замыкания.

Постановка проблемы

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

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

Подробное решение

В конструкции [...ys = std::forward<Ts>(xs)] многоточие означает разворачивание init-capture. Компилятор логически получает набор захватов, эквивалентный примерно следующему:

[ys1 = std::forward<T1>(x1), ys2 = std::forward<T2>(x2), ys3 = std::forward<T3>(x3)] { /* тело */ }

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

Для вызова print(1, 2, 3) пакет содержит три элемента, поэтому лямбда хранит копии или перемещённые значения аргументов. В данном случае все элементы имеют тип int, а результат свёртки равен 1 + (2 + 3), то есть 6.

std::forward<Ts>(xs) сохраняет категорию каждого исходного аргумента при инициализации захвата. Однако сами ys — это поля лямбды, обычно хранящие значения. Поэтому сохранение rvalue не означает, что лямбда хранит ссылку на временный объект.

У выражения (ys + ...) нет нейтрального значения для пустого пакета. Поэтому вызов print() в таком варианте некорректен: свёртка с оператором + без начального значения требует непустой пакет. Если пустой случай допустим, нужно использовать двустороннюю свёртку с начальным значением, например (0 + ... + ys).

Другой важный предел — время жизни ссылок. Если вместо копирующего init-capture использовать захват ссылкой, лямбда может обратиться к уже уничтоженным объектам. Для отложенного вызова безопаснее сохранять значения, особенно когда исходные аргументы являются временными объектами.

Ситуация из практики

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

Первый вариант — сохранить аргументы в std::tuple. Он универсален и позволяет обращаться к элементам по индексам, но требует дополнительной логики с std::index_sequence, а также усложняет чтение и диагностику ошибок.

Второй вариант — использовать C++20 init-capture с разворачиванием пакета. Он непосредственно формирует поля замыкания, не требует индексов и хорошо показывает намерение: каждый аргумент становится отдельным сохранённым состоянием лямбды.

Для проекта на C++20 выбран второй вариант. Он уменьшил количество вспомогательного кода и сохранил значения до отложенного вызова. Если проект должен собираться на C++17, пришлось бы использовать tuple либо отдельный вспомогательный объект.

Что кандидаты часто упускают

  1. Что произойдёт при вызове print() без аргументов?

    Такой вызов не скомпилируется из-за выражения (ys + ...). Это унарная правая свёртка, а для оператора + язык не определяет значение по умолчанию для пустого пакета. Для поддержки пустого случая применяют свёртку с явным начальным значением, например (0 + ... + ys), если типы действительно совместимы с 0.

  2. Почему сохранение через init-capture не продлевает жизнь исходной ссылки?

    Вариант ys = std::forward<Ts>(xs) инициализирует поля лямбды значениями. Для rvalue объект обычно перемещается, а для lvalue копируется, если тип копируемый. Поэтому лямбда владеет сохранёнными объектами независимо от времени жизни исходных аргументов.

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

  3. Можно ли сохранить в таком захвате некопируемый тип?

    Да, если аргумент передан как временный объект и выражение инициализации корректно перемещает его в поле лямбды. Например, std::unique_ptr<int> можно переместить через std::forward<Ts>(xs).

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