Effective C++ 条款05:了解 C++ 默默编写并调用哪些函数
编译器可以暗自为 class 创建 default 构造函数、copy 构造函数、copy assignment 操作符和析构函数。
开篇引言
在 C++ 中,你可以定义一个完全空的类:
cpp
class Empty {};
这个类真的是"空"的吗?答案是否定的。如果你仔细观察,会发现编译器为这个类默默生成了多个函数。理解这些编译器生成的函数何时生成、如何工作、有什么限制,是掌握 C++ 对象模型的关键一步。
Scott Meyers 在《Effective C++》第五条告诫我们:了解 C++ 默默编写并调用哪些函数。 只有理解了编译器在幕后做了什么,才能写出正确、高效的代码,避免那些令人困惑的编译错误。
编译器会生成哪些函数?
对于一个空类,编译器会声明(并在需要时生成定义)以下函数:
| 函数 | 签名 | 访问级别 | 特性 |
|---|---|---|---|
| 默认构造函数 | Empty() |
public |
inline |
| 析构函数 | ~Empty() |
public |
inline,非虚(除非基类有虚析构) |
| 拷贝构造函数 | Empty(const Empty&) |
public |
inline |
| 拷贝赋值操作符 | Empty& operator=(const Empty&) |
public |
inline |
C++11 以后,还会生成移动构造函数和移动赋值操作符(在满足条件时)。
代码演示
cpp
#include <iostream>
class Empty {};
// 编译器实际上生成了:
// class Empty {
// public:
// Empty() {} // 默认构造函数
// Empty(const Empty& rhs) {} // 拷贝构造函数
// Empty& operator=(const Empty& rhs) { // 拷贝赋值操作符
// return *this;
// }
// ~Empty() {} // 析构函数
// };
void demo() {
Empty e1; // 调用默认构造函数
Empty e2(e1); // 调用拷贝构造函数
Empty e3 = e1; // 调用拷贝构造函数
e3 = e2; // 调用拷贝赋值操作符
} // 调用析构函数
默认构造函数
何时生成?
只有当类没有声明任何构造函数时,编译器才会生成默认构造函数。
cpp
class HasCtor {
public:
HasCtor(int x) : value(x) {} // 声明了构造函数
private:
int value;
};
// HasCtor hc; // 编译错误!没有默认构造函数
HasCtor hc(10); // OK
默认构造函数做什么?
编译器生成的默认构造函数会调用每个基类和每个非静态成员的默认构造函数:
cpp
class Member {
public:
Member() { std::cout << "Member()\n"; }
};
class Derived : public Member {
public:
// 编译器生成的默认构造函数等价于:
// Derived() : Member() {}
private:
std::string name; // 调用 string 的默认构造函数
int* ptr; // 不初始化!内置类型不会自动初始化
};
void ctor_demo() {
Derived d; // 输出:Member()
// string 默认构造
// ptr 未初始化!
}
注意:编译器生成的默认构造函数不会初始化内置类型成员!这是条款 04 强调要手动初始化的原因。
拷贝构造函数
何时调用?
cpp
class Widget {
public:
Widget() = default;
// 编译器生成拷贝构造函数
private:
int data;
std::string name;
};
void copy_ctor_demo() {
Widget w1;
Widget w2(w1); // 直接初始化,调用拷贝构造函数
Widget w3 = w1; // 拷贝初始化,调用拷贝构造函数
Widget w4{w1}; // 列表初始化,调用拷贝构造函数(C++11)
// 注意:以下情况可能不调用拷贝构造函数(RVO/NRVO 优化)
Widget w5 = Widget(); // 可能被优化为直接构造
}
拷贝构造函数做什么?
编译器生成的拷贝构造函数执行逐成员拷贝(memberwise copy):
cpp
class Person {
public:
Person(const std::string& n, int a) : name(n), age(a) {}
// 编译器生成的拷贝构造函数等价于:
// Person(const Person& rhs)
// : name(rhs.name), // 调用 string 的拷贝构造函数
// age(rhs.age) // 直接拷贝内置类型
// {}
private:
std::string name;
int age;
};
浅拷贝陷阱
当类包含指针成员时,默认的逐成员拷贝会导致严重问题:
cpp
class ShallowCopy {
public:
ShallowCopy(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 没有自定义拷贝构造函数!使用编译器生成的版本
~ShallowCopy() {
delete[] data; // 双重释放问题!
}
private:
char* data;
};
void shallow_copy_demo() {
ShallowCopy s1("Hello");
ShallowCopy s2(s1); // 浅拷贝!s1.data 和 s2.data 指向同一块内存
// s1 和 s2 析构时都会 delete[] data,导致双重释放!
// 未定义行为,可能崩溃!
}
Rule of Three/Five: 如果类需要自定义析构函数、拷贝构造函数或拷贝赋值操作符中的任何一个,通常三个都需要自定义(C++11 后扩展为五个,加上移动构造函数和移动赋值操作符)。
拷贝赋值操作符
何时生成?
只有当类没有声明拷贝赋值操作符时,编译器才会生成。
拷贝赋值操作符做什么?
cpp
class AssignmentDemo {
public:
AssignmentDemo(int v) : value(v), name("default") {}
// 编译器生成的拷贝赋值操作符等价于:
// AssignmentDemo& operator=(const AssignmentDemo& rhs) {
// value = rhs.value; // 直接赋值内置类型
// name = rhs.name; // 调用 string 的拷贝赋值
// return *this;
// }
private:
int value;
std::string name;
};
自我赋值问题
编译器生成的拷贝赋值操作符不检查自我赋值:
cpp
class UnsafeSelfAssign {
public:
UnsafeSelfAssign(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 编译器生成的拷贝赋值操作符:
// UnsafeSelfAssign& operator=(const UnsafeSelfAssign& rhs) {
// delete[] data; // 释放自己的内存
// data = new char[strlen(rhs.data) + 1]; // 如果 rhs 就是 *this?
// strcpy(data, rhs.data); // 访问已释放的内存!
// return *this;
// }
~UnsafeSelfAssign() { delete[] data; }
private:
char* data;
};
void self_assign_demo() {
UnsafeSelfAssign s("Hello");
s = s; // 自我赋值!使用编译器生成的版本会导致严重错误
}
正确的拷贝赋值实现
cpp
class SafeAssign {
public:
SafeAssign(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 拷贝构造函数
SafeAssign(const SafeAssign& rhs) {
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
}
// 拷贝赋值操作符(正确处理自我赋值)
SafeAssign& operator=(const SafeAssign& rhs) {
if (this == &rhs) { // 检查自我赋值
return *this;
}
// 异常安全的方式:先创建副本,再交换
char* newData = new char[strlen(rhs.data) + 1];
strcpy(newData, rhs.data);
delete[] data;
data = newData;
return *this;
}
// 更现代的写法:copy-and-swap
SafeAssign& operator=(SafeAssign rhs) { // 按值传递,调用拷贝构造
swap(rhs); // 与临时对象交换
return *this;
}
void swap(SafeAssign& other) noexcept {
using std::swap;
swap(data, other.data);
}
~SafeAssign() { delete[] data; }
private:
char* data;
};
析构函数
何时生成?
只有当类没有声明析构函数时,编译器才会生成。
析构函数做什么?
编译器生成的析构函数是非虚的(除非基类声明了虚析构函数),它会调用成员和基类的析构函数:
cpp
class Base {
public:
~Base() { std::cout << "~Base()\n"; }
};
class Member {
public:
~Member() { std::cout << "~Member()\n"; }
};
class Derived : public Base {
public:
// 编译器生成的析构函数等价于:
// ~Derived() {
// // 调用成员的析构函数
// // 调用 Base 的析构函数
// }
private:
Member m;
std::string name;
};
void dtor_demo() {
Derived d;
} // 输出:~Member() -> ~Base()
多态基类必须有虚析构函数
cpp
class PolymorphicBase {
public:
virtual void draw() const {}
// 编译器生成的析构函数是非虚的!
};
class PolymorphicDerived : public PolymorphicBase {
public:
~PolymorphicDerived() { std::cout << "~PolymorphicDerived()\n"; }
};
void virtual_dtor_demo() {
PolymorphicBase* p = new PolymorphicDerived();
delete p; // 如果 ~PolymorphicBase() 不是虚函数,
// 这里只调用基类析构函数,派生类资源泄漏!
}
任何作为多态基类的类都应该声明虚析构函数。如果类不打算作为基类,应该明确标记为
final。
编译器拒绝生成函数的情况
1. 包含引用成员
cpp
class HasReference {
public:
HasReference(int& ref) : ref_(ref) {}
// 编译器不会生成拷贝赋值操作符!
// 因为引用不能重新绑定
private:
int& ref_;
};
void ref_demo() {
int x = 10, y = 20;
HasReference hr1(x);
HasReference hr2(y);
// hr1 = hr2; // 编译错误!拷贝赋值操作符被删除了
}
2. 包含 const 成员
cpp
class HasConst {
public:
HasConst(int v) : const_(v) {}
// 编译器不会生成拷贝赋值操作符!
// 因为 const 成员不能修改
private:
const int const_;
};
void const_demo() {
HasConst hc1(10);
HasConst hc2(20);
// hc1 = hc2; // 编译错误!拷贝赋值操作符被删除了
}
3. 基类或成员的拷贝赋值操作符不可访问
cpp
class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete; // 删除拷贝构造
NonCopyable& operator=(const NonCopyable&) = delete; // 删除拷贝赋值
};
class ContainsNonCopyable {
public:
ContainsNonCopyable() = default;
// 编译器不会生成拷贝构造和拷贝赋值!
// 因为成员 NonCopyable 的拷贝操作被删除了
private:
NonCopyable member;
};
4. 总结表
| 条件 | 默认构造 | 拷贝构造 | 拷贝赋值 | 析构函数 |
|---|---|---|---|---|
| 用户声明了任何构造函数 | 不生成 | --- | --- | --- |
| 用户声明了拷贝构造 | --- | 不生成 | --- | --- |
| 用户声明了拷贝赋值 | --- | --- | 不生成 | --- |
| 用户声明了析构函数(C++11前) | --- | --- | --- | 不生成 |
| 包含引用成员 | --- | 生成 | 不生成 | 生成 |
| 包含 const 成员(无默认构造) | 不生成 | 生成 | 不生成 | 生成 |
| 基类对应操作被删除 | 不生成 | 不生成 | 不生成 | 不生成 |
C++11 的移动语义
移动构造函数和移动赋值操作符
C++11 引入了移动语义,编译器在满足条件时也会生成移动操作:
cpp
class ModernWidget {
public:
ModernWidget() = default;
// 如果用户没有声明拷贝构造、拷贝赋值、移动构造、移动赋值和析构
// 编译器会生成:
// ModernWidget(ModernWidget&& rhs) noexcept; // 移动构造
// ModernWidget& operator=(ModernWidget&& rhs) noexcept; // 移动赋值
private:
std::vector<int> data;
std::string name;
};
void move_demo() {
ModernWidget w1;
ModernWidget w2 = std::move(w1); // 调用移动构造函数
ModernWidget w3;
w3 = std::move(w2); // 调用移动赋值操作符
}
移动操作被抑制的情况
一旦用户声明了拷贝操作或析构函数,编译器就不会生成移动操作:
cpp
class Legacy {
public:
Legacy() = default;
Legacy(const Legacy& rhs); // 用户声明了拷贝构造
Legacy& operator=(const Legacy& rhs);
~Legacy(); // 用户声明了析构
// 编译器不会生成移动构造和移动赋值!
};
Rule of Zero/Three/Five: 要么让编译器生成所有特殊成员函数(Rule of Zero),要么明确声明全部五个(Rule of Five)。不要只声明其中几个。
实际应用场景
场景 1:实现不可拷贝的类
cpp
class UniqueResource {
public:
UniqueResource() : handle(acquire_resource()) {}
// 显式删除拷贝操作
UniqueResource(const UniqueResource&) = delete;
UniqueResource& operator=(const UniqueResource&) = delete;
// 允许移动
UniqueResource(UniqueResource&& rhs) noexcept
: handle(rhs.handle) {
rhs.handle = nullptr;
}
UniqueResource& operator=(UniqueResource&& rhs) noexcept {
if (this != &rhs) {
release_resource(handle);
handle = rhs.handle;
rhs.handle = nullptr;
}
return *this;
}
~UniqueResource() { release_resource(handle); }
private:
ResourceHandle acquire_resource() { /* ... */ return nullptr; }
void release_resource(ResourceHandle h) { /* ... */ }
ResourceHandle handle;
};
场景 2:PIMPL 惯用法中的拷贝控制
cpp
class Widget {
public:
Widget();
~Widget();
// 必须声明(在 .cpp 中定义),因为 Impl 是不完整类型
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
Widget(Widget&& rhs) noexcept;
Widget& operator=(Widget&& rhs) noexcept;
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};
// widget.cpp
class Widget::Impl {
public:
std::vector<double> data;
std::string name;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // 必须在 .cpp 中定义
// 拷贝操作(深拷贝)
Widget::Widget(const Widget& rhs)
: pImpl(std::make_unique<Impl>(*rhs.pImpl)) {}
Widget& Widget::operator=(const Widget& rhs) {
if (this != &rhs) {
*pImpl = *rhs.pImpl; // 深拷贝 Impl 的内容
}
return *this;
}
// 移动操作
Widget::Widget(Widget&& rhs) noexcept = default;
Widget& Widget::operator=(Widget&& rhs) noexcept = default;
场景 3:计数器类(自定义拷贝行为)
cpp
class ObjectCounter {
public:
ObjectCounter() { ++count; }
// 自定义拷贝构造:增加计数
ObjectCounter(const ObjectCounter&) { ++count; }
// 自定义拷贝赋值:不增加计数(对象已存在)
ObjectCounter& operator=(const ObjectCounter&) {
return *this; // 计数不变
}
~ObjectCounter() { --count; }
static int getCount() { return count; }
private:
static int count;
};
int ObjectCounter::count = 0;
void counter_demo() {
ObjectCounter c1; // count = 1
ObjectCounter c2(c1); // count = 2
ObjectCounter c3;
c3 = c1; // count 仍为 2(赋值不增加)
} // count = 0
总结与建议
核心要点
-
编译器可以暗自为 class 创建默认构造函数、拷贝构造函数、拷贝赋值操作符和析构函数。
-
这些编译器生成的函数都是
public且inline的。 它们执行逐成员初始化/拷贝/赋值/析构。 -
如果 class 包含引用成员或 const 成员,编译器不会生成拷贝赋值操作符。
-
如果基类的某个特殊成员被删除或不可访问,编译器不会为派生类生成对应的函数。
Rule of Zero / Three / Five
| 规则 | 说明 | 适用场景 |
|---|---|---|
| Rule of Zero | 不声明任何特殊成员函数,让编译器生成全部 | 简单类、所有成员都能正确自管理 |
| Rule of Three | 声明析构、拷贝构造、拷贝赋值 | 管理资源(C++98/03) |
| Rule of Five | 声明全部五个特殊成员函数 | 管理资源(C++11 及以后) |
cpp
// Rule of Zero 示例
class ModernPerson {
std::string name;
std::vector<int> scores;
std::shared_ptr<Address> address;
// 编译器生成的函数完全够用!
};
// Rule of Five 示例
class RawBuffer {
public:
RawBuffer(std::size_t size) : size_(size), data_(new char[size]) {}
// 析构
~RawBuffer() { delete[] data_; }
// 拷贝
RawBuffer(const RawBuffer& rhs) : size_(rhs.size_), data_(new char[rhs.size_]) {
std::copy(rhs.data_, rhs.data_ + size_, data_);
}
RawBuffer& operator=(const RawBuffer& rhs) {
RawBuffer tmp(rhs);
swap(tmp);
return *this;
}
// 移动
RawBuffer(RawBuffer&& rhs) noexcept : size_(rhs.size_), data_(rhs.data_) {
rhs.data_ = nullptr;
rhs.size_ = 0;
}
RawBuffer& operator=(RawBuffer&& rhs) noexcept {
if (this != &rhs) {
delete[] data_;
data_ = rhs.data_;
size_ = rhs.size_;
rhs.data_ = nullptr;
rhs.size_ = 0;
}
return *this;
}
void swap(RawBuffer& other) noexcept {
using std::swap;
swap(size_, other.size_);
swap(data_, other.data_);
}
private:
std::size_t size_;
char* data_;
};
经典名言
了解 C++ 默默编写并调用哪些函数。
编译器生成的函数在大多数情况下是正确的,但当你的类管理资源(内存、文件句柄、网络连接等)时,必须显式控制这些特殊成员函数的行为。理解编译器的默认行为,是避免资源泄漏和未定义行为的第一步。
参考阅读:
- 《Effective C++》Scott Meyers,条款 05
- 《C++ Primer》关于拷贝控制的章节
- 《Effective Modern C++》关于移动语义和智能指针的条款
系列预告: 本系列前五篇已覆盖《Effective C++》第一章"让自己习惯 C++"的全部条款。后续将继续深入解析构造/析构/赋值运算、资源管理、设计与声明等章节的精华内容。
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。