zoukankan      html  css  js  c++  java
  • 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限定符之后;

  • 相关阅读:
    shell的执行顺序问题
    七层负载均衡——HAProxy
    不要自以为是码农
    SSL协议运行机制
    Linux启动流程
    MIM协议与Base64编码
    Adele的生活
    你值得拥有:25个Linux性能监控工具
    [Zabbix] 如何实现邮件报警通知以及免费短信报警通知
    php.ini中date.timezone设置分析
  • 原文地址:https://www.cnblogs.com/fortunely/p/15637187.html
Copyright © 2011-2022 走看看