C++のムーブセマンティクスとNRVO
C++では,コピーによるオーバーヘッドを避けるためにムーブセマンティクスがサポートされています.これを活用する例として,ベクトル演算を題材に解説を試みます.簡単に言えば,右辺値はコンパイラだけが知る計算や処理の一時的な中間結果や作業用変数であり,メモリから近々削除される値です.そのため,そのメモリ領域を横取りすることができ,コピーを省略することができます.左辺値は固定されたアドレスに変数として読み書きされる値です.よって,その値を横取りすることはできません.基本的に左辺値参照はconst &で表され,右辺値参照は&&で表されます.
NRVO (Named Return Value Optimization) は,関数の戻り値を呼び出し側で用いる際に,コピーやムーブ操作を省略する最適化技術です.例えば,関数内でローカル変数を用いて計算を行い,そのローカル変数の値をそのまま戻り値とし,呼び出し側でこの戻り値を変数に代入するような状況では,ローカル変数の代わりにこの変数を直接計算に用いることで,余分なコピーやムーブ操作を省略できます.これにより,パフォーマンスの向上が期待できます.
ベクトル演算のC++プログラム
class Vectorは,std::vector<int>型のデータを持ち,整数のベクトルを保持します.このクラスは,加算の複合代入演算 += と二項演算の加算 + をサポートします.なお,ベクトルは同じ長さでなければなりませんが,プログラムを簡単にするために,同じ長さかどうかのチェックは省略しています.
-
Vector::Vector(std::vector<int> data): ベクトルの初期値を指定してベクトル変数を生成するコンストラクタ. -
Vector::Vector(const Vector &vector): コピーコンストラクタ.左辺値参照のベクトル変数vectorをコピーして,新しいベクトル変数を生成するコンストラクタ. -
Vector::Vector(Vector &&vector):ムーブコンストラクタ.右辺値参照のベクトル変数vectorをムーブして,新しいベクトル変数を生成するコンストラクタ. -
Vector::Vector &operator=(const Vector &vector):コピー代入.左辺値参照のベクトル変数vectorの値を代入の左辺のベクトル変数にコピーします. -
Vector::Vector &operator=(Vector &&vector): ムーブ代入.右辺値参照のベクトル変数vectorの値を代入の左辺のベクトル変数にムーブします. -
Vector::Vector &operator+=(const Vector &vector): 左辺のベクトル変数に右辺のvectorの値を加算します.
二項演算+のためのオーバーロードは,2つの引数が右辺値参照と左辺値参照の4通りの組み合わせについて定義します.
-
Vector &&operator+(Vector &&lhs, Vector &&rhs): 右辺値参照lhsと右辺値参照rhsが引数の場合.lhs+=rhsを計算し,lhsをstd::moveで右辺値参照として返します. -
Vector &&operator+(Vector &&lhs, const Vector &rhs): 右辺値参照lhsと左辺値参照rhsが引数の場合.lhs+=rhsを計算し,lhsをstd::moveで右辺値参照として返します. -
Vector &&operator+(const Vector &lhs, Vector &&rhs): 左辺値参照lhsと右辺値参照rhsが引数の場合.rhs+=lhsを計算し,rhsをstd::moveで右辺値参照として返します. -
Vector operator+(const Vector &lhs, const Vector &rhs): 左辺値参照lhsと左辺値参照rhsが引数の場合.lhsで初期化されたベクトル変数resultを作成し,result+=rhsを計算し,計算結果resultの値を返します.このとき,NRVOが適用され,呼び出し元が必要とする領域上でresultの計算は行われ,resultの計算結果がreturnに伴いコピーされるこはありません.
#ifndef __VECTOR_HPP__
#define __VECTOR_HPP__
#include <iostream>
#include <string>
#include <vector>
// Vector class
class Vector {
// The data of the vector
std::vector<int> data;
// Friend functions
friend Vector &&operator+(Vector &&lhs, Vector &&rhs);
friend Vector &&operator+(Vector &&lhs, const Vector &rhs);
friend Vector &&operator+(const Vector &lhs, Vector &&rhs);
friend Vector operator+(const Vector &lhs, const Vector &rhs);
public:
// Returns a string representation of the vector
std::string str() const {
std::string result = "(";
bool first = true;
for (const auto &x : data) {
result += (first ? "" : ",") + std::to_string(x);
first = false;
}
result += ")";
return result;
}
// Prints the vector
void print(const std::string &prefix = "") const {
std::cout << prefix;
bool first = true;
for (const auto &x : data) {
std::cout << (first ? "(" : ",") << x;
first = false;
}
std::cout << ")" << std::endl;
}
// Constructors with initializer list
Vector(std::vector<int> data) : data(std::move(data)) {
std::cout << "Vector::Vector(std::vector<int> = " << str()
<< "): Constructor" << std::endl;
}
// Copy constructor
Vector(const Vector &vector) : data(vector.data) {
std::cout << "Vector::Vector(const Vector & = " << vector.str()
<< "): Copy Constructor" << std::endl;
}
// Move constructor
Vector(Vector &&vector) noexcept : data(std::move(vector.data)) {
std::cout << "Vector::Vector(Vector && = " << str()
<< ") : Move Constructor" << std::endl;
}
// Destructor
~Vector() {
std::cout << "Vector::~Vector() = " << str() << " : Destructor"
<< std::endl;
}
// Copy assignment
Vector &operator=(const Vector &vector) {
std::cout << "Vector::operator=(const Vector & = " << vector.str() << " : "
<< "Copy assignment" << std::endl;
data = vector.data;
return *this;
}
// Move assignment
Vector &operator=(Vector &&vector) noexcept {
std::cout << "Vector::operator=(Vector && = " << vector.str() << ") : "
<< "Move assignment" << std::endl;
data = std::move(vector.data);
return *this;
}
// Operator +=
Vector &operator+=(const Vector &vector) {
std::cout << "Vector::operator+=(const Vector &) : " << str()
<< " += " << vector.str() << std::endl;
for (int i = 0; i < data.size(); ++i)
data[i] += vector.data[i];
return *this;
}
};
// Operator + overloads
Vector &&operator+(Vector &&lhs, Vector &&rhs) {
std::cout << "operator+(Vector && = " << lhs.str() << ", "
<< "Vector && = " << rhs.str() << ") : Move" << std::endl;
lhs += rhs;
return std::move(lhs);
}
Vector &&operator+(Vector &&lhs, const Vector &rhs) {
std::cout << "operator+(Vector && = " << lhs.str()
<< ", const Vector & = " << rhs.str() << ") : Move" << std::endl;
lhs += rhs;
return std::move(lhs);
}
Vector &&operator+(const Vector &lhs, Vector &&rhs) {
std::cout << "operator+(const Vector & = " << lhs.str()
<< ", Vector && = " << rhs.str() << ") : Move" << std::endl;
rhs += lhs;
return std::move(rhs);
}
Vector operator+(const Vector &lhs, const Vector &rhs) {
std::cout << "operator+(const Vector & = " << lhs.str()
<< ", const Vector & = " << rhs.str() << ") : Copy (NRVO)"
<< std::endl;
Vector result(lhs);
result += rhs;
return result;
}
#endif // __VECTOR_HPP__
実行例
その1:コピーコンストラクタ
ベクトル変数v1をベクトル(1, 2, 3)で初期化し作成します.そして,ベクトル変数v2をv1のコピーとして作成します.
#include "vector.hpp"
int main() {
Vector v1({1, 2, 3});
v1.print("v1 = ");
Vector v2(v1);
v2.print("v2 = ");
}
実行結果
v1はコンストラクタで作成され,v2はコピーコンストラクタで作成されています.
Vector::Vector(std::vector<int> = (1,2,3)): Constructor
v1 = (1,2,3)
Vector::Vector(const Vector & = (1,2,3)): Copy Constructor
v2 = (1,2,3)
Vector::~Vector() = (1,2,3) : Destructor
Vector::~Vector() = (1,2,3) : Destructor
その2:ムーブコンストラクタ
v2の初期値をv1 + Vector({1, 1, 1}に変更します.
int main() {
Vector v1({1, 2, 3});
v1.print("v1 = ");
Vector v2(v1 + Vector({1, 1, 1}));
v2.print("v2 = ");
}
実行結果
-
Vector({1, 2, 3})のコンストラクタが呼ばれv1となります. -
Vector({1, 1, 1})のコンストラクタが呼ばれ,右辺値となります. -
v1が左辺値,(1, 1, 1)が右辺値なので,ムーブセマンティクスでoperator+が呼ばれます. - 右辺値
(1, 1, 1)を上書きするようにoperator+=が呼ばれ,(1,1,1) += (1,2,3)が行われます.計算結果は,(1, 1, 1)を上書きし,(2,3,4)となります. -
operator+=の計算結果(2,3,4)は右辺値なので,v2のためのムーブコンストラクタが呼び出され,それを横取りし,コピー代入を避けます.
Vector::Vector(std::vector<int> = (1,2,3)): Constructor
v1 = (1,2,3)
Vector::Vector(std::vector<int> = (1,1,1)): Constructor
operator+(const Vector & = (1,2,3), Vector && = (1,1,1)) : Move
Vector::operator+=(const Vector &) : (1,1,1) += (1,2,3)
Vector::Vector(Vector && = (2,3,4)) : Move Constructor
Vector::~Vector() = () : Destructor
v2 = (2,3,4)
Vector::~Vector() = (2,3,4) : Destructor
Vector::~Vector() = (1,2,3) : Destructor
その3:NRVO (Named Return Value Optimization)
ベクトル変数v3をv1 + v2で初期化し作成します.
#include "vector.hpp"
int main() {
Vector v1({1, 2, 3});
v1.print("v1 = ");
Vector v2({1, 1, 1});
v2.print("v2 = ");
Vector v3(v1 + v2);
v3.print("v3 = ");
}
実行結果1:NRVOが適用される場合
普通にg++でコンパイルするとNRVOが適用されます.
-
v1 + v2の計算を行うとき,2つの左辺値の加算なので,Vector operator+(const Vector &lhs, const Vector &rhs)が呼ばれます. - 左辺値は横取りできないので,ローカル変数として,
lhrで初期化されたベクトル変数resultを作成します.この時,コピーコンストラクタが呼ばれます. -
result += rhsが行われ,加算結果がresultに書き込まれます. -
resultの値がv3に代入されるはずですが,省略されています.NRVOにより2.と3.の処理がresultではなく,代わりにv3に対して行われ,コピーやムーブを避けています.
Vector::Vector(std::vector<int> = (1,2,3)): Constructor
v1 = (1,2,3)
Vector::Vector(std::vector<int> = (1,1,1)): Constructor
v2 = (1,1,1)
operator+(const Vector & = (1,2,3), const Vector & = (1,1,1)) : Copy (NRVO)
Vector::Vector(const Vector & = (1,2,3)): Copy Constructor
Vector::operator+=(const Vector &) : (1,2,3) += (1,1,1)
v3 = (2,3,4)
Vector::~Vector() = (2,3,4) : Destructor
Vector::~Vector() = (1,1,1) : Destructor
Vector::~Vector() = (1,2,3) : Destructor
実行結果2:NRVOが適用されない場合
NRVOを適用しないためにg++にオプション-fno-elide-constructorsをつけてコンパイルし実行すると,下の結果が得られます.違いは,ムーブコンストラクターが動作している点です.return resultによる関数の戻り値を引数するムーブコンストラクターを実行し,ベクトル変数v3が作成されています.関数の戻り値は右辺値なので,コピーコンストラクタでなくムーブコンストラクタが呼ばれています.そしてデストラクタが呼ばれ,resultの領域が解放されています.
Vector::Vector(std::vector<int> = (1,2,3)): Constructor
v1 = (1,2,3)
Vector::Vector(std::vector<int> = (1,1,1)): Constructor
v2 = (1,1,1)
operator+(const Vector & = (1,2,3), const Vector & = (1,1,1)) : Copy (NRVO)
Vector::Vector(const Vector & = (1,2,3)): Copy Constructor
Vector::operator+=(const Vector &) : (1,2,3) += (1,1,1)
Vector::Vector(Vector && = (2,3,4)) : Move Constructor
Vector::~Vector() = () : Destructor
v3 = (2,3,4)
Vector::~Vector() = (2,3,4) : Destructor
Vector::~Vector() = (1,1,1) : Destructor
Vector::~Vector() = (1,2,3) : Destructor
その4:もう少し複雑な例
3つのベクトル変数の合計v1 + v2 + v3を計算しています.
int main() {
Vector v1({1, 2, 3});
v1.print("v1 = ");
Vector v2({1, 1, 1});
v2.print("v2 = ");
Vector v3({1, -1, 2});
v3.print("v3 = ");
(v1 + v2 + v3).print("v1 + v2 + v3 = ");
}
実行結果1:NRVOが適用される場合
合計v1 + v2 + v3の計算に注目します.まず,v1 + v2の計算が行われ,+ v3が行われます.
-
v1 + v2は,左辺値+左辺値なので,ローカル変数のresultに計算結果が書き込まれ,それが呼び出し元で作業用変数に代入され右辺値となります. - この作業用変数と
v3の加算のため,operator+(Vector &&lhs, const Vector &rhs)が呼び出され,作業用変数に上書きされ計算結果となります. - NRVOにより
resultを計算に用いず,代わりに呼び出し元の作業用変数を用います.これにより,コピーやムーブが省略されます.
Vector::Vector(std::vector<int> = (1,2,3)): Constructor
v1 = (1,2,3)
Vector::Vector(std::vector<int> = (1,1,1)): Constructor
v2 = (1,1,1)
Vector::Vector(std::vector<int> = (1,-1,2)): Constructor
v3 = (1,-1,2)
operator+(const Vector & = (1,2,3), const Vector & = (1,1,1)) : Copy (NRVO)
Vector::Vector(const Vector & = (1,2,3)): Copy Constructor
Vector::operator+=(const Vector &) : (1,2,3) += (1,1,1)
operator+(Vector && = (2,3,4), const Vector & = (1,-1,2)) : Move
Vector::operator+=(const Vector &) : (2,3,4) += (1,-1,2)
v1 + v2 + v3 = (3,2,6)
Vector::~Vector() = (3,2,6) : Destructor
Vector::~Vector() = (1,-1,2) : Destructor
Vector::~Vector() = (1,1,1) : Destructor
Vector::~Vector() = (1,2,3) : Destructor
実行結果2:NRVOが適用されない場合
同様に,NRVOを適用しないためにg++にオプション-fno-elide-constructorsをつけてコンパイルし実行します.v1 + v2の計算のあと,ムーブコンストラクタが呼ばれ,戻り値resultが作業用変数にムーブされています.そして,resultの領域を解放するためのデストラクタが呼ばれています.
Vector::Vector(std::vector<int> = (1,2,3)): Constructor
v1 = (1,2,3)
Vector::Vector(std::vector<int> = (1,1,1)): Constructor
v2 = (1,1,1)
Vector::Vector(std::vector<int> = (1,-1,2)): Constructor
v3 = (1,-1,2)
operator+(const Vector & = (1,2,3), const Vector & = (1,1,1)) : Copy (NRVO)
Vector::Vector(const Vector & = (1,2,3)): Copy Constructor
Vector::operator+=(const Vector &) : (1,2,3) += (1,1,1)
Vector::Vector(Vector && = (2,3,4)) : Move Constructor
Vector::~Vector() = () : Destructor
operator+(Vector && = (2,3,4), const Vector & = (1,-1,2)) : Move
Vector::operator+=(const Vector &) : (2,3,4) += (1,-1,2)
v1 + v2 + v3 = (3,2,6)
Vector::~Vector() = (3,2,6) : Destructor
Vector::~Vector() = (1,-1,2) : Destructor
Vector::~Vector() = (1,1,1) : Destructor
Vector::~Vector() = (1,2,3) : Destructor
まとめ
今回出てきた内容をまとめると,次のパターンとなります.
NRVOを適用する関数の書き方
ローカル変数resultに計算処理を行い,それをそのまま返す.resultはこの関数内だけで用いられる変数なので,呼び出し元に戻るときに削除される.したがって,NRVOが適用されることで,コンパイラはローカル変数resultを使用せず,呼び出し元の適切な場所をresultの代わりに用いるため,resultからのコピーやムーブが省略される.
Vector f(){
Vector result;
// resultへの計算処理
return result;
}
引数が右辺値参照の場合
引数で受け取った右辺値参照vectorを流用して計算処理を行う.この引数vectorは呼び出し元にとって一時的な計算結果などであり,近々削除される不要なものである.この引数上で計算処理を行い,右辺値参照として返す.返り値は右辺値として扱われるので,呼び出し元で変数に代入する場合,ムーブが行われる.
Vector &&f(Vector && vector){
// vectorへの計算処理
return std::move(vector);
}
引数が左辺値参照の場合
引数vectorは左辺値参照のため,関数内で変更できない.一旦ローカル変数resultにコピーし,計算処理後にこのローカル変数resultを返り値とする.NRVOが適用されることで,コンパイラはローカル変数resultを使用せず,呼び出し元の適切な場所をresultの代わりに用いるため,resultからのコピーやムーブが省略される.
Vector f(const Vector &vector){
Vector result(vector)
// resultへの計算処理
return result;
}