c++primer笔记十三、拷贝控制

一个类有五种特殊的成员函数
1.拷贝构造函数 2.拷贝赋值运算符 3.移动构造函数 4.移动赋值运算符 5.析构函数
分别定义了初始化对象时做什么,一个对象赋予给另一个对象时做什么,销毁时做什么
这些操作称为拷贝控制操作
如果没有定义,编译器会自动定义缺失的操作

13.1 拷贝、赋值和销毁

13.1.1 拷贝构造函数

拷贝构造函数的第一个参数必须是一个自身类类型的引用,且任何额外参数都有默认值
引用参数几乎总是const的引用
拷贝构造函数基本都会被隐式使用,不应该是explicit

class Foo {
public:
    Foo();      //默认构造函数
    Foo(const Foo&);    //拷贝构造函数
}
合成拷贝构造函数

如果没有定义编译器会自动合成一个拷贝构造函数,对某些类来说,它用来阻止拷贝该类类型的对象。一般情况会将其参数的成员逐个拷贝到正在创建的对象中。编译器会从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
根据成员类型来拷贝,如果是类类型则使用拷贝构造函数,内置类型则直接拷贝
例:Sales_data类的合成拷贝函数的等价形式:

class Sales_data{
public:
    Sales_data(const Sales_data&);
private:
    std::string bookNo;
    int unit_sold = 0;
    double revenue = 0.0;
}
Sales_data::Sales_data(const Sales_data &orig):
    bookNo(orig.bookNo), 
    unit_sold(orig.unit_sold),
    revenue(orig.revenue)
    {
        
    }
拷贝初始化

直接初始化和拷贝初始化的差异

string dots(10, '.');       //直接
string s(dots);             //直接
string s2 = dots;           //拷贝
string null_book = "9-9-9"; //拷贝
string nines = string(100, '9') //拷贝

直接初始化要去编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数
拷贝初始化时要去编译器将右侧对象拷贝到正在创建的对象中,如果需要进行类型转换
拷贝初始化也可以通过移动构造函数完成

拷贝初始化其它的情况:

  1. 将一个对象作为实参传递给一个非引用雷系的形参
  2. 从一个返回类型为非引用类型的函数返回一个对象
  3. 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

某些类还会对他们分配的对象进行拷贝初始化,如insert和push成员会拷贝初始化,用emplace都直接初始化

参数和返回值

拷贝构造函数用来初始化非引用类类型参数,形参或返回值。
因此拷贝构造函数的参数必须时引用,否则会无限循环去构造拷贝

拷贝初始化的限制

如果初始化的值要去通过explicit的构造函数来类型转换,拷贝和直接初始化就有差异了

vector<int> v1(10);     //直接初始化,正确
vector<int> v2 = 10;    //接受大小参数的构造函数时explicit的,错误
void f(vector<int>);    //f的参数进行拷贝初始化
f(10);                  //错误,不能用explicit的构造函数拷贝一个实参
f(vector<int(10));       //正确,从一个int直接构造一个临时vecotr
编译器可以绕过拷贝构造函数

在拷贝初始化过程中,编译器可以(不是必须)跳过拷贝/移动构造函数,直接创建对象,即允许将

string null_book = "9-9-9";

改写为

string null_book("9-9-9");

不过即使编译器略过了拷贝构造函数,但在这个程序点上,拷贝构造函数必须是存在且可访问的(不能是private)

13.1.2 拷贝赋值运算符

类也可以控制其对象如何赋值:

Sales_data trans, accum;
trans = accum;

如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个

重载赋值运算符

重载运算符本质是函数,其名字由operator关键字后面接表示要定义的运算符的符号组成。赋值运算符是名为operator=的函数,也有一个返回类型和一个参数列表

重载运算符的参数表示运算符的运算对象,赋值运算符必须定义为成员函数。如果一个运算符是成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符如赋值运算符,其右侧运算对象作为显示参数传递

拷贝赋值运算符接受一个与其所在类相同类型的参数

class Foo {
public:
    Foo& operator=(const Foo&);
}

为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。

标准库要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用

合成拷贝赋值运算符

某些类用来禁止该类型对象的赋值。
其它情况会将右侧运算对象的每个非static成员通过成员类型的拷贝赋值运算符来赋予左侧运算对象的对应成员,对于数组逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用

Sales_data& Sales_data::operator=(const Sales_data &rhs)
{
    bookNo = rhs.bookNo;
    units_sold = rhs.units_sold;
    revenue = rhs.revenue;
    return *this;
}

13.1.3 析构函数

析构函数释放对象使用的资源,并销毁对象的非static数据成员
名字由~接类名,无返回值无参数,不能重载。一个类只有唯一的析构函数

class Foo{
public:
    ~Foo();
}
析构函数完成什么工作

析构函数也有一个函数体和一个析构部分。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁

在对象的最后一次使用之后,析构函数的函数体可执行收尾工作。通常用来释放对象在生存期分配的所有资源。

在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁是发生什么完全依赖于成员的类型。销毁类类型的成员需要执行自己的析构函数,销毁内置类型则什么也不需要做

智能指针是类类型,也有析构函数,会在析构阶段自动销毁

什么时候调用析构函数

无论何时一个对象被销毁,就会自动调用其析构函数:

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

由于析构函数会自动运行,程序酒可以按需分配资源,通常无须担心何时被释放这些资源

{
    //新作用域
    //p和p2指向动态分配内存
    Sales_data *p = new Sales_data;
    auto p2 = make_shared<Sales_data>();
    Sales_data item(*p);
    vector<Sales_data> vec;
    vec.push_back(*p2);
    delete p;       //对p指向的对象执行析构函数
}
//退出局部作用域,对item,p2,vec调用析构
//销毁p2会递减其引用计数,如果计数变为0也销毁
//销毁vec会销毁它的元素
合成析构函数

类似的,对于某些类合成析构函数被用来阻止该类型的对象被销毁、如果不是则合成析构的函数体就为空

//等价合成析构函数
class Sales_data
{
public:
    //成员会自动销毁,除此之外不需要做其它事情
    ~Sales_data(){}
}

在空析构函数执行后,成员被自动销毁

特别的,string的析构函数会被调用,释放bookNo成员所用的内存

析构函数体自身并不直接销毁,而是在之后的隐含析构阶段被销毁。在这个对象销毁过程中析构函数体是作为成员销毁步骤之外的另一部分而进行的。

13.1.4三/五法则

需要析构函数的类也需要拷贝和赋值操作

当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。通常对析构的需求最大,如果这个类需要一个析构函数,那么肯定也要一个拷贝构造函数和一个拷贝赋值运算符

比如一个类在构造函数中分配动态内存,而合成析构函数不会delete一个指针数据成员,需要定义一个析构函数来释放。
合成的拷贝构造函数和拷贝赋值运算符,会简单拷贝指针成员,多个对象可能指向相同内存,会delete两次

需要拷贝操作的类也需要赋值操作,反之亦然

13.1.5 使用=default

可以通过将拷贝控制成员定义为=deault来显式地要求编译器生成合成地版本

class Sales_data
{
public:
    Sales_data() = default;
    Sales_data(const Sales_data&) = default;
    Sales_data& operator=(const Sales_data &);
    ~Sales_data() = default;
}
Sales_data& Sales_data::operator=(const Sales_data&) = default;

在类内用=default修饰成员地声明时,合成的函数将隐式地声明为内联的。
如果不希望时内联函数,应该只对成员的类外定义使用=default

13.1.6 阻止拷贝

某些类需要阻止拷贝或赋值,如iostream,避免多个对象写入或读取相同的IO缓冲。如果不定义拷贝控制成员就会自动合成。

定义删除的函数

将拷贝构造和拷贝赋值运算符定义为删除的函数来阻止拷贝。
删除函数:虽然定义了它们,但是不能使用。在函数的参数列表后面加上=delete

struct NoCopy{
    NoCopy() = default;
    NoCopy(const NoCopy&) = delete; //阻止拷贝
    NoCopy &operator=(const NoCopy&) = delete;  //阻止赋值
    ~Nocopy() = default;
}

=delete必须在函数第一次声明的时候使用,且可以对任何函数指定。=default只能指定拷贝控制成员

析构函数时不能删除的成员

如果析构被删除就无法销毁此类型的对象,对于一个删除了析构函数的类型,编译器不允许定义改类型的变量或创建该类型的临时对象。
同时如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象,因为一旦定义就无法销毁。

对于删除了析构函数的类型,虽然不能定义这种类型的变量或成员,但是可以动态分配这种类型的对象,但是不能释放

struct NoDtor {
    NoDtor() = default;
    ~NoDtor() = delete;
};
NoDtor nd;      //错误,析构函数被删除
NoDtor *p = new NoDtor();   //正确
delete p;       //错误
合成的拷贝控制成员可能时删除的

对于某些类,编译器将这些合成的成员定义为删除的函数:

  1. 如果类的某个成员的析构函数时删除或不可访问的,则类的合成析构函数被定义为删除
  2. 如果类的某个成员的拷贝构造函数的删除或不可访问的,则类的合成拷贝构造函数被定义为删除的。
  3. 如果类的某个成员的拷贝赋值运算符时删除或不可访问的,或类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的
  4. 如果类的某个成员的析构函数时删除或不可访问的,或是类有一个引用成员,它没有类初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。

本质是:一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对于的成员函数将被定义为删除的

private拷贝控制

旧标准下,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝

class PrivateCopy{
    //无访问说明符,默认为private
    PrivateCopy(const PrivateCopy&);
    PrivateCopy &operator=(const PrivateCopy&);
public:
    PrivateCopy() = default;
    ~PrivateCopy();
}

由于析构函数是public的,用户可以定义此对象
但是拷贝构造和拷贝赋值是private,用户无法拷贝这个类型的对象,而友元可以拷贝。为了阻止友元和成员函数进行拷贝,只声明为private但不定义。

因为试图访问一个未定义的成员将导致一个链接时错误

13.2拷贝控制和资源管理

通常管理类外资源的类必须定义拷贝控制成员,为了定义这些成员,首先必须确定此类型对象的拷贝语义。一般来说两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者一个指针

类的行为像一个值,意味这它应该有自己的状态,拷贝时副本和源对象完全独立
行为像指针则共享状态,当拷贝时,副本和源对象使用相同的底层数据。

在标准库中,容器和string类的行为像一个值,而shared_ptr类似指针。IO类和unique_ptr不允许拷贝和赋值,都不像

为HasPtr类定义拷贝控制成员,首先令类的行为像一个值,然后重新实现类,使它的行为像一个指针。两个成员一个int和一个string指针

13.2.1 行为像值的类

为了提供类值的行为,对于类的管理的资源,每个对象都应该拥有一份自己的拷贝。为了实现,HasPtr需要:

  1. 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
  2. 定义一个析构函数来释放string
  3. 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string

类值版本的HasPtr如下

class HasPtr {
public:
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0) { }
    //对ps指向的string,每个HasPtr对象都有自己的拷贝
    HasPtr(const HasPtr &p):
        ps(new std::string(*p.ps)), i(p.i) { }
    HasPtr& operator=(const HasPtr &);
    ~HasPtr() {delete ps;}
private:
    std::string *ps;
    int i ;
}
类值拷贝赋值运算符

赋值运算符通常组合了析构和构造函数的操作,会销毁左侧运算对象的资源,从右侧运算对象拷贝数据。这些操作要以正确的顺序执行,且异常安全(发生异常保证左侧对象置于一个有意义的状态)

本例先拷贝,处理自赋值情况,在释放左侧,更新指针

HasPtr& HasPtr::opreator=(const HasPtr &rhs)
{
    auto newp = new string(*rhs.ps);
    delete ps;
    ps = newp;
    i = rhs.i;
    return *this;
}

13.2.2 定义行为像指针的类

拷贝成员本身而不是指向的string,析构函数不能单方面地释放关联的string,只有当最后一个指向string的HasPtr销毁才能释放

一个好方法是使用shared_ptr来管理,或者使用引用计数

引用计数

工作方式如下:

  1. 除了初始化对象外,每个构造函数还有创建一个引用计数,记录有多少对象与正在创建的对象共享状态。当创建一个对象时,只有一个对象共享状态,计数器初始化为1
  2. 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
  3. 析构函数递减计数器,指出共享状态的用户少了一个,如果计数器为0则释放
  4. 拷贝赋值运算符递增右侧对象计数器,递减左侧对象计数器。如果左侧为0则销毁。

唯一的难题再于确定那里存放引用计数,计数器不能直接作为HasPtr对象的成员

HasPtr p1("Hiya!");
HasPtr p2(p1);  //p1p2指向同一个string
HasPtr p3(p1);  //p1p2p3指向相同的string

当创建p3是递增p1的计数器还是p2是个问题,因此一个解决方案是使用动态内存,当创建一个对象时也分配一个新的计数器,当拷贝或赋值对象时,拷贝指向计数器的指针。原副本就拥有相同的计数器

定义一个使用引用计数的类
class HasPtr{
public:
    //构造函数分配新的string和计数器
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
    HasPtr(const HasPtr &p):
        ps(p.ps), i(p.i), use(p.use) { ++*use; }
    HasPtr& operator={const HasPtr&};
    ~HasPtr();
private:
    std::string *ps;
    int i;
    std::size_t *use;
}
类指针的拷贝成员“篡改”引用计数

析构函数

HasPtr::~HasPtr()
{
    if(--*use == 0){
        delete ps;
        delete use;
    }
}

拷贝赋值运算符
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++rhs.use;
if(--
use == 0)
{
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}

13.3交换操作

除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。对于那些与重排元素顺序的算法一起使用的类,定义swap很重要,这些算法会调用swap

如果一个类定义了自己的swap,那么算法会使用自定义版本,否则使用标准库的swap,为了交换两个对象进行一次拷贝两次赋值

HasPtr temp = v1;
v1 = v2;
v2 = temp;

对于HasPtr,更希望swap交换指针,而不是重新分配string的副本,即希望这样交换两个HasPtr:

string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
编写自己的swap

在类上定义一个自己版本的swap来重载swap的默认行为
例:

class HasPtr{
    freind void swap(HasPtr&, HasPtr&);
    //其它成员定义都一样
}
inline
void swap(HasPtr&, HasPtr&)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);
    swap(lhs.i, rhs.i);
}

首先定义为friend,以便能访问HasPtr的数据成员
其次用inline修饰来优化

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

swap函数中调用的swap不是std::swap,数据成员是内置类型,是没有特定版本的swap,在上例中用的是std::swap

但是如果一个类的成员有自己的swap,那就不能用std的swap。虽然会通过编译,但是会存在问题

//正确的swap函数如下
viod swap(Foo &lhs, Fpoo &rhs)
{
    using std::swap;
    swap(lhs.h, rhs.h);
}

匹配会优先选择类型特定的swap版本

在赋值运算中使用swap

定义swap的类通常用swap来定义它们的赋值运算符,使用了一种拷贝并交换的计数,将左侧运算对象与右侧运算对象的一个副本进行交换

HasPtr& HasPtr::operator=(HasPtr rhs)
{
    swap(*this, rhs);
    return *this;
}

此版本的赋值运算符中,参数不是引用,将右侧运算对象以值传递的方式传给了赋值运算符。因此rhs是右侧的副本,参数传递是拷贝HasPtr的操作会分配该对象的string的一个新副本

调用swap来交换rhs和this中的数据成员,这个调用将左侧运算对象中原来保存的指针存入rhs,并将rhs中原来的指针存入this,因此在swap调用后,*this中的指针成员将指向新分配的string

当运算符结束时,rhs被销毁,HasPtr执行析构,delete掉rhs指向的内存,即释放掉左侧运算对象中原来的内存

使用拷贝和交换的赋值运算自动就是异常安全的,且能正确处理自赋值

13.4 拷贝控制示例

通常来说分配资源的类更需要拷贝控制,但不是唯一原因,一些类也需要拷贝控制成员来帮助进行簿记工作或其它操作

设计Message和Folder类表示电子邮件消息和消息目录,每个Message对象可以出现在多个Folder中,但是任意给定的Message内容只有一个副本。如果一个Message内容改变,则我没从它所在的任何Folder来浏览都看到改变后的内容

为了记录Message位于哪些Folder中,每个Message都保存一个所在Folder的指针的set,同理每个Folder保存包含Message的指针的set

Message提供save和remove操作,来向一个给定的Folder添加或删除

Message类
class Message {
    friend class Folder;
public:
    //folders被隐式初始化为空集合
    explicit Message(const std::string &str = ""):
        contents(str) { }
    Message(const Message&);
    Message& operator=(const Message&);
    ~Message();
    
    void save(Folder&);
    void remove(Folder&);
private:
    std::string contents;
    std::set<Folder*> folders;
    //工具函数
    //将本Message条件到指向参数的Folder中
    void add_to_Folders(const Message&);
    //从folders中的每个Folder中删除本Message
    void remove_from_Folders();
}
save和remove 成员
void Message::save(Folder &f)
{
    folders.insert(&f);
    f.addMsg(this);
}
void Message::remove(Folder &f)
{
    folders.erase(&f);
    f.remMsg(this);
}
拷贝控制成员

拷贝一个Message时得到的副本应该和原Message出现在相同的Folder中
因此变量Folder指针的set,对每个指向原Message的Folder添加一个新的Message指针

void Message::add_to_Folders(const Message &m)
{
    for(auto f : m.folders)
    {
        f->addMsg(this);
    }
}

Message::Message(const Message &m):
    contents(m.contents), folders(m.folders)
{
    add_to_Folders(m);
}
Message的析构

当一个Message被销毁时,必须从指向此Message的Folder中删除它,拷贝赋值运算也要指向此操作,因此定义一个公共函数

void Message::remove_from_Folders()
{
    for(auto f: folders)
    {
        f->remMsg(this);
    }
}
Message::~Message()
{
    remove_from_Folders();
}
拷贝赋值运算

结合两者

Message &Message :: operator=(const Message &rhs)
{
    remove_from_Folders();
    contents = rhs.contents;
    folders = rhs.folders;
    add_to_Folders(rhs);
    return *this;
}
swap函数

通过定义一个特定的swap,避免对contents和folders成员不必要的拷贝
swap要管理被交换的指针

void swap(Message &lhs, Message &rhs)
{
    using std::swap;    //本例不需要,但是一个好习惯
    for (auto f : lhs.folders)
        f->remMsg(&lhs);
    for (auto f : rhs.folders)
        f->remMsg(&rhs);
    swap(lhs.folders, rhs.folders);
    swap(lhs.contents, rhs.contents);
    for(auto f : lhs.folders)
        f->addMsg(&lhs);
    for(auto f : rhs.folders)
        f->addMsg(&rhs);
}

13.5动态内存管理类

某些类需要在运行时分配可变大小的内存空间。通常使用标准库容器来保存数据,但不是每个类都使用。这些了一般来说必须定义自己的拷贝控制成员来管理所分配的内存

例:实现一个vector的简化版本只用于string,命名为StrVec

StrVec类的设计

为了获得可接受的性能,预先分配足够的内存来保存可能需要的更多元素。vector每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个对象。如果没有,vector会重新分配空间:它获得新的空间,将已有元素移动到新空间中,是否旧空间,并添加新元素

使用一个allocator来获得原始内存,由于allocator分配的内存时未构造的,需要添加新元素时用allocator的construct成员在原始内存中创建对象。类似的,用destroy成员来销毁元素

每个StrVec有三个指针成员指向其元素所使用的内存:

  1. elemets,指向分配内存中的首元素
  2. first_free,指向最后一个实际元素之后的位置
  3. cap,指向未分配内存末尾之后的位置

除了指针,StrVec还有一个名为alloc的静态成员,类型为allocator。alloc成员会分配StrVec使用的内存。还有四个工具函数:

  1. alloc_n_copy分配内存并拷贝一个给定范围中的元素
  2. free会销毁构造的元素并是否内存
  3. chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存
  4. reallocate在内存用完时为StrVec分配新内存
StrVec类定义
class StrVec{
public:
    StrVec():
     elements(nullptr), first_free(nullptr), cap(nullptr) { }
    StrVec(const StrVec&);
    StrVec &operator = (const StrVec&);
    ~StrVec();
    void push_back(const std::string&);
    size_t size() const {return first_free - elements; }
    size_t capacity const { return cap - elements; }
    std::string *begin() const { return elements; }
    std::string *end() const { return first_free; }
private:
    Static std::allocator<std::string> alloc;
    void chk_n_alloc()
        { if (size() == capacity()) reallocate(); }
    std::pair<std::string*, std::string*> alloc_n_copy
        (const std::string*, const std::string*);
    void free();
    void reallocate();
    std::string *elements;
    std::string *first_free;
    std::string *cap;
}

类体定义了多个成员

  1. 默认构造函数隐式地默认初始化alloc并显式地将指针初始化为nullptr,表明没有元素
  2. size成员返回当前真正使用地元素地数目
  3. capacity返回StrVec可以保存元素的数量
  4. 当没有新空间容纳新元素时,即cap==first_free时,chk_n_alloc为StrVec重新分配内存
  5. begin和end分别指向首元素和最后一个各自的元素之后的位置
使用construct

函数push_back调用chk_n_alloc确保有空间容纳新元素。如果需要chk_n_alloc会调用reallocate。当chk_n_alloc返回时,push_back知道必有空间容纳新元素。它要求其allocator成员来construct新的尾元素:

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

当我们用allocator分配内存时,必须记住内存时未构造的。为了使用此原始内存必须调用construct,在此内存中构造一个对象。

对construct的调用也会递增first_free,表示已经构造了一个新元素。它使用前置递增,因此这个调用会在first_free当前指定的地址构造一个对象,并递增first_free指向下一个未构造的元素

alloc_n_copy成员

在拷贝或赋值StrVec时,可能会调用alloc_n_copy成员。StrVec类有类值的行为,当拷贝或赋值StrVec时,必须分配独立的内存,并从原StrVec对象拷贝元素至新对象

alloc_n_copy成员会分配足够的内存来保证给定范围的元素,并将这些元素拷贝到新分配的内存中。此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后的位置:

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

先计算需要的空间分配空间,再构造给定元素的副本

free成员

两个责任:首先destroy元素,然后释放StrVec自己分配的内存空间。for循环调用allocator的destroy成员,从构造的尾元素开始,到首元素位置,逆序销毁所有元素

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

destroy会运行string的析构,来释放string自己分配的内存空间。

一旦元素被销毁,就调用deallocate来释放本StrVec对象分配的内存空间。我们传递给deallocate的指针必须是之前某次allocate调用所返回的指针。因此在调用前先检查elements是否为空

拷贝控制成员
StrVec::StrVec(const StrVec &s)
{
    auto newdata = alloc_n_copy(s.begin(), s.end());
    elements = newdata.first;
    first_free = cap = newdata.second;
}

析构函数调用free

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

拷贝赋值运算

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;
}
在重新分配内存的过程中移动而不是拷贝元素

reallocate函数应该做的事情:

  1. 为一个新的、更大的string数组分配内存
  2. 在内存空间的前一个部分构造对象,保存现有元素
  3. 销毁原内存空间中的元素,并释放这块内存

string的行为类似值,拷贝一个string就会有两个用户,会拷贝数据。
拷贝这些数据是多余的,应该避免分配和释放string的额外开销

reallocate成员

每次重新分配时都会将StrVec的容量翻倍,如果StrVec为空则分配容纳一个元素的空间:

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;
}

调用move返回的结果会令construct使用string的移动构造函数。

13.6 对象移动

为了支持移动操作,引入了右值引用,即必须绑定右值的引用,通过&&来获得。可以将一个右值引用绑定到这里表达式上,但不能将一个右值引用之间绑定到一个左值上

int i = 42;
int &r = i;     //r引用i
int &&rr = i;   //错误,不能把右值引用绑定到左值
int &r2 = i * 42;       //错误,i*42是右值
const int &r3 = i * 42; //正确,可以把const引用绑定到一个右值
int &&rr2 = i *42;      //正确,可以把rr2绑定到乘法结果上
左值持久,右值短暂

左值有持久的状态,而右值要么是字母常量,要么是表达式气质过程中创建的临时对象;由于右值引用只能绑定到临时对象,因此

  1. 所引用的对象将要被销毁
  2. 该对象没有其它用户
    使用右值引用的代码可以自由地接管所引用对象地资源
变量是左值

变量可以看作是只要一个运算对象而没有运算符地表达式,变量表达式都是左值,我们不能把一个右值引用绑定到一个右值引用类型的变量。即使这个变量是右值引用类型也不行。

int &&rr1 = 42; //正确,字母常量是右值
int &&rr2 = rr1;    //错误,表达式rr1是左值

因为变量是持久的,离开了作用域才被销毁

标准库move函数

虽然不能将一个右值引用绑定到一个左值,但可以显式地将一个左值转换为对应地右值引用类型,通过move来获得绑定到左值上地右值引用,定义在utility中。

int &&rr3 = std::move(rr1); //ok

调用move就承诺除了对rr1赋值或销毁以外,不再使用它。
使用move地代码应该使用std::move,避免名字冲突

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

为了让自己的类型也能支持移动操作,需要为其定义移动构造函数和移动赋值运算符

类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数,这个引用参数再移动构造函数中是一个右值引用。其它额外的参数都必须又默认实参。

移动构造函数还必须确保以后源对象销毁是无害的,一旦资源完成移动,源对象不再指向被移动的资源,这些资源的所有权已经归属新创建的对象

StrVec::StrVec(StrVec &&s) noexcept //移动操作不应抛出任何异常
    : elements(s.elements), first_free(s.first_free), cap(s.cap)
{
    s.elements = s.first_free = s.cap = nullptr; 
}

移动构造函数不分配任何新内存,直接接管给定的StrVec中的内存,源对象指针都置为nullptr

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

移动不分配任何资源因此通常不会有异常,要通知标准库,用noexcept来承诺不抛出异常

在一个构造函数中,noexcept出现在参数列表和初始化列表开始的冒号之间

class StrVec
{
public:
    StrVec(StrVec&&) noexcept;
};
StrVec::StrVec(StrVec &&s) noexcept:    /*成员初始化器*/
{
    //构造体
}

标准库容器会对异常发生时的行为提供保障,移动一个对象会改变它的值,如果移动了部分后抛出异常,则旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。

以vector为例,除非知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。如果希望在vector重新分配内存这里情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们地移动构造函数可以安全使用。我们通过将移动构造函数标记为noexcept来做到这一点

移动赋值运算符

执行与析构函数和移动构造函数相同地工作,也要正确处理自赋值

StrVec &StrVec::operator==(StrVec &&rhs) noexcept
{
    if (this != &rhs){
        free();
        elements = rhs.elements;
        first_free = rhs.first_free;
        cap = rhs.cap;
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}

我们直接检查this指针和rhs地址是否相同,如果相同不需要做任何事情,来处理自赋值。

移动后源对象必须可析构

从一个对象移动数据并不会销毁此对象,但有时源对象会被销毁。因此必须确保以后源对象进入一个可析构的状态。StrVec是通过移动后指针置为nullptr来实现的

同时移动操作还必须保证对象仍然是有效的,可以赋予新值或者安全地使用而不依赖其当前值。另一方面,对源对象留下地值没有任何要求,程序不应该依赖与移后源对象中的数据

合成的移动操作

编译器不会为一些类合成移动操作,特别是如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来替代移动操作。

只有当一个类有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会给它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员时类类型,且该类有对应的移动操作,编译器也能移动这个成员:

struct X {
    int i;
    std::string s;
};
struct hasX {
    X men;
}
X x, x2 = std::move(x);         //使用合成的移动构造函数
hasX hx, hx2 = std::move(hx);

与拷贝操作不同,移动操作永远不会隐式定义为删除的函数,但如果显式地要求编译器生成=deafult地移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除地函数。除了一个例外,何时将合成地移动操作定义为删除的遵循和合成拷贝操作类似的原则:

  1. 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符类似
  2. 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的
  3. 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的
  4. 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的

例:Y是一个类,定义了自己的拷贝构造函数但未定义自己的移动构造函数

struct hasY{
    hasY() = default;
    hasY(hasY &&) = default;
    Y mem;      //Y将有一个删除的移动构造函数
}
hasY hy, hy2 = std::move(hy);   //错误,移动构造函数是删除的

编译器可以拷贝类型为Y的对象,但不能移动。

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

移动右值,拷贝左值

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

StrVec v1, v2;
v1 = v2;                //v2是左值,用拷贝赋值
StrVec getVec(istream &);   //getVec返回的是右值
v2 = getVec(cin);       //用移动赋值

第一个赋值移动是不可行的,不能隐式地将一个右值引用绑定到一个左值
第二个赋值两个运算符都可行,但拷贝赋值运算符需要一个到const的转换,而StrVec&&则是精确匹配

······但如果没有移动构造函数,右值也会被拷贝

如果一个类有拷贝构造但未定义移动构造函数,编译器不会合成移动构造函数。如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,move也如此

class Foo{
public:
    Foo() = default;
    Foo(const Foo&);
};
Foo x;
Foo y(x);
Foo z(std::move(x));    //拷贝构造函数

用拷贝构造函数代替移动构造几乎肯定是安全的。

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

HasPtr定义了拷贝并交换赋值运算符,如果添加一个移动构造函数,也会获得一个移动赋值运算符

class HasPtr{
public:
    HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i){p.ps = 0;}
    HasPtr& operator = (HasPtr rhs) { swap(*this, rhs) ; return *this;}
}

赋值运算符有一个非引用参数,要拷贝初始化,左值被拷贝右值被移动,因此就实现了两种功能

hp = hp2;    //拷贝
hp = std::move(hp2);    //移动

三/五法则:所有五个拷贝控制成员都应该看作一个整体,如果一个类定义了其中一个,就应该定义所有的五个操作。某些类必须定义拷贝构造函数、拷贝赋值运算符和析构才能正确工作,如果拷贝导致额外开销,就用移动构造和移动赋值来避免

Message类的移动操作

使用string和set的移动操作来避免拷贝contents和folders的额外开销。

但是除了移动folders成员,还必须更新每个指向原Message的Folder,必须删除指向旧的Message指针,并添加一个新Message指针

void Message::move_Folders(Message *m)
{
    folders = std::move(m->folders);
    for(auto f : folders)
    {
        f->remMsg(m);
        f->addMsg(this);
    }
    m->folders.clear();
}

移动构造函数用move来移动contents,并默认初始化自己的folders成员

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);
    }
    return *this;
}
移动迭代器

StrVec的reallocate成员使用了for来调用construct从旧内存将元素拷贝到新内存中。作为一种替换方法用uninitiallized_copy更简单,但是会对元素进行拷贝操作,标准库没有类似的函数将对象移动到未构造的内存中。

新标准库定义了移动迭代器适配器,通过改变给定迭代器的解引用运算符的行为来适配此迭代器。移动解引用运算符生成一个右值引用

通过make_move_iterator函数将一个普通迭代器转换为移动迭代器,此函数接受一个迭代器参数,返回一个移动迭代器。原迭代器的所有其它操作在移动迭代器能照常工作,可以将移动迭代器传递给其它算法,尤其是uninitiallized_copy

void StrVec::reallocate()
{
    auto newcaoacity = size() ? 2*size() : 1;
    auto first = alloc.allocate(newcapacity);
    
    auto last = uninitiallized_copy(make_move_iterator(begin()),
                                    make_move_iterator(end()),
                                    first);
    free();
    elements = first;
    first_free = last;
    cap = elements + newcapacity;
}

是uninitiallized_copy对输入序列中的每个元素调用construct来将元素拷贝到目的位置。此算法使用迭代器的解引用从输入序列中提取元素。由于传递的是移动迭代器,因此解引用运算符生成的是一个右值引用,意味着construct将使用移动构造函数来构造元素。

不过标准库不保证哪些算法能使用移动迭代器,哪些不能用。只有确信算法为一个元素赋值或将其传递给一个用户定义的函数后不再访问它,才可以用移动迭代器。

建议:不要随意使用移动操作,会很危险,只有小心使用才能达到优化性能的效果,否则可能导致难以查找的错误

16.6.3 右值引用和成员函数

允许移动的成员函数通常也有两个版本的参数模式,一个接受const的左值引用,一个接受非const的右值引用

void push_back(const X&);   //拷贝
void push_back(X&&);        //移动

为StrVec定义另一个版本的push_back

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

差别在于右值版本引用调用move
当我们调用push_back时,实参类型决定了新元素是拷贝还是移动到容器中

StrVec vec;
string s = "some string or another";
vec.push_back(s);       //拷贝
vec.push_back("done");      //移动
右值和左值引用成员函数

通常在对象调用成员函数不用管左值或右值

string s1 = "a value", s2 = "b value";
auto n = (s1 + s2).find('a');

在string上右值调用find,有时还会有

s1 + s2 = "wow!";

为了维持向后兼容,新版本允许右值赋值,但最好要阻止。
在参数列表后放置一个引用限定符,指出this的左值/右值属性的方式与定义const成员函数相同

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

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

Foo &retFoo();  //返回一个引用,调用左值
Foo retVal();   //返回一个值,调用的是右值
Foo i, j;       //i,j是左值
i = j;
retFoo() = j;   //正确,retFoo()返回的是左值
retVal() = j;   //错误,retVal()返回的是右值
i = retVal();   //正确,可以将右值作为赋值操作的右侧运算对象

一个函数可以同时用const和引用限定,这时候引用限定必须在const之后

class Foo{
public:
    Foo anotherMem() const &;
}
重载和引用函数

引用限定符也可以区分重载版本。还可以综合引用限定和const来区分一个成员函数的重载版本

例:将Foo定义一个名为data的vector成员和一个名为sorted的成员函数,sorted返回一个Foo对象的副本,其中vector已被排序

class Foo{
public:
    Foo sorted() &&;
    Foo sorted() const &;
private:
    vector<int> data;
};
//本对象为右值,可以原址排序
Foo Foo::sorted() &&
{
    sort(data.begin(), data.end());
    return *this;
}
//是const或左值,无法原址排序
Foo Foo::sorted() const & {
    Foo ret(*this);
    sort(ret.data.begin(), ret.data.end());
    return ret;
}

当我们对右值进行sorted时,可以安全地直接对data成员进行排序。对象时一个右值,意味着没有其它用户,因此可以改变对象。当一个const右值或一个左值执行sorted时,我们不能改变对象,因此需要排序前拷贝data

编译器会通过调用sorted的对象的左值/右值属性来确定使用哪个const版本

retVal().sorted();  //右值 &&
retFoo().sorted();  //左值 const &

当定义const成员函数时,可以定义两个版本

定义引用限定函数时,如果定了两个或两个以上具有同名自和参数列表和成员函数,就必须对所以函数加上引用限定符,或者都不加

Foo sorted() &&;
Foo sorted() const; //错误,必须加引用限定符
原文地址:https://www.cnblogs.com/aqq2828/p/14365232.html