右值系列之四:再论赋值

第四篇:再论赋值

原文来自: 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 的实现比最简单的一个一个元素的转移实现差不多慢上三倍,而经过仔细优化,我们还可以做得更好。

因此,这个故事的寓意是:要警惕公式化的转移操作实现;记住,转移语义就是为了优化,所以转移操作必须是非常快的,这里一点开销,那里一点开销,就有可能产生明显的差异。

待续

在下一篇文章中,我们将探讨异常安全的转移构造函数。敬请期待...


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值