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

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

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

Проблема с конструкторами. По мере роста количества политик (параметров шаблона) комбинации ссылок на константы и ссылки на значения растут в геометрической прогрессии. В предыдущем примере:

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)) {}

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

39

Решение

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

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

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

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

34

Другие решения

Это именно тот случай использования техники «передача по значению и ходу».
Хотя он немного менее эффективен, чем перегрузки 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.

28

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

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

Мне известны 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 использовать. Но это также позволяет хранить неподвижные объекты.

Код не проверен, поэтому возможны опечатки.

13