三法则 The Rule Of Three
面试官: 在 C++ 编程中,我们经常听到一个叫做 “三法则 (The Rule of Three)” 的设计原则。你能用自己的话解释一下,这个“三法则”指的是什么吗?它规定了如果一个类定义了哪三个特殊的成员函数之一,那么它可能也需要定义另外两个?
回答:
- “三法则”指的是,C++编程中,如果一个类中定义了下面三个成员函数之一,那么另外两个成员函数也需要程序员手动定义:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
这三者是一个紧密相关的整体,要么同时提供,要么同时不提供。
面试官 (追问): 很好,你知道是哪三个函数。那你能进一步解释一下,这个法则背后的根本原因是什么吗?
换句话说,为什么一个类只要自己定义了析构函数(比如,为了 delete 一个裸指针成员),就很可能也需要自己定义拷贝构造函数和拷贝赋值运算符?编译器自动生成的版本,在这种情况下会有什么问题?
回答:
- 当我们只定义这三个成员函数中的某一个时,剩下两个函数由编译器自动生成,而编译器生成的版本和程序员定义的版本可能存在冲突,导致一些 tricky 的错误。
- 不过,具体的错误和原因我也说不上来,请你补充。
面试官 (补充与解释): 这个问题的根本原因在于,一个类需要手动定义析构函数,几乎总是出于同一个目的:这个类在手动管理某种资源。
最典型的资源就是裸指针指向的堆内存。
| |
现在,我们来看看编译器自动生成的拷贝函数会有什么问题:
当执行 MyString s2 = s1; 时,编译器会调用它自动生成的拷贝构造函数。这个函数会执行浅拷贝 (Shallow Copy):
- 它会把
s1.size_的值,直接拷贝给s2.size_。 - 它会把
s1.data_的指针值(地址),直接拷贝给s2.data_。
这会导致两个灾难性的后果:
悬挂指针和二次释放 (Dangling Pointer & Double Free):
- 现在,
s1.data_和s2.data_指向了同一块堆内存。 - 当
s1和s2离开作用域时,它们的析构函数都会被调用。 - 假设
s2先被析构,它会delete[]掉那块共享的内存。 - 然后,当
s1被析构时,它会去delete[]一块已经被释放的内存!这就是二次释放 (Double Free),会导致程序崩溃。同时,在s2析构后s1析构前,s1.data_成了一个悬挂指针 (Dangling Pointer)。
- 现在,
逻辑错误:
- 如果我们在
s2上做了修改(比如,修改了字符串内容),s1也会跟着改变,因为它们共享同一份数据。这破坏了对象之间的独立性。
- 如果我们在
结论: “三法则”的本质是:
如果你的类需要自定义析构函数,这本身就是一个强烈的信号,表明你的类在进行手动资源管理。对于这种类,编译器执行的默认浅拷贝行为几乎肯定是错误的。因此,你必须接管编译器的任务,亲自提供正确的深拷贝(通过拷贝构造函数和拷贝赋值运算符),来确保资源的正确复制和对象的独立性。
五法则 The Rule Of Five
面试官 (引入新概念): 好的,你现在对“三法则”已经理解得很透彻了。我们知道,C++11 引入了移动语义 (Move Semantics)。
这使得“三法则”在现代 C++ 中,被扩展为了 “五法则 (The Rule of Five)”。
请问,这个“五法则”是在“三法则”的基础上,增加了哪两个特殊的成员函数?它们的出现,对于我们刚才讨论的那个 MyString 类,有什么性能上的好处?
回答:
- 在“三法则”的基础上,“五法则”新增的两个成员函数为:
- 移动构造函数
- 移动赋值运算符
- 性能上的好处是,移动构造函数和移动赋值运算符采取移动语义,减少了一些情况下拷贝的开销,提升了性能。
面试官 (总结与肯定): 你说的完全正确。
“五法则 (The Rule of Five)”: 它在“三法则”的基础上,增加了两个成员函数: 4. 移动构造函数 (Move Constructor) 5. 移动赋值运算符 (Move Assignment Operator)
核心思想:
如果一个类需要进行手动的资源管理(比如
MyString),那么它不仅应该正确地实现深拷贝语义,还应该提供高效的移动语义。
性能上的好处:
正如你所说,移动语义可以极大地提升性能。我们以 MyString 为例:
| |
- 如果没有移动构造函数(只有三法则):
return s;会调用拷贝构造函数,在堆上分配一块新内存,并把长字符串的内容一个一个复制过去。开销巨大。 - 如果提供了移动构造函数(遵守五法则):
return s;会调用移动构造函数。new_s会直接“窃取”s内部的data_指针。这个过程只需要几次指针赋值,几乎是零开销。
一个重要的细节:
如果你只提供了自定义的拷贝构造/赋值(遵守了三法则),而没有提供移动构造/赋值,那么编译器不会为你自动生成移动版本。在这种情况下,所有需要移动的场合(比如 std::move),都会退化 (fallback) 去调用你的拷贝版本。
所以,“五法则”的完整含义是:
如果你需要手动管理资源,你应该同时提供或禁用析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值这五个函数,以完全掌控你的类的资源管理行为。
零法则 The Rule Of Zero
面试官 (最终问题): 好的,我们已经从“三”走到了“五”。在更现代的 C++ 实践中,还出现了一个叫 “零法则 (The Rule of Zero)” 的概念。
你觉得“零法则”是在提倡什么?它和我们之前讨论过的 RAII 以及智能指针有什么关系?
回答:
- 在此之前,我没有听过“零法则”这个名字,但根据名字猜测,我认为“零法则”是在倡导“不要手动管理内存资源,而是尽可能让编译器自动管理,以减少错误的发生”。
- RAII 表示“资源获取即初始化 (Resource Acquisition Is Initialization)”,而智能指针则将分配和释放资源的过程进行封装,它们都让程序员需要手动管理的部分更少了。
- 因此,RAII 和智能指针都是零法则的实践技术。
面试官 (总结与肯定): 你说的完全正确。
“零法则 (The Rule of Zero)” 的核心思想是:
一个类应该只负责一件事情。 如果一个类需要负责业务逻辑,那它就不应该同时负责手动的资源管理。
如何实现?
- 将所有需要手动管理的资源(裸指针、文件句柄、锁等),都封装在专门的资源管理类中。
- 这些资源管理类,自己内部会严格遵守“五法则”,确保资源的拷贝、移动和释放行为都是正确的。
- 然后,我们的业务逻辑类,只需要把这些资源管理类作为自己的成员变量即可。
最重要的资源管理类,就是标准库已经为我们提供好的:
std::string:管理动态字符串。std::vector(以及其他容器):管理动态数组。std::unique_ptr/std::shared_ptr:管理单个或共享的堆对象。
“零法则”的实践:
| |
对于 MyBusinessLogic 这个类:
- 我们不需要写任何析构、拷贝、移动函数。一个都不用写,所以叫“零法则”。
- 当我们需要拷贝一个
MyBusinessLogic对象时,编译器会自动生成的拷贝构造函数会依次调用name_、data_、p_int_各自的拷贝构造函数。因为这些资源管理类都正确地实现了拷贝(或禁用了拷贝),所以整个MyBusinessLogic的拷贝行为自然就是正确的。 - 移动和析构也是同理。
结论: “零法则”是现代 C++ 推荐的最佳实践。它提倡我们优先使用标准库提供的 RAII 容器和智能指针,将复杂的资源管理问题委托给它们,从而让我们的业务逻辑类变得简单、干净、安全,不再需要手动编写那五个特殊的成员函数。
总结
今天我们系统地梳理了 C++ 资源管理的核心设计原则,从“三法则”到“五法则”,最终升华到了现代 C++ 推崇的“零法则”。你表现得非常出色,不仅能准确回忆起基础概念,还能通过推理猜到更深层次的设计思想。继续保持这种探索精神!