C++ 语言拷贝构造函数、拷贝赋值运算符和析构函数

本文详细介绍了C++编程中类的拷贝构造函数、拷贝赋值运算符和析构函数的作用及原理。拷贝构造函数在对象初始化时用于复制已有对象,合成的拷贝构造函数会逐个复制成员。拷贝赋值运算符处理对象间的赋值,同样有合成版本用于成员级别的赋值。析构函数负责在对象销毁时清理资源。这三个函数对于控制类对象的生命周期和行为至关重要。
摘要由CSDN通过智能技术生成

C++ 语言拷贝构造函数、拷贝赋值运算符和析构函数

每个类都定义了一个新类型和在此类型对象上可执行的操作。类可以定义构造函数,用来控制在创建此类型对象时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,拷贝构造函数 (copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数 (move constructor)、移动赋值运算符 (move-assignment operator) 和析构函数 (destructor)。拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作 (copy control)。

如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。在定义任何 C++ 类时,拷贝控制操作都是必要部分。如果我们不显式定义这些操作,编译器也会为我们定义,但编译器定义的版本的行为可能并非我们所想。

1. 拷贝构造函数、拷贝赋值运算符和析构函数

1.1 拷贝构造函数

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

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

拷贝构造函数的第一个参数必须是一个引用类型。虽然我们可以定义一个接受非 const 引用的拷贝构造函数,但此参数几乎总是一个 const 的引用。拷贝构造函数在几种情况下都会被隐式地使用。拷贝构造函数通常不应该是 explicit 的。

1.2 合成拷贝构造函数

如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。对某些类来说,合成拷贝构造函数 (synthesized copy constructor) 用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。

每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

Sales 类的合成拷贝构造函数等价于:

class Sales {
public:
	// 与合成的拷贝构造函数等价的拷贝构造函数的声明
	Sales(const Sales &);
private:
	std::string book_no;
	int sold_num = 0;
	double revenue = 0.0;
};

// 与 Sales 的合成的拷贝构造函数等价
Sales::Sales(const Sales &orig) :
	book_no(orig.book_no),  // 使用 string 的拷贝构造函数
	sold_num(orig.sold_num),  // 拷贝 orig.sold_num
	revenue(orig.revenue)  // // 拷贝 orig.revenue
{}  // 空函数体

1.3 拷贝初始化

直接初始化和拷贝初始化之间的差异:

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

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

拷贝初始化通常使用拷贝构造函数来完成。如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。现在,我们只需了解拷贝初始化何时发生,以及拷贝初始化是依靠拷贝构造函数或移动构造函数来完成的就可以了。

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

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

某些类类型还会对它们所分配的对象使用拷贝初始化。当我们初始化标准库容器或是调用其 insertpush 成员时,容器会对其元素进行拷贝初始化。与之相对,用 emplace 成员创建的元素都进行直接初始化。

1.4 参数和返回值

在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。

拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功。为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。

1.5 拷贝初始化的限制

如果我们使用的初始化值要求通过一个 explicit 的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要.

std::vector<int> vl(10);  // 正确:直接初始化
std::vector<int> v2 = 10;  // 错误:接受大小参教的构造函数是 explicit 的 
void f(std::vector<int>);  // f 的参数进行拷贝初始化
f(10);  // 错误:不能用一个 explicit 的构造函数拷贝一个实参
f(std::vector<int>(10));  // 正确:从一个 int 直接构造一个临时 vector

直接初始化 v1 是合法的,但看起来与之等价的拷贝初始化 v2 则是错误的,因为 std::vector 的接受单一大小参数的构造函数是 explicit 的。当传递一个实参或从函数返回一个值时,我们不能隐式使用一个 explicit 构造函数。如果我们希望使用一个 explicit 构造函数,就必须显式地使用。

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

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

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

改写为

std::string null_book("9-99999-9");  // 编译器略过了拷贝构造函数

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

2. 拷贝赋值运算符

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

Sales trans, accum;
trans = accum; // 使用 Sales 的拷贝赋值运算符

与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

2.1 重载赋值运算符

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

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

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

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

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

2.2 合成拷贝赋值运算符

如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符 (synthesized copy-assignment operator)。对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非 static 成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

下面的代码等价于 Sales 的合成拷贝赋值运算符:

// 等价于合成拷贝赋值运算符
Sales &Sales::operator=(const Sales &rhs) {
	book_no = rhs.book_no;  // 调用 string::operator=
	sold_num = rhs.sold_num;  // 使用内置的 int 赋值
	revenue = rhs.revenue;  // 使用内置的 double 赋值
	return *this;  // 返回一个此对象的引用
}

3. 析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非 static 数据成员,还可能做一些其他工作。析构函数释放对象使用的资源,并销毁对象的非 static 数据成员。析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数。由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。

class Foo {
public:
	~Foo();  // 析构函数
	// ...
};

3.1 析构函数完成什么工作

析构函数有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。在对象最后一次使用之后,析构函数的函数体可执行类设计者希望执行的任何收尾工作。通常,析构函数释放对象在生存期分配的所有资源。

在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁, 析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。隐式销毁一个内置指针类型的成员不会 delete 它所指向的对象。

与普通指针不同,智能指针是类类型,所以具有析构函数。与普通指针不同,智能指针成员在析构阶段会被自动销毁。

3.2 调用析构函数

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

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

References

(美) Stanley B. Lippman, (美) Josée Lajoie, (美) Barbara E. Moo 著, 王刚, 杨巨峰 译. C++ Primer 中文版[M]. 第 5 版. 电子工业出版社, 2013.
https://www.informit.com/store/c-plus-plus-primer-9780321714114

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Yongqiang Cheng

梦想不是浮躁,而是沉淀和积累。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值