《Effective C++》第三版(2005,55 条)成书于 C++98,《More Effective C++》(1996,35 条)甚至早于第一个标准。两本书奠定了 C++ 工程实践的基本盘——RAII、异常安全、最小惊讶的接口设计——但在 C++11 之后的语言里,相当一部分条款已被语言特性直接解决,另一部分则换了个形态继续成立。本文按现代 C++(C++11–C++23)的标准逐条重审:依然成立的讲清楚为什么,被替代的说明替代品是什么。

每个条款标题前的状态标签说明这条建议在现代 C++ 中该怎么用:

  • 仍适用:原则仍然适用;可以继续遵循,并结合现代语法实现。
  • 需更新:要解决的问题仍在,但现代 C++ 有了更直接的写法;优先看正文给出的新做法。
  • 已过时:原书的具体做法已过时,或不再适合作为通用建议;了解原因即可,遇到相关需求按正文中的现代做法处理。

基础条款

仍适用 Item 1:把 C++ 看成多个次语言的联邦

这个心智模型依然正确,且要扩大版图:值语义的 C 部分、面向对象部分、泛型部分(如今有 concepts 约束)、STL/_ranges 部分、以及 constexpr 计算部分。不同次语言里"好的做法"不同——比如 C 部分按值传 struct 天经地义,OOP 部分就要考虑切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <algorithm>
#include <vector>

struct Point { int x = 0; int y = 0; };

// 值语义次语言:struct 按值进出,得到独立副本
Point shifted(Point p, int dx) { p.x += dx; return p; }

// OOP 次语言:多态对象只经引用/指针流转
struct Base { virtual ~Base() = default; };
struct Derived : Base { int extra = 0; };

void by_ref(const Derived& d); // 正确:无切片
// void by_val(Base b); // 传 Derived 会切片

int main() {
// 泛型次语言:值语义让 vector 直接喂给算法
std::vector<Point> pts{{2, 1}, {0, 3}};
std::sort(pts.begin(), pts.end(),
[](const Point& a, const Point& b) {
return a.x < b.x;
});
}

仍适用 Item 2:用 const、enum、inline 代替 #define

动机从未变:让名字进入编译器和调试器的视野。现代工具更强:宏常量用 constexpr 变量(C++17 起可 inline constexpr 放头文件),宏函数用 constexpr 函数或模板,整型常量用 enum class。详见宏的替代方案。

1
2
3
4
5
6
7
8
9
10
11
12
#include <cstdint>

// 宏常量:inline constexpr 有类型、进符号表
inline constexpr std::uint16_t kMaxRetries = 3;
inline constexpr double kPi = 3.14159'26535'89793;

// 整型宏:enum class,成员有名字有类型
enum class LogLevel : std::uint8_t { Debug, Info, Error };

// 宏函数:constexpr 函数,可调试、可重载
constexpr int square(int x) { return x * x; }
static_assert(square(7) == 49);

仍适用 Item 3:尽可能使用 const

const 依旧是语言的核心机制:const 成员函数、mutable 缓存、迭代器 const_iterator、const 成员函数重载委托。现代补充是 constexpr,把"编译期常量"从"运行期只读"中分离出来。详见 const 与 constexpr 专题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <string>
#include <utility>

class Texture {
public:
explicit Texture(std::string name)
: name_(std::move(name)) {}
const std::string& name() const { return name_; }
std::string& name() { return name_; } // 可变访问
private:
std::string name_;
};

// constexpr:把"编译期可算"分离出来
class Rgb {
public:
constexpr Rgb(int r, int g, int b)
: r_(r), g_(g), b_(b) {}
constexpr int gray() const {
return (r_ * 299 + g_ * 587 + b_ * 114) / 1000;
}
private:
int r_, g_, b_;
};

constexpr Rgb kSlate{112, 128, 144};
static_assert(kSlate.gray() == 125); // 编译期完成

仍适用 Item 4:确保对象使用前已初始化

内置类型不自动清零、成员初始化顺序是声明序而非初始化列表序——这两点没变。现代写法是 NSDMI(类内默认成员初始化),把初始化写进声明处,消灭"某个构造函数忘了初始化某成员"这类 bug:

1
2
3
4
5
6
7
8
9
10
11
12
#include <ctime>

class Timer {
public:
void start() { running_ = true; }
std::clock_t elapsed() const {
return running_ ? std::clock() - start_ : 0;
}
private:
std::clock_t start_ = std::clock(); // NSDMI:声明即初始化
bool running_ = false;
};

跨编译单元的全局对象初始化顺序问题(static initialization order fiasco)也依然存在,现代解法是函数内 static 局部对象(C++11 起线程安全,即 magic static)或 constinit 保证零初始化。

仍适用 ME 1:区分指针与引用

判断标准至今好用:可能为"空"或需要"改指向"用指针,否则用引用。std::string_view、std::span 的出现让"我持有但不拥有、可能为空"多了一个选项:非拥有视图既可以有 empty() 又比裸指针安全,现代接口里大量替代了 const T* 加长度参数的组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <span>
#include <string>
#include <string_view>
#include <vector>

// 引用:必有对象,调用方无须判空
std::string& trim(std::string& s);

// 指针:可能为空、可能改指向
const std::string* try_find(std::string_view key);

// 视图:非拥有序列,取代"指针 + 长度"的组合
double mean(std::span<const double> xs) {
if (xs.empty()) return 0.0;
double sum = 0.0;
for (double x : xs) sum += x;
return sum / static_cast<double>(xs.size());
}

int main() {
std::vector<double> samples{1.0, 2.0, 3.0};
double m = mean(samples); // 隐式转 span
return m == 2.0 ? 0 : 1;
}

仍适用 ME 2:用 C++ 风格转换

见显式类型转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <memory>
#include <vector>

struct Request { virtual ~Request() = default; int id = 0; };
struct Upload : Request { int bytes = 0; };

// static_cast:明确的数值语义转换
long hits = 3, total = 4;
double ratio = static_cast<double>(hits) /
static_cast<double>(total);

// dynamic_cast:安全向下转换,失败返回空指针
int main() {
std::vector<std::unique_ptr<Request>> jobs;
jobs.push_back(std::make_unique<Upload>());
int bytes = 0;
for (auto& job : jobs) {
if (auto* up = dynamic_cast<Upload*>(job.get())) {
bytes += up->bytes; // 确是 Upload 才进入分支
}
}
return ratio > 0.0 && bytes == 0 ? 0 : 1;
}

仍适用 ME 3:不要把数组当多态处理

依旧是教科书级 UB 陷阱:通过基类指针对派生类数组做下标运算,指针按 sizeof(Base) 步进,一旦 sizeof(Derived) > sizeof(Base) 就访问错位;通过基类指针 delete[] 派生数组同样始终不合法。数组多态没有合法形态,需要"一组多态对象"就用 std::vector<std::unique_ptr<Base>>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <memory>
#include <vector>

struct Shape {
virtual ~Shape() = default;
virtual int area() const = 0;
};
struct Rect : Shape {
int area() const override { return 6; }
};

int main() {
// 一组多态对象:存 unique_ptr,经基类引用访问
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Rect>());
int total = 0;
for (const auto& s : shapes) total += s->area();
return total == 6 ? 0 : 1;

// 反例(仅注释):Base* p = new Rect[10];
// p[1] 按 sizeof(Base) 步进,访问错位,
// 通过 p 的 delete[] 也始终不合法
}

仍适用 ME 4:避免无意义的默认构造函数

语义上依然成立——没有合理初始状态的对象不该提供默认构造。但注意现代容器(std::vector::resize、std::array 聚合初始化)对默认构造的需求在减少,std::vector 扩容用的是拷贝/移动而非默认构造;真正强制要求默认构造的场景(reserve 后 emplace 并不要求)已不多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <vector>

class Token {
public:
explicit Token(int id) : id_(id) {} // 无默认构造
int id() const { return id_; }
private:
int id_;
};

int main() {
std::vector<Token> tokens;
tokens.reserve(8); // 只分配内存,不构造元素
tokens.emplace_back(42); // 原地构造,无须默认构造
}

资源管理:RAII 与智能指针

这是两本书中生命力最强的部分,现代 C++ 把 Meyers 当年手写的所有设施都标准化了。

仍适用 Item 13:用对象管理资源(RAII)

依然是 C++ 与 GC 语言竞争的根本论据:资源在构造函数中获取、在析构函数中释放,栈展开保证任何退出路径都会执行析构。现代代码里 RAII 对象无处不在——std::unique_ptr、std::shared_ptr、std::lock_guard、std::fstream、std::jthread。RAII 的纪律只有一条:资源和持有它的对象生命周期绑定,绝不裸持:

1
2
3
4
5
6
7
8
9
10
11
12
#include <mutex>

std::mutex mtx;

bool canceled() { return false; }

bool commit(int amount) {
std::lock_guard<std::mutex> lock(mtx); // 构造即加锁
if (amount < 0) return false; // 提前返回也自动解锁
// ... 临界区 ...
return true;
} // 任何退出路径都执行析构,锁必然释放

仍适用 ME 9 / ME 10:用析构函数防止资源泄漏;当心构造函数中的泄漏

这是 RAII 的两个具体推论。构造函数中后初始化的成员抛异常时,已构造完成的成员会被析构——所以把资源放进成员 RAII 对象(unique_ptr 成员而非裸指针成员)后,构造函数天然不会泄漏;构造函数体内 new 出来的局部资源同理用 unique_ptr 接住。

ME 9

1
2
3
4
5
6
7
8
9
10
11
#include <memory>

struct Session { ~Session(); }; // 析构释放真实资源

bool canceled() { return false; }

void task() {
auto s = std::make_unique<Session>();
if (canceled()) return; // 提前返回也走析构
// ... 使用 s ...
} // 无论哪条路径退出,资源都被释放

ME 10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <cassert>
#include <memory>
#include <stdexcept>

inline int live = 0; // 存活资源计数,用于观察

class Widget {
public:
Widget() : res_(std::make_unique<Res>()) { step(); }
private:
static void step() { throw std::runtime_error("boom"); }
struct Res { Res() { ++live; } ~Res() { --live; } };
std::unique_ptr<Res> res_; // 先于构造函数体完成
};

int main() {
try {
Widget w; // step() 抛出,构造失败
} catch (const std::exception&) {
assert(live == 0); // 已构造的 res_ 被自动析构
}
}

仍适用 ME 8:区分 new 表达式、operator new 与 placement new

new 表达式把"分配内存 + 构造对象"合成一步;operator new 只负责分配,placement new 只负责在给定地址上构造。手动管理时各步必须配对:::operator new 的内存用 ::operator delete 归还,placement new 构造的对象用显式析构调用收尾。日常代码仍然优先 RAII——拆开只是为了看清 new 表达式背后自动发生了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <new>

class Widget {
public:
Widget() noexcept = default;
~Widget() = default;
};

int main() {
// new 表达式 = 分配 + 构造,两步合一
auto* w1 = new Widget;
delete w1; // = 析构 + 释放,自动配对

// 拆开看同样的动作:
void* mem = ::operator new(sizeof(Widget)); // 分配
auto* w2 = new (mem) Widget; // 构造
w2->~Widget(); // 析构
::operator delete(mem); // 释放
}

需更新 Item 16:new 与 delete 形式必须配对

new[] 配 delete[],错了是 UB,这没变。现代解法是根本不写裸 new——std::vector / std::make_unique 之后,形式配对错误失去了生存土壤:

1
2
3
4
5
6
7
8
#include <string>
#include <vector>

// 现代:容器接管数组,不存在配对问题
auto names = std::vector<std::string>(10);

// 兜底认知:若历史代码仍有裸数组,
// new[] 只能配 delete[],形式混用是未定义行为

需更新 Item 17:以独立语句将 new 出的对象置入智能指针

原书场景是 process(std::shared_ptr<Widget>(new Widget), priority()):C++17 之前实参求值顺序可交错,new Widget 求值后 priority() 抛异常,泄漏。C++17 已规定各实参求值互不交错,这个具体漏洞被堵上。结论升级为更强的形式:用 make_unique / make_shared 代替裸 new——除了异常安全,make_shared 还把控制块和对象合并为一次分配,缓存更友好;唯一要注意的是 make_shared 因控制块共享内存,weak_ptr 存活期间对象内存不会归还:

1
2
3
4
5
6
7
8
9
10
11
12
#include <memory>

struct Widget { int v = 0; };

int priority() { return 3; }
void consume(std::shared_ptr<Widget> w, int pri);

int main() {
// make_shared:一次分配完成对象与控制块,
// 没有"new 出来的裸对象等着进智能指针"的窗口
consume(std::make_shared<Widget>(), priority());
}

需更新 Item 14:RAII 类的拷贝行为

已被 Rule of Five/Zero + =delete 直接解决——RAII 类默认不可拷贝就是最常见正确答案,需要转移就用移动语义(unique_ptr 的设计):

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <memory>
#include <type_traits>

class Connection {
public:
Connection() : sock_(std::make_unique<int>(0)) {}
private:
std::unique_ptr<int> sock_; // 资源在 RAII 成员里
};

// 拷贝被自动删除,移动保留:RAII 类的常见形态
static_assert(!std::is_copy_constructible_v<Connection>);
static_assert(std::is_move_constructible_v<Connection>);

仍适用 Item 15:提供对原始资源的访问

依然是真实需求:RAII 对象需要提供 .get()、.release()、c_str() 这类显式访问原始资源的出口,标准库智能指针的设计(get 是观察、release 是交出所有权,语义分明)就是范本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <memory>
#include <string>

class Blob {
public:
std::string* get() const { return data_.get(); } // 借出
std::unique_ptr<std::string> release() { // 交出所有权
return std::move(data_);
}
private:
std::unique_ptr<std::string> data_ =
std::make_unique<std::string>("payload");
};

int main() {
Blob b;
std::string* p = b.get(); // 观察,所有权未动
std::size_t n = p->size(); // 借用期内使用

auto owned = b.release(); // 交出所有权给调用方
// release 之后 p 已悬垂:结果必须当场
// 交给负责释放的一方,不得再经旧指针访问
return n > 0 && owned ? 0 : 1;
}

需更新 ME 28 / ME 29:智能指针与引用计数

1996 年要用 30 页篇幅手写的东西,现在是 std::unique_ptr(独占,零开销)、std::shared_ptr(引用计数,原子操作有代价)和 std::weak_ptr(观察 shared,打破循环引用)。手写引用计数在现代代码中没有理由存在。详见智能指针。

ME 28

1
2
3
4
5
6
7
8
9
10
#include <memory>
#include <utility>

struct Job { int id = 0; };

int main() {
auto a = std::make_unique<Job>(); // a 独占资源
auto b = std::move(a); // 转移给 b
// 此后 a == nullptr,释放责任完全在 b
}

ME 29

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <memory>

struct Node {
std::shared_ptr<Node> next; // 强引用会成环
std::weak_ptr<Node> parent; // 弱引用打破环
};

int main() {
auto parent = std::make_shared<Node>();
auto child = std::make_shared<Node>();
parent->next = child; // parent 拥有 child
child->parent = parent; // child 只观察 parent
} // 两个节点都能正常释放
// 若两条边都是 shared_ptr,节点互相持有,
// 引用计数永远到不了零

构造、析构与拷贝控制

需更新 Item 5 / Item 6:了解编译器默默生成哪些函数;显式禁止不需要的

这是被 C++11 改写最彻底的条款。编译器可生成的特殊成员从四个变六个(默认构造、拷贝构造、拷贝赋值、移动构造、移动赋值、析构),且生成规则改为条件式:声明了析构或拷贝操作,移动操作不再生成;声明了移动操作,拷贝操作被删除。原来的"把拷贝构造声明为 private 且不实现"现在是一行 = delete,还能禁止普通函数和模板实例化:

Item 5

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <memory>
#include <type_traits>

class Report {
// Rule of Zero:不声明任何特殊成员函数,
// 资源完全由 unique_ptr 成员管理
private:
std::unique_ptr<int> data_ = std::make_unique<int>(0);
};

// 编译器生成的结果:可移动、不可拷贝
static_assert(std::is_move_constructible_v<Report>);
static_assert(!std::is_copy_constructible_v<Report>);

Item 6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdio>
#include <memory>
#include <utility>

class FileHandle {
public:
explicit FileHandle(const char* path)
: file_(std::fopen(path, "r"), &close) {}
FileHandle(const FileHandle&) = delete; // 显式禁止
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&&) noexcept = default; // 显式放行
FileHandle& operator=(FileHandle&&) noexcept = default;
private:
static void close(std::FILE* f) {
if (f) std::fclose(f);
}
std::unique_ptr<std::FILE, decltype(&close)> file_;
};

现代实践浓缩为两条规则:Rule of Zero——能靠成员 RAII 对象搞定就不声明任何特殊成员(绝大多数类应该如此);Rule of Five——一旦声明了其中任何一个(通常是析构),就显式写全或 =default 全部五个,把编译器的隐式决策变成白纸黑字。详见特殊成员函数。

仍适用 Item 7:多态基类的析构函数声明为 virtual

依然完全成立:经基类指针 delete 派生类对象而非虚析构是 UB。现代补充一个反面判断:不打算经基类指针删除的基类,析构函数应为 protected 非 virtual——既给出编译期错误提示,又避免无谓的虚表开销(std::enable_shared_from_this 的基类设计就是这个思路):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <memory>

struct Base {
virtual ~Base() = default; // 多态基类:虚析构
virtual int id() const = 0;
};
struct Derived : Base {
int id() const override { return 7; }
};

int main() {
std::unique_ptr<Base> p = std::make_unique<Derived>();
p.reset(); // 经虚析构正确销毁 Derived 部分
}

需更新 Item 8 / ME 11:别让异常离开析构函数

从"最佳实践"升级为"语言规则":C++11 起析构函数隐式 noexcept,抛异常逃出析构直接 std::terminate。析构中可能抛的操作要就地捕获处理,无法处理的失败要么吞掉记日志,要么提供单独的 close() 让调用方显式面对错误(std::fstream 析构静默关闭失败就是这个逻辑)。

Item 8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdio>
#include <stdexcept>

class LogFile {
public:
explicit LogFile(const char* path)
: file_(std::fopen(path, "w")) {}
~LogFile() { // 析构隐式 noexcept,逃出即 terminate
if (file_) std::fclose(file_); // 失败也无处可报
}
void close() { // 想面对错误的一方用显式接口
if (file_ && std::fclose(file_) != 0)
throw std::runtime_error("close failed");
file_ = nullptr;
}
private:
std::FILE* file_;
};

ME 11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdexcept>
#include <string_view>

void flush(); // 可能抛的清理动作
void log_failure(std::string_view msg); // 保证不抛

class Job {
public:
~Job() noexcept { // 析构隐式 noexcept
try {
flush(); // 可能失败的操作就地兜住
} catch (const std::exception& e) {
log_failure(e.what()); // 记录后放弃
} // 绝不放任异常逃出析构
}
};

仍适用 Item 9:构造/析构期间不要调用虚函数

机制未变:基类构造期间对象的动态类型就是基类,虚调用会解析到基类版本,纯虚函数则直接导致链接错误或 UB;派生类成员尚未初始化,调用派生版本反而是错的。现代模板代码里这条以更隐蔽的形态出现——基类构造函数中调用会经派生类特化的 CRTP 成员,同样是 UB。正确做法依然是:构造期的"定制点"用派生类向基类传参表达(derived 把信息交给 base 的构造函数),而不是基类反过来问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <string>

class Base {
public:
explicit Base(std::string tag) : tag_(std::move(tag)) {}
private:
std::string tag_;
};

class Derived : public Base {
public:
Derived() : Base("derived") {} // 定制信息靠传参
int extra() const { return extra_; }
private:
int extra_ = 42; // 基类构造期间此成员尚不存在
};

仍适用 Item 10:赋值运算符返回 *this 的引用

为了 a = b = c 链式写法,依旧是惯例,无变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <string>

class Buffer {
public:
Buffer& operator=(const Buffer& rhs) { // 返回引用
if (this != &rhs) data_ = rhs.data_;
return *this;
}
private:
std::string data_;
};

int main() {
Buffer a, b, c;
a = b = c; // c 先赋给 b,b 的返回值再赋给 a
}

仍适用 Item 11:处理自赋值

w = w 是老问题,自移动(std::move(x) 传给自己的移动赋值)是新形态。传统解法 copy-and-swap 依然正确且自带强异常安全:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <string>
#include <utility>

class Widget {
public:
explicit Widget(std::string d) : data_(std::move(d)) {}
// Rule of Five:声明了移动赋值,拷贝构造也写明
Widget(const Widget&) = default;
// 拷贝赋值:copy-and-swap,自带强异常安全
Widget& operator=(const Widget& rhs) {
Widget tmp(rhs); // 拷贝可能抛异常,先做
swap(tmp); // 提交阶段不抛异常
return *this; // tmp 析构释放旧资源
}
// 移动赋值:自移动守卫 + 不抛实现
Widget& operator=(Widget&& other) noexcept {
if (this != &other) data_ = std::move(other.data_);
return *this;
}
void swap(Widget& other) noexcept {
std::swap(data_, other.data_);
}
private:
std::string data_;
};

copy-and-swap 的代价是拷贝赋值放弃 move 优化;上面的移动赋值补上这一环:if (this != &other) 守卫让 x = std::move(x) 不会把自己搬空。

仍适用 Item 12:拷贝对象的所有部分

依然是真实高频 bug:派生类的拷贝赋值忘了调 Base::operator=(rhs),基类子对象不被更新;后期给类加了新成员,忘了更新拷贝函数。Rule of Zero 能根治后一类(不写拷贝函数就没有"忘了更新"),前一类在继承场景仍然要人肉保证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <string>

class Base {
public:
Base& operator=(const Base& rhs) {
tag_ = rhs.tag_;
return *this;
}
private:
std::string tag_;
};

class Derived : public Base {
public:
Derived& operator=(const Derived& rhs) {
Base::operator=(rhs); // 先拷贝基类子对象
extra_ = rhs.extra_; // 再拷贝自己的全部成员
return *this;
}
private:
int extra_ = 0;
};

接口设计与参数传递

仍适用 Item 18:让接口容易被正确使用,难以被误用

依然是两本书里价值最高的设计条款,没有之一。手段没有变:用类型系统挡住非法值(enum class Month 代替 int),用类型限制操作范围,让资源所有权在签名上可见(工厂返回 unique_ptr,调用方想泄漏都难):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdexcept>

enum class Month : int { Jan = 1, Feb, Mar, Apr, May,
Jun, Jul, Aug, Sep, Oct, Nov, Dec };
struct Day { int value; };
struct Year { int value; };

class Date {
public:
Date(Month m, Day d, Year y) {
// 类型挡住参数错位;合法范围在构造处校验
if (static_cast<int>(m) < 1 ||
static_cast<int>(m) > 12)
throw std::invalid_argument("month");
if (d.value < 1 || d.value > 31)
throw std::invalid_argument("day");
month_ = m;
day_ = d;
year_ = y;
}
private:
Month month_{};
Day day_{1};
Year year_{1970};
};

int main() {
Date ok(Month::Aug, Day{15}, Year{2026});
// Date bad(Day{15}, Month::Aug, Year{2026});
// 参数错位:编译错误
}

注意 enum class 挡的是错位与误转,Month(13) 这样的显式构造依然合法,所以合法值要在构造处校验。

现代补充:std::span / string_view 把"指针 + 长度"的隐式契约变成显式类型;[[nodiscard]] 让忽略返回值变成警告。

仍适用 Item 19:把类设计当作类型设计

略作现代翻译:你的类型如何构造/拷贝/移动/比较(C++20 == / <=> 是否 defaulted)、与类型转换的关系、什么是合法操作与非法操作(=delete 让"非法"可诊断):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <compare>

class Quantity {
public:
explicit Quantity(double meters) : meters_(meters) {}
friend bool operator==(const Quantity&,
const Quantity&) = default;
friend auto operator<=>(const Quantity&,
const Quantity&) = default;
private:
double meters_;
};

int main() {
Quantity q{3.0}; // explicit:3.0 不隐式转换
bool same = (q == Quantity{3.0}); // 比较 =default
return same ? 0 : 1;
}

需更新 Item 20:优先按 const 引用传参,而非按值

这是被移动语义改写而非推翻的条款。正确结论升级为一张现代传参决策表:

  • 只读且类型小(int、指针、span、string_view)——按值;
  • 只读、类型大或大小未知——const T&;
  • 只读的连续字符/字节序列——std::string_view / std::span<const T>;
  • 要把实参内容收进对象内部(sink 参数)——按值 + 内部 std::move,或重载 const T& / T&& 双版本;
  • 只可移动的类型(unique_ptr)——按值或 T&&。

移动语义让"按值传入再 move"成为常规操作,这在 2005 年是不敢想的;但"拷贝大对象按值传"依然错误,条款的物理直觉(拷贝有代价)没有过时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <string>
#include <string_view>
#include <vector>

class Doc {
public:
// 只读的连续字符序列:string_view 按值
std::size_t count(std::string_view word) const;

// 只读大对象:const 引用
double avg(const std::vector<int>& samples) const;

// sink:按值收进来,move 进成员
void rename(std::string name) {
title_ = std::move(name);
}

private:
std::string title_;
};

仍适用 Item 21:必须返回对象时,别返回引用

返回栈上局部对象的引用/指针是悬垂,这一点没变。这条在现代有了完美收尾:C++17 起按值返回 prvalue 强制省略拷贝(guaranteed copy elision),返回语句直接在被调方的存储上构造对象,return Widget{a, b}; 零拷贝;NRVO(命名变量)仍是可选优化但普遍存在。结论:放心按值返回,详见 RVO 与拷贝消除:

1
2
3
4
5
6
7
8
9
10
11
#include <string>

struct Widget {
std::string name;
};

Widget make_widget(std::string name) {
return Widget{std::move(name)}; // 按值返回:零拷贝
}
// 反例(勿仿):返回局部对象的引用或指针,
// 出函数即悬垂

仍适用 Item 22:数据成员声明为 private

理由依然充分:封装、不变量、细粒度读写控制。C++20 的指定初始化器(.x = 1)只对公开成员的聚合可用,不构成反例——聚合本来就是"无不变量"的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdexcept>

class Account {
public:
void deposit(int cents) {
if (cents <= 0)
throw std::invalid_argument("amount");
balance_ += cents; // 不变量:余额恒非负
}
int balance() const { return balance_; }
private:
int balance_ = 0; // private:不变量只有类能维护
};

仍适用 Item 23:优先非成员非友元函数

且是标准库的组织哲学:std::sort 不是任何容器的成员,正因如此它对一切容器可用,且不接触容器私有数据——非成员非友元 = 更小的耦合面 = 更大的封装。配套技巧也还在:把非成员函数放进和类同名的命名空间,让扩展自然汇聚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <string>

class Account {
public:
Account(std::string owner, int cents)
: owner_(std::move(owner)), balance_(cents) {}
const std::string& owner() const { return owner_; }
int balance() const { return balance_; }
private:
std::string owner_;
int balance_;
};

// 同命名空间的非成员非友元:只依赖公有接口
std::string describe(const Account& a) {
return a.owner() + ": " + std::to_string(a.balance());
}

仍适用 Item 24:需要类型转换作用于所有参数时,用非成员函数

Rational 乘法要支持 2 * r 与 r * 2 就必须让左参数也参与转换。现代实现用 hidden friend(隐藏友元)惯用法一举多得:友元定义写在类内,只被 ADL 找到,既保持非成员语义,又不污染外层命名空间、不参与无关类型的重载决议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <cassert>

class Rational {
public:
Rational(int n = 0, int d = 1) // 允许 int 隐式转入
: num_(n), den_(d) {}
friend Rational operator*(const Rational& lhs,
const Rational& rhs) {
return Rational(lhs.num_ * rhs.num_,
lhs.den_ * rhs.den_);
}
int num() const { return num_; }
int den() const { return den_; }
private:
int num_, den_;
};

int main() {
Rational r{3, 4};
auto a = 2 * r; // 左参 int 经构造函数转换
auto b = r * 2; // 右参同样,两侧对称
assert(a.num() == 6 && a.den() == 4);
assert(b.num() == 6 && b.den() == 4);
}

需更新 Item 25:考虑支持非抛出的 swap

被标准库大幅接管:std::swap 在 C++11 后按 move 实现,自定义类型只要可移动就自动获得不错的 swap。仍需手写 swap 的场景收窄为"move 无法做到的交换"(如以指针 + 外部资源实现的类型);若手写,提供成员 swap 并在自身命名空间提供自由 swap(供 ADL),别特化 std::swap(对模板类型做不到):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <string>
#include <utility>

class Buffer {
public:
explicit Buffer(std::string data)
: data_(std::move(data)) {}
void swap(Buffer& other) noexcept { // 成员版本
std::swap(data_, other.data_);
}
private:
std::string data_;
};

// 同命名空间的非成员 swap:供 ADL 使用
void swap(Buffer& a, Buffer& b) noexcept { a.swap(b); }

void exchange(Buffer& a, Buffer& b) {
using std::swap; // 先引入 std::swap 兜底
swap(a, b); // ADL 找到 Buffer 的版本
}

实现细节

仍适用 Item 26:尽可能推迟变量定义

现代写法更简单:直接定义时初始化(Widget w{compute();} 而非先默认构造再赋值),既推迟定义又省掉一次默认构造。提前定义的代价依然是"后续路径提前 return 时白白构造析构":

1
2
3
4
5
6
7
8
9
#include <string>

std::string render(int level) {
if (level < 0) return {}; // 卫语句先走
// 走到这里才定义:定义即初始化,无浪费
std::string out = "level " + std::to_string(level);
out += " ready";
return out;
}

仍适用 Item 27:尽量少做转型动作

static_cast 的老坑(*iter 与 iter 的 dynamic_cast 行为差异)换成了新坑:范围 for 里 for (char c : static_cast<std::string>(s)) 触发意外拷贝、lambda 捕获中的转换。dynamic_cast 成片出现仍是设计坏味道——该用虚函数分派或 std::variant + visit 的地方在做运行期类型硬分支。详见显式类型转换:

1
2
3
4
5
6
7
8
9
10
11
12
#include <string_view>

int vowels(std::string_view s) {
int n = 0;
for (char c : s) { // 直接遍历视图,零拷贝
if (c == 'a' || c == 'e' || c == 'i' ||
c == 'o' || c == 'u')
++n;
}
return n;
}
// 对比坏味道:std::string(s) 会先整串复制一遍

仍适用 Item 28:避免返回对象内部的句柄

且更加要紧:成员函数返回内部成员的引用/指针/迭代器,破坏封装(外部可改写内部状态)更制造悬垂。现代版高危形态是视图悬垂——string_view / span 成员函数返回后,调用方把视图存下来,对象一销毁视图就悬垂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <string>

class Person {
public:
explicit Person(std::string name)
: name_(std::move(name)) {}
// 返回拥有的副本:调用方怎么存都不会悬垂
std::string name() const { return name_; }
private:
std::string name_;
};

int main() {
std::string kept = Person{"Ada"}.name(); // 安全
}

规则不变:句柄的生存期承诺要么写进类型(返回 std::string 副本),要么写进文档并接受审查。

需更新 Item 30:审慎使用 inline

语义已重写:inline 关键字的本职退化为"允许多重定义"(ODR 豁免),与是否内联展开基本无关;现代编译器凭 LTO 和调用点信息跨翻译单元自行内联。编译期求值的重担转移给了 constexpr / consteval——"想要编译期算好"如今直接写常量求值函数,而不是抠 inline。头文件层面的零成本抽象(小访问器)依然天然内联,条款的精神(衡量二进制膨胀与 icache 压力)未变:

1
2
3
4
5
6
7
8
9
// 头文件里的 inline constexpr 变量:
// 多个翻译单元见到的是同一实体,不违反 ODR
inline constexpr int kThreads = 4;

// constexpr 函数:调用点常量求值,运行期也能调用
constexpr int buffer_for(int items) { return items * 64; }

static_assert(buffer_for(kThreads) == 256); // 编译期
// int buf = buffer_for(runtime_count()); // 运行期

仍适用 Item 31:最小化编译依赖

方向没变,手段升级了两代。第一代是 Pimpl,现代写法用 unique_ptr 持有不完整类型,注意两个坑:持有不完整 Impl 的类,其析构函数必须在 .cpp 中定义(unique_ptr 的析构需要完整类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// widget.h
#include <memory>

class Widget {
public:
Widget();
~Widget(); // 必须在 widget.cpp 中定义
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};

// widget.cpp
#include <string>
#include <vector>
#include "widget.h"

struct Widget::Impl {
std::string name; // 真正的成员都在这里
std::vector<int> data; // 改动不触发使用者重编
};

Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // 此处 Impl 已完整

第二代是 C++20 模块:import 直接消灭头文件的文本包含模型与 include 顺序耦合。Pimpl 的"隐藏私有成员"封装价值仍在,"减少重编译"的职责正逐步移交模块。前置声明的老建议(能前置声明就不 include)依然适用于尚未模块化的代码库。

继承与面向对象

仍适用 Item 32:公有继承意味着 is-a

且被 C++20 concepts 写进了语言:std::derived_from<Derived, Base>(派生在前)在编译期检查的就是这条关系。LSP 直觉(“凡用基类处皆可换派生类”)依旧是判断设计正误的第一试金石:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <concepts>

struct Base { int shared = 0; };
struct Derived : Base { int extra = 0; };

static_assert(std::derived_from<Derived, Base>);

int read(const Base& b) { return b.shared; }

int main() {
Derived d; // is-a:基类引用接收派生
return read(d) + d.extra;
}

仍适用 Item 33:避免遮蔽继承来的名字

解法没变——派生类里 using Base::on_event; 把基类重载集合引入,否则基类的所有 on_event 重载被派生类的一个同名函数整体遮蔽。C++11 的 using Base::Base;(继承构造函数)是同一机制在新场景的应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <string_view>

class Handler {
public:
void on_event(int) {}
void on_event(int, int) {}
};

class HttpHandler : public Handler {
public:
using Handler::on_event; // 引入基类重载集合
void on_event(std::string_view) {} // 新增重载
};

int main() {
HttpHandler h;
h.on_event(1); // 若无 using,这里编译失败
h.on_event(1, 2);
h.on_event("/index");
}

仍适用 Item 34:区分接口继承与实现继承

三分法至今是设计词汇表:纯虚函数 = 只定接口;简单虚函数 = 接口 + 缺省实现;非虚函数 = 接口 + 强制实现。现代细化为一条纪律:非虚函数意味着"对所有特化不变",任何"派生类可能要改"的念头都该立刻升级为虚函数或改设计:

1
2
3
4
5
6
7
8
9
10
11
#include <string_view>

class Shape {
public:
virtual int area() const = 0; // 纯虚:只定接口
virtual std::string_view name() const {
return "shape"; // 虚:接口+缺省实现
}
void normalize(); // 非虚:流程强制不变
virtual ~Shape() = default;
};

仍适用 Item 35:考虑虚函数以外的替代方案

依旧是 OOP 设计最有营养的条款,三个替代方案全部现代化:

  • NVI(非虚接口):公有非虚函数做模板方法(打日志、拿锁、校验),私有虚函数做定制点。定制点可私有,因为派生类重写虚函数不需要访问权限;
  • std::function 策略:把"可定制行为"做成构造参数,从"继承体系中注入"改为"运行期注入",可组合性远超虚函数;
  • CRTP 静态多态:编译期绑定,无虚表开销,C++20 concepts 让约束显式化,见 CRTP。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <mutex>

class Shape {
public:
virtual ~Shape() = default;
void draw() const { // 公有非虚:不变的流程骨架
std::lock_guard<std::mutex> lock(mtx_);
++draw_calls_; // 统一插桩只写一处
do_draw(); // 私有虚:派生类的定制点
}
int calls() const { return draw_calls_; }
private:
virtual void do_draw() const = 0; // 定制点可私有
mutable std::mutex mtx_;
mutable int draw_calls_ = 0;
};

class Circle : public Shape {
private:
void do_draw() const override {} // 重写私有虚合法
};

仍适用 Item 36:绝不重新定义继承来的非虚函数

被 Item 34 收编:非虚函数静态绑定,"派生类重定义它"本身就自相矛盾——调用方经基类指针调的是基类版本,行为分裂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {
public:
void reload() { version_ = 1; } // 非虚
int version() const { return version_; }
private:
int version_ = 0;
};

class Derived : public Base {
public:
void reload() { touched_ = true; } // 别这样做
bool touched_ = false;
};

int main() {
Derived d;
Base& b = d;
b.reload(); // 静态绑定 → Base::reload
// d.reload(); // 同名函数却走另一套逻辑,调用方分裂
}

仍适用 Item 37:绝不重新定义继承来的带默认参数的虚函数

机制未变:虚函数动态绑定,默认参数静态绑定,按基类接口调用时派生类版本拿到的是基类的默认值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cassert>

class Shape {
public:
virtual ~Shape() = default;
virtual void draw(int = 1) const {} // 基类默认 1
};

class Circle : public Shape {
public:
void draw(int scale = 2) const override {
last_scale = scale;
}
static inline int last_scale = 0;
};

int main() {
Circle c;
Shape& s = c;
s.draw(); // 动态绑定到 Circle::draw
// 默认参数静态绑定:拿到的是基类的 1,不是 2
assert(Circle::last_scale == 1);
}

NVI 是标准解法:默认参数只出现在非虚公有函数里,虚函数定制点要求显式传参,两个世界不再交错。

仍适用 Item 38:通过组合建模 has-a 或 is-implemented-in-terms-of

应用复用的第一选择永远是组合;std::stack 用 std::deque 实现而不公有继承它,就是"以……实现"的范本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdexcept>
#include <vector>

class IntStack {
public:
void push(int v) { data_.push_back(v); }
int top() const {
if (data_.empty())
throw std::out_of_range("empty stack");
return data_.back();
}
private:
std::vector<int> data_; // 只暴露需要的接口
};

仍适用 Item 39:审慎使用私有继承

结论依旧:能用组合就别私有继承(私有继承连"部分整体"都不表达,只是"以基类实现")。两个残余合法场景:需要访问 protected 成员、需要重写虚函数(这时多半该问为什么基类有虚函数);以及空基类优化(EBO)——无状态分配器/比较器经私有继承可零开销携带;空成员的存储可与相邻成员折叠,不一定真的多占 1 字节,C++20 的 [[no_unique_address]] 则明确允许这种折叠(是否生效取决于实现与布局,不保证固定结果):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <string>
#include <utility>

struct LexLess {
bool operator()(const std::string& a,
const std::string& b) const {
return a < b;
}
};

class Store {
public:
bool less(const std::string& a, const std::string& b) {
return comparer_(a, b);
}
private:
// 空状态比较器:组合 + no_unique_address
[[no_unique_address]] LexLess comparer_;
};

仍适用 Item 40:审慎使用多重继承

实践收敛为一条主流形态:单继承实现 + 多个纯接口(无数据、全虚函数),COM 与许多插件体系都如此。带实现的菱形继承和虚基类依旧罕见且代价高;真正麻烦的 MI 歧义(两个基类同名成员)现代靠 using 声明显式消解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <string_view>

class Printable {
public:
virtual void print() const = 0;
virtual ~Printable() = default;
};

class Serializable {
public:
virtual std::string_view serialize() const = 0;
virtual ~Serializable() = default;
};

// 单继承实现 + 多个纯接口:主流形态
class Report final : public Printable,
public Serializable {
public:
void print() const override {}
std::string_view serialize() const override {
return "{}";
}
};

模板与泛型

模板章(Item 41–48)受现代 C++ 冲击最大:

仍适用 Item 41:理解隐式接口与编译期多态

C++20 concepts 把隐式要求变成显式签名,报错从"实例化失败"变成"约束不满足":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <concepts>
#include <vector>

// 显式签名:T 必须支持 < 与 ==(totally_ordered)
template <std::totally_ordered T>
constexpr bool sorted(const std::vector<T>& xs) {
for (std::size_t i = 1; i < xs.size(); ++i)
if (xs[i] < xs[i - 1]) return false;
return true;
}

static_assert(sorted(std::vector<int>{1, 2, 3}));
static_assert(!sorted(std::vector<int>{2, 1}));
// struct NoCompare {}; 无 < 与 ==
// sorted(std::vector<NoCompare>{});
// 报错直接指向 totally_ordered 约束不满足

仍适用 Item 42:理解 typename 的两种含义

C++20 起大量语境的 typename 变为可选,但依赖名前缀规则本身未变:

1
2
3
4
5
6
7
#include <iterator>

template <typename It>
typename std::iterator_traits<It>::value_type
first_value(It it) {
return *it; // 依赖类型名前必须写 typename
}

仍适用 Item 43:用 this-> 访问模板基类成员

机制未变:依赖名不查非依赖基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
class Base {
public:
void foo() {}
};

template <typename T>
class Logged : public Base<T> {
public:
void run() {
this->foo(); // 依赖基类成员须经 this-> 查找
}
};

仍适用 Item 44:将与参数无关的代码抽离模板

代码膨胀仍是真实代价;共通矩阵/维度参数外提的思路不变:

1
2
3
4
5
6
7
8
9
10
11
#include <cstddef>

// 与 T 无关的检查:普通函数,只实例化一份代码
void check_capacity(std::size_t rows, std::size_t cols);

template <typename T>
void fill(T* grid, std::size_t rows, std::size_t cols) {
check_capacity(rows, cols); // 外移到非模板
for (std::size_t i = 0; i < rows * cols; ++i)
grid[i] = T{}; // 只有这里依赖 T
}

仍适用 Item 45:用成员函数模板接受兼容类型

依旧是标准库的设计模式(shared_ptr<U> 转 shared_ptr<T> 的转换构造):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <concepts>
#include <memory>

template <typename T>
class Box {
public:
Box() = default;
// 成员模板接受兼容类型:U* 可转 T* 才放行
template <typename U>
requires std::convertible_to<U*, T*>
Box(const Box<U>& other) : p_(other.share()) {}

std::shared_ptr<T> share() const { return p_; }
private:
std::shared_ptr<T> p_;
};

struct Base { virtual ~Base() = default; };
struct Impl : Base { int v = 1; };

int main() {
Box<Base> b = Box<Impl>{}; // 派生 Box 转基类 Box
}

仍适用 Item 46:模板内的类型转换需求用非成员函数

与 Item 24 合流,hidden friend 是现代答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename T>
class Rational {
public:
Rational(T n = T{}, T d = T{1})
: num_(n), den_(d) {}
// hidden friend:写在类内,仅经 ADL 可见
friend Rational operator*(const Rational& a,
const Rational& b) {
return Rational(a.num_ * b.num_, a.den_ * b.den_);
}
T num() const { return num_; }
T den() const { return den_; }
private:
T num_, den_;
};

int main() {
Rational<int> r{3, 4};
auto a = 2 * r; // int 左参经构造函数转换
auto b = r * 2; // 右参同样
return a.num() + b.den(); // 6 + 4
}

需更新 Item 47:用 traits 类提取类型信息

已标准化为 <type_traits>;tag dispatch 被 if constexpr 大幅取代,见 type_traits:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <type_traits>

template <typename T>
constexpr auto to_double(T v) {
if constexpr (std::is_integral_v<T>) {
return static_cast<double>(v); // 整数:转换
} else {
return v; // 其余:原样
}
}

static_assert(to_double(3) == 3.0);
static_assert(to_double(0.5) == 0.5);

需更新 Item 48:认识模板元编程

大部分场景被 constexpr / consteval 常量求值取代,TMP 退守类型计算(如 concepts 组合):

1
2
3
4
5
6
7
8
// consteval:只在编译期求值,取代 TMP 数值计算
consteval unsigned long long
factorial(unsigned n) {
return n <= 1 ? 1ULL : n * factorial(n - 1);
}

static_assert(factorial(5) == 120);
// factorial(runtime_n); // 编译错误:须常量表达式

运算符重载

需更新 ME 5:警惕用户定义的转换函数

问题的现代解法已入语言:explicit 转换运算符。explicit operator bool() const 只在条件语境(if、&&、!)隐式生效,if (p) 可用而 int n = p; 编译错误——2005 年前发明的 safe bool 惯用法(私有嵌套类指针成员)可以彻底退役。隐式单参构造函数的问题依旧要靠 explicit 防守,双向转换(两条路都隐式可走)依然是重载决议灾难,原则不变:最多一个方向隐式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Stream {
public:
explicit operator bool() const { return ok_; }
// 坏例(勿仿):operator int() const
// 会让 s + 1、s << 1 等表达式走向意外重载
private:
bool ok_ = true;
};

int main() {
Stream s;
if (s) {} // 条件语境:explicit 依然生效
// int n = s; // 隐式转 int:编译错误
}

仍适用 ME 6:区分前置与后置 ++/--

后置版本必须保存旧值返回副本,前置版本零额外开销;迭代器密集循环里 ++it 依旧优于 it++(范围 for 内部已用前置):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Index {
public:
Index& operator++() { // 前置:只做自增
++v_;
return *this;
}
Index operator++(int) { // 后置:多一次拷贝
Index old = *this; // 保存旧值
++v_;
return old;
}
int value() const { return v_; }
private:
int v_ = 0;
};

仍适用 ME 7:绝不重载 &&、||、,

重载后失去短路求值与求值顺序保证,语义彻底改变。没有替代品也不需要替代品——这条是禁止令,至今有效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <cassert>

bool probe(bool result, int& calls) {
++calls; // 观察右侧是否被求值
return result;
}

int main() {
int calls = 0;
bool ok = false && probe(true, calls);
assert(!ok && calls == 0); // 内建 &&:右侧未求值
}
// 若重载了 &&,两个实参都必须求值,
// 短路语义与求值顺序保证同时消失

需更新 ME 21:为避免隐式转换而重载

现代用模板运算符一行解决当年要写 N 个重载的问题,hidden friend 写法见上文 Item 24:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <concepts>

class Counter {
public:
explicit Counter(int v) : v_(v) {}
int value() const { return v_; }

// 一份定义覆盖所有整数类型,
// 按实参类型精确匹配,不依赖隐式转换
template <std::integral I>
friend Counter operator+(const Counter& c, I n) {
return Counter(c.v_ + static_cast<int>(n));
}
private:
int v_;
};

int main() {
Counter c{10};
Counter next = c + 5; // 直接拿整数,无临时 Counter
return next.value();
// 对比 Item 24:那里靠 int 隐式转 Rational,
// 这里是模板按实参类型精确匹配,二者互补
}

仍适用 ME 22:优先复合赋值(+=)而非独立赋值(+)再自赋

a += b 复用 a 的既有容量(std::string::append),a = a + b 则构造全新临时再整体替换。热路径累积字符串时依旧显著,另见 std::format:

1
2
3
4
5
6
7
8
9
10
#include <string>
#include <vector>

std::string join(const std::vector<std::string>& parts) {
std::string out;
out.reserve(128); // 一次预留
for (const auto& p : parts) out += p; // 复用容量
return out;
}
// 对比:head + "\n" + body 每步都产生临时串

异常安全

仍适用 Item 29 / ME 12 / ME 13:争取异常安全代码;异常 vs 参数;按引用捕获

三级异常安全保证(基本保证:不变量不破;强保证:要么成功要么回滚;不抛保证)依旧是衡量接口质量的标尺;catch 必须按引用(按值捕获会切片且多一次拷贝)、重抛用 throw; 而非 throw e;(后者按捕获对象切片拷贝),都还是高频错误:

Item 29

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <string>
#include <utility>

class Config {
public:
void update(const std::string& patch) {
std::string next = data_; // 副本上试验
next += patch; // 可能抛出的操作
next += patch;
data_.swap(next); // 全部成功才提交
} // 中途抛出:data_ 原封不动,强保证
private:
std::string data_;
};

ME 12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <cassert>
#include <stdexcept>

struct Guard {
int& live;
~Guard() { ++live; } // 栈展开时也保证执行
};

int main() {
int live = 0;
try {
Guard g{live}; // 正常的函数调用没有这个动作
throw std::runtime_error("x");
} catch (const std::exception&) {
assert(live == 1); // 抛出 → g 析构,栈展开
}
}

ME 13

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdexcept>
#include <string>

struct ParseError : std::runtime_error {
explicit ParseError(std::string msg)
: std::runtime_error(std::move(msg)) {}
};

void translate() {
try {
throw ParseError("bad token");
} catch (const std::exception& e) { // 按引用:不切片
// 记录 e.what() 后重抛
throw; // throw e; 会按静态类型拷贝、切片
}
}

int main() {
try {
translate();
} catch (const ParseError& e) { // 派生类型完好
return e.what() == std::string("bad token") ? 0 : 1;
}
return 1;
}

强异常安全的经典手法 copy-and-swap 依旧有效(见 Item 11 代码),std::vector::push_back 依旧是强保证的教科书实现。变化的是潮流:noexcept 三件套(noexcept 声明、noexcept 运算符、std::move_if_noexcept)让"不抛保证"成为移动语义正确工作的前提;错误处理则多了 std::expected(C++23)这条无异常路径,见异常与错误处理。

已过时 ME 14:审慎使用异常规格

条款本身已随对象死亡:动态异常规格(throw(std::bad_alloc))C++11 弃用、C++17 删除。继承其正确内核的是 noexcept——只回答"抛不抛"这一个问题,且违约为 std::terminate 而非意外的运行期失败。现代纪律:真正不抛的移动、swap 才标 noexcept(标注与实际不符,抛出即 std::terminate),析构隐式 noexcept;不抛的移动缺失时 vector 扩容会退化回拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <type_traits>
#include <utility>

class Buffer {
public:
Buffer() = default;
// 只在确定不抛时才标 noexcept
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)) {}
Buffer& operator=(Buffer&& other) noexcept {
data_ = std::exchange(other.data_, nullptr);
return *this;
}
private:
int* data_ = nullptr;
};

static_assert(std::is_nothrow_move_constructible_v<Buffer>);

仍适用 ME 15:理解异常处理的开销

模型没变:主流实现是"零成本"表驱动——不抛异常时几乎零开销(landing pad 不占热路径),代价在异常发生时(两阶段栈展开,比正常返回慢几个数量级)和二进制体积。结论依旧:异常用于"异常",不要当控制流;热路径内层循环避免抛出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <map>
#include <optional>
#include <string>

// 常见的"没找到":用类型表达,不走异常
std::optional<int> lookup(
const std::map<std::string, int>& table,
const std::string& key) {
auto it = table.find(key);
if (it == table.end()) return std::nullopt;
return it->second;
}

int main() {
std::map<std::string, int> table{{"a", 1}};
auto v = lookup(table, "missing");
return v ? *v : -1; // 缺失是正常返回值;
} // 异常只留给真正罕见、无法就地处理的错误

效率

仍适用 ME 16:牢记 80-20 定律

且更该强调:程序 80% 的时间花在 20% 的代码上,而人对热点的直觉普遍不准。现代做法不变——先用 profiler(perf、Instruments、超采样)找到那 20%,再动手;编译器也没闲着,-O2 起的激进优化会让"看起来快"的手写代码变慢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <chrono>
#include <cstdio>

long long sum_to(int n) {
long long s = 0;
for (int i = 1; i <= n; ++i) s += i;
return s;
}

int main() {
namespace chrono = std::chrono;
auto t0 = chrono::steady_clock::now();
long long r = sum_to(1'000'000); // 结果必须被观察
auto t1 = chrono::steady_clock::now();
std::printf("r=%lld, %lld us\n", r,
static_cast<long long>(
chrono::duration_cast<
chrono::microseconds>(t1 - t0)
.count()));
// 微基准只回答"这段代码多快",
// 找那 20% 的热点仍要交给 profiler
}

仍适用 ME 17:考虑惰性求值

依旧是延迟计算的天下,且有了最好的现代注脚:ranges 视图整条流水线都是惰性的,不产出中间临时容器,iota 甚至可以无限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <cassert>
#include <ranges>
#include <vector>

int main() {
int evaluated = 0;
auto squares =
std::views::iota(0)
| std::views::transform([&](int x) {
++evaluated; // 观察惰性求值
return x * x;
})
| std::views::take(5);
assert(evaluated == 0); // 此刻一个值都没算
std::vector<int> out;
for (int x : squares) out.push_back(x);
// 遍历到哪算到哪:只算了 take 要求的 5 个
assert(evaluated == 5 && out.size() == 5);
}

其余形态(按需构造大成员、写时拷贝)依然视场景可用,见 ranges。

仍适用 ME 18:分期摊还预期计算(缓存与预取)

频繁请求的数据缓存一份(LRU、std::map 记忆化)、vector::reserve 预分配、顺序访问时的预取。缓存与 ME 17 的惰性是一对方向相反的赌注——重复概率高就提前算,概率低就推迟算:

1
2
3
4
5
6
7
8
9
10
11
#include <vector>

std::vector<int> build(std::size_t n) {
std::vector<int> v;
v.reserve(n); // 一次性预分配
for (std::size_t i = 0; i < n; ++i)
v.push_back(static_cast<int>(i)); // 不再扩容
return v;
}
// reserve 是摊还预期的手段,
// 不改变 push_back 本身的算法复杂度

仍适用 ME 19:理解临时对象的代价

形态翻新:传参触发隐式转换构造临时、按值返回大对象、s1 + s2 + s3 链式拼接的中间结果(现代 operator+ 按值接收 + 移动已大幅缓解)。识别手段不变:开 -fno-omit-... 级别的警告或读汇编数构造调用;std::string::append、std::format 依旧是无临时对象的正道:

1
2
3
4
5
6
7
8
9
10
11
12
#include <string>

std::string page(const std::string& head,
const std::string& body) {
std::string out;
out.reserve(head.size() + body.size() + 1);
out += head; // append 复用已预留的容量
out += '\n';
out += body;
return out;
}
// 对比:head + "\n" + body 会先产出中间临时串

已过时 ME 20:协助返回值优化

已被 C++17 的强制拷贝消除盖上官印:按值返回 prvalue 零拷贝、零移动(强制消除只覆盖 prvalue,命名变量的 NRVO 仍是可选优化),详见 RVO。原书"把返回对象塞进运算符返回值优化"的手工技巧可以全部忘掉,只剩一条朴素建议:写 return Widget{...};,别费心返回临时以外的花招:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <string>

class Widget {
public:
explicit Widget(std::string v)
: value_(std::move(v)) {}
Widget(const Widget&) = delete; // 不可拷贝
Widget(Widget&&) = delete; // 不可移动
private:
std::string value_;
};

Widget make() {
// prvalue 直接在被调方的存储上构造:
// 即使拷贝/移动全部删除,也能编译
return Widget{"hi"};
}

int main() { auto w = make(); (void)w; }

已过时 ME 23:考虑替代库

议题依旧存在(iostream 慢可换 {fmt}/std::format,malloc 慢可换 jemalloc/tcmalloc),但作为常规条款弱化——标准库本身在吸收这些替代品(std::format 是 C++20 从 {fmt} 库演化而来,并非 Boost 的直接标准化),std::format、std::span 的存在让"换库"越来越少成为必要:

1
2
3
4
5
6
7
#include <format>
#include <string>

// C++20 起 std::format 已入标准,按需才引第三方库
std::string greet(std::string_view name, int hits) {
return std::format("hello, {}, {} hits", name, hits);
}

仍适用 ME 24:理解虚函数、多继承、虚基类与 RTTI 的代价

虚调用要经间接跳转、通常阻止内联并带来分支预测压力(不是"查虚表"本身)——具体代价随实现与调用场景变化,没有固定数字。现代对策清单比 1996 年长得多:final 类/虚函数开放去虚化、LTO 跨模块去虚化、CRTP 编译期消除虚表、std::variant + visit 把分派放到编译期可见的类型表上(实现细节各异)。虚基类与 RTTI 的成本账没变——RTTI 就是每虚表一份 type_info 指针,见 RTTI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <variant>
#include <vector>

struct Circle { double r = 0; };
struct Square { double a = 0; };

// 封闭集合:variant + visit,编译期可见全部类型
using Shape = std::variant<Circle, Square>;

double total(const std::vector<Shape>& shapes) {
double sum = 0;
for (const auto& s : shapes)
sum += std::visit(
[](const auto& sh) -> double {
if constexpr (requires { sh.r; })
return 3.14159 * sh.r * sh.r;
else
return sh.a * sh.a;
}, s);
return sum;
}
// 对照:类型集合开放时用虚函数分派,
// 两种机制的成本都取决于实现,勿记固定数字

惯用法的沉浮

ME 25–31 是 1996 年的手工工具箱,多数已被语言收编(ME 28 / ME 29 的智能指针与引用计数已见上文资源管理部分):

仍适用 ME 25:虚构造函数与虚非成员函数

clone() 惯用法依然标准——现代返回 std::unique_ptr<Base>,不再依赖协变返回类型(智能指针不支持协变);“命名构造函数”(静态工厂)更是大行其道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <memory>

class Animal {
public:
virtual ~Animal() = default;
virtual std::unique_ptr<Animal> clone() const = 0;
};

class Dog final : public Animal {
public:
// 返回类型是 unique_ptr<Animal>,不协变
std::unique_ptr<Animal> clone() const override {
return std::make_unique<Dog>(*this);
}
// 命名构造函数:静态工厂
static std::unique_ptr<Animal> puppy() {
return std::make_unique<Dog>();
}
};

需更新 ME 26:限制对象数量

"禁止拷贝"现由 =delete 表达;Meyers 单例(函数内 static)在 C++11 后自动线程安全,是现在唯一推荐的单例写法——注意线程安全只覆盖首次初始化,之后并发读写成员仍需自行加锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <string>
#include <string_view>

class Config {
public:
static Config& instance() {
static Config c; // C++11 起,首次初始化线程安全
return c;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
std::string get(std::string_view key) const;
private:
Config() = default;
};

已过时 ME 27:限制堆上/栈上对象

这类需求在现代代码中已少见;工厂返回 unique_ptr 表达"只能堆上",保护析构表达"不经基类指针删除"。确需受控构造时的窄例外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <memory>

class License {
public:
// 受控构造:唯一入口,类自己决定创建方式
static std::unique_ptr<License> issue(int seats) {
// make_unique 无权访问私有构造,这里直接 new
return std::unique_ptr<License>(new License(seats));
}
int seats() const { return seats_; }
private:
explicit License(int seats) : seats_(seats) {}
int seats_;
};

int main() {
auto lic = License::issue(5); // 构造路径受控
}

注意:make_unique 无法访问私有构造,所以这里用 new 交给 unique_ptr;而"返回 unique_ptr"本身并不表达"禁止栈上创建",那条边界由私有构造与 =delete 划定。

仍适用 ME 30:代理类

依然是高阶技巧:vector<bool>::reference、多维下标链;C++23 zip 等视图的元组引用同族。警惕点依旧:代理临时对象不可绑定到非 const 引用存活:

1
2
3
4
5
6
7
8
9
#include <vector>

int main() {
std::vector<bool> bits(8, false);
bits[2] = true; // operator[] 返回代理对象
auto ref = bits[3]; // auto 是 reference 代理,
ref = true; // 写回的是容器里的位
return bits[3] ? 0 : 1; // 容器确实被修改
}

需更新 ME 31:双分派 / 多重分派

已被 std::variant + std::visit 大幅替代。两个多态对象按双方动态类型分派,OOP 里要写指数级的双重分派代码,现代做法是把"类型集合封闭"这个前提显式化,交给 std::visit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <string>
#include <variant>

struct Ship { std::string name = "ship"; };
struct Asteroid { int size = 10; };
// 类型先完整定义,才能参与 variant

using Entity = std::variant<Ship, Asteroid>;

struct Collide {
std::string operator()(const Ship& s,
const Asteroid&) const {
return s.name + " shoots asteroid";
}
std::string operator()(const Asteroid& a,
const Ship& s) const {
return "asteroid(" + std::to_string(a.size)
+ ") hits " + s.name;
}
std::string operator()(const Ship& a,
const Ship& b) const {
return a.name + " meets " + b.name;
}
std::string operator()(const Asteroid&,
const Asteroid&) const {
return "rocks drift past";
}
};

int main() {
Ship s;
Asteroid a{40};
Entity e1 = s, e2 = a;
auto r1 = std::visit(Collide{}, e1, e2); // 船 vs 石
auto r2 = std::visit(Collide{}, e2, e1); // 石 vs 船
return r1 != r2 &&
r1 == "ship shoots asteroid" &&
r2 == "asteroid(40) hits ship" ? 0 : 1;
}

类型集合开放(编译期未知派生类)时仍需虚函数或手写分派——variant 的封闭集合既是限制也是优化机会。

内存与库生态

仍适用 Item 49–51:new-handler、自定义 new/delete

机制都在:std::set_new_handler、全局/类级替换 operator new、placement new 约定。变化在于实践:替换分配器如今首选链接现成实现(jemalloc、mimalloc)而非手写 operator new;C++14 补了 sized delete、C++17 补了 std::aligned_alloc 与对齐 new,老条款里"忘记 sized deallocation"一类坑已被语言填平。详见 new 与 delete。

Item 49

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <new>

class NewHandlerGuard { // RAII:保存并恢复旧 handler
public:
explicit NewHandlerGuard(std::new_handler h)
: old_(std::set_new_handler(h)) {}
~NewHandlerGuard() { std::set_new_handler(old_); }
NewHandlerGuard(const NewHandlerGuard&) = delete;
NewHandlerGuard& operator=(
const NewHandlerGuard&) = delete;
private:
std::new_handler old_;
};

void out_of_memory(); // 补救策略,这里不调用它

void big_work() {
NewHandlerGuard guard(out_of_memory); // 作用域生效
// ... 大量分配 ...
} // 自动恢复全局 handler,不污染其他代码

Item 50

1
2
3
4
5
6
7
8
9
10
11
#include <memory_resource>
#include <vector>

void demo() {
std::byte buf[1024];
std::pmr::monotonic_buffer_resource pool(buf, sizeof buf);
std::pmr::vector<int> v(&pool); // 从栈上池分配
for (int i = 0; i < 100; ++i) v.push_back(i);
} // pool 析构一次性归还,无逐次 free
// 顺序:先用现成分配设施(pmr、容器),
// 最后才考虑替换全局 operator new/delete

Item 51

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <new>

class Blob {
public:
// 类级 operator new:只对本类型生效
static void* operator new(std::size_t n) {
return ::operator new(n); // 委托全局版本
}
static void operator delete(void* p) noexcept {
::operator delete(p);
}
int first() const { return payload_[0]; }
private:
int payload_[16];
};

int main() {
auto* b = new Blob; // 调 Blob::operator new
delete b; // 调 Blob::operator delete
// 分配失败必须抛 std::bad_alloc:
// 全局 ::operator new 已遵守这一约定
}

仍适用 Item 54 / ME 35:熟悉标准库

这条以自身的方式实现了:TR1 的全部内容——function、bind、shared_ptr、regex、tuple、array、unordered_map、random、type_traits——已在 C++11 转正。今天的新增必修课是 C++17 的 optional / variant / string_view / filesystem,C++20 的 concepts / ranges / format 库(<format>)、span / jthread / syncstream,以及 C++23 的 expected / mdspan / print:

Item 54

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <optional>
#include <string>

std::optional<int> find_port(const std::string& line) {
std::size_t eq = line.find('=');
if (eq == std::string::npos) return std::nullopt;
return std::stoi(line.substr(eq + 1));
}

int main() {
auto port = find_port("port=8080"); // 可缺失
return port.value_or(0) == 8080 ? 0 : 1;
}

ME 35

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <string>
#include <type_traits>
#include <variant>

using Field = std::variant<int, std::string>;

std::string describe(const Field& f) {
return std::visit(
[](const auto& v) -> std::string {
if constexpr (std::is_same_v<
std::decay_t<decltype(v)>,
int>)
return std::to_string(v);
else
return v;
}, f);
}

需更新 Item 55:熟悉 Boost

Boost 从"C++ 标准库的后备军"变成"标准化的孵化器":filesystem(C++17)、optional/variant/any(C++17)、span(C++20)、format(C++23)皆由此而来。今天对多数项目,合理建议反而收缩为:优先标准库,Boost 按需引入——Asio、UnitTest、少数算法库仍有不可替代性,但引入整个 Boost 的时代已过:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <filesystem>

namespace fs = std::filesystem;

int main() {
// 标准库已有:路径、查询、组成、遍历
fs::path log = fs::current_path() / "data" / "a.txt";
bool ok = fs::exists(log);
fs::path dir = log.parent_path(); // 目录部分
fs::path stem = log.stem(); // 去扩展名
return ok && !dir.empty() && !stem.empty() ? 0 : 1;
// Boost 按需:仅当需要尚未标准化的能力时引入
}

仍适用 ME 34:让 C++ 与 C 顺畅协作

要点依旧:extern "C" 关掉名字修饰、main 唯一、跨语言边界的异常必须拦截(C 不理解栈展开)、struct 布局是唯一稳定契约。现代多了一层:与 C ABI 交互时 std::vector、std::span 等 C++ 类型都不能过边界,能传的只有 C 可表示的类型——指针加长度,std::span 只在 C++ 侧包一层使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <cstddef>
#include <span>

extern "C" {
// C ABI:只传 C 可表示的类型,不传 std::span
int c_process(const unsigned char* data, std::size_t len);
}

int bridge(const unsigned char* data, std::size_t len) {
if (!data || len == 0) return -1; // 边界处先验证
try {
std::span<const unsigned char> view(data, len);
return c_process(view.data(), view.size());
} catch (...) {
return -2; // 异常绝不越过 C 边界
}
}

仍适用 ME 32 / ME 33 / Item 52 / Item 53:面向未来编程、非叶类抽象、写你所意、理解警告

这四条"元条款"没有时效性:代码为变更而写(ME 32)、被继承的具体类几乎总是错误(ME 33,接口与实现分离)、代码表达意图而非绕过编译器(Item 52)、警告清零而非压制(Item 53,现代对应物还包括打开 -Wall -Wextra -Werror 与静态分析):

ME 32

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <functional>
#include <string>
#include <utility>

class Pipeline {
public:
// 策略是构造参数:换行为不必改类
explicit Pipeline(
std::function<std::string(std::string)> step)
: step_(std::move(step)) {}
std::string run(std::string in) {
return step_(std::move(in));
}
private:
std::function<std::string(std::string)> step_;
};

ME 33

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <map>
#include <string>
#include <string_view>

// 面向接口:抽象只描述"能做什么"
class Storage {
public:
virtual ~Storage() = default;
virtual void put(std::string_view key,
std::string_view val) = 0;
};

// 具体实现 final:不鼓励继承具体类
class MemoryStorage final : public Storage {
public:
void put(std::string_view key,
std::string_view val) override {
data_[std::string(key)] = std::string(val);
}
private:
std::map<std::string, std::string> data_;
};

Item 52

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <string>

enum class RetryPolicy { None, Fixed, Exponential };

std::string describe(RetryPolicy p) {
switch (p) { // 编译器检查:漏掉枚举值即警告
case RetryPolicy::None:
return "no retry";
case RetryPolicy::Fixed:
return "fixed backoff";
case RetryPolicy::Exponential:
return "exponential backoff";
}
return "unknown"; // 覆盖完整时不可达
}

Item 53

1
2
3
4
5
6
7
8
9
#include <system_error>

[[nodiscard]] std::error_code write_config();

int main() {
if (std::error_code ec = write_config(); ec) {
// 显式面对错误;忽略返回值则触发警告
}
}

现代检查清单

两本书 90 条,压缩成一张当代备忘录:

#规则出处
1资源只经 RAII 对象持有,裸 new/delete 不出手I-13,16,17
2make_unique/make_shared 优先于裸 newI-17
3Rule of Zero 优先;声明析构就补全五个(或 =default)I-5,6
4多态基类虚析构;不经基类指针删则 protected 析构I-7
5析构不抛异常(语言已强制);移动/swap 标 noexceptI-8, ME-11,14
6构造/析构中不调虚函数、不向派生方向转型I-9
7拷贝/移动赋值处理自赋值,拷贝所有部分含基类I-11,12
8接口用类型挡错误:强类型参数、[[nodiscard]]、工厂返回 unique_ptrI-18
9传参看语义:小值传值、只读大对象 const&、序列传 string_view/span、sink 传值再 moveI-20
10放心按值返回(C++17 强制消除);绝不返回局部对象的引用I-21
11对称运算符用 hidden friend 非成员I-24, ME-21
12定义变量即初始化,定义尽量推迟I-26
13少 cast;dynamic_cast 成片 = 设计问题I-27
14不返回内部句柄;警惕视图悬垂I-28
15接口标注异常安全等级;强保证用 copy-and-swapI-29
16Pimpl 配 unique_ptr 时析构移到 .cpp;新项目评估 C++20 模块I-31
17is-a 才公有继承;has-a / 以……实现用组合I-32,38
18虚函数可定制时先想 NVI、std::function、CRTP、variantI-35, ME-31
19默认参数只出现在非虚函数上I-37
20不重载 &&/`
21转换运算符一律 explicit(operator bool 除外场景同理)ME-5
22按引用 catch,throw; 重抛ME-13
23profiler 找热点;惰性(ranges)与缓存(记忆化)按访问概率二选一ME-16,17,18
24类型集合封闭的多分派用 variant + visitME-31

两本书的核心遗产不是那 90 条结论,而是得出结论的方法:追问每条语法背后的机制(对象何时构造、资源何时释放、类型何时绑定),机制不变结论就不过时。Modern C++ 给了新的工具,但需要回答的问题,还是 Meyers 在 1996 年提出的那批。