2.4.8、使用Move语法实现Swap函数
move语法提升性能的又一个例子,使用swap()函数交换两个对象。下面的swapCopy()的实现没有使用move语法:
void swapCopy(Object& a, Object& b)
{
Object temp { a };
a = b;
b = temp;
}
首先,a被拷贝到temp,然后b被拷贝到a,最后temp被拷贝到b。如果对象拷贝的消耗比较大的话,该实现就会有比较糟糕的性能。使用move语法,实现就可以避免掉所有的拷贝:
void swapMove(Object& a, Object& b)
{
Object temp { std::move(a) };
a = std::move(b);
b = std::move(temp);
}
这就是标准库中std::swap()的实现。
2.4.9、在return语句中使用std::move()
从c++17开始,对于return object语句编译器不再允许执行任何拷贝或者移动对象;其中object是一个没有命名的临时对象。这叫做mandatory elision of copy/move,意思是通过值返回对象就不会有任何性能损失。如果object是一个本地变量而不是一个函数参数,允许非mandatory elision of copy/move operations,其优化叫做named return value optimization(NRVO)。标准c++不对该优化进行保证。有些编译器只在发行构建中而不是在debug构建中执行该优化。对于强制或非强制省略,编译器可以避免从函数返回的对象拷贝。这样做的结果就是zero-copy pass-by-value语法。
警告:记住对于NRVO,即使copy/move构造函数不被调用,仍然需要是可访问的;否则的话,根据标准,程序的格式就是不正确的。
那么,当使用std::move()返回一个对象会出现什么情况呢?看一下下面的代码:
return std::move(object);
这行代码,编译器无法应用强制或非强制(NRVO)copy/move操作的省略,因为只有在return object格式时才管用。既然copy/move省略无法再使用,编译器的下一个选项就是使用move语法,如果对象支持的话,如果不支持,就会使用copy的语法。
与NRVO相比,倒退到move语法会有一小部分性能影响,但是倒退到copy语法可能会有较大的性能影响!所以,记住如下的警告:
警告:如果从函数中返回一个本地变量或者没有命名的临时变量,只要写return object就好了,千万不要使用std::move()。
记住,如果想要从一个成员函数中返回类的一个数据成员,如果想要把它移出来而不是返回一个拷贝的话就需要使用std::move()。
还有,对于像下面这样的表达式要细心:
return condition ? obj1 : obj2;
这不是return object;的形式,编译器不会应用copy/move省略。更糟糕的是,condition ? obj1 : obj2形式的表达式是一个左值,所以编译器使用拷贝构造函数而不是返回一个对象。为了至少触发move语法,可以重写return语句如下:
return condition ? std::move(obj1) : std::move(obj2);
或者
return std::move(condition ? obj1 : obj2);
然而,如下重写return语句会更清晰,因为编译器可以自动使用move语法而不用显式地使用std::move():
if (condition) {
return obj1;
} else {
return obj2;
}
2.4.10、传递参数给函数的更优的方式
到现在为止,建议都是对于非原始函数参数使用reference-to-const参数以避免不必要的昂贵的对于传递给函数的参数的拷贝。然而,对于混合情况使用右值,情况改善不大。相像一下,一个函数,不管怎么样都要拷贝一个参数传递给其中的一个参数。这种情况常见于类成员函数。下面是一个简单的例子:
class DataHolder
{
public:
void setData(const vector<int>& data) { m_data = data; }
private:
vector<int> m_data;
};
setData()会对传入的参数做一个拷贝。现在你已经对于右值以及右值引用很熟练了,可以会想添加一个重载来优化setData()以避免右值情况下的任何拷贝。举例如下:
class DataHolder
{
public:
void setData(const vector<int>& data) { m_data = data; }
void setData(vector<int>&& data) { m_data = move(data); }
private:
vector<int> m_data;
};
当用临时变量调用setData()时,不会生成拷贝;数据用move取代。
下面的代码段触发了一个对于reference-to-const setData()重载的调用,因此生成了一个数据的拷贝:
DataHolder wrapper;
vector myData { 11, 22, 33 };
wrapper.setData(myData);
另一方面,下面的代码段使用临时变量调用setData(),它触发了一个对于右值引用的setData()重载的调用。数据按顺序move而不是copy。
wrapper.setData({ 22, 33, 44 });
不幸的是,这种方式优化setData()需要对左值与右值都进行重载的实现。幸运的是,有一种更好的方式来对单个成员函数进行优化,那就是使用pass-by-value。是的,pass-by-value!到目前为止,都是建议使用reference-to-const参数来你看期间任何不必要的拷贝,但是现在我们要建议使用pass-by-value了。明确一下。对于不拷贝的参数,传递reference-to-const仍然有效。pass-by-value建议只适于于不管怎么样函数都要进行拷贝的参数。在这种情况下,使用pass-by-value语法,代码对左值与右值都是优化的。如果传递左值,只拷贝一次,与reference-to-const参数一样。但是,如果传递的是右值,就不需要拷贝,与右值引用参数一样。让我们来看一下代码:
class DataHolder
{
public:
void setData(vector<int> data) { m_data = move(data); }
private:
vector<int> m_data;
};
如果左值传递给setData(),会把数据拷贝到data参数,然后移到m_data。如果右值传递给setData(),它会移到data参数,再移给m_data。
注意:对于函数要拷贝的参数使用pass-by-value,但是只在参数是一个支持move语法的类型时,并且不需要参数有多态行为的时候。否则,要使用reference-to-const参数。通过值传递多态类型会导致分片。这个我们以后再讨论吧。
2.5、零规则
在本章的一开始介绍过五规则。它指出,一旦声明五个特别成员函数(析构函数、拷贝构造函数、move构造函数,拷贝赋值操作符与move赋值操作符)的一个,要通过实现,缺省或者删除它们来进行全部声明。原因是编译器有复杂的规则来决定 是否要自动提供一个这些特殊成员函数编译器生成的版本。通过自己声明全部,就不需要让编译器决定,使你的意图更清晰。
到目前为止,所有的讨论都是解释怎么写这五个特殊的成员函数。然而,在现代c++中,也可以应用零规则。
零规则指出,你可以用这样的一种方式来设计类,不要求那些五个特殊成员函数中的任何一个。怎么做到这一点呢?可以对非多态类型的来做,避免使用旧风格的动态分配内存或其他资源。反过来,使用像标准库容器这样的现代构造函数与智能指针。例如,可以使用vector<vector<SpreadsheetCell>>而不是SpreadsheetCell**数据成员在Spreadsheet类中。或者更好的是,使用vector<SpreadsheetCell>保存线性的spreadsheet的代表。vector自动处理内存,所以没有任何五个特殊成员函数的必要。
警告:在现代c++中,使用零规则!
五规则应该仅限于客户化资源获得即初始化(RAII)类中。RAII类承担了资源的属主,在合适的时间处理释放。是一个用于设计的技巧,例如,以前提到过的vector与unique_ptr。还有,多态类型也要求遵从五规则。