《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 ; };Point shifted (Point p, int dx) { p.x += dx; return p; }struct Base { virtual ~Base () = default ; };struct Derived : Base { int extra = 0 ; };void by_ref (const Derived& d) ; int main () { 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 std::uint16_t kMaxRetries = 3 ;inline constexpr double kPi = 3.14159'26535'89793 ;enum class LogLevel : std::uint8_t { Debug, Info, Error };constexpr int square (int x) { return x * x; }static_assert (square (7 ) == 49 );
仍适用 Item 3:尽可能使用 constconst 依旧是语言的核心机制: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_; };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 (); 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); 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 ; };long hits = 3 , total = 4 ;double ratio = static_cast <double >(hits) / static_cast <double >(total);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; } } 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 () { 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 ; }
仍适用 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 ; }
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; } catch (const std::exception&) { assert (live == 0 ); } }
仍适用 ME 8:区分 new 表达式、operator new 与 placement newnew 表达式把"分配内存 + 构造对象"合成一步;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 () { 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 );
需更新 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 () { 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_; };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 (); 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>(); auto b = std::move (a); }
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; child->parent = parent; }
构造、析构与拷贝控制 需更新 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 { 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 (); }
需更新 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 () { 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 { 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; }
仍适用 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)) { } Widget (const Widget&) = default ; Widget& operator =(const Widget& rhs) { Widget tmp (rhs); swap (tmp); return *this ; } 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 }) ; }
注意 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 }; bool same = (q == Quantity{3.0 }); 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 : std::size_t count (std::string_view word) const ; double avg (const std::vector<int >& samples) const ; 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 ; };
仍适用 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 ) : 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; 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_; };void swap (Buffer& a, Buffer& b) noexcept { a.swap (b); }void exchange (Buffer& a, Buffer& b) { using std::swap; swap (a, b); }
实现细节 仍适用 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; }
仍适用 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 int kThreads = 4 ;constexpr int buffer_for (int items) { return items * 64 ; }static_assert (buffer_for (kThreads) == 256 );
仍适用 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 #include <memory> class Widget {public : Widget (); ~Widget (); private : struct Impl ; std::unique_ptr<Impl> impl_; };#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 ;
第二代是 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; 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 ); 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 (); }
仍适用 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 {} };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 (); 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]] 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> 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 }));
仍适用 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; }
仍适用 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 (); } };
仍适用 Item 44:将与参数无关的代码抽离模板代码膨胀仍是真实代价;共通矩阵/维度参数外提的思路不变:
1 2 3 4 5 6 7 8 9 10 11 #include <cstddef> 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{}; }
仍适用 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 ; 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>{}; }
仍适用 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) {} 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; auto b = r * 2 ; return a.num () + b.den (); }
需更新 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 unsigned long long factorial (unsigned n) { return n <= 1 ? 1ULL : n * factorial (n - 1 ); }static_assert (factorial (5 ) == 120 );
运算符重载 需更新 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_; } private : bool ok_ = true ; };int main () { Stream s; if (s) {} }
仍适用 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 ; return next.value (); }
仍适用 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; }
异常安全 仍适用 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); } 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 ); } }
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) { throw ; } }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 ; 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 ())); }
仍适用 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); 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; }
仍适用 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; out += '\n' ; out += body; return out; }
已过时 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 () { 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> 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 ; };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 : 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; 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) { 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 ; auto ref = bits[3 ]; 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 ; };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); auto r2 = std::visit (Collide{}, e2, e1); 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 { 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) ; }
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); }
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 : 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; delete b; }
仍适用 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:熟悉 BoostBoost 从"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 ; }
仍适用 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" {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 ; } }
仍适用 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 ; };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 2 make_unique/make_shared 优先于裸 newI-17 3 Rule of Zero 优先;声明析构就补全五个(或 =default) I-5,6 4 多态基类虚析构;不经基类指针删则 protected 析构 I-7 5 析构不抛异常(语言已强制);移动/swap 标 noexcept I-8, ME-11,14 6 构造/析构中不调虚函数、不向派生方向转型 I-9 7 拷贝/移动赋值处理自赋值,拷贝所有部分含基类 I-11,12 8 接口用类型挡错误:强类型参数、[[nodiscard]]、工厂返回 unique_ptr I-18 9 传参看语义:小值传值、只读大对象 const&、序列传 string_view/span、sink 传值再 move I-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-swap I-29 16 Pimpl 配 unique_ptr 时析构移到 .cpp;新项目评估 C++20 模块 I-31 17 is-a 才公有继承;has-a / 以……实现用组合 I-32,38 18 虚函数可定制时先想 NVI、std::function、CRTP、variant I-35, ME-31 19 默认参数只出现在非虚函数上 I-37 20 不重载 &&/` 21 转换运算符一律 explicit(operator bool 除外场景同理) ME-5 22 按引用 catch,throw; 重抛 ME-13 23 profiler 找热点;惰性(ranges)与缓存(记忆化)按访问概率二选一 ME-16,17,18 24 类型集合封闭的多分派用 variant + visit ME-31
两本书的核心遗产不是那 90 条结论,而是得出结论的方法:追问每条语法背后的机制(对象何时构造、资源何时释放、类型何时绑定),机制不变结论就不过时。Modern C++ 给了新的工具,但需要回答的问题,还是 Meyers 在 1996 年提出的那批。