【Effective C++ 第三版】第 1 篇:让自己习惯 C++ —— 视 C++ 为语言联邦、const 与初始化
一句话核心结论:C++ 不是”一个语言”,而是 C、Object-Oriented C++、Template C++、STL 四个子语言组成的联邦。写好 C++ 的第一步,是先认清”我现在在哪个子语言里”,然后用那个子语言的规则写代码——
const是给编译器的契约,初始化是给运行时的人身保险。
系列导航
| # | 文章 | 状态 |
|---|---|---|
| 0 | 系列总览:55 个条款 × 11 篇文章 | ✅ 已发布 |
| 1 | 本文:让自己习惯 C++ | ✅ 已发布 |
| 2 | 构造/析构/赋值:对象生命周期的 5 把钥匙 | 🔜 计划中 |
| 3 | 资源管理:RAII 范式与智能指针 | 🔜 计划中 |
| … | … | … |
| 11 | 杂项 + 总结:编译器警告、TR1、Boost | 🔜 计划中 |
前言:为什么从”让自己习惯 C++”开始?
Scott Meyers 把全书第一章叫”Accustoming Yourself to C++”——“让自己习惯 C++”。这绝不是谦虚地说”先打好基础”。
他在说:如果你不先改变思维方式,后面 54 个条款你都学不会。
C++ 程序员最常犯的错,是把它当成”加了 class 的 C”——用 C 的思路写 C++,然后惊讶于”为什么代码这么难维护”。
这一章 4 个条款,是 C++ 思维的”启动器”:
| 条款 | 一句话 |
|---|---|
| 01 | C++ 是个多范式联邦,规则因地而异 |
| 02 | 让编译器帮你工作,少用 #define |
| 03 | const 是无敌的——它能让编译器替你挡 50% 的 bug |
| 04 | 永远初始化,没有例外 |
一、条款 01:视 C++ 为一个语言联邦
1.1 反常识的真相
大部分人以为 C++ 是”一个语言”,包含:
- C 的语法
- class
- template
- STL
但 Scott Meyers 说:C++ 是 4 个子语言的联邦——
graph TB
A["🦄 C++\n(联邦)"]
A --> B["🟣 C 子语言\n(过程式)"]
A --> C["🟢 Object-Oriented C++\n(OOP)"]
A --> D["🟠 Template C++\n(泛型)"]
A --> E["🔵 STL\n(库)"]
B --- B1["没有模板\n没有异常\n没有重载"]
C --- C1["class 封装继承多态\nvirtual 动态绑定"]
D --- D1["模板元编程\n编译期多态\ntraits"]
E --- E1["容器 + 迭代器 + 算法\n函数对象 + 适配器"]
style A fill:#E8D5F5,stroke:#CE93D8,stroke-width:3px,color:#333
style B fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C fill:#B5EAD7,stroke:#80CBC4,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFB3C6,stroke:#F48FB1,color:#333
style B1 fill:#F5F5F5,stroke:#9E9E9E,color:#333
style C1 fill:#F5F5F5,stroke:#9E9E9E,color:#333
style D1 fill:#F5F5F5,stroke:#9E9E9E,color:#333
style E1 fill:#F5F5F5,stroke:#9E9E9E,color:#333关键:每个子语言的高效编程守则不同!
1.2 四大子语言的”行为准则”
| 子语言 | 核心思想 | 典型准则 |
|---|---|---|
| C 子语言 | 过程式 + 内置类型 | 没有模板、没有异常、没有重载;C 的规则 |
| Object-Oriented C++ | class + 继承 + virtual | 多态、虚函数、public 继承 is-a |
| Template C++ | 泛型 + 编译期 | 模板元编程、traits、编译期多态 |
| STL | 容器 + 迭代器 + 算法 + 函数对象 | 值传递、迭代器代替下标、函数对象代替函数指针 |
1.3 实战:同一段代码,4 个子语言的不同写法
1 | // ========== C 子语言 ========== |
1.4 为什么”子语言不同”这么重要?
因为同一段代码,在 4 个子语言里”好”的标准不同:
| 准则 | C | OOP C++ | Template C++ | STL |
|---|---|---|---|---|
| 参数传递 | 值 | const 引用 | const 引用 | 值(STL 风格) |
| 错误处理 | 返回码 | 异常 | 异常 | 异常(容器)或函数对象 |
| 复用单位 | 函数 | class | 模板 | 容器 + 算法 |
| 多态 | 函数指针 | 虚函数 | 模板特化 | 迭代器 + 函数对象 |
一个反例:
1 | // ❌ 在 STL 里用 OOP 思维:传引用给算法 |
核心思想:写代码前先问自己——“我现在在哪个子语言里?”
二、条款 02:尽量以 const、enum、inline 替换 #define
2.1 为什么 #define 不好?
#define 是预处理器指令——它做的是简单的文本替换,不进入编译器的符号表。
1 |
这行代码的问题:
- 编译错误找不到
ASPECT_RATIO:编译器看到的1.653,符号表里没有ASPECT_RATIO - 不会进入调试器:断点、单步都看不到这个”宏”
- 不能限定作用域:除非你
#undef,否则全局可见 - 没有类型检查:可以拼到任何地方
2.2 解决方案 1:const 替代宏常量
1 | // ❌ #define |
对比:
| 维度 | #define | const / constexpr |
|---|---|---|
| 进入符号表 | ❌ | ✅ |
| 调试可见 | ❌ | ✅ |
| 有类型 | ❌ | ✅ |
| 限定作用域 | ❌(除非 #undef) | ✅(命名空间、类) |
| 编译器检查 | ❌ | ✅ |
2.3 特殊情况:类内的常量
1 | class GamePlayer { |
注意:旧标准下需要类外提供定义。但 static const int 作为 integral type 的特例,可以直接取地址。
2.4 解决方案 2:enum 替代宏的”私有常量”
1 | class GamePlayer { |
enum hack 的两个好处:
- 不可被取地址:更接近
#define的行为 - 不会被 ODR(One Definition Rule)使用:避免某些模板/继承场景的麻烦
但:enum hack 是 C++98 时代的”妥协方案”,C++11/14/17 后基本可以用 constexpr/static inline 替代。
2.5 解决方案 3:inline 替代宏函数
1 | // ❌ 宏函数:参数无类型、不会求值两次 |
inline 的作用:
| 维度 | #define 宏 | inline 函数 |
|---|---|---|
| 类型检查 | ❌ | ✅ |
| 多次求值问题 | ⚠️ 有 | ✅ 没有 |
| 可调试 | ❌ | ✅ |
| 作用域 | 全局 | 命名空间内 |
2.6 三种替代的”决策树”
flowchart TD
START(["🎯 想要替换 #define"]) --> Q1{"是常量?"}
Q1 -->|"是"| Q2{"是整型 / 编译期?"}
Q2 -->|"是"| A1["✅ constexpr(首选)\n或 enum hack(兼容老代码)"]
Q2 -->|"否(浮点/字符串)"| A2["✅ const(namespace 或类内)"]
Q1 -->|"否(是函数)"| A3["✅ inline template 函数\n(首选)\n或 inline 普通函数"]
style START fill:#C7CEEA,stroke:#9FA8DA,color:#333
style Q1 fill:#FFF9C4,stroke:#F9A825,color:#333
style Q2 fill:#FFF9C4,stroke:#F9A825,color:#333
style A1 fill:#B5EAD7,stroke:#80CBC4,color:#333
style A2 fill:#B5EAD7,stroke:#80CBC4,color:#333
style A3 fill:#B5EAD7,stroke:#80CBC4,color:#333三、条款 03:尽可能使用 const
3.1 const 的”无所不能”
const 是 C++ 里最强大的关键字之一。它可以修饰:
- 指针
- 迭代器
- 函数参数
- 函数返回值
- 成员函数
- 局部变量
1 | // 1. 修饰指针 |
3.2 const 成员函数:让编译器替你抓错
1 | class TextBlock { |
两个 operator[] 重载的妙用:
1 | TextBlock tb("Hello"); |
这就是 const 正确性(const correctness)——让你的代码”自描述”,让编译器帮你挡住非法操作。
3.3 const 成员函数能不能修改成员?—— mutable
1 | class CachedData { |
mutable 释放了 const 函数的”非物理修改”限制——但你必须保证”用户感知不到修改”。
3.4 const 成员函数 vs non-const 成员函数:避免代码重复
1 | class TextBlock { |
反向调用原则:non-const 版本调用 const 版本(用 const_cast 去掉 const),不要反过来。
四、条款 04:确定对象被使用前已先被初始化
4.1 C++ 初始化的”恐怖现实”
C++ 里”对象的初始化”是最容易出错的地方——因为 C++ 保证:
对内置类型,初始化是程序员的责任;对类类型,初始化由构造函数负责。
但现实是:
1 | // ❌ 未初始化 |
4.2 初始化 vs 赋值的本质区别
1 | // ❌ 赋值(看似初始化,实则先默认构造再赋值) |
这个版本做了什么?
name_用string()默认构造phone_用PhoneNumber()默认构造age_是内置类型,不构造(值不确定)- 然后才是赋值
等等! age_ 在赋值前还是不确定的吗?是的——对内置类型成员,C++ 不保证它在赋值前是 0。
4.3 正确的写法:成员初始化列表
1 | // ✅ 成员初始化列表:一步构造 |
成员初始化列表的优势:
| 维度 | 函数体内赋值 | 成员初始化列表 |
|---|---|---|
| 次数 | 默认构造 + 赋值 | 一次构造 |
| 性能 | 低(额外一次构造) | 高 |
| 内置类型 | 不保证初始化前是 0 | 保证已初始化 |
| const 成员 | 不能赋值 | 必须用列表 |
| 引用成员 | 不能赋值 | 必须用列表 |
4.4 成员初始化的顺序:声明顺序决定!
经典坑:
1 | // ❌ 顺序错误:构造函数看起来是 b_ → a_ |
实际初始化顺序:按类中成员声明的顺序进行,与初始化列表顺序无关!
1 | // 类中声明顺序:a_ 在前,b_ 在后 |
最佳实践:初始化列表顺序与声明顺序保持一致,编译器会发出 -Wreorder 警告。
4.5 “非成员对象” 的初始化
1 | // ❌ 文件作用域的对象:"static initialization order fiasco" |
核心原则:避免文件作用域的对象——它们的初始化顺序不可控。
4.6 跨编译单元的初始化顺序问题
1 | // a.cpp |
w 在 a.cpp 使用时,可能在 b.cpp 中的 w 还没构造完成!
解决方案:用函数包装(见 4.5)。
4.7 综合实践:完美的类设计模板
1 | class PerfectClass { |
核心原则:
- 永远用成员初始化列表
- 初始化顺序与声明顺序一致
- 内置类型也要在初始化列表里显式初始化
- 避免文件作用域对象
五、4 个条款的内在联系
graph LR
A["条款 01\nC++ 是 4 个子语言的联邦"] --> B["条款 02\nconst/enum/inline 替代 #define"]
A --> C["条款 03\n尽可能使用 const"]
A --> D["条款 04\n对象使用前先初始化"]
B --> E["🎯 让编译器帮你工作"]
C --> E
D --> E
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#FFF9C4,stroke:#F9A825,color:#333
style C fill:#B5EAD7,stroke:#80CBC4,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFB3C6,stroke:#F48FB1,stroke-width:3px,color:#333三个条款的共同主题:
让编译器做你应做的工作——而不是把责任推给运行时。
- 条款 02:让编译器知道常量(而不是预处理器)
- 条款 03:让编译器保证不变性(而不是文档)
- 条款 04:让构造函数保证初始化(而不是运行时检查)
六、常见误区与陷阱
6.1 const 误用 1:把 const 放在错误位置
1 | // ❌ 反例:把 const 放在函数后面(误以为是返回类型) |
6.2 const 误用 2:忘了 const 修饰函数参数
1 | // ❌ 参数是 const,应该用 const 引用 |
6.3 初始化误用 1:在构造函数体内初始化
1 | // ❌ 默认构造 + 赋值(低效) |
6.4 初始化误用 2:成员初始化顺序错乱
1 | // ❌ 顺序错乱(编译器会警告 -Wreorder) |
6.5 #define 滥用 1:宏函数中的副作用
1 | // ❌ 宏函数:参数多次求值 |
七、C++11/14/17 的演进
| 条款 | C++98 时代 | C++11/14/17 时代 |
|---|---|---|
| 01 | “C with classes” 思维 | 加入 auto、lambda、concepts |
| 02 | enum hack 解决 | constexpr、static inline 替代 |
| 03 | 手动 const 正确性 | constexpr 函数、概念约束 |
| 04 | 成员初始化列表 | “in-class 初始化” 更方便 |
示例:in-class 初始化(C++11 起):
1 | class Widget { |
八、面试高频考点
8.1 必背题
| 题目 | 答案要点 |
|---|---|
| C++ 是面向对象的语言吗? | 不是,它是多范式联邦——C/OOP/Template/STL |
#define 和 const 有什么区别? | #define 是预处理器,不进入符号表;const 是编译器管理,有类型检查 |
const char* 和 char* const 区别? | 前者数据不可变,后者指针不可变 |
| 成员初始化列表 vs 函数体内赋值? | 列表一次构造;赋值是默认构造 + 赋值,低效 |
| 为什么内置类型也要在初始化列表里? | C++ 不保证内置类型在赋值前是 0 |
8.2 高频追问
| 追问 | 关键点 |
|---|---|
const 成员函数能修改 mutable 成员吗? | 可以——mutable 是为 const 函数的”逻辑常量”准备的 |
| 静态成员变量怎么初始化? | 类内声明,类外定义(C++17 起 inline 变量例外) |
| 跨编译单元的全局对象初始化顺序? | 不可控!避免使用文件作用域对象,用函数包装 |
constexpr 和 const 区别? | constexpr 一定是编译期常量;const 可能是运行时常量 |
| in-class 初始化 vs 成员初始化列表? | in-class 给默认值,列表覆盖——两者结合效果最佳 |
九、配套实验
9.1 实验 1:const 的”无所不能”
1 | // 文件:const_demo.cpp |
编译运行:
1 | g++ -std=c++17 -Wall const_demo.cpp -o const_demo |
9.2 实验 2:成员初始化顺序
1 | // 文件:init_order.cpp |
编译警告:
1 | g++ -std=c++17 -Wall -Wextra init_order.cpp -o init_order |
9.3 实验 3:语言联邦的”风格切换”
1 | // 文件:federation.cpp |
十、回到条款:4 条黄金法则
| 条款 | 黄金法则 |
|---|---|
| 01 | 先想”我在哪个子语言”,再写代码 |
| 02 | #define 用于条件编译,不用于常量和函数 |
| 03 | const 是你的朋友,能加就加 |
| 04 | 永远用成员初始化列表,顺序与声明一致 |
十一、结尾思考题
思考题 1:以下代码的
sizeof(Widget)是多少?为什么?
1 | class Widget { |
思考题 2:为什么 C++ 不保证内置类型成员被自动初始化?C++11/17 之后有什么改进吗?
思考题 3:以下代码是 const 正确的吗?哪里可以改进?
1 | class Matrix { |
思考题 4:C++ 的”语言联邦”模型,对你现在的项目有什么启示?
十二、本篇速查表
| 主题 | 关键 API / 模式 | 适用场景 |
|---|---|---|
| 4 个子语言 | C / OOP C++ / Template C++ / STL | 决定编码风格 |
const 修饰 | 指针、迭代器、参数、返回值、成员函数 | 所有”承诺不变”的地方 |
constexpr | 编译期常量 | 数组大小、模板参数 |
enum hack | 类内私有整型常量 | 兼容老代码、避免取地址 |
| 成员初始化列表 | : member_(value) | 所有构造函数必须用 |
| in-class 初始化 | int x_ = 0; | C++11 起的默认初值 |
mutable | mutable T x_; | const 函数的”逻辑修改” |
inline 函数 | template<typename T> inline T f(...) | 替代宏函数 |
十三、系列导航
| # | 文章 | 状态 |
|---|---|---|
| 0 | 系列总览 | ✅ 已发布 |
| 1 | 本文:让自己习惯 C++ | ✅ 已发布 |
| 2 | 构造/析构/赋值:对象生命周期的 5 把钥匙 | 🔜 计划中 |
| 3 | 资源管理:RAII 范式与智能指针 | 🔜 计划中 |
| … | … | … |
| 11 | 杂项 + 总结 | 🔜 计划中 |
下一篇:第 2 篇《构造/析构/赋值:对象生命周期的 5 把钥匙》——条款 5-12 一起讲透 C++ 对象的”生老病死”:编译器默默生成的 4 个函数、虚析构的必要性、operator= 的自我赋值陷阱、Rule of Three 的完整实现。
行动建议:
- 今天:翻一下你的 C++ 项目,把所有
#define常量替换成const/constexpr- 今天:给所有”只读”的成员函数补上
const关键字- 本周:把所有”裸成员”加上 in-class 默认初始化
- 思考:你的项目里哪些地方应该加
mutable?哪些不该加?