更新 C++ 风格指南

This commit is contained in:
Yu Lou 2024-04-12 23:58:12 -07:00
parent ba825d0a1a
commit 982a930438
3 changed files with 130 additions and 143 deletions

View File

@ -1,143 +1,135 @@
3. 类
------------------------
是 C++ 中代码的基本单元. 显然, 它们被广泛使用. 本节列举了在写一个类时的主要注意事项.
(class) 是 C++ 中最基本的代码单元. 因此,类在 C++ 中被广泛使用. 本节将列出编写类时需要注意的事项.
3.1. 构造函数的职责
3.1. 构造函数的内部操作
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
不要在构造函数中调用虚函数, 也不要在无法报出错误时进行可能失败的初始化.
构造函数 (constructor) 中不得调用虚函数 (virtual method). 不要在没有错误处理机制的情况下进行可能失败的初始化.
**定义**
在构造函数中可以进行各种初始化操作.
构造函数可以执行任意初始化操作.
**优点**
- 无需考虑类是否被初始化.
- 经过构造函数完全初始化后的对象可以为 ``const`` 类型, 也能更方便地被标准容器或算法使用.
- 无需担心类是否已经初始化.
- 由构造函数完全初始化的对象可以是 ``const`` 类型, 也方便在标准容器或算法中使用.
**缺点**
- 如果在构造函数内调用了自身的虚函数, 这类调用是不会重定向到子类的虚函数实现. 即使当前没有子类化实现, 将来仍是隐患.
- 在没有使程序崩溃 (因为并不是一个始终合适的方法) 或者使用异常 (因为已经被 :ref:`禁用 <exceptions>` 了) 等方法的条件下, 构造函数很难上报错误
- 如果执行失败, 会得到一个初始化失败的对象, 这个对象有可能进入不正常的状态, 必须使用 ``bool IsValid()`` 或类似这样的机制才能检查出来, 然而这是一个十分容易被疏忽的方法.
- 构造函数的地址是无法被取得的, 因此, 举例来说, 由构造函数完成的工作是无法以简单的方式交给其他线程的.
- 在构造函数内调用虚函数时, 不会分派 (dispatch) 到子类的虚函数实现 (implementation). 即使当前还没有子类, 将来也是隐患, 会引发混乱.
- 构造函数难以报告错误, 只能让程序崩溃 (有时不合适) 或者用 :ref:`异常 <exceptions>` (我们禁止使用) 来表示错误.
- 如果初始化失败,对象将处于异常状态. 为了检查对象有效性,需要额外添加 ``IsValid()`` 等状态检查机制, 但用户容易忘记调用这些检查.
- 由于无法获取构造函数的地址, 因此难以将构造函数的工作转交给其他线程执行.
**结论**
构造函数不允许调用虚函数. 如果代码允许, 直接终止程序是一个合适的处理错误的方式. 否则, 考虑用 ``Init()`` 方法或工厂函数, 参考 `TotW #42 <https://abseil.io/tips/42>`_.
构造函数不得调用虚函数, 或尝试报告一个非致命错误. 如果对象需要进行有意义的 (non-trivial) 初始化, 考虑使用明确的 Init() 方法或使用工厂模式. 避免在没有其他状态或标志位表征某个公有的函数是否可以被调用的类中使用 ``Init()``方法(此类形式的半构造对象有时无法正确工作).
构造函数不允许调用虚函数. 合适时, 终止程序也是一种处理错误的方式. 否则, 可以像 `第 42 号每周提示 <https://abseil.io/tips/42>`_ 一样定义 ``Init()`` 方法或工厂函数 (factory function). 若要添加 ``Init()`` 方法, 应该确保可以从对象的状态中得知哪些公用方法是可用的. 若在对象未构造完成时调用其方法, 很容易导致错误.
.. _implicit-conversions:
3.2. 隐式类型转换
~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
不要定义隐式类型转换. 对于转换运算符和单参数构造函数, 请使用 ``explicit`` 关键字.
不要定义隐式类型转换. 定义类型转换运算符和单个参数的构造函数时, 请使用 ``explicit`` 关键字 (keyword).
**定义**
隐式类型转换允许一个某种类型 (称作 *源类型*) 的对象被用于需要另一种类型 (称作 *目的类型*) 的位置, 例如, 将一个 ``int`` 类型的参数传递给需要 ``double`` 类型的函数.
可以通过隐式转换, 在需要目标类型的地方使用源类型的对象. 例如向接受 ``double`` 参数的函数传入 ``int`` 类型的参数.
除了语言所定义的隐式类型转换, 用户还可以通过在类定义中添加合适的成员定义自己需要的转换. 在源类型中定义隐式类型转换, 可以通过目的类型名的类型转换运算符实现 (例如 ``operator bool()``). 在目的类型中定义隐式类型转换, 则通过以源类型作为其唯一参数 (或唯一无默认值的参数) 的构造函数实现.
除了语言内置的隐式类型转换, 用户还可以在类中定义特定的成员来添加自定义转换函数. 若要在源类型中定义隐式类型转换, 可以定义一个以目标类型命名的类型转换运算符 (例如 ``operator bool()``). 若要在目标类型中定义隐式类型转换, 则可以定义一个以源类型为唯一参数 (或唯一无默认值的参数) 的构造函数.
``explicit`` 关键字可以用于构造函数或类型转换运算符, 以保证只有当目的类型在调用点被显式写明时才能进行类型转换, 例如使用 ``cast``. 这不仅作用于隐式类型转换, 还能作用于初始化列表语法:
可以在构造函数或类型转换运算符上添加 ``explicit`` 关键字, 确保使用者必须明确指定目标类型, 例如使用转换 (cast) 运算符. 该关键字也适用于列表初始化:
.. code-block:: c++
.. code-block:: c++
class Foo {
explicit Foo(int x, double y);
...
};
class Foo {
explicit Foo(int x, double y);
...
};
void Func(Foo f);
void Func(Foo f);
此时下面的代码是不允许的:
.. code-block:: c++
.. code-block:: c++
Func({42, 3.14}); // 报错
Func({42, 3.14}); // Error
这一代码从技术上说并非隐式类型转换, 但是语言标准认为这是 ``explicit`` 应当限制的行为.
此类代码并非真正的隐式类型转换, 但语法标准将其视为类似的转换, 因此也适用于 ``explicit`` 关键字.
**优点**
- 有时目的类型名是一目了然的, 通过避免显式地写出类型名, 隐式类型转换可以让一个类型的可用性和表达性更强.
- 有时目标类型显而易见. 隐式类型转换无需明确指定类名, 使类型更易用、更简洁.
- 隐式类型转换可以简单地取代函数重载, 例如使用带有一个 ``string_view`` 参数的函数来取代对 ``std::string````const char*`` 的重载函数.
- 隐式类型转换可以简化函数重载 (overload), 例如只需定义一个接受 ``string_view`` 参数的函数, 即可取代 ``std::string````const char*`` 的重载函数.
- 初始化对象时, 列表初始化语法是一种简洁明了的写法.
- 初始化对象时, 列表初始化语法简洁明了.
**缺点**
- 隐式类型转换会隐藏类型不匹配的错误. 有时, 目的类型并不符合用户的期望, 甚至用户根本没有意识到发生了类型转换.
- 隐式转换可能掩盖类型不匹配错误. 有时目标类型与用户预期不符, 甚至用户不知道会出现类型转换.
- 隐式类型转换会让代码难以阅读, 尤其是在有函数重载的时候, 因为这时很难判断到底是哪个函数被调用.
- 隐式转换降低了代码可读性. 尤其是存在函数重载的情况下, 难以判断实际调用的函数.
- 单参数构造函数有可能会被无意地用作隐式类型转换.
- 单参数构造函数可能被意外当作隐式类型转换, 这可能不是作者的本意.
- 如果单参数构造函数没有加上 ``explicit`` 关键字, 读者无法判断这一函数究竟是要作为隐式类型转换, 还是作者忘了加上 ``explicit`` 标记.
- 如果单参数构造函数没有 ``explicit`` 标记, 读者无法判断这是隐式类型转换还是遗漏了 ``explicit`` 标记.
- 隐式类型转换可能会导致调用点歧义, 尤其是存在双向隐式转换时. 这可能是因为两种类型都提供隐式转换, 或者一种类型同时存在隐式构造函数和隐式转换运算符.
- 隐式类型转换可能导致调用歧义 (call-site ambiguity), 尤其是存在双向隐式转换的情况下. 这种情况可能是因为两种类型都定义了隐式转换, 或者一种类型同时定义了隐式构造函数和隐式转换运算符.
- 如果目的类型是隐式指定的, 那么列表初始化会出现和隐式类型转换一样的问题, 尤其是在列表中只有一个元素的时候.
- 如果隐式指定列表初始化时的目标类型, 那么同样存在上述问题, 尤其是在列表只有一个元素的情况下.
**结论**
在类型定义中, 类型转换运算符和单参数构造函数都应当用 ``explicit`` 进行标记. 一个例外是, 拷贝和移动构造函数不应当被标记为 ``explicit``, 因为它们并不执行类型转换.
在类的定义中, 类型转换运算符和单参数构造函数都应该标记为 ``explicit``. 拷贝和移动构造函数除外, 因为它们不执行类型转换.
对于设计目的就是需要互换的类型来说, 隐式类型转换有时是必要且合适的. 例如, 两种类型的对象只是对同一个值的不同表示. 在这种情况下, 请联系项目负责人说明特殊情况.
对于某些可互换的类型, 隐式类型转换是必要且恰当的, 例如两种类型的对象只是同一底层值的不同表示形式. 此时请联系项目负责人申请豁免.
单参数构造函数不应当省略 ``explicit``. 接受一个 ``std::initializer_list`` 作为参数的构造函数应当省略 ``explicit``, 以便支持拷贝初始化 (例如 ``MyType m = {1, 2};``).
接受多个参数的构造函数可以省略 ``explicit`` 标记. 接受单个 ``std::initializer_list`` 参数的构造函数也应该省略 ``explicit`` 标记, 以支持拷贝初始化 (copy-initialization, 例如 ``MyType m = {1, 2};``).
.. _copyable-and-movable-types:
3.3. 可拷贝类型和可移动类型
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
一个类的公有接口必须明确这个类是可复制构造/可移动构造/不可复制和移动构造的. 若复制构造和移动构造对于某一类型是清晰且有意义的, 则支持这些操作.
类的公有接口必须明确指明该类是可拷贝的、仅可移动的、还是既不可拷贝也不可移动的. 如果该类型的复制和移动操作有明确的语义并且有用,则应该支持这些操作.
**定义**
可移动类型允许对象从临时变量进行初始化.
可移动类型 (movable type) 可以用临时变量初始化或赋值.
可拷贝类型允许对象从另一个相同类型的对象进行初始化 (因此从定义上来说也是可移动的), 同时不改变源对象的值. ``std::unique_ptr<int>`` 就是一个可移动但不可复制的对象的例子 (因为智能指针对应的值必须在拥有所有权的对象中修改). ``int````string`` 为既可移动也可复制的例子 (对于 ``int`` 类型, 移动和复制操作时相同的, 对于 ``std::string`` 类型, 移动操作比复制操作开销更低).
可拷贝类型 (copyable type) 可以用另一个相同类型的对象初始化 (因此从定义上来说也是可移动的), 同时源对象的状态保持不变. ``std::unique_ptr<int>`` 是可移动但不可拷贝类型的例子 (因为在赋值过程中必须修改提供初始值的 ``std::unique_ptr<int>`` 对象). ``int````std::string`` 是既可移动也可拷贝的例子. (``int`` 类型的移动和拷贝操作等效. ``std::string`` 类型的移动操作比拷贝操作的开销更低.)
对于用户自定义的类型, 复制行为由复制构造函数和复制构造赋值运算符来定义. 移动行为由移动构造函数和移动构造赋值运算符来定义, 否则由复制构造函数和复制构造赋值运算符来定义.
对于用户自定义的类型, 拷贝操作由拷贝构造函数 (copy constructor) 和拷贝赋值运算符 (copy-assignment operator) 定义. 移动操作由移动构造函数 (move constructor) 和移动赋值运算符 (move-assignment operator) 定义, 不存在时由拷贝构造函数和拷贝赋值运算符代替.
在某些情况下, 编译器可能会隐式地调用复制/移动构造函数. 例如, 通过传递值的方法传递对象时.
编译器会在某些情况下隐式调用拷贝或移动构造函数, 例如使用值传递 (pass by value) 传递参数时.
**优点**
可移动及可拷贝类型的对象可以通过传值的方式进行传递或者返回, 这使得 API 更简单, 更安全也更通用. 与传指针和引用不同, 这样的传递不会造成所有权, 生命周期, 可变性等方面的混乱, 也就没必要在协议中予以明确. 这同时也防止了客户端与实现在非作用域内的交互, 使得它们更容易被理解与维护. 这样的对象可以和需要传值操作的通用 API 一起使用, 例如大多数容器, 并允许它们在类型组合等方面提供额外的灵活性.
可移动和可拷贝类型的对象可以通过值传递来传递参数或返回值, 使 API 更简单、安全、通用. 与指针或引用传递 (pass by reference) 不同, 值传递不会造成所有权、生命周期和可变性 (mutability) 等方面的混乱, 也无需在调用约定中进行规定, 阻止了用户和定义者之间的跨作用域交互, 使代码更可读、更好维护, 并且更容易被编译器优化. 您可以在必须使用值传递的通用 API (例如大多数容器) 中使用这些对象, 同时它们在类型组合 (type composition) 等场景下具有更好的灵活性.
拷贝 / 移动构造函数与赋值操作一般来说要比它们的各种替代方案, 比如 ``Clone()``, ``CopyFrom()`` or ``Swap()``, 更容易定义, 因为它们能通过编译器产生, 无论是隐式的还是通过 ``= default``. 这种方式很简洁, 也保证所有数据成员都会被复制. 拷贝与移动构造函数一般也更高效, 因为它们不需要堆的分配或者是单独的初始化和赋值步骤, 同时, 对于类似 `省略不必要的拷贝 <https://en.cppreference.com/w/cpp/language/copy_elision>`_ 这样的优化它们也更加合适.
拷贝、移动构造函数和赋值运算符通常比其他替代方案 (比如 ``Clone()``, ``CopyFrom()````Swap()``) 更容易确保正确性, 因为可以隐式或显式用 ``= default`` 指示编译器自动生成代码. 这种写法简洁明了, 可以保证复制所有数据成员. 拷贝和移动构造函数通常更高效, 因为不需要分配堆内存, 不需要分离初始化和赋值的过程, 同时还适用于 `拷贝消除 <https://en.cppreference.com/w/cpp/language/copy_elision>`_ 等优化.
移动操作允许隐式且高效地将源数据转移出右值对象. 这有时能让代码风格更加清晰.
用户可以通过移动运算符隐式而高效地从右值对象 (rvalue) 中转移资源. 这有时可以使代码更加清晰.
**缺点**
某些类型并不需要拷贝, 为它们提供拷贝操作会让人迷惑, 也显得荒谬而不合理. 单例对象 (``Registerer``), 或与其他对象实体紧耦合的类型 (``Mutex``) 从逻辑上来说都不应该提供拷贝操作. 为基类提供拷贝 / 赋值操作是危险的, 因为在使用它们时会造成 `对象切割 <https://en.wikipedia.org/wiki/Object_slicing>`_ . 默认的或者随意的拷贝操作实现可能是不正确的, 这往往导致令人困惑并且难以诊断出的错误.
某些类型不应该支持拷贝. 为这些类型提供拷贝操作可能会产生误导性的、无意义甚至错误的结果. 对于单例对象的类型 (例如注册表)、用于特定作用域的类型 (例如 ``Cleanup``) 或与其他对象紧密耦合的类型 (例如互斥锁), 它们的拷贝操作没有意义. 为多态类型的基类提供拷贝运算符很危险, 因为使用此运算符时会导致 `对象切割 (object slicing) <https://en.wikipedia.org/wiki/Object_slicing>`_ . 默认或者随意编写的拷贝操作可能存在错误, 往往会引发令人困惑且难以诊断的错误.
拷贝构造函数是隐式调用的, 也就是说, 这些调用很容易被忽略. 这会让人迷惑, 尤其是对那些所用的语言约定或强制要求传引用的程序员来说更是如此. 同时, 这从一定程度上说会鼓励过度拷贝, 从而导致性能上的问题.
拷贝构造函数是隐式调用的, 因此很容易忽视这些调用. 一些其他编程语言经常或者强制使用引用传递. 习惯了这些语言的程序员可能会感到困惑. 同时, 这也容易导致过度拷贝, 引发性能问题.
**结论**
每个类的公有接口都应该明确这个类是否支持复制构造/移动构造. 通常应在 ``public`` 字段中显示的声明/删除对应的函数或操作符.
每个类的公有接口都应该明确指明该类是否支持拷贝和移动操作. 通常应在声明的 ``public`` 部分显式声明或删除对应的操作.
通常来说, 一个可复制的类应该显示声明复制操作, 一个只能移动的类应显示声明移动操作, 一个既不能复制也不能移动的类应该显示删除复制操作. 可复制类也应声明移动操作以支持高效的移动. 显示的声明或删除所有4个复制/移动操作是允许的, 但不是必须的. 如果您提供复制或移动赋值运算符, 则必须提供对应的构造函数.
具体来说, 可拷贝的类应该显式声明拷贝运算符, 仅能移动的类应该显式声明移动运算符, 既不能拷贝也不能移动的类应该显式删除复制运算符. 可拷贝的类也可以声明移动运算符, 以支持更高效的移动. 您可以显式声明或删除所有四个拷贝和移动运算符, 但这不是必需的. 如果您提供拷贝或移动赋值运算符, 则必须提供同类的构造函数.
.. code-block:: c++
@ -145,8 +137,8 @@
public:
Copyable(const Copyable& other) = default;
Copyable& operator=(const Copyable& other) = default;
// 隐式移动操作会被上面的声明抑制.
// 您可以声明移动操作来支持更高效的移动
// 上面的声明阻止了隐式移动运算符.
// 您可以显式声明移动操作以支持更高效的移动.
};
class MoveOnly {
@ -154,204 +146,199 @@
MoveOnly(MoveOnly&& other) = default;
MoveOnly& operator=(MoveOnly&& other) = default;
// 复制操作被隐式地删除, 但您可以显式地声明:
// 复制操作被隐式删除了, 但您也可以显式删除:
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
};
class NotCopyableOrMovable {
public:
// 既不可复制也不可移动
// 既不可复制也不可移动.
NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
NotCopyableOrMovable& operator=(NotCopyableOrMovable&) = delete;
// 移动操作被隐式删除, 但您可以显式声明:
// 移动操作被隐式删除, 但您可以显式声明:
NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
NotCopyableOrMovable& operator=(NotCopyableOrMovable&&) = delete;
};
当且仅当上述声明/删除为显而易见时, 您可以省略它们:
只有在以下显而易见的情况下, 才能省略上述声明:
- 如果一个类没有 ``private`` 部分, 例如结构体或仅包含接口的基类, 则可复制性/移动性可以由任意公有属性的数据成员的可复制性/可移动性来确定.
- 如果一个类没有 ``private`` 部分 (例如结构体或纯接口基类), 则该类型的可拷贝性和可移动性可以由其公有数据成员的可拷贝性/可移动性来确定.
- 如果一个基类是不可移动/复制的, 那么其派生类也不行. 一个隐式保留移动/复制操作的仅包含接口的基类无法说明具体子类的可复制性/可移动性.
- 如果一个基类明显是不可移动或不可拷贝的, 那么其派生类自然也是不可移动或不可拷贝的. 如果纯接口基类只是隐式声明这些操作, 则不足以明确说明子类的可复制性或可移动性.
- 注意到如果您显式声明/删除复制构造函数或复制操作赋值运算符的其中一个, 另一个也必须显式地声明/删除. 移动操作亦是如此.
- 注意, 如果您显式声明或删除拷贝构造函数或拷贝赋值运算符之一, 另一个也必须显式声明或删除. 移动操作亦如此.
如果用户不清楚一个类的复制/移动的含义, 或者不清楚复制/移动操作会产生意料之外的开销, 那么这个类应该是不可复制/移动的. 可复制类型的移动操作严格来说只是一种性能优化, 并且是错误和复杂性的潜在来源, 因此请避免定义它们, 除非它们的实现比复制操作更高效. 如果您的类是可复制的, 建议您在设计中保证默认的构造和赋值运算符是正确的. 请记得像检查您自己的代码一样地检查默认操作的正确性.
如果普通用户容易误解某个类的拷贝或移动操作的含义, 或者此操作会产生意想不到的开销, 那么这个类应该设计为不可拷贝或不可移动的. 可拷贝类型的移动操作只是一种性能优化, 容易增加复杂性并引发错误. 除非移动操作明显比拷贝操作更高效, 不要定义移动操作. 如果您的类可拷贝, 那么最好确保自动生成的默认 (default) 实现是正确的. 请像检查您自己的代码一样检查默认实现的正确性.
为了消除对象切片的风险, 最好将基类抽象化, 通常可以将构造和析构函数声明成protected属性, 或者提供一个或多个纯虚的成员函数. 尽量避免继承一个具体的类.
为了避免对象切割的风险, 基类最好是抽象 (abstract) 类. 若要声明抽象类, 可以将构造函数或析构函数声明为 ``protected``, 或者声明纯虚 (pure virtual) 成员函数. 尽量避免继承一个具体类 (concrete class).
.. _structs-vs-classes:
3.4. 结构体 VS.
3.4. 结构体还是
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
仅当只有数据成员时使用 ``struct``, 其它一概使用 ``class``.
只能用 ``struct`` 定义那些用于储存数据的被动对象. 其他情况应该使用 ``class``.
**说明**
C++ 中 ``struct````class`` 关键字的含义几乎一样. 我们自己为这两个关键字赋予了不同的语义, 所以您要选择合适的关键字.
在 C++ 中 ``struct````class`` 关键字的含义几乎是一样的. 我们为这两个关键字添加我们自己的语义理解, 以便为定义的数据类型选择合适的关键字.
应该用结构体定义用于储存数据的被动对象, 其中可能包含常量成员. 所有成员都必须是公共的. 结构体的成员之间不能存在不变式 (invariant) 关系, 因为用户直接访问这些成员时可能破坏不变式. 结构体可以有构造函数、析构函数和辅助方法, 但是这些函数不能要求或实现不变式.
``struct`` 用来定义包含数据的被动式对象, 也可以包含相关的常量, 并且它们的属性必须是公共的. 由于用户能直接访问 ``struct`` 中的所有字段, 因此 ``struct`` 中不能包含任何表示不同字段关系的不变量. ``struct`` 中可能存在构造函数、析构函数和辅助的函数方法, 这些方法不能要求任何不变量的值.
如果需要实现更多功能或不变式约束, 或者该结构体用途广泛并且会在未来不断更新, 那么类更合适. 在不确定的时候, 应该选择类.
如果需要更多的函数功能或不变量, 亦或者对象具有抽象性且可能会被继承, 那么 ``class`` 会更合适. 如果拿不准, 就用 ``class``.
为了与 STL 保持一致, 特征 (trait)、 :ref:`模板元函数 (template metafunction) <template-metaprogramming>` 、仿函数 (functor) 等无状态的类型可以使用结构体而不用类.
为了和 STL 保持一致, 对于traits技术、 :ref:`模板元编程 <template-metaprogramming>` 、仿函数等特性可以不用 ``class`` 而是使用 ``struct``.
注意到类和结构体的成员变量使用不同的 :ref:`命名规则 <variable-names>`.
注意, 类和结构体的成员变量具有不同的 :ref:`命名规则 <variable-names>`.
.. _structs-vs-pairs-and-tuples:
3.5. 结构体 VS. pair 和 tuple
3.5. 结构体、数对还是元组
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
当变量的命名有意义时, 倾向于使用 ``struct`` 而不是 pair 或 tuple.
如果可以给成员起一个有意义的名字, 应该用结构体而不是数对 (pair) 或元组 (tuple).
**说明**
虽然使用数对和元组可以免于定义自定义类型, 从而节省编写代码的时间, 但是有意义的成员名称通常比 ``.first``, ``.second````std::get<X>`` 更可读. C++14 引入了 ``std::get<Type>``, 只要某类型的元素唯一, 就可以根据类型而非下标来访问元组元素. 这在一定程度上缓解了问题, 但成员名称通常比类型名称更清晰、更有信息量.
使用 pair 或 tuple 可以省略为变量命名, 潜在地减少了写代码的开销, 但一个清晰的变量命名会让你的代码更加清晰, 相比于 ``.first``, ``.second``, ``std::get<X>`` 来说. 虽然 C++14 通过引入 ``std::get<Type>`` 来获取 tuple 中的变量 (而不是下标索引) 会让代码更清晰, 但一个带名字的变量总是会比上述方法更加显而易见.
pair 或 tuple 可能适用于通用代码, 其中的变量没有特定含义. 为了与现有的代码和 API 互操作时, 可能也需要使用它们.
数对和元组适合通用代码, 因为其中的元素没有特定含义. 与现有代码或 API 交互时也可能需要它们.
.. _inheritance:
3.6. 继承
~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
使用组合 (YuleFox 注: 这一点也是 GoF 在 <<Design Patterns>> 里反复强调的) 常常比使用继承更合理. 如果使用继承的话, 定义为 ``public`` 继承.
通常情况下, 组合 (composition) 比继承 (inheritance) 更合适. 请使用 ``public`` 继承.
**定义**
当子类继承基类时, 子类包含了父基类所有数据及操作的定义. “接口继承”指从纯抽象基类 (不包含状态或定义的方法) 继承; 所有其他继承都是“实现继承”.
当子类继承基类时, 子类会包含基类定义的所有数据及操作. **接口继承** (interface inheritance) 指从纯抽象基类 (pure abstract base class, 不包含状态或方法定义) 继承; 所有其他继承都是 **实现继承** (implementation inheritance).
**优点**
实现继承通过复用基类代码来减少代码大小. 由于继承是在编译时声明, 程序员和编译器都可以理解相应操作并发现错误. 接口继承是用来强制规定必须实现的 API. 在继承类中没有实现某个必须的 API 时, 编译器可以检测到错误.
实现继承复用了基类代码, 因此可以减少代码量. 继承是在编译时声明的, 因此您和编译器都可以理解并检查错误. 接口继承可以强制一个类公开特定 API. 当类没有定义 API 中的方法时, 编译器可以检测到错误.
**缺点**
对于实现继承, 由于子类的实现代码散布在父类和子类间之间, 要理解其实现变得更加困难. 子类不能重写父类的非虚函数, 因此无法修改其实现.
对于实现继承, 子类的实现代码散布在父类和子类之间, 因此更难理解. 子类不能重写 (override) 父类的非虚函数, 因此无法修改其实现.
多重继承可能引入很多问题, 因为它通常会带来更高的性能开销 (事实上, 从单继承到多重继承导致的性能下降会高于从普通方法到虚方法的性能下降), 并且它有导致“菱形继承”的风险, 容易造成歧义、混乱和彻底的错误.
多重继承的问题更严重, 因为这样通常会产生更大的性能开销 (事实上, 多重继承相比单继承的性能损失大于虚方法相比普通方法的性能损失). 此外, 这容易产生 **菱形继承** (diamond inheritance) 的模式, 造成歧义、混乱和严重错误.
**结论**
所有继承必须是 ``public`` 的. 如果您想使用私有继承, 您应该把该对象的实例保存在成员变量中. 当你不希望你的类被继承时, 应该使用 ``final`` 关键字.
所有继承都应该使用 ``public`` 的访问权限. 如果要实现私有继承, 可以将基类对象作为成员变量保存. 当您不希望您的类被继承时, 可以使用 ``final`` 关键字.
不要过度使用实现继承. 组合常常更合适一些. 尽量做到只在 "是一个" ("is-a", YuleFox 注: 其他 "has-a" 情况下请使用组合) 的情况下使用继承: 如果 ``Bar`` 的确 "是一种" ``Foo``, ``Bar`` 才能继承 ``Foo``.
不要过度使用实现继承. 组合 (composition) 通常更合适. 尽量只在 "什么是什么" ("is-a", YuleFox 注: 其他 "has-a" 情况下请使用组合) 关系的情况下使用继承: 如果 ``Bar`` 是一种 ``Foo``, 那么 ``Bar`` 才能继承 ``Foo``.
``protected`` 的变量和函数限制为那些可能需要从子类访问的成员函数. 注意到 :ref:`数据成员应该是私有的 <access-control>`.
只将子类可能需要访问的成员函数设为 ``protected``. 注意, :ref:`数据成员应该是私有的 <access-control>`.
使用 ``override````final`` (较少使用) 关键字来标识重载的虚函数或者虚的析构函数. 重载时不要使用 ``virtual`` . 基本原理: 如果一个函数带有 ``override````final`` 关键字, 但却不是基类虚函数的重载, 会出现编译报错, 有助于定位问题. 这些声明符也是一种标识, 如果不存在这些说明符, 代码阅读者需要查看基类来确定这些函数或者析构函数是否为虚的.
明确使用 ``override````final`` (较少使用) 关键字限定重写的虚函数或者虚析构函数. 重写时不要使用 ``virtual`` 关键字. 原因: 如果函数带有 ``override````final`` 关键字, 却没有正确重写基类的虚函数, 会导致编译错误, 有助于发现常见笔误. 这些限定符相当于文档. 如果不用限定符, 读者必须检查所有祖先类才能确定函数是否是虚函数.
多重继承是允许的, 但多重实现继承是强烈不推荐的.
允许多重继承, 但强烈建议避免多重实现继承.
.. _operator_overloading:
3.7. 运算符重载
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
明智地使用运算符重载. 不要使用用户自定义的字面值.
谨慎使用运算符重载 (overload). 禁止自定义字面量 (user-defined literal).
**定义**
C++ 允许用户通过使用 ``operator`` 关键字 `对内建运算符进行重载定义 <http://en.cppreference.com/w/cpp/language/operators>`_ , 只要其中一个参数是用户定义的类型. ``operator`` 关键字还允许用户使用 ``operator""`` 定义新的字面运算符, 并且定义类型转换函数, 例如 ``operator bool()``.
用户可以使用 ``operator`` 关键字来 `重载内置运算符 <http://en.cppreference.com/w/cpp/language/operators>`_ , 前提是其中一个参数是用户自定义类型. 用户还可以使用 ``operator""`` 定义一类新的字面量, 或者定义类型转换函数 (例如 ``operator bool()``).
**优点**
重载运算符可以让代码更简洁直观, 也使得用户定义的类型和内置类型拥有相似的行为. 重载运算符对于某些运算来说是符合语言习惯的名称 (例如 ``==``, ``<``, ``=``, ``<<``), 遵循这些语言约定可以让用户定义的类型更易读, 也能更好地和需要这些重载运算符的函数库进行交互操作.
重载运算符可以让用户定义的类型拥有与内置类型相似的行为, 使得代码更简洁直观. 重载运算符相当于特定操作的惯用名称 (例如 ``==``, ``<``, ``=``, ``<<``). 若用户定义的类型符合这些习惯, 则代码更易读, 并便于与使用这些名称的库进行互操作.
对于创建用户定义的类型的对象来说, 用户定义字面值是一种非常简洁的标记.
自定义字面量提供了一种创建自定义类型对象的简洁写法。
**缺点**
- 要提供正确, 一致, 不出现异常行为的操作符运算需要花费不少精力, 而且如果达不到这些要求的话, 会导致令人迷惑的 Bug.
- 需要花费精力才能实现正确、一致且符合预期的一组重载运算符. 稍有不慎就会引起困惑和错误.
- 过度使用运算符会带来难以理解的代码, 尤其是在重载的操作符的语义与通常的约定不符合时.
- 过度使用运算符会让代码难以理解, 特别是重载运算符的语义不合常理时.
- 函数重载有多少弊端, 运算符重载就至少有多少.
- 函数重载的弊端也同样适用于运算符重载,甚至更加严重.
- 运算符重载会混淆视听, 让你误以为一些耗时的操作和操作内建类型一样轻巧.
- 重载运算符可能会混淆视听, 让您把一些耗时的操作误以为是快速的内置运算.
- 对重载运算符的调用点的查找需要的可就不仅仅是像 grep 那样的程序了, 这时需要能够理解 C++ 语法的搜索工具.
- 要列出重载运算符的调用者, 需要能理解 C++ 语法的搜索工具, 无法使用 ``grep`` 等通用工具.
- 如果重载运算符的参数写错, 此时得到的可能是一个完全不同的重载而非编译错误. 例如: ``foo < bar`` 执行的是一个行为, 而 ``&foo < &bar`` 执行的就是完全不同的另一个行为了.
- 如果重载运算符的参数类型错误, 您可能会调用一个完全不同的重载函数, 而不是得到编译错误. 例如: ``foo < bar````&foo < &bar`` 会执行完全不同的代码.
- 重载某些运算符本身就是有害的. 例如, 重载一元运算符 ``&`` 会导致同样的代码有完全不同的含义, 这取决于重载的声明对某段代码而言是否是可见的. 重载诸如 ``&&``, ``||````,`` 会导致运算顺序和内建运算的顺序不一致.
- 某些运算符的重载是危险的. 例如, 重载一元运算符 ``&`` 时, 取决于重载声明是否在某段代码中可见, 同样的代码可能具有完全不同的含义. 重载的 ``&&``, ``||````,`` 与内置运算符的运算顺序不一致. (译者注: 例如, 内置的 ``&&`` 运算符会短路求值, 左侧为假时会跳过右侧的运算, 而重载的运算符不会短路.)
- 运算符从通常定义在类的外部, 所以对于同一运算, 可能出现不同的文件引入了不同的定义的风险. 如果两种定义都链接到同一二进制文件, 就会导致未定义的行为, 有可能表现为难以发现的运行时错误.
- 通常我们在类的定义以外定义运算符, 所以不同的文件可能对同一个运算有不同的定义. 如果同一二进制文件中链接 (link) 了两种定义, 结果就是未定义行为 (undefined behavior), 可能出现难以发现的运行时错误.
- 用户定义字面量 (UDLs) 所创建的语义形式对于某些有经验的 C++ 程序员来说都是很陌生的, 例如将 ``"Hello World"sv`` 作为 ``std::string_view("Hello World")`` 的简写. 现有的符号更清晰, 但不够简洁.
- 自定义字面量会创造新的语法, 例如将 ``std::string_view("Hello World")`` 简写为 ``"Hello World"sv``. 经验丰富的 C++ 程序员都对此感到陌生. 现有的语法更清晰, 尽管不够简洁.
- 用户定义字面量 (UDLs) 不能被命名空间限定, 所以对于它们的使用需要我们 :ref:`约定禁止 <namespaces>` 使用或者在头文件中禁止使用 (除非字面量必须出现在某个有问题的头文件的接口里). 鉴于头文件必须避免字面量后缀, 我们更希望能避免头文件的字面量和源文件的字面量不同的情况.
- 自定义字面量不能限制在命名空间中, 因此使用自定义字面量时必须同时使用 using 指令 (using-directive, 我们:ref:`禁止使用 <namespaces>`) 或 using 声明 (using-declaration, 我们禁止在头文件里使用, 除非导入的名称是需要暴露的接口). 因为头文件不能使用自定义字面量, 所以源文件的字面量格式也不应该与头文件不同.
**结论**
只有在意义明显, 不会出现奇怪的行为并且与对应的内建运算符的行为一致时才定义重载运算符. 例如, ``|`` 要作为位或/逻辑或来使用, 而不是作为 shell 中的管道.
重载运算符应该意义明确, 符合常理, 并且与对应的内置运算符行为一致. 例如, 应该用 ``|`` 表示位或/逻辑或, 而非类似 shell 的管道.
有对用户自己定义的类型重载运算符. 更准确地说, 将它们和它们所操作的类型定义在同一个头文件中, ``.cc`` 中和命名空间中. 这样做无论类型在哪里都能够使用定义的运算符, 并且最大程度上避免了多重定义的风险. 如果可能的话, 请避免将运算符定义为模板, 因为此时它们必须对任何可能得参数都起作用. 如果你定义了一个运算符, 请将其相关且有意义的运算符都进行定义, 并且保证这些定义的语义是一致的.
为您自己定义的类型定义重载运算符. 具体来说, 重载运算符和对应的类型应该在同一个头文件, ``.cc`` 文件和命名空间中. 这样, 任何使用该类型的代码都可以使用这些运算符, 避免了多重定义的风险. 尽量避免用模板定义运算符, 否则每个可能的模板类型都必须符合以上要求. 定义一个运算符时, 请同时定义相关且有意义的运算符, 并且保证语义一致.
建议不要将不进行修改的二元运算符定义为成员函数. 如果一个二元运算符被定义为类成员, 这时隐式转换会作用于右侧的参数却不会作用于左侧. 这时会出现 ``a + b`` 能够通过编译而 ``b + a`` 不能的情况, 这是很让人迷惑的.
建议将不修改数据的二元运算符定义为非成员函数. 如果二元运算符是成员函数, 那么右侧的参数可以隐式类型转换, 左侧却不能. 此时可能 ``a + b`` 能编译而 ``b + a`` 编译失败, 令人困惑.
于可以比较其值是否相等的类型 ``T``, 请定义非成员运算符 ``operator==`` 并且提供文档说明何时将类型为 ``T`` 的两个值视为相等. 如果一个类型为 ``T`` 的值 ``t1`` 小于 ``t2`` 是显而易见的, 那么你还应该定义 ``operator<==>``, 它的定义应该与 ``operator==`` 保持一致. 最好不要重载其他比较和排序运算符.
可以判断相等性的类型 ``T``, 请定义非成员运算符 ``operator==``, 并用文档说明什么条件下认为 ``T`` 的两个值相等. 如果类型 ``T`` 的“小于”的概念是显而易见的, 那么您也可以定义 ``operator<=>``, 并且保持与 ``operator==`` 的逻辑一致. 不建议重载其他的比较、排序运算符.
不要为了避免重载操作符而走极端. 比如, 应当定义 ``==``, ``=``,``<<`` 而不是 ``Equals()``, ``CopyFrom()````PrintTo()``. 反过来说, 不要只是为了满足函数库需要而去定义运算符重载. 比如说, 如果你的类型没有自然顺序, 而你要将它们存入 ``std::set`` 中, 最好还是定义一个自定义的比较运算符而不是重载 ``<``.
不要为了避免重载操作符而走极端. 比如, 应当定义 ``==``, ``=````<<`` 而不是 ``Equals()``, ``CopyFrom()````PrintTo()``. 另一方面, 不要仅仅因为其他库需要运算符重载而定义运算符. 比如, 如果您的类型没有自然顺序, 但您要在 ``std::set`` 中存储这样的对象, 最好使用自定义比较器 (comparator) 而不是重载 ``<``.
不要重载 ``&&``, ``||``, ``,`` 或一元运算符 ``&``. 不要重载 ``operator""``, 也就是说, 不要引入用户定义字面量. 不要使用任何其他人提供的字面量 (包括标准库).
不要重载 ``&&``, ``||``, ``,`` 或一元的 (unary) ``&`` 运算符. 不要重载 ``operator""``, 即不要引入自定义字面量. 不要使用其他人提供的任何自定义字面量 (包括标准库).
类型转换运算符:ref:`隐式类型转换 <implicit-conversions>` 一节有提及. ``=`` 运算符在 :ref:`可拷贝类型和可移动类型 <copyable-and-movable-types>` 一节有提及. 运算符 ``<<``:ref:`流 <streams>` 一节有提及. 同时请参见 :ref:`函数重载 <function-overloading>` 一节, 其中提到的的规则对运算符重载同样适用.
类型转换运算符的内容参见 :ref:`隐式类型转换 <implicit-conversions>`. ``=`` 运算符的内容参见 :ref:`拷贝构造函数 <copyable-and-movable-types>`. 关于重载用于流 (stream) 操作的 ``<<`` 运算符, 参见 :ref:`流 <streams>`. 另请参阅 :ref:`函数重载 <function-overloading>` 的规则, 这些规则也适用于运算符重载.
.. _access-control:
3.8. 存取控制
3.8. 访问控制
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
*所有* 数据成员声明为 ``private``, 除非是常量 (遵循 :ref:`常量命名规则 <constant-names>`). 这会简化关于常量的获取, 但需要形成一些带有 ``const`` 值获取的访问器.
类的 *所有* 数据成员应该声明为私有 (private), 除非是常量. 这样做可以简化类的不变式 (invariant) 逻辑, 代价是需要增加一些冗余的访问器 (accessor) 代码 (通常是 const 方法).
出于技术上的原因, 在使用 `Google Test <https://github.com/google/googletest>`_我们允许测试夹具类中的数据成员在 ``.cc`` 文件中定义为 ``protected``. 如果测试夹具类是在使用它的 ``.cc`` 文件之外定义的, 例如在 ``.h`` 文件中, 将数据成员变量声明为私有的.
由于技术原因, 在使用 `Google Test <https://github.com/google/googletest>`_, 我们允许在 ``.cc`` 文件中将测试夹具类 (test fixture class) 的数据成员声明为受保护的 (protected). 如果测试夹具类的声明位于使用该夹具的 ``.cc`` 文件之外 (例如在 ``.h`` 文件中), 则应该将数据成员设为私有.
.. _declaration-order:
3.9. 声明
3.9. 声明
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**总述**
.. tip::
将相似的声明放在一起, 将 ``public`` 部分放在最前.
将相似的声明放在一起. 公有 (public) 部分放在最前面.
**说明**
类的定义通常以 ``public:`` 开头, 其次是 ``protected:``, 最后以 ``private:`` 结尾. 空的部分可以省略.
类定义一般应以 ``public:`` 开始, 后跟 ``protected:``, 最后是 ``private:``. 省略空部分.
在各个部分中, 应该将相似的声明分组, 并建议使用以下顺序:
在各个部分中, 建议将类似的声明放在一起, 并且建议以如下的顺序:
1. 类型和类型别名 (``typedef``, ``using``, ``enum``, 嵌套结构体和类, 友元类型)
- 1. 类型及其别名 (包括 ``typedef``, ``using``, ``enum``, 嵌套的结构体与类以及友元类型)
2. (可选, 仅适用于结构体) 非静态数据成员
- 2. (可选, 仅用于 ``struct``) 非静态数据成员
3. 静态常量
- 3. 静态常量
4. 工厂函数 (factory function)
- 4. 工厂函数
5. 构造函数和赋值运算符
- 5. 构造函数和赋值运算符
6. 析构函数
- 6. 析构函数
7. 所有其他函数 (包括静态与非静态成员函数, 还有友元函数)
- 7. 所有的剩余函数 (静态与非静态成员函数, 以及友元函数)
8. 所有其他数据成员 (包括静态和非静态的)
- 8. 所有的剩余数据成员 (静态的和非静态的)
不要将大段的函数定义内联在类定义中. 通常, 只有那些普通的, 或性能关键且短小的函数可以内联在类定义中. 参见 :ref:`内联函数 <inline-functions>` 一节.
不要在类定义中放置大段的函数定义. 通常, 只有简单、对性能至关重要且非常简短的方法可以声明为内联函数. 参见 :ref:`内联函数 <inline-functions>` 一节.
译者 (YuleFox) 笔记
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -118,7 +118,7 @@
**定义:**
你可以通过声明编译器展开内联函数, 而不是使用正常的函数调用机制.
你可以通过声明建议编译器展开内联函数, 而不是使用正常的函数调用机制.
**优点:**
@ -134,7 +134,7 @@
另一个实用的经验准则: 内联那些有循环或 ``switch`` 语句的函数通常得不偿失 (除非这些循环或 ``switch`` 语句通常不执行).
应该注意, 即使函数被声明为内联函数, 也不一定真的会被内联; 比如, 通常虚函数和递归函数不会被内联. 通常不应该声明递归函数为内联. (YuleFox 注: 递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的, 大多数编译器都不支持内联递归函数). 为虚函数声明内联的主要目的是在类 (class) 中定义该函数, 以便于使用该函数或注释其行为. 这常用于存取函数和变异函数.
应该注意, 即使函数被声明为内联函数, 也不一定真的会被内联; 比如, 虚函数和递归函数通常不会被内联. 一般不应该声明递归函数为内联. (YuleFox 注: 递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的, 大多数编译器都不支持内联递归函数). 为虚函数声明内联的主要目的是在类 (class) 中定义该函数, 以便于使用该函数或注释其行为. 这常用于存取函数和变异函数.
.. _name-and-order-of-includes:

View File

@ -1,7 +1,7 @@
扉页
============
:版本: 2024/02/18
:更新时间: 2024/04/12
:原作者: