Каррирование и частичное применение в C++: владение, время жизни и категории значений

Носителям прекрасного языка программирования C++ тема частичного применения может показаться скучной. Что примечательного в пяти строчках кода? Но дьявол кроется в деталях: я часто встречал реализации, которые работали не так, как ожидали их авторы. Иногда дело доходило до undefined behavior. Поэтому здесь хочется разобраться прежде всего с владением, временем жизни и категориями значений.

Код будет на C++23. В конце нам пригодится explicit object parameter. Для самой идеи C++23 не обязателен.

Сразу договоримся о терминах. Частичное применение фиксирует часть аргументов функции. Каррирование в строгом смысле преобразует функцию нескольких аргументов в цепочку функций одного аргумента. Здесь мы реализуем именно частичное применение, хотя функцию назовём carry. Исходный вызываемый объект будем называть функтором, а возвращаемую обёртку - карри.

Примеры ниже развивают одну реализацию: очередное определение carry заменяет предыдущее. Для краткости не будем повторять заголовки и main в каждом листинге. Нам понадобятся:

#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <tuple>
#include <type_traits>
#include <utility>

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

template <typename Functor, typename... CarriedArgs>
auto carry_bad(Functor&& functor, CarriedArgs&&... carried_args) {
  return
      [functor = std::forward_as_tuple(std::forward<Functor>(functor)),
       carried_args_as_tuple = std::forward_as_tuple(std::forward<CarriedArgs>(
           carried_args)...)](auto&&... remaining_args) mutable {
        auto remaining_args_as_tuple = std::forward_as_tuple(
            std::forward<decltype(remaining_args)>(remaining_args)...);
        auto total_args = std::tuple_cat(std::move(carried_args_as_tuple),
                                         std::move(remaining_args_as_tuple));
        return std::apply(std::get<0>(functor), total_args);
      };
}

auto carried = carry_bad([](int a, int b) { return a + b; }, 5);
std::cout << carried(7) << '\n';  // UB

Автор ожидал, что временные объекты сохранятся внутри карри. На деле сохранились только ссылки. Я не буду объяснять, что происходит в коде выше. По ходу статьи всё станет ясно.

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

template <typename Functor, typename... CarriedArgs>
auto carry(Functor&& functor, CarriedArgs&&... carried_args);

Сначала решим более простую задачу: все аргументы уже переданы, надо только вызвать функтор. Назовём эту функцию invoke.

В первом приближении можно написать что-то такое:

template <typename Functor, typename... CarriedArgs>
auto invoke(Functor&& functor, CarriedArgs&&... carried_args) {
  return functor(std::forward<CarriedArgs>(carried_args)...);
}

Аргументы мы форвардим, а функтор нет? Надо строго понимать, почему это может быть важно. Например:

struct ValueCategoryBasedFunctor {
  bool operator()() & { return false; }
  bool operator()() && { return true; }
};

ValueCategoryBasedFunctor functor;
std::cout << invoke(std::move(functor)) << '\n';  // 0

Чтобы вызвать функтор с учётом категории выражения, переданного в invoke, исправим выражение вызова:

return std::forward<Functor>(functor)(
    std::forward<CarriedArgs>(carried_args)...);
std::cout << invoke(std::move(functor)) << '\n';  // 1

Будем считать это поведение ожидаемым (спойлер, это может быть не так).

Осталась проблема с результатом. Пусть функтор возвращает ссылку:

int x = 0;
auto get_x = [&x]() -> int& { return x; };

auto&& result = invoke(get_x);
result = 5;
std::cout << x << '\n';  // 0: invoke пока возвращает int

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

Нам нужен decltype(auto): для возвращаемого выражения применяются правила decltype, и вызов функции, возвращающей int&, сохраняет этот тип. Формальные правила находятся в выводе placeholder-типов и описании decltype.

template <typename Functor, typename... CarriedArgs>
decltype(auto) invoke(Functor&& functor, CarriedArgs&&... carried_args) {
  return std::forward<Functor>(functor)(
      std::forward<CarriedArgs>(carried_args)...);
}
invoke(get_x) = 5;
std::cout << x << '\n';  // 5

Вернёмся к частичному применению и функции carry.

template <typename Functor, typename... CarriedArgs>
auto carry(Functor&& functor, CarriedArgs&&... carried_args);

Мы хотим уметь делать примерно такие вещи:

auto functor = [](auto, auto) { /* ... */ };
auto carried = carry(functor, x);
// Позже вызываем функтор:
carried(y);

Значит, возвращать надо функтор:

  return [](){};

Функтор принимает оставшиеся аргументы:

  return [](auto&&... remaining_args){};

Мне больше нравится явное указание типа, поэтому:

  return []<typename... RemainingArgs>(RemainingArgs&&... remaining_args){};

В теле карри должен будет вызываться функтор. Пока мы не будем думать и напишем просто амперсанд:

  return [&]<typename... RemainingArgs>(RemainingArgs&&... remaining_args){};

Осталось воспользоваться выражением вызова из только что написанного invoke:

  return
      [&]<typename... RemainingArgs>(RemainingArgs&&... remaining_args) -> decltype(auto) {
        return std::forward<Functor>(functor)(
            std::forward<CarriedArgs>(carried_args)...,
            std::forward<RemainingArgs>(remaining_args)...);
      };

Итого имеем:

template <typename Functor, typename... CarriedArgs>
auto carry(Functor&& functor, CarriedArgs&&... carried_args) {
  return
      [&]<typename... RemainingArgs>(RemainingArgs&&... remaining_args) -> decltype(auto) {
        return std::forward<Functor>(functor)(
            std::forward<CarriedArgs>(carried_args)...,
            std::forward<RemainingArgs>(remaining_args)...);
      };
}

Теперь нужно определиться с “зоной ответственности” нашего карри. С живущими достаточно долго объектами всё работает:

int x = 42;
int y = 14;
auto functor = [](int a, int b) { return a + b; };
auto carried = carry(functor, x);

std::cout << carried(y) << '\n';  // 56

Здесь карри не владеет ни функтором, ни x. Вызывающая сторона должна обеспечить их существование при каждом обращении к ним из карри.

Далее некоторые простые нарушения этого правила. Заменим x литералом:

auto functor = [](int a, int b) { return a + b; };
auto carried = carry(functor, 5);  // Временный int уничтожается в конце строки
std::cout << carried(14) << '\n';  // UB: чтение через висячую ссылку

Для привязки ссылочного параметра материализуется временный int. В конце полного выражения, инициализирующего carried, этот объект уничтожается. Сам карри остаётся, но сохранённая в нём ссылка уже висит: при вызове carried(14) мы обращаемся к int, время жизни которого закончилось. Основание - правило для временного объекта, связанного со ссылочным параметром.

Поэтому немедленный вызов в том же полном выражении корректен:

int result = carry([](int a, int b) { return a + b; }, 5)(6);
std::cout << result << '\n';  // 11

И временная лямбда, и временный int ещё существуют во время вызова.

Lvalue тоже не является гарантией долгой жизни:

auto make_carried() {
  int x = 42;
  auto functor = [](int a, int b) { return a + b; };
  return carry(functor, x);
}

auto carried = make_carried();
// carried(1); // UB: и x, и functor уже уничтожены

Теперь попробуем владеть переданными объектами. Первое желание - заменить [&] на [=]. Потакнём нашему желанию:

  return
      [=]<typename... RemainingArgs>(RemainingArgs&&... remaining_args) -> decltype(auto) {
        return std::forward<Functor>(functor)(
            // Ошибка при CarriedArgs = int: захваченный объект доступен через const
            std::forward<CarriedArgs>(carried_args)...,
            std::forward<RemainingArgs>(remaining_args)...);
      };

Но захваченные по значению объекты доступны в обычной лямбде через константный operator(). Поэтому, например, std::forward<int> уже не сможет принять захваченный int как неконстантный объект. Добавление mutable убирает эту конкретную проблему:

return [=]<typename... RemainingArgs>(
           RemainingArgs&&... remaining_args) mutable -> decltype(auto) {
  return std::forward<Functor>(functor)(
      std::forward<CarriedArgs>(carried_args)...,
      std::forward<RemainingArgs>(remaining_args)...);
};

А теперь вернёмся к нашим проблемам со временем жизни. Даже соберём их вместе:

auto make_carried() {
  std::string str_123{"123"};
  auto functor = [](std::string lhs, std::string rhs) { return lhs + rhs; };
  return carry(functor, str_123, std::string{"456"});
}

auto carried = make_carried();
std::cout << carried() << '\n';  // 123456

Локальная строка уничтожена, временная строка уничтожена, сам functor тоже. А карри работает: теперь у него свои копии всех этих объектов.

Со временем жизни разобрались. А что, если вызвать этот карри ещё раз?

std::string str_123{"123"};
auto functor = [](std::string lhs, std::string rhs) { return lhs + rhs; };
auto carried = carry(functor, str_123, std::string{"456"});
std::cout << carried() << ' ' << carried() << '\n';  // В этом запуске: 123456 123

Сохранённая копия str_123 при каждом вызове копируется в lhs, а сохранённая строка "456" перемещается в rhs. В этом запуске после первого перемещения она оказалась пустой. Стандарт гарантирует только корректное, но не определённое заранее состояние. Повторный вызов допустим, но прежнее содержимое сохранённой строки не гарантируется.

Вопрос в том, ожидаемый ли это результат? Хотим ли мы переиспользовать наше каррирование или нет? Этот вопрос актуален для всех версий каррирования (и для версии через амперсанд тоже). В данном случае у нас такая политика: как нам дали, так мы и применили. Если мы хотим повторно использовать сохранённые значения, нужно отдельно решить, разрешаем ли функтору перемещать из них при вызове. К этому мы ещё вернёмся.

У захвата через [=] остаётся ещё одна проблема: мы сохраняем всё по значению через copy-конструкторы, очевидно, что это не самая оптимальная стратегия. Всё-таки хочется сохранять по значению не только через copy-конструкторы, но и через move-конструкторы. Воспользуемся init-capture пакета, появившимся в C++20, с явным форвардингом:

template <typename Functor, typename... CarriedArgs>
auto carry(Functor&& functor, CarriedArgs&&... carried_args) {
  return [functor = std::forward<Functor>(functor),
          ... carried_args =
              std::forward<CarriedArgs>(carried_args)]<typename... RemainingArgs>(
             RemainingArgs&&... remaining_args) mutable -> decltype(auto) {
    return std::forward<Functor>(functor)(
        std::forward<CarriedArgs>(carried_args)...,
        std::forward<RemainingArgs>(remaining_args)...);
  };
}

Init-capture без & выводит тип по правилам auto. Верхнеуровневые ссылки и cv-квалификаторы не сохраняются, массив может превратиться в указатель, функция - в указатель на функцию. Формулировка находится в описании init-capture.

Мы добавили возможность перемещать объекты в замыкание, но заодно изменили правила вывода типов. Посмотрим на массив:

int values[]{1, 2, 3};
auto functor = [](const int* values) { return values[0]; };

auto carried = carry(functor, values);  // Ошибка компиляции уже при создании карри

Для массива соответствующий CarriedArgs вывелся как int (&)[3]. В выражении захвата std::forward<CarriedArgs>(carried_args) ещё работает с исходным массивом и возвращает ссылку на него. Но дальше тип сохраняемого объекта выводится по правилам auto, и внутри замыкания оказывается int*.

В теле лямбды имя carried_args обозначает уже сохранённый объект, а CarriedArgs остаётся исходным типом. Если расписать это для одного аргумента, получится:

using OriginalType = int (&)[3];

auto stored = std::forward<OriginalType>(values);  // int*
// std::forward<OriginalType>(stored);  // Ошибка: указатель вместо массива

При обычном захвате [=] этот пример работал: замыкание хранило собственную копию массива, элементы которой инициализировались поэлементно. Это предусмотрено правилами захвата по значению. Init-capture изменил тип хранилища, а выражение передачи сохранённого объекта мы оставили прежним.

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

Уточню: владение значением также не означает владения всем, на что это значение ссылается. Копия string_view, указателя или лямбды со ссылочными захватами сохраняет внешние зависимости.

И ещё один долг остался за mutable:

const auto carried = carry([](int a, int b) { return a + b; }, 5);
// carried(6); // Ошибка: у mutable-лямбды неконстантный operator()

Запомним это.

Сначала решим вопрос хранения: что делать, если lvalue хочется оставлять по ссылке, а rvalue сохранять как собственные значения?

Для этого удобно собрать связанные аргументы в кортеж. Захват:

... carried_args = std::forward<CarriedArgs>(carried_args)

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

Кто-то может предложить сконкатенировать кортежи через std::tuple_cat, а потом раскрыть один общий кортеж через std::apply. Мне не хочется создавать промежуточный кортеж только ради вызова функтора. Если связанные аргументы хранятся по значению, это ещё и дополнительное копирование или перемещение в новый кортеж. Хочется сразу передать функтору уже сохранённые аргументы и добавить оставшиеся. Вот здесь std::apply и пригодится.

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

std::apply(std::forward<Functor>(functor), carried_args_as_tuple);

Либо так:

std::apply(std::forward<Functor>(functor), std::move(carried_args_as_tuple));

В чём разница? Давайте посмотрим на контракт std::apply: по существу он раскрывает индексы Is... в такое выражение:

std::invoke(std::forward<Functor>(functor),
            std::get<Is>(std::forward<Tuple>(tuple))...);

Полный контракт приведён в описании std::apply. Категории получаемых аргументов определяются перегрузками std::get и типами элементов.

Для неконстантного rvalue-кортежа std::get возвращает ElementType&&, с учётом reference collapsing: для элемента T& получим T&.

Если кортеж хранит исходные lvalue по ссылке, а rvalue по значению, то категории можно получить прямо через get. Тогда достаточно:

return std::apply(std::forward<Functor>(functor),
                  std::move(carried_args_as_tuple));

У нас, однако, есть ещё оставшиеся аргументы. Вклиним между apply и функтором обёртку: сначала научим её передавать элементы кортежа функтору, а потом добавим оставшуюся пачку. Начнём с заготовки:

std::apply(
    []<typename... LvalueTypes>(LvalueTypes&&... stored_carried_args) {
      // Тут далее что-то делаем с functor
    },
    carried_args_as_tuple);

Пока принимаем их через LvalueTypes&&..., с точной сигнатурой ещё определимся.

Вспоминаем, что ссылочный результат надо сохранить и на этом уровне. Поэтому у обёртки тоже должен быть decltype(auto):

[]<typename... LvalueTypes>(LvalueTypes&&... stored_carried_args)
    -> decltype(auto) {
  // Тут далее что-то делаем с functor
}

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

[&]<typename... LvalueTypes>(LvalueTypes&&... stored_carried_args)
    -> decltype(auto) {
  // Вызываем functor. А что делать со stored_carried_args?
}

Теперь вопрос: как передавать stored_carried_args? Их ссылочные типы зависят от того, какой кортеж мы дали apply. Но сами имена параметров внутри обёртки - lvalue-выражения. Мы можем оставить их как lvalue или привести к xvalue.

Исходные CarriedArgs у нас уже есть. Пока типы сохранённых объектов совместимы с ними, нужные категории можно восстановить через std::forward<CarriedArgs>. Поэтому для выбранного правила передачи нам достаточно передать кортеж как lvalue, а категории выбрать внутри обёртки. Я считаю, что так чище:

std::apply(
    [&]<typename... LvalueTypes>(LvalueTypes&&... stored_carried_args)
        -> decltype(auto) {
      return std::forward<Functor>(functor)(
          std::forward<CarriedArgs>(stored_carried_args)...);
    },
    carried_args_as_tuple);

Поскольку мы передаём кортеж как lvalue, get даст lvalue-ссылки на его элементы. Значит, LvalueTypes&&... несёт слишком много общности: достаточно LvalueTypes&.... А сами типы LvalueTypes в теле мы не используем, поэтому можно сразу написать auto&...:

[&](auto&... stored_carried_args) -> decltype(auto) {
  return std::forward<Functor>(functor)(
      std::forward<CarriedArgs>(stored_carried_args)...);
}

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

return std::apply(
    [&](auto&... stored_carried_args) -> decltype(auto) {
      return std::forward<Functor>(functor)(
          std::forward<CarriedArgs>(stored_carried_args)...,
          std::forward<RemainingArgs>(remaining_args)...);
    },
    carried_args_as_tuple);

Вернёмся к каррированию. Подготовим вызов, а выражение создания кортежа пока оставим незаполненным:

template <typename Functor, typename... CarriedArgs>
auto carry(Functor&& functor, CarriedArgs&&... carried_args) {
  return [functor = std::forward<Functor>(functor),
          carried_args_as_tuple = /* выберем ниже */]<typename... RemainingArgs>(
             RemainingArgs&&... remaining_args) mutable -> decltype(auto) {
    return std::apply(
        [&](auto&... stored_carried_args) -> decltype(auto) {
          return std::forward<Functor>(functor)(
              std::forward<CarriedArgs>(stored_carried_args)...,
              std::forward<RemainingArgs>(remaining_args)...);
        },
        carried_args_as_tuple);
  };
}

Конструкция уже выглядит не самым очевидным образом. Осталась одна неразрешённая проблема: как нам проинициализировать carried_args_as_tuple?

У нас есть три пути:

  1. std::make_tuple;

  2. std::forward_as_tuple;

  3. Самостоятельно написать, какой тип нужен будет кортежу, и проинициализировать его.

Давайте идти сверху вниз. Начнём со std::make_tuple:

carried_args_as_tuple =
    std::make_tuple(std::forward<CarriedArgs>(carried_args)...)

Для обычных объектных аргументов мы снова получаем хранение по значению: lvalue копируются, а rvalue могут перемещаться. То есть по способу хранения связанных аргументов мы вернулись к уже разобранному init-capture. Неплохо мы начали замыкать порочный круг. Даже проблема с массивом остаётся: сохранится указатель, а std::forward<CarriedArgs> в теле по-прежнему будет ожидать массив.

У make_tuple есть существенная оговорка: его тип результата - std::tuple<std::unwrap_ref_decay_t<Args>...>. После преобразования типов по правилам decay он также разворачивает std::reference_wrapper<T> в T&. Это видно из сигнатуры make_tuple.

Следующий вариант - std::forward_as_tuple. Название такое, как будто то, что нам надо:

carried_args_as_tuple =
    std::forward_as_tuple(std::forward<CarriedArgs>(carried_args)...)

Для исходного lvalue получаем элемент T&, для rvalue - T&&. Но T&& здесь тоже ссылка, собственного объекта в кортеже не появилось. По хранению связанных аргументов мы вернулись к знакомой версии с амперсандом: за время жизни объектов опять отвечает тот, кто их передал. Сам функтор в нашей новой схеме по-прежнему сохраняется по значению.

В начале статьи и временный функтор, и временный int были сохранены именно так. После завершения инициализации карри ссылки повисли.

Остался последний, третий вариант - самому написать тип кортежа. Тут нужно заметить простую вещь: типы уже пришли нам такими, какими они нужны для выбранного хранения:

carried_args_as_tuple =
    std::tuple<CarriedArgs...>{std::forward<CarriedArgs>(carried_args)...}

Третий вариант использует уже выведенные CarriedArgs: для lvalue в этом пакете есть &, для rvalue его нет. Конструктор кортежа получает аргументы с восстановленными категориями. В частности, заменить здесь std::forward на безусловный std::move нельзя: элемент T& должен связаться с lvalue, а не с xvalue.

Явные аргументы шаблона у tuple существенны. Например:

int x = 0;
auto carried_args_as_tuple = std::tuple{x};  // std::tuple<int>

При этом CTAD не тождественен make_tuple по вышеупомянутой причине.

Вернёмся к нашему массиву values. Он передан как lvalue, поэтому с таким способом хранения получится:

std::tuple<int (&)[3]> carried_args_as_tuple{values};

Кортеж хранит ссылку на исходный массив. При передаче его элемента std::forward<CarriedArgs> снова получает массив, поэтому прежней ошибки типов здесь нет. Но собственной копии элементов у карри тоже нет: исходный массив должен оставаться живым при обращении к нему из карри. Мы выбрали другой способ хранения; версию с init-capture и указателем это само по себе не исправило.

Теперь можно сравнить, что получилось. В таблице T - объектный тип без верхнеуровневых cv-квалификаторов, не массив и не reference_wrapper:

Выражение для хранения

Элемент для исходного lvalue типа T

Элемент для исходного rvalue типа T

std::make_tuple(std::forward<CarriedArgs>(carried_args)...)

T

T

std::forward_as_tuple(std::forward<CarriedArgs>(carried_args)...)

T&

T&&

std::tuple<CarriedArgs...>{std::forward<CarriedArgs>(carried_args)...}

T&

T

Подставляем третий вариант в оставленное место в списке захвата.

Теперь связанные аргументы хранятся по правилу “lvalue по ссылке, rvalue по значению”.

Проверим оба случая одним примером:

std::string str_123{"123"};
auto functor = [](const std::string& lhs, const std::string& rhs) {
  return lhs + rhs;
};
auto carried = carry(functor, str_123, std::string{"456"});

std::cout << carried() << '\n';  // 123456
str_123 = "789";
std::cout << carried() << '\n';  // 789456

Исходной временной строки уже нет, но её значение хранится внутри карри. А str_123 осталась по ссылке, поэтому при следующем вызове мы видим её новое значение.

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

const auto carried = carry([](int a, int b) { return a + b; }, 5);
// std::cout << carried(6) << '\n'; // Пока ошибка из-за mutable

Для обёртки хотелось бы поддержать оба случая: неконстантный доступ при вызове неконстантного объекта и константный - при вызове константного.

В C++23 можно объявить явный параметр объекта. Меняем начало оператора вызова, убирая mutable:

]<typename... RemainingArgs>(
    this auto&&,
    RemainingArgs&&... remaining_args) -> decltype(auto) {

this auto&& выводится из объекта карри при каждом вызове. Для обычного lvalue-карри получается ссылка на неконстантный объект замыкания, для константного - ссылка на константный. Правила приведены в описании оператора вызова лямбды.

Но одной замены сигнатуры недостаточно. Внутри всё ещё написано std::forward<CarriedArgs>(stored_carried_args). При создании карри из временного std::string соответствующий CarriedArgs вывелся как std::string. При вызове константного карри его собственная строка доступна уже как const std::string&.

Получается следующая ситуация:

const std::string text = "hello";

// std::forward<std::string>(text); // Ошибка: нужен неконстантный аргумент
std::forward<const std::string>(text);   // const std::string&&
std::forward<const std::string&>(text);  // const std::string&
std::move(text);                         // const std::string&&

std::forward<std::string> здесь потребовал бы отбросить const. У move тип выводится из текущего выражения, и константность сохраняется. Это видно из сигнатур и контрактов этих функций.

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

Значит, при вызове нам достаточно знать, был ли исходный аргумент передан как lvalue. Действительный тип объекта и доступную константность возьмём из нового вывода:

template <typename OriginalType, typename LvalueType>
decltype(auto) forward_stored(LvalueType& arg) {
  if constexpr (std::is_lvalue_reference_v<OriginalType>) {
    return (arg);
  } else {
    return std::move(arg);
  }
}

Теперь можем передать и нашу константную строку:

forward_stored<std::string>(text);  // const std::string&&

LvalueType выводится как const std::string. Исходный тип std::string не является lvalue-ссылкой, поэтому выполняется std::move(arg). Для оставшихся аргументов обычный std::forward подходит: их типы только что вывелись в текущем вызове.

Теперь можно вернуться и к ошибке в версии с init-capture:

int values[]{1, 2, 3};
using OriginalType = int (&)[3];
auto stored = std::forward<OriginalType>(values);  // int*

forward_stored<OriginalType>(stored);  // int*&

Здесь OriginalType - ссылка на массив, а LvalueType вывелся как int*. Поэтому в теле прежней лямбды с init-capture можно заменить передачу связанных аргументов:

// Было:
std::forward<CarriedArgs>(carried_args)...
// Стало:
forward_stored<CarriedArgs>(carried_args)...

После этой замены вернёмся к нашему примеру с функтором, принимающим const int*:

auto carried = carry(functor, values);
std::cout << carried() << '\n';  // 1

Массив при этом по-прежнему должен быть жив.

Соберём версию с кортежем и forward_stored целиком:

template <typename Functor, typename... CarriedArgs>
auto carry(Functor&& functor, CarriedArgs&&... carried_args) {
  return [functor = std::forward<Functor>(functor),
          carried_args_as_tuple =
              std::tuple<CarriedArgs...>{std::forward<CarriedArgs>(
                  carried_args)...}]<typename... RemainingArgs>(
             this auto&&, RemainingArgs&&... remaining_args) -> decltype(auto) {
    return std::apply(
        [&](auto&... stored_carried_args) -> decltype(auto) {
          return forward_stored<Functor>(functor)(
              forward_stored<CarriedArgs>(stored_carried_args)...,
              std::forward<RemainingArgs>(remaining_args)...);
        },
        carried_args_as_tuple);
  };
}

Сложение через константный карри теперь работает:

const auto add_five = carry([](int a, int b) { return a + b; }, 5);
std::cout << add_five(6) << '\n';  // 11

Константность здесь поверхностная. Собственное значение в константном кортеже доступно через const, а ссылка на внешний объект не становится ссылкой на константный объект:

int x = 0;
const std::tuple<int, int&> values{42, x};

static_assert(std::is_same_v<decltype(std::get<0>(values)), const int&>);
static_assert(std::is_same_v<decltype(std::get<1>(values)), int&>);

Это также оговорено в контракте std::get. Поэтому следующий код допустим:

int x = 0;
const auto increment = carry([](int& value) { ++value; }, x);
increment();
std::cout << x << '\n';  // 1

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

auto&& result =
    carry([](const std::string& value) -> const std::string& { return value; },
          std::string{"hello"})();

// Здесь result уже висит: временный карри уничтожен в конце инициализации.
// std::cout << result; // UB

Теперь становятся видны два самостоятельных решения, которые раньше были записаны через один и тот же CarriedArgs. Выражение std::tuple<CarriedArgs...> определяет хранение. Вызов forward_stored<CarriedArgs> определяет, в каком виде сохранённый объект получит функтор.

Например, строку можно переместить внутрь карри, а при каждом вызове передавать как lvalue. Или сохранить копию исходного lvalue, но продолжать передавать эту копию как lvalue. Если хранить всё по значению, тип элемента кортежа уже не показывает, как объект был передан первоначально, - вот где отдельное правило передачи особенно полезно.

Это подводит к трём настройкам реализации:

Настройка

Какое решение принимает

storage

Как хранить связанные аргументы: значения, ссылки или значения для rvalue и ссылки для lvalue

call

Как передавать сохранённые объекты: всегда как lvalue или выбирать категорию по исходному аргументу

target

Как хранить сам функтор; к нему можно применить те же варианты хранения

Писать восемнадцать имплементаций мотивации нет (буквально восемнадцать). Выделим эти настройки в отдельные политики. call::as_passed будет соответствовать показанному forward_stored, а call::as_lvalue - возвращать сохранённый объект как lvalue. Политика call будет применяться и к сохранённым аргументам, и к функтору; новые аргументы вызова по-прежнему будем форвардить отдельно.

Передача lvalue сама по себе тоже не гарантирует повторяемость результата или неизменность состояния: функтор может изменить объект через неконстантную ссылку.

Давайте теперь вынесем эти решения из carry. Каждую политику оформим как отдельную структуру со статическим шаблонным методом.

Свободную функцию carry заменим статическим методом policy::carry, а новые определения соберём в пространстве имён carry.

Начнём с хранения. Вместо конкретного конструктора кортежа в списке захвата хочется написать:

carried_args_as_tuple = Storage::template store<CarriedArgs...>(carried_args...)

store принимает аргументы как lvalue, а исходные типы получает явно через OriginalTypes.... LvalueTypes... выводятся из параметров. Форвардинг выполним при создании кортежа.

Первой вынесем ту стратегию, до которой уже дошли: lvalue по ссылке, rvalue по значению.

namespace carry::storage {

struct as_passed {
  template <typename... OriginalTypes, typename... LvalueTypes>
  static auto store(LvalueTypes&... args) {
    return std::tuple<OriginalTypes...>(std::forward<OriginalTypes>(args)...);
  }
};

}  // namespace carry::storage

Теперь хотим всё сохранять по значению. Интерфейс тот же, меняются только типы элементов кортежа:

namespace carry::storage {

struct by_value {
  template <typename... OriginalTypes, typename... LvalueTypes>
  static auto store(LvalueTypes&... args) {
    return std::tuple<std::decay_t<OriginalTypes>...>(
        std::forward<OriginalTypes>(args)...);
  }
};

}  // namespace carry::storage

Здесь намеренно используем decay_t, чтобы сохранить std::reference_wrapper как объект. Если поставить make_tuple, сработает уже разобранное разворачивание в T&, а мы хотим оставить этот выбор явным.

И третий вариант - хранить ссылки:

namespace carry::storage {

struct by_reference {
  template <typename... OriginalTypes, typename... LvalueTypes>
  static auto store(LvalueTypes&... args) {
    return std::forward_as_tuple(std::forward<OriginalTypes>(args)...);
  }
};

}  // namespace carry::storage

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

namespace carry::call {

struct as_passed {
  template <typename OriginalType, typename LvalueType>
  static decltype(auto) forward(LvalueType& arg) {
    if constexpr (std::is_lvalue_reference_v<OriginalType>) {
      return arg;
    } else {
      return std::move(arg);
    }
  }
};

}  // namespace carry::call

Для передачи сохранённых объектов как lvalue добавим call::as_lvalue:

namespace carry::call {

struct as_lvalue {
  template <typename OriginalType, typename LvalueType>
  static decltype(auto) forward(LvalueType& arg) {
    return arg;
  }
};

}  // namespace carry::call

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

namespace carry::target {

using storage::as_passed;
using storage::by_reference;
using storage::by_value;

}  // namespace carry::target

Функтор сохраним в кортеже из одного элемента:

functor = Target::template store<Functor>(functor)

А получать функтор для вызова будем через std::get<0>(functor).

Теперь соберём три настройки в одну конфигурацию. В списке захвата работают Storage и Target, а внутри apply - Call:

namespace carry {

template <typename Storage, typename Call, typename Target>
struct policy {
  template <typename Functor, typename... CarriedArgs>
  static auto carry(Functor&& functor, CarriedArgs&&... carried_args) {
    return
        [functor = Target::template store<Functor>(functor),
         carried_args_as_tuple = Storage::template store<CarriedArgs...>(
             carried_args...)]<typename... RemainingArgs>(
            this auto&&, RemainingArgs&&... remaining_args) -> decltype(auto) {
          return std::apply(
              [&](auto&... stored_carried_args) -> decltype(auto) {
                return Call::template forward<Functor>(std::get<0>(functor))(
                    Call::template forward<CarriedArgs>(stored_carried_args)...,
                    std::forward<RemainingArgs>(remaining_args)...);
              },
              carried_args_as_tuple);
        };
  }
};

}  // namespace carry

Вернёмся к примеру с повторным вызовом. Сохраним строки по значению, а при вызове будем передавать их как lvalue:

using owning = carry::policy<carry::storage::by_value, carry::call::as_lvalue,
                             carry::target::by_value>;

std::string str_123{"123"};
auto functor = [](std::string lhs, std::string rhs) { return lhs + rhs; };
auto carried = owning::carry(functor, str_123, std::string{"456"});

std::cout << carried() << ' ' << carried() << '\n';  // 123456 123456

Функтор принимает строки по значению, поэтому при каждом вызове копирует их из хранилища. Само хранение осталось по значению, а способ передачи мы поменяли.

Поменяем теперь только хранение аргументов:

using mixed = carry::policy<carry::storage::as_passed, carry::call::as_lvalue,
                            carry::target::by_value>;

int x = 10;
auto functor = [](int value, int delta) { return value + delta; };
auto copied = owning::carry(functor, x);
auto referenced = mixed::carry(functor, x);

x = 20;
std::cout << copied(1) << ' ' << referenced(1) << '\n';  // 11 21

В первом случае сохранили копию x, во втором - ссылку.

А для выбора категории у нас уже был подходящий функтор - ValueCategoryBasedFunctor. Он возвращал false при вызове через lvalue и true при вызове через rvalue. Посмотрим, как на него влияет Call:

using moving = carry::policy<carry::storage::by_value, carry::call::as_passed,
                             carry::target::by_value>;

auto as_lvalue = owning::carry(ValueCategoryBasedFunctor{});
auto as_rvalue = moving::carry(ValueCategoryBasedFunctor{});

std::cout << as_lvalue() << ' ' << as_rvalue() << '\n';  // 0 1

Оба функтора сохранены по значению из rvalue. При этом call::as_lvalue передала свой функтор как lvalue, а call::as_passed - как xvalue.

Хранение функтора тоже можно поменять отдельно. Возьмём функтор с состоянием:

struct Counter {
  int calls = 0;

  int operator()(int value) { return value + ++calls; }
};

using borrowed_target =
    carry::policy<carry::storage::by_value, carry::call::as_lvalue,
                  carry::target::by_reference>;

Counter functor;
auto copied = owning::carry(functor, 10);
auto referenced = borrowed_target::carry(functor, 10);

std::cout << copied() << ' ' << functor.calls << '\n';      // 11 0
std::cout << referenced() << ' ' << functor.calls << '\n';  // 11 1

Первый карри изменяет свою копию счётчика, второй - исходный functor.

Даже с call::as_lvalue можно передать новый unique_ptr по значению:

auto carried = owning::carry(
    [](int value, std::unique_ptr<int> pointer) { return value + *pointer; },
    5);

std::cout << carried(std::make_unique<int>(6)) << '\n';  // 11

Пятёрка пришла из хранилища через Call::forward, а указатель - через std::forward<RemainingArgs>. Выбор политики для сохранённых объектов не меняет передачу новых аргументов.

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

Имплементацию можно найти тут: https://github.com/kxigor/Carry

Комментарии (2)


  1. SiGMan
    21.09.2026 20:44

    TL:DR;

    Может все ж таки корректнее писать curry/currying? Проверочное слово - Haskell Brooks Curry.


    1. KXI Автор
      21.09.2026 20:44

      Формально да, но формально я и не каррирование писал