Подтвердить что ты не робот

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

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

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

Проблема заключается в конструкторах. По мере увеличения количества политик (параметров шаблона) комбинации ссылок на константы и ссылки 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)) {}

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

4b9b3361

Ответ 1

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

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

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

Как сказал Howard Hinnant , мой метод может быть SFINAE-недружественным, так как теперь LinearClassifier принимает любую пару типов в конструкторе. Ответ Барри показывает, как с этим бороться.

Ответ 2

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

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

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

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

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

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

Ответ 3

Для полноты оптимальный конструктор с двумя аргументами возьмет две ссылки пересылки и использует 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))
{ }

Ответ 4

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

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


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

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

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

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

для дешевого перехода на Loss и move-is-copy 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. Но он также позволяет хранить недвижущиеся объекты.

Код не проверен, поэтому есть опечатки.