几个月前,线上服务突然出现诡异的内存暴涨,排查到最后发现是一个自定义的 Buffer 类没有正确实现拷贝构造,导致浅拷贝把同一块堆内存析构了两次,程序直接崩溃。这种问题在 C++ 里比比皆是,凡是跟资源管理沾边的类,只要默认成员函数没写对,迟早出事。今天我把构造函数、析构函数、拷贝构造函数这组 C++ 类的"默认三件套"彻底拆开讲一遍,包含它们的生成规则、调用时机、隐藏陷阱,以及现代 C++ 的最佳实践。这篇内容适合正在学 C++ 的初学者,也适合写了两三年 C++ 但没系统梳理过默认成员函数的老手,看完至少能少踩一半的坑。
1. 为什么"默认"成员函数反而是最容易出问题的地方
C++ 的设计者在最初设计类机制时做了一个非常大胆的决定:如果你没有给类显式声明某些成员函数,编译器会自动生成它们。这个设计初衷是好的——让程序员可以写出极简的类,只关注业务数据,把那些"肯定会用到"的底层函数交给编译器处理。但它也埋下了巨大的隐患:编译器生成的默认版本只是"能编译通过",并不一定"行为正确",尤其当类里涉及指针、堆内存、文件句柄、互斥锁等资源时。
先明确一个基础概念:C++ 的类里有六个默认成员函数,加上后来的移动构造和移动赋值,一共是八个。但通常讲"默认成员函数"时,重点指的是前六个:
- 默认构造函数(无参构造)
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 取址运算符(很少重载,一般忽略)
- const 取址运算符(同样很少重载)
我工作这么多年,取址运算符重载只在实际项目中见过一两次,基本用于特殊调试框架,所以这篇内容重点放在前四个:构造函数、析构函数、拷贝构造、拷贝赋值。这四个函数的生成规则完全遵循"如果你不声明,编译器就默认帮你生成;如果你声明了任何一个跟拷贝/析构相关的函数,编译器就不再生成默认版本"的逻辑。
让我用一张表来概括编译器自动生成的行为:
| 成员函数 | 未声明时编译器行为 | 声明后是否还生成默认版本 |
|---|---|---|
| 默认构造函数 | 如果没有任何构造函数,自动生成 | 声明任何构造函数后不再生成 |
| 析构函数 | 自动生成(非虚) | 声明析构函数后不再生成 |
| 拷贝构造函数 | 自动生成 | 声明拷贝构造后不再生成 |
| 拷贝赋值运算符 | 自动生成 | 声明拷贝赋值后不再生成 |
| 移动构造/移动赋值 | 特定条件下自动生成 | 声明拷贝/析构相关函数后通常不再生成 |
这里最值得注意的隐藏逻辑是:一旦你声明了构造函数,默认构造函数就消失了。这意味着 Foo f; 这种最常见的实例化方式可能直接编译失败,除非你显式声明无参版本。这个设计初看起来很不友好,但细想是合理的——如果你定义了带参构造函数,说明对象必须依赖外部传参来正确初始化,编译器不应该默认假设"无参"也成立。
实际开发中,我见过大量因为默认构造函数"消失"而导致的编译错误。比如有人写了 class HttpConnection { public: HttpConnection(const std::string& host); },然后在某处写了 HttpConnection conn;,编译器果断报错。这不是语法问题,是你击穿了默认成员函数的基本规则。
另外一个容易忽略的点是:编译器生成的默认构造函数,只对类内的基础类型成员(int、double、指针等)不做初始化,它们的值是未定义的,而你自定义类型的成员变量(比如 std::string)会调用它们自己的默认构造。这就导致一个非常反直觉的现象:
cpp复制class Config {
public:
int timeout; // 未初始化,值为随机
std::string name; // 会调用 std::string 默认构造,为空字符串
};
timeout 的值取决于栈上残留的数据,可能恰好是 0,也可能是任意的整数。这种"偶尔对,偶尔错"的随机行为,比直接编译报错更可怕,因为它很难稳定复现,排查成本极高。所以我对所有写 C++ 的人的第一条建议都是:永远不要依赖编译器默认初始化内置类型成员,应该养成在类内直接给默认值的习惯:
cpp复制class Config {
public:
int timeout = 3000; // C++11 起支持类内初始化
std::string name;
};
这样一来,即使编译器生成了默认构造函数,它也会用这些默认值初始化成员,行为完全可预期。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 构造函数:对象初始化的唯一入口,初始化列表不只是风格问题
构造函数的作用是在对象进入其生命周期时完成初始化。这个"初始化"过程看似简单,但有不少人把"初始化"和"赋值"混为一谈,写出来的代码能跑,却暗藏性能问题和初始化顺序风险。
2.1 构造函数声明之后,默认构造就"没了"
前面提到过,只要定义了任何构造函数(哪怕是带默认参数的),编译器都不再自动生成无参版本。这是新手最常踩的坑。做一个简单的对比:
cpp复制// 情况一:未声明任何构造函数
class A {
public:
int x = 0;
};
A a; // 正确,编译器生成默认构造
// 情况二:声明带参构造
class B {
public:
B(int val) : x(val) {}
int x = 0;
};
B b; // 编译错误:不存在默认构造函数
解决办法要么显式声明 B() = default;,要么给带参构造的所有参数提供默认值。= default 是 C++11 引入的语法,它的含义是"我来声明这个函数,但让编译器实现它",既保留了手动声明的意图,又保留了编译器默认实现的效率。
需要说明的是,= default 生成的默认构造仍然遵循同样的规则:基础类型成员不会被初始化。如果你在类内给成员设了默认值,那么这些默认值生效;如果没设,成员依然是未定义状态。所以即便用了 = default,我还是建议把所有成员都通过类内初始化给出初值,形成双重保险。
2.2 初始化列表:值语义的初始化,不是赋值
很多从 Java 或 C# 转过来的开发者,写构造函数时习惯在函数体里赋值:
cpp复制class HttpRequest {
public:
HttpRequest(const std::string& url, int timeout) {
this->url = url; // 这是赋值,不是初始化
this->timeout = timeout; // 这里其实是先默认构造,再赋值
}
private:
std::string url;
int timeout;
};
这段代码功能上没问题,但背后发生了两件事:url 先被默认构造出空字符串,然后再被赋值为传入的值。而 timeout 因为是基础类型,没有明显的额外开销。问题在哪个层面体现?当你面对的是没有默认构造函数的成员类型时:
cpp复制class Logger {
public:
Logger(const char* tag) { /* ... */ }
};
class Service {
public:
Service() {
// 编译错误!logger 没有默认构造函数可用
logger = Logger("service");
}
private:
Logger logger;
};
这种编译器直接报错的情况,就是"赋值式写法"无法逾越的边界。解决办法就是在初始化列表里直接构造:
cpp复制class Service {
public:
Service() : logger("service") {}
private:
Logger logger;
};
初始化列表的真正价值有三层。第一层是语法正确性:对于引用类型成员、const 成员、无默认构造的类类型成员,初始化列表是唯一可行的初始化方式。第二层是性能:省去了"先默认构造再赋值"的中间步骤,直接从合适的构造函数初始化。第三层是语义清晰:读者一眼就能看出每个成员是如何初始化的。
引用和 const 成员必须在初始化列表完成的规则值得单独强调。比如:
cpp复制class Connection {
public:
Connection(int id) : m_id(id), m_stable("main") {}
private:
const int m_id; // const 成员只能在初始化列表
std::string& m_stable; // 引用成员只能在初始化列表
};
2.3 初始化顺序的陷阱:按照声明顺序,而不是列表顺序
初始化列表还有一个容易被忽略的规则:成员变量的初始化顺序只跟它们在类中声明的顺序有关,跟初始化列表里书写的顺序无关。这意味着如果你在列表里先写了一个后声明的成员,再用先声明的成员的值来初始化它,就会用到未初始化值。
来看一个具体的坑:
cpp复制class Order {
public:
Order(int id) : m_id(id), m_desc(std::to_string(m_id)) {}
private:
std::string m_desc; // 先声明
int m_id; // 后声明
};
这段代码里,m_desc 先被初始化,但此时 m_id 还没有被赋值为传入的 id(它处于未定义状态),所以 std::to_string(m_id) 不知道会转换成什么。正确做法是调整声明顺序,让 m_id 排在前面:
cpp复制class Order {
public:
Order(int id) : m_id(id), m_desc(std::to_string(m_id)) {}
private:
int m_id; // 先声明
std::string m_desc; // 后声明
};
这个坑的特点是:编译器不警告,运行结果随机,只有当你初始化列表的参数之间存在依赖关系时才暴露。所以我个人的经验是:初始化列表的书写顺序永远跟随成员声明顺序,不要在列表顺序上玩花样,否则下次别人维护你的代码时很可能会莫名踩坑。
2.4 explicit 关键字:防止隐式转换的防线
构造函数还有一个常被忽略的问题:如果不是 explicit,单参构造函数可以充当隐式类型转换的桥梁。这种隐式转换有时候很方便,比如 std::string 可以从 const char* 隐式构造,但在自定义类型里,隐式转换经常成为 bug 的温床。
cpp复制class Buffer {
public:
Buffer(int size) { /* 分配 size 字节 */ }
void Write(const Buffer& b) { /* ... */ }
};
void Process(Buffer b) {}
Process(1024); // 隐式创建 Buffer(1024),这合理吗?
如果 Buffer 表示一块缓冲区,那么 Process(1024) 会弹出一个 1024 字节的临时缓冲区再传入函数,代码意图可能完全不是这样。更隐蔽的场景是在重载决议中,隐式构造可能让本应报错的调用错误匹配到某一个重载版本。
解决办法是在构造函数前加 explicit:
cpp复制class Buffer {
public:
explicit Buffer(int size) { /* ... */ }
};
void Process(Buffer b) {}
Process(1024); // 编译错误:不能从 int 隐式转换到 Buffer
explicit 的过度使用确实会损失一点代码简洁性,但反正传入方可以显式 Buffer(1024),这点成本完全可控。我个人的原则是:所有单参构造函数默认加 explicit,除非你确实希望隐式转换(比如封装包装类型时)。这个习惯帮我挡住了大量"函数参数传错类型却编译通过"的诡异 bug。
3. 析构函数:RAII 的基石,也是最容易被漏写的函数
析构函数是类生命中最后一个被调用的函数,负责释放对象占用的资源。C++ 和 Java、Go 最根本的区别之一,就是 C++ 有确定性析构:对象离开作用域的那一刻,析构函数立即执行,资源随之释放。这种确定性带来了 RAII(Resource Acquisition Is Initialization)这种极具 C++ 特色的资源管理范式。
3.1 什么时候必须在析构函数里做清理
如果一个类只包含基础类型成员,或者它包含的成员(如 std::string、std::vector)自己管理自己的资源,那你可以不写析构函数——编译器生成的默认析构函数会逐个调用成员的析构函数,成员自己把资源释放干净。这是"零规则"(Rule of Zero)的核心思想。
但当一个类直接持有了非 RAII 的资源句柄时,析构函数就是必须亲自写的了。典型情况包括:
- 裸指针(指向
new出来的对象) - 文件句柄(
FILE*) - 网络 socket 描述符
- 互斥锁(手动的
lock/unlock)
cpp复制class FileHandler {
public:
explicit FileHandler(const char* path)
: fp(fopen(path, "r")) {
if (!fp) throw std::runtime_error("open failed");
}
~FileHandler() {
if (fp) fclose(fp);
}
private:
FILE* fp;
};
看到这里你可能会想:文件句柄难道不能用 RAII 包装吗?当然可以,现代 C++ 里你更应该使用 std::ifstream 而不是自研 FileHandler。但问题是,C++ 生态里有大量 C 风格的 API(如数据库驱动、图形库、硬件接口),它们返回裸指针或句柄,你不得不写自己的 RAII 包装。这种情况下,析构函数就是资源的"最后防线"。
3.2 析构函数不抛异常,哪怕抛也要吞掉
析构函数的执行时机往往伴随异常抛出。考虑一个场景:对象在栈展开(stack unwinding)过程中被析构,析构函数里抛出的异常会导致程序调用 std::terminate 直接崩溃。即便不在栈展开中,抛出异常也可能导致对象资源清理不完整。所以业界共识是:析构函数里不要抛异常,析构函数默认应该声明为 noexcept。C++11 起析构函数默认就是 noexcept 的,如果你试图在析构函数里 throw,编译器会直接报错或者调用 std::terminate。
如果析构过程中真的可能出错(比如关闭文件时刷新失败),比较稳妥的做法是记录日志,但不要让异常传播出去。你的程序可以容忍"关闭文件时刷盘失败"被记录,但不能容忍程序因此崩溃。
3.3 虚析构函数:基类指针 delete 子类对象时的"必要之恶"
当类被用作继承体系的基类时,析构函数应该声明为 virtual:
cpp复制class Base {
public:
virtual ~Base() = default; // 虚析构,推荐用 default
};
class Derived : public Base {
// ...
};
Base* p = new Derived;
delete p; // 如果 Base 析构不是 virtual,这里行为是未定义的
delete p 会依据 p 的静态类型(Base*)来决定调用哪个析构函数。如果 Base 的析构函数不是虚函数,编译器就只调用 Base::~Base(),而不会调用 Derived::~Derived(),Derived 持有的资源可能永远不会被释放,这就是典型的"基类析构不是虚函数导致的资源泄漏"。
但这里有一个需要澄清的误区:不是所有基类都需要虚析构。如果你的类不打算被多态删除(即不会出现 Base* p = new Derived; delete p;),那虚析构只会增加虚表指针的存储开销和虚函数调用的间接性。现代 C++ 推荐的原则是:
- 类用作多态基类:必须声明虚析构
- 类只是被继承做代码复用,但不会通过基类指针删除:析构可以非虚,但这种情况更推荐用组合或
final类 - 类完全不打算作为基类:析构非虚,类可声明为
final
3.4 析构顺序:成员先析构,派生类先析构
析构顺序与构造顺序完全相反。先看继承中的顺序:构造时先调用基类构造函数,再调用派生类构造函数;析构时先调用派生类析构函数,再调用基类析构函数。再看成员变量的顺序:构造时成员先构造,然后进入构造函数体;析构时先执行析构函数体,然后成员依次逆序析构。
这个顺序的合理性在于:派生类析构函数可能需要访问基类的资源,而基类析构函数需要保证派生类的资源已经清理完毕,否则会出现"析构了基类之后又试图访问基类成员"之类的非法操作。
4. 拷贝构造函数:默认生成的只是"会复制",不是"正确复制"
拷贝构造函数通过一个同类型的已有对象来构造新对象。如果你没有显式定义,编译器会生成一个逐成员拷贝的版本。问题在于:逐成员拷贝对基础类型是复制值,对指针是复制指针本身,也就是浅拷贝。一旦类里有指针成员,浅拷贝带来的就是双重释放和悬空指针的安全隐患。
4.1 浅拷贝的本质与深拷贝的解法
用一个最简单的例子来看浅拷贝的危害:
cpp复制class StringBuf {
public:
StringBuf(int cap) : cap_(cap), data_(new char[cap]) {}
~StringBuf() { delete[] data_; }
private:
int cap_;
char* data_;
};
StringBuf a(1024);
StringBuf b(a); // 默认拷贝构造:b.data_ 和 a.data_ 指向同一块内存
a 和 b 的析构函数会各自 delete[] data_,同一块内存被释放两次,这是未定义行为,通常会在运行时表现为堆损坏(heap corruption),或者程序崩溃。就算没有立刻崩溃,当你在某个分支修改了 b.data_[0],a.data_[0] 也跟着变,逻辑上完全不符合"拷贝"的语义。
正确做法是实现深拷贝——为新的对象分配独立的内存,并复制数据内容:
cpp复制class StringBuf {
public:
StringBuf(const StringBuf& other)
: cap_(other.cap_), data_(new char[other.cap_]) {
std::copy(other.data_, other.data_ + cap_, data_);
}
// ...
};
深拷贝的代价是每次拷贝都需要一次堆分配和数据复制,性能开销显著。因此在实际项目中,你经常看到的是另一种方案:禁止拷贝。比如 std::unique_ptr 就是典型的禁止拷贝的类型——一个资源只属于一个所有者,要传递所有权就使用移动语义或者 std::move。
4.2 拷贝构造函数的参数为什么必须是引用
这是 C++ 初学者最困惑的问题之一:为什么拷贝构造函数的参数必须是 const T&,不能用 T?答案不是风格偏好,而是语法规则。
如果拷贝构造的参数是 T(按值传参),那么在调用拷贝构造时,需要先拷贝实参到形参,而这个"拷贝"行为本身又会调用拷贝构造函数,于是无限递归,编译器直接拒绝这种写法:
cpp复制// 编译错误:拷贝构造函数的参数必须是引用
StringBuf(StringBuf other);
按值传递需要把它"复制"一份出来,怎么复制?再调用拷贝构造。这就成了"鸡生蛋"问题。所以 C++ 标准强制规定:拷贝构造函数第一个参数必须是 T&、const T&、volatile T& 或 const volatile T&。实践中一律写 const T&,原因有两点:一是通过 const 约束避免修改源对象,二是确保临时对象也可以被 copy(临时对象不能绑定到非 const 左值引用)。
4.3 拷贝赋值运算符与拷贝构造的区别和共同问题
拷贝赋值运算符处理的对象是"已存在的对象被赋值为另一个对象的值"。它的原型如下:
cpp复制StringBuf& operator=(const StringBuf& other);
跟拷贝构造相比,拷贝赋值多了一个核心难点:需要处理旧资源。如果类管理着资源,赋值时必须先把原来的资源释放掉,再拷贝新的。如果顺序搞反,可能出现"先拷贝发现空间不够,但旧空间还没释放"或者"left 和 right 是同一个对象"(自赋值)的问题。
经典的"拷贝并交换"(copy-and-swap)惯用法可以优雅地解决自赋值和异常安全问题。核心思路是:先拷贝构造一个临时对象,然后把这个临时对象与当前对象交换,临时对象析构时自动释放旧资源。
cpp复制class StringBuf {
public:
StringBuf(const StringBuf& other)
: cap_(other.cap_), data_(new char[other.cap_]) {
std::copy(other.data_, other.data_ + cap_, data_);
}
StringBuf& operator=(const StringBuf& other) {
if (this != &other) { // 自赋值检查
delete[] data_; // 释放旧资源
cap_ = other.cap_;
data_ = new char[other.cap_];
std::copy(other.data_, other.data_ + cap_, data_);
}
return *this;
}
// ...
};
这里的 if (this != &other) 是处理自赋值的经典写法。虽然上面这段代码逻辑正确,但它存在一个问题:如果你先 delete[] data_; 再 new char[other.cap_],当内存分配失败抛出异常时,当前对象已经处于没有 data_ 的状态,可能无法恢复。这违反了强异常安全的承诺。
用 copy-and-swap 就不存在这个问题:
cpp复制class StringBuf {
public:
StringBuf(const StringBuf& other)
: cap_(other.cap_), data_(new char[other.cap_]) {
std::copy(other.data_, other.data_ + cap_, data_);
}
void swap(StringBuf& other) noexcept {
std::swap(cap_, other.cap_);
std::swap(data_, other.data_);
}
StringBuf& operator=(StringBuf other) { // 传值,不是 const&
swap(other); // 异常安全,自动处理自赋值
return *this;
}
};
注意这里 operator= 的参数不是引用,而是 StringBuf other。当传入实参时,编译器根据实参类型选择调用拷贝构造或移动构造来创建 other,然后 swap 把当前对象的旧资源换进 other,other 在离开作用域时自动析构,释放旧资源。整个过程一气呵成,自赋值也天然安全:如果是 a = a,other 是 a 的拷贝,交换后再析构,最终 a 自身状态不变。
这种写法的精妙之处在于把"拷贝赋值"拆成了"拷贝构造 + 交换"两步,而这两步都可以单独保证异常安全。这也是我在生产代码里最常使用的方式,除非性能分析证明有瓶颈,否则基本上是最好的默认实现。
5. 三/五法则:一个类应该具备的完整函数族
谈完拷贝构造和拷贝赋值,现在可以正式介绍 C++ 的"三法则"(Rule of Three)和"五法则"(Rule of Five)。这两个法则本质上是在回答一个问题:如果你需要自定义析构函数、拷贝构造或拷贝赋值中的任何一个,为什么你应该认真考虑三个或五个都定义/删除。
5.1 三法则的推导逻辑
三法则说的是:如果你需要自定义析构函数,那么你几乎一定需要自定义拷贝构造函数和拷贝赋值运算符。道理很简单——需要自定义析构,说明类里管理着非 RAII 资源(裸指针、句柄等)。这种类通过默认的浅拷贝复制时,会得到多个对象共享同一份资源,最终在多个析构处重复释放。要么实现深拷贝(自定义拷贝构造和拷贝赋值),要么禁止拷贝(把它们声明为删除或私有)。
如果你定义了拷贝构造,也建议同时定义拷贝赋值和析构。原因是:这三个函数面对的是同一个资源管理问题,定义其中一个而不定义另外两个,会让类处于一种"资源管理逻辑各自独立"的危险状态。
5.2 五法则与移动语义
C++11 引入了移动构造和移动赋值,三法则升级为五法则。移动语义解决的问题是:当源对象是"将亡值"(rvalue)时,没有必要做深拷贝,直接把资源"偷"过来,再把源对象的指针置空即可。
cpp复制class StringBuf {
public:
// 移动构造
StringBuf(StringBuf&& other) noexcept
: cap_(other.cap_), data_(other.data_) {
other.data_ = nullptr;
other.cap_ = 0;
}
// 移动赋值
StringBuf& operator=(StringBuf&& other) noexcept {
if (this != &other) {
delete[] data_;
cap_ = other.cap_;
data_ = other.data_;
other.data_ = nullptr;
other.cap_ = 0;
}
return *this;
}
// ...
};
这里的 noexcept 非常关键。移动操作通常不会抛出异常,把它标记为 noexcept 可以让 std::vector 等容器在扩容时放心地移动元素而不是拷贝元素。如果移动构造不是 noexcept,std::vector 扩容时为了保证强异常安全,会退而求其次采用拷贝构造,而拷贝构造通常慢得多。所以 "移动操作加 noexcept" 不仅是一种规范,还直接影响了容器性能。
五法则的标准建议是:如果你自定义了析构、拷贝构造、拷贝赋值、移动构造、移动赋值中的任何一个,通常应该认真定义全部五个,或者通过 = default / = delete 明确声明对它们的处理意图。
5.3 Rule of Zero:优先使用 RAII 类型成员
五法则的应用范围其实在日渐收窄。因为当类的成员都使用 RAII 类型(如 std::string、std::vector、std::unique_ptr)时,编译器默认生成的析构、拷贝、移动行为完全正确,你根本不需要自定义任何成员函数。这就是 Rule of Zero:一个不定义任何特殊成员函数的类,反而是更符合现代 C++ 资源管理思想的设计。
cpp复制class User {
public:
std::string name;
std::vector<int> scores;
// 不需要自定义任何特殊成员函数
};
Rule of Zero 的适用条件是:类成员本身正确地管理了自己的资源。注意 std::unique_ptr 是一个有趣的特例——它自身是不可拷贝的 RAII 类型,所以一个类如果持有 std::unique_ptr 成员,编译器生成的拷贝构造和拷贝赋值会被隐式删除。如果你需要复制这个类,就得自己处理深层拷贝;如果不需要复制,那 = default 移动操作通常是正确选择。
5.4 具体项目里如何快速判断该类是否需要自定义特殊成员函数
我总结了一组判断顺序,可以用来快速评估一个自定义类型:
- 类里是否有裸指针、文件句柄、非 RAII 资源句柄?
- 没有:遵循 Rule of Zero,什么都不写。
- 有:进入下一步。
- 资源是否允许被复制?
- 允许:自定义拷贝构造、拷贝赋值,再加上析构函数。
- 不允许:删除拷贝构造和拷贝赋值(
= delete),考虑实现移动构造和移动赋值。
- 这个类会不会被继承,并且通过基类指针删除?
- 会:析构函数加
virtual。 - 不会:析构保持非虚,优先
= default。
- 会:析构函数加
这个判断流程看似简单,但它覆盖了我在实际代码审查中最常遇到的问题。
6. 编译期帮你兜底的手段:delete 与 =default 的正确打开方式
现代 C++ 里有一个既简单又强大的工具,就是 = delete 和 = default。它们不是性能优化手段,而是语义声明工具,让编译器替你检查错误。
6.1 禁止拷贝:how to say "不可复制" 的两种写法
早年间禁止拷贝的标准做法是把拷贝构造和拷贝赋值声明为私有且不实现,利用"成员函数声明但未定义,外部调用会链接错误"这一机制,在编译/链接期拦截复制行为。但这种方式错误信息不直观,链接器报错通常比编译器报错更难理解,而且私有声明在友元类面前形同虚设。
C++11 之后标准做法就是 = delete:
cpp复制class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
调用被删除的函数时,编译器直接给出明确的诊断信息:使用的已删除函数。还有一种更省事的写法是继承一个 NonCopyable 基类,但我觉得显式 = delete 更直观,可读性更强,也避免了多继承引入的复杂布局问题。
= delete 还有一个超出"禁止拷贝"的用法:删除 new 操作符,禁止在堆上创建对象:
cpp复制class StackOnly {
public:
void* operator new(size_t) = delete;
void* operator new[](size_t) = delete;
};
这样 new StackOnly 在编译期就被拒绝,对象只能在栈上创建。
6.2 = default 与空函数体的区别
很多人以为 ~StringBuf() {} 和 ~StringBuf() = default; 是一样的,其实有本质区别:
cpp复制class A {
public:
~A() {} // 自定义空析构函数
};
class B {
public:
~B() = default; // 编译器生成默认析构
};
自定义空析构函数会使类拥有一个唯一的主体,编译器不再生成移动构造函数和移动赋值运算符。而 = default 告知编译器"你来实现它",移动操作仍然会被生成。这在实践中影响很大:
A类作为成员被放入std::vector等容器时,如果容器需要移动操作,A可能退化到使用拷贝,性能下降。A类如果持有只允许移动的成员(如std::unique_ptr),自定义空析构会导致移动操作被抑制,而拷贝又是删除的,最终这个类无法放入需要移动语义的容器中。
所以我的建议非常明确:析构函数如果不需要自定义清理逻辑,直接 = default,永远不要写空函数体。这个习惯可以避免大量"莫名其妙无法 move"的派生问题。
6.3 移动后置空的必要性:悬空指针的根源
实现移动构造和移动赋值时,一个常见的疏漏是忘记把源对象的指针置空。如果只把指针"偷"过来,源对象析构时会再次 delete 那块内存,发生双重释放:
cpp复制StringBuf::StringBuf(StringBuf&& other) noexcept
: cap_(other.cap_), data_(other.data_) {
// 如果没有 other.data_ = nullptr; 这里就会出问题
other.data_ = nullptr;
other.cap_ = 0;
}
把 other.data_ 置空后,other 的析构函数执行 delete[](nullptr) 是安全的(C++ 标准规定 delete 空指针是合法操作)。这也是移动操作的"有效但未指定状态"要求的一种体现——被移动后的对象必须仍然可以析构,也必须可以被重新赋值。
7. 实战中的默认成员函数排查清单
最后分享一份我平时做代码审查时用来检查默认成员函数问题的清单。这些条目都是从真实的生产事故里提炼出来的,覆盖面比较广,你可以直接拿来做自查:
- 类里有裸指针或资源句柄,但没定义析构函数(大概率内存泄漏或资源泄漏)。
- 类里定义了析构函数,但拷贝构造和拷贝赋值仍然是默认的(同类资源,两个对象共享,析构时双重释放)。
- 拷贝构造参数写成了按值传递(编译直接报错,这个一般不会漏)。
- 构造函数用赋值替代初始化列表,导致额外开销或直接编译失败(尤其面对无默认构造的成员)。
- 初始化列表顺序与成员声明顺序不一致,参数间存在依赖(运行结果随机)。
- 单参构造函数未加 explicit,导致意外隐式转换。
- 基类析构函数非 virtual,但类解锁多态删除。
- move 操作没有置空源对象,导致双重释放。
- 移动操作未声明 noexcept,容器扩容性能下降。
- 类持有
std::unique_ptr却期望编译器自动生成拷贝构造(这会静默变成删除,编译期可发现,但报错信息可能不直观)。 - 定义了空析构
{}而不是= default,抑制了移动操作。
把这个清单过一遍,能规避掉绝大多数与默认成员函数相关的运行时崩溃和内存问题。C++ 的默认成员函数看似"默认",实际上每一环都关系到资源所有权和生命周期管理。理解生成规则背后"谁拥有资源、谁释放资源"这条主线,比记住一堆语法细节重要得多。
