编码风格之(8)C++语言规范(Google风格)3.md

编码风格之(8)C++特性规范(Google风格)3


Author: Once Day Date: 2024年10月12日

一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦…

漫漫长路,有人对你微笑过嘛…

全系列文章可参考专栏: 源码分析_Once-Day的博客-CSDN博客

参考文章:


1. 高级特性
1.1 所有权和智能指针

对于动态分配的对象,最好有单一、固定的所有者。最好使用智能指针转移所有权。

“所有权”是一种用于管理动态分配内存(和其他资源)的簿记技术。动态分配对象的所有者是负责确保在不再需要时删除该对象或函数的对象或函数。所有权有时可以共享,在这种情况下,最后一个所有者通常负责删除它。即使所有权不共享,也可以从一段代码转移到另一段代码。

“智能”指针是像指针一样工作的类,例如,通过重载 * 和 -> 运算符。一些智能指针类型可用于自动化所有权簿记,以确保满足这些责任。std::unique_ptr 是一种智能指针类型,它表示动态分配对象的独占所有权;当 std::unique_ptr 超出范围时,该对象将被删除。它不能被复制,但可以移动以表示所有权转移。std::shared_ptr 是一种智能指针类型,它表示动态分配对象的共享所有权。std::shared_ptrs 可以被复制;对象的所有权在所有副本之间共享,并且当最后一个 std::shared_ptr 被销毁时,该对象将被删除。

优点:

  • 如果没有某种所有权逻辑,几乎不可能管理动态分配的内存。
  • 转移对象的所有权可能比复制它更便宜(如果可以复制的话)。
  • 转移所有权可能比“借用”指针或引用更简单,因为它减少了在两个用户之间协调对象生命周期的需要。
  • 智能指针可以通过使所有权逻辑明确、自文档化和无歧义来提高可读性。
  • 智能指针可以消除手动所有权簿记,简化代码并排除大量错误。
  • 对于 const 对象,共享所有权可以成为深度复制的简单而有效的替代方案。

缺点:

  • 所有权必须通过指针(无论是智能指针还是普通指针)来表示和转移。指针语义比值语义更复杂,尤其是在 API 中:您不仅要担心所有权,还要担心别名、生命周期和可变性等问题。
  • 值语义的性能成本通常被高估,因此所有权转移的性能优势可能无法证明可读性和复杂性成本的合理性。
  • 转移所有权的 API 会强制其客户端采用单一内存管理模型。
  • 使用智能指针的代码对资源释放发生的位置不太明确。
  • std::unique_ptr 使用移动语义来表达所有权转移,这相对较新,可能会让一些程序员感到困惑。
  • 共享所有权可能是精心所有权设计的一种诱人的替代方案,会使系统的设计变得模糊不清。
  • 共享所有权需要在运行时进行显式记账,这可能会很昂贵。
  • 在某些情况下(例如循环引用),具有共享所有权的对象可能永远不会被删除。
  • 智能指针并不是普通指针的完美替代品。

如果需要动态分配,则最好将所有权保留在分配它的代码中。如果其他代码需要访问该对象,请考虑向其传递副本,或传递指针或引用而不转移所有权。最好使用 std::unique_ptr 来明确所有权转移。例如:

std::unique_ptr<Foo> FooFactory();
void FooConsumer(std::unique_ptr<Foo> ptr);

如果没有充分理由,请不要将代码设计为使用共享所有权。其中一个原因是避免昂贵的复制操作,但只有在性能优势显著且底层对象不可变(即 std::shared_ptr<const Foo>)时才应这样做。如果您确实使用共享所有权,则最好使用 std::shared_ptr

切勿使用 std::auto_ptr。相反,请使用 std::unique_ptr

1.2 右值引用

仅在下面列出的某些特殊情况下使用右值引用。

右值引用是一种只能绑定到临时对象的引用。其语法与传统引用语法类似。例如,void f(std::string&& s); 声明一个函数,其参数是对 std::string 的右值引用。

当将标记“&&”应用于函数参数中的非限定模板参数时,将应用特殊的模板参数推导规则。这种引用称为转发引用。

优点:

  • 定义移动构造函数(采用类类型的右值引用的构造函数)可以移动值而不是复制它

    例如,如果 v1 是 std::vector<std::string>,那么 auto v2(std::move(v1)) 可能只会导致一些简单的指针操作,而不是复制大量数据。在许多情况下,这可以带来显著的性能提升。

  • 右值引用可以实现可移动但不可复制的类型,这对于没有合理复制定义但您可能仍想将它们作为函数参数传递、将它们放入容器等的类型非常有用。

  • std::move 是有效使用某些标准库类型(如 std::unique_ptr)所必需的

  • 使用右值引用标记的转发引用可以编写一个通用函数包装器,将其参数转发给另一个函数,并且无论其参数是临时对象还是 const 都可以工作。这被称为‘完美转发’

缺点:

  • **右值引用尚未被广泛理解。**引用折叠和转发引用的特殊推导规则等规则有些晦涩难懂。
  • 右值引用经常被误用。在函数调用后,参数应具有有效的指定状态,或者不执行任何移动操作的情况下,在签名中使用右值引用是违反直觉的。

不要使用右值引用(或将 && 限定符应用于方法),但以下情况除外:

  • 您可以使用它们来定义移动构造函数和移动赋值运算符(如可复制和可移动类型中所述)
  • 您可以使用它们来定义 && 限定的方法,这些方法在逻辑上“使用” *this,使其处于不可用或空状态。请注意,这仅适用于方法限定符(位于函数签名的右括号之后);如果您想“使用”普通函数参数,最好按值传递它。
  • 您可以将转发引用与 std::forward 结合使用,以支持完美转发
  • 您可以使用它们来定义重载对,例如一个采用 Foo&&,另一个采用 const Foo&。通常首选解决方案只是按值传递,但重载函数对有时会产生更好的性能,例如如果函数有时不消耗输入。一如既往:如果您为了提高性能而编写更复杂的代码,请确保有证据证明它确实有帮助。
1.3 友元

我们允许在合理范围内使用友元类和函数。

友元通常应在同一个文件中定义,以便读者不必在另一个文件中查找类的私有成员的用途。 友元的常见用途是让 FooBuilder 类成为 Foo 的友元,以便它可以正确构造 Foo 的内部状态,而无需将此状态暴露给外部。 在某些情况下,将 unittest 类设为其测试类的友元可能会很有用。

友元扩展了类的封装边界,但不会破坏该边界。 在某些情况下,当您只想让另一个类访问成员时,这比将成员设为公共更好。 但是,大多数类应该仅通过其公共成员与其他类交互。

1.4 异常

我们不使用 C++ 异常

优点:

  • 异常允许应用程序的更高级别决定如何处理深度嵌套函数中“不可能发生的”故障,而无需记录模糊且容易出错的错误代码。
  • 大多数其他现代语言都使用异常。在 C++ 中使用它们会使其与其他人熟悉的 Python、Java 和 C++ 更加一致。
  • 一些第三方 C++ 库使用异常,在内部关闭它们会使与这些库集成变得更加困难。
  • 异常是构造函数失败的唯一方式。我们可以使用工厂函数或 Init() 方法来模拟这种情况,但它们分别需要堆分配或新的“无效”状态。
  • 异常在测试框架中非常方便。

缺点:

  • 当您向现有函数添加 throw 语句时,必须检查其所有传递调用者。他们必须至少提供基本的异常安全保证,或者他们必须永远不捕获异常并乐于让程序终止。例如,如果 f() 调用 g() 调用 h(),并且 h 抛出 f 捕获的异常,则 g 必须小心,否则可能无法正确清理。
  • 更一般地说,异常使得通过查看代码很难评估程序的控制流:函数可能会在您意想不到的地方返回。这会导致可维护性和调试困难。您可以通过一些关于如何以及在何处使用异常的规则来最大限度地降低这种成本,但代价是开发人员需要知道和理解更多。
  • 异常安全需要 RAII 和不同的编码实践。需要大量的支持机制才能轻松编写正确的异常安全代码。此外,为了避免要求读者理解整个调用图,异常安全代码必须将写入持久状态的逻辑隔离到“提交”阶段。这既有好处也有代价(也许你不得不混淆代码来隔离提交)。允许异常会迫使我们总是付出这些代价,即使这些代价不值得。
  • 打开异常会将数据添加到生成的每个二进制文件中,从而增加编译时间(可能略有增加),并可能增加地址空间压力。
  • 异常的可用性可能会鼓励开发人员在不合适时抛出它们,或者在不安全时从中恢复。例如,无效的用户输入不应导致抛出异常。

从表面上看,使用异常的好处大于成本,尤其是在新项目中。但是,对于现有代码,引入异常会对所有依赖代码产生影响。如果异常可以传播到新项目之外,那么将新项目集成到现有的无异常代码中也会变得有问题。由于 Google 现有的大多数 C++ 代码都没有准备好处理异常,因此采用会产生异常的新代码相对困难。

鉴于 Google 现有的代码不容忍异常,使用异常的成本略高于新项目的成本。转换过程会很慢且容易出错。我们不认为可用的异常替代方案(例如错误代码和断言)会带来很大的负担。

我们反对使用异常的建议不是基于哲学或道德理由,而是基于实践理由。因为我们想在 Google 使用我们的开源项目,而如果这些项目使用异常,那么这样做会很困难,所以我们也需要建议不要在 Google 开源项目中使用异常。如果我们必须从头开始,情况可能会有所不同。

此禁令也适用于异常处理相关功能,例如 std::exception_ptr 和 std::nested_exception。

对于 Windows 代码,此规则有一个例外(无意双关)。

1.5 无异常

当 noexcept 有用且正确时指定它。

noexcept 说明符用于指定函数是否会抛出异常。如果异常从标记为 noexcept 的函数中逃逸,程序将通过 std::terminate 崩溃。

noexcept 运算符执行编译时检查,如果表达式被声明为不抛出任何异常,则返回 true。

优点:

  • 在某些情况下,将移动构造函数指定为 noexcept 可以提高性能,例如,如果 T 的移动构造函数为 noexcept,则 std::vector<T>::resize() 会移动而不是复制对象。
  • 在启用异常的环境中,在函数上指定 noexcept 可以触发编译器优化,例如,如果编译器知道由于 noexcept 说明符不会引发任何异常,则它不必为堆栈展开生成额外的代码。

缺点:

  • 在遵循本指南并禁用异常的项目中,很难确保 noexcept 说明符是正确的,甚至很难定义正确性的含义。
  • 很难(甚至不可能)撤消 noexcept,因为它以难以检测的方式消除了调用者可能依赖的保证。

如果 noexcept 能够准确反映函数的预期语义,即如果函数主体内部以某种方式抛出异常,则表示出现致命错误,则可以使用 noexcept 来提高性能。您可以假设移动构造函数上的 noexcept 具有显著的性能优势。如果您认为在其他函数上指定 noexcept 具有显著的性能优势,请与您的项目负责人讨论。

如果完全禁用异常(即大多数 Google C++ 环境),则首选无条件 noexcept。否则,使用条件 noexcept 说明符和简单条件,仅在函数可能抛出的少数情况下评估为 false。

测试可能包括类型特征检查所涉及的操作是否可能抛出(例如,移动构造对象的 std::is_nothrow_move_constructible),或分配是否可以抛出(例如,标准默认分配的 absl::default_allocator_is_nothrow)。

请注意,在许多情况下,异常的唯一可能原因是分配失败(我们认为移动构造函数不应抛出,除非由于分配失败),并且在许多应用程序中,将内存耗尽视为致命错误而不是程序应尝试恢复的异常情况是适当的。

即使对于其他潜在故障,您也应该优先考虑接口简单性,而不是支持所有可能的异常抛出场景:例如,不要编写一个依赖于哈希函数是否可以抛出的复杂 noexcept 子句,只需记录您的组件不支持哈希函数抛出并使其无条件 noexcept。

1.6 运行时类型信息

避免使用运行时类型信息(RTTI)。RTTI 允许程序员在运行时查询对象的 C++ 类。这是通过使用 typeid 或 dynamic_cast 来实现的。

RTTI 的标准替代方案(如下所述)需要修改或重新设计相关的类层次结构。有时,这种修改不可行或不受欢迎,尤其是在广泛使用或成熟的代码中。

RTTI 在某些单元测试中很有用。例如,它在工厂类的测试中很有用,其中测试必须验证新创建的对象是否具有预期的动态类型。它在管理对象与其模拟之间的关系方面也很有用。

在考虑多个抽象对象时,RTTI 很有用。考虑

bool Base::Equal(Base* other) = 0;
bool Derived::Equal(Base* other) {
  Derived* that = dynamic_cast<Derived*>(other);
  if (that == nullptr)
    return false;
  ...
}

在运行时查询对象的类型通常意味着设计问题。需要在运行时知道对象的类型通常表明类层次结构的设计存在缺陷。

不规范地使用 RTTI 会使代码难以维护。它可能导致基于类型的决策树或 switch 语句散布在整个代码中,在进行进一步更改时必须检查所有这些语句。

RTTI 有合法用途,但容易被滥用,因此使用时必须小心谨慎。您可以在单元测试中自由使用它,但应尽可能避免在其他代码中使用它。特别是,在新代码中使用 RTTI 之前要三思。如果您发现自己需要编写根据对象的类而行为不同的代码,请考虑以下查询类型的替代方法之一:

虚拟方法是根据特定子类类型执行不同代码路径的首选方式。这会将工作放在对象本身内。如果工作属于对象之外,而是属于某些处理代码,请考虑使用双分派解决方案,例如访问者设计模式。这允许对象本身之外的工具使用内置类型系统确定类的类型。

当程序的逻辑保证基类的给定实例实际上是特定派生类的实例时,则可以在对象上自由使用 dynamic_cast。通常,在这种情况下可以使用 static_cast 作为替代方案。

基于类型的决策树强烈表明您的代码走在了错误的轨道上。

if (typeid(*data) == typeid(D1)) {
  ...
} else if (typeid(*data) == typeid(D2)) {
  ...
} else if (typeid(*data) == typeid(D3)) {
...

当向类层次结构中添加其他子类时,此类代码通常会中断。此外,当子类的属性发生变化时,很难找到并修改所有受影响的代码段。

不要手动实现类似 RTTI 的解决方法。反对 RTTI 的论点同样适用于带有类型标记的类层次结构等解决方法。此外,解决方法会掩盖您的真实意图。

1.7 强制转换

使用 C++ 风格的强制类型转换,例如 static_cast<float>(double_value),或使用括号初始化来转换算术类型,例如 int64_t y = int64_t{1} << 42。除非强制类型转换为 void,否则不要使用 (int)x 之类的强制类型转换格式。

只有当 T 是类类型时,才可以使用 T(x) 之类的强制类型转换格式

C++ 引入了与 C 不同的强制类型转换系统,用于区分强制类型转换运算的类型。

C 强制类型转换的问题在于操作的歧义性;有时您正在进行转换(例如,(int)3.5),有时您正在进行强制类型转换(例如,(int)“hello”)。括号初始化和 C++ 强制类型转换通常可以帮助避免这种歧义。此外,在搜索 C++ 强制类型转换时,它们更明显。

C++ 风格的强制类型转换语法冗长而繁琐。

一般情况下,不要使用 C 样式强制转换。相反,当需要显式类型转换时,请使用这些 C++ 样式强制转换。

  • 使用括号初始化来转换算术类型(例如,int64_t{x})。这是最安全的方法,因为如果转换会导致信息丢失,代码将无法编译。语法也很简洁。

  • 使用 absl::implicit_cast 安全地向上转换类型层次结构,例如,将 Foo* 转换为 SuperclassOfFoo* 或将 Foo* 转换为 const Foo*。C++ 通常会自动执行此操作,但在某些情况下需要显式向上转换,例如使用 ?: 运算符。

  • 当您需要显式将指针从类向上转换为其超类,或者当您需要显式将指针从超类转换为子类时,使用 static_cast 作为执行值转换的 C 样式强制转换的等效项。在最后一种情况下,您必须确保您的对象实际上是子类的一个实例。

  • 使用 const_cast 删除 const 限定符(参见 const)。

  • 使用 reinterpret_cast 将指针类型与整数和其他指针类型(包括 void*)进行不安全的转换。仅当您知道自己在做什么并且了解别名问题时才使用此功能。此外,请考虑取消引用指针(不进行强制转换)并使用 std::bit_cast 强制转换结果值。

  • 使用 std::bit_cast 使用相同大小的不同类型(类型双关语)解释值的原始位,例如将 double 的位解释为 int64_t。

有关使用 dynamic_cast 的指导,请参阅 RTTI 部分。

1.8 IO流

在适当的情况下使用流,并坚持“简单”用法。重载<<仅用于表示值的类型进行流式传输,并且只写入用户可见的值,而不写入任何实现细节。

流是 C++ 中的标准 I/O 抽象,例如标准头文件 <iostream>。它们在 Google 代码中被广泛使用,主要用于调试日志记录和测试诊断。

优点:

  • << 和 >> 流运算符为格式化 I/O 提供了易于学习、可移植、可重用和可扩展的 API。相比之下,printf 甚至不支持 std::string,更不用说用户定义类型了,并且很难移植使用。printf 还迫使您在该函数的众多略有不同的版本中进行选择,并浏览数十个转换说明符。

  • 流通过 std::cinstd::coutstd::cerrstd::clog 为控制台 I/O 提供一流的支持。C API 也一样,但由于需要手动缓冲输入而受到阻碍。

缺点:

  • 可以通过改变流的状态来配置流格式。此类改变是持久的,因此您的代码的行为可能会受到流的整个先前历史的影响,除非您每次在其他代码可能触碰它时都特意将其恢复到已知状态。用户代码不仅可以修改内置状态,还可以通过注册系统添加新的状态变量和行为。
  • 由于上述问题、流代码中代码和数据的混合方式以及运算符重载的使用(可能选择与您预期不同的重载),很难精确控制流输出。
  • 通过 << 运算符链构建输出的做法会干扰国际化,因为它会将字序嵌入代码中,并且流对本地化的支持存在缺陷。
  • 流 API 微妙而复杂,因此程序员必须积累经验才能有效地使用它。
  • 解决 << 的许多重载对于编译器来说成本极高。当在大型代码库中广泛使用时,它会消耗多达 20% 的解析和语义分析时间。

仅当流是最适合该工作的工具时才使用流。通常,当 I/O 是临时的、本地的、人类可读的并且针对其他开发人员而不是最终用户时,就是这种情况。与您周围的代码以及整个代码库保持一致;如果有针对您的问题的既定工具,请使用该工具。特别是,对于诊断输出,日志记录库通常是比 std::cerr 或 std::clog 更好的选择,而 absl/strings 或等效库中的库通常是比 std::stringstream 更好的选择。

避免将流用于面向外部用户或处理不受信任数据的 I/O。相反,找到并使用适当的模板库来处理国际化、本地化和安全强化等问题。

如果您确实使用流,请避免使用流 API 的有状态部分(错误状态除外),例如 imbue()、xalloc() 和 register_callback()。使用显式格式化函数(例如 absl::StreamFormat())而不是流操纵器或格式化标志来控制格式化细节,例如数字基数、精度或填充

仅当您的类型表示一个值时,才将 << 重载为您的类型的流式运算符,并且 << 会写出该值的人性化字符串表示。避免在 << 的输出中公开实现细节;如果您需要打印对象内部以进行调试,请改用命名函数(名为 DebugString() 的方法是最常见的惯例)

1.9 预增和预减

除非需要后缀语义,否则请使用增量和减量运算符的前缀形式 (++i)。

当变量递增(++i 或 i++)或递减(–i 或 i–)且表达式的值未被使用时,必须决定是预递增(递减)还是后递增(递减)。

后缀递增/递减表达式会计算出修改前的值。这会导致代码更紧凑,但更难阅读。前缀形式通常更易读,效率也不会降低,而且效率更高,因为它不需要复制操作前的值。

在 C 语言中,即使不使用表达式值,也会使用后增,这在 for 循环中尤为常见。

使用前缀增量/减少,除非代码明确需要后缀增量/减少表达式的结果。

1.10 使用常量

在 API 中,只要有意义就使用 const。对于某些 const 用途来说,constexpr 是更好的选择。

声明的变量和参数前面可以加上关键字 const,以表明变量不会改变(例如,const int foo)。类函数可以有 const 限定符,以表明函数不会改变类成员变量的状态(例如,class Foo { int Bar(char c) const; };)。

让人们更容易理解变量的使用方式。允许编译器进行更好的类型检查,并且可以生成更好的代码。帮助人们确信程序的正确性,因为他们知道他们调用的函数在修改变量的方式上是有限的。帮助人们知道在多线程程序中哪些函数可以安全使用而无需锁定。

const 是病毒式的:如果你将一个 const 变量传递给一个函数,那么该函数的原型中必须有 const(否则该变量将需要 const_cast)。调用库函数时,这可能是一个特殊的问题。

我们强烈建议在有意义且准确的 API(即函数参数、方法和非局部变量)中使用 const。这提供了一致的、大多数经过编译器验证的文档,说明操作可以改变哪些对象。拥有一致且可靠的方法来区分读取和写入对于编写线程安全代码至关重要,并且在许多其他情况下也很有用。特别是:

  • 如果函数保证不会修改通过引用或指针传递的参数,则相应的函数参数应分别为引用到 const (const T&) 或指针到 const (const T*)

  • 对于通过值传递的函数参数,const 对调用者没有影响,因此不建议在函数声明中使用。

  • 将方法声明为 const,除非它们会改变对象的逻辑状态(或允许用户修改该状态,例如通过返回非常量引用,但这种情况很少见),或者它们不能安全地同时调用。

我们既不鼓励也不反对在局部变量上使用 const。

类的所有 const 操作都应该能够安全地同时调用。如果这不可行,则必须明确将该类记录为“线程不安全”

有些人喜欢将 int const *foo 改为 const int* foo。他们认为这种形式更易读,因为它更一致:它遵循了 const 始终遵循其描述的对象的规则。但是,这种一致性论点不适用于嵌套指针表达式较少的代码库,因为大多数 const 表达式只有一个 const,并且它适用于底层值。在这种情况下,无需保持一致性。将 const 放在首位可以说更易读,因为它遵循英语将“形容词”(const)放在“名词”(int)之前的做法。

话虽如此,虽然我们鼓励将 const 放在首位,但我们并不要求这样做。但要与周围的代码保持一致!

1.11 常量使用

使用 constexpr 定义真正的常量或确保常量初始化。使用 constinit 确保非常量变量的常量初始化。

可以将某些变量声明为 constexpr,以表明这些变量是真正的常量,即在编译/链接时固定。可以将某些函数和构造函数声明为 constexpr,这样它们便可用于定义 constexpr 变量。可以将函数声明为 consteval,以将其使用限制在编译时。

使用 constexpr 可以用浮点表达式而不是文字来定义常量;定义用户定义类型的常量;以及用函数调用来定义常量。

如果过早将某个对象标记为 constexpr,则在以后需要降级时可能会导致迁移问题。目前对 constexpr 函数和构造函数中允许的内容的限制可能会导致在这些定义中使用晦涩难懂的解决方法。

constexpr 定义可以更可靠地指定接口的常量部分。使用 constexpr 指定真正的常量以及支持其定义的函数。consteval 可用于运行时不得调用的代码。避免使函数定义复杂化以使其能够与 constexpr 一起使用。不要使用 constexpr 或 consteval 强制内联。

使用 constexpr 定义真常量或确保常量初始化。使用 constinit 确保非常量变量的常量初始化

可以将某些变量声明为 constexpr,以指示这些变量是真常量,即在编译/链接时固定。可以将某些函数和构造函数声明为 constexpr,这样它们就可以用于定义 constexpr 变量。可以将函数声明为 consteval,以将其使用限制在编译时。

使用 constexpr 可以使用浮点表达式(而不仅仅是文字)来定义常量;定义用户定义类型的常量;以及使用函数调用来定义常量。

如果以后必须降级,过早将某些内容标记为 constexpr 可能会导致迁移问题。当前对 constexpr 函数和构造函数中允许的内容的限制可能会导致在这些定义中使用模糊的解决方法。

constexpr 定义可以更可靠地指定接口的常量部分。使用 constexpr 指定真常量和支持其定义的函数。 consteval 可用于运行时不得调用的代码。避免使函数定义复杂化以使其与 constexpr 一起使用。不要使用 constexpr 或 consteval 强制内联。

1.12 整数类型

在内置的 C++ 整数类型中,唯一使用的就是 int。如果程序需要不同大小的整数类型,请使用 <cstdint> 中的精确宽度整数类型,例如 int16_t。如果您的值可能大于或等于 2^31,请使用 64 位类型,例如 int64_t。请记住,即使您的值对于 int 来说永远不会太大,它也可能用于可能需要更大类型的中间计算。如有疑问,请选择更大的类型。

C++ 没有为 int 等整数类型指定精确的大小。当代架构上的常见大小是 short 为 16 位,int 为 32 位,long 为 32 或 64 位,long long 为 64 位,但不同平台会做出不同的选择,尤其是 long。

优点是声明的统一性。缺点是C++ 中整数类型的大小可能根据编译器和体系结构而变化

标准库头文件 <cstdint> 定义了 int16_tuint32_tint64_t 等类型。当您需要保证整数的大小时,您应该始终优先使用这些类型,而不是 shortunsigned long long 等。最好省略这些类型的 std:: 前缀,因为额外的 5 个字符不值得增加混乱。在内置整数类型中,只应使用 int。在适当的情况下,欢迎您使用标准类型别名,如 size_tptrdiff_t

我们经常使用 int,因为我们知道整数不会太大,例如循环计数器。对于这样的事情,请使用普通的 int。您应该假设 int 至少为 32 位,但不要假设它有超过 32 位。如果您需要 64 位整数类型,请使用 int64_tuint64_t

对于我们知道可能“很大”的整数,请使用 int64_t

您不应使用无符号整数类型(例如 uint32_t),除非有正当理由(例如表示位模式而不是数字),或者您需要定义模 2^N 的溢出。特别是,不要使用无符号类型来表示数字永远不会为负数。相反,请使用断言。

如果您的代码是返回大小的容器,请确保使用可以适应容器的任何可能用途的类型。如有疑问,请使用较大的类型而不是较小的类型。转换整数类型时要小心。整数转换和提升可能会导致未定义的行为,从而导致安全错误和其他问题。

无符号整数适合表示位域和模块化算法。由于历史原因,C++ 标准也使用无符号整数来表示容器的大小, 标准机构的许多成员认为这是一个错误,但目前实际上无法修复。无符号算法不模拟简单整数的行为,而是由标准定义为模拟模块化算法(溢出/下溢时回绕),这意味着编译器无法诊断出大量错误。在其他情况下,定义的行为会阻碍优化。

也就是说,混合整数类型的符号性会导致同样多的问题。我们能提供的最佳建议是:尝试使用迭代器和容器而不是指针和大小,尽量不要混合符号性,并尽量避免使用无符号类型(除了表示位域或模块化算法)。不要仅仅为了断言变量是非负的而使用无符号类型

1.13 浮点数

在内置的 C++ 浮点类型中,唯一使用的就是 float 和 double。您可以假设这些类型分别代表 IEEE-754 binary32 和 binary64。 不要使用 long double,因为它会产生不可移植的结果。

1.14 架构特性

编写可移植架构的代码。不要依赖特定于单个处理器的 CPU 功能。

  • 打印值时,使用类型安全的数字格式化库(如 absl::StrCatabsl::Substituteabsl::StrFormatstd::ostream),而不是 printf 系列函数。

  • 将结构化数据移入或移出进程时,使用序列化库(如 Protocol Buffers)对其进行编码,而不是复制内存中的表示形式。

  • 如果需要将内存地址用作整数,请将它们存储在 uintptr_ts 中,而不是 uint32_tsuint64_ts 中。

  • 根据需要使用大括号初始化来创建 64 位常量。

int64_t my_value{0x123456789};
uint64_t my_mask{uint64_t{3} << 48};
  • 使用可移植浮点类型;避免使用 long double
  • 使用可移植的整数类型;避免使用 shortlonglong long
1.15 预处理器宏

避免定义宏,尤其是在标头中;最好使用内联函数、枚举和 const 变量。使用项目特定的前缀命名宏。不要使用宏来定义 C++ API 的各个部分。

宏意味着您看到的代码与编译器看到的代码不同。这可能会导致意外行为,尤其是因为宏具有全局作用域。

当宏用于定义 C++ API 的各个部分时,宏引入的问题尤其严重,对于公共 API 更是如此。当开发人员错误地使用该接口时,编译器发出的每条错误消息现在都必须解释宏是如何形成接口的。重构和分析工具在更新接口时会遇到极大的困难。因此,我们特别禁止以这种方式使用宏。例如,避免使用以下模式:

class WOMBAT_TYPE(Foo) {
  // ...

 public:
  EXPAND_PUBLIC_WOMBAT_API(Foo)

  EXPAND_WOMBAT_COMPARISONS(Foo, ==, <)
};

幸运的是,宏在 C++ 中的必要性远不如在 C 中那么高。

  • 不要使用宏来内联性能关键型代码,而是使用内联函数。
  • 不要使用宏来存储常量,而是使用 const 变量。
  • 不要使用宏来“缩写”长变量名,而是使用引用。
  • 不要使用宏来有条件地编译代码……好吧,根本不要这样做(当然,除了 #define 保护以防止重复包含头文件)。这会使测试变得更加困难。

宏可以做其他技术无法做到的事情,而且您确实可以在代码库中看到它们,尤其是在较低级别的库中。而且它们的一些特殊功能(如字符串化、连接等)无法通过语言本身获得。但在使用宏之前,请仔细考虑是否有非宏方法来实现相同的结果。如果您需要使用宏来定义接口,请联系您的项目负责人以请求豁免此规则。

以下使用模式将避免宏的许多问题;如果您使用宏,请尽可能遵循它:

  • 不要在 .h 文件中定义宏。

  • 在使用宏之前先 #define 宏,然后在使用宏之后立即 #undef 宏。

  • 不要在用自己的宏替换现有宏之前先 #undef 宏;而是选择一个可能唯一的名称。

  • 尽量不要使用会扩展为不平衡 C++ 构造的宏,或者至少要很好地记录该行为。

  • 最好不要使用 ## 来生成函数/类/变量名称。

强烈建议不要从标头导出宏(即,在标头中定义宏,而不在标头末尾之前 #undef 宏)。如果您确实从标头导出宏,则它必须具有全局唯一的名称。为此,它必须使用由项目命名空间名称(但大写)组成的前缀命名。

1.16 0和nullptr(NULL)

对指针使用 nullptr,对字符使用 \0(而不是 0 文字),对于指针(地址值),请使用 nullptr,因为这可提供类型安全性。

对空字符使用 \0。使用正确的类型可使代码更具可读性。

1.17 sizeof获取大小

优先使用 sizeof(varname) 而不是 sizeof(type)

获取特定变量的大小时使用 sizeof(varname)。如果有人现在或以后更改变量类型,sizeof(varname) 将进行相应更新。您可以将 sizeof(type) 用于与任何特定变量无关的代码,例如管理外部或内部数据格式的代码,其中适当的 C++ 类型的变量不方便。

// 推荐代码
MyStruct data;
memset(&data, 0, sizeof(data));

// 不推荐代码
memset(&data, 0, sizeof(MyStruct));

// 推荐代码
if (raw_size < sizeof(int)) {
    LOG(ERROR) << "压缩记录不够大,无法计数:" << raw_size;
    return false;
}
1.18 类型推断(包括自动)

仅当类型推断可以让不熟悉项目的读者更清楚地理解代码,或者让代码更安全时才使用类型推断。不要仅仅为了避免编写显式类型的不便而使用它。

在以下几种情况下,C++ 允许(甚至要求)编译器推断类型,而不是在代码中明确说明:

  • 函数模板参数推导,函数模板可以在没有显式模板参数的情况下调用。编译器根据函数参数的类型推导这些参数:

    template <typename T>
    void f(T t);
    
    f(0);  // Invokes f<int>(0)
    
  • 自动变量声明,变量声明可以使用 auto 关键字代替类型。编译器根据变量的初始化器推断出类型,遵循与使用相同初始化器推断函数模板参数相同的规则(只要您不使用花括号代替圆括号)。

    auto a = 42;  // a is an int
    auto& b = a;  // b is an int&
    auto c = b;   // c is an int
    auto d{42};   // d is an int, not a std::initializer_list<int>
    

    auto 可以用 const 限定,并且可以用作指针或引用类型的一部分,以及(自 C++17 起)用作非类型模板参数。此语法的罕见变体使用 decltype(auto) 而不是 auto,在这种情况下,推导的类型是将 decltype 应用于初始化程序的结果。

  • 函数返回类型推导,auto(和 decltype(auto))也可用于代替函数返回类型。编译器根据函数主体中的返回语句推断返回类型,遵循与变量声明相同的规则:

    auto f() { return 0; }  // The return type of f is int
    

    Lambda 表达式的返回类型可以用相同的方式推断,但这是通过省略返回类型而不是通过显式 auto 来触发的。令人困惑的是,函数的尾随返回类型语法也在返回类型位置使用 auto,但这并不依赖于类型推断;它只是显式返回类型的替代语法。

  • 通用 lambda,Lambda 表达式可以使用 auto 关键字代替其一个或多个参数类型。这会导致 lambda 的调用运算符成为函数模板而不是普通函数,每个自动函数参数都有一个单独的模板参数:

    // 按降序对 vec 进行排序
    std::sort(vec.begin(), vec.end(),  { return lhs > rhs; });
    
  • Lambda init 捕获,Lambda 捕获可以有显式的初始化器,可以用来声明全新的变量而不是仅仅捕获现有的变量:

    [x = 42, y = "foo"] { ... }  // x is an int, and y is a const char*
    

    此语法不允许指定类型;而是使用自动变量的规则来推断类型。

  • 结构化绑定,使用 auto 声明元组、结构或数组时,您可以指定各个元素的名称,而不是整个对象的名称;这些名称称为“结构化绑定”,整个声明称为“结构化绑定声明”。此语法无法指定封闭对象或各个名称的类型:

    auto [iter, success] = my_map.insert({key, value});
    if (!success) {
      iter->second = value;
    }
    

    auto 也可以用 const&&& 限定,但请注意,这些限定符在技术上适用于匿名元组/结构/数组,而不是单个绑定。确定绑定类型的规则非常复杂;结果往往并不令人惊讶,只是即使声明了引用,绑定类型通常也不会是引用(但它们通常仍然表现得像引用)。

优点:

  • C++ 类型名称可能很长且繁琐,尤其是当它们涉及模板或命名空间时。

  • 当 C++ 类型名称在单个声明或小代码区域内重复时,重复可能无助于提高可读性。

  • 有时让类型推断出来更安全,因为这样可以避免意外复制或类型转换的可能性。

缺点:

当类型明确时,C++ 代码通常更清晰,尤其是当类型推断依赖于来自代码远处的信息时。在以下表达式中:

auto foo = x.add_foo();
auto i = y.Find(key);

如果 y 的类型不太为人所知,或者 y 声明于很多行之前,则结果类型可能不明显。

程序员必须了解类型推导何时会产生或不会产生引用类型,否则他们会在无意的情况下获得副本。

如果推导类型用作接口的一部分,那么程序员可能会更改其类型,而只是想更改其值,从而导致比预期更彻底的 API 更改。

基本规则是:仅使用类型推导来使代码更清晰或更安全,不要仅仅为了避免编写显式类型的不便而使用它。在判断代码是否更清晰时,请记住您的读者不一定在您的团队中,也不一定是熟悉您的项目的人,因此您和您的审阅者认为不必要的混乱类型通常会为其他人提供有用的信息。例如,您可以假设 make_unique<Foo>() 的返回类型是显而易见的,但 MyWidgetFactory() 的返回类型可能不是。

这些原则适用于所有形式的类型推导,但细节会有所不同,如以下部分所述。

  • 函数模板实参推导,函数模板参数推导几乎总是可以的。类型推导是与函数模板交互的预期默认方式,因为它允许函数模板像无限的普通函数重载集一样工作。因此,函数模板几乎总是设计为模板参数推导清晰且安全,否则不会编译。

  • 局部变量类型推断,对于局部变量,可以使用类型推断来消除明显或不相关的类型信息,从而使代码更清晰,以便读者可以专注于代码中有意义的部分:

    std::unique_ptr<WidgetWithBellsAndWhistles> widget =
        std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
    absl::flat_hash_map<std::string,
                        std::unique_ptr<WidgetWithBellsAndWhistles>>::const_iterator
        it = my_map_.find(key);
    std::array<int, 6> numbers = {4, 8, 15, 16, 23, 42};
    
    auto widget = std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2);
    auto it = my_map_.find(key);
    std::array numbers = {4, 8, 15, 16, 23, 42};
    

    类型有时包含有用信息和样板,例如上例中的情况:很明显,该类型是一个迭代器,并且在许多上下文中,容器类型甚至键类型都不相关,但值的类型可能很有用。在这种情况下,通常可以定义具有明确类型的局部变量来传达相关信息:

    if (auto it = my_map_.find(key); it != my_map_.end()) {
      WidgetWithBellsAndWhistles& widget = *it->second;
      // Do stuff with `widget`
    }
    

    如果类型是模板实例,并且参数是样板但模板本身是有用的,则可以使用类模板参数推导来抑制样板。但是,这实际上提供有意义的好处的情况非常罕见。请注意,类模板参数推导也受单独的样式规则约束。

    如果更简单的选项可行,请不要使用 decltype(auto),因为它是一个相当晦涩的功能,因此在代码清晰度方面成本很高。

  • 返回类型推导,仅当函数主体的返回语句数量很少且其他代码很少时才使用返回类型推导(对于函数和 lambda 来说),因为否则读者可能无法一眼看出返回类型是什么。此外,仅当函数或 lambda 的范围非常窄时才使用它,因为具有推导返回类型的函数不定义抽象边界:实现就是接口。特别是,头文件中的公共函数几乎永远不应该具有推导返回类型。

  • 参数类型推断,应谨慎使用 lambda 的自动参数类型,因为实际类型由调用 lambda 的代码决定,而不是由 lambda 的定义决定。因此,显式类型几乎总是更清晰,除非在非常接近定义的位置显式调用 lambda(以便读者可以轻松看到两者),或者将 lambda 传递给一个众所周知的接口,以至于很明显它最终将使用哪些参数来调用它(例如,上面的 std::sort 示例)。

  • Lambda 初始化捕获,初始化捕获由更具体的样式规则涵盖,该规则在很大程度上取代了类型推断的一般规则。

  • 结构化绑定,与其他类型的类型推导不同,结构化绑定实际上可以通过为较大对象的元素赋予有意义的名称来为读者提供更多信息。这意味着结构化绑定声明可能比显式类型提供净可读性改进,即使在 auto 不会这样做的情况下也是如此。当对象是一对或元组(如上面的 insert 示例)时,结构化绑定尤其有用,因为它们一开始就没有有意义的字段名称,但请注意,除非预先存在的 API(如 insert)强迫您使用,否则通常不应使用对或元组。

    如果绑定的对象是结构体,提供更适合您用途的名称有时可能会有所帮助,但请记住,这也可能意味着这些名称对读者来说比字段名称更难识别。我们建议使用注释来指示底层字段的名称(如果它与绑定的名称不匹配),使用与函数参数注释相同的语法:

    auto [/*field_name1=*/bound_name1, /*field_name2=*/bound_name2] = ...
    

    与函数参数注释一样,这可以使工具检测出字段的顺序是否错误。

1.19 类模板参数推导

仅对明确选择支持类模板参数推导的模板使用类模板参数推导。

类模板参数推导(通常缩写为“CTAD”)发生在当使用命名模板的类型声明变量,并且未提供模板参数列表(甚至没有空尖括号)时:

std::array a = {1, 2, 3};  // `a` is a std::array<int, 3>

编译器使用模板的“推导指南”从初始化程序中推导参数,这些指南可以是显式的,也可以是隐式的。

显式推导指南看起来像带有尾随返回类型的函数声明,只是没有前导 auto,并且函数名称是模板的名称。

例如,上面的示例依赖于 std::array 的此推导指南:

namespace std {
template <class T, class... U>
array(T, U...) -> std::array<T, 1 + sizeof...(U)>;
}

主模板中的构造函数(与模板特化相反)也隐式定义推导指南。

当您声明依赖于 CTAD 的变量时,编译器会使用构造函数重载解析规则选择推导指南,并且该指南的返回类型将成为变量的类型。

CTAD 有时可以让您从代码中省略样板。

从构造函数生成的隐式推导指南可能具有不良行为,或完全不正确。这对于在 C++17 中引入 CTAD 之前编写的构造函数尤其成问题,因为这些构造函数的作者无法知道(更不用说修复)他们的构造函数会给 CTAD 带来的任何问题。此外,添加显式推导指南来修复这些问题可能会破坏任何依赖隐式推导指南的现有代码。

CTAD 也存在与 auto 相同的许多缺点,因为它们都是从变量的初始化程序中推导变量类型全部或部分的机制。 CTAD 确实为读者提供了比 auto 更多的信息,但它也没有给读者一个明显的提示,表明信息已被省略。

不要将 CTAD 与给定模板一起使用,除非模板维护者已选择通过提供至少一个显式推导指南来支持使用 CTAD(std 命名空间中的所有模板也假定已选择加入)。如果可用,应使用编译器警告强制执行此操作。

CTAD 的使用还必须遵循类型推导的一般规则。

1.20 指定初始化器

仅以符合 C++20 的形式使用指定的初始化器。

指定初始化器是一种语法,允许通过明确命名其字段来初始化聚合(“普通旧结构”):

  struct Point {
    float x = 0.0;
    float y = 0.0;
    float z = 0.0;
  };

  Point p = {
    .x = 1.0,
    .y = 2.0,
    // z will be 0.0
  };

明确列出的字段将按指定方式初始化,其他字段将以与传统聚合初始化表达式(如 Point{1.0, 2.0})相同的方式初始化。

指定初始化器可以生成方便且高度可读的聚合表达式,特别是对于字段顺序不如上面的 Point 示例那么直接的结构体。

虽然指定初始化器早已成为 C 标准的一部分,并作为扩展得到 C++ 编译器的支持,但在 C++20 之前,C++ 并不支持它们。

C++ 标准中的规则比 C 和编译器扩展中的规则更严格,要求指定初始化器出现的顺序与结构定义中字段出现的顺序相同。因此,在上面的例子中,根据 C++20,先初始化 x 然后初始化 z 是合法的,但先初始化 y 然后初始化 x 是不合法的。

仅以与 C++20 标准兼容的形式使用指定初始化器:初始化器的顺序与相应字段在结构定义中出现的顺序相同

1.21 Lambda 表达式

适当时使用 lambda 表达式。当 lambda 超出当前范围时,最好使用显式捕获。

Lambda 表达式是创建匿名函数对象的简洁方法。在将函数作为参数传递时,它们通常很有用。例如:

std::sort(v.begin(), v.end(), [](int x, int y) {
  return Weight(x) < Weight(y);
});

它们还允许通过名称显式或使用默认捕获隐式地从封闭范围捕获变量。显式捕获要求列出每个变量,作为值或引用捕获:

int weight = 3;
int sum = 0;
// Captures `weight` by value and `sum` by reference.
std::for_each(v.begin(), v.end(), [weight, &sum](int x) {
  sum += weight * x;
});

默认捕获隐式捕获 lambda 主体中引用的任何变量,如果使用任何成员,则包括此变量:

const std::vector<int> lookup_table = ...;
std::vector<int> indices = ...;
// Captures `lookup_table` by reference, sorts `indices` by the value
// of the associated element in `lookup_table`.
std::sort(indices.begin(), indices.end(), [&](int a, int b) {
  return lookup_table[a] < lookup_table[b];
});

变量捕获还可以具有显式初始化器,其可用于按值捕获仅移动变量,或用于普通引用或值捕获无法处理的其他情况:

std::unique_ptr<Foo> foo = ...;
[foo = std::move(foo)] () {
  ...
}

这样的捕获(通常称为“初始化捕获”或“广义 lambda 捕获”)实际上不需要从封闭范围“捕获”任何东西,甚至不需要从封闭范围中获取名称;这种语法是定义 lambda 对象成员的完全通用的方法:

[foo = std::vector<int>({1, 2, 3})] () {
  ...
}

带有初始化器的捕获的类型使用与自动相同的规则推断。

优点:

  • Lambdas 比其他定义要传递给 STL 算法的函数对象的方式更简洁,这可以提高可读性。

  • 适当使用默认捕获可以消除冗余并突出显示默认的重要异常。

  • Lambdas、std::function 和 std::bind 可以组合使用,作为通用回调机制;它们使编写以绑定函数为参数的函数变得容易。

缺点:

  • lambda 中的变量捕获可能是悬空指针错误的来源,特别是当 lambda 超出当前范围时。
  • 默认按值捕获可能会产生误导,因为它们不能防止悬空指针错误。按值捕获指针不会导致深层复制,因此它通常具有与按引用捕获相同的生命周期问题。当按值捕获 this 时,这尤其令人困惑,因为 this 的使用通常是隐式的。
  • 捕获实际上声明了新变量(无论捕获是否具有初始化程序),但它们看起来与 C++ 中的任何其他变量声明语法都不一样。特别是,没有变量类型的位置,甚至没有 auto 占位符(尽管 init 捕获可以间接指示它,例如使用强制类型转换)。这可能使它们甚至很难被识别为声明。
  • init 捕获本质上依赖于类型推导,并且具有与 auto 相同的许多缺点,还有一个问题是语法甚至没有提示读者正在进行推导。
  • lambda 的使用可能会失控;非常长的嵌套匿名函数会使代码更难理解。

在适当的情况下使用 lambda 表达式,格式如下所述。

如果 lambda 可能超出当前范围,则优先使用显式捕获。例如,而不是:

{
  Foo foo;
  ...
  executor->Schedule([&] { Frobnicate(foo); })
  ...
}
// BAD! The fact that the lambda makes use of a reference to `foo` and
// possibly `this` (if `Frobnicate` is a member function) may not be
// apparent on a cursory inspection. If the lambda is invoked after
// the function returns, that would be bad, because both `foo`
// and the enclosing object could have been destroyed.

而是下面这种形式:

{
  Foo foo;
  ...
  executor->Schedule([&foo] { Frobnicate(foo); })
  ...
}
// BETTER - The compile will fail if `Frobnicate` is a member
// function, and it's clearer that `foo` is dangerously captured by
// reference.
  • 仅当 lambda 的生命周期明显短于任何潜在捕获时,才使用默认的引用捕获 ([&])。
  • 仅将默认的值捕获 ([=]) 用作绑定短 lambda 的几个变量的一种方式,其中捕获的变量集一目了然,并且不会导致隐式捕获 this。(这意味着出现在非静态类成员函数中并在其主体中引用非静态类成员的 lambda 必须显式捕获 this 或通过 [&]。)最好不要编写带有默认值捕获的长或复杂 lambda。
  • 仅使用捕获来实际捕获封闭范围中的变量。不要将捕获与初始化程序一起使用来引入新名称,或大幅更改现有名称的含义。相反,以常规方式声明一个新变量,然后捕获它,或者避免使用 lambda 简写并显式定义一个函数对象。
  • 有关指定参数和返回类型的指导,请参阅类型推导部分。
1.22 模板元编程

避免复杂的模板编程。

模板元编程是指利用 C++ 模板实例化机制是图灵完备的这一事实的一系列技术,可用于在类型域中执行任意编译时计算。

模板元编程允许极其灵活的接口,这些接口类型安全且性能高。没有它,GoogleTest、std::tuple、std::function 和 Boost.Spirit 等设施就不可能实现。

模板元编程中使用的技术通常对语言专家以外的任何人都晦涩难懂。以复杂方式使用模板的代码通常难以阅读,并且难以调试或维护。

模板元编程通常会导致极差的编译时错误消息:即使接口很简单,当用户做错事时,复杂的实现细节就会变得明显。

模板元编程使重构工具的工作变得更加困难,从而干扰大规模重构。首先,模板代码在多个上下文中展开,很难验证转换在所有上下文中是否有意义。其次,一些重构工具使用的 AST 仅表示模板扩展后的代码结构。很难自动返回到需要重写的原始源构造。

模板元编程有时会提供比没有它时更干净、更易于使用的接口,但它也常常会让人过于聪明。它最好用于少量的低级组件,在这些组件中,额外的维护负担会分散到大量使用中。

在使用模板元编程或其他复杂的模板技术之前请三思;想想在您切换到另一个项目后,您的团队中的普通成员是否能够很好地理解您的代码以进行维护,或者非 C++ 程序员或随意浏览代码库的人是否能够理解错误消息或跟踪他们想要调用的函数的流程。如果您使用递归模板实例化或类型列表或元函数或表达式模板,或者依赖 SFINAE 或 sizeof 技巧来检测函数重载解析,那么很有可能您做得太过了。

如果您使用模板元编程,您应该期望投入大量精力来最小化和隔离复杂性。您应该尽可能将元编程隐藏为实现细节,以便面向用户的标头可读,并且您应该确保棘手的代码得到特别好的注释。您应该仔细记录代码的使用方式,并且应该说明“生成的”代码是什么样子。特别注意编译器在用户犯错时发出的错误消息。错误消息是用户界面的一部分,您的代码应该根据需要进行调整,以便从用户的角度来看错误消息是可理解和可操作的。

1.23 概念和约束

谨慎使用概念。一般来说,概念和约束只应在 C++20 之前使用模板的情况下使用。避免在标头中引入新概念,除非标头被标记为库内部。不要定义编译器不强制执行的概念。优先使用约束而不是模板元编程,并避免使用 template<Concept T> 语法;而是使用 require(Concept<T>) 语法。

concept 关键字是一种定义模板参数要求(例如类型特征或接口规范)的新机制。requires 关键字提供了在模板上放置匿名约束并在编译时验证约束是否得到满足的机制。概念和约束通常一起使用,但也可以单独使用。

优点:

  • 概念允许编译器在涉及模板时生成更好的错误消息,从而减少混乱并显著改善开发体验。
  • 概念可以减少定义和使用编译时约束所需的样板,通常可以提高生成代码的清晰度。
  • 约束提供了一些难以通过模板和 SFINAE 技术实现的功能。

缺点:

  • 与模板一样,概念会使代码变得更加复杂和难以理解。
  • 概念语法可能会让读者感到困惑,因为概念在使用时看起来与类类型相似。
  • 概念,尤其是在 API 边界,会增加代码耦合、僵化和僵化。
  • 概念和约束可以从函数体中复制逻辑,导致代码重复并增加维护成本。
  • 概念混淆了其底层契约的真相来源,因为它们是独立的命名实体,可以在多个位置使用,所有这些位置都是彼此独立发展的。这可能会导致明示和暗示的要求随着时间的推移而出现分歧。
  • 概念和约束以新颖且不明显的方式影响过载解析。
  • 与 SFINAE 一样,约束使大规模重构代码变得更加困难。

当存在等效概念时,应优先使用标准库中的预定义概念而不是类型特征。例如,如果在 C++20 之前使用了 std::is_integral_v,则应在 C++20 代码中使用 std::integral。同样,优先使用现代约束语法(通过 require(Condition))

避免使用旧模板元编程构造(例如 std::enable_if<Condition>)以及 template<Concept T> 语法。

不要手动重新实现任何现有概念或特征。例如,使用 require(std::default_initializable<T>) 而不是 require(requires { T v; }) 或类似方法。

新概念声明应该很少见,并且只在库内部定义,这样它们就不会在 API 边界上暴露。更一般地说,如果您不会在 C++17 中使用它们的旧模板等效项,请不要使用概念或约束。

不要定义重复函数体的概念,也不要强加无关紧要的要求,或者从阅读代码主体或产生的错误消息中显而易见的要求。例如,避免以下情况:

template <typename T>     // Bad - redundant with negligible benefit
concept Addable = std::copyable<T> && requires(T a, T b) { a + b; };
template <Addable T>
T Add(T x, T y, T z) { return x + y + z; }

相反,最好将代码保留为普通模板,除非您可以证明概念可以显著改善特定情况,例如,对于深度嵌套或不明显的要求,产生的错误消息。
概念应该由编译器静态验证。不要使用主要优点来自语义(或其他未强制执行的)约束的任何概念。编译时未强制执行的要求应通过其他机制(例如注释、断言或测试)来实施。

猜你喜欢

转载自blog.csdn.net/Once_day/article/details/143028889