こんにちは。田原です。
今回は、最後にムーブの使いどころについて解説します。ムーブはC++11前までは無かった機能ですので頻繁に使われるわけではありません。意外に使いどころが難しいです。しかし、実は標準ライブラリで有効に活用されている縁の下の力持ちです。その仕組を把握しておくとプログラミングの幅が広がります。
1.関数の戻り値の場合
前回、ちょっとだけ触れましたが、関数の戻り値にムーブを使うと性能が上がりそうに感じます。しかし、RVO(Return Value Optimization)やNRVO(Named Return Value Optimization)と呼ばれる最適化手法に任せた方がより性能が上がります。その見極めの話です。
1-1.ムーブといえどもメンバ変数はコピーする
まず、ムーブは一切コピーせずに「移動」するものではありません。「移動」という言葉に反するように感じますが、そもそもメモリに記録されているデータを移動することはできません。できることはコピーだけです。
ムーブ・セマンティクスは「移動」と感じられる機能であればよいので何を移動するのか限定できないのですが、多くの場合リソースの所有権を移動します。例えばヒープ領域に獲得したメモリは誰かが解放しないといけません。権利と義務はバランスするものですので「所有権」を持つものは「開放する義務」も担うと考えるとよいと思います。この所有権(裏返すと解放する義務)を移動します。
メモリだけでなく、ファイルをオープンした時に受け取るファイル・ハンドルや、IP通信のソケット、マルチスレッド同期用のミューテックスなど多数のリソースを使ってプログラムを開発しますが、それらのリソースの所有権(解放する義務)をムーブすると考えるとイメージしやすいと思います。
そして、それらのリソースをメンバ変数を用いて管理します。ヒープ・メモリならば当該メモリへのポインタや獲得済メモリ・サイズをメンバ変数で管理するでしょう。(ファイル・ハンドルやソケット・ハンドルなども同様ですね。)
例を示します。IntVectorクラスは指定された個数のint型メモリを獲得し、そのメモリへのポインタと個数をメンバ変数(mDat、mSize)で管理しています。(他にデモのために変数名をmNameで管理しています。)
#include <iostream> #include <iomanip> #include <string> class IntVector { int* mData; std::size_t mSize; std::string mName; public: IntVector(std::size_t iSize, int iFuctor, char const* iName) : mData(new int[iSize]), mSize(iSize), mName(iName) { for (std::size_t i=0; i < mSize; ++i) mData[i]=i*iFuctor; print("constuctor"); } IntVector(IntVector&& iRhs) : mData(iRhs.mData), mSize(iRhs.mSize), mName(std::move(iRhs.mName)) { iRhs.mData = nullptr; iRhs.mSize = 0; print("move constuctor"); } ~IntVector() { delete[] mData; } int& operator[](std::size_t i) { return mData[i]; } std::size_t size() const { return mSize; } void print(std::string const& iTitle) const { std::cout << iTitle << " " << mName << "\n" << " mData(" << &mData << ") = " << mData << "\n" << " mSize(" << &mSize << ") = " << mSize; for (std::size_t i=0; i < mSize; ++i) { if ((i % 8) == 0) std::cout << "\n"; std::cout << std::setw(5) << mData[i]; } std::cout << "\n"; } }; int main() { IntVector vec0(8, 10, "vec0"); IntVector vec1=std::move(vec0); vec0.print("vec0.print"); vec1.print("vec1.print"); }
45行目でvec0をムーブしてvec1をコンストラクトしています。
さて、vec0, vec1のmData, mSizeメンバ変数のアドレスと中身を表示してみました。
mDataの値がvec0からvec1へ移動しています。これはmDataで管理しているメモリ・アドレスがvec0からvec1へ移動していることを示します。(delete nullptr;は何もしませんので、所有権を「放棄」したことになります。)
そして、mData, mSize自身のアドレスに着目して下さい。vec0, vec1のこれらはムーブの前後で変化していません。それぞれの領域は従来のクラス・インスタンスと同じ方法で確保されますから、定義した時にスタック上に確保されます。その確保時に割り当てられたメモリはムーブしても移動しません。物理的なメモリの特定番地に記録されているということは、特定のトランジスタがON/OFFしている訳です。そのトランジスタが物理的に移動することは不可能ですから、各メンバ変数も移動しません。ある意味当たり前なのです。
ムーブが使えるようになったと言っても従来からのコンピュータとC++の基本的な仕組みが変化したわけではありません。一度確保した変数を移動することはできませんので、「移動」は、中身をコピーして元の中身をクリア(無効な値を設定)することになるのです。(もちろん他の方法で「移動」してもよいです。どんな方法があるのか皆さんの想像力にお任せします。私は歳のせいか、もう思いつきません。)
従って、vec0をvec1へムーブするために、vec0のリソース管理領域(各メンバ変数)の値をvec1の該当するメンバ変数へコピーし、vec0の所有権を解除(nullptrを設定)しています。ソースを見てもそのようになっていることが分かると思います。
なお、mNameをstd::move指定して移動しています。何故に改めてstd::moveが必要なのか前回の「2.ところで右辺値参照は左辺値です」を参照下さい。
1-2.RVO/RVNOの場合
ここらで、関数からの戻り値を戻す先について考えてみましょう。
戻り値は、ある意味当たり前ですが、今まで解説してきた全ての変数、および、一時オブジェクト(式の中で使われる時と戻り値を受け取る変数がない時)のどれかへ戻ります。(誰も受け取らない時は、こっそり一時オブジェクトが受け取ってから破棄されてます。)
int型などの基本形の場合はCPU内部のレジスタが使われることもありますが、クラス・インスタンスの場合は概ねどこかのメモリへ記録されます。
それらは、静的変数領域(グローバル変数、staticなローカル変数、staticなメンバ変数)、スタック領域(非staticなローカル変数、クラス・インスタンス)、ヒープ領域ですね。全てメモリが割り当てられアドレスが決まります。
そして、これらの戻り値を受け取る変数がどこにあるのか、当然ですがコンパイラは把握しています。
ならば、その戻り先のアドレスを関数に教えて、戻り値をそのアドレスのメモリに直接作ればコピーを省略できます。
全ての戻り値についてできる訳ではありませんが、非staticなローカル変数の一部のケースでは可能です。関数から戻る時ローカル変数は破棄されますので。
1-2-1.一時オブジェクト(名前無し)の最適化(RVO : Return Value Optimization)
return文で生成した一時オブジェクトは、上記の戻り先のアドレスに直接生成できます。
先程のサンプルのIntVectorを例にして説明すると以下のようになります。
IntVector rvo() { return IntVector(2, 1, "(no name)"); } int main() { std::cout << "--- rvo() ---\n"; IntVector vec0(rvo()); vec0.print("vec0"); }
ソース・コード上はIntVector(2, 1, "(no name)")
でコンストラクトされたインスタンスがIntVector vec0(rvo());
に渡され、IntVectorのムーブ・コンストラクタが呼ばれてvec0が生成されることになります。この場合、ムーブ・コンストラクタが呼ばれたことが表示される筈ですが、でていません。
vec0のアドレスをコンパイラは知っています。そのアドレスをこっそりrvo()関数へ渡します。rvo()関数はその渡されたアドレスでIntVector(2, 1, "(no name)")
をコンストラクトすれば良いのです。
つまり、IntVector(2, 1, "(no name)")
にてreturn文を飛び越えて、直接vec0のメモリ上にコンストラクトされているというわけなのです。
実際、上記サンプルの各メンバ変数のアドレスを確認して見てください。最初にIntVector(2, 1, "(no name)")
にてコンストラクトした時と、vec0のそれらの値が同じであることが確認できます。
このような最適化は、copy elision(コピーの省略)と呼ばれています。(ムーブのなかった時代から存在する機能ですのでコピーです。)
また、次で説明するNRVOもコピーの省略です。
なお、RVOが働くとムーブ・コンストラクタはよびだされませんが、文法上は必要です。
最適化は「なるべくこっそり」行うものですから、最適化なしではコンパイルできないソースはコンパイル・エラーにするべきなのです。wandboxサンプルの#if 1を#if 0へ変更してみて下さい。コンストラクタが削除されている旨のエラーになります。
1-2-2.名前付きオブジェクトの最適化(NRVO : Named Return Value Optimization)
通常のローカル変数をreturn文で返却したいことは良くあると思います。
参照やポインタにしてローカル変数を返却してはいけませんが、ローカル変数そのものを返却するのはOKですね。
ただし、文法的にはreturnしたローカル変数がムーブ(ムーブ・コンストラクタがない時はコピー)されます。
(コピー・コンストラクタも無い時は、コンパイル・エラーで返却できません。)
さて、優秀なコンパイラは、このローカル変数の領域として、先程のrvo()関数にこっそりと渡された戻り先のアドレスを使います。
IntVector nrvo() { IntVector vec(4, 10, "vec"); return vec; } int main() { std::cout << "\n--- nrvo() ---\n"; IntVector vec1(nrvo()); vec1.print("vec1"); }
rvo()と同様に、各コンストラクタが呼ばれていないこと、および、戻したvecのメンバ変数mDataとmSize自身のアドレスが、戻り先vec1のそれらと同じことを確認下さい。
そして、一時オブジェクトとは異なりローカル変数には名前が付いているので
Visual C++ 2015/2017でも同様なテストを行ったところ、Relaseビルドでは同じように対応していました。しかし、Debugビルドの時NRVOは機能せず、ムーブ・コンストラクタが呼び出されました。Debugビルドでは最適化が緩いのであり得る話です。
1-2-3.RVO/NRVOが機能しない時
ローカル変数は関数終了後なくなります。どうせなくなるものなら最初から戻り先に構築しておけば良いですね。
しかし、もし複数の同じクラスのローカル変数を生成し、条件によってどれを返却するのか変える場合、困ります。
戻り先は1つしかないし、どれを戻すのかコンパイル時には決定できないため、どれを戻り先の領域へ構築すればよいのコンパイラは把握できません。
従って、次のようなコードはRVO/NRVOが機能しません。
IntVector no_opt(bool x) { IntVector vecx(6, 100, "vecx"); IntVector vecy(6, 100, "vecy"); if (x) return vecx; else return vecy; } int main() { std::cout << "\n--- no_opt() ---\n"; IntVector vec2(no_opt(true)); vec2.print("vec2"); }
この場合、vec2を生成するためにムーブ・コンストラクタが呼ばれ、かつ、戻り先のvec2の各メンバ変数自身のアドレスは、vecx, vecyのどちらのそれらとも異なります。つまり、vec2はvecx, vecyとは異なる領域に確保され、return時にコンストラクトされます。その際にムーブ・コンストラクタが使われます。
もし、ムーブ・コンストラクタがなければコピー・コンストラクタが使われます。
もし、コピー・コンストラクタもなければコンパイル・エラーです。
ところで、vecx, vecyは左辺値なのでstd::move指定しなくてもムーブされるのは変ですね。
これらはローカル変数ですから、return後破棄されます。ということは一時オブジェクトと同じ扱いをしても問題はでないです。(return後、破棄されるので中身が空になってもプログラマはびっくりしません。)
なので、ムーブすると決められているのだと思います。
1-2-4.std::moveを使うとどうなる?
std::moveを書かなくてもRVO/NRVOが機能しない時、ムーブされましたので少なくともローカル変数を返却する時はstd::moveを記述する必要はないですね。
グローバル変数やstaticなローカル変数、ヒープ変数はreturn後も有効ですから、std::moveを書かなければコピーさます。そして、コピー・コンストラクタがなければエラーになりますが、コピー・コンストラクタがあれば適切にコピーして返却されます。
次のサンプルは、ローカル変数とグローバル変数をムーブする例です。
また、#if 0~#endifで括ってますが、グローバル変数を普通にreturnするコードもあります。この場合、グローバル変数を勝手にムーブするべきではありませんから、コピーになります。しかし、コピー・コンストラクタがないのでコンパイル・エラーになります。
IntVector move_local() { IntVector vec(8, 1000, "vec"); return std::move(vec); } IntVector vec_global(12, 1000, "vec_global"); IntVector move_global() { return std::move(vec_global); } #if 0 IntVector copy_global() { return vec_global; } #endif int main() { std::cout << "\n--- move_local() ---\n"; IntVector vec3(move_local()); vec3.print("vec3"); std::cout << "\n--- move_global() ---\n"; IntVector vec4(move_global()); vec_global.print("vec_global"); vec4.print("vec4"); }
以下、ここまでのまとめソースとCMakeLists.txtです。
2.動的ポリモーフィズムとの組み合わせが便利
動的ポリモーフィズムは、複数の異なるクラス・インスタンスを基底クラスへのポインタで管理する手法です。
その基底クラスへのポインタを1つしか使わないケースは実は稀です。実際にはstd::vectorやstd::list等のSTLコンテナを用いて多数管理することが多いと思います。
基底クラスへのポインタを直接STLコンテナで管理してもよいのですが、リソース・リークが怖いです。
第26回目 オジブェクト指向の3大特長の3つ目「動的ポリモーフィズム」では、AutoPointerというクラスを導入してリソース・リークを回避しました。
さて、AutoPointerクラスではこっそりムーブ・コンストラクタを使ってます。なぜかというと、std::vectorで所有権を管理しているクラスを保持する場合には、ムーブが必要になるからです。
std::vectorはランダムアクセスできるようにするため内部で管理するメモリは途切れのない連続したメモリを確保しています。その領域が不足した場合に領域を拡張しますが、そのまま拡張できないことは少なくありません。その領域の次のアドレスにあるヒープ領域が既に他の目的で割り当てられている場合です。
そのような時は既存の領域を拡張するのではなく必要なサイズ分連続した新たな領域を獲得し、旧領域のデータをから新領域へコピー(①)し、旧領域(②)を解放します。
単なるint型のような基本形ならそれで問題ありません。AutoPointerのように所有権を管理しているクラス・インスタンスで問題がでます。第34回目で解説したように所有権を管理するクラスをコピーすると所有権までコピーされてしまいます。std::shart_ptrのように所有権の数を管理しているクラスなら対処できますが、所有権の数を管理していないものをコピーしてしまうと第34回で解説したようにデストラクトする度に解放される結果、同じ領域が2回以上解放されて実行時エラーになります。
この問題に対処するために、第34回目で所有権をムーブする処理を紹介しました。
std::shared_ptr補足
C++11でstd::unique_ptrと同時にstd::shared_ptrが導入されています。std::unique_ptrは所有権を1つしか管理しないため、ムーブできますがコピーできません。std::shared_ptrはコピーされた回数を管理しており、デストラクト時に回数をデクリメントします。そして、最後のインスタンスがデストラクトされる時に所有権を解放します。結構便利です。ただし、当然所有権の数をコピーされたインスタンス同士で共有する必要がありますのでその分 負荷が高いです。また、リソースの所有権を持つインスタンスが複数存在するというのは「ボスが複数いる」状態です。一度混乱が起こると手が付けられなくなります。
ですので、安易にstd::shared_ptrを使うことはあまりお勧めしません。まずはstd::unique_ptrを用い、それではどうしてもダメな場合にのみstd::shared_ptrを使う方針が安全と思います。
std::vectorで管理する所有権を持つクラス・インスタンスは上記の②で旧領域の解放時に解放されるので、新領域は既に解放した領域へのポインタを保持しており、そこをアクセスすると未定義動作になりますし、新領域が解放される時に2回目の解放になるため実行時エラーになります。そこで、上記の①の時にコピーではなくムーブすれば良いです。そのために、AutoPointerはムーブ・コンストラクタを実装したのです。
そして、そのための汎用なクラスがSTLのstd::unique_ptrです。std::unique_ptrは生のポインタとほぼ同じ負荷ですし、ボスは1人ですので安易に使って問題ありません。そして、std::unique_ptrはそれが管理する領域へのポインタをムーブすることができます。
つまり、①のタイミングでポイント先の所有権が新領域へムーブされ、②のタイミングで旧領域を解放する際に既にムーブされた後なので所有権がなく管理領域が不正に解放されることはないのです。
以下は第26回目のサンプル・ソースを修正したものです。
AutoPointerの定義を削除し、4行目でstd::unique_ptr用のヘッダをインクルードし60行目のaVectorの定義を微修正しています。
3.解放が必要な各種リソースを管理する時
メモリだけでなく、ファイルをオープンした時のハンドルや何らかの機器制御ライブラリ等から受け取ったリソースを解放する時も注意が必要です。それらは通常メモリ同様ちょうど1回だけ解放が必要です。
そのようなリソースを管理するクラスをRAIIパターンに則って作ることは多いと思います。
そして、そのクラスをムーブ対応しておく(ムーブ・コンストラクタとムーブ代入演算子を書く)と、下記のメリットが得られます。
- 必要な時は所有権を移動できるので使い勝手が良い
- コピー・コンストラクタとコピー代入演算子が自動生成されないので不用意にコピーされず安心
4.入門講座終了と応用講座開講のご挨拶
「借りたら返す」は現実世界だけではなく、コンピュータ・プログラムの世界でも重要と思います。
ファイル1つ扱う際にも要注意事項ですね。それを高いレベルでマスターできる言語がC/C++と思います。そして、「借りたら返す」を効率よくスマートに管理できる手法がRAIIパターンです。こちらはC言語では実装できません。
つまり、RAIIパターンはある意味C++の醍醐味と思います。他の多くの言語がサポートしていないC++独特の機能なのです。
そして、RAIIクラスのインスタンスは安易にコピーできません。そもそもコピーが必要なケースは稀ですので、コピーを禁止するのは好ましいです。そして、ムーブできるとstd::vectorで管理できるようになります.またRAIIクラスを生成する関数をスマートに記述できるようになります(生成するクラスを動的に指定して、戻り値で戻したい場合などに便利です)。
このようにムーブによりRAIIクラスを使える場面が大きく広がります。
ですので、入門レベルでも可能であればムーブを理解できると好ましいと思います。そして、その中では最も難しい概念でもあります。
それを解説できましたので、今回で「入門講座」を終了したいと思います。
ここまでお付き合い頂けた皆さん、お疲れ様でした。
そして、次回から「応用講座」を開講しようと思います。
まずはテンプレートの簡単な部分から入ります。テンプレートについてはSFINAE(エラーを「なかったもの」として取り扱う機能)の基本的な使い方までを数回に分けて解説予定です。その後、細かい応用的な部分について順不同で解説していきたいと思います。
なお、次週は一回お休みし、再来週から再スタート致します。
今後共よろしくお願い致します。