C++プログラマーで値あるいはオブジェクトの「コピー(Copy)」
その操作は構文としては代入演算子"="で表現されます.
一方,比較的新しい概念として「移動(Move)」と呼ばれる操作が存在します.左矢印の"<-"で表現することにします.
int i = 3; int j = 1; j <- i; // jは3になる.iが何になるかということは関知しない.
- なぜこのような操作を考えるのか?
- 値が移動した後の抜け殻のオブジェクトはどうなるのか?
- 具体的な応用例は?
- 現在の言語規格内でエミュレートできるか?
なぜMove Semanticsを考えるのか
Move Semanticsのモチベーションは主に以下の2点です.
- 例外安全性
- 効率
- コピーと移動の明確な区別
Move Semanticsがその効力を発揮するのはメモリなりファイルなりの資源(resource)を獲得し,それらを保持し,それらを解放する責任を持つクラスです.
このようなクラスにおいて,そのオブジェクトが保持している資源を移動するという操作を考えてみます.仮にそのクラスにコピーのそうさのみ許されていると仮定した場合,オブジェクトaが保持している資源をオブジェクトbに移動するという操作を行うには以下の手続きを踏まなければなりません.
- オブジェクトbを生成し,同時にオブジェクトbが保持するべき資源を獲得する
- 必要があればオブジェクトaの状態をオブジェクトbにコピーする
- オブジェクトaが保持していた資源を破棄し,オブジェクトaを解体する
今,具体的にstd::vectorのように内部に動的なバッファを確保するクラスMyArrayを考えてみます.このクラスは例えばバッファへのポインタとバッファサイズを内部に持っているでしょう.
class MyArray { ..... T *p_; std::size_t n_; };
MyArray a; ..... MyArray b(a); // (A)
コピーコンストラクタ(A)で行われることを列挙してみましょう.
- aが保持しているバッファの値を保持できるだけの十分なメモリを獲得する.
- aのバッファの内容を新たに獲得したメモリ上にコピーする.
コードで書くと以下のようになります.
MyArray::MyArray(MyArray const &r) : p_(), n_() { p_ = new T[r.n_]; for(int i = 0; i < r.n_; ++i){ p_[i] = r.p_[i]; } }
このようにコピーがあればそれを用いて「移動」,すなわちあるオブジェクトから別のオブジェクトへの値の移し変えを行うことが可能です.(「移動」では値を移し変えた後のオブジェクトのいわば「抜け殻」がどうなるかについては関知しません.「コピー」ではコピー元のオブジェクトは「移動」以前の状態を保持し続けるわけですが,値を移し変えた後の「移動」元のオブジェクトの状態には関知しないという観点からは,「コピー」は「移動」を包含する操作だと言えます.)
さて,
MyArray a; ..... MyArray b; b <- a;
- オブジェクトaが保持しているバッファの所有権をbに移動する.
これだけです.コードで書くなら以下のようになります.
MyArray::operator<-(MyArray &rhs) // 非定数参照を受けることに注意 { p_ = rhs.p_; n_ = rhs.n_; rhs.p_ = 0; rhs.n_ = 0; }
MyClassのような簡単な例でも「移動」を実現するための専用の実装を提供する利点
は明らかです.
- 例外安全性 - コピーでは新たなメモリ資源の獲得,および値のコピーにおいて例外を送出しえます.(template引数に与えられた型はコピーにおいて例外を送出するクラスかも知れないのです!)移動専用の実装ではポインタとバッファサイズのコピーであり,例外の非送出(
throw()
)を保証できるでしょう. - 効率 - コピーでは新たなメモリ資源の獲得を必要とします.これは通常高コストな操作となります.また,バッファの深いコピー(deep copy)を必要とします.一方で移動専用の実装では単にポインタの浅いコピー(shallow copy)およびバッファサイズのコピーのみが必要となります.新たなメモリ資源の確保もバッファの深いコピーも必要とせずコピーを用いた移動よりも効率的であることは明らかです.
もう一つ移動をコピーと異なって提供したい状況があります.コピーの操作は本質的に提供できないが移動の操作は提供可能なクラスの存在です.
オブジェクトのうちのただ一つのみが資源を獲得できるようなクラスを考えてみます.このようなクラスにコピー操作は提供できないでしょう.一方で資源をあるオブジェクトから別のオブジェクトへ移動するような操作は提供可能でしょう.
(説明が分かりにくい場合はネットワーク上にただ1台しかないプリンタを表現するクラスを想像してみてください.このとき上記文中の「資源」は「プリンタのハンドル」とでも置き換えられるでしょう)
(上で述べているクラスの設計はいわゆるシングルトンのそれとは若干異なります.シングルトンではクラスのオブジェクトが高々一つしか存在しませんが,上で述べている設計はクラスのオブジェクトは複数存在してもかまわないが,そのうちで資源を保持しうるオブジェクトが任意の瞬間に高々一つであるような設計となります.)
このような設計に基づいたクラスとして最も身近なのがstd::auto_ptrでしょう.std::auto_ptrはoperator=を提供していますが,その機能は「コピー」を提供するものではなく「移動」を提供するものです.std::auto_ptrが「コピー」を提供せず「移動」のみを提供しているのはそれ自身は非常に正しくすばらしい設計です.std::auto_ptrの欠点を挙げるとすればその「移動」の機能をoperator=で提供してしまっているところにあると言えます.C++の世界では通常operator=は「コピー」の機能を提供する構文である,という暗黙の(あるいは明文化された)了解があります.実際STLのコンテナは了解に基づいていますが,std::auto_ptrはこの了解の極めて顕著な例外です.そのため例えば「std::auto_ptrのコンテナ(Container Of Auto Ptr, COAP)」は予期しにくい所有権の破棄をもたらす著名な悪例となるわけです.
このstd::auto_ptrの欠点を解消する目的でもMoveは有用となりえます.すなわち「移動」の操作を行う構文を「コピー」の操作を行う構文と明示的に区別すれば,不要な混乱を排除できるわけです.またそればかりか,コンテナクラスが正しく要素型の「移動」操作を正しく扱うよう設計されていれば,「移動」のみを提供するクラスも正しくコンテナクラスの要素型とすることも可能となってきます.
「移動」操作の例外安全性と「抜け殻」に対する要求
これまで,「移動」の操作を適用した後の移動元のオブジェクトの状態については一貫して「関知しない」としてきました.しかしながら,最低限クラスのオブジェクトとして有効な(valid)状態を保持していなければならないのは言うまでもありません.すなわち,移動後の移動元のオブジェクトに対してメンバ関数を呼び出すことができ,またそれらが通常のオブジェクトにおける機能と等価な機能を提供できなければなりません.特に移動後の移動元オブジェクトに対して最低限デストラクタを適用できなければならないのは火を見るより明らかです
「移動」の操作は例外安全性の観点から対して強い要請を必要とするのが普通であるため,例外非送出を要求します.