web-dev-qa-db-ja.com

コンストラクターでのconst参照とrvalue参照の指数関数的成長を回避する

機械学習ライブラリ用にいくつかのテンプレートクラスをコーディングしていますが、この問題に何度も直面しています。私は主にポリシーパターンを使用しています。このパターンでは、クラスはさまざまな機能のテンプレート引数ポリシーを受け取ります。次に例を示します。

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

問題はコンストラクターにあります。ポリシー(テンプレートパラメーター)の量が増えると、const参照と右辺値参照の組み合わせが指数関数的に増加します。前の例では:

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

これを回避する方法はありますか?

42

実際、これが 完全転送 が導入された正確な理由です。コンストラクターを次のように書き直します

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

しかし、Ilya Popovが彼の answer で示唆していることを行う方がおそらくはるかに簡単でしょう。正直なところ、私は通常この方法でそれを行います。なぜなら、移動は安価であることが意図されており、もう1回移動しても状況が劇的に変わることはないからです。

Howard Hinnant 言われています のように、LinearClassifierはコンストラクターで型の任意のペアを受け入れるため、私のメソッドはSFINAEに適さない可能性があります。 バリーの答え はそれに対処する方法を示しています。

36
lisyarus

これはまさに「値渡しと移動」手法のユースケースです。左辺値/右辺値のオーバーロードよりもわずかに効率が劣りますが、それほど悪くはなく(1回余分に移動)、手間が省けます。

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

左辺値引数の場合、1つのコピーと1つの移動があり、右辺値引数の場合、2つの移動があります(クラスLossOptimizerが移動コンストラクターを実装している場合) 。

更新:一般的に、 完全な転送ソリューション の方が効率的です。一方、このソリューションは、SFINAEで制約されていない場合は任意のタイプの引数を受け入れ、引数に互換性がない場合はコンストラクター内でハードエラーが発生するため、常に望ましいとは限らないテンプレートコンストラクターを回避します。言い換えると、制約のないテンプレートコンストラクターはSFINAEに適していません。この問題を回避する制約付きテンプレートコンストラクターについては、 Barryの回答 を参照してください。

テンプレート化されたコンストラクターのもう1つの潜在的な問題は、ヘッダーファイルに配置する必要があることです。

アップデート2:ハーブサッターは、CppCon2014の講演「Backtothe Basics」でこの問題について語っています 1:03:48から 。彼は、最初に値渡し、次に右辺値参照のオーバーロード、次に完全な転送について説明します 1:15:22 制約を含みます。そして最後に、彼はコンストラクターについて、値を渡すための唯一の良いユースケースとして話します 1:25:5

31
Ilya Popov

完全を期すために、最適な2引数のコンストラクターは、2つの転送参照を取得し、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の(またはそれらから派生した)引数のみを受け入れることが保証されます。残念ながら、書くことは非常に一口であり、元の意図から非常に気が散っています。これを正しく行うのはかなり難しいですが、パフォーマンスが重要な場合はそれが重要であり、これが実際に進む唯一の方法です。

しかし、それが問題ではなく、LossOptimizerの移動が安価である場合(または、さらに良いことに、このコンストラクターのパフォーマンスは完全に無関係である場合)、 IlyaPopovのソリューション

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

うさぎの穴のどこまで行きたいですか?

私はこの問題に取り組む4つのまともな方法を知っています。前提条件が一致する場合は、通常、前の条件を使用する必要があります。後の条件はそれぞれ複雑さが大幅に増すためです。


ほとんどの場合、移動は非常に安価で、2回行うのは無料であるか、移動はコピーです。

移動がコピーであり、コピーがフリーでない場合は、_const&_でパラメーターを取ります。そうでない場合は、値でそれを取ります。

これは基本的に最適に動作し、コードをはるかに理解しやすくします。

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

移動が安価なLossおよびmove-is-copyoptimizerの場合。

これにより、すべての場合において、値パラメーターごとに、以下の「最適な」完全転送(注:完全転送は最適ではありません)を1回余分に移動します。移動が安価である限り、これは最良のソリューションです。クリーンなエラーメッセージを生成し、_{}_ベースの構築を可能にし、他のどのソリューションよりもはるかに読みやすいからです。

このソリューションの使用を検討してください。


移動がコピーよりも安価であるが無料ではない場合、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))
{}
_

これには、引数の_{}_ベースの構築を行う能力が必要です。また、上記のコードを呼び出すと、最大で指数関数的な数のコンストラクターを生成できます(インライン化されることを願っています)。

SFINAEの失敗を犠牲にして、_std::enable_if_t_句を削除できます。基本的に、その_std::enable_if_t_句に注意しないと、コンストラクターの誤ったオーバーロードが選択される可能性があります。同じ数の引数を持つコンストラクターのオーバーロードがある場合、または早期障害が気になる場合は、_std::enable_if_t_が必要です。それ以外の場合は、より単純なものを使用してください。

このソリューションは通常「最適」と見なされます。おそらく最適ですが、最適ではありません。


次のステップは、タプルを使用した定置構造を使用することです。

_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を返す関数だけです。