Избегайте экспоненциального роста ссылок const и ссылок rvalue в конструкторе

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

template <class Loss, class Optimizer> class LinearClassifier { ... }

Проблема в конструкторах. По мере роста количества политик (параметров шаблона) количество комбинаций ссылок const и ссылок rvalue растет экспоненциально. В предыдущем примере:

LinearClassifier(const Loss& loss, const Optimizer& optimizer) : _loss(loss), _optimizer(optimizer) {}

LinearClassifier(Loss&& loss, const Optimizer& optimizer) : _loss(std::move(loss)), _optimizer(optimizer) {}

LinearClassifier(const Loss& loss, Optimizer&& optimizer) : _loss(loss), _optimizer(std::move(optimizer)) {}

LinearClassifier(Loss&& loss, Optimizer&& optimizer) : _loss(std::move(loss)), _optimizer(std::move(optimizer)) {}

Есть ли способ избежать этого?


person Federico Allocati    schedule 26.04.2016    source источник
comment
использовать ссылки для переадресации?   -  person Piotr Skotnicki    schedule 26.04.2016
comment
И тогда вы можете придерживаться последнего конструктора (я думаю, верно?)   -  person Unda    schedule 26.04.2016
comment
@Unda последний конструктор принимает ссылки rvalue   -  person Piotr Skotnicki    schedule 26.04.2016
comment
@PiotrSkotnicki Да, но это синтаксис, который вы используете вместе с std::foward, не так ли?   -  person Unda    schedule 26.04.2016
comment
На этот вопрос нет ответа, который всегда был бы правильным для всех типов, которые могут быть Loss и Optimizer. Лучший ответ зависит от таких деталей, как: Loss и Optimizer дорого копировать, но дешево перемещать? Устраивают ли авторов и сопровождающих этот код ограничения шаблонов (e.g. enable_if)? Решение с передачей по значению иногда подходит. Если вы выберете эталонное решение для пересылки, я настоятельно рекомендую правильно ограничить его. Если только один из Loss и Optimizer можно дешево переместить, можно рассмотреть гибридное решение.   -  person Howard Hinnant    schedule 26.04.2016
comment
@Unda не совсем так: чтобы эти ссылки rvalue превратились в ссылки пересылки, Loss и Optimizer должны быть выведены типами.   -  person Quentin    schedule 26.04.2016
comment
Я думаю, что код в вопросе не только сложный, но и неверный по сути. Посмотрите на инициализатор _loss(loss). Даже если loss имеет тип Loss&&, этот инициализатор все равно будет рассматривать loss как lvalue. Это важно, хотя и неинтуитивно. @ Федерико, у тебя было впечатление, что _loss(loss) переедет из Loss&& loss? Фактически, он будет скопирован.   -  person Aaron McDaid    schedule 26.04.2016
comment
@AaronMcDaid нет, я знаю, что это будет скопировано, на самом деле я просто скопировал первую строку и заменил const & на && для этого примера. Мой код немного сложнее этого, и я не хотел заполнять вопрос лишними вещами, но спасибо за участие!   -  person Federico Allocati    schedule 26.04.2016
comment
являются значениями _loss и _optimizer или ссылками?   -  person M.M    schedule 27.04.2016
comment
Я смущен тем, почему вы написали 4 случая, а не просто LinearClassifier(const Loss& loss, const Optimizer& optimizer). Случаи со ссылкой на rvalue предполагают, что вы, возможно, захотите отказаться от аргументов... но тогда вы фактически не использовали std::move в аргументах   -  person M.M    schedule 27.04.2016
comment
@ ММ, я думаю, что ты сказал то же самое, что и я. По сути, все, что может связываться с Loss&&, также может связываться с const Loss&, и поэтому нет смысла писать перегрузку Loss&&, если вы не сделаете что-то другое в инициализаторах, отличное от const Loss&&. Думаю, спрашивающий это понимает, и понимает, что это просто игрушечный код в вопросе. Я бы предпочел более реалистичный код, так как этот игрушечный код действительно сбивает с толку.   -  person Aaron McDaid    schedule 27.04.2016
comment
@AaronMcDaid, @M.M Я отредактировал это сейчас! В моем случае _loss и _optimizer являются значениями, и, в частности, для некоторых оптимизаторов перемещение дешевле, чем копирование, поэтому, если передано значение r, я хочу переместить и скопировать только в том случае, если получу ссылку lvalue. Я просто поставил Loss&& для завершения, потому что в какой-то момент какой-то тип Loss может быть дорогим для копирования.   -  person Federico Allocati    schedule 27.04.2016


Ответы (4)


Собственно, именно по этой причине была введена идеальная переадресация. Перепишите конструктор как

template <typename L, typename O>
LinearClassifier(L && loss, O && optimizer)
    : _loss(std::forward<L>(loss))
    , _optimizer(std::forward<O>(optimizer))
{}

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

Как сказал Говард Хиннант < /a>, мой метод может быть недружественным к SFINAE, так как теперь LinearClassifier принимает любую пару типов в конструкторе. Ответ Барри показывает, как с этим бороться.

person lisyarus    schedule 26.04.2016
comment
Теперь у нас есть два ответа с 'это [точно | точный] вариант использования для' - два разных метода, оба из которых мне понятны. Может ли кто-нибудь уточнить, хороши ли оба или почему мы не должны даже рассматривать другое, или они действительно взаимозаменяемы? - person peterchen; 26.04.2016
comment
@peterchen Мой метод немного громоздкий и гораздо менее читаемый, но позволяет избежать лишних ходов. Следует предпочесть, если этот дополнительный ход начинает быть проблемой. - person lisyarus; 26.04.2016
comment
@FedericoAllocati Да, это так. - person lisyarus; 26.04.2016
comment
Не все ходы дешевы... некоторые ходы являются копиями. - person Barry; 26.04.2016
comment
@Barry Конечно, и это как раз тот случай, когда нам нужна идеальная переадресация. - person lisyarus; 26.04.2016
comment
Шаблонные конструкторы не всегда желательны, что может сделать копирование и перемещение более привлекательными. - person Ilya Popov; 26.04.2016
comment
Этот дизайн является достойным направлением, но в его нынешнем виде есть недостаток, который может быть серьезным: std::is_constructible<LinearClassifier, int, int>::value это true (и вы можете заменить все, что хотите, вместо int). Если тебе все равно, ладно. Но правильная SFINAE становится все более и более важной. Чтобы исправить это, вы либо используете решение по значению из другого ответа, либо ограничиваете L и O таким образом, чтобы они создавали только экземпляры для Loss и Optimizer, и этот ответ (пока) не объясняет, как это сделать. - person Howard Hinnant; 26.04.2016
comment
@HowardHinnant По запросу. Хотя было бы здорово, если бы решение по значению могло быть оптимальным... Конечно, его намного проще написать и понять. - person Barry; 26.04.2016
comment
Как я могу обеспечить, чтобы объект lvalue не изменялся в таком конструкторе с помощью std::forward? Здесь у нас есть L&& loss, если дать lvalue - будет L& loss, и его можно изменить. Хорошей практикой всегда было использование const SomeType&, но здесь мы не можем просто написать const L&& loss, потому что хотим иметь возможность перемещения. Каково решение? - person I.S.M.; 26.11.2017

Это как раз тот случай, когда используется техника «переход по значению и перемещение». Хотя это немного менее эффективно, чем перегрузки lvalue/rvalue, это не так уж плохо (один дополнительный шаг) и избавляет вас от хлопот.

LinearClassifier(Loss loss, Optimizer optimizer) 
    : _loss(std::move(loss)), _optimizer(std::move(optimizer)) {}

В случае аргумента lvalue будет одна копия и одно перемещение, в случае аргумента rvalue будет два перемещения (при условии, что ваши классы Loss и Optimizer реализуют конструкторы перемещения).

Обновление: в целом идеальное решение для пересылки более эффективно. С другой стороны, это решение избегает шаблонных конструкторов, которые не всегда желательны, потому что оно будет принимать аргументы любого типа, если они не ограничены SFINAE, и приведет к серьезным ошибкам внутри конструктора, если аргументы несовместимы. Другими словами, неограниченные шаблонные конструкторы не совместимы с SFINAE. См. ответ Барри для ограниченного конструктора шаблонов, который позволяет избежать этой проблемы.

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

Обновление 2: Херб Саттер рассказывает об этой проблеме в своем выступлении на CppCon 2014 «Назад к основам» начиная с 1:03. :48. Сначала он обсуждает передачу по значению, затем перегрузку по rvalue-ref, затем идеальную пересылку в 1:15:22 включая ограничение. И, наконец, он говорит о конструкторах как о единственном хорошем варианте использования для передачи по значению в 1 :25:50.

person Ilya Popov    schedule 26.04.2016
comment
Идеальное решение для переадресации более эффективно. На самом деле оно может быть более эффективным. - person edmz; 26.04.2016
comment
Я не понял часть, потому что каждый тип аргумента не будет ограничен SFINAE и приведет к серьезным ошибкам внутри конструктора, если аргументы несовместимы. Размещение заголовочного файла не проблема, потому что это библиотека только для заголовков :) - person Federico Allocati; 26.04.2016
comment
Конструктор, показанный @lisyarus, подойдет для любого вызова с двумя аргументами независимо от их типов. Это приводит к нескольким последствиям: у вас не может быть никакого другого конструктора с двумя аргументами, и если какой-то другой код попытается проделать какие-либо трюки SFINAE с использованием вашего конструктора, он не сработает (потому что конструктор примет любые типы, а затем выдаст ошибку внутри тело конструктора). (См. комментарий Говарда Хиннанта для примера). - person Ilya Popov; 26.04.2016
comment
шаблонные конструкторы не подходят для SFINAE. Это просто тавтологично. Шаблоны конструкторов, не совместимые с SFINAE, не являются дружественными к SFINAE... но дружественные к SFINAE шаблоны... - person Barry; 26.04.2016
comment
@ Барри, вот почему я сказал, что тогда не ограничен. Конечно, они совместимы с SFINAE, если правильно ограничены. - person Ilya Popov; 26.04.2016

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

template <class T, class U>
using decays_to = std::is_convertible<std::decay_t<T>*, U*>;

А потом:

template <class L, class O,
          class = std::enable_if_t<decays_to<L, Loss>::value &&
                                   decays_to<O, Optimizer>::value>>
LinearClassifier(L&& loss, O&& optimizer)
: _loss(std::forward<L>(loss))
, _optimizer(std::forward<O>(optimizer))
{ }

Это гарантирует, что мы принимаем только аргументы типа Loss и Optimizer (или производные от них). К сожалению, писать довольно сложно, и это сильно отвлекает от первоначального замысла. Это довольно сложно сделать правильно, но если производительность имеет значение, то она имеет значение, и это действительно единственный путь.

Но если это не имеет значения, и если Loss и Optimizer дешевы для перемещения (или, что еще лучше, производительность для этого конструктора совершенно не имеет значения), предпочитайте Решение Ильи Попова:

LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss))
, _optimizer(std::move(optimizer))
{ }
person Barry    schedule 26.04.2016
comment
Интересный выбор ограничения. Я согласен, это сложно сделать правильно, и я попал на крючок за печально известную ошибку (youtube.com/watch?v=xnqTKD8uD64) :-). Как насчет использования std::is_convertible<L, Loss> для ограничения? Это (например) позволит const char* L создать std::string Loss. А также разрешит ваш Derived → Base пример. - person Howard Hinnant; 26.04.2016
comment
@HowardHinnant Можно было бы использовать и std::is_constructible<Loss, L&&>. Просто хотел быть как можно более строгим для универсальности. Но да, трудно выбрать... Что я ищу в этом видео? :) - person Barry; 26.04.2016
comment
Всего за час до этого выступления, когда я выезжал из города, Херб спросил меня, какое ограничение должно быть для такой задачи. Ближе к концу разговора (1:15:00?). Я слишком много думал и ошибся. Нет ничего лучше тестирования! :-) - person Howard Hinnant; 26.04.2016
comment
@ Ховард, я думаю, что ограничение в порядке! Итак, вы требуете, чтобы пользователь был явным - это вряд ли заслуживает дурной славы - person Barry; 26.04.2016
comment
вот некоторый синтаксический сахар с использованием fold-выражений, который позволяет передать -IMO довольно удобочитаемое-выражение forward_compatible_v<std::pair<L, Loss>, std::pair<O, Optimizer>> к ограничению enable_if_t. - person TemplateRex; 27.04.2016
comment
Как я могу обеспечить, чтобы объект lvalue не изменялся в таком конструкторе с помощью std::forward? Здесь у нас есть L&& loss, если дать lvalue - будет L& loss, и его можно изменить. Хорошей практикой всегда было использование const SomeType&, но здесь мы не можем просто написать const L&& loss, потому что хотим иметь возможность перемещения. Каково решение? - person I.S.M.; 26.11.2017

Как далеко в кроличьей норе вы хотите зайти?

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


По большей части, либо перемещение настолько дешево, что дважды это бесплатно, либо перемещение — это копирование.

Если ход копируется, а копия несвободна, берем параметр const&. Если нет, берите по значению.

Это будет вести себя в основном оптимально и сделает ваш код намного проще для понимания.

LinearClassifier(Loss loss, Optimizer const& optimizer)
  : _loss(std::move(loss))
  , _optimizer(optimizer)
{}

для дешевого перемещения Loss и перемещения-копии optimizer.

Это делает 1 дополнительный шаг по сравнению с «оптимальной» идеальной переадресацией ниже (примечание: идеальная переадресация не оптимальна) для каждого параметра значения во всех случаях. Пока перемещение дешевое, это лучшее решение, потому что оно генерирует четкие сообщения об ошибках, позволяет построение на основе {} и его намного легче читать, чем любое другое решение.

Рассмотрите возможность использования этого решения.


Если перемещение дешевле, чем копирование, но не является бесплатным, один из подходов идеально основан на пересылке: Либо:

template<class L, class O    >
LinearClassifier(L&& loss, O&& optimizer)
  : _loss(std::forward<L>(loss))
  , _optimizer(std::forward<O>(optimizer))
{}

Или более сложный и удобный для перегрузки:

template<class L, class O,
  std::enable_if_t<
    std::is_same<std::decay_t<L>, Loss>{}
    && std::is_same<std::decay_t<O>, Optimizer>{}
  , int> * = nullptr
>
LinearClassifier(L&& loss, O&& optimizer)
  : _loss(std::forward<L>(loss))
  , _optimizer(std::forward<O>(optimizer))
{}

это стоит вам возможности строить свои аргументы на основе {}. Кроме того, приведенный выше код может сгенерировать до экспоненциального числа конструкторов, если они будут вызваны (надеюсь, они будут встроены).

Вы можете отказаться от предложения std::enable_if_t за счет отказа SFINAE; в основном, неправильная перегрузка вашего конструктора может быть выбрана, если вы не будете осторожны с этим предложением std::enable_if_t. Если у вас есть перегрузки конструктора с одинаковым количеством аргументов или вы заботитесь о раннем сбое, вам нужен std::enable_if_t. В противном случае используйте более простой.

Это решение обычно считается "наиболее оптимальным". Это приемлемо оптимально, но не наиболее оптимально.


Следующим шагом будет использование конструкции emplace с кортежами.

private:
template<std::size_t...LIs, std::size_t...OIs, class...Ls, class...Os>
LinearClassifier(std::piecewise_construct_t,
  std::index_sequence<LIs...>, std::tuple<Ls...>&& ls,
  std::index_sequence<OIs...>, std::tuple<Os...>&& os
)
  : _loss(std::get<LIs>(std::move(ls))...)
  , _optimizer(std::get<OIs>(std::move(os))...)
{}
public:
template<class...Ls, class...Os>
LinearClassifier(std::piecewise_construct_t,
  std::tuple<Ls...> ls,
  std::tuple<Os...> os
):
  LinearClassifier(std::piecewise_construct_t{},
    std::index_sequence_for<Ls...>{}, std::move(ls),
    std::index_sequence_for<Os...>{}, std::move(os)
  )
{}

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

Чтобы увидеть, как это работает, пример теперь piecewise_construct работает с std::pair. Сначала вы передаете кусочную конструкцию, затем forward_as_tuple аргументы для последующего построения каждого элемента (включая копирование или перемещение ctor).

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


Последний симпатичный прием — стирание текста. На практике для этого требуется что-то вроде std::experimental::optional<T>, что может сделать класс немного больше.

Это не быстрее, чем кусочное построение. Он абстрагирует работу, которую выполняет конструкция emplace, упрощая ее для каждого использования, и позволяет вам отделить тело ctor от файла заголовка. Но есть небольшие накладные расходы, как во время выполнения, так и в пространстве.

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

struct delayed_emplace_t {};
template<class T>
struct delayed_construct {
  std::function< void(std::experimental::optional<T>&) > ctor;
  delayed_construct(delayed_construct const&)=delete; // class is single-use
  delayed_construct(delayed_construct &&)=default;
  delayed_construct():
    ctor([](auto&op){op.emplace();})
  {}
  template<class T, class...Ts,
    std::enable_if_t<
      sizeof...(Ts)!=0
      || !std::is_same<std::decay_t<T>, delayed_construct>{}
    ,int>* = nullptr
  >
  delayed_construct(T&&t, Ts&&...ts):
    delayed_construct( delayed_emplace_t{}, std::forward<T>(t), std::forward<Ts>(ts)... )
  {}
  template<class T, class...Ts>
  delayed_construct(delayed_emplace_t, T&&t, Ts&&...ts):
    ctor([tup = std::forward_as_tuple(std::forward<T>(t), std::forward<Ts>(ts)...)]( auto& op ) mutable {
      ctor_helper(op, std::make_index_sequence<sizeof...(Ts)+1>{}, std::move(tup));
    })
  template<std::size_t...Is, class...Ts>
  static void ctor_helper(std::experimental::optional<T>& op, std::index_sequence<Is...>, std::tuple<Ts...>&& tup) {
    op.emplace( std::get<Is>(std::move(tup))... );
  }
  void operator()(std::experimental::optional<T>& target) {
    ctor(target);
    ctor = {};
  }
  explicit operator bool() const { return !!ctor; }
};

где мы стираем действие создания необязательных параметров из произвольных аргументов.

LinearClassifier( delayed_construct<Loss> loss, delayed_construct<Optimizer> optimizer ) {
  loss(_loss);
  optimizer(_optimizer);
}

где _loss это std::experimental::optional<Loss>. Чтобы удалить необязательность _loss, вы должны использовать std::aligned_storage_t<sizeof(Loss), alignof(Loss)> и быть очень осторожным при написании ctor для обработки исключений и ручного уничтожения вещей и т. д. Это головная боль.

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

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

Код не тестировался, возможно, есть опечатки.


В c++17 с гарантированным исключением необязательная часть задержанного ctor становится устаревшей. Любая функция, возвращающая T, — это все, что вам нужно для отложенного ctor T.

person Yakk - Adam Nevraumont    schedule 26.04.2016
comment
Мне просто нравится, как код взрывается с каждой итерацией оптимальности ;-) Каким-то образом C++, похоже, оставил всю простоту C позади... - person cmaster - reinstate monica; 26.04.2016
comment
Я не уверен, должен ли я испытывать отвращение или трепет. - person isanae; 26.04.2016
comment
@cmaster В какой-то степени; но выполнение тех же операций в C было бы более громоздким и совершенно неудобным в сопровождении и почти невозможным без повторения каждый раз, когда вы хотите его использовать. delayed_construct<T> (самый безумный) на самом деле имеет очень короткое тело для использования (такой же длины, как и первое решение!), и то, что он делает, было бы настоящей головной болью в C. Вам лучше сдаться задолго до того, как вы достигнете того, что есть. на самом деле происходит в этом, и нет шансов сделать это в целом. В C++ я пишу беспорядок один раз (и этот беспорядок короче, чем эквивалент C), и я могу использовать его повторно. - person Yakk - Adam Nevraumont; 27.04.2016
comment
@cmaster Теперь я, вероятно, не стал бы его использовать; Я бы поспорил за № 1, за исключением экстремальных обстоятельств. И № 1 уже смехотворно короче, чем эквивалентная реализация C. Решение C может быть таким же коротким, как и решение #1, но это потому, что оно обычно не выполняет такого же количества операций по оптимизации в крайних случаях, как даже #1 под капотом. - person Yakk - Adam Nevraumont; 27.04.2016
comment
Как я могу обеспечить, чтобы объект lvalue не изменялся в таком конструкторе с помощью std::forward? Здесь у нас есть L&& loss, если дать lvalue - будет L& loss, и его можно изменить. Хорошей практикой всегда было использование const SomeType&, но здесь мы не можем просто написать const L&& loss, потому что хотим иметь возможность перемещения. Каково решение? - person I.S.M.; 26.11.2017
comment
@i.s.m. Его можно изменить. Взятие вещей const& не мешает их изменению, const_cast далеко const является законным C++. И rvalue изменяются при перемещении. Или вы беспокоитесь о том, чтобы испортить тело функции? Вы можете написать механизм пересылки или копирования, если это станет вашей проблемой. - person Yakk - Adam Nevraumont; 26.11.2017
comment
Я немного запутался, что у нас было что-то вроде этого: LinearClassifier(Loss&& loss, const Optimizer& Optimizer) - без форварда и др., а с ними мы получили LinearClassifier(Loss&& loss, Optimizer& optimizer) - без const. Здесь мы можем изменить оптимизатор lvalue без использования const_cast. Я думаю, что это более опасно, чем const Optimizer&. Поэтому я пытаюсь найти решение, которое добавит константу, если мы этого хотим и если исходный тип был только lvalue. Правильно так делать или нет? - person I.S.M.; 26.11.2017
comment
@i.s.m. извините, слишком много тиопов и аббревиатур; Я не понимаю вашего беспокойства. Я советую вам, если у вас есть практический вопрос, использовать кнопку «Задать вопрос» выше. - person Yakk - Adam Nevraumont; 26.11.2017