点群表示機能を自作してみるのおまけです。
RAIIクラスを実装する場合に move 操作をどう実装するか迷ったので、その検討内容を記録として残します。
前提
このページでは以下のクラスを前提としています。
- デストラクタを自前で実装することが必要。
- copy 操作は不要。
move 操作とは
C++11 にて導入されたムーブコンストラクタ(move constructor)、ムーブ代入演算子(move assignment operator)を「move 操作」と定義して話をします。 右辺値参照を引数にとるコンストラクタ、および代入演算子のことです。右辺値参照とは何ぞやという話題はこのページでは省略します。
以下のような宣言になります。
class A {
public:
A(A&& other) noexcept; // move constructor
A& operator = (A&& other) noexcept; // move assignment operator
};
ムーブコンストラクタは other の中身を自身に移し、 other はクリアされた状態になるものとします。クリアという表現があいまいですが、以下のイメージのオブジェクトを「クリアされている」として話を進めたいと思います。
クリアされた状態の例
- デフォルトコンストラクタで初期化された状態。
- vector で size が 0 の状態。(capacity が 0 である必要はない。)
- デストラクタで削除するポインタ型メンバ変数が null である。
- 通常デストラクタで削除するポインタ値を「デストラクタで削除しない」フラグを立てる。
ムーブ代入演算子も同様に other の中身を自身に移し、other はクリアされた状態になるのは同じとします。但しコンストラクタの場合と違い this が値を持っているので、その古い値をクリアする必要があります。
noexcept 指定していますが、ムーブ操作は noexcept 指定する方がよいでしょう。(絶対に例外を投げないように実装する。) 実用上はムーブコンストラクタのみ noexcept 指定するのでもよいかもしれません。 (Effective Modern C++ を参照のこと。)
実装案
正攻法の実装
namespace D3D11Graphics {
typedef Microsoft::WRL::ComPtr<ID3D11DeviceContext> ID3DDeviceContextPtr;
typedef Microsoft::WRL::ComPtr<ID3D11Resource> ID3DResourcePtr;
class D3DMappedSubResource
{
public:
D3DMappedSubResource() noexcept;
D3DMappedSubResource(D3DMappedSubResource&& other) noexcept
: m_pDC(std::move(other.m_pDC)),
m_pSubResource(std::move(other.m_pSubResource)),
m_aData(std::move(other.m_aData))
{
//other.m_pDC.Reset();
//other.m_pSubResource.Reset();
m_aData = nullptr;
}
~D3DMappedSubResource() { UnmapNoexcept(); }
D3DMappedSubResource& operator = (D3DMappedSubResource&& other) noexcept
{
if (this != &other) {
UnmapNoexcept();
m_pDC = std::move(other.m_pDC);
m_pSubResource = std::move(other.m_pSubResource);
m_aData = std::move(other.m_aData);
//other.m_pDC.Reset();
//other.m_pSubResource.Reset();
m_aData = nullptr;
}
return *this;
}
private:
void UnmapNoexcept() noexcept;
private:
ID3DDeviceContextPtr m_pDC;
ID3DResourcePtr m_pSubResource;
char* m_aData;
};
} // end of namespace D3D11Graphcis
ポイントは以下の通りです。
- ムーブコンストラクタとムーブ代入演算子にムーブする処理を記述する。 (メンバが増えた場合両方に記述することが必要。)
- 代入演算子では自己代入に備えることが必要。
ムーブ処理において移動元に m_aData には null を代入していますが、m_pDC ではその処理(Reset()が該当)がありません。これは ComPtr のムーブコンストラクタやムーブ代入演算子の中でその処理が行われているためです。
ムーブベースの実装
正攻法の実装でメンバのムーブの実装部分を2重で書いていた件は、その処理を関数化すれば解決します。実装すると以下のようになります。
namespace D3D11Graphics {
typedef Microsoft::WRL::ComPtr<ID3D11DeviceContext> ID3DDeviceContextPtr;
typedef Microsoft::WRL::ComPtr<ID3D11Resource> ID3DResourcePtr;
class D3DMappedSubResource
{
public:
D3DMappedSubResource() noexcept;
D3DMappedSubResource(D3DMappedSubResource&& other) noexcept : D3DMappedSubResource()
{
MoveImpl(other);
}
~D3DMappedSubResource() { UnmapNoexcept(); }
D3DMappedSubResource& operator = (D3DMappedSubResource&& other) noexcept
{
if (this != &other) {
UnmapNoexcept();
MoveImpl(other);
}
return *this;
}
private:
void UnmapNoexcept() noexcept;
void MoveImpl(D3DMappedSubResource& other) noexcept
{
m_pDC = std::move(other.m_pDC);
m_pSubResource = std::move(other.m_pSubResource);
m_aData = other.m_aData;
//other.m_pDC.Reset();
//other.m_pSubResource.Reset();
other.m_aData = nullptr;
}
private:
ID3DDeviceContextPtr m_pDC;
ID3DResourcePtr m_pSubResource;
char* m_aData;
};
} // end of namespace D3D11Graphcis
ポイントは以下の通りです。
- 正攻法とほぼ同じ。但しムーブコンストラクタでいったんデフォルトコンストラクタを用いた初期化を行う点が異なる。
よほどクリティカルな状況(神経質になるべき状況)でなければ正攻法と比べ実際上の違いは存在しないでしょう。 私はやりませんが、気になるならデフォルトコンストラクタによる初期化自体さぼってもいいかもしれません。
swapベースの実装
namespace D3D11Graphics {
using std::swap;
class D3DMappedSubResource
{
public:
D3DMappedSubResource() noexcept;
D3DMappedSubResource(D3DMappedSubResource&& other) noexcept : D3DMappedSubResource()
{
swap(*this, other);
}
~D3DMappedSubResource() { UnmapNoexcept(); }
D3DMappedSubResource& operator = (D3DMappedSubResource&& other) noexcept
{
D3DMappedSubResource tmp(std::move(other));
swap(*this, tmp);
return *this;
}
friend inline void swap(D3DMappedSubResource& left, D3DMappedSubResource& right) noexcept
{
left.m_pDC.Swap(right.m_pDC);
left.m_pSubResource.Swap(right.m_pSubResource);
swap(left.m_aData, right.m_aData);
}
private:
void UnmapNoexcept() noexcept;
private:
ID3DDeviceContextPtr m_pDC;
ID3DResourcePtr m_pSubResource;
char* m_aData;
};
} // end of namespace D3D11Graphcis
ポイントは以下の通りです。
- swap 関数を利用して実装する。
- メンバをムーブする処理はswapに集約します。(但し各メンバを初期化する処理はデフォルトコンストラクタに必要。)
- ムーブ代入演算子で other をローカル変数にムーブする目的は以下の通り。
- ムーブコンストラクタで other をクリアします。
- ローカル変数のデストラクタで this の元の値を削除します。
- 自己代入に対応するコードを不要にします。
- swap はグローバルな friend 関数として実装する。
- この書き方は知らないと出てこないと思いますが、一種の慣用句だと思います。
- 同時に
using std::swap;
が(通常)必要になります。 (後述)
swap() 関数はこのように実装するのが最適だと考えますが、やや策士策に溺れている感が我ながらあります... 以下補足します。
- swap() 関数の呼び出し時にはネームスペース指定をしない
swap(p1, p2);
のような呼び出し方をします。 ADL(argument dependent lookup)という C++ の(古式ゆかしい)仕様により using namespace なしで呼び出すことが可能です。 最適な swap() 関数を見つけるためにはこの方針が妥当と考えます。 - また D3D11Graphics::swap() 関数の中で std::swap() を使用可能にするために、namespace D3D11Graphics の中で
using std::swap;
としています。-
using namespace std;
では namespace の中ではD3D11Graphics::swap が優先的に見つかってしまいます。 (他の namespace を探してくれない。この点 C++ の仕様がどうだったかちょっと覚えていません。) - std::swap() と std:: で修飾して呼び出すことは、最適な swap() 関数を探すという前述の目的を満たさないため行いません。
-
なお、WRL::ComPtr も基本的にムーブ操作は swap を用いて実装されていました。(swap 関数はメンバ関数として実装されていましたが。)
ComPtr& operator=(_Inout_ ComPtr &&other) throw()
{
ComPtr(static_cast<ComPtr&&>(other)).Swap(*this);
return *this;
}
結論
「で、結論は?」ということなのですが、これが難しい...
私は実装中は swap ベースの実装が良いと思いましたが、この記事を書くために見直していて、ムーブベースの実装の方がよかったかも、と思い始めました。 (どちらでもいいとも思いますが。)
比較
パフォーマンスを最優先するなら正攻法で実装する一択だと思います。但しメンバのムーブ処理を2か所に書くことが必要になります。 自前の swap 関数を実装する場合、swap 関数でもメンバ毎の処理を書く必要が生じます。 (そうすれば std::swap よりも効率的な実装になる。)
逆にわずかなパフォーマンスのロスを許容しつつメンバの追加における実装作業を減らしたいと考えると、ムーブを共通化したムーブベースの実装かswapベースの実装から考えることになると思います。
- ムーブ操作のパフォーマンスについてはムーブベースの実装の方がわずかに優位。
- ムーブコンストラクタだとムーブベースだとメンバの代入(初期化を含む)2~3回、swapベースだと3~4回位の差。
- ムーブ代入演算子の場合はメンバの代入4,5回分位の差。 UnmapNoexcept() の処理が重い場合は実質同じ。
- ムーブベースだとメンバの代入2回 + UnmapNoexcept() 。
- swap ベースだと代入6~7回 + UnmapNoexcept()。
- パフォーマンスに関してはコンパイラの最適化により大幅に変わりうるので一般的な議論は難しいことは承知の比較です。
- swap関数のパフォーマンスは、自前で実装した swap ベースの実装の方が優位。
- メンバ変数一つ辺りの差だと以下の通り。
- swapベースだと代入3回。
- ムーブベースだと最大代入6回
- ムーブベースの場合、全体で UnmapNoexcept() の呼び出し3回分の処理が増えます。
- 但し UnmapNoexcept() は実際上クリア処理は行われないので、呼び出しのオーバーヘッドのみがコストになります。 inline 展開されれば実質0になりうる。
- 但しムーブベースの実装でswapを実装するならば、当然同等になります。
- メンバ変数一つ辺りの差だと以下の通り。
- ムーブ操作と swap 関数とどちらが実行される頻度が多いかで考えると、おそらくムーブ操作でしょう。
- 但しコピー操作と比べるとムーブ操作の回数も大したことはないでしょう。(しばしば RVO (Return value optimization) も働きますし。)
- コードの可読性という意味では swap ベースの実装が優位と思います。
- swap ベースの実装はムーブコンストラクタ、デストラクタ、swap() といった標準的な構成要素だけで実装できます。
- それに対しムーブベースの実装は、自己代入対応、UnmapNoexcept()の必要性など、注意すべき事項が多いです。 具体的には「この関数って noexcept だっけ?」といった疑問を解消しながら読む必要があります。
- 但し上のコメントの量を見れば分かる通り、実装の小細工度、複雑度で言えば swap ベースの実装の方が不利です。ムーブベースの実装の方が素直でしょう。
所感
結論と言えるほど他の人にお勧めできる提案はできないので、所感という形にします。
- 正しく実装・使用するならぶっちゃけどちらでもよいと思います。
- しいて言えば、自前 swap 関数を実装する方法はユーザーが std:swap() を間違って使ってしまうリスクがあり、メリットを減じています。 このリスクの分だけムーブベースの実装の方が無難かなと思います。
- 専用の swap 関数を他の人が利用しない場合、ムーブ操作を swap 関数で実装するのは「時期尚早な不最適化」であろうと思います。
- move 操作が swap 関数よりもプリミティブである(と考える)ことがこの減点の要因でしょう。
コピー代入演算子の「コピー&スワップ」ほど定番の方法が存在しないようなのが不思議だったのですが、決定版となるアイデアがまだないのかもしれません。
- 「コピー&スワップ」はコードが(場合によっては過度に)簡潔になり、また多くのクラスで利用可能なパターンです。 ですが move が導入される前の古いパターンなのかもしれません。
- 「以下の順でクラスは実装されていく」と考えるとよいかも、というのが現在の私の仮説です。
- クラス作成時はムーブ、コピー操作は全て削除。
- 必要になったらムーブ操作を実装する。
- 必要になったらコピー操作を実装する。
- コピーコンストラクタは普通に実装。
- コピー代入演算子は「コピーしてムーブ」で実装。
- std::swap() を高速化する必要があれば swap() 関数を自前で実装する。 (pimpl イディオムを使っても高速化されるでしょう。)
- 今回はクリア処理(UnmapNoexcept())は考慮に入れましたが、実際上は「メンバ変数が多くてムーブの代入処理自体が重い場合は」とかも考慮する必要が生じます。 こういうところで悩むなら、コピー操作のいらないクラスでムーブだけ実装するのはやめて、unique_ptr を使う方が合理的かもしれません。
参考資料
-
C++11のstd::swapはC++03のstd::swapとは互換性がない (本の虫)
- この中の「Andrew Koenigは、swapはコピーやムーブ以上に基本的な操作だと書いている。」というところに書かれている内容が今回の swap ベースの実装の元ネタです。
-
「 Copy して Swap 」 対 「 Copy して Move 代入」 (野良C++erの雑記帳)
- 今回の記事を記述する間に見つけたページです。ムーブではなくコピーに関する記述ですが、いくつか気づきをもらったのでリンクしておきます。
- コピー代入演算子の実装にムーブ代入を使うというアイデアは初めて見たかもしれません。メンバのコピーを記述しなくてよくなるのですね。