Skip to content

理念

理念性规则概览:

理念性规则强调一般性,因此,无法进行检查。不过,理念性规则为下面的具体规则提供了理论依据。一共有 13 条理念性规则。

如果觉得没听懂不用在意,的确有点抽象,可以简单理解为一般情况下的编码规范。

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

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

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

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

根据我的一般经验,起码有一半的 C++ 开发者不懂成员函数修饰 const 是为什么,能做什么。只是默认,不修改当前类的数据成员就要加 const,明确语义,增加可读性。 但,不够正确,也远不止如此

cpp
#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 函数根本没有修改对象的数据,所以这其实是开发者的问题,不应该写出这种代码。

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

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

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

cpp
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 修饰,不在于这个成员函数到底是否会修改自己的成员,而在于 “不变性”。

const 修饰本身就代表存在不变的暗示。

以上规则应当是所有库作者、接口设计者、接口使用者,都清楚的基本常识

另外,应当注意特殊情况,比如使用了 mutable 关键字修饰的成员,经典的莫过于:

cpp
struct widget{
    mutable std::mutex m;
    // todo..

    void go()const{
        std::lock_guard<std::mutex>lc{m};
        // todo..
    }
};
  • “M&M 规则”:mutable 与 mutex 一起出现

如果不以 mutable 修饰 std::mutex,那么这段代码无法通过编译。

因为 std::lock_guard 的构造和析构要调用 m 的 lockunlock 成员函数,它们都是非 const 修饰的,我们前面也说了:const 的对象无法调用非 const 的成员函数。 在 const 成员函数中,你也可以简单理解为成员就是以 const 修饰了,除非你使用了 mutable。


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

cpp
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 标准里的扩展时,可以用一个稳定的接口将它们封装起来

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

cpp
#if __cplusplus >= 201103L
#define INLINE inline
#else
#define INLINE __attribute__((__always_inline__))
#endif
  • 会着火的语义 当你的程序有未定义行为时,你的程序有”着火“语义 —— 你的计算机可能会着火

P.3 表达意图

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

cpp
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)执行。这意味着我们不关心处理的顺序。举个例子

cpp
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)提供的实现。

  • 窄化转换是对算术值的有精度损失的隐式转换。

cpp
int i1(3.14);
int i2 = 3.14;

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

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

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

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

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

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

书籍原文不是这样的,我实在无法理解它为什么那么抽象,它整了个 static_assert(size(int) >= 4) (英文原书也是这样),评价位烂活。 你说它 size 要指代一切编译期求值的函数吧,它给你传了个 int,真不知道省略个 of 要干嘛。 后面译者回复了我,说就是错误,英文原书也有错,他没注意改。

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

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

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

cpp
static_assert(std::is_integral_v<T>);

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

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

cpp
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++。你发现下面循环中的问题了吗?

cpp
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 ,就可以把前面的函数变成一行。

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

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

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

cpp
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_ 移动。

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

cpp
//不修改 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 类,比如直接把复制构造和复制赋值运算符给删了,会怎么样?

cpp
#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 的移动构造,是转移所有权(其实你就可以理解为把原对象的指向数据的指针给了我们当前的对象,然后原对象赋空)。

我们举个例子:

cpp
#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 库。

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