C++常见问题总结_拷贝控制(对象移动)

拷贝控制(对象移动)

一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。在C++常见问题总结_拷贝控制(拷贝、赋值、销毁)一文中已经介绍了拷贝构造函数、拷贝赋值运算符和析构函数。在本篇文章中将介绍移动构造函数和移动赋值运算符。
新标准一个主要的特性就是可以移动对象而非拷贝对象的能力。在C++常见问题总结_动态内存管理类中所写的StrVec类中,我们在重新分配内存时,从旧内存元素到新内存使用的就是移动操作,而不是拷贝操作,这样可以大幅度的提升性能。此外,像IO类unique类,这样一些类包含不能被共享的资源,因此,这些类型的对象不能拷贝只能移动。在旧的标准中,容器中保存的类型必须是可以被拷贝的,但在新标准下,我们可以用容器保存不可拷贝的类型,只要他们可以被移动即可。为了了解移动操作,我们首先需要了解下右值引用。

右值引用
为了介绍右值引用,首先要了解一下左值和右值。

  • 左值和右值
    在 C++11 中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值、将亡值。在 C++11 中可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)。举个例子, inta = b+c, a 就是左值,其有变量名为 a,通过&a 可以获取该变量的地址;表达式 b+c、函数 int func()的返回值是右值,在其被赋值给某一变量前,我们不能通过变量名找到它,&(b+c)这样的操作则不会通过编译。
    左值和右值是表达式的属性。一些表达式生成或要求左值,而另外一些则生成或要求右值。一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。

  • 右值和将亡值
    在理解 C++11 的右值前,先看看 C++98 中右值的概念: C++98 中右值是纯右值,纯右值指的是临时变量值、不跟对象关联的字面量值。临时变量指的是非引用返回的函数返回值、表达式等,例如函数 int func()的返回值,表达式 a+b;不跟对象关联的字面量值,例如 true, 2,” C”等。C++11 对 C++98 中的右值进行了扩充。在 C++11 中右值又分为纯右值(prvalue, Pure Rvalue)和将亡值(xvalue, eXpiring Value)。其中纯右值的概念等同于我们在 C++98 标准中右值的概念,指的是临时变量和不跟对象关联的字面量值;将亡值则是C++11 新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用 T&&的函数返回值、 std::move 的返回值,或者转换为 T&&的类型转换函数的返回值。将亡值可以理解为通过“盗取”其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。

  • 右值引用
    1、 右值引用就是必须绑定到右值的引用,通过&&来获得右值引用,右值引用的一个重要特性:只能绑定到一个将要销毁的对象
    2、左值引用就是对一个左值进行引用的类型。右值引用就是对一个右值进行引用的类型,事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。
    3、右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。
    4、左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。
    5、左值引用通常也不能绑定到右值,但常量左值引用是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。 不过常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。

int i = 123;
int &r = i; // 正确
int &&rr1 = 123; // 正确
int &&rr2 = i; // 错误,右值引用不能绑定左值
int &r2 = i * 2; // 错误,左值引用不能绑定右值
const int &r3 = i * 2; // 正确,常引用可以绑定右值
int &&rr3 = i * 2; // 正确,绑定到乘法结果

几个特性:

  • 左值持久,右值短暂
    左值有持久的状态, 而右值要么是字面常量, 要么是在表达式求值过
    程中创建的临时对象。
    由于右值引用只能绑定到临时对象,则:
    1、所引用的对象将要被销毁
    2、该对象没有其他用户
    意味着: 使用右值引用的代码可以自由地接管所引用对象的资源。
  • 变量是左值
    类似于任何表达式,变量表达式也有左值和右值属性。变量是左值, 因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。变量是持久的,直到他离开作用域才被销毁。
int &&rr1=42;//正确,字面值常量是右值
int &&rr2=rr1;//错误:表达式rr1是左值
  • 标准库move函数
    虽然不能将一个右值引用绑定到一个左值上,但我们可以显示的讲一个左值转换为对应的右值引用类型。通过move函数来获得绑定到右值上的左值引用
#include<utility>
int &&r1=42;
int &&r2 = r1; // 编译失败
int &&r2 = std::move(r1); // 编译通过
//对move调用,通常直接调用std::move而不是move,可以避免潜在的命名冲突。

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

移动构造函数和移动赋值运算符
移动构造函数
移动构造函数的第一个参数是该类类型的一个引用(右值引用),与拷贝构造函数一样任何额外的参数都必须有默认实参。资源移动完成之后,源对象必须不在指向被移动的资源,这些资源的所有权已经归属创建的对象。

为我们的StrVec类动态内存管理类定义一个移动构造函数

StrVec::StrVec(Strvec&&s)noexcept:
elements(s.elements),first_free(s.first_free),cap(s.cap){
s.elements=s.first_free=s.cap}//保证销毁移后源对象是安全的

说明:与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的StrVec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终移后源对象将被销毁,对其执行析构操作(析构函数在first_free上调用deallocate)。如果我们忘记改变s.first_free,则销毁移后源对象就会释放掉我们刚刚移动的内存。

  • 移动操作、标准库容器和异常
    由于移动操作“窃取”资源,它通常不会分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。因为,除非标准库知道我们移动构造函数不会抛出异常,否则它会认为移动我们类对象时可能会抛出异常,并且为了处理这种可能性而作额外的工作。
    通过指明noexcept是我们承诺一个函数不抛出异常的方法。
class StrVec
{
    public:
        StrVec(StrVec&&) noexcept;
};

StrVec::StrVec(StrVec&&s) noexcept:
{}

必须在声明处和定义处都指定noexcept。
不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
1、移动操作通常不会抛出异常,但抛出异常是允许的
2、标准库容器能对异常发生时其自身的行为提供保障
使用移动构造函数时发生了异常:
如果在移动了部分而不是全部元素后抛出了一个异常,就会产生问题。旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。此时,标准库容器能对异常发生时其自身的行为提供保障。
使用拷贝构造函数时发生了异常:
当在新内存中构造元素时,旧元素保持不变,如果发生了异常,可以释放新分配的内存,保持原有元素不变。
综上,如果希望在标准库容器(如:vector)重新分配内存情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式的告诉标准库我们的移动构造函数可以安全使用。

移动赋值运算符
与移动构造函数一样,我们移动赋值运算符如果不抛出任何异常,我们应该将其标记为noexcept。并且必须正确处理自赋值的情况。
为我们的StrVec类动态内存管理类定义一个移动赋值运算符。

StrVec& StrVec::operator=(Strvec&&s)noexcept
{
if(this!=&s)
{
free(); //释放被赋值对象中已有的元素
elements(s.elements);
first_free(s.first_free);
cap(s.cap); //接管给定对象的内存
/* 保证销毁移后源对象是安全的 */
s.elements=s.first_free=s.cap=nullptr;
}
return *this;
}

说明:在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。对象有效是指可对其进行赋值,销毁和使用其值;移后源对象仍然是有效的但是我们不应该在使用移后源对象的值。

合成的移动操作
如果类定义了拷贝构造函数,拷贝赋值运算符,或是析构函数编译器将不会合成移动构造函数与移动赋值运算符,在未定义移动操作时,使用移动操作将会通过函数匹配调用拷贝操作来代替移动操作 。只有当类没有定义拷贝控制成员,且类的每一个非 static 数据成员都可以移动时,编译器才会合成移动操作,如果定义了移动操作也应该定义拷贝操作否则拷贝操作被默认为删除的。

struct X
{
    int i;
    string s;
};
struct hasx
{
    X mem;           //X有合成的移动操作
};
X x,x2=std::move(x);  //使用合成的移动操作
hasx hx,hx2=std::move(hx);

删除的移动操作
与拷贝操作不同,不能显示的定义移动操作是删除的,但在以下几种情况下可以隐式定义删除的移动操作。
类成员的拷贝操作被定义成删除的。
1.类成员定义了拷贝操作但未定义移动操作
2.类成员未定义拷贝操作但是编译器不能合成移动操作
3、类的析构函数是删除的则类的移动操作是删除的
4、类中含有 const 或是引用成员,则移动赋值运算符是删除的。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则这些成员默认的被定义为删除的。

struct hasy{
    hasy()=default;
    hasy(hasy&&)=default;
    y mem;
};
hasy hy,hy2=std::move(hy);//错误,移动构造函数是删除的

移动右值,拷贝左值
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数,赋值情况类似。

StrVec v1,v2;
v1=v2;//v2 是一个左值(变:变量),调用拷贝构造函数
StrVec getVec(istream &); //返回右值(非:返回非引用类型)
v2=getVec(v1);//调用移动构造函数

//当没有移动构造函数时,右值也被拷贝
class Foo {
public:
    Foo()=default;
    Foo(const Foo&);
};
Foo x;
Foo y(x);  //拷贝构造函数,x是一个左值
Foo z(std::move(x));//会将一个Foo&&转换为一个 const Foo& 使用的是拷贝构造函数。

example:
为我们的Message类拷贝控制示例定义移动构造函数和移动赋值运算符。

void Message::move_Folders(Message *m)
{
    folders=std::move(m->folders); //使用set的移动赋值运算符
    for(auto f : folders)
    {
        f->remMsg(m);              //从Folder中删除旧Message
        f->addMsg(this);           //将本Message添加到Folder中
    }
    m->folders.clear();
}

Message::Message(Message &&m):contents(std::move(m.contents))
{
    move_Folders(&m);
}
Message& Message::operator=(Message &&rhs)
{
    if(this !=&rhs)
    {
        remove_from_Folders();
        contents=std::move(rhs.contents);
        move_Folders(&rhs);//重置Folders指向本Message
    }
    return *this;
}

移动迭代器
定义:一般一个迭代器的解引用运算符返回一个指向元素的左值,但是移动迭代器的解引用返回一个指向元素的右值引用。

make_move_iterator();
//此函数接受一个迭代器作为参数,返回一个移动迭代器
auto move_iter=make_move_iterator(begin());

example:改写我们在StrVec类动态内存管理类中定义的reallocate函数

//调整空间大小,当空间不够时会将容量加倍
 void StrVec::reallocate()
{
    auto newcapacity = size() ? 2 * size() : 1;

    auto newdata = alloc.allocate(newcapacity);

    auto dest = newdata;
    auto elem = elements;
    for (size_t i = 0; i != size(); ++i)
        alloc.construct(dest++, std::move(*elem++));

    free();
    elements = newdata;
    first_free = dest;
    cap = elements + newcapacity;

}
//使用移动迭代器版本
//uninitialized_copy对输入中的每个元素调用construct来讲元素“拷贝”到目的位置。
//由于传给他的是移动迭代器,意味着construct将使用移动构造函数来构造元素
 void StrVec::reallocate()
{
    auto newcapacity = size() ? 2 * size() : 1;

    auto newdata = 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;

}

右值和左值引用成员函数
除了构造函数和赋值运算符以外,一个成员函数也可以同时提供拷贝和移动的版本。

// 标准容器中 push_back 提供两个版本:
void push_back(const T&); // 拷贝:绑定任意类型的 T
void push_back( T&&); // 移动:只绑定可修改的右值
int i = 5;
vector<int> vi;
vi.push_back(i); // 调用 push_back(const int &)
vi.push_back(2); // 调用 push_back(int &&)</int>

example: 为我们的StrVec类动态内存管理类中的push_back成员函数定义移动版本。

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

//移动版本
void StrVec::push_back(const string&&str)
{
    chk_n_alloc();
    alloc.construct(first_free++, std::move(s));
}
StrVec vec;
string s="asdasd";
vec.push_back(s);//调用 push_back(const string&str)
vec.push_back("dasd");//调用 push_back(const string&&str)
  • 右值和左值引用成员函数
    通常我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。
// 旧标准中,如果有右值引用操作,会产生如下问题:
string s1 = "aaa", s2 = "bbb";
s1 + s2 = "as"; // 正确,但没有意义。

上面中对右值进行了赋值,在旧标准中,我们没有办法阻止这种使用方式,为了维持向后兼容性,新标准库类仍然允许向右值赋值,可以在自己的类中通过引用限定符来阻止这种用法。

class foo{
public:
foo &operator=(const foo&) &;//只能想可修改的左值赋值
};
foo &foo::operator=(const foo&rhs)&
{return *this;}

foo &retfoo();//返回一个引用;retfoo调用是一个左值
foo retval();//返回一个右值;retval调用是一个右值
foo i, j;
i=j;    //正确,i是一个左值
retfoo()=j; //正确,retfoo返回一个左值
retval()=j; //错误,retval返回一个右值
i=retval();//正确

引用限定符可以是&或&&,分别指出 this 可以指向一个左值还是右值,且必须同时出现在函数的声明和定义处。类似 const 限定符,引用限定符只能用于(非 static)成员函数。当一个函数同时使用引用限定符和 const 时,引用限定符必须跟随在const 限定符之后。

重载和引用函数
就像一个成员函数可以根据是否有const来区分重载版本一样,引用限定符也可以区分重载版本,并且可以综合引用限定符和const来区分一个成员函数的重载版本。

class foo{
public:
    foo sorted() &&;   //可用于可改变的右值
    foo sorted() const &;  //可用于任何类型的foo
private:
    vector<int> data;
};
//本对象为右值,因此可以原址排序
foo foo::sorted() &&
{
    sort(data.begin(),data.end());
    return *this;
}
// 本对象是一个const或是一个左值,我们不能对其进行原址排序
foo foo::sorted() &&
{
    foo ret(*this);
    sort(ret.begin(),ret.end());
    return ret;
}
foo &retfoo();//返回一个引用;retfoo调用是一个左值
foo retval();//返回一个右值;retval调用是一个右值
retfoo.sorted(); //调用左值版本
retval.sorted(); //调用右值问题

重载时:如果定义两个或两个以上具有同名和同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者都不加。(这一点不同于const成员函数)

class foo{
public:
    foo sorted() &&;   //可用于可改变的右值
    foo sorted() const ;  //错误,必须加上引用限定符
private:
    vector<int> data;
};

猜你喜欢

转载自blog.csdn.net/xc13212777631/article/details/80627365
今日推荐