C++編で扱っている C++ は 2003年に登場した C++03
という、とても古いバージョンのものです。C++ はその後、C++11 -> C++14
-> C++17 -> C++20 -> C++23 と更新されています。
なかでも C++11 での更新は非常に大きなものであり、これから C++
の学習を始めるのなら、C++11
よりも古いバージョンを対象にするべきではありません。特に事情がないなら、新しい
C++ を学んでください。 当サイトでは、C++14 をベースにした新C++編を作成中です。
この章の概要です。
C言語でも C++ でも、代入演算子は、右辺の式の結果を左辺へコピーします。
= b; // b が a へコピーされる a
関数に実引数を渡す際も、コピーが行われます。
(b); // b が関数a の仮引数へコピーされる a
コピーは基本的には、コピーする大きさが大きいほど、処理に時間が掛かります。
C言語では、構造体を関数に渡す際にポインタを使うことで、構造体全体をコピーするのではなく、メモリアドレスのコピーだけで済ませる効率改善策がよくとられます(C言語編第33章)。
この手法は C++ でも同様に有用ですし、クラス型のオブジェクトをコピーするときにも適用できます。
void func(const MyClass* a)
{
->Print();
a}
int main()
{
;
MyClass b(&b); // ポインタで渡すとコストが小さい
func}
クラスと構造体は実質的には同じ概念なので、クラス型のオブジェクトのコピーもまた、すべてのメンバ変数^を1つ1つコピーします。C++ の場合、アクセス指定がありますが、たとえメンバ変数が「非公開」であったとしても関係なくコピーされます。
ただし、このコピー動作は「デフォルト」の場合の挙動です。後で取り上げますが、この挙動は変更できます。
一時オブジェクトとは、ソースコード上に直接的には記述されていない、名前がないオブジェクトのことです。名前がないので、一時オブジェクトは rvalue です。
一時オブジェクトは、それが必要となる箇所に、コンパイラが生成・破棄するコードを埋め込みます。
一時オブジェクトが作られる代表的な場面は、戻り値として、実体を返す場合です。ここでいう「実体」には、int型や double型といった組み込み型や、構造体型、クラス型などを含みます。前章で取り上げた参照は含みません。
#include <iostream>
#include <string>
std::string func()
{
return "xyz";
}
int main()
{
std::cout << func() << std::endl;
}
実行結果:
xyz
func関数の戻り値型は std::string なので、ポインタや参照ではなく、実体です。また、実際に返そうとしているものは、“xyz” という文字列リテラルです。
戻り値を返そうとする一連の流れの中に、名前の付いたオブジェクトが登場しません。しかし、戻り値を表現するための何らかのオブジェクトは必要ですから、コンパイラが、一時オブジェクトを生成するコードを挿入します。
この場面で生成される一時オブジェクトは、戻り値型に合わせた std::string型です。その初期値には、return文に与えた “xyz” が使われます。そうして作られた一時オブジェクトを、関数の呼び出し元へ返します。これはつまり、次のように書いた場合と同じことです。
std::string func()
{
return std::string("xyz");
}
このコード例のように、「型名()」という記法で明示的に一時オブジェクトを作ることがあります。一時オブジェクトを作る構文であると捉えてもいいですが、これは結局は、関数形式キャスト(第7章)です。
一時オブジェクトの破棄は、生成された場所を含んだ完全式の終わりのタイミングで行われることになっています。完全式というのは、他の式の一部になっていない式のことを指します。
今回の例でいえば、一時オブジェクトが作られるタイミングは、func関数を呼び出す式(関数呼び出し式)の中です。これは「std::cout ~ std::endl」という式の一部なので完全式ではありません。完全式なのは、「std::cout ~ std::endl」全体ですから、この式の終わりで破棄されます。
では、次のように書き換えたらどうなるでしょう?
#include <iostream>
#include <string>
std::string func()
{
return "xyz";
}
int main()
{
std::string s = func();
std::cout << s << std::endl;
}
実行結果:
xyz
この場合も、前の例と同様に一時オブジェクトが作られます。一時オブジェクトが作られるタイミングは、func関数の呼び出し式内です。完全式なのは、「std::string s = func()」なので、この全体の実行を終えたところで、一時オブジェクトが破棄されます。
破棄された一時オブジェクトへアクセスする行為は、未定義の動作です。この例では、破棄される前に変数へコピーし、コピーの方をアクセスしているので問題ありません。
一時オブジェクトをコピーで受け取る方法は安全ですが、コピーには時間がかかるかもしれません。後で取り上げるように、実体を返す行為は意外と効率よく行えることがありますが、参照を使う方法があることも確認しておきしょう。
前章で解説したとおり、const参照によって参照された rvalue は、その const参照が存在している限り、消えずに残り続けます。この方法は安全ですし、コピーが発生しないので効率的です。
#include <iostream>
#include <string>
std::string func()
{
return "xyz";
}
int main()
{
const std::string& s = func();
std::cout << s << std::endl;
}
実行結果:
xyz
少し例を変えて、今度は、std::string::c_str関数(【標準ライブラリ】第2章)が返すポインタを受け取ってみます。
#include <iostream>
#include <string>
std::string func()
{
return "xyz";
}
int main()
{
const char* s = func().c_str();
std::cout << s << std::endl; // 危険
}
std::string::c_str関数は、std::string が内部で持っている生の文字列のメモリアドレスを返します。そのため、一時オブジェクトが破棄されてしまうと、ポインタは不正なものになってしまいます。
クラス型のオブジェクトの代入によって、コピーを行うとき、そのクラス用に定義された代入演算子が使用されます。
デフォルトでは、コンパイラが自動的に生成した代入演算子が使われ、この動作は前述したとおり、すべてのメンバ変数を1つ1つコピーするというものです。
代入演算子は、次のように自分で定義できます。
class MyClass {
public:
& operator=(const MyClass& rhs);
MyClass};
& MyClass::operator=(const MyClass& rhs)
MyClass{
// 代入操作時に行う処理を記述
return *this;
}
関数名として、「operator=」という特殊な名称を用いることで、=演算子の処理を定義できます(operator と = の間にはスペースがあっても構いません)。変な名前ではありますが、これは関数名ですし、実際に関数です。
演算子に独自の定義を与えることは、=演算子に対してだけではなく、ほとんどの演算子で可能です。このように、演算子の処理を定義することを、演算子オーバーロードと呼びます。他の演算子での例は、第19章で取り上げます。
演算子オーバーロードを行う場合は、ある程度のセオリーに従うべきです。考え方としては、その演算子を int型に対して適用したときに、できることはそのままできるようにし、できないことはできないままにしておく、ということです。見慣れた int型での使い方に合わせておけば、混乱が起きにくいでしょう。
operator= は、*this
を参照として返すように実装してください。こうすることで、a = b = c;
のような連続的な呼び出しが可能になります。これは、a、b、c が
int型の変数だったとしてもできることなので、クラス型のオブジェクトであっても同様にできるべきです。
仮引数については自由度がありますが、少なくとも、自身と同じクラス型を受け付けられる必要があるでしょう。そこで仮引数は、自身と同じクラス型を参照する const参照とします。
こうして定義された operator= があれば、以下のような代入時に呼び出されます。
, b;
MyClass a= b; // b を実引数として、operator= を呼ぶ a
イメージしにくいかもしれませんが、実は次のように書くこともできることを知ると意味が分かるかもしれません。
, b;
MyClass a.operator=(b); a
【上級】a = b = c;
は
a.operator=(b.operator=(c));
と同じです。
このような記述は可能ですが、普通はわざわざこういう書き方はしません。
代入演算子を使った自然な書き方であっても、operator= を明示的に書く書き方であっても、右辺(または実引数)が「&b」ではなく「b」だという点に注目しておきましょう。これは operator= の仮引数に参照を使った効果です。もし、仮引数をポインタにしていたら、次のように書かなければならなくなります。
, b;
MyClass a= &b; // b のメモリアドレスを a に代入?
a
* p;
MyClass= &b; // ならばこれは何? p
これは不自然ですし、ポインタ変数にメモリアドレスを代入する場合との区別も付かなくなり混乱しそうです。C++ でもポインタが使えるのにもかかわらず、参照という機能が新たに追加された理由は、このような場面において、自然な記述を維持しつつ、処理コストを低減することにあります。
C++11 では、コンパイラが自動生成する関数を、生成させないように削除できます。operator= でも可能です。
class MyClass {
public:
& operator=(const MyClass& rhs) = delete;
MyClass};
プログラマーが自分で operator= を定義する場面の一例として、メンバ変数に、動的に確保された領域を指すポインタ変数が含まれているケースがあります。
class OtherClass {
};
class MyClass {
public:
(OtherClass* other) :
MyClass(other)
mObj{}
~MyClass()
{
delete mObj;
}
private:
* mObj; // デフォルトのコピー動作だと、同じものを指すポインタが2つできることになる
OtherClass};
int main()
{
(new OtherClass());
MyClass mc1(new OtherClass());
MyClass mc2
= mc1; // mc2.mObj は上書きされてしまう (delete されていない)
mc2 } // mc1, mc2 のデストラクタが呼び出されるが、それぞれ同じ OtherClassオブジェクトを delete しようとする
デフォルトの代入演算子の動作のままだと、delete が行われることなく上書きされてしまいます。
また、同じ領域を指すポインタ変数が2つになってしまう点にも注意が必要です。これは、ポインタ変数が指し示す先をコピーするのではなく、ポインタ変数自体がコピーされている点が問題です。このようなコピーは、シャローコピー(浅いコピー)といいます。
これらの問題を、operator= を自分で定義することによって回避できます。
& MyClass::operator=(const MyClass& rhs)
MyClass{
* p = new OtherClass(); // オブジェクトを新規で作る
OtherClass*p = *rhs.mObj; // オブジェクトの内容(メンバ変数)をコピー
delete mObj; // コピー先にあったオブジェクトは解放しておく
= p;
mObj
return *this; // 自身の参照を返す
}
まず、新規で OtherClassオブジェクトを作り、メンバ変数をコピーします。ここで、「*p = *rhs.mObj;」は、OtherClassクラスの operator= を呼び出していますが、これは自分で定義していないので、デフォルトの挙動のままです。もちろん、OtherClassクラスが MyClassクラスと同じ問題を抱えているのなら、OtherClassクラス用に operator= を定義しなければならないかもしれません。
その後、コピー先が持っている mObj に対する delete を行います。そして、先ほど作った新しいオブジェクトを代入します。
【上級】真っ先にコピー先の mObj を delete するように書いてしまいそうですが、new演算子の実行は失敗する可能性があるため(第14章参照)、このような順序になります。先に delete を実行すると、そのあとで new が失敗すると、コピー元がもともと持っていた情報が失われてしまうため、プログラムを正常に続行させることが困難になります。
このように、ポインタ変数の指し示す先にあるものをコピーし、ポインタ変数の方も、新しい領域を指すように作り直すコピーは、ディープコピー(深いコピー)といいます。
operator= を実装する際に注意すべき点があります。それは、「a = a;」のような使われ方をしても問題がないようにすることです。つまり、自分自身へ自分をコピーするような使い方です。このような代入操作を、自己代入と呼ぶことがあります。
普通、自己代入では何も起きないことが望ましい挙動ですから、最初に、自己代入になっていないかどうかをチェックすることが考えられます。
前の項でのサンプルプログラムの場合は、このようなチェックをしていません。このようなチェックがなくても特に問題にならないケースも多々ありますが、ここでは例として、自己代入をチェックするように修正してみましょう。
& MyClass::operator=(const MyClass& rhs)
MyClass{
if (this != &rhs) { // 自己代入でないときだけ、以下の処理を行う
* p = new OtherClass(); // オブジェクトを新規で作る
OtherClass*p = *rhs.mObj; // オブジェクトの内容(メンバ変数)をコピー
delete mObj; // コピー先にあったオブジェクトは解放しておく
= p;
mObj }
return *this; // 自身の参照を返す
}
自身のメモリアドレス (thisポインタの値) と、引数で渡されてきたオブジェクト(代入元)のメモリアドレスとが一致していたら、自己代入であることが分かります。
自己代入であることが分かったら、単に *this を return するだけにすれば、自己代入のときには何もしなくなり、目的を果たせます。
前述したとおり、実のところ、このチェックがなくても正しく動作します。それでもこのチェックを入れる価値として、無意味なコピーを行うコストを省けるというものがあります。しかし、自己代入をチェックすること自体にもコストは掛かるので、自己代入が頻繁に起こらないのであれば、むしろチェックしないという方針も考えられます。
代入演算子によるコピーは、すでに作られているオブジェクトを、すでにある変数に代入するときにだけ関係するものです。
コピーのもう1つの形として、新しいオブジェクトを作るときに、既存のオブジェクトから複製するというものもあります。
= a; // a をコピーして b を作る MyClass b
知ってのとおり、新しいオブジェクトが作られるときにはコンストラクタが呼び出されますが、コピーによって生成する場合には、コピーコンストラクタという特殊なコンストラクタが呼び出されます。
コピーコンストラクタは、次のように定義します。
class MyClass {
public:
// コピーコンストラクタ
(const MyClass& rhs);
MyClass
private:
* mObj;
OtherClass};
// コピーコンストラクタ
::MyClass(const MyClass& rhs) :
MyClass(new OtherClass())
mObj{
*mObj = rhs.mObj;
}
コピーコンストラクタの仮引数は、自身のクラスの const参照か、非const参照とします。この引数は、コピー元のオブジェクトを参照するものです。意味合いからいって、コピー元を変更する必要はないので、const参照にするのが普通です。
MyClass(const MyClass& rhs, int option = 0);
のように、後続にデフォルト実引数があっても、MyClass b = a;
のように使うことができるので、コピーコンストラクタとして機能します。
【上級】コピーコンストラクタの仮引数を、非const な参照にする数少ない実例の1つに、標準ライブラリの std::auto_ptr があります(【標準ライブラリ】第16章)。
operator= の実装と比べると、コピーコンストラクタの実装はわりと単純です。決定的な違いとして、operator= の場合は、コピー先がもともと持っていた情報を適切に解放するといった処理が必要になる点です。コピーコンストラクタは、これからオブジェクトが新規作成されるタイミングで呼び出されるものなので、「もともと持っている」ものは何もありません。単に、各メンバ変数が適切に初期化できれば、それだけで十分です。
コピーコンストラクタも operator= と同様、自分で書かなければコンパイラが自動的に生成します。デフォルトの動作は、すべてのメンバ変数をシャローコピーするというものです。
代入演算子と同様、コピーコンストラクタも自動生成されますが、C++11 では明示的に記述できます。
class MyClass {
public:
(const MyClass& rhs) = default;
MyClass};
代入演算子と同様、コピーコンストラクタも自動生成されますが、C++11 では自動生成させずに削除できます。
class MyClass {
public:
(const MyClass& rhs) = delete;
MyClass};
ここで、以下のコード片を考えてみます。
;
MyClass a.func(); // a に変更が加わる
a
; // コンストラクタが呼ばれる
MyClass b= a; // a と同じ状態の b を作る b
これは無駄があります。「MyClass b;」の時点で、コンストラクタが呼び出されて、何らかの初期化処理が行われています。その後、「b = a;」でコピーを行うので、コンストラクタで初期化した内容は恐らく上書きされます。よって、コンストラクタが行った初期化処理が無意味なものになってしまうでしょう。
そこで、コピーコンストラクタを使って次のように書き替えます。
;
MyClass a.func(); // a に変更が加わる
a
= a; // a と同じ状態の b を作る MyClass b
前の例だと、コンストラクタ+代入演算子という2段構えになっていたのに対し、後の例だと、コピーコンストラクタだけで、オブジェクト b が作られます。この方が無駄が無く効率的です。
なお、コピーによるオブジェクトの作成は、次のように書くこともできます。
(a); // a と同じ状態の b を作る MyClass b
【上級】a と b の型が異なる場合に限っては、「C b = a;」よりも「C b(a);」の方が効率が良い可能性があります。前者の書き方だと、a の型を b の型に変換する処理を行うために、一時オブジェクトを生成し、その一時オブジェクトをコピーコンストラクタに引き渡すような形にコンパイルされることがあります。後者の書き方では、一時オブジェクトを作ることはありません。
クラスの目的によっては、オブジェクトがコピーできない方が都合が良いケースがあります。しかし、operator= やコピーコンストラクタは、コンパイラが自動的に生成してしまうので、何も対策を講じなければ、コピーできてしまいます。
コピーを禁止する基本的な対策は、operator= とコピーコンストラクタを「非公開」、つまり private にすることです。
class MyClass {
public:
(); // コピーコンストラクタを明示的に宣言すると、
MyClass// 他のコンストラクタは自動生成されなくなるので、必要なら明示的に書くこと
private:
(const MyClass&);
MyClass& operator=(const MyClass&);
MyClass};
このとき、operator= とコピーコンストラクタの定義を書かないようにします。「非公開」にしていても、MyClassクラスの他のメンバ関数内からは呼び出せてしまうので、これも禁止するためです。定義がない関数を呼び出そうとすると、リンクエラーになるので、このような身内からの呼び出しを防ぐことができます。
なお、C++ では、使用することがない仮引数の名前は、付けなくても構いません。
【上級】非公開継承(第28章)を利用すれば、MyClassクラスの他のメンバ関数からの呼び出しもコンパイルエラーにすることが可能です。詳細な解説は、外部サイト (More C++ Idioms)を参照してください。
コピーを避けるため、戻り値を実体で返したくないと思うことがあるかもしれませんが、実体の戻り値を返しても、効率的なコードが生成される可能性は高いです。
まず、次のプログラムを使って、オブジェクトの生成や破棄がどれだけ行われるのかを調べてみます。
#include <iostream>
class C {
public:
() { std::cout << "constructor" << std::endl; }
C(const C&) { std::cout << "copy constructor" << std::endl; }
C~C() { std::cout << "destructor" << std::endl; }
};
()
C func{
return C();
}
int main()
{
= func();
C c }
素直に処理を追いかけて考えると、以下の4つの呼び出しが起こるように思えます。
しかし実際に試すと、以下の結果を得られるでしょう。
実行結果:
constructor
destructor
つまり、コピーコンストラクタの呼び出しが省かれ、その分、デストラクタも1回分減っているということです。
省略されたコピーコンストラクタは、一時オブジェクトを生成する際のものです。これを省略して、代わりに、関数の呼び出し側の方へ直接、オブジェクトを生成するようなコードが生成された結果です。このように、余分なコピーが省かれ、効率が向上しています。
これは、コンパイラが行う最適化の一種で、RVO (Return Value Optimization: 戻り値の最適化) と呼ばれています。この最適化がすべてのコンパイラで行われる保証はありませんが、まず間違いなく行われるといえるほど一般的なものです。
Visual Studio での Debugビルド時のように、基本的に最適化を行わない方針のビルドは除きます。
先ほどのサンプルプログラムで、RVO によって呼び出されなくなったコピーコンストラクタとデストラクタには、標準出力への出力という処理が含まれていたことに注目してください。つまり、何か副作用を持った処理が含まれていたとしても、容赦なく省略しています。普通、コンパイラが行う最適化は、意味が変わらないように行われるものですから、これは非常に特殊です。
さて、プログラムを次のように変形すると、結果が変わるかもしれません。
#include <iostream>
class C {
public:
() { std::cout << "constructor" << std::endl; }
C(const C&) { std::cout << "copy constructor" << std::endl; }
C~C() { std::cout << "destructor" << std::endl; }
};
()
C func{
;
C creturn c;
}
int main()
{
= func();
C c }
Visual Studio 2017 の Debugビルドで試すと、次の結果を得られます。
実行結果:
constructor
copy constructor
destructor
destructor
何も最適化されていないようです。
前のサンプルプログラムでは一時オブジェクトを返していましたが、今回は名前のあるオブジェクトである点に違いがあります。このサンプルプログラムの形であっても、前のサンプルプログラムと同じ理屈で最適化を行うコンパイラが多くあります。たとえば、clang は最適化を行って、次の実行結果を出力します。
実行結果:
constructor
destructor
最適化の理屈は同じで、コピーを省略し、その結果、デストラクタの呼び出しも消えます。前の最適化と同じ結果になるので、これも RVO と呼ぶことがありますし、一時オブジェクトではなく名前を持ったオブジェクトであることを強調して、NRVO (Named Return Value Optimization: 名前付き戻り値の最適化) と呼び分けることもあります。
NRVO を働かせるためには、ローカルオブジェクトの型が関数の戻り値型と同じであることと、return文に与える式が、そのローカルオブジェクトの名前だけであることを守らなければなりません。ただし、関数の仮引数を return する場合には NRVO は働かないのが普通です。
問題① 次のプログラムはコンパイルエラーになります。理由を説明してください。
#include <string>
void func(std::string& s) {}
int main()
{
("abc");
func}
問題② ある関数の仮引数が次のようになっているとき、実引数が渡される際に何が行われているか説明してください。
void func(std::string s);
void func2(const std::string& s);
問題③ 次のプログラムのコメント部分では何が行われているかを、特に、コンストラクタ、コピーコンストラクタ、operator=、デストラクタといった関数のどれが呼び出されているのかという観点から説明してください。
class MyClass {
};
(MyClass mc)
MyClass func1{
return mc;
}
* func2(MyClass* mc)
MyClass{
return mc;
}
& func3(MyClass& mc)
MyClass{
return mc;
}
int main()
{
; // A
MyClass a= a; // B
MyClass b (b); // C
MyClass c* d; // D
MyClass
= a; // E
c
= func1(a); // F
c = func2(&a); // G
d = func3(a); // H
c }
VisualStudio 2015 の対応終了。
コピーに関する話題を、第16章から移動。
章のタイトルを変更(「一時オブジェクト」–>「コピー」)
「参照による束縛」の項を削除し、第16章で解説するようにした。
「C++11 (rvalue reference、右辺値参照)」「C++11
(参照修飾子)」の項を削除。
「C++11 (rvalue reference、右辺値参照)」「C++11 (参照修飾子)」の項の内容を削除。同じ内容を解説している Modern C++編のページへのリンクだけを残した。
「RVO (戻り値の最適化)」の項を全面的に書き直した。
VisualStudio 2013 の対応終了。
≪さらに古い更新履歴を展開する≫
Programming Place Plus のトップページへ
はてなブックマーク に保存 | Pocket に保存 | Facebook でシェア |
X で ポスト/フォロー | LINE で送る | noteで書く |
RSS | 管理者情報 | プライバシーポリシー |