重要的事情说三遍:
注意:本章需要对动态分配内存有适当的理解。
特殊成员函数是在特定情况下隐式定义为类成员的成员函数。共有六种:
成员函数 | 类 C 的典型形式 |
---|---|
默认构造函数 | C::C(); |
析构函数 | C::~C(); |
拷贝构造函数 | C::C (const C&); |
拷贝赋值运算符 | C& operator= (const C&); |
移动构造函数 | C::C (C&&); |
移动赋值运算符 | C& operator= (C&&); |
让我们逐一分析这些成员函数:
默认构造函数(Default constructor)
默认构造函数是在声明类对象但未使用任何参数初始化时调用的构造函数。
如果类定义中没有构造函数,编译器会假定该类具有隐式定义的默认构造函数。因此,在声明如下类后:
class Example {
public:
int total;
void accumulate(int x) { total += x; }
};
编译器会假定 Example
具有默认构造函数。因此,可以通过简单地声明它们而不使用任何参数来构造该类的对象:
Example ex;
但是,一旦类显式声明了带有任意数量参数的构造函数,编译器将不再提供隐式默认构造函数,并且不再允许在没有参数的情况下声明该类的新对象。例如,以下类:
class Example2 {
public:
int total;
Example2(int initial_value) : total(initial_value) {}
void accumulate(int x) { total += x; }
};
这里,我们声明了一个带有 int
类型参数的构造函数。因此,以下对象声明是正确的:
Example2 ex(100); // 正确:调用构造函数
但是以下声明:
Example2 ex; // 无效:没有默认构造函数
将无效,因为该类声明了一个带有一个参数的显式构造函数,这取代了隐式的默认构造函数。
因此,如果需要在没有参数的情况下构造该类的对象,则还应在类中声明适当的默认构造函数。例如:
// 类和默认构造函数
#include <iostream>
#include <string>
using namespace std;
class Example3 {
string data;
public:
Example3(const string& str) : data(str) {}
Example3() {}
const string& content() const { return data; }
};
int main() {
Example3 foo;
Example3 bar("Example");
cout << "bar's content: " << bar.content() << '\n';
return 0;
}
这里,Example3
具有默认构造函数(即不带参数的构造函数),定义为空块:
Example3() {}
这允许在没有参数的情况下构造 Example3
类的对象(如示例中声明的 foo
)。通常,默认构造函数会隐式定义于所有没有其他构造函数的类中,因此无需显式定义。但在这种情况下,Example3
具有另一个构造函数:
Example3(const string& str);
当类中显式声明了任何构造函数时,编译器不会自动提供隐式默认构造函数。
析构函数(Destructor)
析构函数执行构造函数的相反功能:它们负责在类的生命周期结束时所需的清理。我们在前几章定义的类没有分配任何资源,因此实际上不需要任何清理。
但现在,假设上一个示例中的类分配了动态内存来存储它作为数据成员的字符串;在这种情况下,自动在对象生命结束时调用一个函数来释放这些内存将非常有用。为此,我们使用析构函数。析构函数是一个成员函数,与默认构造函数非常相似:它不接受任何参数,也不返回任何值,甚至不返回 void
。它还使用类名作为其名称,但前面加上波浪号 (~
):
// 析构函数
#include <iostream>
#include <string>
using namespace std;
class Example4 {
string* ptr;
public:
// 构造函数:
Example4() : ptr(new string) {}
Example4(const string& str) : ptr(new string(str)) {}
// 析构函数:
~Example4() { delete ptr; }
// 访问内容:
const string& content() const { return *ptr; }
};
int main() {
Example4 foo;
Example4 bar("Example");
cout << "bar's content: " << bar.content() << '\n';
return 0;
}
在构造时,Example4
分配了一个 string
的存储空间。这个存储空间在析构函数中被释放。
对象的析构函数在其生命周期结束时被调用;在 foo
和 bar
的情况下,这发生在函数 main
结束时。
拷贝构造函数(Copy constructor)
当一个对象作为其自身类型的命名对象传递作为参数时,会调用其拷贝构造函数以构造一个副本。
拷贝构造函数是其第一个参数是对类本身的引用(可能是 const
限定)的构造函数,可以使用该类型的单个参数进行调用。例如,对于类 MyClass
,拷贝构造函数可能具有以下签名:
MyClass::MyClass(const MyClass&);
如果一个类没有定义自定义的拷贝或移动构造函数(或赋值运算符),则会提供一个隐式的拷贝构造函数。这个拷贝构造函数只是简单地拷贝其自身的成员。例如,对于如下类:
class MyClass {
public:
int a, b;
string c;
};
一个隐式的拷贝构造函数会被自动定义。该函数的定义大致相当于:
MyClass::MyClass(const MyClass& x) : a(x.a), b(x.b), c(x.c) {}
这个默认的拷贝构造函数可能适用于许多类。但是,浅拷贝仅拷贝类的成员,而对于我们在上面定义的包含指针的 Example4
类来说,这可能不是我们期望的结果,因为它处理指针的存储。对于该类,执行浅拷贝意味着复制指针值,但不复制内容本身;这意味着两个对象(副本和原件)将共享一个 string
对象(它们都指向同一个对象),并且在某些时候(在析构时),两个对象将尝试删除同一个内存块,可能导致程序在运行时崩溃。可以通过定义以下执行深拷贝的自定义拷贝构造函数来解决这个问题:
// 拷贝构造函数:深拷贝
#include <iostream>
#include <string>
using namespace std;
class Example5 {
string* ptr;
public:
Example5(const string& str) : ptr(new string(str)) {}
~Example5() { delete ptr; }
// 拷贝构造函数:
Example5(const Example5& x) : ptr(new string(x.content())) {}
// 访问内容:
const string& content() const { return *ptr; }
};
int main() {
Example5 foo("Example");
Example5 bar = foo;
cout << "bar's content: " << bar.content() << '\n';
return 0;
}
这个拷贝构造函数执行深拷贝,为新的字符串分配存储空间,并初始化为包含原对象的副本。这样,两个对象(副本和原件)有不同的内容副本,存储在不同的位置。
拷贝赋值运算符(Copy assignment)
对象不仅在构造时被复制,在它们被赋值时也会被复制。看下面的区别:
MyClass foo;
MyClass bar(foo); // 对象初始化:调用拷贝构造函数
MyClass baz = foo; // 对象初始化:调用拷贝构造函数
foo = bar; // 对象已初始化:调用拷贝赋值运算符
注意,baz
在构造时使用等号进行初始化,但这不是赋值操作!(尽管看起来像)对象的声明不是赋值操作,它只是调用单参数构造函数的另一种语法。
foo
上的赋值是赋值操作。这里没有声明对象,而是在现有对象 foo
上执行操作。
拷贝赋值运算符是 operator=
的重载,它接受类本身的值或引用作为参数。返回值通常是对 *this
的引用(尽管这不是必需的)。例如,对于类 MyClass
,拷贝赋值运算符可能具有以下签名:
MyClass& operator=(const MyClass&);
拷贝赋值运算符也是一个特殊函数,如果一个类没有定义自定义的拷贝或移动赋值运算符(或移动构造函数),则会隐式定义。
但是,同样地,隐式版本执行浅拷贝,适用于许多类,但不适用于具有指向其存储对象的指针的类,如 Example5
。在这种情况下,不仅类有两次删除指向对象的风险,而且赋值会通过不删除赋值前对象指向的对象而导致内存泄漏。这些问题可以通过执行深拷贝的拷贝赋值运算符来解决:
Example5& operator=(const Example5& x) {
delete ptr; // 删除当前指向的字符串
ptr = new string(x.content()); // 为新字符串分配空间并复制
return *this;
}
或者更好的是,由于其 string
成员不是常量,它可以重复利用同一个 string
对象:
Example5& operator=(const Example5& x) {
*ptr = x.content();
return *this;
}
移动构造函数和移动赋值运算符
类似于复制,移动也使用对象的值来设置另一个对象的值。但与复制不同,内容实际上是从一个对象(源)转移到另一个对象(目标):源对象失去了该内容,目标对象接管了该内容。仅当值的来源是未命名对象时才会发生这种移动。
未命名对象是临时对象,因此甚至没有名称。未命名对象的典型例子是函数的返回值或类型转换。
使用此类临时对象的值来初始化另一个对象或分配其值,实际上不需要复制:该对象将不会用于其他任何事情,因此,其值可以移动到目标对象中。这些情况会触发移动构造函数和移动赋值运算符:
移动构造函数在使用未命名临时对象在构造时初始化对象时调用。同样,移动赋值运算符在将未命名临时对象的值赋给对象时调用:
MyClass fn(); // 返回 MyClass 对象的函数
MyClass foo; // 默认构造函数
MyClass bar = foo; // 拷贝构造函数
MyClass baz = fn(); // 移动构造函数
foo = bar; // 拷贝赋值运算符
baz = MyClass(); // 移动赋值运算符
fn
返回的值和用 MyClass
构造的值都是未命名的临时对象。在这些情况下,不需要进行复制,因为未命名的对象是短暂的,可以由另一个对象获取,当这是更高效的操作时。
移动构造函数和移动赋值运算符是接受类本身的右值引用类型参数的成员:
MyClass(MyClass&&); // 移动构造函数
MyClass& operator=(MyClass&&); // 移动赋值运算符
右值引用通过在类型后面加两个 &&
指定。作为参数,右值引用匹配此类型的临时对象参数。
移动的概念对于管理其使用存储的对象最有用,例如使用 new
和 delete
分配存储的对象。在此类对象中,复制和移动是非常不同的操作:
- 从 A 复制到 B 意味着为 B 分配新内存,然后将 A 的整个内容复制到为 B 分配的新内存中。
- 从 A 移动到 B 意味着将已经分配给 A 的内存转移到 B,而无需分配任何新存储。这只是涉及指针的复制。
例如:
// 移动构造函数/赋值运算符
#include <iostream>
#include <string>
using namespace std;
class Example6 {
string* ptr;
public:
Example6(const string& str) : ptr(new string(str)) {}
~Example6() { delete ptr; }
// 移动构造函数
Example6(Example6&& x) : ptr(x.ptr) { x.ptr = nullptr; }
// 移动赋值运算符
Example6& operator=(Example6&& x) {
delete ptr;
ptr = x.ptr;
x.ptr = nullptr;
return *this;
}
// 访问内容:
const string& content() const { return *ptr; }
// 加法:
Example6 operator+(const Example6& rhs) {
return Example6(content() + rhs.content());
}
};
int main() {
Example6 foo("Exam");
Example6 bar = Example6("ple"); // 移动构造
foo = foo + bar; // 移动赋值
cout << "foo's content: " << foo.content() << '\n';
return 0;
}
编译器已经优化了许多形式上需要调用移动构造函数的情况,这称为返回值优化(RVO)。最显著的是,当函数返回值用于初始化对象时。在这些情况下,可能实际上不会调用移动构造函数。
注意,尽管右值引用可以用于任何函数参数的类型,但除了移动构造函数外,几乎没有其他用途。右值引用很复杂,不必要的使用可能会导致难以跟踪的错误。
隐式成员(Implicit members)
上述六个特殊成员函数是在特定情况下隐式声明为类成员的:
成员函数 | 隐式定义条件 | 默认定义 |
---|---|---|
默认构造函数 | 如果没有其他构造函数 | 不执行任何操作 |
析构函数 | 如果没有析构函数 | 不执行任何操作 |
拷贝构造函数 | 如果没有移动构造函数和移动赋值运算符 | 复制所有成员 |
拷贝赋值运算符 | 如果没有移动构造函数和移动赋值运算符 | 复制所有成员 |
移动构造函数 | 如果没有析构函数、拷贝构造函数和拷贝或移动赋值运算符 | 移动所有成员 |
移动赋值运算符 | 如果没有析构函数、拷贝构造函数和拷贝或移动赋值运算符 | 移动所有成员 |
注意,并不是所有特殊成员函数在相同情况下都会隐式定义。这主要是为了与 C 结构和早期 C++ 版本向后兼容,实际上有些包括了不推荐使用的情况。幸运的是,每个类可以使用关键字 default
和 delete
显式选择哪些这些成员存在其默认定义,哪些被删除。语法如下:
function_declaration = default;
function_declaration = delete;
例如:
// 默认和删除隐式成员
#include <iostream>
using namespace std;
class Rectangle {
int width, height;
public:
Rectangle(int x, int y) : width(x), height(y) {}
Rectangle() = default;
Rectangle(const Rectangle& other) = delete;
int area() { return width * height; }
};
int main() {
Rectangle foo;
Rectangle bar(10, 20);
cout << "bar's area: " << bar.area() << '\n';
return 0;
}
这里,Rectangle
可以通过两个 int
参数构造,也可以通过默认构造(不带参数)构造。然而,它不能通过另一个 Rectangle
对象拷贝构造,因为这个函数已被删除。因此,假设使用上一个示例中的对象,以下语句将无效:
Rectangle baz(foo);
然而,可以通过将其拷贝构造函数定义为:
Rectangle::Rectangle(const Rectangle& other) = default;
使其显式有效,这基本上等同于:
Rectangle::Rectangle(const Rectangle& other) : width(other.width), height(other.height) {}
注意,关键字 default
并不定义等同于默认构造函数(即默认构造函数是指不带参数的构造函数)的成员函数,而是等同于如果未删除的情况下隐式定义的构造函数。
一般来说,为了未来的兼容性,明确定义一个拷贝/移动构造函数或一个拷贝/移动赋值运算符但不是全部的类,建议在没有显式定义的其他特殊成员函数上指定 delete
或 default
。