ことの発端
速度効率やメモリ効率を考えながらコーディングを進めると、コピーなのかムーブなのかは、パフォーマンスに大きく影響されるところです。
ここで登場してくるのが ムーブセマンティクス(move semantics) や 右辺値参照(rvalue refefence) なのですが、これがまた難しい。言っていることは単純なんだけど、C++ の仕様上、これがコピーなのかムーブなのかが分かりづらく1、色んな言語でコード書いていると、しょっちゅう混乱するので、具体的にコードを書いて、一年後の自分のためにメモしようと思った次第です。
登場人物
FOO クラス
FOO クラスは bHasValue というメンバ変数を持っています。
この bHasValue はコンストラクタで常に true に設定されます。
値をムーブする際に、 bHasValue は false に設定されます。(所有権を奪われたイメージ)
実際にはこんなクラスは何の役にも立たないかもすが、ムーブセマンティクスや右辺値参照を確認するにはもってこいなんじゃないかと思います。
struct FOO {
bool bHasValue;
FOO() : bHasValue(true)
{ std::cout << "default ctor" << std::endl; }
FOO(const FOO& src) : bHasValue(true)
{ std::cout << "copy ctor" << std::endl; }
FOO(FOO&& src) : bHasValue(true)
{ std::cout << "move ctor" << std::endl; src.bHasValue = false; }
void operator = (const FOO& src)
{ std::cout << "copy =" << std::endl; }
void operator = (FOO&& src)
{ std::cout << "move =" << std::endl; src.bHasValue = false; }
~FOO() { std::cout << "dtor " << bHasValue << std::endl; }
};
ちなみに operator =() は今回登場しません。(次回はあるのか?)
bar() 関数
bar() 関数は FOO のインスタンスを生成して返却します。
FOO bar() { return FOO(); }
baz() 関数
baz() 関数は FOO の const 参照を返却します。
クラスとかで、オブジェクトプロパティを取得する時なんかで良く使う感じを模試します。
static const FOO static_foo;
const FOO& baz() { return static_foo; }
hoge() 関数
hoge() は与えられた引数によってコピーかムーブかが決まります。
つまり、どちらが呼ばれるかはメモリの効率に大きく影響が出るところです。
void hoge(const FOO&)
{
/* コピー用 */
std::cout << "hoge const &" << std::endl;
}
void hoge(FOO&&)
{
/* ムーブ用 */
std::cout << "hoge &&" << std::endl;
}
何が起こるか
さて、材料が揃いましたので、これから、 & や && や std::move() なんかを使うと何が起こるか観察します。
なお、 static const FOO static_foo; も main() 関数の前後でログが出力されるのですが、ノイズになるので敢えて結果で表示していません。
その前に 右辺値 と 右辺値参照 と & と && のおさらい
& も && も何れも 参照 なので、auto& f = xx; やら auto&& f = xx; やらと書いたとしても どちらの変数 f も 右辺値 でもなければ 左辺値 でもありません。よもや代入式でありながら代入なんて行われていません。あくまで、右辺(の結果)を 参照 しているだけです。 キャプチャ とか エイリアス とかって言いますね。
対して、引数で登場する && 、例えば hoge(FOO&&) みたいなのは「右辺値を参照するのだから右辺値をよこせ」と言ってます。なので hoge を呼び出す際に必要な引数は 右辺値 でなければなりません。 右辺値の参照 は右辺値ではありません。(何言ってるんだという場合はパターン14へ)
右辺値 とはある一瞬に現れる値だったりインスタンスだったりを指します2。 つまり 右辺値 として存在する期間は「刹那」です。「刹那」故にせっかく生成されたインスタンスであ〜だこ〜だすることがやり難いものでした。しかし、それは勿体ないと C++11 で、その 右辺値 をキャプチャできるヤツが現れました。これが 右辺値参照 です。ここまで良いでしょう。しかし、 右辺値参照 はその「刹那」なものとはかけ離れ、一定期間インスタンスが存在することとなります。つまり、 右辺値 を参照(キャプた)するための 右辺値参照 ですが、右辺値参照 そのものは 右辺値 ではありせん。 個人的にはここが理解の壁だったので、あえて太字で書いておきます。
パターン1
int main() {
FOO foo;
auto& f = foo;
auto&& ff = foo;
}
default ctor
dtor 1
基本中の基本なので、特に問題無いかな?
auto& と auto&& の違いは後で出てきます。
パターン2
int main() {
FOO foo;
auto f = foo;
}
default ctor
copy ctor
dtor 1
dtor 1
これも基本中の基本ですね。
ちゃんとコピーコンストラクタが呼び出されています。
パターン3
int main() {
FOO foo;
auto f = std::move(foo);
}
default ctor
move ctor
dtor 1
dtor 0
例えば、f が登場した時点で foo が必要なくなった場合なんかは、std::move() を使って所有権を移転します。
とはいえ、通常のプログラムでこのようなコードを書くことはまず無いと思いますが、ムーブセマンティクスの挙動を知る上では重要になります。
パターン4
int main() {
auto f = FOO();
}
default ctor
dtor 1
さてさて、この辺から怪しい雰囲気になりますかね。(苦笑)
そうなんです。
f に代入していますが、コピーもムーブもされません。
パターン5
int main() {
auto& f = FOO();
}
下記のコンパイルエラーが発生します。
non-const lvalue reference to type 'FOO' cannot bind to a temporary of type 'FOO'
パターン4で「f へのコピーが発生する」と思って、こんな書き方すると怒られます。
パターン6
int main() {
const auto& f = FOO();
std::cout << "..." << std::endl;
}
default ctor
...
dtor 1
ということでエラーに即して、 const auto& にすれば良いんじゃね?と書いて見ますが、結果はパターン3と同じですね。(FOOオブジェクトに対するconst 属性が付加されますが、今回の主題はコピーなのかムーブなのかなので)
ちなみに ... を表示してスコープ内で f が有効なのかを念の為確認しています。(一時オブジェクトってどこまでが一時なのかって、夢に出ることありません?私はありました。)
パターン7
int main() {
auto&& f = FOO();
std::cout << "..." << std::endl;
}
default ctor
...
dtor 1
auto&& としても、結果的にはパターン3と同じです。
パターン8
int main() {
auto f = bar();
}
default ctor
dtor 1
bar() は FOO を生成して返却しますが、この場合、FOOの生成・破棄は1回のみです。3
結構、みなさん勘違いしている方もいらっしゃるのでは?(私も勘違いしてました)
パターン9
int main() {
FOO f = bar();
}
default ctor
dtor 1
パターン8は 「auto だからか?」と疑っている方のために、念の為FOOで受けてみるも結果は同じです。
パターン10
int main() {
FOO f = baz();
}
copy ctor
dtor 1
baz() は const FOO& を返却します。
ここでパターン9の FOO を返却する bar() 比較しましょう。
パターン11
int main() {
auto f = baz();
}
copy ctor
dtor 1
パターン10を auto で受けても同様にコピーが発生します。
パターン12
int main() {
auto& f = baz();
}
コピーもムーブもされません。
単に baz() の返却値をキャプチャします。
パターン13
int main() {
auto&& f = baz();
}
こちらも、コピーもムーブもされません。
単に baz() の返却値をキャプチャします。
パターン14
void fuga(FOO&& f) { FOO ff = f; }
void piyo(FOO&& f) { FOO ff = std::move(f); }
int main() {
std::cout << "fuga() begin" << std::endl;
fuga(FOO());
std::cout << "fuga() end" << std::endl;
std::cout << "piyo() begin" << std::endl;
piyo(FOO());
std::cout << "piyo() end" << std::endl;
}
default ctor
copy ctor
dtor 1
dtor 1
fuga() end
piyo() begin
default ctor
move ctor
dtor 1
dtor 0
piyo() end
たま〜に fuga(FOO&& f); で登場する f って右辺値なんじゃないかと勘違いするのですが、違います。
右辺値を参照しているので右辺値ではないです。右辺値 としてではなく 左辺値 4 として取り扱われてコピーが発生します。
余談
で、パターン14の流れで、完全転送という何ともカッコイイ std::forward に登場していただき fuga(FOO&& f); をこんな感じで書くとコピーは発生せずムーブしなくなり
void fuga(FOO&& f) { FOO ff = std::forward<FOO&&>(f); }
更に進めると FOO&& を T に一般化して
template <typename T> void fuga(T f) { FOO ff = std::forward<T>(f); }
脳内では T が 右辺値の参照 なら右辺値として、 左辺値の参照 ならそのまま 左辺値の参照 として、型 と 値 が転送されるから、これでコピーとムーブもバッチリじゃん、なんて思っていたんですよ。
しかし、現実には、そうは問屋が卸さず、
template <typename T> void fuga(T&& f) { FOO ff = std::forward<T>(f); }
こうする必要があります。
実は、別件でモヤモヤしている件があって、突き進めるとこれがオチになるという結果が待っていました。 5
-
この辺、
Rustが良くできているな〜と思います。当初は代入で右辺の所有権が左辺に奪われるというセンセーショナルな言語仕様でしたが、効率化を考えながらコードを書いている人間には素晴らしいものなんだと思います。 ↩ -
RVO(Return Value Optimization) とNRVO(Named RVO) によるもの。RVOは一時オブジェクトを返却する場合で、NRVOは関数内で変数定義(オブジェクトインスタンス)されたものを返却する場合、呼び出し元でのコピーを省略する(できる)場合があるとする仕様。コピー省略と値のコピー省略を保証(C++17)を参照。 ↩ -
これを
左辺値と言っていいのかな? あくまで右辺値の参照であって右辺値ではないと解するべきなのか。。。 ↩ -
著者以外は意味不明な感じの言い回しですが、ここで
templateの話を進めると、記事の本質から逸脱して、 C++ の整然とした混沌の世界に迷い込みそうなので、深い話はなしにして、記事のリンクを貼るだけにしました。 ↩