C++ でセマンティクスを移動する

Abdul Mateen 2023年10月12日
  1. C++ オブジェクト作成の準備
  2. C++ でセマンティクスを移動する
C++ でセマンティクスを移動する

このチュートリアルでは、C++ での移動セマンティクスについて説明します。

  • ディープコピー&シャローコピーの関連概念について説明します。
  • 左辺値と右辺値の考え方について簡単に説明します。
  • 例を使って移動のセマンティクスを理解しようとします。

注: 浅いコピーと深いコピーの概念に自信がある場合は、Move Semantics セクションに直接ジャンプできます。

C++ オブジェクト作成の準備

次の簡単な例を使用して、オブジェクトのコピーを作成するメカニズムを簡単に理解しましょう。

class T {
  int x;

 public:
  T(int x = 1) { this->x = x; }
  int getX() const { return x; }
  void setX(int x) { this->x = x; }
};
int main() {
  T o1;
  T o2(o1);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  o2.setX(5);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  return 0;
}

この単純な例では、クラス T にはデータ メンバ x が 1つだけあります。 デフォルト値を持つ 1つのパラメータ化されたコンストラクタと x の 2つのファクトリ メソッドを記述しました。

main() では、利用可能なコンストラクターを使用してオブジェクト o1 を作成しました。 2 行目では、別のオブジェクト o2 を作成しました。これは o1 のコピーです。

このオブジェクトは、すべてのクラスにデフォルトで存在するコピー コンストラクターを通じて作成されます。 この デフォルト コンストラクター は、データ メンバーのメンバーごとのコピーを行います。

11 行目では、両方のオブジェクトのデータ メンバーを出力していますが、4 行目では o2 のデータ メンバー x の値を変更しています。 最後に、データ メンバーを再度表示して、両方のオブジェクトに対する変更の影響を確認します。

このコードの出力は次のとおりです。

1	1
1	5

最初の出力行では、両方のデータ メンバーの値が 1 になります。最初のオブジェクトは、パラメーター化されたコンストラクターを使用してデフォルト値 1 で作成されるためです。 2 番目のオブジェクトは o1 のコピーであるため、両方の値は同じです。

ただし、後で o2 がセッター関数を呼び出して、そのデータ メンバーの値を変更します。 出力の 2 行目は、o2 のデータ メンバーの値を変更しても、o1 のデータ メンバーの値に影響しないことを示しています。

浅いコピー

残念ながら、データ メンバーが (ヒープ上に作成された) 動的メモリを指すポインターである場合、上記のコピー メカニズムは機能しません。 このシナリオでは、コピア オブジェクトは、前のオブジェクトによって作成された同じ動的メモリ位置を指します。 したがって、コピア オブジェクトは別のオブジェクトのシャロー コピーを持つと言われます。

オブジェクトまたはデータ メンバーが読み取り専用の場合、ソリューションは正常に機能します。 そうしないと、深刻な問題が発生する可能性があります。 まず、次のコード例で浅いコピーの概念を理解しましょう。

class T {
  int *x, size;

 public:
  T(int s = 5) {
    size = s;
    x = new int[size];
    x[0] = x[1] = x[2] = x[3] = x[4] = 1;
  }
  void set(int index, int val) { this->x[index] = val; }
  void show() const {
    for (int i = 0; i < size; i++) cout << x[i] << ' ';
    cout << '\n';
  }
};
int main() {
  T o1;
  T o2(o1);
  o1.show();
  o2.show();
  o2.set(2, 5);
  o1.show();
  o2.show();
  return 0;
}

この例では、クラス T には、ポインター xsize の 2つのデータ メンバーがあります。 ここでも、デフォルト値を持つパラメーター化されたコンストラクターがあります。

default parameter 値をコンストラクター本体内のデータ メンバー size に割り当てました。

コンストラクターの 7 行目と 8 行目では、長さ size の動的配列を宣言し、すべての配列要素に 1 を割り当てます。 set メソッドは、val の値を動的配列の index の場所に配置します。

main() では、o1o2 を作成しました。ここで、o2 はコピー オブジェクトです。 次に、両方のオブジェクトを印刷します。

ここでも、o2 の配列の 3 番目の要素の値を変更し、オブジェクトを出力しています。

このコードの出力は次のとおりです。

1 1 1 1 1
1 1 1 1 1
1 1 5 1 1
1 1 5 1 1

現在、出力は期待どおりではない可能性があります。 最初の 2 行は正確なコピーを示しています。 ただし、3 行目と 4 行目では、両方のオブジェクトに同じ要素がありますが、変更したのは 1つだけです。

これは、既定のコピー コンストラクターが、最初のオブジェクトのポインターの値 (動的に割り当てられた配列へのアドレス) を 2 番目のオブジェクトのポインターに割り当てることによって、浅いコピーを作成したためです。

繰り返しますが、読み取り専用の値がある場合、この浅いコピーは問題ありません。 それ以外の場合、出力は更新の危険性を明確に示します。

ディープコピー

浅いコピーとは反対に、ディープ コピーでは、オブジェクトごとに個別の動的メモリを割り当てます。 次に、ヒープ内の各要素に対してメンバーごとのコピーを行います。

これは基本的に、コピー コンストラクターと代入演算子をオーバーロードすることによって実現されます。 コピー コンストラクターをオーバーロードしているコーディング例を参照してください。

T(const T &t) {
  size = t.size;
  x = new int[size];
  for (int i = 0; i < size; i++) x[i] = t.x[i];
}

2 行目で、新しい動的割り当てのアドレスが x に割り当てられていることに注意してください。 3 行目と 4 行目では、すべての動的配列要素をコピーしてディープ コピーを作成しています。

注: 上記の例は、コピー コンストラクターのオーバーロードのみのコードを提供します。 コードの残りの部分は、前の例と同じです。

コピー コンストラクターを追加した後の、前の例で記述されたメイン コードの出力を次に示します。

1 1 1 1 1
1 1 1 1 1
1 1 1 1 1
1 1 5 1 1

行 3 と行 4 は、最初のオブジェクトに対する 2 番目のオブジェクトの変更効果を示していません。 これは、2o1 のディープ コピーであるため、変更が 2 番目のオブジェクトにのみ固有であることを意味します。

それでは、左辺値と右辺値の概念について簡単に説明しましょう。

左辺値と右辺値

左辺値または左辺値は、値を受け取る代入演算子の左側のオペランドを参照します。 対照的に、右辺値は、値を提供する代入演算子の右側のオペランドを意味します。

したがって、単純変数は左辺値と右辺値の両方として使用できます。 定数または可変定数は、右辺値としてのみ使用できます。

式は右辺値です。 たとえば、a = b + c と書くことはできますが、b + c = a2 = a と書くことはできません。

C++ でセマンティクスを移動する

最後に、移動のセマンティクスについての議論を始めましょう。 ここでは、コピー コンストラクターに関するこの概念について説明します。

まず、クラス T のコピー コンストラクターへの次の呼び出しを参照してください。

T o2(o1);
T o3(o1 - o2);

上記のコードを詳細に分析すると、後で o1 を調べることができる詳細コピーが必要なのは最初の行だけです。 さらに、コピー コンストラクターに o1 の内容を変更するステートメントを含めることができます (たとえば、t.set(2,5) のようなステートメント)。

したがって、o1 は左辺値であると言います。

ただし、式 o1-o2 は左辺値ではありません。 代わりに、これは満場一致 (名前を持たない) オブジェクトであるため右辺値であり、再び o1-o2 にアクセスする必要はありません。 したがって、右辺値は、検討中のステートメントを実行した直後に破棄される一時オブジェクトを表していると言えます。

C++0x では、右辺値型の参照用に RValue 参照と呼ばれる新しいアプローチが導入されました。 この新しいアプローチにより、関数のオーバーロードで右辺値引数を一致させることができます。

これには、右辺値参照パラメーターを持つ別のコンストラクターを作成する必要があります。

T(T&& t) {
  x = t.data;
  t.data = nullptr;
}

まず、新しいコンストラクター (コピー コンストラクターではなく移動コンストラクターと呼ばれます) に注意してください。ここでは、右辺値の参照である & 記号がもう 1つ追加されています。

2 行目では、動的配列のディープ コピーを行う代わりに、既存のオブジェクトのアドレスを新しいオブジェクトに割り当てています。 安全性をさらに高めるために、nullptr を既存のオブジェクトのポインタに割り当てました。

理由は、一時的なソース オブジェクトにアクセスする必要がないからです。 したがって、移動コンストラクターでは、リソース (つまり、動的配列) を既存の (一時的な) オブジェクトから新しい呼び出しオブジェクトに移動します。

理解を深めるために、代入演算子をオーバーロードする別のコードを見てみましょう。

T& operator=(T t) {
  size = t.size;
  for (int i = 0; i < size; i++) x[i] = t.x[i];
  return *this;
}

ここでは右辺値参照を使用していません。 C++0x では、コンパイラはパラメーターが右辺値か左辺値かをチェックし、それに応じて移動コンストラクターまたはコピー コンストラクターのいずれかが呼び出されます。

したがって、代入演算子 o1=o2 を呼び出すと、コピー コンストラクターは t を初期化します。 ただし、代入演算子 o3 = o2-o1 を呼び出すと、ムーブ コンストラクターは t を初期化します。

その理由は、o2-o1 が左辺値ではなく右辺値であるためです。

最後に、コピー コンストラクターを使用してディープ コピーを作成し、今後のアクセスのためにソース オブジェクトを安全に保つオプションを保存できると結論付けます。 後で rvalue オブジェクトにアクセスする必要がないため、move コンストラクターは既存のオブジェクトの動的メモリを呼び出し元オブジェクトのポインターに割り当てます。