Google C++ Style笔记

Google C++ Style 笔记

头文件

作用域

命名空间

内部链接

当 .cc 文件中的定义不需要在该文件之外被引用时,通过将它们放在一个未命名的命名空间中或声明为静态来给予它们内部链接。不要在 .h 文件中使用这些结构。

.cc 文件中具名命名空间中定义的变量是可以被其它.cc 文件在相同命名空间中使用的。

本地变量

静态和全局变量

所有的对象都有存储期,即生命周期。静态存储周期的对象从其被初始化开始生效,直至程序结束。在函数内部定义的静态变量可能会动态初始化,即在第一次调用时初始化。其它静态变量的初始化作为程序启动的一部分。所有静态变量在程序结束时被销毁。

如果初始化的过程中有非平凡的过程发生,那么这个初始化也可能是动态的。

动态初始化的拥有静态存储器的变量的构造和析构顺序是不确定的,因此,不要在一个静态变量的构造函数中使用另一个静态变量。

结论:

  1. 只有拥有平凡析构函数的对象才能采取静态存储期,例如基本类型、可以平凡析构对象构成的数组。用constexpr修饰的对象也可以平凡地析构。对于带有复杂析构过程的对象,它们在程序结束时的析构顺序可能会导致问题,例如访问已析构对象,导致未定义行为。
    1. 一般实践中,不会这么细吧,有很多static的 standard 容器。
  2. 对于静态变量,应该尽可能地使用常量初始化,应该用constexptconstinit标记静态变量为常量初始化,应该假设任何没有被如此标记的、非 local 静态变量都是动态初始化的。当然,不禁止动态初始化全局变量。

一般建议:

  1. 如果需要全局字符串,可以使用constexpr修饰的string_view变量、字符数组或者指向字符串字面量的指针。
  2. 如果需要一个static的、大小固定的集合,用来搜索或者作为查找表,不要使用标准库中的动态容器作为static变量,因为它们的析构函数是非平凡的。取而代之可以使用一些平凡类型的数组,或者一数组的平凡类型的pair。如果非要用的话,推荐定义一个函数范围内的static指针,指向标准库中的动态容器。
    1. 即可以通过delete来释放动态容器,而不是等待析构函数。
  3. 智能指针不推荐作为static变量,因为它们的析构函数是非平凡的。
    1. 这个建议应该有点过时了,还是智能指针吧。
  4. 尽量为需要成为static的自定义类型提供平凡的析构函数和constexpr的构造函数。
    1. constexpr构造函数:函数体非delete,每个基类子对象、非可变(不是类似union)非static数据成员必须被初始化,基类构造函数为constexpr

晕了。

thread_local 变量

thread_local 变量在每个线程中都有一份独立的实例,不同线程之间的变量不共享。

缺点:

  1. thread_local变量会在运行时初始化,因此在程序启动或者线程创建时会有额外的开销。
  2. thread_local实际上是全局变量,因此,除了其不存在线程安全问题,它具有和全局变量的所有缺点。
  3. thread_local在运行时可能会导致内存膨胀,因为每个线程都有一份独立的实例,而线程的数量可能不是固定的。
  4. 类中的数据成员不能是thread_local,因为类的大小必顫在编译时确定,除非是static成员,即static thread_local
  5. 使用thread_local可能会遇到“释放后使用”的 bug,比如,一个thread_local变量是非平凡的,其析构函数中调用了其它已经被释放的thread_local变量。

因此,在thread_local变量必须在编译期初始化,例如可以使用 C++20 中的关键字constinit来确保。

有关构造函数

构造函数不应该调用虚函数。显而易见,构造过程中,对象尚未被完全构造,其虚函数表不是最终状态,因此调用虚函数只能调用到基类的实现,而非派生类的实现。另外,就算虚函数表是最终状态,调用虚函数也是不安全的,因为派生类的成员变量尚未被初始化。

使用 Init 函数或者工厂方法是一个不错的选择,可以确保在初始化完成后再调用虚函数。

隐式转换

隐式转换问题很多,比如调用了不该调用的构造函数,或者调用了不该调用的运算符重载,以及列表初始化也会遇到问题,例如只包含一个元素的列表,既可以看做是一个元素的列表,也可以看做是调用一个单参数构造函数。

因此,对于类型转换函数、单参数构造函数,必须使用explicit关键字,避免隐式转换。复制和移动构造函数不必是explicit,因为它们不支持类型转换。只有一个std::initializer_list参数的构造函数也不必是explicit,为了支持复制初始化(e.g. MyType m = {1, 2};)。

可复制和移动的类型

能用临时对象初始化并且被赋值的类型就是可移动类型。

能从任何相同类型的对象初始化或者被赋值的类型就是可复制类型。

从数据来源是否被改变的角度来看,std::unique_ptr是可移动但不可复制的。

intstd::string是可复制和可移动的,对于std::string来说,存在比复制更高效的移动操作。

复制构造函数是隐式调用的,因此很容易被忽略。

优点

  1. 可复制和可移动的类型可以通过值传递或者值返回,这可以使得 API 更简单,更易于理解。相较于传递或者返回指针或者引用,值传递和值返回不存在所有权、生命周期、mutability 等问题。
    1. 当然代价就是复制和移动的开销。
    2. 传递的话,const 引用也有这个优点。
  2. 复制/移动构造函数和赋值操作符可以被编译器优化,例如 copy elision(复制消除)。
    1. Copy elision 是一个 C++11 的特性,可以实现零复制值传递语义。
    2. 所谓复制消除就是如果编译器能够确定变量的生命周期,以及对象的值可以直接在目标位置构造时,编译器可以省略不必要的复制或移动操作,例如 NRVO。复制消除要求该类型一定有可用的复制/移动构造函数和析构函数。
    3. 在对象的初始化过程中,如果 initializer expression 是一个纯右值,也会触发复制消除,例如T x = T(T(f()));x直接由f()的结果构造,中间没有移动。
    4. 复制消除在对象之间含有潜在重叠时不会触发,例如用用一个基类对象初始化一个派生类对象中的基类部分。
    5. 复制消除还有其它生效的地方,例如异常、协程。

缺点

  1. 有些类型不需要被复制,例如单例类、handler、范围内限定对象、Mutex 这些,不需要给这些类型提供复制构造函数,移动也是同理。
  2. 复制构造函数往往是隐式调用的,因此很容易被忽略,可能会导致性能问题。这时候传递引用会更好。

结论

  1. 一个类型应该在定义的时候就明确它是否可复制和可移动。

结构体和类

Use a struct only for passive objects that carry data; everything else is a class.

结构体和 Pair、Tuple

pairtuple更适合模板元编程,而不是普通的数据结构。对于普通的数据结构,使用structpair.first.second以及tuple.get方法在普通情况下就是可读性灾难。

继承

组合比继承使用得更频繁。Google 建议所有继承都使用publicprivate继承不如把基类当作成员变量。

public protected private
public 继承 public protected private
protected 继承 protected protected private
private 继承 private private private

从另一种角度,继承可以分为接口继承和实现继承,即继承自抽象类和继承自具体类。

尽量把继承的使用限制在“is-a”的情况下,即派生类是一种基类。

控制protected继承的使用,数据成员一定要是private的。

重写(override)虚函数或者虚析构函数时,只标注override或者final,不要使用virtual。标注为overridefinal后,编译器会检查是否真的重载了虚函数,如果没有,会报错。但是

多重继承是可以的,但是强烈不推荐多重实现继承。

运算符重载

访问控制

声明顺序

  1. 类型和类型别名。
  2. (结构体)非静态数据成员。
  3. 静态常量。
  4. 工厂函数。
  5. 构造函数和赋值运算符。
  6. 析构函数。
  7. 所有的其它函数(静态和非静态成员函数、友元)。
  8. 所有其它数据成员(静态和非静态)。

函数

输入和输出

通常情况下,使用std::optional来表示可选的按值传递的输入;当非可选形式会使用引用时,使用常量指针。使用非常量指针表示可选的输出和可选的输入/输出参数。

#include <iostream>
#include <optional>
#include <string>

// 函数接受一个可选的字符串参数
void PrintMessage(const std::optional<std::string>& message = std::nullopt) {
  if (message) {
    std::cout << "Message: " << *message << std::endl;
  } else {
    std::cout << "No message provided." << std::endl;
  }
}

int main() {
  std::optional<std::string> msg1 = "Hello, World!";
  std::optional<std::string> msg2;  // 默认构造,表示没有值

  PrintMessage(msg1);  // 输出: Message: Hello, World!
  PrintMessage(msg2);  // 输出: No message provided.
  PrintMessage();      // 输出: No message provided.

  return 0;
}

写短函数

函数尽量短小,只做一件事。如果函数超过 40 行,就应该考虑拆分。

函数重载

只有在代码读者可以清楚地知道重载函数的行为时,才应该使用函数重载,即在语义上,确保重载函数的行为是相同的。

另外如下的重载:

class MyClass {
 public:
  void Analyze(const std::string& text);
  void Analyze(const char *text, size_t texelen);
};

不如只设计一个接收std::string_view的函数。

默认参数

为函数提供默认参数是另一种形式的函数重载。

后置返回类型

只有当普通语法不切实际或者可读性大大降低的情况下,才使用后置返回类型。

Google-Specific Magic

所有权和智能指针

对于动态分配的对象,倾向于拥有单一、固定的所有权,倾向于使用智能指针转移所有权。在动态分配对象时,就确保它的所有权,例如倾向于使用std::unique_ptr作为工厂方法的返回值。

优点:

  1. 管理没有所有权的动态分配对象,几乎是不可能的。
  2. 转移所有权比复制便宜。
  3. 转移所有权比“借用”指针或者引用更简单,因为这样可以减少在两个用户之间协调对象生命周期的需要。
  4. 智能指针可以通过使所有权逻辑显式、具有自我说明且明确无误来提高代码的可读性。
  5. 智能指针可以消除手动管理所有权麻烦,从而简化代码,并排除大量潜在的错误。
  6. 对于 const 对象,共享所有权可以成为深度复制的一种简单高效的替代方案。

缺点:

  1. 引入了智能指针,提高了复杂性。
  2. 可能高估了值传递的开销,因此通过转移所有权带来的性能收益可能不那么明显。
  3. 所有权的 API 强迫所有人遵守单一内存模型。
  4. 智能指针管理的资源,析构不是显式的,不知道资源究竟在哪个位置被释放。
  5. 某些情况下,具有共享所有权的对象可能永远不会被删除。
    1. 经典的循环引用问题。

cpplint

其它 C++特性

右值引用

右值引用是一种可以绑定到临时对象上的引用。

用处:

  1. 移动语义。比如v1是一个std::vector<std::string>auto v2(std::move(v1));可能只会有一些指针的修改,而不是数据的整个复制。
  2. 实现可移动不可复制的类型。对于那些没有合理复制定义的类型,右值引用很有用,比如独占的硬件资源、文件句柄等。
  3. std::move是高效使用标准库类型的关键,例如转移std::unique_ptr的所有权。
  4. 完美转发。可以编写一个通用的 wrapper,可以将其参数转发给另一个函数,并且能够处理参数是否为临时对象、是否为常量对象的情况。

友元

友元应该和类放在同一个文件。友元经常的用法是定义FooBuilderFoo的友元,这样的话,FooBuilder就可以检查Foo的内部状态了。还可以把单元测试的类定义为被测试类的友元。

异常

noexcept

限定符noexcept用来标记一个函数不会抛出异常,这样可以让编译器优化。如果一个noexcept的函数抛出了异常,程序会调用std::terminate

运算符noexcept在编译期检查是否一个表达式可能抛出异常。

优点:

  1. 将移动构造函数指定为noexcept可以在某些情况下提升性能,比如,如果T的移动构造函数是noexcept的,std::vector<T>::resize()会移动对象而不是复制对象。
    1. 若元素类型T的移动构造函数被标记为noexcept,程序可以更安全地选择移动对象而不是复制对象(例如在调整容器大小时)。
  2. 在启用异常的环境中,在函数上指定noexcept可以触发编译器优化。例如,如果编译器知道由于noexcept说明符不会抛出异常,它就不需要为栈展开生成额外的代码。

缺点:

  1. 在禁用异常的环境中,很难确保noexcept是正确的,也很难定义这意味着什么。
  2. 撤销nocept是很困难的,因为这会打破调用者的假设,甚至导致重新设计 API。

Run-Time Type Information (RTTI)

避免使用 RTTI。RTTI 允许程序员在运行时检查对象的类型。RTTI 通过typeid运算符和dynamic_cast运算符实现。

单元测试中,RTTI 可能会有用。

在运行时频繁查询对象类型,表明类的继承设计可能有问题。RTTI 的不当使用会导致代码变得难以维护。当发现自己需要用到 RTTI 时,考虑下面两种替代方案:

  1. 虚函数。
  2. 访问者模式。

如果程序可以确保给定的基类实例实际上是一个特定的派生类实例,那么就可以使dynamic_cast。这种情况也可以使用static_cast作为替代,因为dynamic_cast的开销可能会很大。

转换(Casting)

  1. 使用大括号初始化列表对算术类型进行转换,能防止隐式缩窄转换,例如int_64 x{value};
  2. 使用static_cast替换 C 风格的转换,例如static_cast<int>(value);
  3. 使用const_cast移除const限定符,例如const_cast<char*>(value);
  4. 使用reinterpret_cast来执行指针类型与整数或其他指针类型(包括 void*)之间的不安全转换。
  5. 使用dynamic_cast进行运行时类型检查,例如dynamic_cast<Derived*>(base);
  6. C++20,bit_cast用于在两个具有相同大小的类型之间进行位级别转换。在可能的情况下,bit_cast提供了一种不依赖未定义行为的转换方式。

前置自增和自减

Prefer ++i to i++.

const 的使用

尽可能使用constconstexprt在某些情况下比const更好。

使用constexprconstinitconsteval

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

某些变量可以被声明为constexpr,以表明这些变量是真正的常量,即在编译或链接时就固定了。一些函数和构造函数可以被声明为constexpr,这使得它们可以用于定义一个constexpr变量。函数可以被声明为consteval,以限制其只能在编译时使用。

整数类型

考虑到不同平台上整数类型的大小可能不同,应该使用<cstdint>中定义的整数类型。

C++ 标准库中用无符号整数来表示容器大小,这是历史遗留下来的设计决定,虽然它可能不够理想,但目前难以更改。尽量用迭代器和容器来处理数据,减少指针操作和直接处理大小的需求。

浮点数类型

只用floatdouble,不用long double

架构移植性

预处理宏

  1. 不要在头文件中定义预处理宏。
  2. 在使用的地方#define宏,之后#undef

0 和nullptr/NULL

sizeof

Prefer sizeof(varname) to sizeof(type).

型别推导

见《More Effective C++》。

类模板参数推导

指定初始化

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
  };

Lambda

倾向于显式捕获需要的变量。

模板元编程

C++20 模块

不要使用。

协程

不要使用。

Boost

只使用推荐的。

不允许使用的标准库特性

<ratio><cfenc><fenv.h><filesystem>

<filesystem>不允许?项目里常用啊。

非标准拓展

别名

Switch

如果switch语句的条件不是基于枚举值,那么它应该始终包含一个default分支(在枚举值的情况下,编译器会警告您是否有未处理的值)。如果default分支不应该被执行,则应将其视为一个错误。

从一个case标签落入另一个case标签的情况必须使用[[fallthrough]]; 属性进行标注。[[fallthrough]]; 应该放置在发生落入下一个case标签的执行点。一个常见的例外是连续的case标签之间没有插入代码,此时不需要标注。

#include <iostream>

void printDayType(int day) {
    switch (day) {
        case 1: // Monday
        case 2: // Tuesday
        case 3: // Wednesday
        case 4: // Thursday
        case 5: // Friday
            std::cout << "Weekday" << std::endl; // This is user's code
            break;
        case 6: // Saturday
            [[fallthrough]]; // Intentional fallthrough to Sunday
        case 7: // Sunday
            std::cout << "Weekend" << std::endl; // This is user's code
            break;
        default:
            std::cout << "Invalid day" << std::endl;
            break;
    }
}

int main() {
    printDayType(6); // 输出: Weekend
    printDayType(7); // 输出: Weekend
    printDayType(3); // 输出: Weekday
    printDayType(8); // 输出: Invalid day
    return 0;
}

[[fallthrough]]的主要用途就是在switch语句中明确表示一个case分支在执行完它的代码块后,故意继续执行下一个case分支的代码。这种明确表示能够帮助阅读代码的人理解这种 fall-through 是有意的,而不是因为遗漏break等终止语句导致的。

posted @   本丘克  阅读(16)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示