C++ Primer学习笔记 – 对象移动move

背景

C++ 11 新特性对象移动,可以移动对象而非拷贝。在某些情况下,对象拷贝后就立刻被销毁了,比如值传递参数,对象以值传递方式返回,临时对象构造另一个对象。在这些情况下,如果使用移动对象而非拷贝对象能大幅提升性能。

string s1(string("hello")); // 无名对象string("hello") 就是一个会在拷贝构造s1后,立即销毁临时对象

[======]

右值引用

提到对象移动,就不得不提到2个元素:右值引用和std::move库函数。

右值引用(rvalue reference)就是必须绑定到右值的引用,主要包括无名对象、表达式、字面量。通过 && 来获得右值的引用。
简单理解,右值引用就是临时对象的引用,但临时对象并不一定是右值,而是要立刻销毁的临时对象才是右值对象。比如函数内定义的有名对象是临时对象,并不是立刻销毁。

右值引用特性

1)右值引用只能绑定到一个将要销毁的对象。可以自由地将一个右值引用的资源“移动”到另一个对象上;
2)类似于左值引用,右值引用也是一个对象的别名;

右值引用和左值引用的区别

左值引用(lvalue reference)是我们熟悉的常规引用,为了区分右值引用而提出。特点是不能将左值引用绑定到1)要求转换的表达式;2)字面常量;3)返回右值的表达式;

例如,

int a = 2;
int &i = a * 2; // 错误:临时计算结果a * 2 是右值,不能绑定到左值引用
const int& ii = a * 2; // 正确:可以将一个const引用绑定到一个右值上
int &&r = a * 2; // 正确:std::move将左值a转换成了右值,能绑定到右值引用

int &i1 = 42; // 错误:42是字面常量,不能绑定到左值引用
int &&r1 = 42; // 正确:42是字面常量,能绑定到右值引用

int &i2 = std::move(a); // 错误:std::move将左值a转换成了右值,不能绑定到左值引用
int &&r2 = std::move(a); // 正确:std::move将左值a转换成了右值,能绑定到右值引用

注意:可以将一个const引用(不论const &,还是const &&)绑定到一个右值上

左值持久,右值短暂

左值和右值最明显的区别是:左值有持久的状态,不会立即销毁;右值要么是字面常量,要么是表达式求值过程中创建的临时对象。

因此,可以知道右值引用:
1) 所引用的对象将要被销毁;
2)该对象没有其他用户;

详见之前写的这篇文章C++ > 右值引用和左值引用的区别

变量是左值

变量是左值,不能将一个右值引用直接绑定到一个变量上,即使变量是右值引用类型。

int a = 42;
int &&rr1 = 42; // 正确:字面常量是右值
int &&rr2 = a;  // 错误:变量a是左值
int &&rr3 = rr1; // 错误:右值引用rr1是左值

std::move函数

头文件
不能将一个右值引用绑定到一个左值上,但可以通过调用std::move函数,将左值转换为对应的右值引用类型。

int &&rr1 = 42;
int &&rr4 = rr1; // 错误:不能将右值引用绑定到另一个右值引用
int &&rr5 = std::move(rr1); // OK

move函数告诉编译器:我们有一个左值,但希望像一个右值一样处理它。调用move意味着承诺:除对rr1赋值或销毁它之外,不能再使用它。在调用move之后,就不能对移动后源对象的值做任何假设。

int *p = new int(42);
int &&r = std::move(*p);
cout << r << endl;
r = 1;
*p = 3; // 编译器不报错,也不会阻止修改源对象值,但不建议这么做
cout << r << endl;
cout << *p << endl;

注意:与大多数标准库名字的使用不同,对move不提供using上面,建议是直接调用std::move而非move。由于move名字常见,应用程序经常定义该函数,为了避免与应用程序定义的move函数冲突,请使用std::move。
[======]

移动构造函数和移动赋值运算符

移动构造函数(又称move constructor)和移动赋值运算符(又称move assignment运算符),类似于copy函数(copy构造函数,copy assignment运算符),不过前2个函数是从给定对象“窃取”资源,而非拷贝资源。

除了完成资源移动,move constructor还必须确保移动后源对象处于这样的状态:销毁源对象是无害的。
一旦资源移动完成后,资源不再属于源对象而是属于新创建的对象,源对象必须不再指向被移动的资源。

例,为StrVec类定义move constructor,实现从一个StrVec到另一个StrVec的元素move而非copy:

class StrVec
{
public:
    StrVec(const StrVec &s); // copy constructor
    StrVec(StrVec &&s) noexcept; // move constructor
    ...
private:
    string *elements;
    string *first_free;
    string *cap;
};

StrVec::StrVec(StrVec &&s) noexcept // move操作不应抛出任何异常
// 成员初始化器接管s中的资源
    : elements(s.elements), first_free(s.first_free), cap(s.cap)
{
    // 令s进入这一的状态 -- 对齐运行析构函数是安全的
    s.elements = s.first_free = s.cap = nullptr;
}

move构造函数中,新创建对象成员初始化器接管了源对象中的资源,并将源对象指向资源的指针都置空,就完成了资源的移动操作。源对象析构时,资源并不会被释放,因此新对象使用资源是安全的。
noexcept 表明该函数不抛出任何异常。

移动操作、标准库容器和异常

因为移动操作“窃取”资源,通常不分配任何资源。因此移动操作通常不会抛出异常。既然如此,为什么需要指明noexcept呢?
这是因为,除非编译器知道我们的move构造函数不会抛出异常,否则会认为移动我们的类对象可能抛出异常,并且为了处理这种可能而做一些额外工作。因此,如果确认不会抛出异常,就用noexcept显式指出。

TIPS:
不抛出异常的move构造函数和move assignment运算符必须标记为noexcept。

移动操作通常不抛出异常,但不代表不能抛出异常,而且标准库容器能对异常发生时自身的行为提供保障。比如,vector保证,调入push_back发生异常(如内存不够),vector自身不会改变。

为了避免这种潜在问题,除非vector知道元素类型的move构造函数不会抛出异常,否则,在重新分配内存的过程中,必须用copy构造函数而非move构造函数。
如果希望在vector重新分配内存这类情况下,对我们自定义类型的对象进行move而非copy,就必须显式告诉标准库我们的移动构造函数可以安全使用。

简而言之:move构造函数如果可能抛出异常,就使用copy构造函数构造对象。如果move构造函数不抛出异常,就用noexcept显式声明。

移动赋值运算符(move assignment)

move assignment执行与析构函数和move构造函数相同的工作。如果我们的move assignment运算符不抛出任何异常,就应该标记为noexcept。
定义move assignment三步:

  1. 释放当前对象已有资源;
  2. 接管源对象的资源;
  3. 置源对象为可析构状态;
class StrVec
{
public:
    ...
    StrVec& operator=(StrVec &&rhs) noexcept; // move assignment
    ...
private:
    string *elements;
    string *first_free;
    string *cap;
};

StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
    if (this != &rhs) {// 避免自移动,因为move返回结果可能是对象自身
        // 释放this对象已有元素, 相当于调用this->~StrVec
        free(); 
        // 从rhs接管资源
        elements = rhs.elements;
        first_free = rhs.first_free;
        cap = rhs.cap;

        // 将rhs置于可析构状态
        elements = first_free = cap = nullptr;
    }
    return *this;
}

移动后源对象必须可析构

资源从一个对象移动到另一个对象并不会销毁源对象,但有时在移动操作完成后,源对象会被销毁。因此,在编写移动操作时,必须确保移动后源对象进入可析构状态。否则,析构源对象可能导致资源释放,或者修改资源状态,导致接管对象出现异常。

移动资源完成后,程序不应该在依赖于源对象中的数据。虽然可能还能访问源对象中的数据,但结果是不确定的。

TIPS:移动之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其进行任何假设。什么时候可析构?取决于用户,通常可立即析构。

合成的move操作

如果我们不声明自己的copy函数,当需要的时候,编译器会为我们合成默认的版本。类似地,编译器也会为我们合成move函数(move constructor和move assignment运算符,即move操作)
copy操作可以被定义成三种情况:
1)bit-wise copy成员;
2)为对象赋值;
3)删除的函数;

什么时候编译器不合成move操作?
不同于copy函数,编译器不会为某些类合成move操作,特别一个类如果定义了3种成员函数:
1)copy构造函数;
2)copy assignment运算符;
3)析构函数;

什么时候编译器会合成move操作?
只有当一个类没有定义任何自己版本的copy控制成员(即copy函数),且类的每个非static数据成员都可以移动时(通常是内置类型、支持move操作的对象),编译器才会为它合成move操作。

当类没有move操作时,会发生什么?
当一个类没有move操作时,正常的函数匹配,类会使用copy操作来替代。

合成move操作示例:

struct X
{
    int i; // 内置类型
    string s; // string定义了自己的move操作
};
struct hasX
{
    X mem; // X有合成的move操作
};

int main()
{
    X x, x2 = std::move(x); // 使用合成的move构造函数
    hasX hx, hx2 = std::move(hx); // 使用合成的move构造函数
    return 0;
}

思考:当我们既没有定义copy函数,也没有定义move函数时,编译器何时使用合成的copy操作,何时使用合成的move操作?
我的理解:看函数匹配,如果用于构造或赋值的实参是左值,就用合成的copy操作;如果是右值,就用合成的move操作。

定义default、delete move操作
与copy操作不同,move操作不会隐式定义为delete。相反,如果我们用=default显式要求编译出合成move操作,但编译器不能移动所有成员,则编译器会将move操作定义为delete。

那么,什么时候编译器会将move操作定义为delete呢?
其原则是:

  • 与copy构造函数不同,move构造函数被定义为delete的条件是:存在数据成员有copy constructor没有move constructor,或者没有copy constructor但无法合成move constructor。move assignment情况类似。

  • 如果有数据成员的move操作被定义为delete或者不可访问的,那么类的move操作被定义为delete。

  • 类似copy构造函数,如果类的析构函数被定义为delete或不可访问的,则类的move构造函数被定义为delete。

  • 类似copy assignment运算符,如果有类数据成员是const的或引用,则类的move assignment被定义为删除的。

例如,假设Y是一个class,定义了copy构造函数,但没有定义move构造函数:

class Y {
public:
    Y() = default;
    Y(const Y&) { cout << "Y copy constructor invoked" << endl; }
    Y& operator=(const Y&) { cout << "Y copy assignment invoked" << endl; return *this; }
};

// 由于数据成员Y没有move函数(move构造函数和move assignment运算符),编译器不会为hasY合成move函数,相反合成copy函数
struct hasY {
    hasY() = default;
    hasY(hasY &&) = default; // 编译器并不会合成move构造函数
    hasY& operator=(hasY&&) = default; // 编译器不会合成move assignment运算符

    Y mem; // 将有一个delete的move constructor,move assignment运算符
};

int main()
{
    hasY hy, hy2 = std::move(hy); // 实际上并不会调用hasY的move constructor, 而是调用copy constructor
    hasY h3, h4;
    h4 = std::move(h3); // 实际上调用copy assignment运算符
    return 0;
}

运行结果:
可以看到,即使指示move函数为default,但实际上并没有调用move函数,而是调用的copy函数。

Y copy constructor invoked
Y copy assignment invoked

移动操作和合成的copy控制成员间还有相互作用:如果一个类定义了move函数,则该类合成对应的copy函数会被定义为delete。

move右值,copy左值

如果一个类既有move函数,也有copy函数,编译器使用普通的函数匹配规则确定使用哪个函数:左值使用copy函数,右值使用move函数。

StrVec v1, v2; // v1, v2是左值
v1 = v2; // v2是左值,使用copy assignment运算符
StrVec getVec(istream &); // getVec返回右值(即将销毁的临时对象)
v2 = getVec(cin); // getVec返回右值,使用move assignment运算符

如果没有move函数,就使用相应copy函数,即使是右值

当只有copy函数(包括合成的),没有move函数(包括编译器没有合成,用户设置函数为delete)时,即使是右值,也使用copy函数,而不是使用move函数(因为没有)。

class Foo {
public:
    Foo() = default; // default constructor
    Foo(const Foo&); // copy constructor
    ... // 其他函数,但没有move constructor
};

Foo x; // 使用default constructor构建对象x
Foo y(x); // 使用copy constructor构建对象y
Foo z(std::move(x)); // 使用copy constructor,不使用move constructor,因为没有move constructor

std::move(x)返回的是一个绑定到x的Foo&&(右值),由于没有move构造函数,只有copy构造函数,因此即使构建对象z的时候,使用了右值,但实际上会把Foo&&转换为const Foo&,从而调用copy构造函数构造z。

拷贝并交换赋值运算符和move操作

std::swap可以实现资源的移动。

比如,我们定义class HashPtr:

// 定义default构造函数,move构造函数,不定义copy构造函数和move assignment运算符
// 可以推断编译器不会合成copy构造函数和copy assignment运算符( 隐式delete)
class HashPtr {
public:
    HashPtr() : ps(nullptr), i(0) { } // default构造函数
    HashPtr(HashPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; } // move构造函数,由于合成了move构造函数,所以不会合成copy操作
    // assignment(operator=) 既是move assignment运算符,也是copy assignment运算符
    // 注意与HashPtr& operator=(const HashPtr &rhs) {}和HashPtr& operator=(const HashPtr &&rhs) {}的区别
    HashPtr& operator=(HashPtr rhs) { swap(*this, rhs); return *this; } // assignment运算符
    // ... 
private:
    string *ps;
    int i;
};

operator=的参数是HashPtr rhs,意味着传参时,要进行一次copy构造,然而我们已经定义了move构造,编译器不会为我们合成copy构造,也就是说copy构造是隐式delete。

假定hp,hp2都是HashPtr对象:

HashPtr hp;
HashPtr hp2 = hp; // 错误:因为已经定义了move构造函数,编译器不会合成copy构造函数(delete),而hp是一个右值,无法调用move构造函数来构造hp2
HashPtr hp3 = std::move(hp); // OK:std::move将hp转换为右值,调用move构造函数构造hp3
HashPtr hp4, hp5;
hp4 = hp; // 错误:因为hp是左值,copy运算符会用到copy构造函数构造形参rhs,然而copy构造函数是隐式delete
hp5 = std::move(hp); // OK::std::move将hp转换为右值,调用move构造函数构造operator=形参rhs

建议:更新三/五法则

5个拷贝控制成员:
1)1个析构函数;
2)2个copy函数:copy构造函数,copy assignment运算符;
3)2个move函数:move构造函数,moveassignment运算符;

应该看作一个整体,一个类如果定义了任何一个拷贝操作,就应该定义所有5个操作。

  • 如果一个class自定义copy构造函数,那么它很可能需要定义copy assignment (同样适用于move函数);
  • 如果一个class自定义析构函数,那么它很可能需要定义copy函数,因为有自定义对象成员需要自定义copy函数来实现拷贝;
  • 如果一个class自定义析构函数,那么它很可能需要定义move函数,来减少copy资源带来的不必要开销;
  • 如果一个class定义了指针类型数据成员,那么它很可能需要定义析构函数,来释放动态申请的资源;

移动迭代器 move iterator

一般地,一个迭代器解引用(如,*it,it是某个迭代器)返回一个指向元素的左值,然而,move迭代器返回一个指向元素的右值引用。

标准库函数make_move_iterator可以将一个普通迭代器转换为一个move迭代器。
例,不使用move迭代器时,如果要扩张StrVec(自定义动态string数组)的尺寸,可以这样做:

void StrVec::reallocate()
{
    // 分配当前规模大小的2倍空间
    auto newcapacity = size() ? 2 * size() : 1;
    // 分配raw memory
    auto newdata = alloc.allocate(newcapacity);
    // 将数据从旧内存移动到新内存
    auto dest = newdata; // 指向新数组空闲位置
    auto elem = elements; // 指向旧数组下一个元素

    // 在allocator分配的内存上,逐次调用class string的move构造函数
    for (size_t i = 0; i != size(); i++) {
        alloc.construct(dest++, std::move(*elem++));
    }

    free(); // 移动完成,释放旧内存
    // 更新指针
    elements = newdata;
    first_free = dest;
    cap = elements + newcapacity;
}

使用move迭代器:

void StrVec::reallocate()
{
    // 分配当前大小2倍内存空间
    auto newcapacity = size() ? 2 * size() : 1;
    auto first = alloc.allocate(newcapacity);
    // 移动元素
    auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);

    free(); // 释放旧空间
    // 更新指针
    elements = first; 
    first_free = last;
    cap = elements + newcapacity;
}

class StrVec完整源代码

点击查看代码
class StrVec {
public:
    StrVec() :  // allocator成员进行默认初始化
        elements(nullptr), first_free(nullptr), cap(nullptr) { }
    StrVec(const StrVec&);
    StrVec& operator=(const StrVec&);
    ~StrVec();
    void push_back(const string&);

    size_t size() const { return first_free - elements; }
    size_t capacity() const { return cap - elements; }
    string *begin() const { return elements; }
    string *end() const { return first_free; }

private:
    static allocator<string> alloc;
    void chk_n_alloc() { if (size() == capacity()) reallocate(); }
    pair<string*, string*> alloc_n_copy(const string*, const string*);
    void free();
    void reallocate();

    string *elements; // 指向数组首元素的指针
    string *first_free; // 指向数组第一个空闲元素的指针
    string *cap; // 指向数组尾后位置的指针
};

allocator<string> StrVec::alloc;

StrVec::StrVec(const StrVec& s)
{
    auto newdata = alloc_n_copy(s.begin(), s.end());
    elements = newdata.first;
    first_free = cap = newdata.second;
}

StrVec& StrVec::operator=(const StrVec& rhs)
{
    auto data = alloc_n_copy(rhs.begin(), rhs.end());
    free();
    elements = data.first;
    first_free = cap = data.second;
    return *this;
}

StrVec::~StrVec()
{
    free();
}

void StrVec::push_back(const string& s)
{
    chk_n_alloc();
    alloc.construct(first_free++, s);
}

pair<string*, string*> StrVec::alloc_n_copy(const string* b, const string* e)
{
    auto data = alloc.allocate(e - b);
    return { data, uninitialized_copy(b, e, data) };
}

void StrVec::free()
{
    if (elements)
    {
        for (auto p = first_free; p != elements; )
        {
            alloc.destroy(--p);
        }
        alloc.deallocate(elements, cap - elements);
    }
}

void StrVec::reallocate()
{
    // 分配当前大小2倍内存空间
    auto newcapacity = size() ? 2 * size() : 1;
    auto first = alloc.allocate(newcapacity);
    // 移动元素
    auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);

    free(); // 释放旧空间
    // 更新指针
    elements = first; 
    first_free = last;
    cap = elements + newcapacity;
}

// 客户端测试代码
int main()
{
    StrVec s;
    stringstream stream;
    string str;

    for (int i = 0; i < 50; i++) {
        stream << i + 1;
        stream >> str;
        s.push_back(str);
    }
    cout << s.size() << endl;

    StrVec s2;
    s2 = s;
    cout << s2.size() << endl;
    return 0;
}

注意:建议不要随意使用move操作。
1)标准库不保证哪些算法适用move迭代器,哪些不适用。
2)移后源对象具有不确定的状态,可能销毁源对象,也可能不销毁,对其调用std::move很危险的。

因此,如果要使用move操作,必须确保移后源对象没有其他用户,必须确信需要进行move操作是安全的。这并非C++语法要求,而是使用move操作应该遵循的规范。

右值引用和成员函数

除了构造函数和assignment运算符,右值引用也能应用于成员函数,提供成员函数的move版本。
成员函数允许同时提供两个版本重载函数:copy版本,move版本。copy版本接受一个指向const的左值引用,move版本接受一个指向非const的右值引用。
例如,定义了push_back的标准库容器vector,提供了这样2个版本。

void push_back(const X&); // 拷贝:绑定到任意类型的X
void push_back(X&&); // 移动:只能绑定到类型X的可修改的右值

我们可以在上一节StrVec基础上,为其添加2个版本push_back:

class StrVec
{
public:
    void push_back(const string& s); // copy元素
    void push_back(string &&s); // move元素
    ...
};

// copy版本
void StrVec::push_back(const string& s)
{
    chk_n_alloc();  // 如果需要的话为StrVec重新分配内存
    alloc.construct(first_free++, s);
}

// move版本
void StrVec::push_back(string&& s)
{
    chk_n_alloc(); // 如果需要的话为StrVec重新分配内存
    alloc.construct(first_free++, std::move(s));
}

// 客户端
// 实参类型决定了新元素是copy还是move
string vec;
string s = "hello";
vec.push_back(s); // 调用push_back(const string&)
vec.push_back("test"); // 调用push_back(string&&)

右值和左值引用成员函数

通常在一个对象上调用成员函数,并不关心该对象是左值还是右值。
例如,我们在一个string右值(s1+s2)上调用find成员

string s1 = "this is a value", s2 = "another";
auto n = (s1 + s2).find('a');
cout << n << endl; // 打印8

旧标准无法阻止这种使用方式,为了维持向后兼容性,新标准库类仍然允许这种向右值赋值。但如果我们想希望在自己的class中,强制左侧运算对象(即this指向的对象)是一个左值,阻止向右值赋值,该怎么办?
答:可以在参数列表后放置一个引用限定符(reference qualifier),指出this的左值/右值属性。

规则:
加了引用限定符 & 的成员函数,只能被左值对象调用;
加了引用限定符 && 的成员函数,只能被右值对象调用;

//------ 限定向可修改的左值赋值 --------------
class Foo
{
public:
    Foo& operator=(const Foo&) &; // 指出this可以指向一个左值
};

Foo& operator=(const Foo& rhs) &
{
    // 将rhs赋予本对象
    ...

    return *this;
}

//------ 限定向可修改的右值赋值 --------------
class Foo
{
public:
    Foo& operator=(Foo&) &&;  // 指出this可以指向一个右值
};

Foo& operator=(const Foo& rhs) &&
{
    // 将rhs移动给本对象
    ...

    return *this;
}

注意:
1)类似于const限定符,引用限定符只能用于非static成员函数;
2)位置类同const限定符,但如果也有const限定符时,引用限定符只能放在const限定符之后;

原文链接: https://www.cnblogs.com/fortunely/p/15637187.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

    C++ Primer学习笔记 - 对象移动move

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/402823

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年4月21日 上午11:14
下一篇 2023年4月21日 上午11:14

相关推荐