C++11新特性:右值引用与move语义

C++ 引用 拷贝构造函数 赋值运算符 构造函数 析构函数

C++11右值引用是一个颇为重要的新特性,解决了C++中一个广为诟病的性能问题。 右值引用特性允许我们对右值进行修改。借此可以实现move语义: 右值不需要被复制直接传递给构造函数,操作结束后空的右值析构也不会销毁内存。

C++03及之前的标准中,右值是不允许被改变的,实践中也通常使用const T&的方式传递右值。然而这是效率低下的做法,例如:

Person get(){
    Person p;
    return p;
}
Person p = get();

上述获取右值并初始化p的过程包含了Person的3个构造过程和2个析构过程。 使用右值引用的特性我们可以避免其中不必要的内存拷贝,从右值中直接拿数据过来初始化或修改左值。 一个move构造函数是这样声明的:

class Person{
public:
    Person(Person&& rhs){...}
    ...
};

左值和右值

C++ 表达式的值有三种类别:左值、右值和临终值。 其中左值是指在表达式的外部保留的对象,可以将左值视为有名称的对象,所有的变量都是左值。 右值是一个不在使用它的表达式的外部保留的临时值,比如函数的返回值、字面常量。 临终值是生命周期已经结束,但内存仍未回收的值,比如函数的返回值可以声明为int&&。 若要更好地了解左值和右值之间的区别,看下面的示例:

int a = 3;              // a 是变量,所以它是一个左值
                        // 3 是字面常量,所以它是一个右值
int b = a;              // b 是变量,也是一个左值。a 是有名称的,也是一个左值
b = (a + 1);            // (a + 1) 是一个右值,它是一个背后的没有名称的值
b = getValue();         // getValue() 的返回值是一个右值,他没有名称

可以通过表达式的值是否可以取地址来判断左值还是右值。左值都是可以取地址的。

右值的特点在于它不被后续计算所需要,因为它连个名字都没有,程序中无法再次访问一个右值。

右值的重复拷贝

右值虽然是不被后续计算所需要的,但它仍然需要构造和析构。 这在C++中造成了不少的代价,下面是一个中规中矩的Person类:

class Person{
    char* name;
public:
    Person(const char* p){
        size_t n = strlen(p) + 1;
        name = new char[n];
        memcpy(name, p, n);
    }
    Person(const Person& p){
        size_t n = strlen(p.name) + 1;
        name = new char[n];
        memcpy(name, p.name, n);
    }
    ~Person(){ delete[] name; }
};

其实应该用智能指针来管理动态内存,但这里为了简明起见直接用C风格的指针。

当我们拷贝Person对象时,会有额外的不需要的内存分配过程,例如:

Person getAlice(){
    Person p("alice");      // 对象创建。调用构造函数,一次 new 操作
    return p;               // 返回值创建。调用拷贝构造函数,一次 new 操作
                            // p 析构。一次 delete 操作
}
int main(){
    Person a = getAlice();  // 对象创建。调用拷贝构造函数,一次 new 操作
                            // 右值析构。一次 delete 操作
    return 0;
}                           // a 析构。一次 delete 操作

看到没?三次构造函数三次析构函数。返回值优化move语义便是用来避免这些不必要的构造过程和动态内存操作的。

返回值优化

事实上编译器会对上述代码进行返回值优化,其实这里是命名返回值优化(NRVO),可以减少两次拷贝构造。 上述代码其实只需要一次构造和一次析构。为了让代码更加清晰,我们来看去掉动态内存相关代码的Person类:

struct Person{
    Person(const char* p){
        cout<<"constructor"<<endl;
    }
    Person(const Person& p){
        cout<<"copy constructor"<<endl;
    }
    const Person& operator=(const Person& p){
        cout<<"operator="<<endl;
        return *this;
    }
    ~Person(){
        cout<<"destructor"<<endl;
    }
};

Person getAlice(){
    Person p("alice"); return p;
}

int main(){
    cout<<"______________________"<<endl;
    Person a = getAlice();
    cout<<"______________________"<<endl;
    a = getAlice();
    cout<<"______________________"<<endl;
}

程序输出是:

______________________
constructor             // 1) getAlice 里的 p 被构造
______________________
constructor             // 2) getAlice 里的 p 被构造
operator=               // 3) 右值赋值给左值
destructor              // 4) 右值析构
______________________
destructor              // 5) a 析构

可见上述代码经过RVO之后,返回值没有被拷贝:

  • 对于赋初值运算,甚至连a的拷贝构造函数都没有执行,直接使用了getAlice里的对象。
  • 对于赋值运算符,虽然没有拷贝返回值,但operator=还是执行了的。

move语义

对于上述输出的 3) ,右值赋值给左值调用了赋值运算符operator=。它的完整实现可能是这样的:

const Person& Person::operator=(const Person& rhs){
    delete[] name;
    size_t n = strlen(rhs.name) + 1;
    name = new char[n];
    memcpy(name, rhs.name, n);
    return *this;
}

其实上述实现是错误的,既没有解决自赋值问题,也没有保证异常安全。关于自赋值问题可参见:Effective C++: Item 11,关于异常安全可参见:Effective C++: Item 25

但C++11提供了右值引用,rhs不一定要声明为const。它是可以变的,这样我们就可以把右值的数据name直接拿过来而不需要重新申请内存。右值引用的语法是&&

const Person& Person::operator=(Person&& rhs){
    cout<<"move operator="<<endl;
    delete[] name;
    name = rhs.name;
    rhs.name = nullptr;
    return *this;
}

注意这里的rhs.name一定要设为空指针,这样编译器就不会去delete它了。同样地,我们把拷贝构造函数也声明为move拷贝构造函数:

Person(Person&& p){
    cout<<"move copy constructor"<<endl;
    name = p.name;
    p.name = nullptr;
}

然后在来重新执行main函数,可以得到输出:

______________________
constructor             // 1) getAlice 里的 p 被构造
______________________
constructor             // 2) getAlice 里的 p 被构造
move operator=          // 3) 右值赋值给左值,调用move赋值运算符!
destructor              // 4) 右值析构
______________________
destructor              // 5) a 析构

因为拷贝构造函数已经被返回值优化掉了,所以move拷贝构造函数也不会对得到调用。

Harttle

致力于简单的、一致的、高效的前端开发

看看这个?