欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

C++ primer读书笔记第13章:拷贝控制

程序员文章站 2022-05-09 18:23:48
  当定义一个类时,我们显式或者隐式地指定此类型的对象拷贝、移动、赋值和销毁时做什么,一个类通过定义五个特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函...

  当定义一个类时,我们显式或者隐式地指定此类型的对象拷贝、移动、赋值和销毁时做什么,一个类通过定义五个特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。我们称这些操作为拷贝控制操作。通常实现拷贝控制操作最困难的地方是首先认识到什么时候需要定义这些操作。

13.1拷贝、赋值与销毁

13.1.1拷贝构造函数

  如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此拷贝函数是拷贝构造函数。

    class Foo
    {
    public:
        Foo(const Foo&);            //拷贝构造函数
        Foo(const Foo&, int i = 0); //也是拷贝构造函数,但是要记住,自身类型的引用必须是构造函数的第一个参数,且其他参数要有默认值
    }

  拷贝构造函数的第一个参数必须是一个引用类型,而且这个参数几乎总是一个const引用。另外因为拷贝构造函数在很多情况下都会被隐式使用,因此,拷贝构造函数通常不应该是explicit的。如果我们将拷贝构造函数声明为explicit,那么我们必须显示调用此拷贝函数。

class Test
{
public:
    Test() = default;
    explicit Test(const Test&t){ _a = t._a; }
private:
    int _a = 10;
};

int _tmain(int argc, _TCHAR* argv[])
{
    Test t1;
    Test t2 = t1;      //错误,拷贝构造函数为explicit,必须显示调用
    Test t3(t1);       //显示调用explicit拷贝构造函数,正确
}

合成拷贝构造函数

  如果我们没有为类定义一个拷贝构造函数,那么编译器会为我们自动合成一个。与合成默认构造函数不同的是,即使我们定义了其他构造函数,但是没有定义拷贝构造函数,那么编译器也会为我们合成一个默认拷贝构造函数。
合成的默认拷贝构造函数从给定对象中依次将没给非static成员拷贝到正在创建的对象中,其进行的只是简单的值拷贝。

拷贝初始化

  要注意区分直接初始化与拷贝初始化。直接初始化实际上是要求编译器使用普通的函数匹配来选择最匹配的构造函数创建一个对象,而拷贝初始化则是要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的化还要进行类型转换。所以拷贝初始化相较于直接初始化多了一步拷贝的工作,有时对于一个自定义类型,这将产生很大的额外开销。

string dots("zhang");       //直接初始化
string dots = "zhang"       //拷贝初始化

  拷贝初始化通常发生在一下情况:

将一个对象作为实参传递给一个非引用类型的形参时 从一个函数返回一个非引用类型的对象时

用花括号初始化一个数组中的元素或一个聚合类中的成员时

  现在我们可以解释为什么拷贝构造函数的参数必须是引用类型了,因为如果形参类型不是引用类型,那么调用将永远不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,而为了拷贝实参,我们又必须调用它的拷贝构造函数,如此无限循环。
  注意在标准库中,insert或者push成员时,容器会对成员进行拷贝初始化,而用emplace函数插入元素则会进行直接初始化,所以当元素类型是自定义类类型时,在效率上后者优于前者。
  在拷贝初始化过程中,编译器可以跳过拷贝构造函数,直接创建对象,这是编译器的一种优化手段,但是我们必须保证在此时拷贝构造函数是存在而且可访问的。

13.1.2 拷贝赋值运算符

  拷贝赋值运算符其实就是对=运算符的重载函数。如果一个类未定义自己的拷贝赋值运算符,那么编译器也会合成一个默认的拷贝赋值运算符,其作用与默认拷贝构造函数函数类似,都是依次拷贝右侧对象的每个非static成员给左侧对象的相应成员,不同的是它不是用在构造对象的时候。
赋值运算符通常返回一个指向左侧对象的引用,其形参通常是该类型的const引用。另外要注意的是,标准库通常要求其元素类型要具有一个拷贝赋值运算符。

class Test
{
public:
    Test() = default;        //默认构造函数
    Test(const Test&t)       //拷贝构造函数
    {
        _a = t._a; 
    }
    Test &operator=(const Test &t)       //拷贝赋值运算符,用*this返回引用,形参为const引用
    {
        _a = t._a;
        return *this;
    }

private:
    int _a = 10;
};

int _tmain(int argc, _TCHAR* argv[])
{
    Test t1;                        
    Test t2 = t1;          //此处进行的是拷贝初始化,调用的是拷贝构造函数
    t1 = t2;               //此处进行的是赋值运算,调用的是拷贝赋值运算符,注意与上面的区别
}

要特别注意拷贝赋值运算符与拷贝构造函数的区别

13.1.3析构函数

  析构函数进行与构造函数相反的操作:构造函数初始化对象的非static数据成员,析构函数释放函数对象使用的资源,并且销毁对象的非static数据成员。
  析构函数是类的成员函数,其没有返回值,也不接受任何参数,所以其不可以被重载,对一个给定类,只会有一个唯一的析构函数。
  同样,当一个类未定义自己的析构函数时,编译器会为它合成一个默认析构函数,合成析构函数的函数体为空。
下列情况下,会自动调用类的析构函数:

变量在离开其作用域被销毁时 当一个对象被销毁时,其成员被销毁 容器被销毁时,其元素被销毁 对于一个动态分配的对象,当对指向它的指针使用delete运算时,对象会被销毁 对于临时对象,当创建它的完整表达式结束时被销毁

一个要十分注意的点是,析构函数并不直接销毁成员,在整个对象的销毁过程中,析构函数是作为销毁步骤之外的另一部分进行的。

13.1.4 三/五法则

  拷贝构造函数,拷贝赋值运算符、析构函数、(新标准中还有移动构造函数、移动赋值运算符)统称为一个类的拷贝控制函数。
  我们决定一个类是否需要定义自己版本的拷贝控制函数时,有一下两个基本原则:

如果一个类需要自定义析构函数,那么我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。 如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。

这其中其实主要是为了内存控制。

class Test
{
public:
    Test(){ _pa = new int(10);} //构造函数中申请了堆内存

    ~Test(){delete _pa;}  //必须自定义析构函数以释放动态分配的内存,否则会造成内存泄露

    //默认拷贝构造函数的行为
    //Test(const Test &t)
    //{
    //  _pa =t._pa;
    //}
    //这样会导致两个对象的指针成员指向同一块内存,然后在析构的时候会delete此指针两次,造成错误,所以必须自定义拷贝构造函数和拷贝赋值运算符

    Test(const Test &t)     //自定义拷贝构造函数
    {
        _pa = new int();
        *_pa = *(t._pa);
    }

    Test &operator= (const Test &t)   //自定义拷贝赋值运算符
    {
        *_pa = *(t._pa);
        return *this;
    }

private:
    int *_pa = nullptr;
};

13.1.5使用=default

  在c++11中,我们可以通过把函数声明为default来把函数声明为使用系统默认合成的版本。当然我们只能对具有合成版本的成员函数使用=default。=defalut可以出现声明或者定义处。要特别注意的一点是,当我们在类内用=default修饰成员的声明时,合成的函数将隐式声明为内联函数

class Test
{
public:
    Test() = default;
    ~Test() = default;
    Test(const Test &) = default;
    Test &operator= (const Test &t) = default;

private:
    int *_pa = nullptr;
};

13.1.6阻止拷贝

  在c++11中,我们可以通过将拷贝控制函数声明为删除的函数来阻止拷贝。删除的函数是这样的一种函数:我们虽然声明了它,但是我们不可以以任何方式使用它。我们通过=delete来声明删除的函数。
  与=default不同的是,=delete必须出现在函数第一次声明的时候,且不可以出现在定义中。另外,我们可以指定任何函数为删除的函数。
  
  关于拷贝控制有以下几个原则:

值得注意的是,我们不能删除析构函数,因为这样的话对象将无法正常销毁。 如果一个类有数据成员不能默认拷贝、构造、赋值、销毁,那么该类对应的成员函数将被默认定义为删除的。 如果一个类有const成员,则它不能使用合成的拷贝赋值运算符,因为将一个新值赋值给const对象是非法的。

在c++11之前,阻止拷贝控制的方法是将相应的成员函数声明为private并且不定义它。但是c++11之后可以直接使用delete关键字进行声明。

13.2拷贝控制和资源管理

  通常,管理类外资源的类必须自定义拷贝控制成员。
  在定义拷贝控制成员时,通常有两种选择:使类的行为看起来像一个指针或者是类的行为看起来像一个值。

类的行为像一个值,意味着每个对象都有自己的状态。当我们拷贝一个对象时,副本和原对象应该是完全独立的。改变副本不会影响原对象的值,反之亦然。比如标准库中的string类。 类的行为像一个指针,则对象之间应该应该共享状态,副本和原对象应该使用相同的底层数据,改变副本也会改变相应的原对象,反之亦然。这种对象的实现多通过引用计数,比如标准库中的shared_ptr类。

13.2.1行为像值的类

  在定义行为像值的类时,要特别注意拷贝赋值运算符的定义,其必须注意一下两点:

如果一个对象赋予它自身,赋值运算符必须可以正常工作 大多数赋值运算符组合了析构函数和拷贝构造函数的工作 如果有可能,我们编写的赋值运算符还应该是异常安全的——当异常发生时能将左侧对象置于一个有意义的状态

示例如下:

class Val
{
public:
    Val(const std::string &s = std::string()) :ps(new std::string(s)){}
    Val(const Val &v) :ps(new std::string(*(v.ps))){}

    //错误的写法,无法处理自赋值,好的模式是先将右侧运算对象拷贝到一个临时局部对象中
    Val &operator=(const Val &p)
    {
        delete ps;
        ps = new string(*p.ps);     //如果是自赋值,此时ps指向删除的内存,引用此指针将报错
        return *this;
    }
    //特别注意正确赋值构造函数的写法
    Val &operator=(const Val &p)
    {
        auto newp = new (std::nothrow) std::string(*p.ps);
        if (newp)
        {
            delete ps;
            ps = newp;
        }
        else   //异常安全,如果内存分配失败,则不做任何处理
        {
            delete newp;
        }
        return *this;
    }
    ~Val(){ delete ps; }
private:
    std::string *ps = nullptr;
};

13.2.2定义行为像指针的类

  令一个类展现类似指针的行为的最好的方法是使用shared_ptr来管理类中的资源,但是有时我们希望直接管理资源,这种情况下我们通常使用引用计数。

引用计数的工作方式如下:

除了初始化对象外,每个构造函数还要创建一个引用计数,用来记录有对象对象和正在创建的对象在共享状态。 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器,然后拷贝对象函数递增共享的计数器。 析构函数会递减计数器,如果计数器变为0,则析构函数将释放资源。

拷贝赋值运算符,将递增右侧运算对象的计数器,递减左侧对象的计数器,如果左侧对象的计数器变为0,那么将销毁左侧对象。

  在实现过程中我们也要注意拷贝赋值运算符的必须要能处理自赋值的情况,所以我们要先递增右侧运算对象的引用计数,然后递减左侧运算对象的引用计数
示例如下:

class HasPtr
{
public:
    //默认构造函数中将引用计数初始化为1
    HasPtr(const string &s = string()) :ps(new std::string(s)), pUser(new size_t(1)){}      
    //拷贝构造函数中将引用计数加1
    HasPtr(const HasPtr &p) :ps(p.ps), pUser(p.pUser){ ++ *pUser; }
    //特别注意拷贝赋值操作符的写法
    HasPtr& operator=(const HasPtr &rhs)
    {
        //递增右侧对象的引用计数
        ++*rhs.pUser;
        //递减左侧运算对象的引用计数,如果为0,则删除相应的资源
        if (--*pUser == 0)
        {
            delete pUser;
            delete ps;
        }
        ps = rhs.ps;
        pUser = rhs.pUser;
        return *this;
    }

private:
    std::string *ps = nullptr;
    std::size_t *pUser = nullptr;
};

13.3 交换操作

  除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。对于那些与重排元素顺序的算法(如sort、unique)一起使用的类来说,定义swap是非常重要的,因为这类算法在需要交换两个元素时会调用swap。如果一个类定义了自己的swap,那么算法将使用类自定义的版本,否则,算法将使用标准库定义的swap(通常这会影响效率,有时甚至会产生错误的结果)。

  通常我们知道常见的交换两个对象的方法是进行一次拷贝和两次赋值,标准库中的默认版本就采用此种方法:

    //交换v1,v2
    Hasptr temp = v1;
    v1 = v2;
    v2 = temp;

  但是当一个类存在自己分配的资源时,这样重新分配资源是十分浪费的,比如对于HasPtr类来说,我们更希望交换指针,而不是在每次交换时都分配新的string副本。

编写自己的swap函数

  可以在我们的类上定义一个自己版本的swap函数来重载swap的默认行为,如下:

class HasPtr
{
    friend void swap(HasPtr &lhs, HasPtr &rhs);    //swap函数首先必须声明为HasPtr的友元函数
    ...
}

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);         //仅交换指针
    swap(lhs.pUser, rhs.pUser);
}

与拷贝控制成员不同的是,swap并不是必须的,但是对于分配了资源的类来说,这是一种很重要的优化手段。

swap函数应该调用swap,而不是std::swap

  在上述代码中我们要特别注意的一点是swap函数中调用的是swap而不是std::swap。
  比如有一个类Foo,其中有HasPtr成员h,我们希望可以通过自定义的swap操作来避免拷贝:

void swap(Foo &lhs, FOo &rhs)
{
    std::swap(lhs.h, rhs.h); 
}

  如果像上述代码的写法,可以正常编译运行,但是函数中调用的swap函数是标准库中的版本,而不是自定义交换HasPtr的版本,所以上述代码没有起到优化作用。正确写法如下:

void swap(Foo &lhs, FOo &rhs)
{
    using std::swap;   //using声明不可少
    swap(lhs.h, rhs.h); 
}

  这样如果此类型存在自定义的swap版本,则会调用自定义的swap版本,否则将使用标准库的swap版本。

在赋值运算符中使用swap

  定义了swap的类通常会使用swap来定义它们的赋值操作符,这种运算符运用了一种名为拷贝并交换的技术。如下:

    //要注意此函数中形参不是const引用,而变成了值传递,这么做的目的是为了让赋值函数能处理自赋值
    HasPtr &operator=(HasPtr rhs)
    {
        swap(*this, rhs);
        return *this;
    }

  这个技术有趣之处在于他自动处理了自赋值的情况且天生是异常安全的。它通过改变左侧运算符对象之前拷贝右侧运算对象保证了自赋值的正确性。而且代码唯一可能抛出异常的是拷贝函数中的new表达式,如果真发生了异常,它也会在我们改变左侧运算对象之前发生,所以其实异常安全的。(要特别注意理解其为什么是异常安全的)
  要注意这种情况对行为像值的类适用,但是对于有引用计数的类,仔细思考其引用计数应该如何处理。(尚有疑问)

13.4 略

13.5 动态内存管理类

  有些类需要自己进行内存分配。在本节中,我们将定义一个功能类似于vector< string >的类StrVec,注意结合书本相应章节理解此类的结构:

class StrVec
{
public:
    StrVec() = default;
    //析构函数会释放分配的内存
    ~StrVec()
    {
        free();
        alloc.deallocate(first_free, capacity - first_free);
    }
    StrVec(const StrVec& s)
    {
        //newdata作为返回值指明了首元素以及超出末端的位置
        auto newdata = alloc_n_copy(s.begin(), s.end());
        elements = newdata.first;
        first_free = cap = newdata.second;
    }
    StrVec &operator=(const StrVec &s)
    {
        auto data = alloc_n_copy(s.begin(), s.end());
        free();
        elements = data.first;
        first_free = data.second;
        return *this;
    }

public:
    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; }
    void push_back(const string &s)
    {
        chk_n_alloc();
        //当我们使用allocator分配内存时,必须记住内存是未构造的,为了使用原始内存,我们必须调用construct函数构造一个对象
        alloc.construct(first_free++, s);
    }
private:
    //确认是否需要重新分配内存
    void chk_n_alloc()
    {
        if (size() == capacity())
            reallocate();
    }
    //工具函数
    std::pair alloc_n_copy(const string *b, const string *e)
    {
        auto data = alloc.allocate(e - b);
        //返回语句中完成拷贝工作,并且返回第一个元素以及最后一个元素后一位的指针,即begin和end
        return{ data, uninitialized_copy(b, e, data) };
    }
    //销毁元素并释放内存
    void free()
    {
        if (elements)
        {
            //逆序销毁旧元素
            for (auto p = first_free; p != elements;)
                alloc.destroy(--p);
            //释放分配的内存,我们传递给deallocate的指针必须是之前某次allocate返回的指针,因此我们首先要检查elements是否为空
            alloc.deallocate(elements, cap - elements);
        }
    }
    //重新分配更多内存并且拷贝已有元素
    void 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)
        {
            //注意此处重新分配内存拷贝旧成员的时候,使用标准库的move函数,可以避免分配和释放string的额外开销,从而提升效率
            alloc.construct(dest++, std::move(*elem++));
            free();
        }
        elements = newdata;
        first_free = dest;
        cap = elements + newcapacity;
    }

private:
    static std::allocator alloc;   //用于分配元素的内存
    string *elements = nullptr;                 //指向数组中首元素的指针
    string *first_free = nullptr;               //指向数组中第一个空闲元素的指针
    string *cap = nullptr;                      //指向数组尾后位置的指针,为超出末端
};

13.6对象移动

  新标准的一个最主要的特性是可以移动对象而非拷贝对象的能力。
  回想一下,当函数返回一个非引用的值时,会建立一个临时对象并对这个临时对象进行拷贝,而临时对象在拷贝后就立即被销毁了,在这时一个对象的拷贝其实是不必要的,在这种情况下,移动而非拷贝对象可能会大幅度提升性能。而使用移动而非拷贝的另一个原因是因为有些类(如IO类或unique_ptr)都包含不能被共享的资源(如IO缓存),因此这些对象不能拷贝但可以被移动。

13.6.1右值引用

  让我们先来回忆一下关于左值右值的知识。c++的表达式要不然是左值,要不然是右值。有一个简单的归纳是:当一个对象被用作右值时,用的是对象的值(内容),而当一个对象用作左值的时候,用的是对象的身份(在内存中的位置)。有一个重要原则:在需要右值的地方可以使用左值代替,但是不能把右值当做左值使用。当一个左值被当做右值使用时,实际使用的是它的内容。

  现在我们来看看什么是右值引用。c++11中,为了支持移动操作,引入了一种新的引用类型——右值引用。所谓右值引用就是必须绑定到右值的引用。我们可以通过&&而不是&来获得右值引用。右值引用有一个很重要的特性——只能绑定到一个将要销毁的对象。
  对于常规的引用,我们可以称之为左值引用,我们不能将其绑定到要求转换的表达式、字面值常量或者返回右值的表达式(除非它是一个const引用)。而右值引用有这完全相反的特性:我们可以将一个右值绑定到这类表达式上,但是不可以将一个右值引用绑定到一个左值上。

    int i = 42;
    int &r = i;             //正确,将一个左值引用绑定到一个左值
    int &&rr = i;           //错误,不能将一个右值引用绑定到一个左值
    int &r2 = i * 42;       //错误,i*42为一个右值表达式,不可以将一个非常量左值引用绑定到一个右值表达式
    const int &r3 = i * 42; //正确,const引用可以绑定到一个右值上
    int &&rr2 = i * 42;     //正确,将右值引用绑定到一个右值上

左值持久,右值短暂

  左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程创建的临时对象。由于右值引用只能绑定到临时对象,我们可知

所引用的对象将要被销毁 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以*的接管所引用的对象的资源。

变量是左值

我们必须清楚认识到一点,变量都是左值,所以我们无法把一个右值引用绑定到一个变量上即使这个变量本身是一个右值引用:

int &&rr1 = 42;
int &&rr2 = rr1;    //错误,rr1是一个左值!!!

标准move函数

  我们可以显式的将一个左值转换成对应的右值引用类型,我们还可以通过标准库函数std::move来获得绑定到左值上的右值引用,此函数定义在头文件utility头文件中。

int r = 42;
int &&rr = std::move(r);

  我们必须意识到,当我们对一个对象使用move操作后,我们将不再使用它。在对一个对象调用move操作后,我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

13.6.2移动构造函数和移动赋值操作符

  为了让自定义类型支持移动操作,我们需要为其定义移动构造函数和移动赋值操作符。

移动构造函数

  移动构造函数的的第一个参数是该类类型的右值引用,任何额外的参数都必须要有默认值。而且为了完成资源移动,移动构造函数必须确保移后源对象处于这样一个状态——销毁它是无害的。所以,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象了。

StrVec(StrVec &&s) noexcept      //移动操作不应抛出任何异常
{
    //与拷贝构造函数不同的是,移动构造函数并不分配新的资源
    elements = s.elements;
    first_free = s.first_free;
    cap = s.cap;
    //将移后源对象的相关指针置为空,这样对其的析构是安全的
    s.elements = s.first_free = s.cap = nullptr;
}

  StrVec的析构函数在first_free上调用deallocate,如果我们忘记改变s.first_free的状态,那么销毁移后源对象后将会释放掉我们刚刚移动的内存。

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

  我们必须先认清两个事实:首先,虽然移动操作通常不抛出异常,但是抛出异常也是允许的;其次,标准库容器能对异常发生时其自身的行为提供保障,例如vector保证,如果我们push_back时抛出异常,则vector自身将不发生改变。
  现在我们假设vector在push_back的过程需要重新分配资源,所以其会把旧元素移动到新内存中,就像StrVec中那样。如果此过程中使用了移动构造函数,而移动构造函数在移动了部分元素后抛出了异常 ,那么旧空间中的元素已经被改变,而新空间中未构造的元素尚不存在,此时vector将不能保证抛出异常时保持自身不变的要求。但是如果此过程使用的是拷贝构造函数而非移动构造函数,那么即使拷贝构造函数抛出异常,旧元素的值仍未发生任何变化,vector可以满足保持自身不变的要求。所以为了避免这种潜在问题,除非vector知道元素的移动构造函数不会抛出异常,否则其在重新分配内存的时候,它将使用拷贝构造函数而非移动构造函数。所以如果我们希望vector这类的容器在重新分配内存时对自定义类型使用移动构造函数而非拷贝构造函数,那么我们必须将自定义类型的移动构造函数(以及移动赋值操作符)标记为noexcept(不会抛出异常)。
  

移动赋值运算符

  移动赋值运算符执行与析构函数和移动构造函数相同的工作,而且要注意的是其也必须正确处理自赋值的情况。

StrVec &operator=(StrVec &&rhs)
{
    //判断是否是自赋值
    if (this != &rhs)
    {
        free();
        elements = rhs.elements;
        first_free = rhs.first_free;
        cap = rhs.cap;
        //使移后源对象处于可以安全销毁的状态
        rhs.cap = rhs.elements = rhs.first_free = nullptr;
    }
    return *this;
}

  我们费心检查自赋值看起来有些奇怪,毕竟移动赋值运算符需要右侧运算对象是一个右值。我们进行检查的原因是此右值可能是move调用的返回结果,关键点在于我们不能在使用右侧运算对象的资源之前就释放左侧对象的资源。

移后源对象必须可析构

  当我们编写一个移动操作后,必须要确保移后源对象进入一个可安全析构的状态,并且移动操作还必须保证移后源对象仍然是有效的。有效是指可以安全的对其赋新值或者可以安全使用而不依赖其当前值。但是用户不能对移后源对象的值做任何假设,一般在对其重新赋值之前不要使用它。

合成的移动操作

  与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符,但是其合成的条件不同:只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有非static数据成员都能够移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符

//X的成员都可以被移动,所以编译器会为其合成移动操作
struct X
{
    int i;          //内置类型可以被移动
    string s;       //string定义了自己的移动操作
};

struct hasX
{
    X mem;          //X可以被移动,所以hasX也有合成的移动操作
};

在以下情况下,编译器会将移动操作定义为删除的函数:

如果我们显式地将移动操作声明为default,且编译器不能移动所有成员,那么编译器会将移动操作定义为删除的函数。 移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数而未定义移动构造函数,或者有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。 有类成员的移动构造函数或者移动赋值运算符被定义为删除的或者是不可访问时。 如果一个类的析构函数定义为删除的或者是不可访问的,则类的移动操作被定义为删除的。 如果有类的成员是consth或者const引用,则类的移动赋值运算符被定义为删除的。
struct hasY
{
    hasY() = default;
    hasY(hasY &&) = default;   //vs2013尚不支持将移动构造函数声明为default
    Y mem;      //假设Y是一个类,其移动构造函数是删除,则hasY的移动构造函数也会被定义为删除的
};

  移动操作和合成的拷贝控制成员还有最后一个相互关系:如果一个类定义了一个移动构造函数或一个移动赋值操作符,则该类的合成拷贝构造函数或合成拷贝赋值运算符会被定义为删除的函数。

移动右值,拷贝左值

  当一个类既有移动构造函数,也有拷贝构造函数,编译器会使用普通的函数匹配机制来确定使用哪个构造函数,左值匹配拷贝构造函数,右值匹配移动构造函数。
  如果一个类没有移动构造函数,那么即使右值也会被拷贝,即使我们试图通过move来移动它们。用拷贝构造函数代替移动构造函数几乎总是安全的。

更新三/五原则

  所有五个拷贝控制成员都应看做一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作,特别是对于要在类内管理资源的类型。

移动迭代器

  新标准中定义了一种移动迭代器适配器,移动迭代器的解引用运算符将生成一个右值引用。我们可以通过make_move_iterator将一个普通迭代器转换为移动迭代器,然后我们可以将一对移动迭代器传递给算法。于是我们可以重写StrVec的reallocate函数

void StrVec::reallocate()
{
    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;
}

不要随意进行移动操作

  由于一个移后源对象具有不确定的状态,所有我们必须确信对某对象移动后不再使用其值才对其进行移动操作,否则可能造成莫名其妙的错误。在代码小心的使用std::move操作,可以大幅度提升性能。

13.6.3右值引用与成员函数

  除了移动操作外,我们也可以普通的成员函数提供拷贝和移动两种版本,通常一个版本接受一个const的左值引用为参数,令一个版本接受一个非const的右值引用。比如我们可以为StrVec的push_back定义两个版本,如下:

//拷贝版本,可绑定任意类型的string值
void StrVec::push_back(const string &s)
{
    chk_n_alloc();
    alloc.construct(first_free++, s);
}
//移动版本,只能绑定到一个string右值
void StrVec::push_back(string &&s)
{
    chk_n_alloc();
    alloc.construct(first_free++, std::move(s));    //差别只在此处使用移动操作而非拷贝操作    
}
StrVec sv;
string s = "copy";
sv.push_back(s);    //调用拷贝版本的成员函数
sv.push_back("move");  //调用移动版本的成员函数

右值与左值引用成员函数

  在c++11之前,我们在一个对象上调用成员函数,不会判断该对象是一个左值还是右值,例如:

string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');           //s1+s2返回了一个右值,然后在右值对象上调用find函数

  然而有时我们可以以一种令人惊讶的方式使用右值:

s1 + s2 = "wow!"    //对一个右值进行赋值操作!!!

  在旧标准中我们无法阻止这种使用方式。为了维持向后兼容,c++11仍然允许向右值赋值,但是c++11增加了一种阻止这种用法的方法,即使用引用限定符
  我们通过在函数声明与定义中加入引用限定符来限定调用对象的左右值属性。

class Foo
{
public:
    Foo operator=(const Foo &) &;   //限定只能向可修改的左值赋值
};

  引用限定符实际作用和const的声明一样,我们知道const实际上是声明this指针的类型,而引用限定符实际上也是声明this指针的类型,&声明this指针指向一个左值,&&声明this指针指向一个右值。
一个函数可以同时使用const和引用限定,引用限定符必须跟随在const限定符之后。

重载和引用函数

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

class Foo
{
public:
    //以下函数会构成重载函数
    Foo sort() &;   
    Foo sort() &&;  
    Foo sort() const &;
    Foo sort() const &&;
};

  需要注意的是如果我们定义了两个或者两个以上的具有相同名字和相同参数列表的成员函数(注意并非所有重载函数集),就必须对所有函数加上引用限定符,或者所有都不加:

//错误,两个sort函数要不都有引用限定符,要不都没有引用限定符
class Foo
{
public:
    Foo sort() &;
    Foo sort() const;
}
//正确
class Foo
{
public:
    Foo sort() &;
    Foo sort() const &;
}
//正确
class Foo
{
public:
    Foo sort();
    Foo sort() const;
    //与上面的函数参数列表不同
    Foo sort(int *) &;
    Foo sort(int *) const &;
}

另外说明一点:vs2013尚不支持引用限定符的使用