値渡しの問題点
スタックオーバーフロー
値渡しでは、関数を呼び出すたびに、引数に渡したデータがコピーされ、スタックに積まれます。そのため、サイズの大きいデータを値渡しで渡していると、実行時にスタックが溢れてしまうことがあります。これは、C言語でもC++言語でも同様です。
コピーによるオーバーヘッド
値渡しでは、関数を呼び出すたびに、引数に渡したインスタンスのコピーが発生します。このオーバーヘッドのために、参照渡しに比べて、パフォーマンスが悪くなります。
C言語でも、サイズの大きい構造体は参照渡しとして、パフォーマンスの悪化を防ぐ、という指針が知られています。C++言語の場合は、データサイズの他に、データの複雑さも影響してきます。というのも、インスタンスのコピーが行われる際に、コピーコンストラクタが呼び出されるためです。
複雑な構造を持つクラスであれば、コピーコンストラクタに複雑なコピー処理が実装されていることがあります。C言語で構造体をコピーするように、単純にmemcpyされるだけではありませんので、注意が必要です。
浅いコピーか、深いコピーか
引数に渡したインスタンスがコピーされる時、コピーコンストラクタが呼び出されます。コピーコンストラクタの実装によっては、元のインスタンスと完全に同じデータが渡されるとは限らなくなります。
例えば、次のようなツリー構造を考えてみましょう。
ここで、「A」のインスタンスをコピーすると、「A」の配下のデータについては、どのようになるでしょうか?
ここでは、コピーコンストラクタの動作は、次の3通りが考えられます。
- 配下のデータもすべてコピーする。つまり、ツリー全体をコピーする。
-
- 「A」のインスタンスだけコピーする。配下のデータは、元のインスタンスを共有する。
-
- 「A」のインスタンスだけコピーする。コピーされた「A」は、配下に何も持たないようにする。
-
このうち、どの方法を選ぶかは、クラス設計者の考え方次第です。
クラスのインスタンスを値渡しで受け渡す時は、コピーコンストラクタがどのように実装されていて、どのようなコピー処理が行われるのかを、理解していなければなりません。
参照渡しの問題点
参照渡しでは、インスタンスそのものではなく、インスタンスへのポインタが受け渡されます。しかし、C++言語には、ガベージコレクションの仕組みがありません。そのため、参照渡しを使うと、インスタンスの廃棄をいつどこで行うべきか分からなくなってしまうという、厄介な問題が生じます。
引数にポインタを渡した際、そのポインタが、呼び出した関数の中でだけ使われる場合は、関数から戻った後で、呼び出し元でインスタンスを廃棄すればOKです。しかし、引数に渡したポインタが、呼び出した先のクラスで保持されることもあります。その時は、関数から戻っても、インスタンスをすぐに廃棄できないかもしれません。場合によっては、呼び出した先のクラスで廃棄されるので、呼び出し元では廃棄してはいけないこともあります。
ポインタを返す関数についても注意が必要です。データを参照するための関数なら、受け取ったポインタを使うだけですが、新しいインスタンスを生成して返す関数なら、呼び出した側で廃棄する必要があります。
これらは、関数の宣言だけでは区別できません。関数の実装がどのようになっているか、コメントなどを読んで、正しく理解した上で使う必要があります。
値渡しを使うと、この点について頭を悩ませる必要は無くなります。引数に渡したインスタンスは、呼び出した関数から戻る際に自動的に廃棄されます。関数が返したインスタンスは、呼び出し元の関数(正確にはスコープ)の終わりで自動的に廃棄されます。
参照渡ししか使えないケース
抽象クラスやインターフェース
純粋仮想関数を持つクラスを引数に渡す場合は、値渡しを使うことはできません。次のようなコードは、コンパイル時にエラーとなってしまいます。
class DummyInterface { public: virtual void dummy_method ( ) = 0; }; void function ( DummyInterface dummy ) { : }
値渡しでは、関数を呼び出す際にコピーが行われ、新しいインスタンスが生成されます。しかし、純粋仮想関数を持つクラスは、インスタンスを生成することができないため、エラーとなります。
オーバーライドされた関数
クラスを継承して、子クラスで関数をオーバーライドしている場合は、注意が必要です。値渡しを使うと、コンパイルは通りますが、実行時に予期しない結果になることがあります。
実験してみましょう。オーバーライドされた関数を持つ親子のクラスを作ります。
class Parent { public: virtual void print_name ( ) { puts("Parent"); } }; class Child : public Parent { public: virtual void print_name ( ) { puts("Child"); } };
値渡しと参照渡し、それぞれを使って、オーバーライドされた関数を呼び出す関数を作ります。
void function1 ( Parent data ) { data.print_name(); } void function2 ( Parent* data ) { data->print_name(); }
この2つの関数を呼び出してみましょう。
Child child; function1(child); function2(&child);
すると、実行結果は次のようになります。
Parent Child
function1, function2のどちらにも、子クラスのインスタンスを渡していますから、オーバーライドによって、どちらも「Child」と表示されて欲しいところです。しかし、値渡しを使った時は、オーバーライドが機能せず、「Parent」を表示されてしまいました。
値渡しでは、関数を呼び出す際にコピーが行われ、新しいインスタンスが生成されます。しかし、コピーとは言っても、元のインスタンスがそのまま複製される訳ではありません。生成されるのは、関数の引数で定義されたクラスのインスタンスなのです。
上記の例では、function1を呼び出した際に、新しいParentクラスのインスタンスが生成されます。その新しいParentクラスのインスタンスに、元のChildクラスのインスタンスから必要なデータがコピーされた上で、function1に渡されます。function1に渡されるのは、あくまでParentクラスのインスタンスであり、print_name関数は「Parent」を表示してしまうのです。
この問題は、実行してみないと発覚しないので、注意が必要です。
まとめ
値渡しと参照渡し、それぞれの問題点は、トレードオフの関係にあります。
C言語に比べてC++言語では、値渡しの問題が多くなっています。クラスのインスタンスを受け渡す時には、参照渡しを使うことが多くなるでしょう。
しかし、値渡しには、インスタンスの生成と廃棄について気にする必要がなくなり、コードが読みやすくなる、という利点があります。単純で小さいクラス、例えばMFCのCPointやCSizeなどのインスタンスを受け渡す場合は、値渡しを使うべきでしょう。
「値渡し(call-by-value)」「参照渡し(call-by-reference)」という用語(この他に、「名前渡し(call-by-name)」という用語もあります)は、正式には、プログラミング言語における実引数と仮引数との関係を表したものであり、C言語には参照渡しはありません。しかし、C言語において、変数をそのまま引数に渡す方法を「値渡し」、変数のポインタを渡す方法を「参照渡し」と呼んでいる場合も多く見られます。ここでは、後者の意味で「値渡し」「参照渡し」という言葉を使っています。