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

c++ primer 第五版 笔记 第十三章

程序员文章站 2022-03-22 20:57:41
...

第 十三章 拷贝控制

因翻译耗时太长,现做笔记如下:

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

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

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

拷贝构造函数的第一参数必须是一个引用类型。
且这个参数通常为const类型。
且通常不应该为explicit

合成的拷贝构造函数

如果我们没有定义拷贝构造函数,编译器将会为我们定义一个合成的拷贝构造函数。

这个合成的拷贝构造函数,会将成员,逐个的拷贝到正在创建的对象中。

每个成员的类型,决定了它如何拷贝:对类类型成员,会使用拷贝构造函数来拷贝;内置类型的成员则直接拷贝。

虽然不能直接拷贝数组,但是合成拷贝成员,会逐一的拷贝数组中的元素。

拷贝初始化

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

直接初始化:要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。

拷贝初始化:要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要还要进行类型转换。

拷贝初始化不仅在我们用=定义变量时,在如下情况下也会发生:

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

编译器可以绕过拷贝构造函数

在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数。即,编译器将下面的代码:

string null_book = "9-999-99999-9";//拷贝初始化

改写为:

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

但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。

13.1.2 拷贝赋值运算符

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

重载赋值运算符

重载赋值运算符:跟普通函数一样,不过函数名需要以operator开头,然后跟上相应的操作符。因此,重载赋值运算符的函数名,就是operate=。

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

class Foo{
public:
    Foo& operator=(const Foo&);//赋值运算符
};

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

合成的拷贝赋值运算符

如果没有定义自己的拷贝赋值运算符,那么编译器就会为其生成一个合成的拷贝赋值运算符。

合成的拷贝赋值运算符,会将右侧对象的每个非static成员赋值给左侧对象的相应成员。

对于成员是类类型,则调用其赋值运算符。
对于成员是数组类型,则逐一赋值。
对于成员是内置类型,则直接赋值。

13.1.4 三五法则

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

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

13.1.5 使用=default

可以通过将拷贝成员定义为=default来显式的要求编译器生成合成的版本.

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,如上面的拷贝赋值运算符.

注意:我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或者拷贝控制成员)

13.1.6 阻止拷贝

虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但对于某些类来说,这些操作没有合理的意义.在此种情况下,定义类时必须采用某种机制阻止拷贝或者赋值.例如iostream类阻止了拷贝,以避免多个对象写入或者读取相同io的缓冲.

定义删除的函数

在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝.

删除函数:我们虽然定义了他们,但不能以任何方式使用他们.在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的.

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

=delete必须出现在函数的第一次声明中.

=delete可以使用在任何函数中.

=delete不能用在析构函数上.

如果析构函数定义为删除的,那么就无法析构这个对象.对于一个已经定义成删除的析构函数,编译器不允许创建这个类类型的变量或者创建该类的临时对象.因为无法销毁.

但是允许动态的分配这种类型的对象.但,不能释放这些对象:

struct NoDtor{
    NoDtor() = default;//使用合成的默认构造函数
    ~NoDtor() = delete;//不能销毁这个类型的对象
};

NoDtor nd;//错误,无法销毁,所以不能创建

NoDtor *p = new NoDtor();//正确:但不能delete p
delete p ;//错误,因为不能调用析构函数

合成的拷贝控制成员可能是删除的

编译器将如下情况的合成成员函数,定义为删除的.

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

以上规则的本质为:如果一个类有数据成员不能默认构造,拷贝,复制或者销毁,则对应的成员函数将被定义为删除的.

private拷贝控制

在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝的.

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

由于拷贝构造函数和拷贝赋值运算符是private的,用户代码将不能拷贝这个类型的对象.

但是友元和成员函数仍旧可以拷贝对象.为了阻止友元和成员函数进行拷贝.将这些函数拷贝控制成员声明为private,但不定义他们.

注意:希望阻止拷贝的类应该使用=delete来定义他们自己的拷贝构造函数和拷贝赋值运算符,而不应该将他们声明为private

13.3 交换操作

除了定义拷贝控制成员,管理资源的类通常还定义了一个名为swap的函数 .

如果一个类定义了自己的swap,那么算法将使用类定义的版本,否则,算法将使用标准库定义的swap.

为什么要自定义自己的swap函数

如果仅仅使用标准库中的swap函数,那么这个函数,将使用如下的形式进行拷贝:

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

当v1和v2这两个对象非常大的时候,这种交换就非常的耗时,而且这两个对象,其实可以只交换他们的部分内容.因此有必要定义自己的swap函数.

下面举个例子:

//当交换两个HasPtr对象时,只需要交换他们内部的指针即可.

class HasPtr{
    friend void swap(HasPtr &,HasPtr &);
};

inline
void swap(HasPtr &lhs,HasPtr &rhs){
    using std::swap;
    swap(lhs.ps,rhs.ps);//交换内部的指针

    swap(lhs.i,rhs.i);//交换内部的int成员
}
  1. 由于swap是为了优化代码,因此定义为了inline

  2. 注意using std::swap;这条语句.这个会让编译器选择一个最优的swap函数来调用,具体的细节将在16.3节中,学习

13.6 对象的移动

当拷贝一个对象之后,被拷贝的对象,马上就不使用了.此时可以换成另外一种方式:将被拷贝对象的内容移动到拷贝对象里面.这样,消除拷贝的消耗.提升程序的性能.

13.6.1 右值引用

右值引用:必须绑定到右值的引用.通过&&来获得右值引用.

右值引用也是某个对象的另外一个名字.

对于常规引用(也叫作左值引用)来说,不能将其绑定到要求转换的表达式,字面量常量或是返回右值的表达式.

而右值引用有这上述完全相反的绑定特性:可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用绑定到一个常规引用上面.

int i= 42;
int &r = i;//正确
int &&rr = i;//错误:不能将一个右值引用绑定到一个左值上

int &r2 = i * 42;//错误i*42是一个右值

const int &r3 = i * 42;//正确

int &&rr2 = i*42;//正确

从上面的例子可以看到,右值要么是字面量常量,要么是临时对象.

变量本身是左值

int &&rr1 = 42;//正确
int &&rr2 = rr1;//错误,表达式rr1是左值

上述第二个语句之所以错误,是因为rr1是一个变量,而变量是左值.

标准库函数move函数

调用move可以将一个左值转换为右值引用.

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

move调用告诉编译器:有一个左值,但我们希望像一个右值一样处理它.

同时必须认识到,调用move就意味着承若:除了对rr1赋值或者销毁它外,我们将不在使用它.在调用move之后,我们不能对移后源对象的值做任何假设.

注意:使用move,应该直接使用std::move而不是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;
}

移动构造函数,需要满足如下要求:

  1. 形参为该类型的右值引用
  2. 除此之外的形参都应该有默认值
  3. 必须保证移后源对象处于可销毁的状态

上面的例子,相较于拷贝构造函数而言,它直接将右侧对象的资源的指针,复制过来,这样节省了拷贝指针指向资源的耗时操作.

然后再将右侧对象的资源的指针清空,这样可以保证这个右侧对象处在一个可安全销毁的状态下.

移动操作,标准库容器,异常

上面例子中的noexcept,表示不会抛出异常.如果确定函数不会抛出异常,那么就应该加上这句话.否则,编译器会认为这个函数可能抛出异常,然后会增加一些额外的代码.

noexcept的使用将在 18.1.4中详细学习.现在只需要明白上面一句话,即可.

noexcept出现在参数列表和初始化列表开始的冒号之间.

需要在类的头文件声明和定义中,都指定noexcept

为什么需要noexcept

移动构造函数,在移动自己的成员时,需要明确知道自己的成员在移动的时候,不能出错.如果没有明确的告知,那么就不会使用成员的移动构造函数,而是使用成员的拷贝构造函数.

noexcept标记这个移动构造函数是安全的.

移动赋值运算符

移动赋值运算符执行于析构函数和移动构造函数相同的工作.与移动构造函数一样,如果我们的移动赋值运算符不抛出异常,应该标记为noexcept.

StrVec & strVec::operator=(StrVec &&rhs) noexcept{
    if(this != &rhs){
        free()://释放已有元素
        elements = rhs.elments;
        first_free = rhs.first_free;
        cap = rhs.cap;
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}

注意:移动操作之后,移后源对象必须保持有效的,可析构的状态,但是用户不能对其值进行任何假设.

合成的移动操作

与拷贝操作不同,编译器不会为某些类,合成移动操作符。特别是一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器不会为它合成移动构造函数和移动赋值运算符

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为他合成移动构造函数或移动赋值运算符。

移动构造函数永远都不会定义为删除的函数。但是如果我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。

如下情况将定义一个删除的移动操作:

  1. 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类的成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数,且编译器不能为其合成移动构造函数。移动赋值运算符类似

  2. 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。

  3. 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。

  4. 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。

如下例子:

//假定Y是一个类,他定义了自己的拷贝构造函数,但未定义自己的移动构造函数
struct hasY{
    hasY() = default;
    hasY(hasY &&) = default;
    Y mem;//hasY将有一个删除的移动构造函数
};

hasY hy,hy2 = std::move(hy);//错误,移动构造函数是删除的

注意:定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则,这些成员默认地被定义为删除的。

移动右值,拷贝左值

假设StrVec有自己的拷贝构造函数,和移动构造函数,那么则进行普通的函数匹配,来决定哪一个被调用。如下:

StrVec v1,v2;
v1 = v2;//v2是左值,使用拷贝赋值运算符
StrVec getVec(istream &);
v2 = getVec(cin);//getVec(cin)是一个右值;使用移动赋值运算符

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

如果一个类只有拷贝构造函数,而没有移动构造函数,在这种情况下,编译器不会合成移动构造函数,函数的匹配规则会保证该类型的对象会被拷贝,如下例子:

class Foo{
public:
    Foo() = default;
    Foo(const Foo &) ;//拷贝构造函数
    //其他成员的定义,但Foo为定义移动构造函数
};

Foo x;
Foo y(x);//拷贝构造函数x是一个左值
Foo z(std::move(x));//拷贝构造函数,因为未定义移动构造函数

因为可以将一个Foo &&转换为一个const Foo&.所以z的初始化将会调用拷贝构造函数。

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


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;//hp2 是一个左值;hp2 通过拷贝构造函数来拷贝
hp = std::move(hp2);//移动构造函数来移动hp2

更新后的三五原则

所有五个拷贝控制成员应该看做一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有的五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外的开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。

移动迭代器

一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。

通过调用make_move_ieterator将一个普通的迭代器转换为一个移动迭代器。

如下例子:

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

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

建议:不要随意使用移动操作

由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户

通过在类代码中小心地使用move,可以大幅度的提升性能。而如果随意在普通用户代码中使用移动操作,很可能导致莫名其妙的,难以查找的错误,而难以提升应用程序性能。

13.6.3 右值引用和成员函数

如果一个StrVec的成员函数push定义了如下这种形式:

push(const string &);
push(string &&);

StrVec vec;
string s = "some string or another";

vec.push(s);//调用push(const string &)
vec.push("done");//调用push(string &&)

右值和左值的引用成员函数

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

class Foo{
pubilic:
    Foo &operator=(const 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;//错误:revVal返回一个右值

i = retVal();//正确

一个函数可以同时使用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;
}

Foo Foo:sorted() const &{
    Foo ret(*this);//拷贝一个副本
    sort(ret.data.begin(),ret.data.end());//排序副本
    return ret;//返回副本
}

retVal().sorted();//retVal是一个右值,调用Foo::sorted()  &&
retFoo().sorted();//retFoo是一个左值,调用Foo::sorted() const &

当定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有const限定而另一个没有。

引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加:

class Foo{
public:
    Foo sorted() &&;
    Foo sorted() const;//错误:必须加上引用限定符
    //Comp是函数类型的类型别名
    //此函数类型可以用来比较int
    using Comp = bool(const int &,const int &);

    Foo sorted(Comp *);//正确:不同的参数列表
    Foo sorted(Comp *) const;//正确:两个版本都没有引用限定符
};

因此,总结为:如果一个成员函数有引用限定符,则具有相同参数列表的所有版本
都必须有引用限定符。

第十三章完