明けましておめでとうございます。本年もよろしくお願い申し上げます。
お久しぶりの田原です。去年から引き続いてテンプレートの解説を続けますね。
さて、今回は関数テンプレートのパラメータの型推論について少し補足します。関数テンプレートのパラメータ周りの機能は超複雑で私も理解を放棄している部分(例えば、オーバーロード解決の優先順位とか)も少なくないのですが、知っているとちょっと便利な機能についての解説です。
1.配列の渡し方
1-1.通常の関数の場合
通常の関数で普通に配列を渡すと、強制的にポインタへ成り下がり要素数が失われます。
#include <iostream> void foo(int param[5]) { std::cout << "sizeof(param)=" << sizeof(param) << "\n"; } int main() { int array[]={0, 1, 2, 3, 4}; std::cout << "sizeof(array)=" << sizeof(array) << "\n"; foo(array); }
sizeof(array)=20 sizeof(param)=8
配列の定義が直接見えるところでは配列の要素数をコンパイラが把握できるため、sizeof()はその配列自体に割り当てられているバイト数を返します。Wandboxのgccのintのバイト数は4バイトですから、4×5=20で20が表示されます。
しかし、関数の引数の場合、上記foo関数のように要素数が5個であることを明示しても、配列ではなくポインタとして受け取ります。その結果、sizeof()はポインタのバイト数を返却します。Wandboxでは64ビットでビルドされるためポインタのサイズは8バイトあるので 8 が表示されるのです。
C言語との互換性重視でC++標準規格ではこのように決まっていますが、間違い易い部分だからでしょう gcc では警告が次のように表示されます。(prog.ccはWandboxが内部的に割り当てているソース・ファイル名です。)
prog.cc: In function 'void foo(int*)': prog.cc:5:50: warning: 'sizeof' on array function parameter 'param' will return size of 'int*' [-Wsizeof-array-argument] std::cout << "sizeof(param)=" << sizeof(param) << "\n"; ^ prog.cc:3:21: note: declared here void foo(int param[5]) ^
では、配列の要素数を維持したまま渡す術がないかというと実は渡せます。
配列へのポインタや参照として渡せば要素数は維持されます。
#include <iostream> void foo(int (*param)[5]) { std::cout << "foo : sizeof(*param)=" << sizeof(*param) << "\n"; } void bar(int (¶m)[5]) { std::cout << "bar : sizeof(param) =" << sizeof(param) << "\n"; } int main() { int array[]={0, 1, 2, 3, 4}; std::cout << "sizeof(array)=" << sizeof(array) << "\n"; foo(&array); bar(array); }
sizeof(array)=20 foo : sizeof(*param)=20 bar : sizeof(param) =20
C言語でもできます
C言語には参照がありませんが、ポインタならC++と同じ考え方で渡せます。
1-2.関数テンプレートにすると便利
しかし、関数を定義する時点で要素数を決めておく必要があります。要素数が変わると関数定義も変える必要がありますので複数の要素数に対応する関数を定義できません。
上記の「要素数も含めて渡す例」のmain()関数に次のような処理を追加すると、要素数が異なるので引数を変換できないエラーがでてコンパイルできません。
int array2[]={0, 1, 2}; foo(&array2); bar(array2);
このような時は、テンプレートの出番です。
#include <iostream> #include "typename.h" template<typename tType> void foo(tType* param) { std::cout << "foo : sizeof(*param)=" << sizeof(*param) << " : tType=" << TYPENAME(tType) << "\n"; } template<typename tType> void bar(tType& param) { std::cout << "bar : sizeof(param) =" << sizeof(param) << " : tType=" << TYPENAME(tType) << "\n"; } int main() { int array[]={0, 1, 2, 3, 4}; std::cout << "sizeof(array) =" << sizeof(array) << "\n"; foo(&array); bar(array); int array2[]={0, 1, 2}; std::cout << "sizeof(array2)=" << sizeof(array2) << "\n"; foo(&array2); bar(array2); }
sizeof(array) =20 foo : sizeof(*param)=20 : tType=int [5] bar : sizeof(param) =20 : tType=int [5] sizeof(array2)=12 foo : sizeof(*param)=12 : tType=int [3] bar : sizeof(param) =12 : tType=int [3]
このように、tTypeは型推論により、実際に呼び出した時に渡した配列の型として解決されます。
foo()のparamは’tType*’型ですのでそれぞれの配列へのポインタ型、bar()のparamは’tType&’型ですのでそれぞれの配列への参照となります。
1-3.要注意事項があります
ちょっと間違いやすいケースがあります。下記のbaz()のようにtTypeとして仮引数を定義することです。
// (前略) template<typename tType> void baz(tType param) { std::cout << "baz : sizeof(param) =" << sizeof(param) << " : tType=" << TYPENAME(tType) << "\n"; } // (中略) baz(array); // (中略) baz(array2); // (後略)
// (前略) baz : sizeof(param) =8 : tType=int* // (中略) baz : sizeof(param) =8 : tType=int*
原則として配列名は当該配列先頭要素へのポインタとなりますので、このルールに従いparamはint型へのポインタへ型推論されてしまうのです。こちらは普通に使う使い方ですから警告はでませんので要注意です。
Wandboxで確認する。
2.std::forwardで左辺値と右辺値を中継
C++11で右辺値参照が導入され、呼び出した側でもう使わない記憶領域を引き渡しやすくなりました。そして更に関数テンプレートの型推論も右辺値参照に対応してます。
右辺値参照により、式の終わりで破棄される一時領域(右辺値)は暗黙的にムーブできるけど、式の後でも有効な変数(左辺値)は明示的にムーブ許可した時のみムーブできるようになりました。これはC++らしい性能を上げつつバグを減らすために有用な仕組みです。これがより使いやすくなっていますので、その点を解説します。
2-1.通常の関数の場合
以前解説したように、左辺値参照(通常の参照)では一時領域(右辺値)を受け取れません。それを受け取れるようにしたものが右辺値参照です。(従来のconst参照でも受け取れますが、constなので内容を変更できないためムーブできません。)
ですので、元の値を残したまま受け取る関数と、元の値をムーブして受け取る関数をオーバーロードして2つ用意する必要があります。
とこで、DispHelperはパラメータiStringの情報をコンストラクタの初期化子リストで出力するために作ったクラスです。このような使い方をすることは稀ですが、今回は解説のためにちょっと無理っと使ってます。
#include <iostream> #include <string> struct DispHelper { DispHelper(char const* iTitle, void const* iPointer) { std::cout << iTitle << iPointer << "\n"; } }; class Foo { DispHelper mDispHelper; std::string mString; public: // コンストラクタ1(const参照で受け取る) Foo(std::string const& iString) : mDispHelper("Copy:\niString.data()=", iString.data()), mString(iString) { std::cout << "mString.data()="; std::cout .operator<<(mString.data()); std::cout << "\n"; } // コンストラクタ2(右辺値参照で受け取る) Foo(std::string&& iString) : mDispHelper("Move:\niString.data()=", iString.data()), mString(std::move(iString)) { std::cout << "mString.data()="; std::cout .operator<<(mString.data()); std::cout << "\n"; } }; int main() { Foo aFoo0(std::string("0123456789012345")); std::string aString("abc3456789012345"); Foo aFoo1(aString); std::cout << "aString = " << aString << "\n"; Foo aFoo2(std::move(aString)); std::cout << "aString = " << aString << "\n"; }
Move: iString.data()=0xc45de0 mString.data()=0xc45de0 Copy: iString.data()=0xc46160 mString.data()=0xc46180 aString = abc3456789012345 Move: iString.data()=0x122a160 mString.data()=0x122a160 aString =
それぞれのコンストラクタが呼ばれた時、iStringのデータ領域のアドレス(data())と、それを受け取った後のmStringのデータ領域のアドレスを表示しています。
アドレスが異なるということは、mStringに別途領域が確保され、そこにiStringの文字列がコピーされたということです。
アドレスが同じということは、mStringに新たに領域は確保されず、iStringの文字列の所有権がmStringへムーブされたことが分かります。(mStringのデータ領域として元々iStringのデータ領域だったメモリが割り当てられているのですから。)
最初は、Fooのコンストラクタに一時領域(右辺値)を渡したため、Fooクラスのコンストラクタ2が呼ばれています。
次は、Fooのコンストラクタに左辺値を渡してます。右辺値参照は左辺値を受け取らないのでコンストラクタ1が呼ばれています。
最後は、Fooのコンストラクタにstd::move付きで左辺値を渡しています。この場合、右辺値を渡したのと同じことになりますから、コンストラクタ2が呼ばれます。
余談ですが、std::stringのSSO(Small-string optimization)について
std::stringは実は短い文字列ならば、ヒープ領域ではなく自分自身の管理領域(文字列へのポインタや文字数を記録している変数等)に文字列を記録します。SSOと呼ばれています。
上記の例は、16文字の文字列を設定しているのでgccの場合はSSOが働かないため、ヒープ領域に文字列が記録され、ムーブできます。もし、15文字以下にした場合、SSOによりiString自身に記録されます。iString自身のメモリはiStringが開放されると同時に開放されるのでムーブできません。(ムーブ先が開放される前にムーブ元が開放されると異常になりますので。)ですので、SSOが機能している場合はコピーされます。(元々ムーブの時にもコピーされる領域ですから性能は劣化しません。)
試しに各文字列を短くしてみて下さい。コンストラクタ2が呼ばれてムーブ動作した場合でもコピーされます。
SSOについては、Wandboxを開発公開されているmelponさんが詳しく解説されています。興味のある方は参考にされて下さい。
2-2.関数テンプレートにして1つにまとめたい
上記の例では、コピー用とムーブ用でコンストラクタを2つ作りました。解説用のサンプルですので各コンストラクタの中身は事実上空です。しかし、一般にはそれなりに中身を書くと思います。そして、どちらも本質的には同じ内容ですから、中身は同じものになる場合も少なくないでしょう。(というか、普通は同じになりそうです。)
それなのに2つ書かないといけないのは頂けません。同じものを2つ書くと、仕様変更時は2箇所修正し2倍のテストを実行しないと行けません。嫌ですね。
そんな時は、テンプレートが便利です。関数テンプレートで2つのコンストラクタを纏めるよう試みます。
通常関数の場合、型名に&&を付けると右辺値を受け取りますが、左辺値は受け取れませんでした。
しかし、関数テンプレートは拡張されており、型パラメータに&&を付けるとどちらも受け取れるのです。
この時、左辺値を渡すとiStringは通常の参照(左辺値参照)となり、右辺値を渡すとiStringは右辺値参照となります。
template<typename tType> Foo(tType&& iString) : mDispHelper("normal:\niString.data()=", iString.data()), mString(iString) { std::cout << "mString.data()="; std::cout .operator<<(mString.data()); std::cout << "\n"; }
normal: iString.data()=0x1de6de0 mString.data()=0x1de7160 normal: iString.data()=0x1de6de0 mString.data()=0x1de7180 aString = abc3456789012345 normal: iString.data()=0x1de6de0 mString.data()=0x1de6e00 aString = abc3456789012345
あああ、忘れてます!!
最初と3番めのaFoo?
のコンストラクト時は右辺値を渡してますので、iStringは右辺値参照です。
しかし、右辺値参照は左辺値です。orz
従って、mString(iString)で渡しているiStringは左辺値ですからムーブされずにコピーされます。だから、全てデータのアドレスが異なりコピーされています。(ああ無情)
2-3.そこでstd::moveしてみます
template<typename tType> Foo(tType&& iString) : mDispHelper("std::move:\niString.data()=", iString.data()), mString(std::move(iString)) { std::cout << "mString.data()="; std::cout .operator<<(mString.data()); std::cout << "\n"; }
これでどうだ!!
std::move: iString.data()=0x257ede0 mString.data()=0x257ede0 std::move: iString.data()=0x257f160 mString.data()=0x257f160 aString = std::move: iString.data()=0x7ffc0f4642e0 mString.data()=0x7ffc0f464288 aString =
ぐはっ。真ん中のコピーさせたい時もムーブされ、aStringの中身がなくなってしまいました。
std::moveでムーブを許可したのですから、当然です。
でも、Fooのコンストラクタを呼び出した人はムーブを許可していません。ムーブを許可していないのにムーブするとは、「返してね」と言っているのに借りパクされたようなものです。万死に値します。
2-4.こんな時にstd::forwardを使います
2-3の問題は、右辺値参照が左辺値だから発生する問題です。
ならば、通常の参照の時はそのまま、右辺値参照の時だけ右辺値にできれば解決します。
そんな都合のよいツールが、std::forwardです。
実際には通常の参照を受け取ると参照(左辺値)を返却し、それ以外の時は右辺値を返却します。
普通、関数の戻り値は右辺値です。ただし、参照を返却すると左辺値となります。std::forwardも関数の一種ですので、この辺の原理をうまいこと使って実装されているようです。
#include <utility> // (中略) template<typename tType> Foo(tType&& iString) : mDispHelper("std::foward:\niString.data()=", iString.data()), mString(std::forward<tType>(iString)) { std::cout << "mString.data()="; std::cout .operator<<(mString.data()); std::cout << "\n"; }
std::foward: iString.data()=0xbd3de0 mString.data()=0xbd3de0 std::foward: iString.data()=0xbd4160 mString.data()=0xbd4180 aString = abc3456789012345 std::foward: iString.data()=0xbd4160 mString.data()=0xbd4160 aString =
最初と3番目はデータのアドレスが同じですのでムーブされ、真ん中はちゃんとコピーされています。
引数をコピーで受け取るかムーブで受け取るかの差だけで2つ関数を書くのは嫌でしたが、これでやっと1つにまとめることができました。std::forwardサマサマです。
なお、std::forwardはutilityヘッダで定義されていますので、これのインクルードが必要です。
3.まとめ
今回は関数テンプレートのパラメータ周りのちょっとありがたい特殊な機能について解説しました。
特にstd::forwardは結構難しくて私もなかなか理解できず、比較的最近になってやっと使い始めた機能です。しかし、ムーブをスマートに使う際には便利なものですからマスターできると一段と凄いC++erになれると信じてます。
さて、次回は可変長引数について解説します。C++11以降3つの可変長引数が使えます。昔ながらのC言語のprintfでも使われているもの、C++11で正式に制定されたマクロの可変長引数、そして同じくテンプレートの可変長引数です。この内のテンプレートの可変長引数について解説します。お楽しみに。