本章涵盖了从 C++98 过渡到现代 C++ (C++11/14) 所需掌握的关键特性和最佳实践。

条款七:区别使用 (){} 创建对象

初始化语法的选择在现代 C++ 中至关重要。

  • 初始化语法的混乱:C++98 提供了圆括号 ()、等号 = 和花括号 {} 等多种初始化方式,容易造成混淆。

  • 统一初始化(括号初始化):C++11 引入了基于花括号 {} 的统一初始化,可用于几乎所有场景,包括容器初始化、非静态数据成员默认初始化等。

    C++

     std::vector<int> v{ 1, 3, 5 };
     class Widget { int x{ 0 }; };
  • 括号初始化的优势

    • 防止变窄转换:禁止将高精度数值隐式转换为低精度(如 doubleint)。

    • 免疫“最令人头疼的解析”Widget w3{}; 明确调用默认构造函数,不会被误解析为函数声明。

  • 括号初始化的劣势

    • std::initializer_list 的强吸引力:如果构造函数中包含 std::initializer_list 形参,括号初始化会强烈倾向于调用该构造函数,即使其他构造函数匹配度更高,甚至导致编译错误(如变窄转换失败)。

    • 空花括号的含义{} 表示没有实参(调用默认构造函数),除非用于初始化 std::initializer_list 或作为另一层括号内的参数。

  • 建议

    • 在创建对象时,需意识到花括号和圆括号可能调用不同的构造函数(特别是对于 std::vectorstd::make_unique 等)。

    • 类设计者应避免让括号初始化和圆括号初始化产生极其不同的重载决议结果。

条款八:优先考虑 nullptr 而非 0NULL

nullptr 是现代 C++ 中表示空指针的正确方式。

  • 0NULL 的问题:它们本质上是整型,而非指针类型。这会导致重载决议时的二义性或错误调用(如调用 f(int) 而非 f(void*))。

  • nullptr 的优势

    • 类型安全nullptr 的类型是 std::nullptr_t,它可以隐式转换为任何指针类型,但不能转换为整型。

    • 消除歧义:使用 nullptr 调用重载函数时,总是会匹配指针版本。

    • 提升可读性:明确表达空指针意图,尤其在与 auto 结合使用时。

    • 模板友好:在模板类型推导中,nullptr 被推导为 std::nullptr_t,能正确传递给期望指针的函数,而 0 会被推导为 int 导致类型错误。

条款九:优先考虑别名声明而非 typedef

别名声明(Alias Declaration)是 typedef 的现代替代品。

  • 基本用法using UPtrMapSS = std::unique_ptr<...>;typedef 功能相同,但语法更直观(类似赋值)。

  • 核心优势:别名模板:别名声明支持模板化,而 typedef 不支持(需要嵌套在 struct 中)。

    C++

     template<typename T>
     using MyAllocList = std::list<T, MyAlloc<T>>; // 别名模板
  • 避免依赖类型后缀:在模板中使用别名模板定义的类型时(如 MyAllocList<T>),它是一个非依赖类型,无需加 typename 前缀。而使用嵌套 typedef(如 MyAllocList<T>::type)则是依赖类型,必须加 typename

  • Type Traits:C++14 为 C++11 的 type traits 提供了别名模板版本(如 std::remove_const_t<T> 代替 typename std::remove_const<T>::type),更加简洁易用。

条款十:优先考虑限域 enum 而非未限域 enum

限域枚举(Scoped Enum,也称枚举类)解决了传统枚举的多个问题。

  • 未限域枚举的问题

    • 命名空间污染:枚举名会泄漏到包含它的作用域中。

    • 隐式类型转换:枚举名会隐式转换为整型,导致类型不安全的比较和操作。

    • 前置声明限制:在 C++98 中无法前置声明(除非指定底层类型,C++11 支持)。

  • 限域枚举的优势 (enum class)

    • 作用域限制:枚举名仅在枚举类内部可见(需用 Color::red 访问)。

    • 强类型:不存在到整型的隐式转换,需显式 static_cast

    • 可前置声明:默认底层类型为 int,总是可以前置声明,减少编译依赖。

  • 未限域枚举的用武之地:在与 std::tuple 配合时,作为索引访问字段(隐式转换为 size_t)比限域枚举更方便。但可以通过编写 toUType 辅助函数来弥补限域枚举的这一不足。

条款十一:优先考虑使用 deleted 函数而非使用未定义的私有声明

明确禁止特定函数的使用。

  • 旧方法(C++98):将拷贝构造/赋值运算符声明为 private 且不定义。错误通常在链接期报出,且错误信息不直观。

  • 新方法(C++11):使用 = delete 将函数标记为已删除。

    C++

     basic_ios(const basic_ios&) = delete;
  • 优势

    • 编译期检查:任何调用都会在编译期报错。

    • 适用范围广:可用于任何函数(包括非成员函数),不仅限于成员函数。

    • 禁止特定重载:可删除特定参数类型的重载(如禁止 isLucky(char)),或禁止特定模板特化(如禁止 processPointer<void>)。

  • 注意:Deleted 函数通常应声明为 public,以便编译器生成更准确的“函数已删除”错误,而非“私有访问”错误。

条款十二:使用 override 声明重写函数

确保虚函数重写的正确性。

  • 重写的陷阱:重写虚函数要求基类和派生类函数的签名完全一致(包括参数、常量性、引用限定符等)。微小的差异(如 const 缺失、参数类型微变)会导致派生类函数变成一个新的虚函数,而非重写,且编译器可能不报错。

  • override 关键字:显式标记派生类函数为重写。如果签名不匹配,编译器会报错。

  • 引用限定符:C++11 允许限制成员函数只能在对象为左值 (&) 或右值 (&&) 时调用。重写时引用限定符也必须匹配。

    C++

     void doWork() &;  // *this 为左值时调用
     void doWork() &&; // *this 为右值时调用(可优化移动操作)

条款十三:优先考虑 const_iterator 而非 iterator

只要不修改容器内容,就应使用 const_iterator

  • C++98 的局限:获取和使用 const_iterator 很麻烦,且难以与 insert/erase 等只能接受 iterator 的函数配合。

  • C++11 的改进

    • 容器提供了 cbegin()cend() 成员函数,直接返回 const_iterator

    • STL 算法和容器成员函数(如 insert)现在全面支持 const_iterator

  • 通用代码建议:在编写通用库时,优先使用非成员函数版本的 begin/end(C++14 补全了 cbegin/cend 等非成员函数,C++11 可自行实现)以适应原生数组和自定义容器。

条款十四:如果函数不抛出异常请使用 noexcept

优化性能并明确接口契约。

  • noexcept 的意义:保证函数不会抛出异常。是函数接口的重要组成部分。

  • 性能优化

    • noexcept 函数允许编译器生成更优化的代码(不需要维护栈展开状态)。

    • 移动语义std::vector 等容器在扩容时,只有当移动构造函数是 noexcept 时,才会使用移动操作代替拷贝操作,以保证强异常安全。

  • 适用场景:移动操作、swap 函数、内存释放函数、析构函数(默认为 noexcept)应尽可能声明为 noexcept

  • 注意:大多数函数是异常中立的(可能会传播异常),不应声明为 noexcept。仅在确定不会抛出异常时使用。

条款十五:尽可能的使用 constexpr

将计算提前到编译期。

  • constexpr 对象:不仅是 const,而且值在编译期已知。可用于数组大小、模板参数、枚举值等。

  • constexpr 函数

    • 若实参是编译期常量,则产出编译期常量。

    • 若实参是运行时值,则像普通函数一样在运行时计算。

    • 限制:C++11 中限制较多(只能有一条 return),C++14 放宽了限制(允许循环、多语句等)。

  • constexpr:构造函数和 getter/setter 都可以是 constexpr,允许在编译期创建和操作对象(如 Point 类)。

  • 建议:尽可能使用 constexpr,因为它极大地扩展了对象和函数的适用范围,且不损失运行时性能。

条款十六:让 const 成员函数线程安全

const 意味着“只读”,在并发环境下应意味着“线程安全”。

  • 问题:如果 const 成员函数内部修改了 mutable 成员(如缓存),在多线程环境下会导致数据竞争。

  • 解决方案

    • 互斥量 (std::mutex):使用 mutable std::mutex 保护共享数据。适用于复杂操作或多个相关变量。

    • 原子变量 (std::atomic):对于简单的计数器或标志位,使用 mutable std::atomic 开销更小。

  • 注意std::atomic 仅适用于单个变量的同步。若涉及多个变量的联动(如检查标志位后读取值),仍需使用互斥量以防止指令重排或竞争。

  • 原则:除非确定永远不会在并发上下文中使用,否则必须确保 const 成员函数是线程安全的。

条款十七:理解特殊成员函数的生成

C++11 引入了移动操作,改变了特殊成员函数的生成规则。

  • 特殊成员函数:默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值。

  • 移动操作的生成条件:仅当类中没有显式声明拷贝操作、移动操作或析构函数时,编译器才会自动生成移动操作。

  • Rule of Three/Five

    • 如果声明了析构函数、拷贝构造或拷贝赋值中的任何一个,通常意味着需要管理资源,因此不应自动生成移动操作。

    • 声明移动操作会删除 (delete) 自动生成的拷贝操作。

  • = default:如果默认行为是正确的(如成员均可移动/拷贝),应显式使用 = default 声明特殊成员函数,以明确意图并恢复被抑制的自动生成。

  • 陷阱:仅添加析构函数(如为了打日志)就会阻止移动操作的生成,导致对象在“移动”时实际上执行的是昂贵的拷贝操作。

  • 成员函数模板:不会抑制特殊成员函数的生成。