1. C++默认成员函数概述
在C++中,每个类都有一组特殊的默认成员函数,它们由编译器在特定条件下自动生成。这些函数构成了C++对象生命周期的基石,理解它们的行为对于编写健壮的C++代码至关重要。
默认成员函数主要包括:
- 默认构造函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数(C++11引入)
- 移动赋值运算符(C++11引入)
- 析构函数
这些函数之所以被称为"默认",是因为当开发者没有显式声明它们时,编译器会根据需要自动生成对应的实现。但要注意,编译器生成的版本可能并不总是符合我们的预期行为。
重要提示:编译器生成的默认函数都是public且inline的,这意味着它们可以直接被调用且定义在类定义中。
2. 构造函数深度解析
2.1 默认构造函数的特点
默认构造函数是不接受任何参数(或所有参数都有默认值)的特殊构造函数。当类中没有定义任何构造函数时,编译器会生成一个合成的默认构造函数。
这个合成的默认构造函数会:
- 如果类成员有类内初始值,则用它初始化成员
- 否则,默认初始化该成员(对内置类型执行零初始化,对类类型调用其默认构造函数)
cpp复制class Example {
public:
int a; // 默认初始化(未定义值)
std::string b; // 调用string的默认构造函数
};
Example ex; // 使用合成的默认构造函数
2.2 构造函数初始化列表
构造函数初始化列表是初始化类成员的高效方式,它在构造函数体执行前完成成员的初始化。对于const成员、引用成员和没有默认构造函数的类成员,必须使用初始化列表。
cpp复制class InitDemo {
const int x;
int& y;
std::string name;
public:
InitDemo(int val, int& ref)
: x(val), y(ref), name("default") { // 必须使用初始化列表
// 构造函数体
}
};
初始化列表的执行顺序由成员在类中的声明顺序决定,而非初始化列表中的书写顺序。错误的顺序可能导致微妙的bug。
2.3 委托构造函数
C++11引入了委托构造函数,允许一个构造函数调用同类中的另一个构造函数,避免代码重复。
cpp复制class Delegating {
int x, y;
std::string name;
public:
Delegating() : Delegating(0, 0, "default") {} // 委托给下面的构造函数
Delegating(int a, int b, const std::string& s)
: x(a), y(b), name(s) {}
};
3. 拷贝控制成员详解
3.1 拷贝构造函数
拷贝构造函数接受一个本类类型的const引用作为参数,用于从已有对象创建新对象。典型声明形式为:
cpp复制ClassName(const ClassName&);
当发生以下情况时会调用拷贝构造函数:
- 用=定义变量时
- 传递对象给非引用形参时
- 从函数返回非引用对象时
- 用花括号列表初始化数组元素或聚合类成员时
合成的拷贝构造函数会逐个拷贝每个非static成员,对类类型成员调用其拷贝构造函数,对内置类型直接拷贝内存。
3.2 拷贝赋值运算符
拷贝赋值运算符重载了=操作符,用于将一个对象的值赋给另一个已存在的对象。典型声明形式为:
cpp复制ClassName& operator=(const ClassName&);
合成的拷贝赋值运算符与拷贝构造函数类似,也是逐个成员进行赋值。但需要注意自赋值问题:
cpp复制ClassName& ClassName::operator=(const ClassName& rhs) {
if (this != &rhs) { // 防止自赋值
// 执行拷贝操作
}
return *this;
}
3.3 移动语义(C++11)
移动构造函数和移动赋值运算符是C++11引入的重要特性,它们通过"窃取"资源而非拷贝资源来提高性能。
移动构造函数典型声明:
cpp复制ClassName(ClassName&&) noexcept;
移动赋值运算符典型声明:
cpp复制ClassName& operator=(ClassName&&) noexcept;
移动操作通常不分配任何资源,而是接管源对象的资源,然后将源对象置于有效但不可用的状态。标记为noexcept是因为标准库容器在重新分配内存时会优先使用不会抛出异常的移动操作。
4. 析构函数工作机制
4.1 析构函数调用时机
析构函数在对象销毁时自动调用,具体时机包括:
- 变量离开作用域时
- 对象被delete时
- 临时对象创建它的完整表达式结束时
- 容器被销毁时其元素被销毁
- 对于动态分配的对象,当指向它的最后一个shared_ptr被销毁时
析构函数没有返回值,也不接受参数,声明形式为:
cpp复制~ClassName();
4.2 合成析构函数行为
如果类没有定义自己的析构函数,编译器会生成一个合成的析构函数。合成的析构函数函数体为空,但会隐式地调用每个成员的析构函数。注意,析构函数体本身并不直接销毁成员,成员是在析构函数体执行完毕后隐式销毁的。
对于资源管理类,通常需要自定义析构函数来释放资源,这就是著名的RAII(Resource Acquisition Is Initialization)技术的基础。
5. 默认成员函数的生成规则
5.1 默认函数的生成条件
编译器生成默认函数的规则比较复杂,主要遵循以下原则:
- 如果类定义了任何构造函数,编译器不会生成默认构造函数
- 如果类定义了移动操作,编译器不会生成拷贝操作
- 如果类定义了拷贝操作、析构函数或拷贝赋值运算符,编译器不会生成移动操作
- 如果类定义了析构函数,编译器不会生成移动操作(但会生成拷贝操作)
5.2 =default和=delete
C++11允许我们显式要求编译器生成默认版本或删除特定成员函数:
cpp复制class DefaultOps {
public:
DefaultOps() = default; // 显式要求生成默认构造函数
DefaultOps(const DefaultOps&) = delete; // 禁止拷贝
DefaultOps& operator=(DefaultOps&&) = default; // 显式生成移动赋值
};
=default可以在类内(隐式inline)或类外定义,而=delete必须出现在首次声明时。
6. 三/五法则
6.1 传统三法则
在C++11之前,三法则指出:如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么它很可能需要全部三个。这是因为这些函数通常与资源管理相关。
6.2 现代五法则
C++11引入移动语义后,扩展为五法则:如果一个类需要自定义拷贝控制成员中的任何一个,那么它可能需要自定义所有五个(加上移动构造函数和移动赋值运算符)。
7. 实战经验与常见陷阱
7.1 虚析构函数规则
基类的析构函数应该总是声明为virtual,除非确定该类不会被用作基类。这是为了防止通过基类指针删除派生类对象时发生资源泄漏:
cpp复制class Base {
public:
virtual ~Base() = default; // 多态基类必须有虚析构函数
};
7.2 移动操作的noexcept
标准库容器在重新分配内存时会优先使用移动操作,但前提是移动操作不会抛出异常。因此,移动构造函数和移动赋值运算符应该标记为noexcept:
cpp复制class Movable {
public:
Movable(Movable&&) noexcept; // 重要!
Movable& operator=(Movable&&) noexcept;
};
7.3 自赋值处理
在拷贝赋值运算符中,必须处理自赋值情况。常见的做法有两种:
- 显式检查:
cpp复制ClassName& operator=(const ClassName& rhs) {
if (this != &rhs) {
// 执行拷贝
}
return *this;
}
- 拷贝并交换惯用法:
cpp复制ClassName& operator=(ClassName rhs) { // 按值传递,自动拷贝
swap(*this, rhs); // 交换内容
return *this; // rhs现在持有原内容,离开作用域时销毁
}
7.4 默认成员函数与PIMPL惯用法
当使用PIMPL(Pointer to IMPLementation)惯用法时,默认成员函数的行为需要特别注意。通常需要在实现文件中定义这些函数以确保正确的内存管理:
cpp复制// 头文件
class Widget {
struct Impl;
std::unique_ptr<Impl> pImpl;
public:
Widget();
~Widget(); // 必须声明,因为unique_ptr需要完整类型来删除
Widget(Widget&&) noexcept; // 移动构造
Widget& operator=(Widget&&) noexcept; // 移动赋值
// 通常禁用拷贝
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
};
8. 性能优化技巧
8.1 返回值优化(RVO)
现代编译器普遍支持返回值优化(RVO),可以避免不必要的拷贝和移动操作。要利用这一优化,应该直接返回局部对象:
cpp复制Widget makeWidget() {
Widget w;
// 操作w
return w; // 可能触发RVO
}
8.2 移动语义的最佳实践
要充分利用移动语义:
- 对可移动的资源类实现移动操作
- 将不会抛出异常的移动操作标记为noexcept
- 在函数中按值传递可移动类型,然后在函数内移动它们
- 使用std::move将左值转换为右值引用(但不要在return语句中对局部变量使用)
8.3 小型对象优化
对于小型对象,移动操作可能不比拷贝操作更高效。标准库中的std::string等类型通常实现了小型对象优化(SSO),将小字符串直接存储在对象内部而非堆上。在这种情况下,移动操作可能不会带来性能优势。
9. 现代C++中的新变化
9.1 默认成员函数与constexpr
C++20允许默认成员函数(包括构造函数和析构函数)被声明为constexpr:
cpp复制class ConstexprDemo {
public:
constexpr ConstexprDemo() = default;
constexpr ~ConstexprDemo() = default;
};
这使得对象可以在编译期创建和销毁,为元编程提供了更多可能性。
9.2 三路比较运算符
C++20引入的三路比较运算符(<=>)可以简化比较运算符的实现。编译器可以根据<=>自动生成==、!=、<、<=、>、>=等操作符:
cpp复制class Spaceship {
int value;
public:
auto operator<=>(const Spaceship&) const = default;
};
9.3 编译器生成的特殊函数
现代C++编译器在特定条件下会生成更多特殊函数,如:
- 默认的比较运算符(C++20)
- 默认的operator new/delete(在某些情况下)
- 默认的协程相关函数(C++20)
理解这些自动生成的函数有助于编写更高效、更安全的代码。