《Effective C++》读书总结(三)
Author: Once Day Date: 2026年1月5日
一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦...
漫漫长路,有人对你微笑过嘛...
全系列文章可参考专栏: C语言_Once-Day的博客-CSDN博客
参考文章:
文章目录
- [《Effective C++》读书总结(三)](#《Effective C++》读书总结(三))
-
-
-
- [5. 实现](#5. 实现)
-
- [5.1 尽可能推迟变量定义](#5.1 尽可能推迟变量定义)
- [5.2 减少类型转换的使用](#5.2 减少类型转换的使用)
- [5.3 避免返回指向对象内部成员的句柄](#5.3 避免返回指向对象内部成员的句柄)
- [5.4 保证代码的异常安全性](#5.4 保证代码的异常安全性)
- [5.5 透彻了解inline函数](#5.5 透彻了解inline函数)
- [5.6 最小化文件之间的编译依赖关系](#5.6 最小化文件之间的编译依赖关系)
- [6. 继承与面对对象](#6. 继承与面对对象)
-
- [6.1 让public继承塑造出is-a关系](#6.1 让public继承塑造出is-a关系)
- [6.2 避免继承中发生的名称覆盖](#6.2 避免继承中发生的名称覆盖)
- [6.3 区分接口继承和实现继承](#6.3 区分接口继承和实现继承)
- [6.4 考虑virtual函数的替代方法](#6.4 考虑virtual函数的替代方法)
- [6.5 不要重写继承来的非虚函数](#6.5 不要重写继承来的非虚函数)
- [6.6 不要重定义通过继承得到的默认参数值](#6.6 不要重定义通过继承得到的默认参数值)
- [6.7 通过组合塑造has-a或use-a关系](#6.7 通过组合塑造has-a或use-a关系)
- [6.8 慎用private继承](#6.8 慎用private继承)
- [6.9 慎用多继承](#6.9 慎用多继承)
-
-
5. 实现
5.1 尽可能推迟变量定义
在 C/C++ 中,变量定义并非"零成本"的语法装饰,而是一次具有语义和运行时含义的行为。尤其在 C++ 里,变量一旦被定义,构造函数便立即执行;当其作用域结束时,析构函数也必然运行。因此,变量定义的出现时间,直接决定了对象生命周期的起止边界,也间接影响了程序的性能特征与可理解性。Effective C++ 提倡"尽可能延后变量定义",本质上是在鼓励程序员更精确地控制对象生命周期。
从可读性的角度看,过早定义变量往往会拉长变量的"心理作用域"。读代码的人需要在较长的代码路径中记住该变量的存在及其含义,而这些信息在真正使用前并无价值。将变量定义放在首次使用点附近,可以使变量的意图更加明确,减少上下文切换的负担。这种写法更贴近"声明即含义"的原则,使代码在逻辑上呈现出自上而下、逐步展开的结构。
cpp
// 不推荐
Widget w;
do_something();
do_something_else();
w = create_widget();
// 推荐
do_something();
do_something_else();
Widget w = create_widget();
从效率角度看,延后定义往往意味着避免不必要的构造和析构成本。若一个变量在某些控制路径上根本不会被使用,提前定义就会造成"白白构造、立刻销毁"的浪费。对于拥有非平凡构造函数的对象(如管理资源、加锁、分配内存的类型),这种浪费在高频代码路径中尤其明显。推迟定义可以让对象只在真正需要时才存在,从而减少无意义的生命周期。
更进一步,Effective C++ 强调"定义时即初始化",而不是"先定义、后赋值"。这是因为初始化直接调用构造函数,而赋值通常意味着先默认构造、再执行赋值操作,两者在语义和性能上并不等价。尤其是对于自定义类型,赋值可能涉及资源释放与重新分配,其成本往往高于一次恰当的构造。
cpp
// 次优:先默认构造,再赋值
std::string s;
s = read_value();
// 更优:一次性完成构造
std::string s = read_value();
在循环和条件分支中,这一原则同样适用。将变量定义放入最小必要作用域,可以避免跨迭代或跨分支的无谓复用,也能防止逻辑错误被"历史状态"掩盖。现代 C++ 编译器虽然擅长优化,但它们无法替程序员决定对象在语义上是否"应该存在"。因此,延后变量定义不仅是性能微调,更是一种表达设计意图的方式。
5.2 减少类型转换的使用
在 C++ 语境下,类型转换并不是"中性操作",它往往意味着对类型系统约束的突破。Effective C++ 将"尽量少做转型动作"作为一条重要经验,正是因为转型通常暗示着设计上的不协调:要么接口抽象不充分,要么类型关系表达不清晰。频繁出现的转型代码,往往是系统演化过程中结构失衡的信号,而非单纯的语法选择问题。
从安全性与可维护性角度看,旧式 C 风格转型 (T)expr 或 T(expr) 最大的问题在于"过于宽松"。它们可能在一次书写中同时尝试 const_cast、static_cast 甚至 reinterpret_cast,却不向读者暴露任何意图。这种模糊性使得代码审查和问题定位变得困难,也增加了引入未定义行为的风险。因此,在现代 C++ 中,旧式转型应被视为历史遗留手段,而非推荐实践。
cpp
// 风险不透明
Derived* d = (Derived*)base;
// 意图明确
Derived* d = static_cast<Derived*>(base);
在新式转型中,每一种 cast 都承担着明确的语义责任。static_cast 表达的是编译期可验证的、结构上合理的转换;const_cast 明确表态在修改 cv 限定;reinterpret_cast 则几乎等同于"我知道自己在做危险的事"。这种语义分离并非繁琐,而是帮助程序员在阅读和维护时快速判断代码风险等级,也是工具和静态分析器能够介入的前提。
效率层面上,dynamic_cast 尤其需要谨慎对待。它依赖 RTTI,通常意味着运行期类型检查,在性能敏感路径中代价不可忽视。更重要的是,dynamic_cast 的频繁出现,往往暴露出设计未能充分利用多态:如果必须在外部判断对象的真实类型,说明虚函数接口可能缺失或抽象层次不合理。在良好设计的多态体系中,行为应通过虚函数分派完成,而不是通过转型后再"区别对待"。
cpp
// 设计味道不佳
if (auto* d = dynamic_cast<Derived*>(b)) {
d->special();
}
// 更好的设计:多态
b->do_special();
当类型转换确实不可避免时,一个重要的工程技巧是将其封装在函数或类内部。这样做的好处在于,转型被集中管理,调用者只面对稳定的接口,而不需要在业务代码中重复承担类型假设。一旦类型体系发生变化,修改点也被局限在少数位置,从而显著降低维护成本和错误传播范围。
5.3 避免返回指向对象内部成员的句柄
在 C++ 的对象模型中,handles(引用、指针、迭代器)本质上是"越过对象边界的通行证"。当一个类将指向内部成员的 handle 暴露给外部时,等于让调用者直接参与内部状态的管理。Effective C++ 提醒我们尽量避免这种设计,其核心目的在于维护封装边界,使对象的表示形式与对外语义保持清晰分离。
从封装性的角度看,返回内部成员的指针或引用,会将类的实现细节固化为接口的一部分。外部代码一旦依赖这些细节,类就很难在不破坏用户代码的前提下进行演进。例如,将内部 std::vector 改为 std::deque,在逻辑上可能完全等价,却会因为迭代器或引用语义变化而引发连锁问题。避免返回 handles,可以为实现保留充分的重构空间。
cpp
// 不推荐:泄露内部表示
const std::vector<int>& values() const;
// 更稳健:返回值或提供受控访问
std::vector<int> values() const;
int value_at(size_t i) const;
handles 还会削弱 const 成员函数的语义可信度。一个 const 成员函数如果返回的是指向内部数据的指针或引用,那么调用者仍有可能通过该 handle 修改对象状态,或者至少观察到对象随时间变化的内部细节。这使得 const 不再代表"逻辑不可变",而仅仅是"当前函数不写成员",从而降低了接口在阅读和推理时的价值。
更严重的风险来自虚吊号码牌(dangling handles)。当对象被销毁、移动,或其内部容器发生扩容、重排时,之前返回的指针、引用或迭代器可能瞬间失效。这类问题通常不会在编译期或运行初期暴露,而是以未定义行为的形式潜伏在系统中,直到某个偶然的执行路径将其触发,给调试和定位带来极大困难。
cpp
auto it = obj.begin();
obj.modify(); // 内部结构变化
use(*it); // it 可能已经悬空
当然,在性能敏感或底层组件中,完全避免 handles 并不现实。但这应当是经过权衡后的显式选择,而非默认设计。常见的折中方式包括:返回按值构造的结果对象、提供只读快照、或将 handles 的生命周期限制在明确的作用域内,并通过文档或类型系统清晰表达约束。
5.4 保证代码的异常安全性
在 C++ 中讨论"异常安全",本质上是在讨论失败路径是否同样被认真设计过。Effective C++ 指出,为异常安全而努力是值得的,因为异常并非罕见事件,而是系统在资源不足、逻辑分支复杂或组件边界交错时的常态表现。一个异常安全的函数,即使在中途失败,也不会泄漏资源、破坏不变式或留下半完成的对象状态,这直接决定了系统的健壮性上限。
异常安全的最低要求是基本保证。提供基本保证的函数承诺:一旦异常发生,程序中的对象仍然处于"有效但未必如初"的状态,不变式得以维持,资源不会泄漏。这类函数允许数据发生变化,但变化后的状态必须是可继续使用、可析构、可再次调用成员函数的。基本保证并不追求回滚,而是确保系统不会进入不可恢复的混乱状态,它是现实工程中最常见、也最具性价比的一种保证。
cpp
void push_back_safe(std::vector<int>& v, int x); // 异常后 v 仍然有效
在此之上是强烈保证(strong guarantee),它要求函数具有"事务性"语义:要么完全成功,要么在异常发生时让程序状态恢复到调用前。强烈保证极大地简化了调用者的推理模型,因为失败不会留下任何可观察的副作用。经典实现手法是 copy-and-swap:先在临时对象上完成所有可能失败的操作,只有在全部成功后,才通过不抛异常的交换操作提交结果。
cpp
void set_value(T newVal) {
T temp(newVal); // 可能抛异常
swap(value, temp); // 不抛异常
}
然而,强烈保证并非总是可行或合理的。一方面,并非所有类型都支持高效拷贝;另一方面,在涉及 I/O、全局状态或跨线程交互时,"回到原点"往往既昂贵又语义不清。因此,追求强烈保证应当基于接口语义和性能目标,而不是教条式的要求。在很多场景下,明确说明仅提供基本保证,反而是一种更诚实、也更可维护的设计。
第三种级别是不抛异常保证(noexcept guarantee)。这类函数承诺永远不会抛出异常,它们是异常安全体系的地基。析构函数、swap、移动操作以及对内置类型的操作,通常都应提供此保证。noexcept 不仅是语义承诺,还会影响编译器优化策略和标准库行为(例如容器在扩容时是否选择移动还是拷贝)。
最后需要强调的是,异常安全保证具有"短板效应"。一个函数能提供的最高保证,通常不可能超过它所调用函数中最弱的那个级别。如果底层操作只能提供基本保证,那么上层接口几乎不可能对外承诺强烈保证。这要求设计者在接口边界上清楚标注异常安全级别,并在关键路径上优先选择异常安全语义更强的构件。
5.5 透彻了解inline函数
在 C++ 中,inlining 并不是一种简单的"性能开关",而是一种带有明显取舍的编译期优化策略。Effective C++ 强调要"透彻了解 inlining 的里里外外",正是因为 inline 的收益和代价往往同时存在,而且是否真正生效,最终并不由程序员而是由编译器决定。inline 更像是一种"建议",而非强制命令。
首先需要明确的是,inlining 在绝大多数 C++ 程序中发生于编译期。当编译器决定对某个函数进行 inline 时,它会用函数体直接替换调用点,从而消除函数调用开销,并为进一步优化(如常量传播、寄存器分配)创造条件。然而,即使函数被声明为 inline,编译器也完全可以拒绝这一请求;反过来,即便未写 inline,编译器也可能在优化级别较高时自动内联。
编译器对 inline 的判断往往非常现实:函数是否足够简单。包含循环、递归、复杂控制流的函数,通常会被拒绝内联;而虚函数调用更是内联的"重灾区"。由于虚函数的调用目标在运行期才确定,除非编译器能够进行去虚化(如通过最终类、final、或全程序分析),否则 inline 几乎不可能发生。因此,在多态接口上期待 inline,往往是一种不切实际的假设。
cpp
inline int add(int a, int b) { return a + b; } // 典型可内联
virtual int f(); // 大多数情况下无法内联
inline 带来的另一面是代码膨胀。当一个函数被频繁调用且被大量内联展开时,最终生成的目标代码体积可能显著增加。这不仅会影响指令缓存命中率,还可能导致整体性能下降,尤其是在现代 CPU 对 cache 极度敏感的场景中。因此,"更少的函数调用"并不总是等价于"更快的程序",inline 的收益必须与代码尺寸成本一起评估。
此外,inline 函数在库演化方面具有天然劣势。由于 inline 函数的定义通常位于头文件中,调用点在编译期就已经展开,一旦函数实现发生变化,所有使用该头文件的代码都必须重新编译。对于二进制库而言,这意味着 inline 函数无法像普通非 inline 函数那样通过升级库实现来修复 bug 或优化性能,这在 ABI 稳定性要求较高的场景中尤为重要。
需要特别指出的是,模板出现在头文件中,并不构成将其声明为 inline 的理由。模板必须在使用点可见,是为了实例化而非为了内联。是否 inline,仍应由函数的复杂度、调用频率和性能敏感度来决定。盲目地为模板函数加上 inline,往往只会增加编译时间和代码体积,而不会带来可观的运行时收益。
5.6 最小化文件之间的编译依赖关系
在大型 C++ 工程中,编译依存关系往往比运行期依赖更具破坏性:一个头文件的细微改动,可能导致成百上千个源文件被重新编译。Effective C++ 提出的核心思想是:能在声明层面解决的问题,就不要在定义层面解决。其本质并非语法技巧,而是对"稳定接口"与"变化实现"的系统性隔离,这一思想在现代 C++ 架构设计中依然成立。
首先,从对象使用方式看,对象值语义会放大编译依赖。当头文件中直接包含某个类的定义并以内嵌对象形式使用时,任何对该类成员的改动都会迫使依赖者重新编译。相反,若使用对象指针或引用,仅需类的前向声明即可满足编译要求,依赖关系被压缩到最小边界。例如:
cpp
// header.h
class Widget; // 前向声明
class Gadget {
public:
void use(Widget& w);
private:
Widget* pw; // 仅依赖声明
};
这种设计不仅减少了头文件包含,还明确表达了"Gadget 并不拥有 Widget 的定义细节",在语义层面也更利于解耦。
进一步推演这一原则,就形成了 Handle 类(Pimpl 惯用法)。对外暴露的类只包含一个指向实现的指针,真正的成员数据和算法细节全部隐藏在实现类中,并定义于源文件。这种方式的关键价值不在于"隐藏实现",而在于稳定 ABI 与最小化重编译范围。修改实现类时,几乎不会触发使用者的重新编译,这在大型库或二进制分发场景中尤为重要。
cpp
// widget.h
class WidgetImpl;
class Widget {
public:
Widget();
~Widget();
void draw() const;
private:
WidgetImpl* impl; // Handle
};
与 Handle 类并列的,是 Interface 类(纯抽象基类)。Interface 类不关心实现对象的布局,仅定义可调用行为,使用者通过指针或引用与之交互。这种方式不仅降低编译依赖,还自然支持多态扩展,是插件系统、跨模块架构中的常见做法。相比 Pimpl,Interface 更强调"行为契约",而非"实现隐藏",两者可以互补使用。
从工程层面看,这些技术最终都指向同一目标:头文件必须是稳定、轻量、可复用的契约。因此,程序库的头文件应当"完整但只包含声明"。这里的"完整"指接口语义自洽、无需额外包含即可使用;"只有声明"则意味着避免内联复杂实现、避免暴露私有数据结构、避免不必要的 include。即使在 template 场景下,也应审慎控制模板定义的可见范围,防止头文件成为实现细节的泄洪口。
6. 继承与面对对象
6.1 让public继承塑造出is-a关系
在 Effective C++ 中,Scott Meyers 对 public 继承给出了一个极其严格、但非常实用的判定标准:public 继承必须精确地塑模出 is‑a(是一种)关系。这并非语言层面的约束,而是对设计正确性的语义要求。换言之,只要你选择了 public 继承,就等于向使用者承诺:任何在基类对象身上成立的假设、约束与行为,在派生类对象身上同样成立。
从类型系统角度看,public 继承意味着派生类对象可以在任何需要基类对象的地方被透明替换,这正是里氏替换原则(LSP)的直接体现。编译器允许这种替换,是因为语言规则认可"派生类是基类的一种";而设计层面是否正确,则取决于语义是否一致。如果基类定义了某个不变式(invariant),派生类就不能削弱或破坏它,否则即便代码能编译,系统行为也会变得不可预测。
一个经典的反例是"正方形是否是长方形"。从数学定义看似合理,但在面向对象建模中却往往失败:
cpp
class Rectangle {
public:
virtual void setWidth(int w);
virtual void setHeight(int h);
};
class Square : public Rectangle {
public:
void setWidth(int w) override {
Rectangle::setWidth(w);
Rectangle::setHeight(w);
}
};
这里的问题不在实现技巧,而在语义冲突:基类允许宽高独立变化,而正方形强制二者相等。任何依赖 Rectangle 行为假设的代码,在面对 Square 时都会被"合法但错误"的方式破坏,这正是违反 is‑a 关系的典型症状。
进一步来看,public 继承描述的是"可替代性",而不是"可复用性"。很多初学者选择 public 继承,仅仅是为了复用已有代码,但一旦派生类不能完整支持基类的接口语义,就已经背离了设计初衷。在这种情况下,组合(has‑a)或 private 继承往往是更诚实、也更安全的选择,因为它们不会向外界暴露错误的类型承诺。
值得注意的是,"适用于 base class 的每一件事情也适用于 derived class"不仅指函数调用结果,还包括异常保证、性能特征、线程安全假设以及语义约定。例如,如果基类承诺某个操作是 O ( 1 ) O(1) O(1),派生类却将其实现为 O ( n ) O(n) O(n),即使接口一致,也已经在行为层面破坏了 is‑a 关系。这类问题通常在系统规模扩大后才暴露,代价极高。
6.2 避免继承中发生的名称覆盖
在 C++ 的继承体系中,名字查找规则往往比多态机制本身更容易引发隐蔽缺陷。Effective C++ 明确指出:派生类中的名字会遮掩(hide)基类中所有同名名字,这一行为与函数参数列表、返回类型甚至是否为虚函数都无关。这意味着,一旦派生类声明了一个与基类同名的函数,基类中该名字对应的所有重载版本都会在派生类作用域内"消失"。
这种遮掩并非编译器缺陷,而是 C++ 作用域规则的必然结果。然而从设计角度看,它常常违背程序员的直觉,尤其在接口演化或重载较多的基类中更为危险。例如:
cpp
class Base {
public:
virtual void log(int level);
void log(const std::string& msg);
};
class Derived : public Base {
public:
void log(double value); // 遮掩了 Base::log 的所有版本
};
在 Derived 中,log(int) 与 log(string) 都无法直接调用,除非显式限定作用域。这种行为往往不是设计本意,而是无意识地破坏了基类接口的可见性。
解决这一问题的首选方式,是在派生类中使用 using 声明式,将基类同名函数重新引入派生类作用域:
cpp
class Derived : public Base {
public:
using Base::log; // 让 Base 的所有 log 重载重新可见
void log(double value);
};
using 的优势在于语义清晰、成本极低,并且能够一次性引入整组重载函数。自 C++11 以后,这种方式已经成为处理名字遮掩的推荐做法,尤其适用于 public 继承下的接口扩展场景。
在某些情况下,using 并不适用,例如基类函数是 protected、需要额外语义约束,或你希望对调用行为进行包装。这时可以使用 转交函数(forwarding function):在派生类中显式定义函数,并将调用转发给基类实现。
cpp
class Derived : public Base {
public:
void log(int level) {
Base::log(level); // 转交调用
}
};
转交函数的好处在于可控性更强,你可以在转发前后插入额外逻辑(如参数检查、日志、锁等),但代价是样板代码增多,且必须手动维护与基类接口的一致性。
从设计视角看,名字遮掩本身是一个信号:它提醒你派生类是否真的在"扩展"基类接口,还是无意中在"切断"基类能力。对于 public 继承而言,遮掩通常是不合理的,因为它削弱了 is‑a 关系下的可替代性;而在 private 继承或实现继承中,这种行为反而可能是刻意为之。
6.3 区分接口继承和实现继承
在 Effective C++ 的语境中,public 继承并不只是"代码复用"手段,而是接口与实现语义的组合声明。其中一个极易被忽视、却极其关键的区分是:接口继承(interface inheritance)与实现继承(implementation inheritance)并不等价。理解二者的边界,有助于我们更精确地表达设计意图,也能避免派生类在不恰当的地方被过度约束。
首先必须明确一点:在 public 继承之下,派生类始终继承基类的接口。这意味着,无论基类成员函数是否有实现、是否为虚函数,只要它是 public 的,就构成了派生类对外承诺的一部分。接口继承关注的是"可以调用什么",而非"如何完成"。因此,接口是一种语义契约,决定了派生类能否在任何需要基类的地方被透明替换。
纯虚函数(pure virtual function)是最纯粹的接口继承工具。它们只声明"必须支持的行为",却完全不涉及实现细节:
cpp
class Shape {
public:
virtual double area() const = 0;
};
在这种设计下,基类不对算法、性能或实现方式作任何假设,派生类拥有完全的自由。这种方式最接近"接口类"的概念,适合高度抽象、变化频繁或需要跨模块演化的系统。
与之相比,普通虚函数(impure virtual function)同时表达了两层含义:派生类必须支持该接口,但可以选择是否复用基类提供的默认实现。基类实现通常代表一种"合理缺省",而非不可更改的规则:
cpp
class Window {
public:
virtual void draw() {
drawFrame();
}
};
这种形式在框架型设计中非常常见,它为派生类降低了实现成本,同时仍允许在必要时覆盖行为。但这里的关键是:派生类被允许不使用该实现,这使得实现继承成为"建议性的"。
最后,非虚函数(non‑virtual function)表达的是最强约束:接口继承 + 强制性实现继承。派生类无法改变其行为,只能接受基类定义的实现逻辑:
cpp
class Logger {
public:
void log(const std::string& msg) {
writeHeader();
writeBody(msg);
}
};
这种设计通常用于维护类不变式、算法骨架或安全边界。它明确告诉派生类:"你可以扩展我,但不能改变我在这个接口上的行为。"若滥用非虚函数,容易抑制扩展性;但若合理使用,它是保持系统一致性的有力工具。
从设计角度看,三种函数形式本质上是在回答同一个问题:派生类对基类行为拥有多大自由度。纯虚函数给出最大自由,普通虚函数给出受控自由,非虚函数则完全禁止偏离。优秀的 C++ 设计,往往正体现在这种"自由度分配"的精准拿捏上。
6.4 考虑virtual函数的替代方法
在 C++ 中,virtual 函数并不是实现运行期多态的唯一手段。Effective C++ 提醒我们,应当把 virtual 看作一种"有成本、有约束的工具",而非默认选择。当需求并非严格的"类型层次多态"时,考虑替代方案往往能带来更好的封装性、灵活性或可维护性。这些方案的共同点,是将"变化点"从继承关系中剥离出来,以更可控的方式表达。
最经典的替代方案是 Template Method + Non‑Virtual Interface(NVI)手法。其核心思想是:对外暴露的接口函数为 non‑virtual,由它负责流程控制、参数校验、日志或锁等通用逻辑;真正可变的步骤放在 protected 的 virtual 函数中。这样既保留了多态能力,又避免派生类随意破坏接口语义。
cpp
class Processor {
public:
void process() { // NVI
preCheck();
doProcess(); // 变化点
postCheck();
}
protected:
virtual void doProcess() = 0;
};
NVI 的优势在于控制权反转:基类掌控算法骨架,派生类只填充细节,从而显著降低接口被误用的风险。
第二类替代方案是将 virtual 函数替换为函数指针成员变量,本质上是一种轻量级策略模式。对象在构造时或运行期绑定具体行为,而不是在类型层次中固定下来。这种方式避免了继承层级膨胀,也使行为组合更加灵活。但传统函数指针表达能力有限,无法直接携带状态。
为弥补这一不足,现代 C++ 更推荐使用 std::function(早期为 tr1::function)作为成员变量。std::function 的行为类似通用函数指针,但它可以接受任何签名兼容的可调用实体,包括普通函数、lambda、仿函数以及 std::bind 结果:
cpp
class Button {
public:
std::function<void()> onClick;
};
这种方式将"行为"完全数据化,使运行期策略切换变得非常自然。代价是一定的类型擦除开销,以及对调用约定的运行期检查,但在大多数业务场景下这是可接受的。
另一种更结构化的思路,是将继承体系内的 virtual 函数,替换为另一个继承体系内的 virtual 函数。也就是说,不再通过"被继承类"来变化行为,而是通过"被组合对象"的多态来实现。这是经典策略模式的正统用法:
cpp
class SortStrategy {
public:
virtual void sort() = 0;
};
class Context {
std::unique_ptr<SortStrategy> strategy;
};
这样做的关键收益是解耦上下文与变化维度:Context 不再随着策略种类增加而被迫扩展继承层次,系统的演化路径更加清晰。
需要注意的是,将功能提取为非成员函数虽然能减少类复杂度,但也带来明显限制:非成员函数无法访问类的私有成员,往往迫使接口暴露更多内部细节,反而削弱封装性。因此,这种方式更适合算法型、无状态或对封装要求不高的场景。
6.5 不要重写继承来的非虚函数
在 C++ 的对象模型中,非虚函数的调用采用静态绑定,这意味着函数调用在编译期就已经确定,完全取决于表达式的静态类型,而非对象的实际动态类型。《Effective C++》强调绝不重新定义继承而来的 non-virtual 函数,正是基于这一语言机制的根本约束。如果派生类重新定义了基类中的非虚函数,这种"覆盖"并不会表现出多态行为,反而会造成接口语义在不同上下文中出现不一致,从而破坏类层次设计者对行为一致性的基本假设。
从接口契约的角度看,非虚函数通常承载的是"不允许被改变的行为语义"。基类作者选择将函数声明为 non-virtual,本身就暗含了设计意图:该函数的实现要么体现了类的不变式,要么依赖于稳定的调用顺序和内部状态。如果派生类擅自重新定义同名函数,虽然在语法层面是合法的,但实际上等同于隐藏了基类接口。这种隐藏并非多态扩展,而是引入了两个在语义上相互竞争的函数版本,使得通过基类接口使用对象时得到的行为与通过派生类接口使用对象时完全不同。
这种问题在以基类指针或引用操作对象时尤为隐蔽。例如:
cpp
class Base {
public:
void log() const { std::cout << "Base log\n"; }
};
class Derived : public Base {
public:
void log() const { std::cout << "Derived log\n"; }
};
Base* p = new Derived;
p->log(); // 调用 Base::log
Derived d;
d.log(); // 调用 Derived::log
同一个对象在不同静态类型视角下表现出不同行为,这种"表里不一"的特性极易误导调用者,也违背了里氏替换原则。调用者若仅依据基类接口进行编程,便无法察觉派生类对行为所做的更改,从而在系统演化过程中引入难以追踪的逻辑缺陷。
更深层的问题在于可维护性与可读性。重新定义 non-virtual 函数往往会让阅读代码的人误以为这是一次多态重写,而事实上它只是名称隐藏。这种设计会迫使维护者不断回溯静态类型信息,才能判断某一次调用究竟会落到哪个实现上,显著增加理解成本。在大型代码库中,这类问题通常不是通过编译错误暴露,而是以行为偏差的形式在运行期才显现。
如果派生类确实需要在既有行为的基础上进行变化,合理的设计途径应当是在基类中将该函数声明为 virtual,明确表达"允许并期待派生类定制行为"的意图;或者采用模板方法(Template Method)模式,将可变部分抽取为受保护的虚函数,由非虚的公有接口统一调度。这样既能保持接口一致性,又能为派生类提供受控的扩展点,避免因错误使用 non-virtual 函数而破坏整个继承体系的语义稳定性。
6.6 不要重定义通过继承得到的默认参数值
在 C++ 中,缺省参数值与函数体的绑定规则存在本质差异:缺省参数在编译期根据调用点的静态类型进行解析,而虚函数的函数体选择则发生在运行期,依赖对象的动态类型。《Effective C++》明确指出,绝不应重新定义继承而来的缺省参数值,正是为了避免这两种绑定机制叠加后产生的语义错位问题。一旦在派生类中为同一个虚函数指定不同的缺省参数,程序的实际行为将不再直观,也不再符合大多数程序员对"虚函数可重写"的直觉认知。
从语言规则上看,缺省参数并不是函数签名的一部分,它们属于调用表达式的语法糖。编译器在看到函数调用时,会基于当时可见的函数声明,将缺省参数值直接"填充"进调用代码中。这一过程发生在编译期,与虚函数表机制完全无关。因此,即便函数本身是 virtual,缺省参数的选择依然只取决于表达式的静态类型,而不会随着动态分派发生改变。
下面的例子展示了这种不一致性:
cpp
class Base {
public:
virtual void draw(int color = 0) const {
std::cout << "Base color: " << color << '\n';
}
};
class Derived : public Base {
public:
void draw(int color = 1) const override {
std::cout << "Derived color: " << color << '\n';
}
};
Base* p = new Derived;
p->draw(); // 调用 Derived::draw,但参数值是 0
这里的调用结果往往令人困惑:最终执行的是 Derived::draw,却使用了 Base 中定义的缺省参数值。原因在于 p 的静态类型是 Base*,编译器在此处已经将 draw() 展开为 draw(0),而运行期仅负责决定使用哪个函数体。这种"函数体来自派生类,参数却来自基类"的组合,在设计上极不自然,也极易埋下逻辑错误。
从接口设计的角度看,缺省参数值本身就是接口语义的一部分。它向调用者传达了"在未显式指定参数时,系统将采用何种默认行为"。如果派生类悄然改变这一默认值,那么通过基类接口使用对象的代码将永远无法感知这一变化,导致派生类的设计意图无法被正确表达。更糟糕的是,不同调用点因为静态类型不同,可能观察到同一对象的不同默认行为,使得程序行为呈现出不稳定性。
更合理的设计策略通常有两种。一种是将缺省参数只放在基类的虚函数声明中,并在整个继承体系中保持一致,派生类只负责重写函数实现而不触碰默认值。另一种是避免在虚函数接口中使用缺省参数,将"默认行为"的决策逻辑显式地放入函数体内部,或者通过非虚的包装函数对外提供统一的默认调用入口。这些方式都能确保接口语义与动态多态保持一致,避免静态绑定与动态绑定相互掣肘所带来的设计陷阱。
6.7 通过组合塑造has-a或use-a关系
在 C++ 的类型设计中,组合(composition)与 public 继承(public inheritance)常被初学者混用,但它们在语义层面承担着完全不同的角色。《Effective C++》强调应通过复合来塑模 has-a 或 "根据某物实现出" 的关系,本质上是在区分"对象在现实世界中是什么"与"代码在实现层面如何复用"。这种区分并非形式问题,而是直接影响接口语义、可维护性以及未来扩展方向的核心设计决策。
在应用域(application domain)中,组合表达的是清晰的 has-a 关系,即一个对象在概念上"拥有"另一个对象。被包含的对象是宿主对象状态的一部分,二者在语义上形成整体。例如,一个 Car 拥有一个 Engine,引擎并不是汽车的一种,而是汽车所包含的组成要素。此时,组合反映的是问题域的真实结构,而非为了代码复用而做出的权宜之计。通过成员对象的方式建模,可以自然地限定访问边界,避免将不属于抽象接口的能力暴露给使用者。
cpp
class Engine { /* ... */ };
class Car {
private:
Engine engine_; // Car has an Engine
};
在实现域(implementation domain)中,组合更多体现为 "is-implemented-in-terms-of" 或 use-a 的关系。此时,某个类并不在概念上"拥有"另一个类,而只是借助其已有的数据结构或算法来完成自身功能。典型场景是为了复用成熟的容器、同步原语或工具类的实现细节。例如,一个自定义缓存类内部使用 std::list 和 std::unordered_map 来组织数据,但从抽象语义上看,它并不是一种 list 或 map。通过组合而非继承,类的对外接口可以保持专注,而内部实现则拥有更大的自由度。
cpp
class LruCache {
private:
std::list<int> order_;
std::unordered_map<int, int> data_;
};
与之相对,public 继承在语义上表达的是严格的 is-a 关系,它要求派生类能够在任何需要基类的地方被无条件替换。这是一种强约束的抽象承诺,而不仅仅是实现复用手段。如果仅仅为了复用代码而选择 public 继承,往往会导致接口语义被污染:派生类会被迫暴露基类的全部公有接口,即使其中某些操作在派生类的语境下毫无意义,甚至是有害的。
正因为组合和 public 继承的意义完全不同,Effective C++ 才反复强调"优先使用组合而非继承"。组合让类之间的关系更松散,降低了实现对接口变化的敏感度,也避免了继承层次中脆弱基类问题的扩散。同时,它清晰地区分了应用域中的概念建模与实现域中的技术选择,使代码结构既贴合问题本身,又具备长期演进的弹性。
6.8 慎用private继承
在 C++ 的类型建模中,private 继承并不表达传统意义上的 is-a 关系,而更接近于 is-implemented-in-terms-of,即"通过某个类型的实现来完成自身功能"。这意味着派生类并不希望对外暴露基类的接口语义,而只是将其作为一种实现细节加以复用。从接口设计角度看,private 继承刻意切断了替换性,外部代码无法将派生类视作基类使用,这一点与 public 继承有着本质区别,也决定了它在设计层面的定位应当更加克制。
在绝大多数场景下,组合(composition)仍然是更优先的选择。通过在类中持有一个成员对象,可以明确表达"use-a"的关系,并且在可读性、可维护性以及依赖管理上都更加直观。组合还避免了继承层次带来的脆弱性,例如基类接口变动对派生类产生的连锁影响。因此,除非确实需要继承机制本身所提供的能力,否则仅仅为了代码复用而使用 private 继承,通常并不是一个理想的设计决策。
然而,private 继承并非一无是处,它在某些特定条件下具有组合难以替代的优势。最常见的一种情况是派生类需要直接访问基类的 protected 成员。若采用组合方式,这些受保护接口将无法使用,只能通过额外的 public 包装函数间接访问,从而破坏封装层次。private 继承在这里提供了一种折中方案:既保留了基类对派生类的"实现可见性",又不向外部泄露基类的接口。
另一个重要使用场景是需要重新定义从基类继承而来的 virtual 函数。只有通过继承,派生类才能参与虚函数的动态绑定机制,从而在运行期替换基类行为。如果某个类在逻辑上只是"利用"另一个类的多态行为,而不希望对外表现为该基类类型,private 继承便成为一种合理选择。这在实现策略类、适配旧接口或框架扩展点时较为常见。
此外,private 继承还可以触发空基类最优化(Empty Base Optimization, EBO)。当基类为空类时,通过 private 继承,编译器通常可以将其大小优化为零,而组合方式则往往仍需占据至少一个字节。这一特性在高性能或内存敏感的组件中尤为有价值,例如标准库中对 allocator 的实现就大量依赖这种技术。在这些场景下,private 继承不仅是一种语义选择,更是一种底层效率优化手段。
6.9 慎用多继承
在 C++ 中,多重继承天然比单一继承更具表达力,但这种表达力是以复杂性为代价换取的。只要一个类同时继承自多个基类,就可能引入名字查找、函数调用以及数据布局方面的歧义。即便这些歧义在语法层面可以通过限定作用域来消解,它们仍然会增加理解和维护代码的认知成本。因此,多重继承不应被视为常规设计手段,而是一种需要充分理由支撑的选择。
最典型的问题出现在菱形继承结构中:两个中间类继承自同一个基类,而最终派生类又同时继承这两个中间类。如果不加限制,最终派生类中将包含两份基类子对象,这不仅造成状态重复,还会使对基类成员的访问变得含糊不清。为了解决这一问题,C++ 提供了 virtual 继承机制,使得共享的基类在对象中只保留一份实例,从语义上恢复了"唯一基类"的直觉模型。
然而,virtual 继承并非免费的抽象。它通常会引入额外的指针或偏移信息,从而增加对象体积,并在成员访问时带来一定的间接寻址开销。更重要的是,虚基类的初始化责任被集中到最派生类,这使构造函数的设计和参数传递变得更加复杂,也容易引入隐蔽的初始化错误。因此,只有在确实需要共享基类状态、且无法通过重构继承层次避免菱形结构时,才应考虑使用虚继承。
实践中,虚继承最有价值的使用方式是将虚基类设计为不包含数据成员的抽象基类或接口类。此时,虚继承主要用于统一类型身份,而不是共享状态,既降低了布局和初始化的负担,也减少了潜在的运行期成本。这种设计与"接口只描述行为、不承载数据"的原则是相契合的。
多重继承的合理用途之一,是将不同语义层次的继承关系清晰地分离开来:通过 public 继承接口类来表达"能做什么",同时通过 private 继承某个协助实现的类来复用实现细节。前者用于建立稳定的多态接口,后者则隐藏在实现内部,不对外暴露类型关系。这种组合方式使多重继承服务于清晰的设计目标,而不是沦为结构复杂度的来源。

Once Day
也信美人终作土,不堪幽梦太匆匆......
如果这篇文章为您带来了帮助或启发,不妨点个赞👍和关注!
(。◕‿◕。)感谢您的阅读与支持~~~