開発ブログ

雑多に書きます

C++:関数の引数の書き方

多くの学生作品を見ていると、C++関数の引数の渡し方を正しく書いている人が殆どいません。多くの人が、この辺りを割とうろ覚えのままで書いている印象です。私が作品講評とかする時に必ず指摘する箇所の1つで、社員応募作品のチェック時にはC++を理解しているかどうかの指針として見ている、とても重要なポイントです。

何故重要かというと、C++ではこの部分を間違った書き方をしていると非常に重い処理になってしまう事が多々あるからです。

まずはおさらい

C++関数の引数の渡し方は3つあります。

  • 値渡し
  • 参照渡し
  • ポインタ渡し

値渡し

  • C++は引数を渡す場合、通常この「値渡し」を行います
  • 「値渡し」は呼び出し元の変数の内容をコピーして渡します
  • なので、関数内で引数の内容を変更しても、呼び出し元の変数は変更されません
  • コピーなので、変数の使用しているメモリ領域が大きいと、その分のメモリコピーが行われます

ポインタ渡し

  • 「ポインタ渡し」は呼び出し元の変数が入っているメモリ領域の先頭アドレス(ポインタ)をコピーして渡します
  • なので、関数内でポインタが指す変数の内容を変更すると、呼び出し元の変数も変更されます
  • 変数の使用しているメモリ領域が大きくても、ポインタを渡すだけなので高速です

参照渡し

  • 「参照渡し」は「ポインタ渡し」と同様に呼び出し元の変数が入っているメモリ領域の先頭アドレスを渡します
  • なので、関数内で引数(参照)の内容を変更すると、呼び出し元の変数も変更されます
  • アセンブラレベルでの動作は「ポインタ渡し」と同じですが、渡されるのがポインタ型ではなく参照型である為、「ポインタ型」にある+, ++, -, -- 等の演算子でアドレスを操作できません。ポインタでは可能だった変数メモリ領域外のアクセスを防ぐことができるため、C++では基本的に「ポインタ渡し」代わりに「参照渡し」をします

同じ int を掛け算する関数を、それぞれの渡し方で書いてみると以下のようになります。

int  mult_call_by_value    (int  x, int  y) { return   x  *   y ; }
int  mult_call_by_pointer  (int* x, int* y) { return (*x) * (*y); }
int  mult_call_by_reference(int& x, int& y) { return   x  *   y ; }
 
void main() {
    int  a = 10, b= 5;
    int  r1 = mult_call_by_value(a,b);
    int  r2 = mult_call_by_pointer(&a,&b);
    int  r3 = mult_call_by_reference(a,b);
}

使い分け

さて、ここまでは理解している人は多いと思います。困ったことに、どれを使ってもC++文法上では間違いではありません。しかし、書き方次第で負荷が大きく変わってきます。「どういう場合に使うのか?」「どういう書き方にすべきなのか?」というのはC++の動作の仕組みを理解していると、ルールがおのずと見えてきます。

まず、基本型がそれ以外で書き方を分けます。ここでいう基本型とは void 型を除いた int, bool, char, short, long, float double, wchar_t とそれをtypedef したものです。基本型というのはCPUが直接扱える型でありメモリへのコピーは1回で済みます。基本型以外の型(クラス、構造体、配列)は多くのメモリ領域を使用していることが多く、場合によっては 数100~数1000byteのメモリを使用しています。関数を呼び出すたびに大量のメモリのコピーが行われるので非常に重くなります。

その為、基本型以外の変数は必ず「参照渡し」にしてください。そして参照渡しては元の値が書き換えられてしまう可能性があるので、それを禁止する為に const をつけます。

  • 基本型以外(クラス、構造体、配列)はconst付きの参照渡し
void  func( const Typename& arg );  // 基本型以外

逆に、基本型は値1つのみの最小限のメモリコピーになります。呼び出し規約によってはメモリを介さずにCPUのレジスタでやり取りするので、非常に高速に渡せます。その為、必ず値渡しにします。元の値を書き換えることが出来ないので const をつける必要がありません。

  • 基本型は値渡し
void  func( Typename arg );         // 基本型

関数から値を受け取る場合は、型には関係なく以下の書き方になります。この場合、引数名に "out_~"をつけるようにすると区別がつけやすいです。(ここら辺は各社さんのコーディングルールによりますが)

  • const 無しの参照渡し
void  func( Typename& out_arg );  // 引数で返り値をもらう場合

さて、最後にポインタ渡しについてです。
ポインタ渡しは参照型で書けるので参照渡しで書くのが基本ですが、そもそも new でオブジェクトを生成した時の型はポインタ型になるので、ポインタ渡しを完全に排除することはできません。現場でも以外に多く使われています。以下の例の様にオブジェクトを受け渡したり、他で保持する為に渡す場合はポインタ型で扱う事になります。

  • new で生成したオブジェクトを他のクラス(例えばマネージャークラスや親クラス)で保持する為にポインタを渡す
  • キャッシュとしてコピーしてメンバとして保持する場合

const を付けるか付けないかに関しては、ポインタを保持する側で変更処理をさせるか、させないかで、付ける、付けないが変わります。

void  func( const Typename* arg );  // ポインタを保持する側が変更できない想定で、渡す。
void  func( Typename* arg );        // ポインタを保持する側が変更できる想定で、渡す。

補足

呼び出し規約について

いずれブログで取り上げますが、呼び出し規約を簡単に説明します。

呼び出し規約とは、関数が呼び出される時に引数と返り値をどの様に受け渡すかのルールを定めたものです。スタックに何を積むか、レジスタは何を指しているか、関数内でどのレジスタを保存するか、等を取り決めています。当然ながら呼び出し側と呼び出される側が同じ呼び出し規約でないと、引数や返り値が正しく受け渡しができないので、リンク時にエラーが起こります。

呼び出し規約によっては、通常はスタックに積む引数をレジスタで渡すようにもできるので、指定の仕方でより高速に関数呼び出しが可能になります。

参照渡しの例外

CEDEC 2016 の スクウェア・エニックスさんの最適化に関する講演にて、Vector型を参照渡ししない方が高速になる、という例が載っています。 http://cedil.cesa.or.jp/cedil_sessions/view/1536

ただ、書類だけだとわかりにくいですが、SIMDを使用した組み込み関数の例で紹介しているので、以下の様に Vector型は __m128型で定義されているものと想定できます。__m128 はC++の基本型ではないですが、コンパイラ拡張機能として、CPUが扱えるレジスタとして1回のコピーで処理できるため値渡しの方が高速になる、という事例です。

class Vector { 
private:
    __m128 xmm;
public:
    Vector () {}; 
    //    :
}; 

参照渡しにするのはメモリのコピー量が問題になるので、基本型以外でもコピーするメモリ容量が少なければ処理負荷がそこまで高くはなりません。ただクラス等は修正を重ねるうちに肥大化することも多いので、やはり参照渡しにしておくべきと考えます。数が限定的で、なおかつ今後増えない、といった場合には例外的に「値渡し」でも良いでしょう。例えば基本型のペア型(std::pair<>)、基本型3~4つのタプル型(std::tuple<>)位までは許容範囲と考えます。