翻译:怎样理解 C++ 11中的move语义(深入)--- An answer from stackoverflow

紧接上一篇译文,这一篇对move语义的来龙去脉有非常详尽的回答(原文),篇幅较长,如果你能读完,相信你不会再问任何关于move语义的问题了。

-------------------------------------------------------------------------译文

我的第一个回答是对move语义的一个极其简单的介绍,故意略过了很多细节。但是move语义确实还有很多需要解释的,我想这是我给出第二个回答来填坑的时候了。第一个回答已经很久了,我觉得完全把它替换掉有点不太合适,作为一篇介绍,它依然挺好。如果你想更深入,请继续往下读:

Stephan T. Lavavej 给了我很多反馈,非常感谢!

引言

Move语义允许一个对象在特定的情形下,取得其他对象的资源。这在两个方面显得很重要:

1.将昂贵的拷贝运算变为转移。
可以看我的第一个回答,注意,如果一个对象没有保持至少一个额外的资源(不管是直接或间接的),通过move语义实现的转移构造函数就不会带来任何好处,在这种情况下,复制或转移一个对象代价是相同的。

class cannot_benefit_from_move_semantics
{
    int a;        // moving an int means copying an int
    float b;      // moving a float means copying a float
    double c;     // moving a double means copying a double
    char d[64];   // moving a char array means copying a char array

    // ...
};

2.用于实现“安全转移类型”。

也就是这个类型不能复制,只能转移。例如锁,文件句柄和唯一拥有性(unique ownership semantics)的智能指针。这里我们要讨论废弃C++ 98中的智能指针模板std::auto_ptr,在C++ 11标准中被std::unique_ptr代替。中级C++程序员可能都会对std::auto_ptr有所了解,因为他所表现出的“转移语义”。这似乎是讨论C++ 11 中move语义的一个不错的起点。YMMV

什么是Move

C++98标准库中提供了一种唯一拥有性的智能指针std::auto_ptr。它的作用是保证动态分配的对象总会被释放,即使在出现异常的情况下。

1 {
2     std::auto_ptr<Shape> a(new Triangle);
3     // ...
4     // arbitrary code, could throw exceptions
5     // ...
6 }   // <--- when a goes out of scope, the triangle is deleted automatically

auto_ptr的不寻常之处在于它的“复制”行为:

 1 auto_ptr<Shape> a(new Triangle);
 2 
 3       +---------------+
 4       | triangle data |
 5       +---------------+
 6         ^
 7         |
 8         |
 9         |
10   +-----|---+
11   |   +-|-+ |
12 a | p | | | |
13   |   +---+ |
14   +---------+
15 
16 auto_ptr<Shape> b(a);
17 
18       +---------------+
19       | triangle data |
20       +---------------+
21         ^
22         |
23         +----------------------+
24                                |
25   +---------+            +-----|---+
26   |   +---+ |            |   +-|-+ |
27 a | p |   | |          b | p | | | |
28   |   +---+ |            |   +---+ |
29   +---------+            +---------+

注意b是怎样使用a进行初始化的,它不复制triangle,而是把triangle的所有权从a传递给了b,也可以说成“a 被转移进了b”或者“triangle被从a转移到了b”。这或许有点令人困惑,因为triangle对象本身一直保存在内存的同一位置。

auto_ptr 的复制构造函数可能看起来像这样(简化):

 

1 auto_ptr(auto_ptr& source)   // note the missing const
2 {
3     p = source.p;
4     source.p = 0;   // now the source no longer owns the object
5 }

危险的和安全的转移

auto_ptr 的危险之处在于看上去应该是复制,但实际上确实转移。调用被转移过的auto_ptr 的成员函数将会导致不可预知的后果。所以你必须非常谨慎的使用auto_ptr ,如果他被转移过。

1 auto_ptr<Shape> a(new Triangle);   // create triangle
2 auto_ptr<Shape> b(a);              // move a into b
3 double area = a->area();           // undefined behavior

但是auto_ptr并不总是危险的。工厂模式方法正式auto_ptr应用最合适的场景:

1 auto_ptr<Shape> make_triangle()
2 {
3     return auto_ptr<Shape>(new Triangle);
4 }
5 
6 auto_ptr<Shape> c(make_triangle());      // move temporary into c
7 double area = make_triangle()->area();   // perfectly safe

注意以下两个例子是如何使用同一种语法形式的:

1 auto_ptr<Shape> variable(expression);
2 double area = expression->area();

他们其中只有一个导致未定义的行为,那么,表达式amake_triangle()到底有什么不同呢?难道他们不是同一种类型吗?他们当然是,但是他们却有不同的值类型。

值类型

显然,在持有auto_ptr 对象的a表达式和持有调用函数返回的auto_ptr值类型的make_triangle()表达式之间一定有一些潜在的区别,每调用一次后者就会创建一个新的auto_ptr对象。这里a 其实就是一个左值(lvalue)的例子,而make_triangle()就是右值(rvalue)的例子。 

转移像a这样的左值是非常危险的,因为我们可能调用a的成员函数,这会导致不可预知的行为。另一方面,转移像make_triangle()这样的右值却是非常安全的,因为复制构造函数之后,我们不能再使用这个临时对象了。表达式本身不能说是临时的;如果我们再次简单地写下make_triangle(),我们会得到另一个临时对象。事实上,这个转移后的临时对象会在下一行之前销毁掉。

1 auto_ptr<Shape> c(make_triangle());
2                                   ^ the moved-from temporary dies right here

注意到字母l和r历史上源于赋值表达式的左边和右边。现在,这已经不准确了,因为有不能出现在赋值表达式左侧的左值(比如数组和用户定义的没有赋值操作符的类型),也有右值却可以(所有的有赋值操作符的类)。

一个类的右值是一个创建临时对象的表达式。正常情形下,在同一作用域内,不会有另外的表达式再来表示同一临时对象。

右值引用

我们现在知道转移左值是十分危险的,但是转移右值却是很安全的。如果C++能从语言级别支持区分左值和右值参数,我就可以完全杜绝对左值转移,或者把转移左值在调用的时候暴露出来,以使我们不会不经意的转移左值。 

C++ 11对这个问题的答案是右值引用。右值引用是针对右值的新的引用类型,语法是X&&。以前的老的引用类型X& 现在被称作左值引用(注意X&&不是引用的引用,C++不存在这种类型)。

如果再加入一个const,我就有了四种不同类型的引用类型。类型X可以使用到哪些表达式类型?

                lvalue   const lvalue   rvalue   const rvalue
---------------------------------------------------------              
X&              yes
const X&      yes      yes            yes      yes
X&&                                    yes
const X&&                              yes      yes
                

实际使用中,你可以不用管const X&&类型,限制为只读的右值类型基本是没什么用处的。

右值引用X&&是一种仅仅针对于右值的新的引用类型。

隐式转换

右值引用已经经历过了几个版本。从版本2.1开始,右值引用X&&可以在所有的值类型Y上使用,只有提供一个从YX的隐式转换。这种情形下,一个临时X对象被创建,右值引用会指向这个临时对象。

1 void some_function(std::string&& r);
2 some_function("hello world");

在上面的例子中,"hello world"是类型 const char[12]的左值,因为const char[12]可以通过const char*转换为std::string所以会创建一个临时的string对象,参数r会被指向到这个临时对象上。这是右值(表达式)和临时对象之间的区分不那么明显的几种情形之一。

转移构造函数

使用右值引用X&&作为参数的最有用的函数之一就是转移构造函数X::X(X&& source),它的主要作用是把源对象的本地资源转移给当前对象。

C++ 11中,std::auto_ptr<T>已经被std::unique_ptr<T>所取代,后者就是利用的右值引用。我们接下来会讨论简化版的std::unique_ptr<T>。首先,我们封装了一个原始指针,并且重载了->和*操作符,这样我们的类就可以像一个指针一样了。

 1 template<typename T>
 2 class unique_ptr
 3 {
 4     T* ptr;
 5 
 6 public:
 7 
 8     T* operator->() const
 9     {
10         return ptr;
11     }
12 
13     T& operator*() const
14     {
15         return *ptr;
16     }

构造函数取得一个对象的所有权,析构函数释放这个对象。

1     explicit unique_ptr(T* p = nullptr)
2     {
3         ptr = p;
4     }
5 
6     ~unique_ptr()
7     {
8         delete ptr;
9     }

现在,有趣的地方到了,转移构造函数:

1     unique_ptr(unique_ptr&& source)   // note the rvalue reference
2     {
3         ptr = source.ptr;
4         source.ptr = nullptr;
5     }

这个转移构造函数跟auto_ptr中复制构造函数做的事情一样,但是它却只能接受右值作为参数。

1 unique_ptr<Shape> a(new Triangle);
2 unique_ptr<Shape> b(a);                 // error
3 unique_ptr<Shape> c(make_triangle());   // okay

第二行不能编译通过,因为a是左值,但是参数unique_ptr&& source只能接受右值,这正是我们所需要的,杜绝危险的隐式转移。第三行编译没有问题,因为make_triangle()是右值,转移构造函数会将临时对象的所有权转移给对象c。这也正是我所需要的。

转移构造函数将本地资源的所有权转移给当前对象。

转移赋值操作符

关于move语义的最后一部分是转移赋值操作符。它的作用是释放就资源,并从参数中获取新资源:

    unique_ptr& operator=(unique_ptr&& source)   // note the rvalue reference
    {
        if (this != &source)    // beware of self-assignment
        {
            delete ptr;         // release the old resource

            ptr = source.ptr;   // acquire the new resource
            source.ptr = nullptr;
        }
        return *this;
    }
};

注意注意赋值操作符的这个实现复制了析构函数和转移构造函数的逻辑。你熟悉 copy-and-swap惯用法吗?它也可以用到move语义上,成为move-and-swap惯用法:

1     unique_ptr& operator=(unique_ptr source)   // note the missing reference
2     {
3         std::swap(ptr, source.ptr);
4         return *this;
5     }
6 };

现在 source是unique_ptr类型的变量,它将会被转移构造函数初始化,也就是说这个变量将会转化为参数,这个变量还是要求是右值,因为转移构造函数的参数是指向这个右值的引用。当这个控制流到达赋值操作符operator=的关闭大括号时,source会脱离作用域,自动释放资源。

转移赋值操作符将本地资源的所有权转移给当前对象,并释放对象的旧资源,move-and-swap惯用法简化了这个实现。

转移左值

有时候,我们可能想转移左值,也就是说,有时候我们想让编译器把左值当作右值对待,以便能使用转移构造函数,即便这有点不安全。出于这个目的,C++ 11在标准库的头文件<utility>中提供了一个模板函数std::move。这个函数名称有点不尽如人意,因为std::move仅仅是简单地将左值转换为右值,它本身并没有转移任何东西。它仅仅是让对象可以转移。或许它应该被命名为std::cast_to_rvalue 或者 std::enable_move,但是现在我们对这个名称还束手无策。

以下是如何是如何正确的转移左值:

1 unique_ptr<Shape> a(new Triangle);
2 unique_ptr<Shape> b(a);              // still an error
3 unique_ptr<Shape> c(std::move(a));   // okay

请注意,第三行之后,a不在拥有Triangle对象。 不过这没有关系,因为通过明确的写出std::move(a),我们很清楚我们的意图:亲爱的转移构造函数,你可以对a做任何想要做的事情来初始化c;我不要需要a了,对于a,您请自便。

std::move(some_lvalue)将左值转换为右值,使接下来的转移成为可能。

Xvalues

注意即便std::move(a)是右值,但它自己并不生成一个临时对象。这个难题让C++委员会引入了第三种值类型。一种可以被右值引用指向,但是又不是传统意义上的右值的类型,这种类型被叫做Xvalues(即将被释放的value)。传统意义上的右值被重新命名为绝对右值(prvalues ,Pure rvalues)。

绝对右值和Xvalue都是右值,Xvalue和左值都是普通左值(glvalues ,Generalized lvalues),通过下图可能更容易理解他们之间的关系:

      expressions
          /     \
         /       \
        /         \
    glvalues   rvalues
      /  \       /  \
     /    \     /    \
    /      \   /      \
lvalues   xvalues   prvalues

请注意,只有xvalues是新类型,其他类型仅仅是换了个名称,容易分类。 

C++ 98中的右值在C++ 11中被称为绝对右值,也就是上图中的prvalues。

转移出函数内部

到目前为止,我们看到的都是转移局部变量和函数参数。但是转移也适用于其他情形。如果函数按值返回,调用它的对象(可能是一个局部变量,或者一个临时对象,也可能是任何类型的对象)被以函数返回后的表达式作为参数的转移构造函数初始化:

unique_ptr<Shape> make_triangle()
{
    return unique_ptr<Shape>(new Triangle);
}          \-----------------------------/
                  |
                  | temporary is moved into c
                  |
                  v
unique_ptr<Shape> c(make_triangle());

貌似有点奇怪,局部变量没有被声明为static类型,也能被隐式地转移出函数:

1 unique_ptr<Shape> make_square()
2 {
3     unique_ptr<Shape> result(new Square);
4     return result;   // note the missing std::move
5 }

如果转移构造函数接受一个左值result作为一个参数会怎么样呢?result的作用域马上就要结束,它将会在出栈时被释放。没有人会抱怨后来result以某种方式改变了,当控制流转会给调用者的时候,result已经不存在了!基于这个原因,C++ 11有一个特殊的规则来允许函数不调用std::move返回自动释放的对象。事实上,永远不要把自动释放类型的对象转移出函数内部,因为这和返回值优化(NRVO)相冲突。

永远不要使用std::move把自动释放类型的对象转移出函数内部

请注意两种工厂模式方法返回的都是值,而不是右值引用。右值引用依然是引用,和往常一样,永远不要返回一个局部自动释放对象的引用;如果你对编译器写下了像下面这样的代码,调用者会得到一个空悬的引用:

1 unique_ptr<Shape>&& flawed_attempt()   // DO NOT DO THIS!
2 {
3     unique_ptr<Shape> very_bad_idea(new Square);
4     return std::move(very_bad_idea);   // WRONG!
5 }

永远不要返回一个局部自动释放对象的右值引用。转移是为转移构造函数量身定做的,而不是std::move,也不仅仅是指向右值的右值引用。

转移进成员变量

你迟早会下这样的代码:

 1 class Foo
 2 {
 3     unique_ptr<Shape> member;
 4 
 5 public:
 6 
 7     Foo(unique_ptr<Shape>&& parameter)
 8     : member(parameter)   // error
 9     {}
10 };

编译器肯定会“抱怨”parameter 本身是左值。如果你查看它的类型,它是右值引用,但是右值引用仅仅是指向右值的引用,并不意味着右值引用本身是右值。事实上,parameter 仅仅是一个普通变量名而已,在构造函数内部,你可以想怎么用就怎么用,它永远指向同一个对象。对它进行隐式的转移是危险的,因此C++从语言层面上禁止这样使用。

一个命名的右值引用本身是一个左值,跟其他普通左值一样。

这个问题的解决方案是手动让让它可以转移:

 1 class Foo
 2 {
 3     unique_ptr<Shape> member;
 4 
 5 public:
 6 
 7     Foo(unique_ptr<Shape>&& parameter)
 8     : member(std::move(parameter))   // note the std::move
 9     {}
10 };

你可能会说parameter 在初始化member之后就不再使用了,为什么这里没有像函数返回值一样自动插入std::move这条特殊规则?或许是因为这会给编译器的实现带来太多负担。例如,what if the constructor body was in another translation unit?(不理解),相反,函数返回值规则只需要简单的检测符号表就能知道函数返回值后变量是否会自动释放。 

你也可以对parameter 传值。对于像unique_ptr这样的转移类型,似乎还没有成文的机制。就我个人而言,我喜欢传值,因为这样接口会更清晰。

特殊成员函数

C++ 98标准会在需要的时候自动生成三种特殊类型的成员函数:复制构造函数,赋值操作符和析构函数

1 X::X(const X&);              // copy constructor
2 X& X::operator=(const X&);   // copy assignment operator
3 X::~X();                     // destructor

Rvalue经历过了很多版本,从版本3.0开始,C++ 11标准增加了另外两种特殊成员函数:转移构造函数和转移赋值操作符。注意VC10和VC11都还没有实现3.0版本,所以,你需要自己实现他们。

1 X::X(X&&);                   // move constructor
2 X& X::operator=(X&&);        // move assignment operator

这两个新特殊成员函数只有在没有手动声明的情况下,才会自动声明。而且,如果你手动声明了转移构造函数和转移赋值操作符,复制构造函数和复制操作符都不会被自动声明了。 

这在实际应用中意味着什么呢?

如果你在写了一个不需要管理资源的类,这五种特殊成员函数就都不需要手动声明,而且你会得到正确的复制和转移。否则你需要自己实现这五种特殊成员函数,当然,如果你的类型使用转移不会带来任何好处,那你就没必要自己实现和转移有关的特殊成员函数(转移构造函数和转移赋值操作符)。 

注意到复制赋值操作符和转移赋值操作符可以合并到一起,形成一个以值为参数的统一赋值操作符:

1 X& X::operator=(X source)    // unified assignment operator
2 {
3     swap(source);            // see my first answer for an explanation
4     return *this;
5 }

这样的话,五个需要实现的特殊成员函数就变成了四个,这里有个异常安全和效率的平衡问题,但是我不是这方面的专家。

通用引用类型

考虑以下模板函数:

1 template<typename T>
2 void foo(T&&);

你可能希望T&&仅仅指向右值,因为乍一看,它像一个右值引用,但是事实证明,它也可以指向左值:

1 foo(make_triangle());   // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&
2 unique_ptr<Shape> a(new Triangle);
3 foo(a);                 // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

如果参数是是X类型的右值,模板参数T会被推断为X类型,因此T&&会成为X&&,这正是大家想看到的,但是如果参数是X类型的左值,根据那条特殊规则,模板参数T会被推断为X&,因此T&& 会成为这个样子:X& &&,但是鉴于C++标准还没有引用的引用类型,X& &&这个类型会被压缩为X&。起初,这似乎看起来有点令人困惑和无用,但是压缩引用是完美推导(perfect forwarding)的本质,这里我们不讨论。

T&&不是一个右值引用,而是一个通用引用类型。它也以指向左值,在这种情况下,T和T&&都是左值引用。

如果你想限制函数模板参数为右值,你可以联合SFINAE(Substitution Failure Is Not an Error,匹配失败不是错误)和类型萃取(type traits):

1 #include <type_traits>
2 
3 template<typename T>
4 typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
5 foo(T&&);

转移的实现

现在你理解了什么是引用压缩,这里我们我来看一下std::move的实现:

1 template<typename T>
2 typename std::remove_reference<T>::type&&
3 move(T&& t)
4 {
5     return static_cast<typename std::remove_reference<T>::type&&>(t);
6 }

正如你看到的,由于通用引用类型T&&,move接受任何类型的参数,而且它返回右值引用。调用std::remove_reference<T>::type是有必要的,因为如果X为左值类型,返回值类型将为变为X& &&,这将被压缩为X&。由于t永远是一个左值(命名的右值引用是左值),但是我们想把t绑定到右值引用,我就得显示地将t转换为想要的返回值类型。调用返回右值引用函数的本身是xvalue。现在你知道xvalues是怎么来的了吧。

调用返回右值引用函数的本身是xvalue,例如std::move

注意到这个例子中返回右值引用是没有问题的,因为t没有指向任何局部自动销毁对象,而是指向由调用者传进来的一个对象。(全文完)

posted on 2013-01-22 14:27  落叶的思维  阅读(13487)  评论(22编辑  收藏  举报

导航