第四篇:再论赋值
原文来自: http://cpp-next.com/archive/2009/09/your-next-assignment/
这是关于C++中的高效值类型的系列文章中的第四篇。在上一篇中,我们讨论了如何处理右值引用函数参数并介绍了只可转移的类型。这次,我们重温一下转移赋值,并且看看如何才能正确并高效地把它写出来。
在本系列文章的第二篇中,我们示范了一个 vector 转移赋值的实现,但其中隐藏着一个微妙的问题。以下列代码为例:
mutex m1, m2;
std::vector<shared_ptr<lock> > v1, v2;
v1.push_back(shared_ptr<lock>(new lock(m1)));
v2.push_back(shared_ptr<lock>(new lock(m2)));
v2 = v1; // #1
…
v2 = std::move(v1); // #2 - Done with v1 now, so I can move it
…
赋值#1释放了 v2 所独占的所有锁(并让 v2 成为 v1 所拥有的所有东西的共用者)。但是赋值#2并没有即时效果,除了交换各自的锁拥有权。在#2的情况下,原来由 v2 所持有的锁不会释放,直至 v1 离开其范围域,这可能是很久很久以后。上锁与解锁的顺序与否是正确的多线程程序和死锁之间的区别,所以这是一个严重的问题,我们前面的关于转移赋值的描述需要加以修正:
转移赋值的语义:转移赋值操作符从“偷取”它的参数的值,将该参数置于可析构和可赋值的状态,并保留左操作数的任何用户可见的副作用。
有了以上指引的帮助,现在我们可以修改我们对 std::vector 的转移赋值实现:
vector& operator=(vector&& rhs)
{
this->clear();
std::swap(*this, rhs);
return *this;
}
在实践中,最先的 clear() 通常没有什么作用(也没有什么开销),因为转移赋值的目标通常已经是空的了,通常它本身就是前面某次转移赋值的源对象。在大多数标准算法以及前面我们作为了一个例子的插入排序算法中,这确实是真的。不过,加上这一个 clear() 可以在向一个非空的左操作数进行转移赋值时避免麻烦。
规范性的赋值?
正如前一篇所提到的,复制赋值有一种“规范性的实现”,基于一次复制构造和一次交换:
T& operator=(T rhs) { swap(*this, rhs); return *this; }
它有很多好处:
- 很容易写对
- 与“手工实现”相比,极大地减少了复杂度
- 利用了复制省略
- 提供了强异常保证,如果 swap 是无抛出的
只要你实现了转移构造和廉价、无抛出的交换,以上也可以看作是一个好的转移赋值操作符的实现。右值参数可以转移构造至 x 然后交换给 *this。华而不实!如果你已经使用了规范的复制赋值操作符,毕竟你可能就不需要再写一个转移赋值操作符了。
这就是说,即使是在C++03中,这种“规范实现”往往过早地从左值进行复制。对于 std::vector 的情形,在赋值号左边的对象也许有足够的空间,你只需要销毁其中的元素然后将右边的元素复制过来就可以了,这样可以避免一次昂贵的内存分配,而且如果源 vecotr 非常大的话,可能会导致内存不足。
所以,std::vector 使用了一种更为经济的赋值操作符,其签名为 vector& operator=(vector const&),该实现允许将左值的复制延迟至已确认了必须要复制之后。不幸的是,如果我们试图将这个规范的复制赋值签名用于右值的话,将产生歧义的重载。相反,我们需要类似的东西,即效果相同但是将临时对象的生成移至赋值操作符之内:
vector& operator=(vector&& rhs)
{
vector(std::move(rhs))
.swap(*this);
return *this;
}
看上去,这确实就是转移赋值语义的泛型实现,不过这里有另外一个问题。我们来计算一下这个操作的总开销:
- 第3行:3次内存读和4-6次的内存写(视实现而定)
- 第4行:6次读和6次写
- 第5行:*this 原用内容的析构
现在来和“清除后交换”的实现比较一下:
vector& operator=(vector&& rhs)
{
this->clear();
std::swap(*this, rhs);
return *this;
}
- 第3行:*this 原用内容的析构
- 第4行:6次读和6次写
回想一下早前我们提到过的,多数(甚至可能是绝大多数)转移赋值的左操作数是一个刚刚被转移走的对象。在一般情形下,析构 *this 原用内容——通过 clear 或是通过临时对象的析构——的开销只是单次的测试和跳转。所以,其它的操作是主要开销,而“清除后交换”的实现要比另一个实现差不多快上两倍。
实际上,我们可能还可以做得更好。是的,理论上 swap 操作可以让我们回收利用左操作数的空间而不是过早地把它处理掉,但实际问题是该什么时候做呢?答案是仅当从一个已被 std::move 的左值进行赋值的时候,因为真正的右值很快会被销毁。那么有多大机会左操作数真的有足够空间呢?机会不大,因为左操作数通常都是一个刚刚被转移走的对象。因此,以下这样的实现有可能是真正最高效的:
vector& operator=(vector&& rhs)
{
if (this->begin_)
{
this->destroy_all(); // destroy all elements
this->deallocate(); // deallocate memory buffer
}
this->begin_ = rhs.begin_;
this->end_ = rhs.end_;
this->cap_ = rhs.cap_;
rhs.begin_ = rhs.end_ = rhs.cap_ = 0;
return *this;
}
说的够多了,我要看数字!
如果你想知道以上各种实现的实际效果, 我用了这个测试文件来证明不仅可以通过实现转移语义来提高速度,还可以进一步地优化它。这个测试是对一个 std::vector 和一个 boost::array 使用支持转移语义的 std::rotate 算法。如你所见,对于 std::vector,“规范的”转移语义实现要比“清除后交换”实现好一点点,不过,通过以最少操作实现转移赋值,还可以更快。
vector 转移赋值的实现比较
array 转移赋值的实现比较
对于 boost::array(我们假设其结构类似于 boost::tuple),轮到使用 swap 的实现比最简单的一个一个元素的转移实现差不多慢上三倍,而经过仔细优化,我们还可以做得更好。
因此,这个故事的寓意是:要警惕公式化的转移操作实现;记住,转移语义就是为了优化,所以转移操作必须是非常快的,这里一点开销,那里一点开销,就有可能产生明显的差异。
待续
在下一篇文章中,我们将探讨异常安全的转移构造函数。敬请期待...