ユニバーサル参照とコピー コンストラクター

ワシントン州レドモンドで開催された最新の NWCPP 会議で、常に面白い人である Scott Meyers は、いわゆる「ユニバーサル リファレンス」とその落とし穴に関する彼の最新の洞察を共有しました。特に、彼はユニバーサル参照のオーバーロードの危険性について警告していました。彼のアドバイスは良かったと思いましたが、ユニバーサル参照とコピー コンストラクターの間の相互作用に関するいくつかの重要なコーナー ケースを見逃していました。この記事では、特別な問題とは何か、およびそれらを回避するいくつかの方法を示します。

ユニバーサル リファレンス

しかし、最初に、復習。スコットは「普遍的な参照」とは何を意味していますか?彼は基本的にこれを意味します:

template<typename T>
void foo( T && t )
{
    // "T &&" is a UNIVERSAL REFERENCE
}

上記のコードでは、 T && Scott がユニバーサル リファレンスと呼んでいるものです。 C++ には、名前付きオブジェクトと名前なしオブジェクトへの参照を (大まかに) 区別するための左辺値参照と右辺値参照があります。テンプレートの型推論と参照の崩壊のルールが共謀して、上記の単純な構文に T && という一見魔法のような特性を持たせます。 何でもバインドできます 、右辺値または左辺値。強調して繰り返します:T && ここには、左辺値参照または右辺値参照のいずれかを指定できます。 考慮事項:

int i = 42;
foo( i );  // lvalue, "T &&" deduced to be "int &"
foo( 42 ); // rvalue, "T &&" deduced to be "int &&"

見る? foo 左辺値または右辺値のいずれかで呼び出すことができ、T && の推定型 それを反映しています。 (完全な転送は、ユニバーサル リファレンスの特性に依存しています。) スコットが「ユニバーサル リファレンス」というフレーズを作り出したのは、このやや魔法のような特性です。

ユニバーサル リファレンスのオーバーロードを避ける

Scott のアドバイスはシンプルかつ適切です:ユニバーサル参照のオーバーロードを避けることです。つまり、これをしないでください :

template<typename T>
void foo( T const & t )
  {/*...*/}

template<typename T>
void foo( T && t )
  {/*...*/}

上記のコードでは、作成者はおそらく、すべての左辺値を最初に移動し、すべての右辺値を 2 番目に移動することを望んでいました。 しかし、そうではありません。 何が起こるかは次のとおりです:const 左辺値は最も確実に最初に移動し、すべての右辺値は最も確実に 2 番目に移動しますが、非 const 左辺値も 2番目に行きます。ご覧のとおり、2 番目のオーバーロードはユニバーサル参照を受け取ります。これは、思い出すと、何にでもバインドされます。上で見たように、T && int & と推測できます . const 以外の整数を渡すと、int & を取ることができる 2 番目のオーバーロード int const & を取ることができる最初のものよりも良い一致です

残念ながら、これは安全に忘れることができる難解な問題ではありません。現実の世界でこの間違いを犯す人を見てきました。あるケースでは、結果としてコードが誤って左辺値から移動し、実稼働コードに刻々と過ぎた時限爆弾が残っていました.

スコットのアドバイスは、代わりに one を書くことです 関数、ユニバーサル参照を取得する関数、および内部的に 2 つのヘルパーのいずれかにディスパッチします。ディスパッチする賢明な方法の 1 つは、std::is_lvalue_reference を使用することです。 特性、そのように:

template<typename T>
void foo_impl( T && t, std::true_type )
  {/* LVALUES HERE */}

template<typename T>
void foo_impl( T && t, std::false_type )
  {/* RVALUES HERE */}

template<typename T>
void foo( T && t )
{
    foo_impl( std::forward<T>(t),
              std::is_lvalue_reference<T>() );
}

冗長ではありますが、これがこの特定の問題を処理するためのかなり簡単な方法であることに同意します。

特別なメンバー関数に関する特別な問題

これはすべて順調です。これをさらに別の C++ の癖としてチョークで書き、流砂を認識し、そこに足を踏み入れないようにすることを学びます。こんなに簡単に降りられたら!問題は、コピー コンストラクターから発生します。 C++ には、自動生成されるタイミングに関する規則があります。通常、これはユーザーが定型文を繰り返し入力する手間を省くための恩恵ですが、驚くべきこともあります。

T 型のオブジェクトを保持する単純なラッパー オブジェクトを考えてみましょう。 :

template<typename T>
struct wrapper
{
    T value;
    wrapper( T const & v )
      : value( v ) {}
};

それはダンディです。しかし、これは 2013 年のことであり、ムーブ セマンティクスと完全転送が現在あるため、それらを利用するためにラッパーを変更したいと考えています。完全な転送を行うには、ユニバーサル参照を使用する必要があるため、次のようにします:

template<typename T>
struct wrapper
{
    T value;
    template<typename U>
    wrapper( U && u )
      : value( std::forward<U>(u) ) {}
};

// The array is perfectly forwarded to the
// string constructor.
wrapper<std::string> str("hello world");

これはコーシャですよね?状況によっては、コンパイラが上記のコンストラクタをコピー コンストラクタとして使用しようとすることがありますが、これは適切ではありません。

ちょっと待って!あなたは言う。テンプレートをコピー コンストラクタとして使用することはできません。それがあなたが考えていることなら、あなたはほぼ 右。真実は、Scott Meyers がこれを正しく指摘しているのですが、コンパイラはテンプレートを使用して 生成 することを拒否しているということです。 コピー コンストラクタ。この違いは微妙ですが、後で説明するように非常に重要です。

コンパイラがこれを確認すると:

// Copy the wrapper
wrapper<std::string> str2 = str;

wrapper を調べます クラスを作成し、コピー コンストラクターが表示されない場合 (およびテンプレートを使用して生成することを拒否する場合)、自動的に新しいコンストラクターを生成します:

template<typename T>
struct wrapper
{
    T value;
    template<typename U>
    wrapper( U && u )
      : value( std::forward<U>(u) ) {}
    // THIS IS COMPILER-GENERATED:
    wrapper( wrapper const & that )
      : value( that.value ) {}
};

次に起こることは本当に奇妙です。コンパイラは、使用するコンストラクターを生成した後、それを使用しないことを決定します。 なんて言うの?! それは正しい。オーバーロードの解決が開始されます。対象となるコードは次のとおりであることを思い出してください。

wrapper<std::string> str2 = str;

str wrapper<std::string> 型の非 const 左辺値です .選択できるコンストラクタは 2 つあります。コンパイラによって生成されたものは確かに実行可能ですが、最初のものの方がより適しています。なんで?なぜなら U && wrapper<std::string> & と推測できます . コピー コンストラクターの生成にテンプレートが使用されることはありませんが、オーバーロードの解決でテンプレートが選択された場合、いずれにせよテンプレートが使用される可能性があります。 つまり、wrapper を転送することになります std::string に異議を唱える コンストラクター、そして失敗します。おっとっと。 strだった 以前は const でした の場合、他のコンストラクターが選択され、機能していたはずです。シッツォ!

Variadic テンプレートは、この軟膏のもう 1 つのハエです。以下を検討してください:

template<typename ... Ts>
struct tuple
{
    // Whoops, this can be a copy constructor!
    template<typename ... Us>
    tuple( Us &&... us ) : /* etc... */
};

ここでの意図は、すべての引数を完全に転送するコンストラクターでタプル型を定義することです。そのように使用できますが、(帽子をかぶってください) コピー コンストラクターとしても使用できます。その場合、Us &&... tuple & を推測します .

解決策

では、善意の C++ プログラマーは何をすべきでしょうか? 1 つの引数を完全に転送するコンストラクターが本当に必要な場合はどうすればよいでしょうか。たくさんの「修正」がありますが、ほとんどは独自の問題を抱えています。これが最も確実に機能することがわかったものです。

// write this once and put it somewhere you can
// reuse it
template<typename A, typename B>
using disable_if_same_or_derived =
    typename std::enable_if<
        !std::is_base_of<A,typename
             std::remove_reference<B>::type
        >::value
    >::type;

template<typename T>
struct wrapper
{
    T value;
    template<typename U, typename X =
        disable_if_same_or_derived<wrapper,U>>
    wrapper( U && u )
      : value( std::forward<U>(u) )
    {}
};

そこでは多くのことが行われていますが、要点は次のとおりです。パラメーターが wrapper の場合、メタプログラミングを使用してコンストラクターを無効にします。 .実際、wrapper から派生した型のコンストラクターは無効になっています。 、 それも。なんで? C++ の期待されるセマンティクスを保持するためです。考慮事項:

struct A {};
struct B : A {};
B b;
A a = b;

そうすることに何の問題もありません。 B A から継承 、だから A を構築できます B から スライス動作が得られます。 A の場合 私たちが議論してきたこれらの厄介なユニバーサル コンストラクターの 1 つを取得した場合、それはもはやスライスされません。代わりにユニバーサル コンストラクターが呼び出され、新しい、エキサイティングな、おそらく間違った動作が得られます。

まとめ

要するに、Scott のアドバイスに従い、普遍的な参照に過負荷をかけないでください。ただし、ユニバーサル コンストラクター (つまり、ユニバーサル参照を受け取る単一引数のコンストラクター) を作成している場合は、テンプレートを制約して、コピー コンストラクターとして使用できないようにします。そうしないと後悔するぞ!