简介

  • C++ Core Guidelines笔记

第二章 理念

在代码中直接表达思想

  • 程序员应该直接用代码直接表达他们的思想,因为代码可以被编译器和工具检查
  • 一个专业的C++开发者应该了解STL算法

用ISO标准C++写代码

  • 使用当前的C++标准,不要使用编译器扩展
  • 此外,要注意未定义行为和实现定义行为
    • 未定义行为:
    • 实现定义行为:程序的行为可能因编译器实现而异。实现必须在文档里描述实现的行为

在C++编程中,有两个重要的概念:未定义行为(Undefined Behavior)和实现定义行为(Implementation Defined Behavior)。

  1. 未定义行为 (Undefined Behavior):
    • 当程序包含未定义行为时,C++标准没有规定程序的行为,允许编译器和运行时环境采用任何行为。这可能导致程序崩溃、产生意外结果、或者在不同的编译器、平台或编译选项下表现不同。
    • 未定义行为可能是由于程序中的错误、溢出、指针操纵等原因引起的,也可能是标准规定没有定义的操作。
  2. 实现定义行为 (Implementation Defined Behavior):
    • 当某个方面的行为是由C++标准定义的,但是标准允许不同的实现在这方面做出不同的选择,这被称为实现定义行为。
    • 例如,标准规定某个操作的结果可以有多种可能,但实现需要选择其中一种并在文档中明确说明。

示例:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    std::cout << arr[5] << std::endl; // 未定义行为,越界访问数组

    int x = -5;
    unsigned int y = 10;
    std::cout << x * y << std::endl; // 实现定义行为,结果取决于具体的实现
    return 0;
}

在这个例子中,访问数组arr的第六个元素是未定义行为,因为数组索引越界。而对于x * y的乘法,结果是实现定义的,因为标准并没有规定对于负数和无符号数相乘的具体行为。

在编写C++代码时,尽量避免未定义行为,因为它可能导致代码在不同环境下的行为不可预测。实现定义行为可能会因不同的编译器或平台而异,但至少有一个可预测的结果。

  • 当你必须使用没有写在ISO标准里的扩展时,可以用一个稳定的接口将它们封装起来

表达意图

  • 表达意图是良好的代码文档的一个重要准则。文档应该说明代码会做什么,而不是代码会怎么做

不要泄漏任何资源

  • 资源可以是内存,也可以是文件句柄或者套接字。
  • 处理资源的惯用法是RAII。RAII是 Resource Acquisition Is Initialization(资源获取即初始化)的缩写,本质上意味着你在用户定义类型的构造函数中获取资源,在析构函数中释放资源。通过使对象成为一个有作用于的对象,C++的运行时会自动照顾到资源的生存期
  • C++大量使用RAII:锁负责处理互斥量,智能指针负责处理原始内存,STL的容器负责处理底层元素,等等

不可变数据优先于可变数据

  • 使用不可变数据的理由有很多。
  • 首先,当你使用常量时,你的代码更加容易验证
  • 最重要的是常量在并发程序中具有很大的优势
  • 不可变数据在设计上是没有数据竞争的,因为数据竞争的必要条件就是对数据进行修改

封装杂乱的构件,不要让他在代码中散布开

  • 混乱的代码往往是低级代码易于隐藏错误,容易出现问题。
  • 如果可能的话,用STL中的高级构件(例如容器和算法)来取代你的杂乱代码。
  • 如果这不可能,就把那些杂乱的代码封装带一个用户自定义的类型或者函数中

适当使用辅助工具

  • 计算机比人类更擅长做枯燥和重复性的工作。也就是说,应该使用静态分析工具,并发工具和测试工具来自动完成这些验证。
  • 用一个以上的C++编译器来编译代码,往往是验证代码的最简单方式。一个编译器可能检测不到某种未定义行为,而另一个编译器可能会在同样的情况下发出警告或者产生错误

适当使用支持库

  • 你应该去找设计良好,文档齐全,支持良好的库。
  • 突出的例子包括:
    • C++标准库
    • Guidelines支持的库
    • Boost库

第三章 接口

  • 接口是服务的提供者和使用者之间的契约

避免非const的全局变量

  • 全局变量会在函数中注入隐藏的依赖,而该依赖并不是接口的一部分
  • 非const的全局变量有许多弊端。首先,非const的全局变量破坏了封装。这种对封装的破坏让你无法对函数/类(实体)进行孤立思考。

避免单例

  • 单例就是全局变量,因此你应当尽可能避免单例。单例简单,直接地保证该类最多只有一个实例存在。
  • C++ 单例模式 详解 单例模式是一种设计模式,其目的是确保一个类只有一个实例,并提供全局访问点。这有助于确保在整个应用程序中共享相同的资源或状态,以及提供一种方便的方法来访问该实例。下面详细解释C++中的单例模式。

实现单例模式的步骤:

  1. 私有构造函数: 将类的构造函数声明为私有,以防止外部直接实例化类。

    1
    2
    3
    4
    
     class Singleton {
     private:
         Singleton() {}  // 私有构造函数
     };
    
  2. 静态成员变量: 在类中声明一个静态私有指针,用于保存唯一的实例。

    1
    2
    3
    4
    5
    
     class Singleton {
     private:
         static Singleton* instance;  // 静态私有指针
         Singleton() {}  // 私有构造函数
     };
    
  3. 静态方法: 提供一个公共的静态方法,用于获取类的实例。在这个方法中,检查实例是否已经存在,如果不存在,则创建一个新实例并返回。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
     class Singleton {
     private:
         static Singleton* instance;  // 静态私有指针
         Singleton() {}  // 私有构造函数
    
     public:
         static Singleton* getInstance() {
             if (instance == nullptr) {
                 instance = new Singleton();
             }
             return instance;
         }
     };
    
  4. 删除复制构造函数和赋值运算符: 为了防止通过复制构造函数或赋值运算符创建新实例,将它们声明为私有并不实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
     class Singleton {
     private:
         static Singleton* instance;  // 静态私有指针
         Singleton() {}  // 私有构造函数
    
     public:
         static Singleton* getInstance() {
             if (instance == nullptr) {
                 instance = new Singleton();
             }
             return instance;
         }
    
     private:
         Singleton(const Singleton&);  // 禁止复制构造函数
         Singleton& operator=(const Singleton&);  // 禁止赋值运算符
     };
    

线程安全性:

上述实现在单线程环境下是有效的,但在多线程环境中可能会有问题。为了确保线程安全,可以使用加锁机制,例如互斥锁。

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
#include <iostream>
#include <mutex>

class Singleton {
private:
    static Singleton* instance;  // 静态私有指针
    static std::mutex mutex;     // 互斥锁
    Singleton() {}               // 私有构造函数

public:
    static Singleton* getInstance() {
        std::lock_guard<std::mutex> lock(mutex);  // 加锁
        if (instance == nullptr) {
            instance = new Singleton();
        }
        return instance;
    }

private:
    Singleton(const Singleton&);             // 禁止复制构造函数
    Singleton& operator=(const Singleton&);  // 禁止赋值运算符
};

// 初始化静态成员变量
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;

int main() {
    // 获取单例实例
    Singleton* singletonInstance1 = Singleton::getInstance();
    Singleton* singletonInstance2 = Singleton::getInstance();

    std::cout << "Address of instance 1: " << singletonInstance1 << std::endl;
    std::cout << "Address of instance 2: " << singletonInstance2 << std::endl;

    return 0;
}

这个例子中,通过 std::mutex 实现了简单的互斥锁,确保在多线程环境中仍然能够正确地创建单例实例。

运用依赖注入化解

  • 当某个对象使用单例的时候,隐藏的依赖就被注入对象中。而借助依赖注入技术,这个依赖可以变成接口的一部分,并且服务是从外界注入的。这样,客户代码和注入的服务之间就没有了依赖。
  • 依赖注入的典型方式是构造函数,设置函数(setter)成员或者模板参数

构建良好的接口

  • 函数应该通过接口(而不是全局变量)进行沟通。
  • 接口应当遵循以下规则:
    • 接口明确
    • 接口精确并且具有强类型
    • 保持较低的参数数目
    • 避免相同类型却不相关的参数相邻
  • 术语”可调用”(callable)。可调用实体是在行为上像函数的东西。它可以是函数,也可以是函数对象,或者是lambda表达时。
  • 如果可调用实体接受一个参数,它就是一元可调用实体;如果它接受两个参数,则称为二元可调用实体
  • std::transform_reduce先将一元可调用实体应用到一个范围或者将二元可调用实体应用到两个范围,然后将二元可调用实体应用到前一步的结果的范围上。

不要用单个指针来传递数组

  • 这条规则的出现是为了解决一些未定义行为
  • 补救的方法也简单,使用STL中的容器,例如std::vector,并在函数体中检查容器的大小

为了库ABI的稳定,考虑使用PImpI惯用法

  • 应用程序二进制接口(ABI)是两个二进制程序模块间的接口
  • 借助PImpI惯用法,可以隔离类的用户和实现,从而避免重复编译。
  • PImpI是 pointer to implementation(指向实现的指针)的缩写,它指的是C++中的一种编程技巧:
    • 将实现细节放在另一个类中,从而将其从类中移除。而这个包含实现的细节的类是通过一个指针来访问的。
    • 这么做是因为私有数据成员会参与类的内存布局,而私有函数成员会参与重载决策。这些依赖意味着对成员实现细节的修改会导致所有类的用户都需要重新编译。
    • 持有指向实现的指针(PImpI)的类可将用户隔离在类实现的变化之外,而代价则是多了一次间接。
  • C++ PImpI编程技巧 详解 PImpl(Pointer to Implementation)是一种编程技巧,也称为“编译期实现”或“内部实现”,它的目的是将类的实现细节封装在一个单独的实现类中,从而减少头文件的依赖关系,提高代码的模块化性和可维护性。

PImpl模式的实现步骤:

  1. 声明外部接口: 在类的头文件中声明类的公共接口,但将实际的成员变量和实现细节的声明放到一个内部类中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
     // MyClass.h
     class MyClass {
     public:
         MyClass();
         ~MyClass();
    
         void publicMethod1();
         void publicMethod2();
    
     private:
         class Impl;  // 内部实现类的前向声明
         Impl* pImpl;  // 内部实现类的指针
     };
    
  2. 定义实现类: 在实现文件中定义内部实现类,并将实际的成员变量和函数实现放在这里。

    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
    
     // MyClass.cpp
     #include "MyClass.h"
    
     // 实现内部实现类
     class MyClass::Impl {
     public:
         void privateMethod1() {
             // 实现细节
         }
    
         void privateMethod2() {
             // 实现细节
         }
    
         // 成员变量
         int data;
     };
    
     // MyClass 构造函数
     MyClass::MyClass() : pImpl(new Impl()) {}
    
     // MyClass 析构函数
     MyClass::~MyClass() {
         delete pImpl;
     }
    
     // 公共方法的实现调用内部实现类的方法
     void MyClass::publicMethod1() {
         pImpl->privateMethod1();
     }
    
     void MyClass::publicMethod2() {
         pImpl->privateMethod2();
     }
    

PImpl的优势:

  1. 降低编译依赖: 将实现细节从头文件中移除,降低了头文件的依赖关系。这样,当实现发生变化时,只有实现文件需要重新编译,而不会影响到调用方。

  2. 隐藏实现细节: 将实现细节放在内部实现类中,可以隐藏对类的具体实现的细节,只需要暴露公共接口给用户。

  3. 减小编译时间: 当头文件发生变化时,只有依赖头文件的文件需要重新编译,而不会触发整个项目的重新编译。

  4. 改善二进制兼容性: 通过将实现细节放在内部实现类中,可以减少对外部接口的更改,提高二进制兼容性。

  5. 模块化设计: 可以更容易地设计模块化的系统,每个模块只关注自己的接口和实现细节。

注意事项:

  1. 内存管理: 要确保在类的析构函数中正确释放内部实现类的内存,以防止内存泄漏。

  2. 拷贝和赋值: PImpl模式可能导致默认的拷贝构造函数和赋值运算符不再适用,需要自定义这些函数并确保正确处理内部实现类的拷贝和赋值。

  3. 性能开销: PImpl模式引入了指针和额外的间接层,可能会带来一些微小的性能开销,但通常在维护性和可读性上的优势远远超过了这些开销。

PImpl是一种强大的C++编程技巧,特别适用于大型项目和库的开发,有助于提高代码的模块化性和可维护性。

本章精华

  • 不要使用全局变量,它们会引入隐藏的依赖
  • 单例就是变相的全局变量
  • 接口,尤其是函数,应该表达出意图
  • 接口应当是强类型的,而且应该只有几个不容易弄混的参数
  • 不要按指针接收C数组,而应该使用std::span
  • 如果你想要将类的使用和实现分开,请使用PImpI惯用法

第四章 函数

  • 软件开发人员通过将复杂的任务划分为较小的单元来掌控复杂性。在处理完小单元后,他们把小单元放在一起来掌控复杂的任务。
  • 函数是一种典型的单元,也是程序的基本构件。

函数定义

  • 好软件的最重要原则是好名字。
  • 将有意义的操作打包成精心命名的函数
  • 一个函数应该执行单一的逻辑操作
  • 使函数保持简短

  • 当你无法为函数找到一个有意义的名称时,这充分说明你的函数执行不止一项逻辑操作,而且你的函数并不简短

如果函数有可能需要在编译期求值,就把它声明为 constexpr

  • constexpr函数是可能在编译期运行的函数。当你在常量表达式中调用constexpr函数时,或者当你要用一个constexpr变量来获取constexpr函数的结果时,它会在编译期运行。也可以用只能在运行其求值的参数来调用constexpr函。
  • constexpr函数是隐含內联的
  • 编译期求值的constexpr的结果通常会被系统标记为只读
  • 性能是constexpr函数的第一大好处;它的第二大好处是,编译期求值的constexpr函数是纯函数,因此constexpr函数是线程安全的
  • 最后,计算结果会在运行期作为只读存储区域中的常量来提供

如果你的函数必定不抛异常,就把它声明为noexcept

  • 通过将函数声明为noexcept,你减少了备选控制路径的数量;因此,noexcept对优化器来说是一个有价值的提示
  • 即使你的函数可以抛出异常,noexcept往往也合理。noexcept在这种情况下意味着:我不在乎异常。其原因可能是:你无法对异常作出反应。在这种情况下,系统处理异常的唯一办法是调用std::terminate()
  • 以下类型的函数永远不应该抛出异常:
    • 析构函数
    • swap函数
    • 移动操作和默认构造函数

优先使用纯函数

  • 纯函数是指在给定相同参数时总是返回相同结果的函数。这个属性也被称为引用透明性。纯函数的行为就像无限大的查找表
  • 非纯函数是指random()或者time()这样的函数,它们会在不同的调用中返回不同的结果。换句话说,与函数体之外的状态交互的函数是不纯的
  • 纯函数有一些非常有趣的属性
    • 孤立的测试
    • 孤立的验证或重构
    • 还存其结果
    • 被自动重排或者在其他线程上执行
  • 纯函数也常被称为数学函数。
  • constexpr函数在编译期求值时是纯的。模板元编程是一种嵌在命令式语言C++中的纯函数式语言

优先采用简单而约定俗成的信息传递方式

  • 数据的类型:
    • 拷贝开销低或者不可能拷贝构造: func(X)
    • 移动开销低:std::vector, std::string
    • 移动开销中: std::array<std::vector>或者BigPOD(POD代表 Plain Old Data,简旧数据,意为一般的传统数据–没有析构函数,构造函数以及虚成员函数的类)
    • 移动开销未知: 模板
    • 移动开销高:BigPOD[]或者std::array
  • 参数传递的方向
    • 入:输入参数
    • 入并保留拷贝:被调用者保留一份数据
    • 入并移入:参数处在所谓的被移动状态。被移动状态意味着它处于合法但未指定的状态。基本上,你在重新使用被移动的对象前必须对他进行初始化
    • 入/出:参数会被修改
    • 出:输出参数

对于入参,拷贝开销低的类型按值传递,其他类型则以const引用来传递

  • 默认情况下,输入值可以拷贝就拷贝。如果拷贝开销不低,就通过const引用来传入。
  • 经验法则:
    • 如果 sizeof(par) <= 2 * sizeof(void*),则按值传递参数par
    • 如果 sizeof(par) > 2 * sizeof(void*),则按const引用传递par

对于转发参数,要用TP&&来传递,并且只std::forward该参数

  • 有时你想转发参数par。这意味着你希望保持左值的左值性,以及右值的右值性,这样才能完美地转发参数,使它的语义不发生变化
  • 转发参数的典型用例是工厂函数,工厂函数通过调用某个用户指定对象的构造函数创建出该对象。你不知道参数是不是右值,也不知道构造函数需要多少个参数

在C++中,工厂函数是一种设计模式,它提供了一种创建对象的方式,使得在不暴露对象的具体实现细节的情况下能够创建对象。工厂函数通常用于创建类的实例,而不是直接调用类的构造函数。这有助于实现抽象和封装,同时提供了灵活性和可维护性。

以下是关于C++工厂函数的一些详解:

  1. 定义: 工厂函数是一个函数,负责创建和返回类的实例。它通常是类的静态成员函数,或者是一个独立于类的函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    class Product {
    public:
        virtual ~Product() {}
        virtual void doSomething() = 0;
    };
    
    class ConcreteProduct : public Product {
    public:
        void doSomething() override {
            // 具体产品的实现
        }
    };
    
    class Factory {
    public:
        static Product* createProduct() {
            return new ConcreteProduct();
        }
    };
    
  2. 返回类型: 工厂函数通常返回一个指向基类(抽象类)的指针或引用,这样可以隐藏具体实现的细节,同时允许客户端代码通过基类接口使用对象。

  3. 抽象类: 工厂函数通常用于创建抽象类的实例,这样可以根据需要选择合适的具体实现。在上面的例子中,Product 是抽象类,而 ConcreteProduct 是它的具体实现。

  4. 灵活性: 工厂函数提供了一种动态创建对象的方式,允许在运行时根据条件或配置选择要创建的具体类型,从而提供更大的灵活性。

    1
    2
    3
    4
    5
    6
    7
    
    int main() {
        Product* product = Factory::createProduct();
        product->doSomething();
        delete product;
    
        return 0;
    }
    
  5. 多态性: 通过返回基类指针,工厂函数支持多态性,允许通过基类接口调用具体类的方法。

  6. 单例工厂: 工厂函数可以实现为单例,确保在应用程序中只存在一个工厂实例,从而确保对对象创建的全局控制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    class SingletonFactory {
    public:
        static SingletonFactory& getInstance() {
            static SingletonFactory instance;
            return instance;
        }
    
        Product* createProduct() {
            return new ConcreteProduct();
        }
    
    private:
        SingletonFactory() {}  // 私有构造函数,确保只能通过 getInstance() 访问
    };
    

总的来说,工厂函数是一种有用的设计模式,它提供了一种灵活、可扩展且易于维护的方式来创建对象,特别是在需要隐藏具体实现的情况下。

  • 三个点(省略号)表示形参包。我们将使用形参包的模板称为变参模板
  • 形参包的打包和解包
    • 当省略号在类型参数T1的左边时,参数包被打包;当省略号在右边时,参数包被解包
    • 返回语句 T(std::forward(t1)...)中的这种解包实质上意味着表达式std::forward(t1)被不断重复,直到形参表的所有参数都被消耗掉,并且会在每个子表达式之间加一个逗号
  • C++ 形参包 详解

在C++中,形参包(parameter pack)是C++11引入的一个特性,它允许你定义一个可以包含任意数量参数的函数或类模板。形参包的主要优势之一是在不知道参数数量的情况下,仍然能够编写通用的代码。

以下是有关C++形参包的详解:

  1. 定义形参包: 形参包使用省略号 ... 表示。它可以用在函数模板或类模板的参数列表中。

    1
    2
    3
    4
    5
    
    // 函数模板形参包的例子
    template <typename... Args>
    void myFunction(Args... args) {
        // 使用args...
    }
    

    在上面的例子中,Args 是一个模板参数包,而 args 是函数参数包。

  2. 展开形参包: 通过使用展开操作符 ...,可以在函数体内展开形参包,以便对每个参数执行相同的操作。

    1
    2
    3
    4
    
    template <typename... Args>
    void printValues(Args... args) {
        (std::cout << ... << args) << '\n';  // 展开形参包,逐个输出参数
    }
    

    在上面的例子中,(std::cout << ... << args) 部分展开了形参包,逐个将参数传递给 std::cout

  3. 递归展开: 形参包可以用于递归展开,实现对每个参数的逐一处理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    template <typename T>
    void printSingleValue(const T& value) {
        std::cout << value << '\n';
    }
    
    template <typename T, typename... Args>
    void printValues(T first, Args... rest) {
        printSingleValue(first);    // 处理第一个参数
        printValues(rest...);       // 递归展开处理剩余参数
    }
    

    在上面的例子中,printValues 函数递归展开形参包,对每个参数调用 printSingleValue 函数。

  4. 折叠表达式: C++17 引入了折叠表达式,使得展开形参包更加简洁。

    1
    2
    3
    4
    
    template <typename... Args>
    void printValues(Args... args) {
        (std::cout << ... << args) << '\n';  // 折叠表达式
    }
    

    折叠表达式使得对形参包的处理更加紧凑和易读。

  5. 使用形参包的场景: 形参包通常在需要处理可变数量参数的通用函数或模板中使用,例如元组的操作、可变参数模板、泛型代码等。

形参包是C++中强大的工具,它为编写通用和灵活的代码提供了便利。在处理不定数量参数的场景中,形参包的使用可以显著提高代码的可读性和可维护性。

  • 转发与变参模板的结合是C++中典型的创建模式

对于 入-出 参数,使用非const的引用来传递

  • 这条规则把函数的设计意图传达给了调用法:该函数会修改它的参数

对于 出 的输出值,优先使用返回值而非输出参数

  • 用返回值就好,但是别用const,因为它不但没有附加价值,而且还会干扰移动语义

要返回多个 出 值,优先考虑返回结构体或者多元组

  • 当你向std::set中插入一个值时,成员函数insert的重载会返回一个std::pair,它由两部分组成:
    • 一个指向所插入元素的迭代器
    • 还有一个bool,如果插入成功,它会被设置为true
  • C++11中的std::tie和C++17中的结构化绑定是将两个值绑定到某变量的两种优雅方式
  • 结构化绑定 C++结构化绑定(Structured Bindings)是C++17引入的一项特性,它提供了一种方便的方式来将多个变量绑定到结构体、元组或其他类似的数据结构的成员上。结构化绑定的目的是简化对结构化数据的访问和处理。

在结构化绑定中,可以使用auto关键字和花括号来声明和初始化多个变量,这些变量会被绑定到结构体或元组的成员。这使得代码更加简洁和可读。

以下是结构化绑定的基本语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <tuple>

int main() {
    // 示例:使用结构化绑定绑定元组的元素
    std::tuple<int, double, std::string> myTuple{42, 3.14, "Hello"};

    auto [a, b, c] = myTuple;  // 结构化绑定

    // 现在 a, b, c 分别是 myTuple 的元素
    // a = 42, b = 3.14, c = "Hello"

    return 0;
}

上述例子中,auto [a, b, c] 表示使用结构化绑定将myTuple中的元素绑定到变量abc上。

另外,结构化绑定还可以用于对结构体成员的绑定,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Point {
    int x;
    int y;
};

int main() {
    Point p{10, 20};

    auto [px, py] = p;  // 结构化绑定

    // px = 10, py = 20

    return 0;
}

结构化绑定在循环中也非常有用,可以方便地遍历容器中的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <vector>

int main() {
    std::vector<std::pair<int, double>> vec;

    for (const auto& [index, value] : vec) {
        // 使用结构化绑定遍历容器中的元素
        // index 和 value 分别绑定到 pair 的第一个和第二个元素
        // ...
    }

    return 0;
}

结构化绑定在C++中提供了一种更简洁、更灵活的方式来处理结构化数据,从而使代码更加清晰易读。这个特性的引入使得C++语言更具现代感和表达力。

参数传递: 所有权语义

  • func(value): 函数func自己有一份value的拷贝并且就是其所有者。func会自动释放该资源
  • func(pointer*): func借用了资源,所以无权删除该资源。func在每次使用前都必须检查该指针是否为空指针
  • func(reference&):func借用了资源。与指针不同,引用的值总是合法的
  • func(std::unique_ptr): func是资源的新所有者。func的调用方法显式地把资源的所有权传递给了被调用方。func会自动释放该资源
  • func(std::shared_ptr): func是资源的额外所有者。func会延长资源的生存期。在func结束时,它也会结束对资源的所有权。如果func是资源的最后一个所有者,那么它的结束会导致资源的释放

  • 在应用层面使用std::move的意图并不在于移动。在应用层面使用std::move的目的是所有权的转移

  • C++ 所有权语义 详解 C++的所有权语义指的是对于对象内存的所有权管理方式,即确定何时创建、拥有、传递和销毁对象。在C++中,主要有两种所有权语义,即值语义和引用语义。
  1. 值语义(Value Semantics)
    • 对象拥有其值:当使用值语义时,对象在栈上或作为成员变量直接存储其值。当对象复制时,新的对象独立拥有自己的值,不受原始对象的影响。
    • 拷贝构造函数和赋值运算符:值类型对象通常需要定义拷贝构造函数和赋值运算符,以确保正确地复制对象的值。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    class ValueSemanticsExample {
    public:
        ValueSemanticsExample(int val) : value(val) {}
           
        // 拷贝构造函数
        ValueSemanticsExample(const ValueSemanticsExample& other) : value(other.value) {}
           
        // 赋值运算符
        ValueSemanticsExample& operator=(const ValueSemanticsExample& other) {
            if (this != &other) {
                value = other.value;
            }
            return *this;
        }
    
    private:
        int value;
    };
    
  2. 引用语义(Reference Semantics)
    • 对象拥有引用:当使用引用语义时,对象本身并不存储值,而是存储对其他对象的引用。多个对象可以共享相同的值。
    • 拷贝构造函数和赋值运算符需小心:引用类型对象通常需要小心处理拷贝构造函数和赋值运算符,以避免意外地共享底层资源。
    1
    2
    3
    4
    5
    6
    7
    
    class ReferenceSemanticsExample {
    public:
        ReferenceSemanticsExample(int& valRef) : valueReference(valRef) {}
    
    private:
        int& valueReference;
    };
    

在现代C++中,智能指针也提供了一种更灵活的所有权语义。智能指针允许在堆上动态分配内存,并通过引用计数等机制来管理内存的释放。std::unique_ptr提供了独占所有权,而std::shared_ptr允许多个指针共享同一块内存。

1
2
3
4
5
6
7
8
9
#include <memory>

class SmartPointerExample {
public:
    SmartPointerExample(int val) : value(std::make_unique<int>(val)) {}

private:
    std::unique_ptr<int> value;
};

在选择值语义还是引用语义时,需要根据程序的需求和性能考虑。值语义通常更容易理解和使用,而引用语义可以更有效地共享资源,但需要小心管理共享状态,以避免潜在的问题。

返回 T* (仅仅)用于表示位置

  • 指针仅用于表示位置。这正是函数find的作用

  • 指针或引用永远不应该转移所有权

当不希望发生拷贝,也不需要表达 没有返回对象 时,应该返回 T&

  • 当不存在 没有返回对象 这种可能性的时候,就可以返回引用而非指针了
  • 有时你想进行链式操作,但是不详为不必要的临时对象进行拷贝和析构

  • 返回局部对象的引用(指针)是未定义行为。未定义行为本质上意味着,不要假想程序的行为

不要返回 T&&

不要返回 std::move(本地变量)

main()的返回类型是 int

  • 依照C++标准,main函数有两种变体
    • int main() {…}
    • int main(int argc, char** argv[]) {…}
  • 第二个版本等效于 int main(int argc, char* argv[]) {…}
  • main函数并不需要返回语句。如果控制流到达main函数的末尾而没有碰到一条返回语句,其效果相当于执行 return 0; 这意味着程序的成功执行

当函数不适用时(需要捕获局部变量,或者编写一个局部函数),请使用lambda表达式

  • 什么时候必须用lambda表达式,什么是否必须使用普通函数。这里有两条明显的理由
    • 如果可调用实体必须捕获局部变量或者它是在局部作用域内声明的,你就必须使用lambda函数
    • 如果可调用实体需要支持重载,那么应该使用普通函数

在局部使用(包括要传递给算法)的lambda表达式中,优先通过引用来捕获

在非局部使用(包括要被返回,存储在堆上或者要传递给其他线程)的lambda表达式中,避免通过引用来捕获

  • 这两条规则高度关联,它们可以归结为: lambda表达式应该只对有效数据进行操作。
    • 当lambda通过拷贝捕获数据时,根据定义,数据总是有效的
    • 当lambda通过引用捕获数据时,数据的生存期必须超过lambda的生存期

在有选择的情况下优先采用默认参数而非重载

  • 如果你需要用不同数量的参数来调用一个函数,尽可能优先采用默认参数而不是重载。这样你就遵循了DRY(不要重复自己)原则

不要使用 va_arg 参数

  • 当你的函数需要接受任意数量的参数时,要使用变参模板而不是va_arg参数

  • 变参数函数(variadic function)是像std::printf这样的函数,可以接受任意数量的参数。
  • 问题是,必须假设传递的类型是正确的。当然这种假设非常容易出错,其正确性依赖于程序员的素养

  • va_arg宏的背景信息
    • va_list : 保存下列宏的必要信息
    • va_start : 启用对变参数函数参数的访问
    • va_arg : 访问下一个变参数函数的参数
    • va_end : 结束对变参数函数参数的访问
  • C++ va_arg 变参数函数参数 详解 va_arg 是C语言中用于处理可变参数函数(变参函数)的宏,它允许函数接受可变数量的参数。在C++中,虽然不鼓励使用C风格的可变参数函数,但仍然支持这一特性。

以下是关于va_arg的详解:

  1. 头文件: 使用 va_arg 需要包含 <cstdarg> 头文件,其中定义了一组宏和类型,用于处理可变参数函数。

    1
    
    #include <cstdarg>
    
  2. 可变参数函数的声明和定义: 可变参数函数通常以省略号 ... 结尾,其中包含不定数量的参数。va_list 类型用于存储参数列表。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    #include <cstdarg>
    #include <iostream>
    
    void myVarArgsFunction(int num, ...) {
        va_list args;
        va_start(args, num);
    
        for (int i = 0; i < num; ++i) {
            int value = va_arg(args, int);
            std::cout << "Argument " << i + 1 << ": " << value << std::endl;
        }
    
        va_end(args);
    }
    

    在上面的例子中,myVarArgsFunction 接受一个整数参数 num,表示后续可变参数的数量。函数通过 va_arg 从参数列表中获取具体的参数值。

  3. va_startva_end: 在使用 va_arg 之前,需要使用 va_start 宏初始化 va_list 对象,以及在函数结束时使用 va_end 宏清理资源。

    1
    2
    3
    4
    5
    6
    7
    8
    
    void myVarArgsFunction(int num, ...) {
        va_list args;
        va_start(args, num);
    
        // ...
    
        va_end(args);
    }
    
  4. 注意事项
    • 参数传递的方式对使用 va_arg 很重要。对于整数、指针等基本类型,va_arg 可以很好地工作。但对于复杂的用户自定义类型,需要谨慎处理。
    • 没有可变参数的方式获取参数数量,需要依赖固定参数来传递数量信息。
    • 使用 va_arg 时,需要清楚每个参数的类型,以避免类型不匹配的问题。
  5. C++ 中的替代方案: 在现代C++中,推荐使用模板和标准库中的可变参数模板(variadic templates)来代替传统的可变参数函数。这种方式更类型安全,更灵活。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    #include <iostream>
    
    template <typename... Args>
    void myVarArgsFunction(Args... args) {
        (std::cout << ... << args) << std::endl;
    }
    
    int main() {
        myVarArgsFunction(1, "Hello", 3.14);
        return 0;
    }
    

    在上面的例子中,使用可变参数模板实现了类似的功能,同时获得了更好的类型安全性。

  • 这些问题可以通过C++17的折叠表达式轻松解决。跟va_arg相比,折叠表达式会自动推导出其参数的数量和类型
  • 变参模板可以接受任意数量的参数。这些任意数量的参数由所谓的参数包持有,用省略号表示。
  • 此外,在C++17中,可以用二元运算符直接对参数包进行归约。这一针对变参模板的增强被称为折叠表达式

本章精华

  • 一个函数应该执行一个操作,要简短,并有一个精心选择的名字
  • 要把可以在编译期运行的函数实现为constexpr
  • 如果可能的话,将你的函数实现为纯函数
  • 区分一个函数的入,入/出和出参。对入参使用按值传递或者按const引用传递,对入/出参数使用按引用传递,对出参使用按值传递
  • 向函数传递参数涉及所有权语义的问题。按值传递使函数成为资源的独立所有者。按指针或引用传递意味着函数值是借用了该资源。std::unique_ptr将所有权转移给函数,std::shared_ptr则使函数称为共享的所有者
  • 当你的函数需要接受任意数量的参数时,要使用变参模板而不是va_arg参数

第五章 类和类层次结构

  • 类是一种用户定义类型,程序员可以为其指定表示方法,操作和接口。
  • 类的层次结构被用来组织相关的结构

  • Guidelines先给出了一些概要规则:
    • 具体类型
    • 构造函数,赋值和析构函数
    • 类的层次结构
    • 重载和运算符重载
    • 联合体

class(类)和struct(结构体)之间的语法差异

  • 在结构体中,所有成员默认为 public(公开)。在类中,所有成员默认 private(私有)
  • 继承的情况也是如此。结构体的基类默认为 public,类的基类默认为 private

把相关的数据组织到结构(struct或class)中

  • 通过将相关元素放在结构体中,函数签名变得可以自我描述。

当类具有不变式时使用class;如果数据成员可以独立变化,则使用struct

  • 类的不变式是用于约束类的实例的不变式。成员函数必须使这个不变式保持成立。不变式约束了类的实例的可能取值。
  • C++ 类中的不变式是什么意思
    • 在C++中,类的不变式(invariant)指的是在类的对象上始终保持成立的条件或属性。这是一种约定或规则,用于确保对象的有效性和一致性。在面向对象编程中,不变式是类设计的一部分,用于描述对象应该具有的状态。
    • 不变式通常与类的公共接口和方法一起工作,确保在对象上执行操作时,类的内部状态不会违反这些不变式。不变式可以看作是类内部约定的一部分,旨在维护对象的有效性。
    • 例如,考虑一个表示时间的类,该类有小时和分钟两个成员变量。一个可能的不变式是,小时应该在0到23之间,分钟应该在0到59之间。在类的方法中,如果有任何操作可能破坏这个不变式,需要在方法执行前或执行后重新确保不变式的成立。
  • 类的不变式在构造函数中被初始化和检查

在类中体现出接口和实现之间的区别

  • 类的公开成员函数是类的接口,私有部分则是实现

仅当函数需要直接访问类的内部表示时,才把它变成成员

  • 如果一个函数不需要访问类的内部结构,它就不应该是成员。这样的话,你会得到松耦合,而类的内部结构的改变不会影响辅助函数

  • 运算符 =, (), [] 和 -> 必须是类的成员

将辅助函数与它们支持的类放在同一个命名空间中

  • 辅助函数应该在类的命名空间中,因为它是类的接口的一部分。与成员函数相反,辅助函数不需要直接访问类的内部表示。

不要在一条语句里定义类或者枚举的同时声明该类型的变量

  • 如果在一条语句里定义类或者枚举同时声明其类型的变量,会引起混淆,因此应该避免

如有任何非公开成员,就使用class,而不是struct

尽量减少成员的暴露

  • 数据隐藏和封装是面向对象类设计的基石之一:你将类中的成员封装起来,只允许通过公共成员函数进行访问。
  • 你的类可能有两种接口
    • 一种是用于外部的 public 接口
    • 一种是用于派生类的 protected接口。
  • 其余成员都应该属于 private

具体类型

  • 具体类型是 最简单的一种类。它常常被称作为值类型,不属于某个类型层次结构的一部分
  • 规范类型是一种 行为类似于int 的类型,因此,它必须支持拷贝和赋值,相等比较,以及可交换。更正式的说法是,一个规范类型X行为上像int,支持下列操作
    • 默认构造: X()
    • 拷贝构造: X(const X&)
    • 拷贝赋值: operator = (const X&)
    • 移动构造: X(X&&)
    • 移动赋值: operator = (X&&)
    • 析构: ~X()
    • 交换操作: swap(X&, X&)
    • 相等运算符: operator == (const X&, const X&)

优先使用具体类型而不是类层次结构

  • 如果没有需要类层次结构的用例,就使用具体类型。具体的类型更容易实现,更小且更快。
  • 不必担心继承,虚性,引用或指针,包括内存分配和释放。不会有虚派发,因此也没有运行期的开销

让具体类型规范化

  • 如果你有一个具体类型,可以考虑将它升级为规范类型
  • 内置类型(例如int或者double)是规范类型,而用户定义类型(例如std::string)或容器(std::vector, std::unordered_map)也是如此

构造函数 ,赋值函数和析构函数

  • 六个特殊的成员函数,它们控制着对象的生命周期
    • 默认构造函数: X()
    • 拷贝构造函数: X(const X&)
    • 拷贝赋值函数: operator = (const X&)
    • 移动构造函数: X(X&&)
    • 移动赋值函数: operator = (X&&)
    • 析构函数: ~X()
  • 编译期可以为这 六大 生成默认实现,但是也可以明确用 =default(预置) 来要求编译提供它们,或者使用 =delete(预置) 来删除它们
  • 默认构造函数可以在没有参数的情况下被调用,但是它可能每个参数都有默认值

如果能避免定义默认操作,那就这么做

  • 这一规则也被称为 零法则。这意味着你可以通过使用有合适的拷贝/移动语义类型,来避免自行编写构造函数,拷贝/移动构造函数,赋值运算符或者析构函数。
  • 有合适的拷贝/移动语义的类型包括规范类型,例如内置类型bool或者double,也包括标准模板库(STL)的容器,例如std::vector或者std::string

  • 当编译器为一个类自动生成拷贝构造函数时,它调用该类的所有成员和所有基类的拷贝构造函数

如果定义或 =delete 了任何默认操作,就对所有默认操作都进行定义或 =delete

  • 六大 是紧密相关的。由于这种关系,你应该对所有特殊成员函数进行定义或者 =delete

让默认操作保持一致

构造函数应当创建完全初始化的对象

  • 构造函数的职责就是创建完全初始化的对象。类不应该有init(初始化)成员函数。将成员函数init设为私有,并从所有构造函数中调用它,这样做好一些,但是仍然不是最佳选择。
  • 当一个类的所有构造函数有共同的操作时,请使用委托构造函数.

  • C++ 委托构造函数是什么 C++11引入了委托构造函数的概念,它允许一个构造函数调用同一类中的另一个构造函数,以便避免代码的重复。

具体来说,委托构造函数是通过在成员初始化列表中使用自身类的其他构造函数来实现的。这样可以在一个构造函数中调用另一个构造函数,从而避免重复初始化相同的代码。

以下是一个简单的示例:

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
#include <iostream>

class MyClass {
public:
    // 委托构造函数
    MyClass(int x) : MyClass(x, 0) {}

    // 主要构造函数
    MyClass(int x, int y) : x(x), y(y) {
        std::cout << "Constructing MyClass(" << x << ", " << y << ")" << std::endl;
    }

    void print() {
        std::cout << "x: " << x << ", y: " << y << std::endl;
    }

private:
    int x;
    int y;
};

int main() {
    MyClass obj1(42);
    obj1.print();

    MyClass obj2(10, 20);
    obj2.print();

    return 0;
}

在上面的示例中,MyClass有两个构造函数,其中一个是主要构造函数,另一个是委托构造函数。委托构造函数通过调用主要构造函数来初始化对象。

通过委托构造函数,可以在不同的构造函数中共享初始化逻辑,提高代码的可维护性。需要注意的是,委托构造函数的调用必须在成员初始化列表中完成,而不能在构造函数的函数体内。

如果构造函数无法构造出有效对象,则应该抛出异常

  • 如果使用无效的对象,你就总得在使用之前检查对象的状态.这样非常繁琐,低效而且容易出错.

确保可拷贝的(值类型)类有默认构造函数

  • 不正式的说,当类的实例缺少有意义的默认值时,该类就不需要默认构造函数

不要定义仅初始化数据成员的默认构造函数,而应该使用成员初始化器

  • 在设计新类时遵循的方法是:在类的主体中定义默认行为。明确定义的构造函数只用来改变默认行为

默认情况下,把单参数的构造函数声明为explicit

  • 说的更明确一点,一个没有explicit的单参数构造函数是个转换构造函数。

按照成员声明的顺序定义和初始化成员变量

  • 类成员是按照它们的声明顺序进行初始化的
  • 类的成员完全按照它们初始化的相反顺序被销毁

在使用常量来初始化时,优先选择类内初始化器,而不是构造函数的成员初始化

  • 虽然类内初始化规定了一个对象的默认行为,但是构造函数可以改变这一默认行为。

在构造函数里优先使用初始化而不是赋值

  • 初始化对赋值有两个最明显的优点:
    • 你不会因为忘记赋值而使用未初始化的成员
    • 初始化可能更快,并且绝不会比赋值慢

特殊构造函数

  • 从C++11开始,一个构造函数可把它的工作委托给同一个类的另一个构造函数,并且构造函数可从父类继承。

使用委托构造函数来表示类的所有构造函数的共同动作

  • 一个构造函数可以把它的工作委托给同一个类的另一个构造函数。委托是C++中把所有构造函数的共同动作放到一个构造函数中的现代方式。
  • 递归调用构造函数是未定义行为。

使用继承构造函数将构造函数导入不需要进一步显式初始化的派生类中

  • 如果可以的话,在派生类中重用基类的构造函数。当派生类没有成员时,这种重用的想法很合适。
  • 如果在可重用构造函数时不重用,就违反了DRY(不要重复自己)原则。
  • 继承的构造函数保留了它们在基类中定义的所有特性,例如访问说明符或者属性explicit和constexpr

拷贝操作

  • 在拷贝之后(a = b),a 和 b必须相同(a == b)
  • 拷贝可深可浅。
    • 深拷贝意味着对象a和b之后时相互独立的(值语义)
    • 浅拷贝意味着对象a和b之后共享一个对象(引用语义)

移动操作

  • C++标准要求被移动的对象之后必须处于一个未指定但是有效的状态。通常情况下,这个被移动的状态是移动操作源对象的默认状态

多态类应当抑制公开的拷贝/移动操作

  • 多态类是定义或者继承了至少一个虚函数的类
  • 拷贝一个多态类的操作可能会以切片而告终。切片是C++中最黑暗的部分之一
    • 切片意味着你想在赋值或者初始化过程中拷贝一个对象,但是你只得到该对象的一部分。

析构函数

  • 对象的析构函数会在其生存期结束时被自动调用。更准确地说,对象的析构函数是在对象超出作用域时调用的

如果一个类在对象销毁时需要明确的动作,那就定义析构函数

  • 问题在于,在你的情况下,编译器生成的析构函数是否已经够用。
    • 如果必须在用户定义类型的生存期结束时执行额外的代码,就必须写析构函数。
    • 反过来说,如果类中没有成员需要额外的清理,就没必要定义析构函数

类获得的所有资源都必须在该类的析构函数中释放

指针和引用

  • 如果你的类有原始指针或者引用,则必须要回答一个关键问题: 谁是所有者

如果类里有原始指针(T*)或者引用(&),请考虑它是否有所有权

  • 如果一个类有原始指针或者引用,你必须明确所有权问题,因为指针既可以表示所有权,也可以表示借用。
  • 如果所有权不明确,你可能会删除你不拥有的一个对象的指针,也可能会漏删你拥有的一个指针。在第一种情况下,由于双重删除,会有未定义行为;在第二种情况下,会面临内存泄漏

如果类具有所有权的指针成员,请定义析构函数

  • 如果类拥有一个对象,它就要负责销毁它,销毁是析构函数的工作。

基类的析构函数应该要么是public且virtual,要么是protected且非virtual

  • 公开的虚析构函数
    • 如果基类有public且virtual的析构函数,你可以通过基类的指针来销毁派生类的实例,引用也是如此。
  • 受保护的非虚析构函数
    • 如果基类的析构函数是protected,你就不能用基类的指针或者引用来销毁派生对象;因此,析构函数不需要声明为virtual
  • 关于基类的析构函数的访问说明符的一些总结性意见
    • 如果基类的析构函数私有(private),你就无法从该类派生
    • 如果基类的析构函数受保护(protected),那么你能从该类派生出子类,然而只能使用子类

如果你需要明确使用默认语义,则使用 =default

当想要禁用默认行为(且不需要替代方法)时使用 =delete

不要在构造函数和析构函数中调用虚函数

  • 在构造函数或析构函数中调用纯虚函数是未定义行为。

使 == 对操作数的类型对称,并使其 noexcept

  • 解决不对称的优雅方法是在类中声明一个友元运算符 ==
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    class MyInt
    {
      int num;
    public:
      MyInt(int n) : num(n) {};
      friend bool operator == (const MyInt& lhs, const MyInt& rhs) noexcept
      {
          return lhs.num == rhs.num;
      }
    }; 
    

类层次结构

  • 什么是类的层次结构?
    • 一个类的层次结构代表了一组分层组织的概念。
    • 基类通常有两种用途。一种通常被称为接口继承,另一种是实现继承。
  • 接口继承使用公共继承,它把用户和实现分开,允许派生类增加或者改变基类的功能,而不影响基类的用户。
  • 实现继承经常使用私有继承。在典型情况下,派生类通过调整基类的功能来提供其功能。实现继承的一个突出例子是适配器模式,因为你可以用多重继承来实现。适配器模式的想法是将现有的接口改编成一个新的接口。适配器使用了对实现的四有继承和对新接口的公共继承。新的接口使用现有的实现来为用户提供服务。

(仅)使用类的层次结构来表达具有内在层次结构的概念

如果基类被当作接口使用,那就把它变成抽象类

  • 抽象类是至少有一个纯虚函数的类。纯虚函数(virtual void function() = 0)是必须由派生类实现的函数(除非派生类也是抽象类)。一个抽象类不能被实例化。
  • 抽象类可以为纯虚函数提供一个实现。这样,派生类也可以使用这个实现。
  • 接口通常应该由公共的纯虚函数组成,没有数据成员,并且有默认/空的虚析构函数(virtual ~My_interface() = default)

当需要完全分离接口和实现时,以抽象类作为接口

  • 抽象类的目的就是分离接口和实现。

如果要对多态类进行深拷贝,应该使用虚函数clone,而不是公开的拷贝构造/赋值

  • 拷贝一个多态类可能会导致切片问题。为了解决这个问题,应该覆盖一个虚clone函数,让它根据实际类型进行复制并且返回一个到新对象的有所有权的指针(std::unique_ptr)
  • 在派生类里,通过使用所谓的协变返回类型来返回派生类型
    • 协变返回类型 : 允许覆盖成员函数返回被覆盖成员函数的返回类型的派生类型。
  • C++ 协变返回类型是什么 C++11引入了协变返回类型(Covariant Return Types)的概念,允许在派生类中覆盖基类的虚函数时,返回类型可以是基类函数返回类型的派生类型。

在使用协变返回类型时,你可以在派生类中重新定义虚函数的返回类型,而不必显式使用类型转换。这使得代码更加清晰和类型安全。

以下是一个简单的示例:

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
#include <iostream>

class Base {
public:
    virtual Base* clone() const {
        std::cout << "Base::clone()" << std::endl;
        return new Base(*this);
    }
};

class Derived : public Base {
public:
    // 协变返回类型
    virtual Derived* clone() const override {
        std::cout << "Derived::clone()" << std::endl;
        return new Derived(*this);
    }
};

int main() {
    Derived derived;
    Base* basePtr = &derived;

    // 调用协变返回类型的虚函数
    Base* cloned = basePtr->clone();

    delete cloned;

    return 0;
}

在上述示例中,Derived 类覆盖了基类 Base 的虚函数 clone,并使用协变返回类型,返回的类型是 Derived* 而不是 Base*。这样,通过基类指针调用虚函数时,返回的是正确的派生类型。

不要无缘无故的把函数变成 virtual

  • 虚函数不是一个无代价的特性
    • 增加了运行时间和对象代码的大小
    • 因为它可以在派生类中被覆盖,它更容易出问题
  • 通常情况下,一个类的所有数据成员的访问说明符都相同:所有数据成员要么全部属于public,要么全部属于private
    • 如果数据成员上面没有不变式,则用public。使用struct关键字
    • 如果数据成员上有不变式,则用private。使用class关键字。

避免无价值的取值和设值函数

  • 如果取值和设值函数没有对数据成员提供额外的语义,它们就没有价值
  • x和y可以取任意值。这意味着Point的实例并没有对x和y维持一个不变式。x和y仅仅是数值而以,更合适的做法是以struct作为值的集合。

避免protected数据

  • protected数据使程序变得复杂且容易出错。如果把protected数据放到基类里,你就不能单独考虑派生类,因而破坏了封装。你会不得不对类的整个层次结构进行思考
    • 我必须实现一个构造函数来初始化protected数据吗
    • 如果我使用protected数据,它们的实际价值是什么
    • 如果我修改protected数据,谁会受影响
  • 在类层次结构变得越来越复杂的时候,这些问题也会变得越来越难以回答
  • 换句话说,protected数据都是类层次结构范围内的一种全局数据而你知道非const的全局数据不好。

确保所有的非const数据成员都具有相同的访问级别

多重继承

  • 多重继承有两个典型的使用场景:
    • 将接口继承和实现继承分开
    • 实现多个不同的接口

在设计类的层次结构时,要区分实现继承和接口继承

  • 接口继承关注的是接口和实现的分离,这样派生类的修改就可以不影响基类的用户;
  • 实现继承则使用继承来扩展现有的功能,从而支持新功能。

  • 纯接口继承是指你的基类只有纯虚函数。相反,如果你的基类有数据成员,或者已经有函数实现,那就是实现继承了。

  • 不应该把接口继承和实现继承的概念混在一起。我们如何才能两者兼顾:用接口分层的稳定接口,还有实现继承的代码重用?
    • 一个可能的答案是双重继承。
    • 另一个答案是PImpl惯用法。
  • PImpl代表指向实现的指针(pointer to implementation)。它把实现的细节放在一个单独的类里,并通过指针来访问。

使用多重继承来表示多个不同的接口

使用using为派生类及其基类创建重载集

不要为虚函数和它的覆盖函数提供不同的默认参数

  • 如果你为虚函数和覆盖函数提供不同的默认参数。你的类可能会引发很多混乱

dynamic_cast

  • dynamic_cast的职责说明:
    • 沿继承层及向上,向下及侧向,安全的转换到其他类的指针和引用

在穿越类层次不可避免时,应该使用 dynamic_cast

  • dynamic_cast的职责就是在类层次中穿越

当 找不到所需的类 被视为错误时,须对引用类型使用 dynamic_cast

当 找不到所需的类 被视为有效选择时,须对指针类型使用 dynamic_cast

  • 简而言之,你可以对一个指针或引用使用 dynamic_cast。
  • 如果 dynamic_cast 失败了,对于指针,你会得到一个空指针,对于引用,则会出现 std::bad_cast 异常。
  • 因此,如果失败是一种有效选择,请对指针使用 dynamic_cast;如果失败不是一个有效选择,那就是用使用

永远不要把指向派生类对象数组的指针赋值给指向基类的指针

  • 其结果可能是无效对象访问或者内存破坏

  • 退化,是一种隐式转换的名称,它进行左值到右值,数组到指针以及函数到指针的转换,并去除const和volatile限定。

重载和运算符重载

  • 你可以对函数,成员函数,模板函数和运算符进行重载。你不能重载函数对象,因此你也不能重载lambda表达式。
  • 重载和重载运算符的七条规则遵循一个关键思想:为用户构建直观的软件系统。

应当对带有常规含义的操作使用运算符

  • 常规意义暗示着,你应当使用合适的运算符。举例来说:
    • ==, !=, <, <=, >, >= : 比较操作
    • +, -, *, /, % : 算术操作
    • ->, 一元*, [] : 对象访问
    • = : 对象赋值
    • «, » : 输入和输出操作

对于对称的运算符,应采用非成员函数

避免隐式转换运算符

重载的操作应当大致等价

仅对大致等价的操作进行重载

  • 等价的操作应该有相同的名字。

在操作数所在的命名空间中定义重载运算符

  • 实参依赖查找(ADL,也叫Koenig查找)意味着,对于无限定的(unqualified)函数调用,C++在编译时会把函数参数命令空间中的函数也考虑进去。
  • 对于运算符,C++编译时也考虑操作数的命名空间。因此,应该在操作数的命名空间中定义重载运算符。

联合体

  • 联合体是一种特殊的类类型。所有成员都从同一地址开始。一个联合体一次只能容纳一个类型:因此你可以节约内存。
  • 一个带标签联合体(又称可辨识联合体)是一个可以跟踪其类型的联合体。std::variant就是一个带标签联合体。
  • C++ Core Guidelines指出,联合体的职责是节约内存。你不应该使用裸联合体,而应该使用std::variant这样的带标签联合体。

使用union来节约内存

避免裸联合体

  • 裸联合体非常容易出错,因为你必须跟踪底层类型。

使用匿名union来实现带标签联合体

本章精华

  • 尽量使用具体类型而不是类的层次结构。让你的具体类型称为规范类型。规范类型支持六大(默认构造函数,析构函数,拷贝和移动构造函数,拷贝和移动赋值运算符),交换函数和相等运算符。
  • 如果可能的话,就让编译器生成这六大。如果不能,就通过default请求所有这些特殊成员函数。如果这也不行,就明确实现所有这些函数,并给它们一个一致的设计。拷贝构造函数或拷贝赋值运算符应该拷贝构造。移动构造函数或移动赋值运算符应该移动。
  • 构造函数应该返回一个完全初始化的对象。使用构造函数来建立不变式。(不变式(invariant),是一个在程序执行过程中永远保持成立的条件。不变式在检测程序是否正确方面非常有用。例如编译器优化就用到了不变式)。不要使用构造函数来设置成员的默认值。尽量使用类内初始化来减少重复。
  • 如果你需要在对象销毁时进行清理动作,请实现析构函数。基类的析构函数应该要么是public且virtual,要么是protected且不是虚函数
  • 要对具有内在层次的结构使用类的层次结构进行建模。如果基类作为一个接口使用,就让基类成为抽象类,以便分离接口和实现。一个抽象类应该只有一个预置的默认构造函数
  • 区分接口继承和实现继承。接口继承的目的是将用户与实现分隔开:实现继承是为了重用现有的实现。不要在一个类中混合这两个概念。
  • 在一个有虚函数的类里,析构函数应该要么是public加virtual,要么是protected。对于一个虚函数,要使用virtual override或者final中的一个,不多也不少。
  • 一个类的数据成员应该要么全部public,要么全部private。如果类建立了一个不变式,就让它们全部private,并使用class。如果不是,就让他们public,并使用struct
  • 把单参数构造函数和转化运算符标记为explicit
  • 使用联合体来节约内存,但不要使用裸联合体;尽量使用带标签联合体,例如C++17中的std::variant

第六章 枚举

  • 枚举用来定义整数值的集合,也是这类集合的类型
  • 优先选择有作用域枚举(scopted enumeration),而不是传统的枚举。有作用域枚举也被称为强类型枚举或枚举类(enum class)
  • 传统的枚举有什么缺陷?
    • 没有作用域
    • 会隐式转换为int
    • 会污染全局命令空间
    • 类型未知。只要求该类型足够大且能容下所有枚举向

优先使用枚举而不是宏

  • 宏没有作用域,且没有类型

使用枚举表示相关联的具名常量的集合

优先使用 enum class,而不是普通enum

  • 有作用域枚举项(enum class)不会自动转型为int。要访问它们,就必须要使用作用域运算符

不要对枚举项使用ALL_CAPS命令方式

  • 如果对枚举项使用ALL_CAPS(全大写加下划线),你可能会与宏发生冲突,因为宏通常写成ALL_CAPS的方式
  • 当然,这条规则不仅适用于枚举项,也适用于一般常量

避免使用无名枚举

  • 不是每个编译期常量都应该是enum。C++还允许将编译期常量定义为constexpr变量。只是互相关联的常量集合中使用enum

仅在必要时指定枚举的底层类型

  • 从C++11开始,可以指定枚举的底层类型,以节省内存。默认情况下,有作用域enum的类型是int,因此,可以前置声明enum

仅在必要时指定枚举项的值

  • 通过指定枚举项的值,你可能会设定一个值两次

本章精华

  • 使用有作用域枚举而不是传统的枚举。顾名思义,有作用域枚举具有作用域,不会隐式地转换为int,不会污染全局命名空间,默认情况下其底层类型是int
  • 仅在必要时指定有作用域枚举的底层类型和枚举项的值

第七章 资源管理

  • 首先,资源是什么?资源就是你必须管理的东西。这意味着,你因为资源的有限而必须获取和释放它,或者你必须对它进行保护。
  • 你拥有的存储空间,套接字,进程或线程都是有限的;而在某个时间点上,只有一个进程可以写入共享文件,只有一个线程可以写入共享变量。

  • 如果考虑到资源管理,一切都可以归结为一个关键点: 所有权。现代C++非常优秀的一点是,我们可以在代码中直接表达对所有权的意图。
    • 局部对象: C++运行时作为所有者来自动管理这些资源的生存期。全局对象或类的成员也是如此。C++ Core Guidelines中将它们称为有作用域的对象。
    • 引用: 我不是所有者。我仅仅借用了不可以为空的资源
    • 原始指针: 我不是所有者。我仅仅借用了可能为空的资源。我不可以删除该资源。
    • std::unique_ptr: 我是资源的独占所有者。我可以显式的释放资源。
    • std::shared_ptr: 我跟其他的shared_ptr共享资源,并且当我是最后一名所有者时会释放资源。我可以显式地释放我的所有权份额
    • std::weak_ptr: 我不是该资源的所有者,但是我可以通过使用成员函数lock()暂时称为该资源的共享所有者。
  • 第一条通用规则是 C++惯用法: RAII。RAII代表Resource Acquisition Is Initialization(资源获取即初始化)。C++标准库系统性地依赖于RAII

使用资源句柄和RAII(资源获取即初始化)自动管理资源

  • RAII的理念很简单,你为资源创建一种代理对象。代理的构造函数获取资源,而代理的析构函数释放资源。RAII的中心思想是,这个代理作为局部对象,其所有者是C++运行时,于是,它所代理的资源也归C++运行时所有。当作为本地对象的嗲里对象离开作用域的时候,代理的析构函数会被自动调用。
  • RAII在C++生态系统中被大量使用。RAII的例子有标准模板库(STL)的容器,智能指针和锁。
    • 容器管理元素
    • 智能指针管理内存
    • 锁管理互斥量

原始指针(T*)不表示所有权

原始引用(T&)不表示所有权

  • 这两条规则都概括了向函数传递指针或引用时,以及要从函数返回指针(T*)或者左值引用(T&)时所有权方面需要考量的规则。
  • 指针和引用的关键问题是,谁是资源的所有者?
    • 如果你不是所有者,只是借用了它,那你不得删除该资源

优先使用有作用域的对象,不做非必要的堆上分配

  • 有作用于的对象是一个带有自己作用域的对象。它可能是个本地对象,全局对象或者某个类的成员。
  • C++运行时会管理好有作用域的对象

  • 一种好用的技巧是使用额外的花括号来定义一个人工作用域。由于人工作用域的存在,你可以显式地控制一个本地对象的生存期。

内存分配和释放

  • 在C++中使用new创建一个对象的操作包括两个步骤
    • 为该对象分配内存
    • 在分配好的内存上构造该对象
  • operator new 或 operator new []是第一步;构造函数是第二步
  • 同样的策略也适用于析构函数,不过得反过来。
    • 首先,调用析构函数(如果有的话),
    • 然后用operator delete或者operator delete []释放内存

避免malloc() 和 free()

  • new 和 malloc,或 delete 和 free 之间有什么区别?
    • C 函数malloc 和 free只做了一半的工作。malloc 分配内存,free则释放内存
    • malloc并不调用构造函数,而free也不调用析构函数
  • 这意味着,你如果使用一个仅仅通过malloc创建的对象,程序有未定义行为

避免显式调用 new 和 delete

  • 你应该牢记这条规则。这条规则的重点在于显式这个词,因为智能指针或STL容器会让你的对象隐式地使用 new 和 delete

立即将显式资源分配的结果交给一个管理者对象

  • 这条内存分配规则有一个特殊的名字: NNN.NNN是 No Naked New(不要裸的New)的缩写,它意味着内存分配的结果应该交给一个管理者对象。这个管理者对象可以是std::unique_ptr或者std::shared_ptr

在一条表达式语句中最多进行一次显式资源分配

智能指针

  • 从库的角度,智能指针是C++11标准中最重要的补充。
  • 智能指针的规则归结为两类
    • 作为所有者的基本用法
    • 以及作为函数参数的基本用法

用unique_ptr或shared_ptr表示所有权

  • 为了全面,这条规则还包括std::weak_pte。现代C++共有三种智能指针来表达三种不同的所有权
    • std::unique_ptr: 独占所有者
    • std::shared_ptr: 共享所有者
    • std::weak_ptr: 对std::shared所管理资源的非占有的引用
  • std::unique_ptr是其资源的独占所有者。它不可以被拷贝,只能被移动
  • std::shared_ptr则共享所有权。当你拷贝或者拷贝赋值某个共享指针,它的引用计数增加;当你删除或者重置某个共享指针,它的引用计数则减少。当引用计数变为0时,其底层资源将被删除。
  • std::weak_ptr并不是智能指针。它是一个引用,引用指向被std::shared_ptr所管理的对象。它的接口颇为有限,不可以透明地访问底层资源。通过对std::weak_ptr调用其成员函数lock,可以从某个std::weak_ptr创建出一个std::shared_ptr

除非需要共享所有权,否则能用unique_ptr就别用std::shared_ptr

  • 当你需要智能指针的时候,应该首选std::unique_ptr。在设计上std::unique_ptr和原始指针一样块,且一样可以高效利用内存。
  • 不要为了做拷贝而贪图方便地使用std::shared_ptr。std::unique_ptr不可以被拷贝,但仍可以被移动

使用make_shared()创建shared_ptr

使用make_unique()创建unique_ptr

  • 使用这种方式创建智能指针,有两个理由
    • 第一个理由是异常安全
    • 第二个理由只对std::shared_ptr成立
      • 当你调用std::shared_ptr(new int(1998))时,会发生两次内存分配:一次时针对new int(1998),还有一次是针对std::shared_ptr的控制块。内存分配代价较高,所以你应当尽量避免。
      • std::make_shared(1998)可将两次内存分配变成一次,因此更快。
      • 此外,分配出来的对象(new int(1998))和控制块彼此相邻,所以访问也会更快。

使用std::weak_ptr来打破shared_ptr形成的环

  • 如果std::shared_ptr互相引用,就会形成环状引用
  • 例如,一个双向连表就会形成环。如果使用std::shared_ptr来实现链表,那引用计数永远不会为零,最终导致内存泄漏

函数参数

  • 本节其余规则回答了这样几个问题
    • 函数该如何接受智能指针作为参数
    • 参数应该是std::unique_ptr还是std::shared_ptr
    • 参数应该以const还是以引用的方式获取
  • 你应该将这些以智能指针作为函数参数的规则看作之前更一般函数参数传递规则的细化

  • 首先,必须回答何时以智能指针作为函数参数
  • 其次,如果函数通过引用来获取参数,会带来危险

只在显式表达生存期语义时以智能指针作参数

  • 如果你把指针指针作为参数传递给一个函数,而在这个函数中,你只使用智能指针的底层资源,你就做错了。
  • 在这种情况下,应该以原始指针或引用作为函数参数,因为你并不需要智能指针的生存期语义

  • 重装(reset)表示函数会修改引用参数的智能指针的内容,来指向一个不同的对象。

  • 关于类型为std::shared_ptr的参数,有三条规则
    • 接受shared_ptr参数以表达函数是共享的所有者
    • 接受shared_ptr&参数以表达函数可能会重装共享指针
    • 接受const shared_ptr&参数以表达函数可能保有指向对象的一份引用计数

不要传递从智能指针别名中获得的指针或引用

  • 智能指针的别名(智能指针的引用)是智能指针,但你不是所有者。如果违反这条规则,会造成悬空指针
  • 更为直白的规则:只有当你实际持有共享资源所有权的一部分时,你才可以访问该资源

本章精华

  • 要自动管理资源。为资源创建某种代理对象。代理的构造函数获取资源,而代理的析构函数释放资源。C++运行时会管理好代理
  • 尽可能使用有作用域的对象。有作用域的对象是一个带有自己作用域的对象。它可能是个本地对象,全局对象,或者是某个类的成员。C++运行时会管理好有作用域的对象。
  • 不要使用malloc和free,并避免使用new和delete。立即将显式资源分配的结果交给一个管理者对象,例如std::unique_ptr或std::shared_ptr
  • 使用智能指针std::unique_ptr来表达独占所有权,并使用智能指针std::shared_ptr来表达共享所有权。使用std::make_unique来创建std::unique_ptr,并使用std::make_shared来创建std::shared_ptr
  • 如果你要表达生存期语义,就以智能指针作为函数参数。否则,用普通指针或引用即可
  • 按值接受智能指针作为函数参数以表达所有权语义;按引用接受智能指针以表达函数可能会重装智能指针。

第八章 表达式和语句

  • 表达式和语句是表达动作和计算的最基本和最直接的方式
  • 表达式和语句的非正式定义
    • 表达式的计算结果为值
    • 语句做某事,通常由表达式或语句组成
  • 包含在块作用域中的声明是语句。块作用域是指包含在花括号内的内容

优先使用标准库,而不是其他库和 手工代码

  • 如果你想提高组织中的代码质量,那就用一个目标取代所有的编码规则:用算法代替原始循环。

优先使用合适的抽象,而非直接使用语言特性

  • 合适的抽象通常意味着不必考虑资源的所有权

声明

  • 首先,以下是C++ Core Guidelines对声明的定义
    • 声明是一条语句。声明将名字引入作用域中,并可能引起具名对象的构造。
  • 声明的规则是关于名字,变量及其初始化以及宏的规则

  • 好名字可能是号软件最重要的规则

保持作用域较小

  • 如果作用域很小,你可以把它放在一屏之中以便弄清楚状况。如果作用域变得太大,则应该将代码结构化为函数或类。在重构过程中,要时别逻辑实体并使用自解释的名称。

在 for 语句的初始化和条件中声明名字以限制作用域

  • 自第一个C++标准开始,就可以在for语句中声明变量
  • 自C++17以来,我们还可以在if或switch语句中声明变量

常用的和局部的名字要短,不常用的和非局部的名字要长

  • 给变量起名i或j,或给变量起名T,其意图立即明确
    • i和j是索引
    • T是模板参数的类型
  • 名字应该是自解释的。在简短的上下文中,你一眼就能够理解变量的含义。但它不会自动适用于较长的上下文,这时,应该使用更长的名字

避免看起来相似的名字

避免ALL_CAPS风格的名字

  • 如果使用了ALL_CAPS(全大写加下划线)风格的名字,宏替换可能会发生,因为ALL_CAPS常常用于宏

每条声明(仅)声明一个名字

使用auto来避免类型名字的多余重复

  • 如果你使用auto,修改代码可能就是小菜一碟

不要在嵌套作用域中复用名字

  • 出于可读性和可维护性原因,不应在嵌套作用域中复用名字。

始终初始化对象

  • 使用auto,这样,你再也不会忘记初始化变量。

不要在确实需要使用变量(或常量)之前就将其引入

在获得可用来初始化变量的值之前不要声明变量。

优先使用{}初始化语法

  • 使用 {} 初始化的原因有很多
    • 始终可用
    • 克服最令人烦恼的解析
    • 放置窄化转换
  • 前两点使C++更符合直觉,而最后一点经常可以防止未定义行为。

  • 利用auto进行类型推导
    • 如果你用auto进行自动类型推导,然后结合使用{}初始化,你会得到std::initializer_list。
    • 这种违反直觉的行为在C++17中改掉了
  • 窄化转换是指算术值的隐式转换,精度损失也包含在内。

不要将同一个变量用于两个不相关的目的

使用lambda表达式进行复杂的初始化(尤其是对const变量)

  • 为什么要就地(in place)调用lambda函数?
    • 这条规则回答了这个问题。可以把复杂的初始化步骤放在lambda里。
    • 如果你的变量应该变成const,那么lambda的就地调用尤其有价值
  • 如果不想在初始化后修改你的变量,则应该让他成为const。但有时,变量的初始化包括不止一个步骤,因此,你不能使变量称为const
  • 这个时候,lambda表达式就可以用来救场了。你可以使用一种技术,它被称为立即调用的lambda表达式。
    • 你可以把初始化代码放到lambda表达式中,通过引用捕获环境变量,用就地调用的lambda函数初始化你的const变量。
      1
      2
      3
      4
      5
      6
      7
      8
      
      const widget x = [&]{
      widget val;
      for (auto i = 2; i <= N; ++i>)
      {
      val += some_obj.do_something_with();
      }
      return val;
      }();
      
    • 你需要把整个初始化代码都放在lambda的函数体中,最后一对圆括号调用了它。

  • 如果C++标准化委员会中有一个共识,那就是宏必须被淘汰。

避免复杂的表达式

如果对运算符优先级不确定,那就使用括号

保持指针的使用简单明了

  • 对指针的复杂操纵是错误的一个主要来源

避免 魔法常量,采用符号常量

  • 符号常量比魔法常量更明确

避免对范围检查的需要

  • 如果不需要检查范围的长度,你就不会遇到 差一错误(off-by-one error)
    • 一种常见的安全编码错误,指边界条件写错造成的循环次数差一的错误。最常见的原因是不小心把 < 和 <= 写错了

使用nullptr 而不是0或者NULL

  • 为什么不应该使用0或NULL来表示空指针
    • 0 : 字面量0可以是空指针(void*)0,也可以是数字0。这由上下文决定,因此,起初是空指针的东西,最后可能变成数字
    • NULL : NULL是一个宏,因此你不知道里面是什么
  • 空指针nullptr避免了数字0和宏NULL的歧义。nullptr的类型会一直是std::nullptr_t。
  • 你可以将nullptr赋值给任意一个指针,该指针就会变空,不指向任何数据。
  • nullptr可以显式的或基于上下文转换为bool类型。因此,你可以在逻辑表达式中使用nullptr。

用delete[]删除数组,用delete删除非数组对象

  • 手工内存管理,以及不使用STL的容器或智能指针(std:unique_ptr<X[]>)是非常容易出错的
  • 用非数组形式的delete来删除C数组是未定义行为

不要对无效指针解引用

  • 如果你对一个无效指针解引用,你的程序就会出现未定义行为。避免这种行为的唯一方法是在使用指针之前检查它
  • 如何解决此问题
    • 不要使用裸指针。
    • 如果需要像指针的语义,请使用智能指针

避免有未定义求值顺序的表达式

不要依赖函数参数的求值顺序

避免转型

  • 粗略来说,C风格转型会先从static_cast开始,接着是const_cast,最后执行reinterpret_cast

如果必须使用转型,请使用具名转型

  • 在C++11中,我们有以下六种转型
    • static_cast : 在类似的类型之间进行转换,例如指针类型或数字类型
    • const_cast : 添加或删除const或volatile
    • reinterpret_cast : 在指针之间或在整形和指针之间转换
    • dynamic_cast : 在同一个类层次中的多态指针或引用之间进行转换
    • std::move : 转换为右值引用
    • std::forward : 将左值转换为左值引用,或者将右值转换为右值引用

不要用转型去除const

  • 如果底层对象(例如 constInt)是const,而你试图修改底层对象,那么用转型去除const的行为是未定义行为

语句

  • 语句主要分为两类
    • 迭代语句和选择语句

迭代语句

  • C++实现了三种迭代语句: while, do while 和 for
  • 在C++11中,for循环中加入了语法糖: 基于范围的for循环

  • 基于范围的for循环更加容易阅读,而且在循环过程中不会出现索引错误,也不会对索引进行修改
  • 当你有一个明显的循环变量时,应该使用for循环而不是while语句。如果没有,那你应该使用while语句

  • 你应该在for循环中声明循环变量。要提醒你的是,从C++17开始,变量声明也可以在if或switch语句的初始化部分进行。
  • 避免使用do while语句和goto语句,并尽量不在迭代语句中使用break和continue,因为它们难以阅读。
    • 如果某样东西难以阅读,它就容易出错,并使你的代码重构变得困难。
    • break语句会结束迭代语句,而continue语句则结束当前迭代步骤。
  • 只要有合适的具名算法,就应该优先使用算法而不是原始循环。
  • STL的一百多个算法提供了对容器的隐式操作。这种操作常常可通过lambda表达式进行适配,还常常可以利用并行或者并行加向量化的版本来运行

选择语句

  • if 和 switch 是C++里从C语句继承下来的选择语句
  • 在可以选择时,你应该首选switch语句而不是if语句,因为switch语句通常更加可读,而且比if语句更好优化

不要依赖switch语句中的隐式直落行为

  • 从C++17开始,属性[[fallthrough]],可以明确地表达你的意图。[[fallthrough]]必须在它自己的语句行中,并紧接在一个case标签之前。
  • [[fallthrough]]向编译器表明,这里是估计要直落的。这样,编译器就不会发出诊断警告了。

仅使用default来处理一般情况

算术

  • 有符号和无符号证书的算术,以及典型的算术错误,例如上溢和下溢和除以零

不要混合有符号和无符号的算术运算

  • 如果你混合有符号和无符号的算术运算,你可能不会得到想要的结果

使用无符号类型进行位操作

  • 用位操作符(~, », »=, «, «=, &, &=, ^, ^=, , =)对有符号操作数进行操作存在由实现定义的行为。
  • 实现定义的行为意味着该行为在不同的实现之间会不同,并且实现必须记录每种行为的效果。因此,不要在有符号类型上进行位操作,而应该使用无符号类型。

使用有符号类型进行算术运算

  • 首先,你不应该用无符号类型做算术,因为减法得到的负值在无符号类型中无法正确表达。
  • 其次,根据前面的规则,不应该混合有符号和无符号的算术

不要试图通过使用 unsigned 来避免负值

  • 有一个有趣的关系: 当你给unsigned int 变量赋值为-1时,你会得到最大的unsigned int
  • 算术表达式的行为在signed和unsigned类型之间有所不同

  • 检测溢出
    • 使用花括号表达式 x = {x + 1000}替换错误的赋值 x += 1000。
    • 它们的不同在于,编译器检查窄化转换,因此会检测到溢出

典型的算术错误

  • 如果违反以下三条规则,将会导致未定义行为
    • 避免上溢
    • 避免下溢
      • 上溢和下溢的效果是一样的: 内存损坏,因此是未定义行为
    • 不要除以零

本章精华

  • 如果你在手写循环,那说明你可能对标准模板库(STL)的算法还不够了解,STL包含一百多种算法
  • 好名字可能是号软件的最重要原则。好的名字意味着你的名字应该是自解释的,是尽可能局部的,不应该与现有的名字相似,不应该使用ALL_CAPS风格,也不应该在嵌套的作用域中重复使用
  • 始终初始化变量。优先使用{}初始化以防止窄化转换。对于const变量的复杂初始化,请使用就地调用lambda表达式
  • 不要把宏用于常量或函数。如果你必须时用它,或者维护现有的宏,请使用为一的ALL_CAPS名字
  • 可能的话,你应该优先选择基于范围的for循环而不是普通for循环。基于范围的for循环更加人容易阅读,而且不会引起下标错误
  • 应该优先选择switch语句,而不是if语句。switch语句更加容易阅读,而且有更多的优化潜力
  • 除非有特殊原因,否则应该使用有符号的整数,不要混合使用有符号和无符号算术
  • 请注意,上溢和下溢是未定义行为,通常在程序运行时以崩溃告终。

第九章 性能

错误的优化

  • 不要无故优化
  • 不要过早进行优化
  • 不要优化并非性能关键的东西
  • 有一句名言可以很好地总结前三条规则
    • 真正的问题是,程序员们花了太多的时间在错误的地方和错误的时间里担心效率问题:过早优化是编程中万恶之源(或至少是其中大部分罪恶之源)。–高德纳,《计算机程序设计艺术》
  • 简而言之,请务必记住 过早优化是编程中万恶之源 这句话。在进行任何新能假设之前,请使用最关键的规则来进行性能分析: 测量程序的性能

错误的假设

  • 不要假设复杂的代码一定比简单的快
  • 不要假设低级代码一定比高级代码块
  • 不要在没有测量的情况下对性能妄下断言

  • 在继续之前,必须先声明一下: 不建议使用臭名昭著的单例模式。单例模式有很多缺点。

设计应当允许优化

  • 这条规则尤其适用于移动语义,因为你写算法时应该使用移动语义,而不是拷贝语义。移动语义的使用会自动带来一些好处
    • 算法使用低开销的移动操作,而不是高开销的拷贝操作
    • 算法要稳定的多,因为它不需要内存分配,所以不会出现std::bad_alloc异常
    • 可将算法用于只能移动的类型,例如std::unique_ptr

依赖静态类型系统

  • 写本地代码
    • 一般来说,若使用就地调用的lambda表达式(而不是函数)来调整 std::sort的行为,代码会更快。编译器拥有所有可用的信息来生成最优化的代码。相反,函数可能定义在另一个翻译单元中,这对优化器来说是一个硬边界
  • 写简单代码
    • 优化器会搜寻可以被优化的已知模式。如果你的代码是手写的且极为复杂,这会使优化器寻找已知模式的工作变得更加困难。最终,你常常会得到没有充分优化的代码
  • 给予编译器额外的提示
    • 当函数不能抛出异常,或者你不关心异常时,将它声明为noexcept。如果一个虚函数不应该被覆盖,那么可将其声明为final,这对优化器来说也是很有意义的。

将计算从运行期移至编译期

  • constexpr 函数可以在运行期使用,也可以在编译期使用,如果要在编译期使用它,其参数必须是常量表达式。

以可预测的方式访问内存

本章精华

  • 在基于错误的假设进行任务所谓的优化之前,请测量程序的性能
  • 帮助编译器来优化程序。请使用移动语义来实现函数,如果可能的话,让他们成为constexpr
  • 现在计算机架构为连续读取内存而进行了优化。因此,应该将std::vector,std::array或std::string作为首选

第十章 并发

  • 并发 : 多个任务的执行可重叠。并发是并行的超集
  • 并行 : 多个任务再同一时间运行。并行是并发的子集

假设代码将作为多线程程序的一部分来运行

  • 对共享的非原子变量进行的异步读写是一种数据竞争。因此,程序具有未定义行为。
  • 有什么办法来摆脱数据竞争
    • 使用锁来保护整个临界区域
    • 用锁来保护函数cached_computation的调用
    • 将这两个静态变量都设置为thread_local。thread_local保证每个线程都能得到变量cached_x 和 cached_result。静态变量会被绑定到主线程的生存期。thread_local变量则绑定到线程的生存期
  • C++11标准保证C++运行期以线程安全的方式初始化静态变量;因此,不需要保护其初始化

避免数据竞争

  • 什么是数据竞争
    • 数据竞争是指至少有两个线程在异步的情况下访问一个非原子的共享变量,并且至少有一个线程试图修改该变量
  • 如果你的程序里有数据竞争,那么该程序有未定义行为。
  • 共享的,可变的状态是发生数据竞争的必要条件。

尽量减少对可写数据的显式共享

  • 根据前面与数据竞争相关的规则,共享数据应该是不变的
  • 现在,唯一需要解决的困难是如何以线程安全的方式初始化常量共享数据。C++11支持下面几种不同方法来实现这一点
    • 在启动线程之前初始化数据。这并不是C++11造成的,但却很容易应用
    • 使用常量表达式,因为它们在编译期被初始化
    • 将函数std::call_once与函数std::once_flag结合起来使用。可将重要的初始化内容放入函数onlyOnceFunc中。C++运行期保证这个函数只会成功运行一次;
    • 使用具有块作用域的静态变量,因为C++11的运行时保证它们会以线程安全的方式初始化

从任务(而不是线程)的角度进行思考

  • 什么是任务?
    • 任务是对执行单元的通用术语
  • 从C++11开始,我们把任务当作一个特别术语来用,它代表两个组成部分:诺值(promise)和期值(future)。
    • 诺值产生期值可以异步获取的值。
    • 诺值和期值可以在不同的线程中运行,然后通过安全的数据通道相连接
  • 诺值在C++中存在3中变体: std::async, std::packaged_task和std::promise
  • std::packaged_task和std::promise的共同点是它们都相当低级。

  • 线程和期值/诺值之间的根本区别是什么?
    • 线程是关于应该如何计算的;
    • 任务是关于应该计算什么的
  • 说的更加具体一点
    • 线程t使用共享变量res来提供结果。与此相反,std::async的诺值会建立一个安全的数据通道,并用它把结果传递给期值fut。这种数据共享意味着,对于线程t,必须保护res
  • 对于线程,你需要明确进行创建。对于诺值std::async,则不一定会自动创建线程。你指定应该计算什么,而不是应该如何计算。如果有必要,C++运行时会决定是否要创建线程

不要试图使用volatile进行同步

  • 在C++中,volatile并没有多线程语义。在C++11中,原子量被称为std::atomic

  • volatile用于特殊对象,不允许对其进行优化读或写操作。volatile通常用于嵌入式编程领域,表示可以独立于常规程序流程而改变的对象。比如,这些对象代表外部设备(内存映射I/O)。因为这些对象可以独立于常规程序流程进行更改,它们的值会被直接写入主存。因此,编译期不能假设它可以根据程序流来判断是否可对读写进行优化

只要可行,就使用工具来对并发代码进行验证

  • 动态代码分析工具ThreadSanitizer和静态代码分析工具CppMem

  • ThreadSnaitizer提供了全局信息,检测程序的执行是否存在数据竞争
  • Cppem能让你深入理解代码里的小片段,大多数是否包括原子量。你会得到问题的答案:根据内存模型,那些情况下交错是可行的

  • ThreadSanitizer(又名TSan)是一个C/C++的数据竞争检测器。数据竞争是并发系统中最常见和最难处理的错误之一。当两个线程同时访问同一个非原子变量,并且其中至少有一个线程的访问是写操作时,数据竞争就会发生。C++11标准正式禁止数据竞争,并将其归为未定义行为
  • ThreadSanitizer是Clang3.2和GCC4.8的一部分。要使用它,必须使用-fsanitize=thread进行编译和链接,并至少使用优化级别-O2和产生调试信息的标志-g。
    • -fsanitize=thread -O2 -g
  • 它有很大的运行期开销
    • 内存使用量可能会增加5到10倍,执行时间也可能增加2到20倍
  • 软件开发的突出法则
    • 首先,让程序正确
    • 然后,让它快起来

  • NNN是 No Naked New(不要裸的New)的缩写,意味着内存分配不应该是一个独立的操作,而应该放在一个管理者对象中进行
  • 互斥量也是如此。互斥量应立即交付给一个管理者对象,在这种情况下,它就是锁对象。
  • 在现代C++中,我们有std::lock_guard, std::unique_lock, std::shared_lock(C++14), std::scoped_lock(C++17)
  • 请记住缩写NNM,它代表 No Naked Mutex,锁实现了RAII惯用法。RAII惯用法背后的关键思想是将资源的生存期和局部变量的生存期绑定。C++会自动管理局部变量的生存期

使用RAII,永远不要直接使用lock()/unlock()

  • 将互斥量放入锁对象中,互斥量在std::lock_guard的构造函数中被自动锁定,并在lck超出作用域时解锁

使用std::lock()或std::scoped_lock来获取多个互斥量

  • 如果一个线程同时需要多个互斥量,那么必须非常小心,始终以相同的顺序锁定互斥量。否则,糟糕的线程的交错可能会导致死锁
  • 解决死锁的最简单方法是以原子方式锁定两个互斥量
  • 在C++11中,可将std::unique_lock和std::lock一起使用
  • 在C++17中,std::scoped_lock可以原子性的锁定任意数量的互斥量。

决不在持有锁的时候调用未知代码(例如回调函数)

线程

  • 线程是并发和并行编程的基本构建块。

将汇合thread看作一个有作用域的容器

将thread看作一个全局容器

  • 首先,必须汇合或分离子线程。如果不这样做,会在子线程的析构函数中得到std::terminate

  • 一个线程可以被看作一个使用外部变量的全局容器。此外,对于汇合线程,容器的生存期是有作用域的。

优先选择std::jthread,而不是std::thread

  • 这条规则的原标题是: 优先选择gsl::join_thread,而不是std::thread
  • 这里用C++20中的std::jthread替换了C++ Core Guidelines支持库中的gsl::joint_thread

  • 除了有std::thread的功能,std::jthread会在析构时自动汇合。

不要对线程调用detach()

  • 分离线程的难度很大

不要在没有条件时wait

  • 条件变量(condition variable)支持一个非常简单的概念:一个线程准备好了一些东西,并向等待它的另一个线程发送通知。

  • 条件变量受到两个很严重的问题的影响

    • 丢失唤醒(loss wakeup): 丢失唤醒的现象是,发送方在接收方开始等待之前发送了通知。结果通知丢失
    • 虚假唤醒(spurious wakeup): 即使没有发送通知,接收方也可能会被唤醒。至少POSIX线程和WindowsAPI会是这些现象的受害者

数据共享

  • 共享数据使用的越少,并且局部变量使用的越多,结果就越好。不过,有时除了共享数据,你别无选择,例如,当子线程想把它的工作传达给父线程时

在线程之间传递少量的数据使用值传递,而不是引用或指针

  • 将数据按值传递给线程有两个好处
    • 没有共享,因此,不可能有数据竞争。数据竞争的前提是有可变并共享的状态
    • 你不需要关心数据的生存期。数据的生存期和所创建的线程的生存期保持一致

要在不相关的thread之间分享所有权,请使用shared_ptr

资源

  • 使用并发的主要原因之一是性能。
  • 必须牢记,使用线程时需要耗用资源: 时间和内存。
  • 资源的使用从创建开始,然后随着上下文切换从用户空间到内核空间,并以线程销毁而结束。
  • 此外,线程由有自己的状态,需要分配和维护

尽量减少上下文切换

尽量减少线程的创建和销毁

尽量减少临界区内的时间占用

  • 锁定互斥量的时间越短,其他线程可运行的时间就越长

记得给你的lock_guard和unique_lock起名字

  • 如果你没有给std::lock_guard或std::unique_lock起名字,那你只是创建了一个临时变量,它在创建后会立即销毁。
  • std::lock_guard或std::unique_lock会自动锁定其互斥量和构造函数,并在其析构函数中解锁。这种模式被称为RAII

关于并行

  • 优先使用STL的并行算法,而不是用线程手写解决方案

  • std::execution::seq : 顺序执行算法
  • std::execution::par : 在多个线程上并行的运行该算法
  • std::execution::par_unseq : 在多个线程上并行运算算法,并允许单个循环的交错

  • 向量化std::execution::par_unseq代表现代处理器指令集的SIMD(单指令多数据)扩展。SIMD使处理器能够在多个数据上并行的执行同一个操作。

消息传递

  • 关于消息传递,有两条规则
    • 使用future从并发任务返回
    • 使用async()生成并发任务
  • 任务是一种C++式的在线程之间传递消息的方式。
  • 消息可以是值,异常或者通知。
  • 任务由诺值和期值两个部分组成。诺值创建消息,期值异步的进行接收

  • 发送值或异常
    • 与线程非常不同,诺值和相关联的期值会共享一个安全通道
  • 发送通知
    • 如果你使用诺值和期值(在短任务中)来同步线程,它们与条件变量有很多共同之处。
    • 大多数情况下,诺值和期值是比条件变量更加安全的选择
  • 跟诺值和期值相比,条件变量的优势在于,可以使用条件变量来多次同步线程。
  • 与此相反,一个诺值只能发送一次通知。而如果你只为单次同步使用条件变量,要么把条件变量用对会比用诺值和期值难得多。一对诺值和期值不需要锁,不会出现虚假唤醒和丢失唤醒,也不需要临界区或额外的条件判断

无锁编程

  • 并发和并行的规则是针对非专家的。无锁编程(lock-free programming)是一个仅面向专家的主题。

除非绝对需要,否则不要使用无锁编程

不要轻信硬件/编译器组合

本章精华

  • 区分并发和并行。并发是指多个任务的重叠,而并行是指多个任务同时运行
  • 通过尽量减少数据的共享来避免数据竞争,并使共享的数据不可变
  • 使用ThreadSanitizer或Cppem等工具来验证并发代码
  • 不要直接对互斥量加/解锁。将互斥量放入锁对象中,比如std::lock_guard或者std::unique_lock
  • 不要在持有锁的时候调用未知代码。尽量不要在任一时间点获取超过一把锁
  • 当你在某个时候需要超过一把锁时,使用std::lock或者std::scoped_lock来原子性的获取
  • 使用std::jthread而不是std::thread,以便在析构时自动重新汇合
  • 不要使用没有附加谓词的条件变量,以避免虚假唤醒和丢失唤醒
  • 如果逆向并行的执行一项工作,最好使用STL的并行算法,而不是用线程来手工实现解决方案
  • 使用任务在线程之间传递消息或异常。使用任务(而不是条件变量)来同步线程
  • 除非绝对需要,否则不要使用无锁编程。事先请仔细研读文献

第十一章 错误处理

  • 根据C++ Core Guidelines,错误处理涉及一下操作
    • 检查错误
    • 将有关错误的信息传递给某些处理程序代码
    • 保存程序的有效状态
    • 避免资源泄漏
  • 应该使用异常来处理错误

  • C++ Core Guidelines中的规则应该可以帮助避免以下几种类型的错误,这里在括号中添加了典型的例子
    • 类型违规(转型)
    • 资源泄漏(内存泄漏)
    • 边界错误(访问到了容器的边界外)
    • 生存期错误(删除后访问对象)
    • 逻辑错误(逻辑表达式)
    • 接口错误(在借口中传递错误的值)
  • 总共二十多条规则,可以分为三类
    • 前两类说的是错误处理策略的设计及其具体实现
    • 第三类讨论不能抛出异常的情况

设计

  • 每个软件单元都有两条与客户的通信途径
    • 一条用于常规情况
    • 另一条用于非常规情况
  • 软件单元应该围绕不变式进行设计

  • 通信
    • 在设计初期制订错误处理策略
    • 不要试图在每个函数中捕获每个异常
    • 尽量减少显式的try/catch使用
  • 什么是软件单元?
    • 软件单元可以是函数,对象,子系统或者整个系统。
  • 软件单元与客户进行通信。因此,通信设计应该在系统设计初期进行。
  • 在边界层面,有两种通信方式:常规和非常规。
    • 常规通信是接口的功能方面,换句话说,它规定软件单元应该做什么
    • 非常规通信代表非功能方面。非功能方面规定了系统的运行方式。非功能方面的很大一部分是错误处理,即什么会出错。通常,非功能方面被称为 质量属性
  • 从控制流的角度来看,显式的try/catch与goto语句有很多共同之处。这意味着如果异常被抛出,控制流会直接跳转到异常处理器,而这段代码可能在不同的软件单元中。
  • 现在的问题是,应该如何组织异常处理?我认为你应该先问自己一个问题:是否可能在本地处理异常?
    • 如果是,那就去做;如果否,那就让异常传播,直到有足够的上下文来处理它。处理异常也可能意味着捕获它,然后重新抛出一个不同的,对客户更方便的异常。这种对异常的转换可以达到这样的目的:软件单元的客户只需要处理数量有限的不同异常
  • 通常情况下,边界是处理异常的合适位置,因为你会希望保护客户端,使其不会随便受异常的影响。

  • 不变式
    • 通过抛出异常来表明函数无法执行其分配的任务
    • 围绕不变式来设计错误处理策略
    • 让构造函数建立不变式,做不到就抛出异常
  • 根据C++ Core Guidelines,不变式是 对象成员的一种逻辑条件,构造函数必须建立这个条件,这样公开成员函数就可以假设它一定成立。在不变式建立后(通常通过构造函数),对象上的每个成员函数就可以调用了
  • 更多关于不变式和如何建立不变式的规则,这些规则补充了本章开头的讨论
    • 当类具有不变式时使用class;如果数据成员可以独立变化,则使用struct
    • 构造函数应该创建完全初始化的对象
    • 不要定义仅初始化数据成员的默认构造函数,而应该使用成员初始化器

只对错误处理使用异常

  • 异常是一种goto语句

应使用专门设计的用户定义类型(而非内置类型)作为异常

  • 你不应该使用内置类型,甚至不应该直接使用标准的异常类型
  • 建议使用标准异常而不是内置类型,因为前者可以给异常附加额外的信息,或者构建异常层次结构。
  • 标准异常也只是相对稍好,但还是算不上号,因为这个异常太通用了。
  • 要解决这些问题,请从std::runtime_error派生出你的具体异常,下面是一个简短的例子
    1
    2
    3
    4
    5
    6
    7
    
    class InputSubsystemException : public std::runtime_error
    {
    const char* what() const noexcept override 
    {
      return "在这里提供异常的更多细节";
    }
    };
    

通过引用从层次结构中捕获异常

  • 如果按值捕获层次结构中的异常,那么你可能会成为切片的受害者

  • 多态类应当抑制公开的拷贝/移动操作,说的明确一点
    • 应该通过const引用来捕获异常;仅在想要修改异常时通过(非const)引用来捕获
    • 如果你要在异常处理器中重新抛出异常e,只需要使用throw而不是throw e;在第二种情况下,e会被复制
  • 要解决按值捕获异常,有个简单的办法:
    • 应用规则:如果基类被当作接口使用,那就把他变成抽象类

在直接拥有对象时决不抛异常

  • 如果throw被激发,那内存会丢失,你就有了内存泄漏。
  • 简单的解决方案是摆脱所有权,让C++运行时成为对象的直接所有者。这意味着只需要应用RAII

不要使用异常规格

正确排列catch子句的顺序

  • 异常的捕获是根据第一次匹配策略进行的。这意味着第一个匹配成功的异常处理器将被使用。这就是应该从具体到一般来组织异常处理器的原因。否则,你的具体异常处理器可能永远不会被调用。

  • 异常处理器有省略号(…),用来捕获所有其他的异常

如果不能抛出异常

  • 如果不能抛出异常,请模仿RAII进行资源管理
  • 如果不能抛出异常,考虑快速失败
    • 如果没有办法从内存耗尽等错误中恢复过来,那就快速失败。如果你不能抛异常,就调用std::abort,促使程序异常终止
  • 如果不能抛出异常,请系统的使用错误代码

  • 根据C++ Core Guidelines,如果出现错误,你有几个问题需要解决
    • 如何将错误指示从函数中传出
    • 在进行错误推出之前,如何从函数中释放所有资源
    • 使用什么作为错误指示
  • 一般来说,函数应该有两个返回值: 值和错误提示。 因此,std::pair就很合适。
  • 不过,即使清理代码被封装在函数中,释放资源也很容易成为维护的噩梦
  • 把清理代码放在函数的末尾,并跳转到该部分即可。

本章精华

  • 软件单元通过常规和非常规的通信途径将结果传达给客户。错误处理是非常规途径的一个主要部分,应该在设计初期进行设定
  • 围绕不变式设计错误处理。构造函数的工作是建立不变式。如果不变式不能被建立,则抛出异常
  • 使用用户定义类型来表示异常。通过引用来捕获异常,顺序应该从具体到一般
  • 仅将异常用于错误处理
  • 切勿直接拥有对象。始终使用RAII类型来管理任何需要释放的资源。RAII有助于资源管理,即使不使用异常,也是如此。

第十二章 常量和不可变性

  • 无论如何,const,constexpr和不可变性的思想太重要了。

使用const

  • const正确性,是指 意味着使用关键字const来防止const对象被改变

默认情况下,使对象不可变

  • 可以将内置数据类型的值或用户定义的数据类型的实例设为const,其效果是一样的。如果想改变这个对象,就会得到编译错误
  • 如果底层对象是const,则转型去除const的操作可能会导致未定义行为,参见 不要转型去除const

默认情况下,成员函数应该声明为const

  • 声明成员函数为const的做法有两个明显的好处。
    • 不可变的对象只能调用const成员函数
    • 而const成员函数不能修改底层对象
  • 物理常量性(physical constness)
    • 对象被声明为const,因此不能被改变。它在内存中的表示方法是固定的
  • 逻辑常量性(logical constness)
    • 对象被声明为const,但可以被改变。它的逻辑值是固定的,但它在内存中的表示方式可能在运行期发生变化

默认情况下,传递指向const对象的指针和引用

使用const定义构造后值不可变的对象

  • 在并发环境中使用不可变数据和共享数据时,还有一个问题需要解决:必须以线程安全的方式初始化共享变量。对此,我能够想到至少四种做法
    • 在启动线程前初始化共享变量
    • 使用函数std::call_once与标志std::once_flag
    • 使用具有块作用域的static变量
    • 使用constexpr变量

对可以在编译期计算的值使用constexpr

  • constexpr值可以提供更好的性能。会在编译期进行计算,并且永远不会受到数据竞争的影响。必须在编译期初始化constexpr值constexprValue

本章精华

  • 默认情况下,使对象不可变。不可变对象不会受到数据竞争的影响。确保以线程安全的方式初始化这些对象
  • 默认情况下,成员函数应声明为const。辨别你的对象是物理const还是逻辑const
  • 不要用转型从原始const对象中去除const。如果你试图修改该对象,那这种转换是未定义行为
  • 如果可能,把函数声明为constexpr。constexpr函数可以在编译期运行。它在编译期运行时是纯函数,并提供额外的优化机会

第十三章 模板和泛型编程

  • 暂不接触,不做记录

第十四章 C风格变成

优先使用C++而不是C

  • 原因:C++提供更好的类型检查和更多的写法支持。它为高级编程提供了更好的支持,并往往生成更快的代码

完整的源代码可用

没有完成的源代码

  • 使用C++编译期编译main函数
    • 与C编译期不同,C++编译期会生成在main函数之前执行的额外启动代码
  • 使用C++编译期链接程序
  • 使用来自同一供应商的C和C++编译期

如果必须使用C作为接口,则调用此类接口的代码里使用C++

  • 通过使用extern “C”链接说明符,可以防止C++编译器重编这些名字。这样,你既可以从C++调用C函数,也可以从C调用C++函数
  • 可以把extern “C”用在
    • 每个函数前
    • 一个作用于内的每个函数
    • 整个头文件(通过使用包含保护宏)。当使用C++编译器时,宏__cplusplus会被定义

如果必须使用C,请使用C和C++的公共子集,并以C++的方式编译C代码

本章精华

  • 如果必须支持C代码,请使用C++编译器编译C代码。如果不可能那样做,请使用C++编译期编译main函数,并使用C++链接器链接程序。使用同一供应商的C和C++编译期
  • 通过使用extern “C”链接说明符,可以防止C++编译器重编名字。这样,你既可以从C++调用C函数,也可以从C调用C++函数。

第十五章 源文件

  • 我们应该区分代码的实现和接口
  • 区分声明(用作接口)和定义(用作实现)。使用头文件表示接口并强调逻辑结构。

接口和实现文件

  • 声明或接口通常实现在.h文件中,定义或实现则在.cpp文件中

如果你的项目还没有采用其他约定,那代码文件使用.cpp后缀,接口文件使用.h后缀

  • 头文件
    • *.h
    • *.hpp
    • *.hxx
    • *.inl
  • 实现文件
    • *.cpp
    • *.c
    • *.cc
    • *.cxx

.h文件不可含有对象定义或非内联函数定义

  • 单一定义定义规则,ODR是 One Definition Rule。下面是它在函数方面的规定
    • 一个函数在任何翻译单元中不能有一个以上的定义
    • 一个函数在程序中不能有一个以上的定义
    • 有外部链接的內联函数可以在一个以上的翻译单元中被定义。这些定义必须满足一个要求,它们全部相同

.cpp文件必须包含定义其接口的.h文件

为所有的.h文件使用#include防护宏

  • 通过在头文件首尾放置包含防护宏,头文件就只会被包含一次
  • 有两点需要注意
    • 给你的防护宏一个唯一的名字。如果你多次使用同一个防护宏名字,可能导致本应该包含的头文件被排除在外
    • #pragma预处理指令不标准,但广泛受到支持。这个pragma指令意味着下面这种头文件的写法是不可被移植的

避免源文件间的循环依赖

  • 直接的解决方法是,在b.h中前置声明A,或者在a.h中前置声明B
  • 标准库头文件 中有标准输入/输出的前置声明

避免对隐含 #include 进来的名字的依赖

头文件应当是自包含的

  • 一个自包含的头文件可被包含在翻译单元的最上面。
  • 自包含的意思是,头文件不依赖于之前包含的其他文件。

仅对代码迁移,基础程序库(例如std)或者在局部作用域中使用using namespace指令

  • using指令隐藏了名称的来源,且破坏了代码的可读性

不要在头文件的全局作用域中使用using namespace

使用namespace表示逻辑结构

不要在头文件中使用无名(匿名)命名空间

为所有的内部/不导出的实体使用无名(匿名)命名空间

  • 无名namespace使用内部链接。内部链接意味着无名namespace内的名称只能在当前的翻译单元内引用,而不能导出。这同样适用于在无名namespace中声明的名称
  • 当你在头文件中使用无名namespace时,每个翻译单元都定义了这个无名namespace的唯一实例。头文件中的无名namespace会导致
    • 所产生的可执行文件大小膨胀
    • 无名namespace中的任何声明都是指每个翻译单元中的不同实体。这可能不是无名所期望的行为
  • 无名namespace的用法类似于C语言里使用的static关键字

本章精华

  • 头文件不应包含对象定义或非內联函数。它们应该是自包含的并且有#include防护宏。不要在头文件中使用using namespace
  • 源文件应该包含必要的头文件,并且避免循环依赖
  • 命名空间应该表达软件的逻辑结构。如果可能的话,应当避免使用using namespace指令以提升可读性

第十六章 标准库

优先采用STL的array或vector而不是C数组

  • 与C数组相比,std::vector的一大优势是可以自动管理内存

  • vector的大小是指它的元素数
  • 容器的容量是指一个vector在没有额外的内存分配的情况下可以容纳的元素数
  • 因此,一个vector的容量至少要和它的大小一样大。我们可以用方法resize来调整一个vector的大小,也可以用成员函数reserve来调整一个容器的容量

  • 有几点需要说明
    • 容器大小和容量的调整是自动完成的,不需要使用任何像new和delete那样的内存操作
    • 通过调用成员函数vec.resize(n),如果 n > vec.size(),那么vec这个vector里面的元素会默认初始化
    • 通过使用成员函数vec.reserve(n),如果n > vec.capacity(),那么容器vec会获得至少能够容纳n个元素的新内存
    • shrink_to_fit的调用是没有约束力的。这意味着C++运行时并非必须按照容器的大小来调整其容量。但是到目前为止,在GCC, Clang或cl.exe汇总用到成员函数shrink_to_fit总是会释放掉不必要的内存

默认应优先采用STL的vector,除非有理由使用别的容器

  • 如果你想在运行期向你的容器中添加元素或者从你的容器中删除元素,请使用std::vector,否则,请使用std::array.
  • 此外,std::vector可以比std::array大的多,因为它的元素会放在堆里。std::array使用的缓冲区则和使用它的上下文在一起

  • std::array和std::vector具有以下优点
    • 最快的通用访问(随机访问,包括对CPU向量化友好)
    • 最快的默认访问模式(从头到尾或者从尾到头的访问对CPU缓存预取友好)
    • 最低的空间开销(连续布局的每个元素额外开销为零,对CPU缓存友好)

避免边界错误

  • C数组本身不支持检测边界错误。而STL的许多容器都支持一个容器检查边界的at成员函数。在访问一个不存在的元素的情况下,会抛出一个std::out_of_range异常
  • 下面的容器都有一个带边界检查的at成员函数
    • 序列容器: std::array, std::vector和std::deque
    • 关联容器: std::map 和 std::unordered_map
    • std::string

文本

  • std::string : 拥有一个字符序列
  • std::string_view : 指向一个字符序列
  • char* : 指向单个字符
  • std::byte : 描述字节值(不一定是字符)
  • 总结一下:
    • 只有std::string是一个所有者,其他的都是指向已有的文本

使用std::string 来拥有字符序列

使用std::string_view来指向字符序列

  • std::string_view指向一个字符序列。说的明确一些,std::string_view并不拥有该字符序列。它代表的是一个子符序列的视图。这个字符序列可以是一个C++字符串或C字符串
  • std::string_view需要两样信息:指向字符序列的指针和长度。

用char*来指向单个字符

使用std::byte来指向未必表示字符的字节值

  • std::byte(C++17)是实现C++语言定义中规定的字节概念的一个独立类型。这意味着字节即不是整数,也不是字符。
  • 它的作用是访问对象存储。std::byte的接口包含了比特位逻辑操作的方法

对当作标准库string使用的字符串字面量使用后缀s

  • 在C++14之前,没办法不用C字符串来创建C++字符串
  • 到了C++14,我们有了C++字符串字面量,它们是C字符串字面量加上后缀s: “CstringLiteral”s

输入和输出

  • 当你和外部世界交互时,有两个输入/输出库发挥作用:
    • 基于流的I/O库(简称为iostream库)
    • C风格I/O函数
  • 当然你应该优先选择iostream库。
    • iostream是一种用于流式I/O的类型安全,可扩展,支持有格式和无格式输出的库。他支持多种(用户可扩展)缓冲策略和多种地域设置。它可被用于传统的I/O,对内存的读写(字符串流),以及用户定义的扩展,例如跨网络的流(asio)

仅在必要时使用字符级输入

当进行读取时,始终要考虑非法输入的情况

  • 只有当流处于std::ios::goodbit状态时,对流的操作才会产生影响。当流处于std::ios::badbit状态时,它不能被重置为std::ios::goodbit状态

优先使用iostream进行I/O操作

  • printf和iostream库之间有一些微妙但是关键的区别
    • printf的格式字符串指定格式和显式值的类型,而iostream库的格式操纵器只指定格式。
    • 反过来说,在使用iostream库时,编译器会自动推断出正确的类型

除非你要使用printf系列函数,否则应该调用ios_base::sync_with_stdio(false)

  • 默认情况下,对C++流的操作与C流的操作是同步的。这种同步发生在每个输入或输出操作之后
    • C++流: std::cin, std::cout, std::cerr, std::clog, std::wcin, std::wcout, std::wcerr 和 std::wclog
    • C流: stdin, stdout 和 stderr
  • 这种同步允许混合C++和C的输入或输出操作,因为对C++流的操作会不加缓冲的进入C流中。
  • 从并发的角度来看,还需要注意的是,同步的C++流是线程安全的。所有的线程都可以写到C++流,而不需要同步机制,有可能会出现字符交错的效果,但不会有数据竞争

  • 当你设置std::ios_base::sync_with_stdio(false)时,C++流和C流之间的同步不会发生,因为C++流可能会把它们的输出放到一个缓冲区里。有了缓冲区之后,输入和输出的操作可能会变得更快。
  • 你应该在任何输入或输出操作之前调用std::ios_base::sync_with_stdio(false)。如果不这样做,行为将由实现决定

避免使用endl

  • 操纵器std::endl和’\n’之间有什么区别
    • std::endl : 写一个换行符并刷新输出缓冲区
    • ‘\n’ : 写一个换行符
  • 刷新缓冲区操作代价较高,因此应该避免。如果有必要,缓冲区会被自动刷新

相关规则

  • 理想情况下,程序应该是静态类型安全的
  • 不要用单个指针来传递数组
  • 保持指针的使用简单明了
  • 避免对范围检查的需要

本章精华

  • 使用std::array或std::vector而不是C数组。如果容器必须在运行其曾长,或者元素的数量对std::array来说太大,那么首选std::vector而不是std::array.std::vector和std::array支持使用at成员函数对元素进行安全访问
  • C++有多种文本支持方式。std::string文本的所有者,而std::string_view, const char *只是指向文本。另外,std::byte包含字节值(不一定是字符)
  • 在输入/输出功能方面,首选iostream库而不是C风格的函数。读取文本时,始终要考虑非法输入

第十七章 架构观念

从不稳定的代码中分离稳定的代码

  • 格里不稳定的代码有助于单元测试,接口改进,重构和最终的废弃

  • 在稳定的和不太稳定的代码之间放置一个接口是一种分离的方式。由于接口的存在,不稳定的代码变成了一种子系统,你可以单独测试或重构它。
  • 这样你不仅可以测试子系统,还可以测试子系统和系统的集成。
    • 第一种测试通常被称为子系统测试
    • 第二种测试通常被称为系统集成测试
  • 子系统有两个进入系统的通道: 功能通道和非功能通道。两者都必须被测试
    • 功能通道提供了子系统的功能
    • 非功能通道则传播了可能发生的异常,系统可以对其作出反应。
  • 由于接口的存在,具体的子系统是接口的实现,因此可以很快被另一个可能更稳定的实现所代替

将潜在可复用的部分表达为程序库

  • 基于 你不会需要它(you aren’t gonna need it, YANGNI)原则,不要在代码中预先投入过多的精力以使其成为可重用的库,而是要先写出代码,使其有可能被复用。
  • 不要重复自己(DRY)原则,当你不止一次需要相同或者类似的功能时,它就会发生作用。这时,你应该考虑以下如何抽象。当我有两个类似的函数时,我会写第三个函数来提供实现,而那两个类似的函数则成为使用这个函数的封装

  • 以库的形式编写可复用的软件,比做一次性的实现要花三到四倍的精力。
  • 我的经验法则是,当知道你会重复使用某个功能时,应该考虑库的问题。而只有当你会重用某功能至少两次时,才应该把它写成一个库。

程序库之间不应该有循环依赖

  • 库C1和C2之间的循环依赖使你的软件系统更加复杂
    • 重新包装C1和C2,使它们不再相互依赖
    • 在物理上将C1和C2合并成一个单独组件C12
    • 把C1和C2放在一起考虑,把两者当作一个单独组件C12

第十八章 伪规则和误解

不要坚持认为声明都应当放在函数的最上面

  • 这条规则是C89标准的遗留问题。C89不允许在语句之后声明一个变量。这导致变量声明和使用之间有很大的距离。
    • 将变量的声明放在其第一次使用之前
    • 始终初始化一个变量,例如int i{}或者最好使用auto

不要坚持在一个函数中只保留一条return语句

不要避免使用异常

  • 反对异常四个主要原因
    • 异常是低效的
    • 异常会导致泄漏和错误
    • 异常的性能不可预测
    • 异常处理的运行需要太多空间
  • 通过使用异常,可以
    • 明确区分错误的返回和普通的返回
    • 不会被遗忘或忽视
    • 可以系统的使用

不要坚持把每个类声明放在独立的源文件中

  • 组织代码的合适方式并不是用文件,正确的方式是使用命名空间。
  • 如果为每个类的声明使用一个独立的文件,将产生过多的文件,进而使你的程序更难管理,编译更慢

不要采用两阶段初始化

  • 显然,构造函数的工作很简单: 在执行构造函数之后,应该有一个完整初始化的对象

  • 你也许经常使用构造函数来设置一个对象的默认行为。不要这样做,而应该直接在类的主题中设置对象的默认行为。
  • 构造函数只是用来改变这些默认行为。参见 不要定义一个只初始化数据成员的默认构造函数,而应该使用成员初始化器

不要把所有清理操作放在函数末尾并使用goto exit

不要使所有数据成员protected

  • 受保护的数据使程序变得复杂且容易出错。如果把受保护的数据放到基类中,你就不能孤立的仅根据派生类来推理,因此,你破坏了封装。
  • 受保护的数据意味着你至少要回答以下三个问题
    • 我是否必须在派生类中实现一个构造函数来初始化受保护的数据
    • 如果我使用受保护的数据,它的实际价值是什么
    • 如果我修改受保护的数据,谁会受到影响
  • 类的层次越深,这些问题的答案就会变得越复杂
  • 受保护的数据是类层次结构范围内的一种全局数据。而你知道,可变的,共享的状态非常糟糕。比如,它会使测试和并发的处理变得相当棘手