《C++ Primer》第13章 拷贝控制(一)

发布时间:2023年12月17日

参考资料:

  • 《C++ Primer》第5版
  • 《C++ Primer 习题集》第5版

定义一个类时,我们要显式或隐式地指定此类型对象在拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment constructor)、析构函数(destructor)。我们称这些操作为拷贝控制操作(copy control)

13.1 拷贝、赋值与销毁(P440)

13.1.1 拷贝构造函数(P440)

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

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

合成拷贝构造函数

如果我们没有为一个类定义拷贝构造函数,编译器会提供合成拷贝构造函数(synthesized copy constructor),从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:

  • 类类型成员,会调用其拷贝构造函数
  • 内置类型成员,直接拷贝;
  • 对数组成员,会逐元素拷贝。

拷贝初始化

当使用直接初始化时,我们实际上要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数;当使用拷贝初始化时,编译器将右侧运算对象拷贝到正在创建的对象中,必要时还要进行类型转换。

拷贝初始化依靠拷贝构造函数移动构造函数来完成,发生在:

  • = 定义变量时;
  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象;
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。

此外,如果我们在初始化标准库容器时调用 pushinsert ,容器会对元素执行拷贝初始化;调用 emplace 则执行直接初始化。

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

参数和返回值

在函数调用过程中,具有非引用类型的参数要执行拷贝初始化,这也解释了为什么拷贝构造函数的参数必须是引用类型:如果参数不是引用类型,则必须调用拷贝构造函数,而调用拷贝构造函数因为要拷贝实参,又必须调用拷贝构造函数,如此无限循环…

拷贝初始化的限制

vector<int> v1(10);    // 正确
vector<int> v2 = 10;    // 错误,因为接受大小参数的构造函数是explicit的

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

在拷贝初始化过程中,编译器可以跳过拷贝/移动构造函数,直接创建对象,即允许编译器将 string s = "hello"; 改写为 string s("hello");

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

亲测 VS2022 没有这个限制,DEV C++ 有这个限制

13.1.2 拷贝赋值运算符(P443)

重载赋值运算符

重载运算符本质上是函数,其名字有 operator 关键字后接运算符的符号构成。赋值运算符就是名为 operator= 的函数。

重载运算符的参数表示运算符的运算对象,如果一个运算符为成员函数,则其左侧运算对象绑定到隐式 this 参数,其他运算符作为参数显式传递。

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

class Foo{
public:
    Foo& operator=(const Foo&);    // 这里的参数可以不是引用
};

为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。需要注意的是,标准库容器要求容器中的元素类型要具有赋值运算符,返回值是左侧对象的引用。

合成的拷贝赋值运算符

如果类未定义自己的拷贝赋值运算符,编译器会提供合成拷贝赋值运算符(synthesized copy-assignment operator),将右侧运算对象的每个非 static 成员赋值给左侧运算对象的相应成员(通过拷贝赋值运算符完成),对于数组类型,逐个元素赋值。返回值为一个指向左侧运算对象的引用。

13.1.3 析构函数(P444)

析构函数是类的一个成员函数,名字由 ~ 接类名构成,没有返回值,也不接受参数:

class Foo{
public:
    ~Foo();
};

由于析构函数不接受参数,因此它不能被重载,一个类只会有唯一的析构函数

析构函数完成什么工作

和构造函数类似,析构函数有一个函数体和一个隐式的析构部分,首先执行函数体,然后按初始化顺序的逆序销毁成员。成员销毁时做什么依赖于对象的类型:类类型执行自己的析构函数内置类型什么都不做

隐式销毁一个内置指针类型不会 delete 它所指向的对象。

什么时候会调用析构函数

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

  • 变量离开作用域被销毁;
  • 当一个对象销毁时,其成员被销毁;
  • 容器(标准库容器、数组)销毁时,其元素被销毁;
  • 对于动态分配的对象,对指向它的指针使用 delete 运算符时被销毁;
  • 对于临时对象,当创建它的表达式结束时被销毁。

合成析构函数

当一个类未定义自己的析构函数时,编译器会提供一个合成析构函数(synthesized destructor),函数体为空。

认识到析构函数体本身不直接销毁成员是非常重要的。

13.1.4 三/五法则(P447)

前面提到,有五个基本操作可以控制类的拷贝操作,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义其他操作的情况是很少见的。

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

当我们决定一个类是否需要定义自己版本的拷贝控制成员时,一个基本原则是首先确定该类是否需要一个析构函数。如如果该类需要析构函数,则一定也需要拷贝构造函数和拷贝赋值运算符

class HasPtr {
public:
	HasPtr(const string &s = string()):
		ps(new string(s)), i(0){}
	~HasPtr() { delete ps; }
private:
	string *ps;
	string::size_type i;
};

HasPtr 在构造函数中分配动态内存,因此需要定义析构函数释放这片内存。如果我们不自己定义拷贝构造函数和拷贝赋值运算符,将有多个 HasPtr 对象指向同一片动态内存,其中一个对象被析构后,其他对象的指针将失效!

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

某些类只需要拷贝或赋值操作,而不需要析构函数。如:一个类为每个对象分配一个唯一的编号。这个类的拷贝构造函数从给定对象拷贝所有其他数据成员,并为新创建的对象分配一个新的编号。此时,也应该自己定义拷贝赋值运算符来避免将序号赋予目的对象

class Person {
private:
	static unsigned ID;
public:
	Person(int wage = 0):
		id(++ID), wage(wage){}
	Person(const Person &p):
		id(++ID), wage(p.wage){}
	Person &operator=(const Person &p) {
		wage = p.wage;
	}
private:
	unsigned id;
	double wage;
};
unsigned Person::ID = 0;

13.1.5 使用=default(P449)

我们可以将拷贝控制成员定义为 =default 来显式要求编译器提供合成版本。

使用 =default 实际上定义了函数,所以在类内用 =default 将隐式声明合成的函数为内联的,在类外使用 =default 则无此效果。

13.1.6 阻止拷贝(P449)

对于某些类,拷贝或赋值没有合理的意义,例如 iostream 不允许拷贝,以避免多个对象写入或读取相同的 IO 缓冲。为了阻止拷贝,看起来不应该定义拷贝控制成员,但是,编译器会生成合成的版本,所以这种策略是无效的。

定义删除的函数

在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何形式使用它们。在参数列表后面加上 =delete

class NoCopy {
	NoCopy() = default;
	NoCopy(const NoCopy &) = delete;
	NoCopy &operator=(const NoCopy &) = delete;
	~NoCopy() = default;
};

=delete 必须出现在函数第一次声明的时候,且可以对任何函数指定 =delete

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

如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器不允许定义该类型的变量或创建该类型的临时对象。我们可以动态分配这种类型的对象,但不能释放这些对象:

class NoDtor {
public:
	NoDtor() = default;
	~NoDtor() = delete;
};
NoDtor nd;    // 错误
NoDtor *p = new NoDtor();    // 正确,但不能delete p

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

前面提到,如果我们没有定义拷贝控制成员,编译器会提供合成的版本;如果我们没有定义构造函数,编译器会提供合成默认构造函数。对于某些类来说,编译器将这些合成的函数定义为删除的函数

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

private拷贝控制

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

文章来源:https://blog.csdn.net/MaTF_/article/details/134956359
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。