Cpp Core Guidelines


前言

这篇文章实际上是C++ Core Guidelines解析的转载加上一些自己的理解(相当于自己集合一些觉得精华的内容放在自己blog上),废话少说。开始挖坑!

C++ Core Guidelines 由 16 个主要部分组成:

  • 简介
  • 理念
  • 接口
  • 函数
  • 类和类的层次结构
  • 枚举
  • 资源管理
  • 表达式和语句
  • 性能
  • 并发性
  • 错误处理
  • 常量和不变性
  • 模板和泛型编程
  • C 风格编程
  • 源文件

理念

理念性规则概览:理念性规则强调一般性,因此,无法进行检查。不过,理念性规则为下面的具体规则提供了理论依据。一共有 13 条理念性规则。可以简单理解为一般情况下的编码规范。

P.1 在代码中直接表达思想

编译器是不会去读注释(或设计文档)的,许多程序员也(固执地)不去读它们。 而代码中所表达的东西是带有 明确的语义 的,并且(原则上)是可以由编译器和其他工具进行检验的。

class Date {
public:
    Month month() const;  // 好
    int month();          // 不好
    // ...
};

第一个 month 比第二个,有更多的信息,以 const 修饰,代表不会修改当前的日期,返回类型 Month 也非常明确。

成员函数修饰 const 是为什么,能做什么。只是默认,不修改当前类的数据成员就要加 const,明确语义,增加可读性。 但不够正确,也远不止如此

#include <iostream>

class Date {
    using Month = int;
public:
    Month m;

    int month() { return m; }          
};

void func(const Date& date){
    std::cout << date.month() << '\n';
}

int main(){
    Date d{10};
    func(d);
}

以上代码会得到一个编译错误,这应该是很常见的调用方式。为什么呢?显然,就是因为 month 成员函数没有以 const 修饰。C++ 不允许 const 的对象调用没有以 const 修饰的成员函数,事实上这个语义非常的合理:我都是 const 对象了,你为啥要修改?但是明明 month 函数根本没有修改对象的数据,所以这其实是开发者的问题,不应该写出这种代码。当前的语境很简单,我们只需要改成:

但是明明 month 函数根本没有修改对象的数据,所以这其实是开发者的问题,不应该写出这种代码。

当前的语境很简单,我们只需要改成:

Month month()const { return m; } // const 对象和非 const 对象都能调用

如果你阅读过 STL 源码,或者看过基本的文档,会知道,大部分成员函数都要提供 const 和非 const 两种版本,我们以 std::arrayoperator[] 为例

constexpr reference operator[]( size_type pos );
constexpr const_reference operator[]( size_type pos ) const;

这两个成员函数都不会修改自己存储的对象,但是为什么要写 const 版本呢?注意返回类型

  • 如果没有以 const 修饰的 std::array 对象,那么它调用 operator[] 自然是可以修改的,行为就像普通数组那样,我们就返回 reference
  • 如果是以 const 修饰的 std::array 对象,那么它调用 operator[] 根据我们的语义,自然不该让它外部能够修改,所以我们返回 const_reference

一个成员函数是否以 const 修饰,不在于这个成员函数到底是否会修改自己的成员,而在于 “可变性”。

相对于 标准库(STL)的算法,使用 for 或 while 等方式的手工循环通常也有上面一样的可读性问题。比如下面这样:

int index = -1;                          //不好
for (int i = 0; i < v.size(); ++i){
    if(v[i]==val){
        index = i;
        break;
    }
}

auto it = std::find(begin(v),end(v),val); //更好

一个专业的 C++ 开发者应该了解 STL 算法。使用它们的话,你就可以避免显式使用循环,你的代码也会变得更容易理解,更容易维护,因此,也更不容易出错。现代 C++ 中有一句谚语:

  • 如果你显式使用循环的话,说明你不了解 STL 算法。

其实就是说成员函数注意返回类型的名字,和 const 修饰,增加可读性。 多使用 STL 算法,而不是自己搓,既能增加可读性也能减少错误。

P.2 用 ISO 标准写代码

要想得到一个可移植的 C++ 程序,最好的选择就是按照标准写代码。

使用当前的 C++ 标准,不要使用编译器扩展,同时注意,未定义行为和实现定义行为

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

比如将使用的编译器扩展用宏封装起来,以后如果要修改,或者说要适应别的平台,都很方便。举一个古代例子

#if __cplusplus >= 201103L
#define INLINE inline
#else
#define INLINE __attribute__((__always_inline__))
#endif

P.3 表达意图

以下的隐式和显式循环中,你能看出什么意图?

for (const auto& v: vec) {...}                              // (1)
for (auto& v: vec) {...}                                    // (2)
std::for_each(std::execution::par, vec, [](auto v) {...});  // (3) 很抽象,这种形式其实根本做不到,当伪代码就好

循环(1)不修改容器 vec 的元素。(2)有可能修改。算法 std::for_each(3)以并行方式(std::execution::par)执行。这意味着我们不关心处理的顺序。举个例子

int main(){
    std::vector<int>vec{1, 2, 3, 4, 5};
    std::for_each(std::execution::par, vec.begin(), vec.end(), [](auto v) {std::cout << v; });//打印的顺序是随机的
    std::cout << '\n';
    std::for_each(vec.begin(), vec.end(), [](auto v) {std::cout << v; });                     //12345
}

表达意图也是良好代码文档的一个重要准测。

  • 文档应该说明代码会做什么,而不是代码会怎么做

其实这里是在指,文档应该讲功能,而非实现细节。 不过事实上技术文档一般都不会完全这样,多少会提一些实现细节的,具体情况具体分析。

P.4 理想情况下,程序应该是静态类型安全的

C++ 是一种静态类型的语言。静态类型意味着编译器知道数据的类型,此外,还说明,编译器可以检测到类型错误。 由于现有的问题领域,我们并非一直能够达到这一目标,但对于联合体、转型(cast)、数组退化、范围错误或窄化转换,确实是有办法的。

  • 在 C++17 中,可以使用 std::variant 安全地替代联合体
  • 基于模板的泛型代码减少了转型的需要,因此,也减少了类型错误。
  • 当用一个 C 数组调用一个函数时,就会发生数组退化。函数需要用指向数组第一个元素的指针,另加数组的长度。这意味着,你从一个类型丰富的数据结构 C 数组开始,却以类型极差的数组首项指针结束。解决方法在 C++20 里:std::span。std::span 可以自动推算出 C 数组的大小,也可以防止范围错误的发生。如果你还没有使用 C++20,请使用 Guidelines 支持库(GSL)提供的实现。
  • 窄化转换是对算术值的有精度损失的隐式转换。
int i1(3.14);
int i2 = 3.14;

如果你使用 {} 初始化语法,编译器就能检测到窄化转换

int i1{3.14};
int i2 = {3.14};

P.5 编译期检查优先于运行期检查

  • 如果可以在编译期检查,那就应该在编译期检查
// Int 被用作整数的别名
int bits = 0;         // 请勿如此: 可以避免的代码
for (Int i = 1; i; i <<= 1)
    ++bits;
if (bits < 32)
    cerr << "Int too small\n";

这个例子并没有达成其所要达成的目的(因为溢出是未定义行为),应当被替换为简单的 static_assert:

// Int 被用作整数类型的别名
static_assert(sizeof(Int) >= 4);    // do: 编译时检查

或者更好的方式是直接利用类型系统,将 int 替换 int32_t

如果当前环境有 int32_t 这个别名,那么代表,你的环境支持 32位整数类型。

或用来检测类型特征(type traits),比如:

static_assert(std::is_integral_v<T>);

P.6 不能在编译期检查的事项应该在运行期检查

因为有 dynamic_cast ,可以安全的将类的指针和引用沿着继承层次结构进行向上,向下以及测向的转换。如果转型失败,

dynamic_cast< 新类型 >( 表达式 )

如果转型失败且 新类型 是指针类型,那么它会返回该类型的空指针。

如果转型失败且 新类型 是引用类型,那么它会抛出与类型 std::bad_cast 的处理块匹配的异常。

第五章中 “dynamic_cast” 一节中会有更多的细节。

P.7 尽早识别运行期错误

可以采取很多对策来摆脱运行期错误。管理好指针和 C 数组,检查他们的范围。对于转换,同样需要检测:

  • 如有可能,应尽量避免转换,对于窄化转换,尤其如此。检查输入也属于这个范畴

P.8 不要泄露任何资源

资源可以是内存、文件句柄、套接字,等等。处理资源的惯用法是 RAII 。RAII 是资源获取即初始化(Resource Acquisition Is Initialization)。 是一种 C++ 编程技术,它将必须在使用前请求的资源(分配的堆内存、执行线程、打开的套接字、打开的文件、锁定的互斥体、磁盘空间、数据库连接等——任何存在受限供给中的事物)的生命周期与一个对象的生存期相绑定。 即:构造函数中获取资源,析构函数中释放资源。

C++ 大量使用 RAII:锁负责处理互斥量,智能指针负责处理原始内存,STL 的容器负责处理底层元素,等等。

这里居然用中文的 “锁” 这个字来指代那些通用锁管理类(std::lock_guard),原书这里用这个字描述是有问题的,请不要模仿。

P.9 不要浪费时间和空间

节省时间和空间都是一种美德。我们用的是 C++。你发现下面循环中的问题了吗?

void lower(std::string s){
    for (unsigned int i = 0; i <= std::strlen(s.data()); ++i){
        s[i] = std::tolower(s[i]);
    }
}

虽然是个错误示例,但这代码写的太过愚蠢了,函数形参不用引用直接拷贝是其一,s 明明是个 std::string 对象,不去调用 size() 成员函数,跑去用 C 标准库的玩意?

使用 STL 中的算法 std::transform ,就可以把前面的函数变成一行。

std::transform(s.begin(), s.end(), s.begin(), [](char c) {return std::tolower(c); });

与函数 lower 相比,算法 std::transform 自动确定了字符串的大小。因此,你不需要使用 std::strlen 指定字符串的长度。

下面是另一个经常出现在生产代码中的典型例子。为一个用户定义的数据类型声明拷贝语义。(拷贝构造函数和拷贝赋值运算符)。最终,编译器永远用不了廉价的移动语义。(即使实际上移动是适用的),而只能一直依赖代价高昂的拷贝语义。

struct S{
    std::string s_;
    S(std::string s) :s_(s) {}
    S(const S& rhs) :s_(rhs.s_) {}
    S& operator=(const S& rhs) { s_ = rhs.s_; return *this; }
};
S s1;
S s2 = std::move(s1);           //进行拷贝,而不能从 s1.s_ 移动。

这里没有任何问题,但是我们详细的描述一下,因为我觉得很多人不清楚移动语义能带来什么。

//不修改 S 类
int main() {
    S s1{ "aaaaaaaaaaaaaaaa" };
    std::cout << reinterpret_cast<const void*>(s1.s_.data()) << '\n';
    S s2 = std::move(s1);
    std::cout << reinterpret_cast<const void*>(s2.s_.data()) << '\n';
}

以上代码打印的地址不一样,这代表实际上是复制了 std::string 管理的数据的。

但是,如果我们修改 S 类,比如直接把复制构造和复制赋值运算符给删了,会怎么样?

#include <iostream>
#include <string>

struct S{
    std::string s_;
    S(std::string s) :s_(s) {}
};

int main(){
    S s1{"aaaaaaaaaaaaaaaa"};
    std::cout << reinterpret_cast<const void*>(s1.s_.data()) << '\n';
    S s2 = std::move(s1);
    std::cout << reinterpret_cast<const void*>(s2.s_.data()) << '\n';
}

打印的地址完全一致。

这代表了 std::string 对象管理的数据并没有真的进行复制。

我们讲一下为什么:没有了复制构造函数和复制赋值运算符后,移动构造不会再被抑制了,编译器可以隐式定义移动构造函数

这个隐式定义的移动构造函数,你大约可以理解为,我们的 S s2 = std::move(s1) 这里调用了 S 的移动构造函数,那么,它的数据成员,同时,也会被 移动,如果是类类型,且有移动构造函数的话,会被调用,相当于,std::string 被调用了移动构造,然后进行了转移。我们知道 std::string 的移动构造,是转移所有权(其实你就可以理解为把原对象的指向数据的指针给了我们当前的对象,然后原对象赋空)。

我们举个例子:

#include <iostream>

struct X{
    X() = default;
    X(X&&) { puts("X(X&&)"); }
    X(const X&) { puts("X(const X&)"); }
    ~X() {}
};

struct Y{
    X x;
};
int main() {
    Y y1;
    Y y2 = std::move(y1);
}

这段代码会打印一个 X(X&&),这证明了我们前面说的:

编译器隐式定义的移动构造函数,被调用,相当于会把自身的数据成员也进行移动,如果它是类类型,且有移动构造,那么也会匹配上,进行调用。

P.10 不可变数据优先于可变数据

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

其实只需要考虑一个事情:如果它可以是常量,那就把它设置为常量

P.11 封装杂乱的构建,不要让它在代码中散布开

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

P.12 适当使用辅助工具

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

P.13 适当使用支持库

这也很好解释。你应该去找设计良好、文档齐全、支持良好的库。你会得到经过良好测试、几乎没有错误的库,其中的算法经过领域专家的高度优化。突出的例子包括:C++ 标准库、Guidelines 支持库和 Boost 库。

我觉得一定有人看到这段会嗤之以鼻,但是总体其实没错的。

接口

接口是服务的提供者和使用者之间的契约。根据 C++ Core Guidelines,接口”可能是代码辅助中最重要的一个方面”。“接口”这一部分大约有 20 条规则。

让接口易于正确使用,难以错误使用

I.2 避免非 const 的全局变量

当然,你应该避免非 const 的全局变量。但是为什么呢?为什么全局变量(尤其是当它不是常量时)会很糟糕?全局变量会在函数中注入隐藏的依赖,而该依赖并不是接口的一部分。下面的代码片段说明了我的观点:

加粗的话注意理解。

int glob{ 2011 };

int multiply(int fac){
    glob *= glob;
    return glob * fac;
}

函数 multiply 的执行有一个副作用——会改变全局变量 glob 的值。因此,你无法对函数进行孤立测试或推理。当更多的线程并发地使用 multiply 时,你就必须对变量 glob 加以保护。非 const 的全局变量还有更多其他弊端。如果函数 multiply 没有副作用,那你可以为了性能而将之前的结果存储到缓存中以进行复用。

注意到我们加粗的内容了吗?这非常重要,自行理解。 我们就讲一下最后一句,这其实是在描述编译器优化 ,我们举个例子:什么情况才可能会是我们说的:没有副作用,那你可以为了性能而将之前的结果存储到缓存中以进行复用

int add(int a,int b){
    return a + b;
}

int main(){
    int c = add(1, 2) + add(1, 2) + add(1, 3);
}

其实无非就是:编译器看到你用同样的入参调用了两次 就可以干掉第二次调用。之前的结果被缓存了。(前提是这得是纯函数)。

但是我们前面依赖了全局变量,就不行,它有外部的副作用,返回的结果可能会根据全局变量的不同而不同,没办法缓存。不能保证:多次调用传入的数据相同就能得到完全一致的结果

3.1 非 const 全局变量的弊端

非 const 的全局变量有许多弊端。首当其冲的弊端是,非 const 的全局变量破坏了封装。这种对封装的破坏让你无法对函数/类(实体)进行独立思考。下面列举非 const 全局变量的主要弊端。

  • 可测试性:无法孤立地测试你的实体。如果单元不存在,那么单元测试也将不存在。你只能进行系统测试。实体的执行效果要依赖整个系统状态。
  • 重构:因为你无法孤立地对代码进行推理,重构它会相当有挑战。
  • 优化:你无法轻易地重新安排函数调用或者在不同的线程上进行函数调用,因为可能有隐藏的依赖。缓存之前函数调用的结果也极为危险。
  • 并发:产生数据竞争的必要条件是有共享而可变的状态。而非 const 全局变量正是共享而可变的。

I.3 避免单例

有时,全局变量伪装得很好。

// singleton.cpp

#include <iostream>

class MySingleton{
public:
    MySingleton(const MySingleton&) = delete;
    MySingleton& operator = (const MySingleton&) = delete;

    static MySingleton* getInstance(){
        if(!instance){
            instance = new MySingleton();
        }
        return instance;
    }

private:
    static MySingleton* instance;
    MySingleton() = default;
    ~MySingleton() = default;
};

MySingleton* MySingleton::instance = nullptr;
  • 单例就是全局变量,因此你应当尽可能*避免单例***。

单例简单、直接地保证该类最多只有一个实例存在。作为全局变量,单例注入了一个依赖,而该依赖忽略了函数的接口。这是因为作为静态变量,单例通常会被直接调用,正如上面例子主函数中的两行所展示的那样:Singleton::getInstance()。而对单例的直接调用有一些严重的后果。你无法对有单例的函数进行单元测试,因为单元不存在。此外,你也不能创建单例的伪对象并在运行期替换,因为单例并不是函数接口的一部分。

我们先聊一下最后一句话:其实就是说,我没办法创造一个和单例一样类型的对象,然后进行函数传参。因为单例不是函数接口的一部分,它通常会被直接调用。 另外,我们要明白,“单例” 它是带状态的,单例的状态是经常变化和难以确定的,因为大家都使用和修改这个单例。如果某个函数使用了这个单例,就没办法对它进行单独的单元测试,因为单例的状态无法确定,单例的状态依赖于所有修改它的代码。这也就是前面说的:你无法对有单例的函数进行单元测试

  • 简而言之,单例破坏了代码的可测试性

实现单例看似小事一桩,但其实不然。你将面对几个挑战:

  • 谁来负责单例的销毁?
  • 是否应该允许从单例派生?
  • 如何以线程安全的方式初始化单例?
  • 当单例互相依赖并属于不同的翻译单元时,应该以何种顺序初始化这些单例?这里要吓唬吓唬你了。这一难题被称为静态初始化顺序问题

3.2 运用依赖注入化解

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

#include<iostream>
#include <chrono>
#include <memory>

class Logger{
public:
    virtual void write(const std::string&) = 0;
    virtual ~Logger() = default;
};

class SimpleLogger:public Logger{
    void write(const std::string& mess) override{
        std::cout << mess << std::endl;
    }
};

class TimeLogger:public Logger{
    using MySecondTick = std::chrono::duration<long double>;
    long double timeSinceEpoch(){
        auto timeNow = std::chrono::system_clock::now();
        auto duration = timeNow.time_since_epoch();
        MySecondTick sec(duration);
        return sec.count();
    }
    void write(const std::string& mess) override{
        std::cout << std::fixed;
        std::cout << "Time since epoch: " << timeSinceEpoch() << ": " << mess << std::endl;
    }
};

class Client{
public:
    Client(std::shared_ptr<Logger>log) :logger(log) {}
    void doSomething(){
        logger->write("Message");
    }
    void setLogger(std::shared_ptr<Logger>log){
        logger = log;
    }
private:
    std::shared_ptr<Logger>logger;
};

int main(){
    Client cl(std::make_shared<SimpleLogger>());  //(1)
    cl.doSomething();
    cl.setLogger(std::make_shared<TimeLogger>()); // (2)
    cl.doSomething();
    cl.doSomething();

    std::cout << std::endl;
}

3.3 构建良好的接口

  • 函数应该通过接口(而不是全局变量)进行沟通

现在我们来到了本章的核心。按照 C++ Core Guidelines,下面是关于接口的建议。

  • 接口明确(I.1)
  • 接口精确并具有强类型(I.4)
  • 保持较低的参数数目(I.23)
  • 避免相同类型却不相关的参数相邻(I.24)

下面的函数 showRectangle 违反了刚提及的接口的所有规则:

void showRectangle(double a,double b,double c,double d){
    a = floor(a);
    b = ceil(b);

    ...
}
void showRectangle(Point top_left, Point bottom_right); // 好

尽管函数 showRectangle 本应当只显示一个矩形,但修改了它的参数。实质上它有两个目的,因此,它的名字有误导性(I.1)。另外,函数签名没有提供关于参数应该是什么的任何信息,也没有关于应该以什么顺序提供参数的信息(I.23 和 I.24)。此外,参数是没有取值范围约束的双精度浮点数。因此,这种约束必须在函数中确立(I.4)。对比而言,第二个 showRectangle 函数接受两个具体的点对象(Point)。

  • 检查 Point是否合法值是 Point 构造函数的工作。这种检查工作本来就不是函数 showRectangle 的职责

进一步阐述规则 I.23I.24 以及标准模板库(STL)中的函数 std::transform_reduce。首先,需要定义属于“可调用”(callable)。可调用实体是在行为上像函数的东西。它可以是函数,也可以是函数对象,或者是 lambda 表达式。如果可调用实体接受一个参数,它就是一元可调用实体;如果它接受两个参数,则称为二元可调用实体。

std::transform_reduce 先将一元可调用实体应用到一个范围或将二元可调用实体应用在两个范围,然后将二元可调用实体应用到前一步的结果的范围上。当你使用一个一元 lambda 表达式调用 std::transform_reduce时,这种调用易于正确使用。

std::vector<std::string>strVec{"Only", "for", "testing", "purpose"};
std::size_t res = std::transform_reduce(
    std::execution::par,
    strVec.begin(), strVec.end(),
    0,
    [](std::size_t a, std::size_t b) {return a + b; },
    [](std::string s) {return s.size(); }
);//res 值为 21。

事实上原书给的上面这段代码是有问题的,无法在 msvc 通过编译,这里使用的是 0 做初始值,有窄化转换,msvc 使用的是 {} 初始化。,检测到了,于是编译错误。(但是需要注意,不是简单的 {} 检测的问题,msvc 的实现和其他 stl 从根本上就不一样) 这里其实可以算作是 msvc 的bug,这个场景需要良构 这里应该把 0 换成 Oull (基于当前 64 位环境),或者标准够高使用 0uz,再或者直接 std::size_t{0}

函数 std::transform_reduce 先将每个字符串变换为它的长度 [](std::string s) {return s.size(); } , 并将二元可调用实体 [](std::size_t a, std::size_t b) {return a + b; }, 应用到结果的范围上。求和的初始值是 0。整个计算是并行的 std::execution::par

当你使用以下接受两个二元可调用实体的重载版本时,函数声明会变得相当复杂且易错。这违反了 I.23 和 I.24。

template<class ExecutionPolicy,
         class ForwardIt1, class ForwardIt2, class T, class BinaryOp1, class BinaryOp2>
T transform_reduce(ExecutionPolicy&& policy,
                   ForwardIt1 first1, ForwardIt1 last1, ForwardIt2 first2,
                   T init, BinaryOp1 binary_op1, BinaryOp2 binary_op2);

调用这个重载函数需要 6 个模板参数和 7 个函数参数。按正确顺序使用两个二元可调用实体,可能也是个挑战。

我们展示一下使用这个重载函数的示例代码

std::vector<std::string>strVec{"Only", "for", "testing", "purpose"};
std::vector<std::string>vec{"a", "b", "c", "d"};
std::size_t res = std::transform_reduce(
    std::execution::par,
    strVec.begin(), strVec.end(),
    vec.begin(),
    std::size_t{0},
    [](std::size_t a, std::size_t b) {return a + b; },
    [](std::string s, std::string s2) {return s.size() + s2.size(); }
);

res 结果 是 25。

函数 std::transform_reduce 复杂的原因在于两个函数被合并成了一个。更好的选择应该是分别定义函数 transform 和 reduce,并支持管道运算符调用:transform | reduce

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

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

这是一条非常特殊的规则,肯定会有很多人不屑一顾。这条规则的出现正是为了解决一些未定义行为。例如下面的函数 copy_n 相当容易出错。

template<typename T>
void copy_n(const T* p, T* q, int n); // 从[p:p+n] 拷贝到 [q:q+n]

...

int a[100] = {0,};
int b[100] = {0,};

copy_n(a, b, 101);

也许某一天累得精疲力尽,就数错了一个。结果会引发一个元素的越界错误,造成未定义行为。补救方法也很简单,使用 STL 中的容器,如 std::vector,并在函数体中检查容器大小。C++20 提供的 std::span 能更优雅地解决这个问题。std::span 是个对象,它可以指代连续存储的一串对象。 std::span 永远不是所有者(其实就是说它是个视图,没所有权)。而这段连续的内容可以是数组,或是带有大小的指针,或是 std::vector。

函数传参数组不用指针,而是用 C++20 的 std::span

template<typename T>
void copy(std::span<const T>src, std::span<T> des);

int arr1[] = {1, 2, 3};
int arr2[] = {1, 2, 3};

copy(arr1,arr2);

copy 不需要元素的数目。一种常见的错误来源就这样被 std::span<T> 消除了。

I.27 为了库 ABI 的文档,考虑使用 PImpl

由于私有数据成员参与类的内存布局,而私有成员函数参与重载决议,对这些实现细节的改动都要求使用了这类的所有用户全部重新编译。而持有指向实现的指针(Pimpl)的 非多态的接口类,则可以将类的用户从其实现的改变隔离开来,而代价是一层间接

  • 接口: Widget.h
class widget {
    class impl;
    std::unique_ptr<impl> pimpl;
public:
    void draw(); // 公开 API 转发给实现
    widget(int); // 定义于实现文件中
    ~widget();   // 定义于实现文件中,其中 impl 将为完整类型
    widget(widget&&) noexcept; // 定义于实现文件中
    widget(const widget&) = delete;
    widget& operator=(widget&&) noexcept; // 定义于实现文件中
    widget& operator=(const widget&) = delete;
};
  • 实现: Widget.cpp
class widget::impl {
    int n; // private data
public:
    void draw(const widget& w) { /* ... */ }
    impl(int n) : n(n) {}
};
void widget::draw() { pimpl->draw(*this); }
widget::widget(int n) : pimpl{std::make_unique<impl>(n)} {}
widget::widget(widget&&) noexcept = default;
widget::~widget() = default;
widget& widget::operator=(widget&&) noexcept = default;

http://cppreference.com 提供了关于 PImpl 惯用法的更多信息。

函数

4.1 函数定义

  • 好软件的重要原则是好名字

这一原则经常被忽视,但对函数而言它尤其适用。

好名字

C++ Core Guidelines 用了前三条规则专门讨论好的名字:“F.1:将有意义的操作 ‘打包’ 成精心命名的函数” “F.2:一个函数应该执行单一的逻辑操作” “F.3:使函数保持简短”。

让我从一则轶事开始。几年前,一位软件开发者问我:“我应该如何称呼我的函数?”我告诉他给函数起一个如 verbObject(动词加对象)这样的名字。如果是成员函数,可能用 verb 就可以了,因为该函数已经对一个对象执行了操作。动词代表了对象执行的操作。那位软件开发者反驳这是不可能的;该函数必须被称为 getTimeAndAddToPhonebook 或 processData,因为这些函数执行不止一项工作(单一责任原则)。

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

如果一个函数放不进一屏,那就是太长了。一屏意味着大约 60 行,每行 140 个字符,但你的衡量标准可能有所不同。这时,你就应该识别出函数的操作,并将这些操作打包成精心命名的函数。

void read_and_print(){      //不好
    int x;
    std::cin >> x;
    //检查错误
    std::cout << x << '\n';
}

由于许多原因,函数 read_and_print 不好。该函数与特定的输入和输出捆绑在一起,不能在不同的上下文中使用。将该函数重构为两个函数,可以解决这些问题,使其更易于测试和维护。

int read(std::istream&is){ //更好
    int x;
    is >> x;
    return x;
}

void print(std::ostream&os,int x){
    os << x << '\n';
}

实例:

#include<iostream>
#include<sstream>

int read(std::istream&is){ //更好
    int x;
    is >> x;
    return x;
}

void print(std::ostream&os,int x){
    os << x << ' ';
}

int main(){
    std::stringstream s{"10 "};
    auto result = read(s);          //从 stringstream 中读取
    print(std::cout, result);       //输出到标准输出中

    std::cout << s.str() << '\n';   //打印 stringstream 中的内容

    print(s,6);                     //输出到 stringstream 中。
    print(s,7);                     //输出到 stringstream 中。
    print(s,8);                     //输出到 stringstream 中。

    std::cout << s.str() << '\n';   //打印 stringstream 中的内容

    s.seekg(0);                     //需要进行回溯,因为默认读取位置在上一个read到的位置
    auto result2 = read(s);
    std::cout << result2 << '\n';   //从 stringstream 中读取
}

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

constexpr 函数是可能在编译期运行的函数。当你在常量表达式中调用 constexpr 函数时,或者当你要用一个 constexpr 变量来获取 constexpr 函数的结果时,它会在编译期运行。也可以用只能在运行期求值的参数来调用 constexpr 函数。constexpr 函数是隐含内联的。

编译期求值的 constexpr 的结果通常会被系统标记为只读。性能是 constexpr 函数的一大好处;它的第二大好处是:

编译期求值的 constexpr 是纯函数,因此 constexpr 函数是线程安全的

加粗这句话是因为这句话是 错误 的。 我们下面把这句话分开来聊:

  • constexpr 函数是线程安全的”?

完全错误。

这里会涉及到一个问题:

  • C++23 前,标准要求至少存在一组实参值,使得函数的一个调用为核心常量表达式的被求值的子表达式(对于构造函数为足以用于常量初始化器)。不要求诊断是否违反这点
  • C++23 起,标准移除了这一要求
#include <iostream>
int n = 5;
constexpr int f(int v){//永远不可能编译期求值
    return n *= n, n * v;
}

int main(){
    std::cout << f(1) << '\n';
}//C++23前可能可以通过编译,也可能不行;C++23起能通过编译

不过这还是太牵强了,我们可以用一个更简单直接的 demo 展示:

constexpr void f(int& a){
    ++a;
}

int main(){
    int b = 10;
    f(b); //运行期调用,修改b
}

以上这段代码显然不是线程安全的,各位可以自己测试你可能可以看到结果是正确的,但是事实上这没什么价值,某些 CPU 能保证标量类型的读写线程安全

  • 编译期求值的 constexpr 函数是纯函数“?

完全错误(C++11 以后)

C++11 的常量求值中函数只能表现为纯的,一旦有修改操作就导致常量求值失败

#include<iostream>

constexpr int f(int &v){
    v +=2;
    return v * v;
}

constexpr std::pair<int,int> call(){
    int v = 1;
    auto r1 = f(v);
    auto r2 = f(v);
    return { r1,r2 };
}

int main(){
    constexpr auto result = call();
    std::cout << result.first << ' ' << result.second << '\n';
}

我的函数 f 的确是编译期求值,没毛病,难不成它是纯函数?你要不看看它都做了什么.

我们回到前面的性能话题

constexpr auto gcd(int a,int b){
    while(b!=0){
        auto t = b;
        b = a % b;
        a = t;
    }
    return a;
}

int main(){
    constexpr int i = gcd(11, 121); //(1)编译期求值

    int a = 11;
    int b = 121;
    int j = gcd(a, b);              //(2)非编译期求值
}

原书这里是要你看汇编的,友情提示,别开优化,这么点代码,开优化,啥 call 都没有。 下面是使用 gcc13.2 生成的 Intel 风格的汇编代码。

(1)所对应汇编指令就是 26行。(2)所对应的汇编指令就是 31~34 行。

调用 constexpr int i = gcd(11, 121); 会变成值 11,但调用 int j = gcd(a, b); 却会产生一个函数调用。

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

通过将函数声明为 noexcept,你减少了备选控制路径的数量;因此,noexcept 对优化器来说是一个有价值的提示。

  • 即使你的函数可以抛出异常,noexcept 往往也合理

noexcept 在这种情况下意味着:

  • 我不在乎异常。其原因可能是,你无法对异常做出反应

这种情况下,系统处理异常的唯一办法是调用 std::terminate()。这个 noexcept 声明也为代码的读者提供了有价值的信息。

下面的函数会在内存耗尽时崩溃。

std::vector<std::string> collect(std::istream& is)noexcept{
    std::vector<std::string>res;
    for (std::string s; is >> s;) {
        res.push_back(s);
    }
    return res;
}

以下类型的函数永远不该抛出异常:析构函数(见第 5 章中 “失败的析构函数” 一节)、swap 函数,移动操作和默认构造函数。

F.8 优先使用纯函数

  • 纯函数是指在给定相同参数时总返回相同结果的函数

其实还有:该函数没有副作用(局部静态变量、非局部变量、可变引用参数或输入/输出流没有突变)。下面也略微提到了。

函数模板 square 就是纯函数:

template<class T>
auto square(T t){
    return t * t;
}

而非纯函数是指 random() 或 time() 这样的函数,它们会在不同的调用中返回不同的结果。换句话说:

  • 与函数体之外的状态交互的函数是不纯的

纯函数可以:

  • 孤立地侧测试
  • 孤立地验证或重构
  • 缓存其结果
  • 被自动重排或在其他线程上执行

纯函数也被称为数学函数。C++ 中的函数默认情况下不是像纯函数式编程语言 Haskell 中那样的纯函数。 在 C++ 中使用纯函数时要基于程序员的素养。constexpr 函数在编译期求值时是纯的

加粗的原因很简单,因为这句话是错的F.4 已经介绍过了。

模板元编程时一种嵌在命令式语言 C++ 中的纯函数式语言。

第 13 章将简单介绍编译期编程,其中包括模板元编程。

4.2 参数传递:入与出

C++ Core Guidelines 有若干条规则表达了在函数中传入和传出参数的各种方式。

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

第一条规则展示了大局。首先,它提供了一个概览,介绍了在函数中传入和传出信息的各种方式(见表 4.1)

表 4.1 很简洁:表头表述了数据在拷贝和移动开销方面的特征,而各行则表明了参数传递的方向。

表 4.1 普通的参数传递

拷贝开销低或不可能拷贝移动开销低到中,或者未知移动开销高
func(x)func(const X&)
入并保留“拷贝”
入/出func(X&)
X func()func(X&)
  • 数据类型
  • 拷贝开销低或不可能拷贝intstd::unique_ptr
  • 移动开销低std::vector<T>BigPOD(POD 代表 Old Data “简旧数据”,意为一般的传统数据——没有析构函数、构造函数以及虚成员函数的类)
  • 移动开销未知:模板
  • 移动开销高BigPOD[] 或者 std::array<BigPOD>
  • 参数传递的方向
  • :输入参数
  • 入并保留“拷贝”:被调用者保留一份数据
  • 入/出:参数会被修改
  • :输出参数

对几个 int 大小的数据的操作是低开销的;在不进行内存分配的前提下,1000 字节左右的操作属于中等开销。

这些普通的参数传递规则则应当是你的首选。不过,也有高级的参数传递规则(见表4.2)。实质上,就是加入了“入并移入”的语义。

表 4.2 高级的参数传递

拷贝开销低或不可能拷贝移动开销低到中,或者未知移动开销高
func(x)func(const X&)
入并保留“拷贝”
入并移入func(X&&)
入/出func(X&)
X func()func(X&)

这两个表的 “” 用的 func(X&) 其实是指代那种老式的写法,传入参数做传出参数,Win32中很常见。 “不可能拷贝” 其实是指:f(X{}) 这种形式,C++17 起强制的复制消除,不可能再复制。 “入并移入” 就是让你 f(std::move(...))

在 “入并移入” 调用后,参数处在所谓的被移动状态。被移动状态意味着它处于合法但未指定的状态。基本上,你在重新使用被移动的对象前必须对它进行初始化。

其余的参数传递规则为以上这些表格提供了必要的背景信息。

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

这条规则执行起来直截了当。默认情况下,输入值可以拷贝就拷贝。如果拷贝开销不低,就通过 const 引用来传入。C++ Core Guidelines 给出了回答以下问题的经验法则:

哪些对象拷贝开销低?哪些对象拷贝高?

  • 如果 `sizeof(par) <= 2 \ sizeof(void*)`,则按值传递参数 par*。
  • 如果 `sizeof(par) > 2 \ sizeof(void*)`,则按 const 引用 传递参数 par*。
void f()(const std::string& s);  // 可以:按 const 的引用传递;
                                 // 总是低开销
void f2(std::string s);          // 差劲:潜在的高昂开销

void f3(int x);                  // 可以:无可匹敌

void f4(const int& x);           // 差劲:在 f4() 里面访问时有额外开销

F.19 对于“转发”参数,要用 Tp&& 来传递,并且只 std::forward 该参数

这条规则代表了一种特殊的输入值。有时你想完美转发参数 par。这意味着你希望保持左值的左值性,以及右值的右值性,这样才能“完美”地转发参数,使它的语义不发生变化。

该转发参数的典型用例是工厂函数,工厂函数通过调用某个用户指定对象的构造函数创造处该对象。你不知道参数是不是右值,也不知道构造函数需要多少参数。

#include <string>
#include <utility>

template<typename T,typename ...T1>     //(1)
T create(T1&&...t1){
    return T(std::forward<T1>(t1)...);
}

struct MyType{
    MyType(int, double, bool) {}
};

int main(){
    // 左值
    int five = 5;
    int myFive = create<int>(five);

    // 右值
    int myFive2 = create<int>(5);

    // 无参数
    int myZero = create<int>();

    // 三个参数;(左值,右值,右值)
    MyType myType = create<MyType>(myZero, 5.5, true);
}

形参包的打包和解包

当省略号在类型参数 T1 的左边时,参数包被打包;当省略号在右边时,参数包被解包。返回语句 T(std::forwardt1(t1)...) 中的这种解包实质上意味着表达式 std::forwardt1(t1) 被不断重复,直到形参包中的所有参数都被消耗掉,并且会在每一个子表达式之间加一个逗号

#include <iostream>

template<typename T, typename ...T1>     //(1)
T create(T1&&...t1) {
    return T(std::forward<T1>(t1)...);
}

struct X{
    X(int,double,char){}
};

int main(){
    X resutl = create<X>(1, 1.2, '*');
}

以上代码的 create 模板,实例化相当于下面这种形式:(也符合前面说的“被不断重复,直到形参包中的所有参数都被消耗掉,并且会在每一个子表达式之间加一个逗号”)

template<>
X create<X, int, double, char>(int && __t10, double && __t11, char && __t12)
{
  return X(X(std::forward<int>(__t10), std::forward<double>(__t11), std::forward<char>(__t12)));
}

对于好奇的读者 C++ Insights 可以展示这个过程。

转发与变参模板的结合是 C++ 中典型的创建模式。下面是 std::make_unique 的一种可能实现。

template<typename T,typename...Args>
std::unique_ptr<T> make_unique(Args&&...args){
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

F.17 对于“入-出”参数,使用非 const 的引用来传递

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

std::vector<int>myVec{1, 2, 3, 4, 5};

void modifyVector(std::vector<int>& vec){
    vec.push_back(6);
    vec.insert(vec.end(), { 7,8,9,10 });
}

F.20 对于“出”的输出值,优先使用返回值而非输出参数

这条规则很简单。

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

也许你认为值的复制开销巨大,这既对也不对。原因在于编译器会应用 RVO(return value optimization,返回值优化)或 NRVO(named return value optimization,具名返回值优化)。

RVO 意味着编译器可以消除不必要的复制操作。到了 C++17,原本只是可能会做的优化成了一种保证

MyType func(){
    return MyType{};        // C++17 中不会拷贝
}
MyType myType = func();     // C++17 中不会拷贝

这几行中可能会发生两次不必要的拷贝操作:第一次在返回调用中,第二次在函数调用中。C++17 中则不会有拷贝操作发生。如果这个返回值有名字,我们就称这种优化为 NRVO。你大概也已经猜到了。

MyType func(){
    NyType myValue;
    return myValue;         // 允许拷贝一次
}
MyType myType = func();     // 在 C++17 中不会拷贝

这里有一个细微的区别:按照 C++17 编译器仍然可以在返回语句中拷贝值 myValue,但在函数调用的地方则不会发生拷贝。

这里详细聊一下 复制消除 NRVO RVO 吧:

下列环境下,允许但不要求编译器省略类对象的复制和移动 (C++11 起)构造,即使复制/移动 (C++11 起)构造函数和析构函数拥有可观察的副作用。这些对象将直接构造到它们本来要复制/移动到的存储中。这是一项优化:即使进行了优化而不调用复制/移动 (C++11 起)构造函数,它仍然必须存在且可访问(如同完全未发生优化),否则程序非良构

在对象的初始化中,当源对象是无名临时量且与目标对象具有相同类型(忽略 cv 限定)时。当无名临时量为 return 语句的操作数时,称这种复制消除的变体为 RVO,“返回值优化 (return value optimization)”。

C++17起 返回值优化是强制要求的(也就是不再被当成优化),而不再被当做复制消除。

这就是 RVO 的规则,只要满足,那么在 C++17 就不可能有复制开销。

我们看到先前的代码示例,之所以第一个示例在 C++17 都不会有额外拷贝就是因为它

return MyType{}; 满足了:当无名临时量为 return 语句的操作数

MyType myType = func(); 满足了:在对象的初始化中,当源对象是无名临时量且与目标对象具有相同类型(忽略 cv 限定)时

因为 NRVO 的存在,不一定会拷贝,只是不保证而已。

return 语句中,当操作数是拥有自动存储期的非 volatile 对象的名字,该名字不是函数形参或 catch 子句形参,且其具有与函数返回类型相同的类类型(忽略 cv 限定)时。这种复制消除的变体被称为 NRVO,“具名返回值优化 (named return value optimization)”。

函数往往必须返回多于一个值。于是,规则 F.21 来了。

F.21 要返回多个“出”值,优先考虑返回结构体或者多元组

当你向 std::set 中插入一个值时,成员函数 insert 的重载会返回一个 std::pair,它由两部分组成:一个指向所插入元素的迭代器;还有一个 bool,如果插入成功,它会被设置为 true。C++11 中的 std::tie 和 C++17 中的结构化绑定是将两个值绑定到某变量的两种优雅方式。

#include <iostream>
#include <set>
#include <tuple>

int main() {
    std::cout << '\n';

    std::set<int>mySet;

    std::set<int>::iterator iter;
    bool inserted = false;
    std::tie(iter, inserted) = mySet.insert(2011); //(1)
    if (inserted) std::cout << "2011 was inserted successfully\n";

    auto [iter2, inserted2] = mySet.insert(2017);  //(2)
    if (inserted2) std::cout << "2017 was inserted successfully\n";

    std::cout << '\n';
}

在(1)处,我们使用 std::tie 将插入操作的返回值解包到 iter 和 inserted 中。而在(2)处,我们使用结构化绑定将插入操作的返回值解包到 iter2 和 inserted2 中。与结构化绑定相比,std::tie 还需要预先声明的变量。运行结果

2011 was inserted successfully
2017 was inserted successfully

4.3 参数传递:所有权语义

上一节探讨参数的流向:哪些参数是入,哪些参数是入/出或出。但对参数来说,除了流动的方向,还有其他需要考虑的问题。传递参数也事关所有权语义。但本节会介绍 5 种典型的参数传递方式:通过拷贝、通过指针、通过引用、通过 std::unique_ptr 和通过 std::shared_ptr 。

只有通过智能指针传参的相关规则是在本节内新出现的。

通过拷贝传参的规则是 4.2 节“参数传递:入与出” 的一部分。而通过指针和引用传参的规则是 第 3 章的一部分。

表 4.3 参数传递的所有权语义

例子所有权规则
func(value)func 是资源所有者F.16
func(pointer*)func 借用了资源I.11 和 F.7
func(reference&)func 借用了资源I.11 和 F.7
func(std::unique_ptr)func 是资源的独占所有者F.26
func(std::shared_ptr)func 是资源的共享所有者F.27

更多细节如下。

  • 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 是资源的最后一个所有者,那么它的结束会导致资源的释放。

谁是所有者?

务必明确表达出所有权。试想一下,你的程序是用传统 C++ 编写的,只能使用原始指针来表达指针、引用、std::unique_ptr 或 std::shared_ptr 这四种传参方式的所有权语义。

  • 传统 C++ 的关键问题是,谁是所有者?

下面的代码说明了我的观点:

void func(double* ptr){
  ...
}

double* ptr = new double[5];
func(ptr);

关键问题是,谁是资源的所有者?是使用该数组的 func 中的被调用方,还是创建该数组的 func 的调用方?如果 func 是所有者,那么它必须释放该资源。如果不是,则func 不可以释放资源。这种情况不能令人满意。如果 func 不释放资源,可能会发生内存泄露。如果 func 释放了资源,可能会导致未定义行为。

因此,所有权需要记录在文档中。使用现代 C++ 中的类型系统来定义所有权的契约是朝正确方向迈出的一大步,可以消除文档的模糊性。

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

举例来说,若对 std::unique_ptr 应用 std::move,会将内存的所有权转移到另一个 std::unique_ptr。智能指针 uniquePtr1 是原来的所有者,而 uniquePtr2 将成为新的所有者。

auto uniquePtr1 = std::make_unique<int>(2011);
std::unique_ptr<int> uniquePtr2{ std::move(uniquePtr1) };

下面是所有权在实践中的五种变体:

#include <iostream>
#include <memory>
#include <utility>

class MyInt{
public:
    explicit MyInt(int val) :myInt(val) {}
    ~MyInt(){
        std::cout << myInt << '\n';
    }
private:
    int myInt;
};

void funcCopy(MyInt myInt) {}
void funcPtr(MyInt* myInt) {}
void funcRef(MyInt& myInt) {}
void funcUniqPtr(std::unique_ptr<MyInt>myInt) {}
void funcSharedPtr(std::shared_ptr<MyInt>myInt){}

int main(){
    std::cout << '\n';

    std::cout << "=== Begin" << '\n';

    MyInt myInt{ 1998 };
    MyInt* myIntPtr = &myInt;
    MyInt& myIntRef = myInt;
    auto uniqPtr = std::make_unique<MyInt>(2011);
    auto sharedPtr = std::make_shared<MyInt>(2014);

    funcCopy(myInt);
    funcPtr(myIntPtr);
    funcRef(myIntRef);
    funcUniqPtr(std::move(uniqPtr));
    funcSharedPtr(sharedPtr);

    std::cout << "=== End" << '\n';
}

运行结果

=== Begin
1998
2011
=== End
2014
1998

运行结果显示,有两个析构函数在 main 函数结束之前被调用,还有两个析构函数在 main 函数结束的地方被调用。

在 main 函数结束之前析构的是 被拷贝到函数中(funcCopy(myInt)),以及被移动到函数中 (funcUniqPtr(std::move(uniqPtr)))。

  1. 拷贝拷贝了一份新的 MyInt 到函数 func 中,func 结束的时候,自然进行析构,打印 1998
  2. 移动转移了智能指针资源的所有权,所以在 func 结束的时候,RAII 释放了内存,打印 2011
  3. shared_ptr 对象的资源并没有转移,它是共享的,有两个对象共享资源,分别是 main 函数局部的,以及 func 函数中的,所以当 func 结束的时候,只是引用计数减一,不会释放资源。只能等到 main 函数也结束的时候才会析构,释放内存,打印 2014
  4. MyInt myInt 析构,打印 1998。其实你可以注意到,打印了两次 1998,因为第一次析构的是复制到函数中的。

4.4 值返回语义

本节中的 7 条规则与前面提到的规则 “F.20:对于‘出’的输出值,优先使用返回值而非输出参数”相一致。这一节的规则还与一些特殊用例和不建议的做法相关。

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

  • 指针仅用于表示位置

这正是 find 的作用。

Node * find(Node* t, const string& s){
    if(!t || t->name == s)return t;
    if((auto p = find(t->left,s)))return p;
    if((auto p = find(t->right,s)))return p;
    return nullptr;
}

这里指针表示名字与 s 相匹配的 Node 的位置。

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

当不存在“没有返回对象” 这种可能性的时候就可以返回引用而非指针了。

有时你想进行链式操作,但不想为不必要的临时对象进行拷贝和析构。典型的用例是输入和输出流或赋值运算符(“F.47:从赋值运算符返回 T&”)。在下面的代码片段中,通过 T& 返回和通过 T 返回有什么微秒的区别?

A& operator = (const A& rhs) { ... };
A operator = (const A& rhs) { ... };

A a1, a2, a3;
a1 = a2 = a3;

返回拷贝(A)的拷贝赋值运算符会触发两个额外的 A 类型临时对象的创建。

局部对象的引用

返回局部对象的引用(指针)是未定义行为

  • 未定义行为本质上意味着,不要假想程序的行为

先修复未定义行为。程序 lambdaFuncionCapture.cpp 返回了局部对象的引用。

#include <functional>
#include <iostream>
#include <string>

auto makeLambda(){
    const std::string val = "on stack created";
    return [&val] {return val; };   //(2)
}

int main(){
    auto bad = makeLambda();        //(1)
    std::cout << bad();             //(3)
}

main 函数调用函数 makeLambda()(1)。该函数返回一个 Lambda 表达式,它具有对局部变量 val(2)的引用。

调用 bad()(3)导致了未定义行为,因为 Lambda 表达式使用了局部变量 val 的引用。由于它是局部变量,它的生存期随着 makeLambda() 的作用域结束而结束。

执行该程序时会得到无法预知的结果。有时我得到整个字符串,有时得到字符串的一部分,有时只得到 0。

且可能随着 C++ 标准的不同,优化的等级等,而变化。纠结这个结果并没有多少价值,未定义行为本质上意味着,不要假想程序的行为

F.45 不要返回 T&&

以及

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

两条规则都非常严格。

不应当以 T&& 作为返回类型。下面的小例子展示了这个问题。

int&& returnRvalueReference(){
    return int{};
}

int main(){
    auto myInt = returnRvalueReference();
}

在编译时,GCC 编译器会立即抱怨对临时对象的引用。准确地说,临时对象的生存期随着整个表达式 auto myInt = returnRvalueReference(); 的结束而结束。

在函数调用中绑定到函数形参的临时量,存在到含这次函数调用的全表达式结尾为止:如果函数返回一个生命长于全表达式的引用,那么它会成为悬垂引用。

: In function 'int&& returnRvalueReference()':
:2:16: warning: returning reference to temporary [-Wreturn-local-addr]
    2 |     return int{};
      |

std::move(本地变量)

由于 RVO 和 NRVO 的拷贝消除,return std::move(本地变量)的使用不是优化而是劣化。劣化意味着程序可能会变得更慢

std::move(本地变量)的确毫无意义,但是 return std::move(expr) 不是,可以看之前的文章

F.46 main() 的返回类型是 int

依照 C++ 标准,main 函数体有两种变体:

int main() { ... }

int main(int argc, char** argv){ ... }

第二个版本等效于 int main(int argc, char* argv[]){ ... }

main 函数并不需要返回语句。如果控制流抵达 main 函数的末尾而没有碰到一条返回语句,其效果相当于 return 0;。return 0 意味着程序成功执行。

这里最前面说的 依照 C++ 标准。 我们都知道 main 函数的形式远不止这些,但那些都是编译器的扩展,我们用 msvc 举几个例子。

int wmain();
int wmain(int argc, wchar_t *argv[]);

参见 msvc 文档

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

这条规则说明了 Lambda 表达式的使用场合。这立刻引出了问题:

  • 什么时候必须用 Lambda 表达式?
  • 什么时候必须用普通函数?

这里有两条明显的理由。

  • 如果可调用实体必须捕获局部变量,或者它是在局部作用域内声明的,你就必须使用 Lambda 表达式
  • 如果可调用实体需要支持重载,那么应使用普通函数

现在我想亮出我对 Lambda 表达式的关键论点,它们经常会被忽视。

表达力

”明确优于隐晦“这条来自 Python(PEP 20 —— 《Python 之阐》)的元规则也适用于 C++。它意味着代码应该明确地表达其意图(见规则”P.1:在代码中直接表达思想“)。 当然,这对 Lambda 表达式来说尤其正确。

std::vector<std::string> myStrVec = { "523345","4336893456","7234",
                                    "564","199","433","2435345" };

std::sort(myStrVec.begin(), myStrVec.end(),
    [](const std::string& f, const std::string& s){
        return f.size() < s.size();
    }
);
std::vector<std::string> myStrVec = { "523345","4336893456","7234",
                                        "564","199","433","2435345" };

bool lessLength(const std::string& f,const std::string& s){
    return f.size() < s.size();
}

std::sort(myStrVec.begin(), myStrVec.end(),lessLength);

Lambda 表达式和函数都为排序算法提供了相同的顺序谓词。如果你不相信他们,你就必须分析其实现。也许这并不可能,因为你只有函数的声明。有了 Lambda 表达式,你的同事无法欺骗你。代码就是真相。让我更挑衅的说:

  • 你的代码的表达能力应该强到不需要文档\

表达能力与不要重复自己
“用 Lambda 编写表达力丰富的代码”这条设计规则往往与另一条重要的设计规则相矛盾:不要重复自己(don’t repeat yourself, DRY)。DRY 意味着你不应该多次编写相同的代码。编写一个可重复使用的单元,如一个函数,并给它指定一个不言自明的名称,是对 DRY 的合适补救。最终,必须在具体的案例中决定是否把表达力看得比 DRY 更重要。

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

以及

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

这两条规则高度关联,它们可以归结为:Lambda 表达式应该只对有效数据进行操作

  • 当 Lambda 通过拷贝捕获数据时,根据定义,数据总是有效的。
  • 当 Lambda 通过引用捕获数据时,数据的生存期必须超过 Lambda 的生存期。

前面局部对象引用的例子就展示了 Lambda 引用无效数据时的各种问题。

有时问题还不那么容易发现。

int main() {
    std::string str{"C++11"};

    std::thread thr{[&str] { std::cout << str << '\n'; }};
    thr.detach();
}

在新创建的线程 thr 中使用的 Lambda 表达式通过引用捕获了变量 str。之后,thr 从其创建者(即主线程)的生存期中分离出来。因此,不能保证创建的线程 thr 使用的是有效的字符串 str,因为 str 的生存期与主线程的生存期绑定了。

可以采用一个直截了断的方法来解决这个问题。通过拷贝捕获 str。

int main() {
    std::string str{"C++11"};

    std::thread thr{[str] { std::cout << str << '\n'; }};
    thr.detach();
}

问题解决了吗?没有!关键的问题是:

  • 谁是 std::cout 的所有者?

std::cout 的生存期与进程的生存期绑定。这意味着,在屏幕上打印出“C++11”之前,std::cout 对象可能已经消失了。解决这个问题的方法是汇合(join)线程 thr。这种情况下,创建者会等待,直到被创建者完全任务,因此,通过引用捕获也就可以了。

int main() {
    std::string str{"C++11"};

    std::thread thr{[&str] { std::cout << str << '\n'; }};
    thr.join();
}

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

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

void print(const string& s, format f = {});

若要使用重载实现相同的功能,则需要两个函数:

void print(const string& s);
void print(const string& s, format f);

F.55 不要使用 va_arg 参数

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

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

std::printf 不是类型安全的函数,很多东西理论上根本无法检测,但是因为用的太多了,大部分编译器都给它开洞检查类型之类的玩意了。

为了理解变参函数的隐含危险,请看下面的小例子。

#include <iostream>
#include <cstdarg>

int sum(int num, ...){
    int sum = 0;
    va_list argPointer;
    va_start(argPointer, num);
    for(int i = 0;i<num;++i)
        sum += va_arg(argPointer, int);
    va_end(argPointer);

    return sum;
}

int main(){
    std::cout << "sum(1, 5): " << sum(1,5) << '\n';
    std::cout << "sum(3, 1, 2, 3): " << sum(3, 1, 2, 3) << '\n';
    std::cout << "sum(3, 1, 2, 3, 4): " << sum(3, 1, 2, 3, 4) << '\n';    //(1)
    std::cout << "sum(3, 1, 2, 3.5): " << sum(3, 1, 2, 3.5) << '\n';      //(2)
}

sum 是一个变参数函数。它的第一个参数是需要被求和的参数个数。以下是关于 va_arg 宏的背景信息,有助于理解该代码。

  • va_list: 保存下列宏的必要信息。
  • va_start: 启用对变参函数参数的访问。
  • va_arg: 访问下一个变参函数的参数。
  • va_end: 结束对变参函数参数的访问。

请阅读 http://cppreference.com 中关于变参数函数的部分来获取进一步的信息。

代码行(1)和(2)中出了些状况。(1)中参数 num 的数量是错的;(2)中我提供了一个 double 而不是一个int。输出结果显示了这两个问题。(1)中的最后一个元素丢失了,而 double 被解释为 int(2)。

sum(1, 5): 5
sum(3, 1, 2, 3): 6
sum(3, 1, 2, 3, 4): 6
sum(3, 1, 2, 3.5): 539767595

注意,最后一个输出结果并不一定,我们只是按照书上的结果写了。经过实测的话,msvc 和 mingw clang 都是 6,gcc 是 3075

这些问题可以通过 C++17 的折叠表达式轻松解决。跟 va_arg 相比,折叠表达式会自动推导出其参数的数量和类型。

#include <iostream>

template<class...Args>
auto sum(Args...args) {
    return (... + args);
}

int main() {
    std::cout << "sum(5): " << sum(5) << '\n';
    std::cout << "sum(1, 2, 3): " << sum(1, 2, 3) << '\n';
    std::cout << "sum(1, 2, 3, 4): " << sum(1, 2, 3, 4) << '\n';
    std::cout << "sum(1, 2, 3.5): " << sum(1, 2, 3.5) << '\n';
}

函数 sum 可能看起来挺可怕,它需要至少一个参数,并使用 C++11 的变参模板。变参模板可以接受任意数量的参数。这些任意数量的参数由所谓的参数包持有,用省略号(…)表示。此外,在 C++17 中,可以用二元运算符直接对参数包进行归约。这一针对变参模板的增强被称为折叠表达式。在 sum 函数的例子中,应用了二元的 + 运算符(… + args)。想要了解 C++17 折叠表达式的更多信息可参阅 https://zh.cppreference.com/w/cpp/language/fold 了解更多细节。

程序的输出正如预期:

sum(5): 5
sum(1, 2, 3): 6
sum(1, 2, 3, 4): 10
sum(1, 2, 3.5): 6.5

4.6 相关规则

关于 Lambda 表达式的另一条规则在第 8 章中——“SE.28”:使用 Lambda 表达式进行复杂的初始化(尤其是对 const 变量)。

我在本章中跳过了 C++20 特性 std::span。我会在第 7 章中提供关于 std::span 的基本信息。


本章精华

重要

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

类和类的层次结构

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

C++ Core Guidelines 中大约有100条关于用户定义类型的规则。

Guidelines 先给出了一些概要规则,然后深入讨论了下面的特殊规则:

  • 具体类型
  • 构造函数、赋值和析构函数
  • 类的层次结构
  • 重载和运算符重载
  • 联合体

下面的 8 条概要规则为特殊规则提供了背景。

5.1 概要规则

概要规则相当简短,没有涉及太多细节。它们对类概括提供了有价值的深刻见解。

class(类)和struct(结构体)之间的语法差异 本节经常提到类和结构体之间的语义区别。首先。语法上的差异是什么?差异很小,但很重要: - 在结构体中,所有成员默认为 public(公开);类为(private)私有。 - 继承情况也是如此。结构体默认继承权限为 public,类为 private。

除此之外,二者在语言语法层面完全一致

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

如何改进 draw 的接口?

void draw(int fromX, int fromY, int toX, int toY);

不明显的是,这些 int 代表了什么。因此,调用函数的时候参数顺序可能会出错。可以对比一下上面的 draw 和下面的新函数:

void draw(Point from, Point to);

通过将相关元素放在结构体中,函数签名变得可以自我描述,因此,比起之前的函数,新函数更不容易出错。

类对象的构造函数也可以用来检测参数的合法性,不过这里的 Point 类型倒是没啥好检测的了。

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

不变式(Invariant)是一个在程序执行过程中永远保持成立的条件。不变式在检测程序是否正确方面非常有用。例如编译器优化就用到了不变式。

类的不变式是用于约束类的实例的不变式。成员函数必须使这个不变式保持成立。 不变式约束了类的实例的可能取值。

这是 C++ 中一个常见的问题:什么时候该使用 class,什么时候该用 struct?

C++ Core Guidelines 给出了以下建议。如果类有不变式,就使用 class

如果类有一个需要在程序执行过程中永远保持成立的条件,就使用 class

一个可能的类的不变式是,(y,m,d)可表示一个有效的日期。

struct Pair{  //成员可以独立变化
    string name;
    int volume;
};

class Date{
public:
    //校验 {yy,mm,dd}是不是合法的日期并进行初始化
    Date(int yy, Month mm, char dd);
    // ...
private:
    int y;
    Month m;
    char d;    //日
}

类的不变式在构造函数中被初始化和检查。数据类型 Pair 没有不变式,因为名称(name)和体积(volume)的所有值都是有效的。Pair 是简单的数据持有者,不需要显式提供构造函数。

值得一提的是,很多库并没有很好的遵守,我们举例 QPoint源码

QPoint 显然是没有不变式,它的成员(xp,yp)所有的值都是有效的,但它依旧使用的是 class。 以及,它没有将它的数据成员设置为 public,反而提供了愚蠢的 6 个成员函数进行访问:rx,ry,x,y,setX,setY

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

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

类的公开成员函数是类的接口,私有部分则是实现。
class Date{
public:
    Date();
    //校验 {yy,mm,dd}是不是合法的日期并进行初始化
    Date(int yy, Month mm, char dd);
    int day()const;
    Month month()const;
    // ...
private:
    // ... 具体的内部表示
};

从可维护性的角度看,可以修改 Date 类的实现,而毫不影响该类的使用者。

就是说面向对象的封装,数据和操作数据的方法(即类的接口)捆绑在一起,并对外部隐藏对象的内部状态。这样可以确保类的接口有效性和不变性。

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

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

一个函数不修改类的私有数据成员,它就不该是成员

class Date{
    // ... 相对小的接口 ...
};

//辅助函数
Date next_weekday(Date);
bool operator == (Date, Date);

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

事实上这条规则没那么好遵守,有非常多的额外情况,英文原书和我们当前描写的都太少了。可以看英文文档,或国人翻译

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

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

namespace Chrono{ // 在这里放置跟时间有关的服务
    class Date { /* ... */ };

    // 辅助函数:
    bool operator == (Date, Date);
    Date next_weekday(Date);
    // ...
}
...
if (date1 == date2) { ... //(1)

由于有实参依赖查找(argument-dependent lookup,ADL),比较 date1 == date2 将额外查找 Chrono 命名空间中的相等运算符。ADL 对于重载的运算符尤其重要,如输出运算符<<。

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

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

// 不好
struct Date { /*...*/ } date { /*...*/ };

// 好
struct Date{ /*...*/ };
Date date{ /*...*/ };

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

明确某事被隐藏/抽象。这是一个有用的约定。

除此之外可能还有很多乱七八糟的理由,但总而言之,就是这样,约定

C.9 尽量减少成员的暴露

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

封装。信息隐藏。最大限度地减少意外访问的机会。这简化了维护。 需要注意的是,也不要什么成员都给封装了,这样会走上 java 的邪路,getset

5.2 具体类型

本节只有两条规则,但引入了具体类型和规范类型这两个术语。 根据 C++ Core Guidelines:

具体类型是“最简单的一种类”。它常常被称作值类型,不属于某个类型层次结构的一部分 。

规范类型是一种“行为类似于 int”的类型,因此,它必须支持拷贝和赋值、相等比较,以及可交换。更正式的说法是,一个规范类型 X 行为上像 int,支持下列操作。

  • 默认构造:X()
  • 拷贝构造:X(const X&)
  • 拷贝赋值:operator = (const X&)
  • 移动构造:X(X&&)
  • 移动赋值:operator = (X&&)
  • 析构:~X()
  • 交换操作:swap(X&, X&)
  • 相等运算符:operator ==(const X&, const X&)

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

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

长话短说:应用 KISS 原则(“keep it simple,stupid”原则,保持简单,让傻瓜都能理解)。你的类型行为像普通数值一样。

C.11 让具体类型规范化

规范类型(如 int)易于理解,它们本身就很直观。这意味着:

  • 如果你有一个具体类型,可以考虑将它升级为规范类型。

内置类型(如 int 或 double)是规范类型,而用户定义类型(如 std::string)或容器(std::vector 或 std::unordered_map)也是如此。

C++20 支持 regular (规范)概念。

5.3 构造函数、赋值运算符和析构函数

这一节讨论构造函数、赋值运算符和析构函数,在本章范围内,此类规则的数量是目前为止最多的。它们控制着对象的生命周期:创建、拷贝、移动和销毁。简而言之,我们把它们称为“六大”。下面是这六个特殊的成员函数。

  • 默认构造函数:X()
  • 拷贝构造函数:X(const X&)
  • 拷贝赋值运算符:operator = (const X&)
  • 移动构造:X(X&&)
  • 移动赋值运算符:operator = (X&&)
  • 析构函数:~X()

编译器可以为这“六大”生成默认实现。本节从有关默认操作的规则开始;接着是有关构造函数、拷贝和移动操作以及析构函数的规则;最后是不属于前四类的其他默认操作的规则。

根据默认构造函数的声明,你可能有这样的印象:默认构造函数不需要参数。这是不对的。默认构造函数可以在没有参数的情况下被调用,但它可能每个参数都有默认值。

预置操作

默认情况下,如果需要,编译器可以生成“六大”。可以定义这六个特殊的成员函数,但也可明确用 = default(预置)来要求编译器提供它们,或者用 = delete(弃置)来删除它们。

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

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

class Named_map{
public:
    // ... 没有声明任何默认操作 ...
private:
    std::string name;
    std::map<int, int> rep;
};

Named_map mm;        // 默认构造
Named_map nm2 {nm};  // 拷贝构造

默认构造和拷贝构造之所以有效,是因为 std::string 和 std::map 已经定义了相应的操作。

  • 编译器所自动生成的拷贝构造函数会调用当前类所有成员的拷贝构造函数

特殊成员函数都是这样,不局限于拷贝构造,这个其实以前在 P.9 提起过。(另外强调一下,这些话全说的是类类型)

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

“六大”是紧密相关的。由于这种关系,你应该对所有特殊成员函数进行定义或 =delete。因此,这条规则被称为“六法则”。有时你会听到“五法则”,这是因为默认构造函数很特殊,有时会被排除在外

默认 构造函数析构函数拷贝 构造函数拷贝赋值移动 构造函数移动赋值
全部不声明预置预置预置预置预置预置
任意构造函数不声明预置预置预置预置预置
默认构造函数用户声明预置预置预置预置预置
析构函数预置用户声明预置预置不声明不声明
拷贝构造函数不声明预置用户声明预置不声明不声明
拷贝赋值预置预置预置用户声明不声明不声明
移动构造函数不声明预置弃置弃置用户声明不声明
移动赋值预置预置弃置弃置不声明用户声明
  • 当你定义*任何构造函数*时,默认构造函数就没有了。默认构造函数是可以在没有参数的情况下调用的构造函数
  • 当你用 =default 或 =delete 定义或删除*默认构造函数*时,其他特殊成员函数都不受影响
  • 当你用 =default 或 =delete 定义或删除析构函数拷贝构造函数拷贝赋值操作符时,编译器不会生成移动构造函数和移动赋值运算符。这意味着移动构造或移动赋值这样的移动操作会回退到拷贝构造或拷贝赋值。这种回退的自动操作在表格中以深色标出。
  • 当用 =default 或 =delete 定义或删除移动构造函数移动赋值运算符时,只能得到定义的 =default 或 =delete 的移动构造函数或移动赋值运算符。后果是,拷贝构造函数和拷贝赋值运算符被设置为 =delete[^2]。因此调用一个拷贝操作,如拷贝构造或拷贝赋值,将导致编译错误。

当你不遵循这条规则时,你会得到非常不直观的对象。下面是 Guidelines 中的一个直观的例子。

#include <cstddef>

class BitArray{
public:
    BitArray(std::size_t len) :len_(len), data_(new int[len]) {}
    ~BitArray(){
        delete[] data_;
    }

private:
    std::size_t len_;
    int* data_;
};

int main(){
    BitArray bitArray1(1000);

    BitArray bitArray2(1000);

    bitArray2 = bitArray1;      //(1)
}                               //(2)

为什么这个程序有未定义行为?例子中默认的拷贝赋值操作 bitArray2 = bitArray1(1)拷贝了 bigArray2 的所有成员。拷贝意味着,在目前情况下,被拷贝的是 data 指针,而不是其指向的数据。因此,bigArray1 和 bigArray2 的析构函数被调用(2),由于重复释放,我们得到了未定义行为。

这个例子中不直观的行为是,编译器生成的 BigArray 的拷贝赋值操作符对 BigArray 进行了浅拷贝,但是 BigArray 的显式实现的析构函数假设了数据的所有权。

运行效果

double free or corruption (!prev)
Program terminated with signal: SIGSEGV

C.22 让默认操作保持一致

默认操作是一个概念上相配合的集合。它们的语义是相互关联的。

  • 如果复制/移动构造和复制/移动赋值所做的是逻辑上不同的事情的话,这会让使用者感觉诡异。
  • 如果构造函数和析构函数并不提供一种对资源管理的统一视角的话,也会让使用者感觉诡异。
  • 如果复制和移动操作并不体现出构造函数和析构函数的工作方式的话,同样会让使用者感觉诡异。

示例,不好

class Silly {   // 不好: 复制操作不一致
    class Impl {
        // ...
    };
    shared_ptr<Impl> p;
public:
    Silly(const Silly& a) : p{make_shared<Impl>()} { *p = *a.p; }   // 深复制
    Silly& operator=(const Silly& a) { p = a.p; return *this; }     // 浅复制
    // ...
};

这些操作在复制语义上并不统一。这将会导致混乱和出现 BUG。

强制实施

  • 【复杂】 复制/移动构造函数和对应的复制/移动赋值运算符,应当在相同的解引用层次上向相同的成员变量进行写入。
  • 【复杂】 在复制/移动构造函数中被写入的任何成员变量,在其他构造函数中也都应当进行初始化。
  • 【复杂】 如果复制/移动构造函数对某个成员变量进行了深复制,就应当在析构函数中对这个成员变量进行修改。
  • 【复杂】 如果析构函数修改了某个成员变量,在任何复制/移动构造函数或赋值运算符中就都应当对该成员变量进行写入。

C.dtor: 析构函数

“这个类需要析构函数吗?” 这是一个令人惊讶的富有洞察力的设计问题。对于大多数类,答案是“否”,因为该类没有资源,或者因为销毁是按零规则4 处理的;也就是说,其成员可以在销毁方面自行解决。如果答案是“是”,则该类的大部分设计都会遵循(请参阅五规则5)。

构造函数

有 13 条规则涉及对象的构造。粗略来说,它们分为 5 类。

  • 构造函数通用
  • 默认构造函数
  • 单参数构造函数
  • 成员初始化
  • 特殊构造函数,如继承或委托构造函数

最后,我需要警告一下。不要从委托构造函数中调用虚函数。在本章后面的“其他默认操作”一节中,我将在包括析构函数的更广泛的背景下提到这个警告。

构造函数通用

我跳过了规则 “C.40: 如果类有不变式,就定义构造函数”,因为我已经在“C.2: 当类具有不变式时使用 class;如果数据成员可以独立变化,则使用 struct”这条规则中写到了相关内容。因此,还剩下两条密切相关的规则:

“C.41: 构造函数应当创建完全初始化的对象”和“C.42: 如果构造函数无法构造出有效对象,则应抛出异常”。

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

构造函数的职责就是创建完全初始化的对象。类不应有 init(初始化)成员函数,不然就是自找麻烦。

class DiskFile{
    FILE* f;
    // ...
public:
    DiskFile() = default;
    void init();        // 初始化 f
    void read();        // 从 f 读取
    // ...
};

int main(){
    DiskFile file;
    file.read();     // 崩溃,或错误读取!
    file.init();     // 太晚了
    // ...
}

用户可能会错误地在 init 之前调用 read,或者只是忘了调用 init。将成员函数 init 设为私有,并从所有构造函数中调用它,这样做好一些,但仍不是最佳选择。当一个类的所有构造函数有共同的操作时,请使用委托构造函数

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

根据前面的规则

  • 如果不能构造出有效的对象,那就该抛异常。

没有太多可补充的东西。如果使用无效的对象,你就总得在使用之前检查对象的状态。这样非常繁琐、低效且容易出错。例子:

class DiskFile{
    FILE* f;
    bool valid;
    // ...
public:
    explicit DiskFile(const std::string& name) :f{ fopen(name.c_str(),"r") }, valid{ false }{
        if (f)valid = true;
        // ...
    }
    bool is_valid()const { return valid; }
    void read();        // 从 f 读取
    // ...
};

int main(){
    DiskFile file{ "Heraclides" };
    file.read();    // 崩溃,或读取错误!
    // ...
    if(file.is_valid()){
        file.read();
        // ...
    }
    else{
        // ...处理错误...
    }
    // ...
}

默认构造函数

接下来的两条规则回答了这个问题:一个类什么时候需要默认构造函数,什么时候不需要默认构造函数?

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

不正式地说,当类的实例缺少有意义的默认值时,该类就不需要默认构造函数。例如,“人”没有有意义的默认值,但是像“银行账户”这样的类型则有。银行账户的初始值可能是零。拥有默认的构造函数,可以使你的类型更容易使用。STL 容器的许多构造函数都要求你的类型有默认构造函数——例如,有序的关联容器(如 std::map)里的值。如果类的所有成员都有默认构造函数,编译器会尽可能为你的类生成默认构造函数

现在说说不应该提供默认构造函数的情况。

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

代码常常胜过千言万语。

#include <iostream>
#include <functional>

class Widget {
public:
    Widget() :width(640), height(480), frame(false), visible(true) {}
    explicit Widget(int w) :width(w), height(getHeight(w)), frame(false), visible(true) {}
    Widget(int w, int h) :width(w), height(h), frame(false), visible(true) {}

    void show()const {
        std::cout << std::boolalpha << width << "x" << height
            << ", frame: " << frame
            << ", visible: " << visible << '\n';
    }
private:
    int getHeight(int w) { return w * 3 / 4; }
    int width;
    int height;
    bool frame;
    bool visible;
};

class WidgetImpro {
public:
    WidgetImpro() = default;
    explicit WidgetImpro(int w) :width(w), height(getHeight(w)) {}
    WidgetImpro(int w, int h) :width(w), height(h) {}

    void show()const{
        std::cout << std::boolalpha << width << "x" << height
            << ", frame: " << frame
            << ", visible: " << visible << '\n';
    }
private:
    int getHeight(int w) { return w * 3 / 4; }
    int width{ 640 };
    int height{ 480 };
    bool frame{ false };
    bool visible{ true };
};

int main(){
    std::cout << '\n';

    Widget wVGA;
    Widget wSVGA(800);
    Widget wHD(1280, 720);

    wVGA.show();
    wSVGA.show();
    wHD.show();

    std::cout << '\n';

    WidgetImpro wImproVGA;
    WidgetImpro wImproSVGA(800);
    WidgetImpro wImproHD(1280, 720);

    wImproVGA.show();
    wImproSVGA.show();
    wImproHD.show();
}

类 Widget 仅使用它的三个构造函数来初始化成员。重构后的 WidgetImpro 类直接在类内部初始化其成员,通过将初始化从构造函数移进类的主体,三个构造函数,变得更加容易理解,类也更容易维护。例如,当你在类中添加新成员时,你只需要在类的主体中添加初始化,而不必在所有的构造函数中添加。此外,你也不需要考虑将初始化器按正确的顺序放在构造函数中了。这样,当创建新对象时,也不可能发生对象只是部分初始化的情况了。

当然,这两个对象的行为是相同的。

运行结果

640x480, frame: false, visible: true
800x600, frame: false, visible: true
1280x720, frame: false, visible: true

640x480, frame: false, visible: true
800x600, frame: false, visible: true
1280x720, frame: false, visible: true

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

你是否注意到了前面那个只有一个参数的构造函数中的关键字 explicit

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

说得更明确一点,一个没有 explicit 的单参数的构造函数是个转换构造函数。转换构造函数接受一个参数,并从该参数中生成该类的一个对象。这种行为会让人大吃一惊

C++11 后:不以说明符 explicit 声明的构造函数被称为转换构造函数(converting constructor)。

class String {
public:
    String(int);   // BAD
    // ...
};

String s = 10;   // 惊喜: 大小为 10 的String

简单的说就是,10 会调用转换构造函数,构造出一个临时的 String 对象,然后再初始化 s,即 String s(String(10)),通常我们应该避免这种行为,比如使用 explicit。

下面是函数传参的形式,其实意思是一样的:

class String {
public:
    String(int);   // BAD
    // ...
};

void f(const String&);

f(10);             // 相当于 f(String(10))

如果你确实想要从构造函数参数类型到类类型的隐式转换,请不要使用 explicit。

class Complex {
public:
    Complex(double d);   // OK: 我们想要 double 到当前类的转换
    // ...
};

Complex z = 10.7;   // 奇怪的转换

类和类的层次结构-2

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

类成员是按照它们的声明顺序进行初始化的。如果你在成员初始化列表以不同的顺序初始化它们,你可能会大吃一惊。

#include <iostream>

class Foo{
    int m1;
    int m2;
public:
    Foo(int x) :m2{ x }, m1{ ++x }{ // 糟糕:初始化顺序会让人误解
        std::cout << "m1:" << m1 << '\n';
        std::cout << "m2:" << m2 << '\n';
    }
};

int main(){
    std::cout << '\n';
    Foo foo(1);
    std::cout << '\n';
}

运行结果:

m1:2
m2:2

许多人认为,首先是 m2 被初始化,然后是 m1。这样 m2 会得到 1,而 m1 会得到 2。

  • 列表中的成员初始化器的顺序和初始化顺序是不相关的

实际的初始化规则远不止如此,参见文档

C.48 在使用常量来初始化时,优先选择默认成员初始化器,而不是构造函数的成员初始化

这条规则有点类似于之前的规则“C.45:不要定义仅初始化数据成员的默认构造函数,而应使用成员初始化器”。默认成员初始化器使你能更容易地定义构造函数。此外,你也不会忘记初始化某个成员了。

class X{
    int i;                          // 不好
    std::string s;
    int j;
public:
    X() :i{ 666 }, s{ "qqq" } {}    // j 没有初始化
    explicit X(int ii) :i{ ii } {}  // s 是 "",而 j 没有初始化
    // ...
};

class X2{
    int i{ 0 };
    std::string s{"qqq"};
    int j{ 0 };
public:
    X2() = default;                 // 所有成员都被初始化成默认值
    explicit X2(int ii) :i(ii) {}   // s 和 j 被初始化为默认值
};
  • 虽然默认成员初始化规定了一个对象的默认行为,但构造函数可以改变这一默认行为

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

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

class Bad{
    string s1;
public:
    Bad(const std::string& s2) { s = s2; }    // 不好:先默认初始化再赋值
    // ...
};

特殊构造函数

从 C++11 开始,一个构造函数可把它的工作委托给同一个类的另一个构造函数,并且构造函数可以从父类继承。这两种技术都允许程序员编写更简洁、更具有表达力的代码。

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

一个构造函数可以把它的工作委托给同一类的另一个构造函数。委托是 C++ 中把所有构造函数的共同动作放到一个构造函数中的现代方式。在 C++11 之前,必须使用一个特殊的初始化函数,它通常被称为 init。

class Degree {
public:
    explicit Degree(int deg) {          //(1)
        degree = deg % 360;
        if (degree < 0)degree += 360;
    }
    Degree() :Degree(0) {}             //(2)
    explicit Degree(double deg) :Degree(static_cast<int>(std::ceil(deg))) { }       //(3)
private:
    int degree;
};

Degree 类的构造函数(2)和(3)将其初始化工作委托给构造函数(1),后者验证其参数。注意,递归调用构造函数是未定义行为。

一个简化的实现在类中初始化 Degree,并使用预置的默认构造函数。

class Degree {
public:
    explicit Degree(int deg) {                              //(1)
        degree = deg % 360;
        if (degree < 0)degree += 360;
    }

    Degree() = default;                                     //(2)

    explicit Degree(double deg) :                           //(3)
        Degree(static_cast<int>(std::ceil(deg))) { }        
private:
    int degree = 0;
};
int main(){
    std::cout << std::ceil(-2.1);
}

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

如果可以的话,在派生类中重用基类的构造函数。当派生类没有成员时,这种重用的想法很合适。如果在可重用构造函数时不用,你就违反了 DRY(don’t repeat yourself 不要重复自己)原则。

继承的构造函数保留了它们在基类中定义的所有特性,如访问说明符,或属性 explicit 和 constexpr。

class Rec{
public:
    Rec(std::string,int){}
    // ... 数据和很多漂亮的构造函数 ...
};

class Oper : public Rec {
    using Rec::Rec;
    // ... 没有数据成员 ...
    // ... 很多漂亮的工具函数
};

struct Rec2 : public Rec{
    int x;
    using Rec::Rec;
};

Rec2 r{ "foo",7 };

int val = r.x;  // r.x 没有初始化

如果 using 声明指代了正在定义的类的某个直接基类的构造函数(例如 using Base::Base;),那么在初始化派生类时,该基类的所有构造函数(忽略成员访问)均对重载决议可见。见文档

使用继承构造函数时会遇到一个危险。如果你的派生类(如 Rec2)有自己的成员,如 int x,它们不会被初始化,除非它们有类内初始化器(见“C.48: 在使用常量初始化时,优先选择类内初始化器,而不是构造函数的成员初始化”)。

拷贝和移动

尽管 C++CoreGuidelines 有八条关于拷贝和移动的规则,它们可以归结为三类规则:拷贝和移动赋值操作,拷贝和移动语义,还有臭名昭著的分片问题。

赋值

语法

“C.60:使用拷贝赋值非 virtual 以 const& 传参,并返回非 const 的引用” 和 “C.63:使移动赋值非 virtual 以 && 传参,并返回非 const 的引用”这两条规则明确说明了拷贝和移动赋值运算符的语法。std::vector 遵循建议的语法。下面是一个简化版本:

// 拷贝赋值
vector& operator = (const vector& other);

// 移动赋值
vector& operator = (vector&& other);            // C++17 前
vector& operator = (vector&& other) noexcept;   // C++17 起

这一小片段代码显示了,移动赋值运算符是 noexcept。在 C++17 中,这条规则非常明显——“C.66:使移动操作 noexcept”。移动操作包括移动构造和移动赋值运算符。一个 noexcept 声明的函数对编译器来说是个优化机会。下面的代码片段显示了 std::vector 的移动操作和声明。

vector(vector&& other) noexcept;                // C++17 起
vector& operator = (vector&& other) noexcept;   // C++17 起

这一小片段代码显示了,移动赋值运算符是 noexcept。在 C++17 中,这条规则非常明显——“C.66:使移动操作 noexcept”。移动操作包括移动构造函数和移动赋值运算符。一个 noexcept 声明的函数对编译器来说是个优化机会。下面的代码片段展示了 std::vector 的移动操作的声明。

vector(vector&& other) noexcept;                // C++17 起
vector& operator = (vector&& other) noexcept;   // C++17 起

自赋值

“C.62:使拷贝赋值对自赋值安全”和“C.65:使移动赋值对自赋值安全”这两条规则都涉及自赋值。自赋值安全意味着操作 x = x 不应该改变 x 的值。

对于 STL 容器、std::string 和内置类型,如 int 等,拷贝/移动赋值对于自赋值是安全的。自动生成的拷贝/移动赋值运算符对于自赋值也是安全的。

下面的类 Foo 行为正确,自赋值是安全的。

class Foo {
    std::string s;
    int i;
public:
    Foo& Foo::operator = (const Foo& a) {
        s = a.s;
        i = a.i;
        return *this;
    }
    Foo& Foo::operator = (Foo&& a) noexcept{
        s = std::move(a.s);
        i = a.i;
        return *this;
    }
    // ...
};

在这种情况下,任何多余、高开销的自赋值检查都会不必要地让性能变差。

class Foo {
    std::string s;
    int i;
public:
    Foo& Foo::operator = (const Foo& a) {
        if (this == &a)return *this;        // 多余的自赋值检查
        s = a.s;
        i = a.i;
        return *this;
    }
    Foo& Foo::operator = (Foo&& a) noexcept {
        if (this == &a)return *this;       // 多余的自赋值检查
        s = std::move(a.s);
        i = a.i;
        return *this;
    }
    // ...
};

语义

本节的两条规则听起来很明显:“C.61:拷贝操作应该进行拷贝”和“C.64:移动操作应该进行移动,并使源对象处于有效状态”。那么是什么意思呢?

  • 拷贝操作
    • 在拷贝之后(a = b),a 和 b 必须相同(a == b)。
    • 拷贝可深可浅。深拷贝意味着对象 a 和 b 之后是相互独立的(值语义)。
  • 移动操作
    • C++ 标准要求被移动的对象之后必须处于一个未指定但有效的状态。通常情况下,这个被移动的状态是移动操作源对象的默认状态。

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

这条规则听起来无伤大雅,但往往是未定义行为的起因。首先,什么是多态类? 多态类是定义或继承了至少一个虚函数的类。 拷贝一个多态类的操作可能会以切片而告终。切片是 C++ 中最黑暗的部分之一。

切片

切片意味着你想要在赋值或初始化过程中拷贝一个对象,但你只得到该对象的一部分。我们给出一个简单的例子:

struct Base{
    int base{ 1998 };
};

struct Dervied :Base {
    int derived{ 2011 };
};

void needB(Base b){
    // ...
}

int main(){
    Dervied d;
    Base b = d;     // (1)
    Base b2(d);     // (2)
    needB(d);       // (3)
}

文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
 上一篇
Effective STL-STL使用经验 Effective STL-STL使用经验
前言基于接口和实现分离的原则,了解STL的接口和STL的实现。 第 1 条:慎重选择容器类型 标准 STL 序列容器:vector、string、deque、list。 标准 STL 关联容器:set、multiset、map、multim
2023-12-12
下一篇 
git如何更新新的个人令牌Tokens git如何更新新的个人令牌Tokens
前言2021年8月13日开始,GitHub在使用Git操作远程仓库时不再支持使用用户名密码形式进行身份验证,需使用个人访问令牌。使用Git操作GitHub仓库时使用用户名密码进行身份验证会报错: 解决方法使用token登录的方式,就是把你
2023-10-09
  目录