C++のコピーコンストラクタについて

AI実装検定のご案内

C++のコピーコンストラクタとは、既存のオブジェクトをもとにして、新しいオブジェクトを作るためのコンストラクタです。

たとえば、あるクラスのオブジェクトをすでに作成していて、その内容を使って別のオブジェクトを初期化したい場合に使われます。

コピーコンストラクタは、単に値を代入するためのものではありません。

重要なのは、まだ存在していない新しいオブジェクトを、既存のオブジェクトから作るという点です。

この点を理解しておくと、コピーコンストラクタとコピー代入演算子の違いもわかりやすくなります。

目次

コピーコンストラクタの基本的な考え方

既存オブジェクトから新しいオブジェクトを作る

コピーコンストラクタは、あるオブジェクトをコピー元として、新しいオブジェクトを初期化するときに使われます。

たとえば、すでに存在するオブジェクトAがあり、その内容をもとにオブジェクトBを作る場合、コピーコンストラクタが関係します。

このとき、Bはまだ作られていません。

そのため、Aの内容を使ってBを「新しく作る」処理が必要になります。

これがコピーコンストラクタの役割です。

代入とは別物

コピーコンストラクタは、すでに存在するオブジェクトに値を入れるものではありません。

すでに存在するオブジェクトに、別のオブジェクトの内容を代入する場合は、コピーコンストラクタではなくコピー代入演算子が使われます。

つまり、コピーコンストラクタは「作るとき」、コピー代入演算子は「作ったあとに入れ替えるとき」に使われるものです。

コピーコンストラクタが使われる主な場面

オブジェクトを別のオブジェクトで初期化するとき

もっとも基本的な場面は、あるオブジェクトを使って別のオブジェクトを初期化するときです。

この場合、コピー元のオブジェクトの内容を使って、新しいオブジェクトが作られます。

見た目としては代入のように見える書き方でも、実際には「初期化」であり、コピーコンストラクタが使われることがあります。

ここはC++初心者が混乱しやすいポイントです。

イコール記号があるからといって、必ずしも代入とは限りません。

関数に値渡しするとき

関数の引数としてオブジェクトを値渡しする場合も、コピーコンストラクタが関係します。

値渡しでは、関数の中で使うための引数オブジェクトが新しく作られます。

そのため、呼び出し元のオブジェクトをもとに、関数側の引数オブジェクトを作る必要があります。

このときにコピーコンストラクタが使われます。

ただし、C++のバージョンや最適化の状況によっては、実際のコピーが省略されることもあります。

とはいえ、基本的な理解としては「値渡しではコピーが発生し得る」と考えておくとよいです。

関数から値を返すとき

関数がオブジェクトを値として返す場合にも、コピーコンストラクタやムーブコンストラクタが関係することがあります。

ただし、現代のC++ではコピー省略が行われることが多く、実際にはコピーコンストラクタが呼ばれない場合もあります。

特にC++17以降では、特定の場面でコピーやムーブをせずに、戻り値のオブジェクトが直接構築されることがあります。

一方で、名前のあるローカル変数を返す場合は、NRVOと呼ばれる最適化の対象になります。

多くのコンパイラでは最適化されますが、すべてのケースで必ず保証されるわけではありません。

そのため、「関数から値を返すと必ずコピーされる」と考えるのは正確ではありません。

より正確には、「コピーやムーブが関係する可能性があるが、実際には省略されることも多い」と理解するとよいです。

コピーコンストラクタの引数

通常はconst参照で受け取る

コピーコンストラクタは、通常、コピー元のオブジェクトをconst参照で受け取ります。

なぜなら、コピー元のオブジェクトを変更する必要がないからです。

コピーとは、あくまで元の内容を参照して、新しいオブジェクトを作る処理です。

元のオブジェクトを書き換える必要はありません。

そのため、コピー元を変更しないことを示すために、constを付けるのが一般的です。

参照で受け取る理由

コピーコンストラクタの引数は、値渡しではなく参照で受け取る必要があります。

もしコピー元を値渡しで受け取ろうとすると、その引数を作るためにさらにコピーが必要になります。

つまり、コピーコンストラクタを呼ぶための引数を作る時点で、またコピーコンストラクタが必要になってしまいます。

そのため、コピーコンストラクタの引数は値渡しではなく、参照でなければなりません。

厳密に言うと、値渡しの形はコピーコンストラクタとして成立しません。

「値渡しにすると効率が悪い」というより、コピーコンストラクタの形として不適切です。

const参照以外の形もあり得る

実務では、コピーコンストラクタはほとんどの場合、const参照で書きます。

ただし、C++の仕様としては、コピーコンストラクタが必ずconst参照でなければならないわけではありません。

同じクラス型への非const参照を受け取る形も、コピーコンストラクタとして扱われます。

とはいえ、非const参照にすると、constオブジェクトからコピーできなくなります。

そのため、通常はconst参照にするのが自然です。

デフォルトのコピーコンストラクタ

コンパイラが自動生成する場合がある

コピーコンストラクタを自分で定義しない場合、コンパイラが自動的にコピーコンストラクタを用意してくれることがあります。

この自動生成されたコピーコンストラクタは、基本的には各メンバを順番にコピーします。

数値型や文字列、標準ライブラリのコンテナなどをメンバとして持っている場合は、多くのケースで問題なく動作します。

たとえば、整数や文字列を持つだけのクラスであれば、自分でコピーコンストラクタを書かなくても自然にコピーできます。

自動生成されても使えない場合がある

ただし、コンパイラがコピーコンストラクタを暗黙に扱うからといって、必ずコピーできるとは限りません。

メンバの中にコピーできない型が含まれている場合、そのクラス全体もコピーできなくなることがあります。

代表的な例が、一意所有を表すスマートポインタです。

一意所有のスマートポインタはコピーできないため、それをメンバに持つクラスも、デフォルトではコピーできません。

つまり、「コピーコンストラクタを書かなければ必ず自動でコピーできる」と考えるのは不正確です。

正確には、コンパイラはコピーコンストラクタを暗黙に宣言することがありますが、条件によってはそのコピーコンストラクタが削除され、コピーできなくなる場合があります。

浅いコピーと深いコピー

浅いコピーとは

浅いコピーとは、メンバの値をそのままコピーすることです。

整数や浮動小数点数などの単純な値であれば、浅いコピーで問題になることはほとんどありません。

また、標準ライブラリの文字列や配列コンテナなどは、自分自身で適切なコピー処理を持っているため、多くの場合は安全にコピーできます。

問題になるのは、クラスが生ポインタなどを使って外部リソースを所有している場合です。

生ポインタを持つクラスで起きる問題

クラスが生ポインタを持ち、そのポインタが動的に確保したメモリを指している場合、単純なコピーは危険です。

自動生成されたコピーコンストラクタは、ポインタが指す先のデータそのものを複製するのではなく、ポインタの値、つまりアドレスをコピーします。

その結果、コピー元とコピー先の両方が、同じメモリ領域を指すことがあります。

この状態で両方のオブジェクトが破棄されると、同じメモリを二度解放しようとしてしまう可能性があります。

これが二重解放です。

二重解放は、プログラムの異常終了や未定義動作の原因になります。

深いコピーとは

深いコピーとは、ポインタのアドレスだけをコピーするのではなく、コピー先でも新しいリソースを確保し、コピー元の中身を複製することです。

これにより、コピー元とコピー先は別々のメモリ領域を持つことになります。

そのため、どちらか一方を変更しても、もう一方には影響しません。

また、破棄時にもそれぞれが自分のメモリを解放するため、二重解放を避けられます。

生ポインタでリソースを所有するクラスでは、この深いコピーを適切に実装する必要があります。

コピーコンストラクタとコピー代入演算子の違い

コピーコンストラクタは新しいオブジェクトを作る

コピーコンストラクタは、まだ存在していない新しいオブジェクトを作るときに使われます。

コピー元のオブジェクトを参考にして、新しいオブジェクトの初期状態を作るための処理です。

つまり、コピーコンストラクタの目的は「初期化」です。

コピー代入演算子は既存オブジェクトを書き換える

一方、コピー代入演算子は、すでに存在しているオブジェクトに対して、別のオブジェクトの内容を代入するときに使われます。

コピーコンストラクタとは違い、代入される側のオブジェクトはすでに構築済みです。

そのため、コピー代入演算子では、既存のリソースをどう処理するかが重要になります。

たとえば、すでにメモリを持っているオブジェクトに別の内容を代入する場合、古いメモリを解放してから新しいメモリを確保する必要があるかもしれません。

この点が、コピーコンストラクタよりもコピー代入演算子を難しくする理由のひとつです。

見た目が似ていても意味が違う

C++では、初期化と代入の見た目が似ていることがあります。

特に、オブジェクトを作るときにイコール記号を使う書き方は、初心者にとって代入に見えやすいです。

しかし、オブジェクトの宣言と同時に別のオブジェクトで初期化している場合、それは代入ではなく初期化です。

一方、すでに作られたオブジェクトに対して、あとから別のオブジェクトを入れる場合は代入です。

この違いは、コピーコンストラクタを理解するうえで非常に重要です。

Rule of Three

3つの特殊メンバ関数をセットで考える

C++には、Rule of Threeという考え方があります。

これは、次の3つのうち1つを自分で定義する必要があるなら、残りの2つも必要になる可能性が高い、という経験則です。

対象になるのは、デストラクタ、コピーコンストラクタ、コピー代入演算子です。

この3つは、特にリソース管理と深く関係しています。

なぜセットで考える必要があるのか

たとえば、クラスが動的に確保したメモリを所有している場合、破棄時にそのメモリを解放する必要があります。

そのため、デストラクタを自分で書くことになります。

しかし、デストラクタが必要なクラスは、コピー時にも注意が必要であることが多いです。

コピーコンストラクタを適切に定義しないと、複数のオブジェクトが同じメモリを所有してしまう可能性があります。

また、コピー代入演算子を適切に定義しないと、既存のリソースの解放や新しいリソースの確保で問題が起きる可能性があります。

そのため、リソースを自分で管理するクラスでは、これら3つをセットで考える必要があります。

絶対的な言語ルールではない

Rule of Threeは、C++の文法上の絶対ルールではありません。

つまり、1つを書いたら残り2つを書かなければコンパイルできない、という意味ではありません。

あくまで、設計上の重要な経験則です。

ただし、実務では非常に重要です。

特に生ポインタやファイルハンドルなど、明示的なリソース管理を行うクラスでは、この考え方を無視するとバグの原因になります。

Rule of Five

C++11以降ではムーブも考える

C++11以降では、コピーだけでなくムーブも重要になりました。

そのため、Rule of Threeに加えて、ムーブコンストラクタとムーブ代入演算子も考える必要があります。

この5つをまとめて考えるのがRule of Fiveです。

対象になるのは、デストラクタ、コピーコンストラクタ、コピー代入演算子、ムーブコンストラクタ、ムーブ代入演算子です。

ムーブはリソースの移動

コピーは、元のオブジェクトを残したまま、新しいオブジェクトに同じ内容を持たせる処理です。

一方、ムーブは、コピー元が持っているリソースをコピー先へ移す処理です。

大きなメモリ領域やファイルハンドルなどを扱う場合、コピーよりもムーブの方が効率的になることがあります。

ただし、ムーブされた後のオブジェクトは、引き続き有効な状態ではありますが、元の値を保持しているとは限りません。

そのため、ムーブ後のオブジェクトについては、破棄する、再代入する、状態を確認してから使う、といった扱いが基本になります。

Rule of Zero

現代C++では自分で書かない設計を目指す

現代のC++では、可能であればRule of Zeroを目指すのが理想です。

Rule of Zeroとは、デストラクタ、コピーコンストラクタ、コピー代入演算子、ムーブコンストラクタ、ムーブ代入演算子を自分で書かない設計のことです。

リソース管理を標準ライブラリの型に任せることで、自分で特殊メンバ関数を書く必要を減らします。

標準ライブラリに任せる

文字列には文字列用のクラスを使い、可変長配列にはコンテナを使い、所有権管理にはスマートポインタを使うことで、多くのリソース管理を安全に任せられます。

このような設計にすると、コピーやムーブ、破棄の処理を標準ライブラリが適切に行ってくれます。

その結果、自分でコピーコンストラクタを書く必要が少なくなります。

スマートポインタの性質には注意する

ただし、スマートポインタを使えば常にコピー可能になるわけではありません。

一意所有を表すスマートポインタはコピーできません。

その代わり、ムーブによって所有権を移すことができます。

一方、共有所有を表すスマートポインタはコピーできます。

コピーすると、複数のオブジェクトが同じリソースを共有する形になります。

つまり、スマートポインタを使う場合でも、その型がどのような所有権を表しているのかを理解する必要があります。

コピーコンストラクタを禁止する場合

コピーできない方が自然なクラスもある

すべてのクラスがコピーできるべきとは限りません。

たとえば、ファイル、ソケット、ミューテックス、排他的なリソースなどを扱うクラスでは、コピーを許すと意味が曖昧になったり、危険になったりします。

このような場合は、コピーコンストラクタとコピー代入演算子を禁止する設計にします。

所有権を1つに限定する

コピーを禁止する主な理由は、リソースの所有者を1つに限定したいからです。

1つのファイルハンドルや1つのメモリ領域を複数のオブジェクトが独立して所有しているように見えると、破棄や解放のタイミングが不明確になります。

そのため、コピーを禁止し、必要であればムーブだけを許可する設計がよく使われます。

この設計では、リソースの所有権を別のオブジェクトへ移すことはできますが、複製はできません。

defaultとdeleteの考え方

defaultは標準的な動作を使う指定

コピーコンストラクタにdefaultを指定すると、コンパイラが生成する標準的なコピー処理を使う、という意味になります。

これは、コピー可能であることを明示したい場合や、他の特殊メンバ関数との関係で明示的に宣言したい場合に使われます。

defaultを使うことで、自分で細かいコピー処理を書かずに、コンパイラの標準的な動作を利用できます。

deleteはコピーを禁止する指定

コピーコンストラクタにdeleteを指定すると、そのクラスのコピーを禁止できます。

コピーしようとするとコンパイルエラーになります。

これは、コピーされると危険なクラスや、そもそもコピーという操作に意味がないクラスで使われます。

ファイル、ミューテックス、一意所有のリソース管理クラスなどでは、コピーを禁止する設計が自然です。

コピーコンストラクタとムーブコンストラクタの違い

コピーは複製する

コピーコンストラクタは、コピー元の内容を使って、コピー先に同じような内容を持たせます。

コピー元はそのまま残ります。

つまり、コピー後には、コピー元とコピー先の両方がそれぞれ有効なオブジェクトとして存在します。

コピーが深いコピーとして実装されていれば、それぞれが独立したリソースを持つことになります。

ムーブは移動する

ムーブコンストラクタは、コピー元が持っているリソースをコピー先へ移します。

この場合、コピー元のリソースを新たに複製するのではなく、所有権を移す形になります。

そのため、大きなリソースを扱う場合には、コピーより効率的です。

ただし、ムーブ後のコピー元オブジェクトは、引き続き有効ではありますが、どのような値を持っているかは前提にしない方が安全です。

moveは移動そのものではない

注意したいのは、moveという操作自体がリソースの移動を実行するわけではない、という点です。

moveは、対象のオブジェクトを「ムーブできるもの」として扱えるようにするためのものです。

実際にリソースが移動するのは、その結果としてムーブコンストラクタやムーブ代入演算子が選ばれたときです。

実務での考え方

自分でコピーコンストラクタを書く機会は多くない

現代C++では、コピーコンストラクタを自分で書く機会は以前より少なくなっています。

理由は、標準ライブラリの型がリソース管理を適切に行ってくれるからです。

文字列、配列、動的メモリ、所有権管理などは、できるだけ標準ライブラリに任せる設計にした方が安全です。

生ポインタで所有権を持たない

特に重要なのは、生ポインタでリソースの所有権を持たないことです。

生ポインタを使うこと自体が常に悪いわけではありません。

しかし、動的に確保したメモリを生ポインタで所有し、その解放まで自分で管理する設計は、コピーや代入、例外処理で問題を起こしやすくなります。

実務では、標準コンテナやスマートポインタを使い、所有権を明確にすることが重要です。

書く必要がある場合は5つを意識する

どうしても自分でリソースを管理するクラスを作る場合は、コピーコンストラクタだけでなく、コピー代入演算子、デストラクタ、ムーブコンストラクタ、ムーブ代入演算子まで含めて考える必要があります。

コピーだけ正しくても、代入やムーブで問題が起きることがあります。

そのため、特殊メンバ関数は単体で考えるのではなく、クラス全体の所有権設計として考えるべきです。

よくある誤解

コピーコンストラクタは値渡しで書けるという誤解

コピーコンストラクタの引数を値渡しにすることはできません。

値渡しにすると、その引数を作るためにコピーが必要になります。

そのため、コピーコンストラクタの仕組みとして成立しません。

コピーコンストラクタでは、コピー元を参照で受け取る必要があります。

イコール記号があると代入だと思う誤解

オブジェクトを作るときにイコール記号があっても、それは代入ではなく初期化である場合があります。

宣言と同時に初期化しているなら、まだオブジェクトは存在していません。

そのため、代入ではなく、初期化として扱われます。

この場合にコピーコンストラクタが関係します。

コピーコンストラクタを書けば十分という誤解

リソースを管理するクラスでは、コピーコンストラクタだけを書けば十分とは限りません。

コピー代入演算子やデストラクタも適切に設計する必要があります。

C++11以降では、さらにムーブコンストラクタとムーブ代入演算子も考える必要があります。

自動生成されるコピーは常に安全という誤解

コンパイラが生成するコピーコンストラクタは、基本的にはメンバごとのコピーを行います。

この動作は、単純なメンバだけを持つクラスでは便利です。

しかし、生ポインタでリソースを所有している場合には危険です。

ポインタの指す先ではなく、ポインタの値だけがコピーされるため、複数のオブジェクトが同じリソースを所有してしまう可能性があります。

まとめ

コピーコンストラクタは、既存のオブジェクトから新しいオブジェクトを作るためのコンストラクタです。

重要なのは、代入ではなく初期化に関係するという点です。

コピーコンストラクタは、オブジェクトを別のオブジェクトで初期化するとき、関数に値渡しするとき、関数から値を返すときなどに関係します。

通常、コピー元はconst参照で受け取ります。

これは、コピー元を変更せず、不要なコピーを避けるためです。

コンパイラはコピーコンストラクタを自動生成することがありますが、それが常に安全とは限りません。

特に、生ポインタでリソースを所有しているクラスでは、浅いコピーによって二重解放などの問題が起きる可能性があります。

リソースを自分で管理するクラスでは、コピーコンストラクタ、コピー代入演算子、デストラクタをセットで考える必要があります。

C++11以降では、ムーブコンストラクタとムーブ代入演算子も含めて考える必要があります。

ただし、現代C++では、できるだけRule of Zeroを目指すのが基本です。

つまり、標準ライブラリの型にリソース管理を任せ、自分でコピーコンストラクタやデストラクタを書かなくて済む設計にするのが安全です。

以上、C++のコピーコンストラクタについてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次