• C++ 核心指南之 C++ 哲学/基本理念(下)


    C++ 核心指南(C++ Core Guidelines)是由 Bjarne Stroustrup、Herb Sutter 等顶尖 C+ 专家创建的一份 C++ 指南、规则及最佳实践。旨在帮助大家正确、高效地使用“现代 C++”。

    这份指南侧重于接口、资源管理、内存管理、并发等 High-level 主题。遵循这些规则可以最大程度地保证静态类型安全,避免资源泄露及常见的错误,使得程序运行得更快、更好。

    文中提到的 GSL(Guidelines Support Library) 是 C++ 核心指南支持库 https://github.com/Microsoft/GSL

    P:Philosophy 基本理念

    本节的规则反映了现代 C++ 的哲学/基本理念,贯穿整个 C++ 核心指南:

    规则摘要:

    • P.7:尽早捕获运行时错误
    • P.8:不要泄露任何资源
    • P.9:不要浪费时间或空间
    • P.10:优先使用不可变数据而不是可变数据
    • P.11:封装混乱的结构,而不是让其散布在代码中
    • P.12:根据需要使用支持工具
    • P.13:根据需要使用支持库

    这些基本理念是其他章节具体规则的理论基础。

    P.7:尽早捕获运行时错误

    以避免(可能无法发现的)错误结果或莫名其妙地程序崩溃

    例子

    // 👎 容易出错
    void increment1(int* p, int n)
    {
        for (int i = 0; i < n; ++i) ++p[i];
    }
    
    void use1(int m)
    {
        const int n = 10;
        int a[n] = {};
        // 可能是手误,或者 m<=n,但万一 m==20 ...
        increment1(a, m);
    }
    

    use1() 中犯了个小错误,可能导致数据损坏或程序崩溃。形如 f(pointer, count) 这样形式的接口没有办法彻底避免 “out-of-range” 错误。如果我们检查下标越界,那也要等到访问 p[10] 的时候才能发现。可以改进代码,更早地进行检查:

    void increment2(span<int> p)
    {
        for (int& x : p) ++x;
    }
    
    void use2(int m)
    {
        const int n = 10;
        int a[n] = {};
        // 可能是手误,或者 m<=n
        increment2({a, m});
    }
    

    现在 m <= n 能在调用 increment2() 时就检查。如果本来就想用 n 作为边界,代码可以进一步简化(同时也消除了错误的可能):

    void use3(int m)
    {
        const int n = 10;
        int a[n] = {};
        // 不需要重复元素个数
        increment2(a);
    }
    

    反面例子

    不要重复检查同一个值,不要把结构化的数据作为 string 传递:

    // 从 istream 中读日期
    Date read_date(istream& is);
    
    // 从 string 中提取日期
    Date extract_date(const string& s);
    
    // 操作日期
    void user1(const string& date)
    {
        auto d = extract_date(date);
        // ...
    }
    
    void user2()
    {
        Date d = read_date(cin);
        user1(d.to_string());
    }
    

    user2() 中同一个日期被 Date 的构造函数校验了 2 次(一次在调用 read_date() 时,一次在调用 user1() -> extract_date() 时),并且原本结构化的 Date d 作为(非结构化的) string 传给了 user1()

    额外的检查是有开销的。有时过早的检查效率不高,因为可能根本用不到这个值。或者只用到了部分,而只检查整个值的开销远大于检查用到部分的开销。类似地,不要增加改变接口复杂度的检查,例如不要在一个平均复杂度 O(1) 的接口中添加一个 O(n) 的检查。

    代码检查建议

    • 检查指针和数组:尽早进行范围检查,但不要重复检查
    • 检查转换:标记或消除窄化转换
    • 查找没有进行检查的输入值
    • 查找被转为 string 的结构化数据(含有不变量的类)
    • ...

    P.8:不要泄露任何资源

    即便资源消耗以极其缓慢的速度增加,最终也会耗尽资源,尤其是对长时间运行的程序来说。

    反面例子

    void f(char* name)
    {
        FILE* input = fopen(name, "r");
        // 👎 如果 something == true,文件句柄泄漏
        if (something) return;
        // ...
        fclose(input);
    }
    

    应该使用 RAII:

    void f(char* name)
    {
        ifstream input {name};
        // OK:不会泄露
        if (something) return;
        // ...
    }
    

    参见:资源管理 R 篇

    “泄漏”是指资源没有清理或者再也无法被清理。例如,在堆上分配一个对象,然后丢弃指向该对象的指针。

    • 本规则不要求在程序结束时回收 long-lived 对象。例如,操作系统能够保证在进程结束时自动清理打开的文件、分配的内存,依赖操作系统的该机制可以简化代码。但是依赖隐式清理的抽象更简单,也更安全
    • 强制实施生命周期安全 Profile 可以消除泄漏。结合 RAII 提供的资源安全性,可以消除对“垃圾回收”的需求,因为根本不会产生垃圾。再结合 类型和边界 Profiles 可以做到完全的类型&资源安全(由工具保证)

    代码检查建议

    • 检查指针:参见:资源管理 R 篇,把指针划分为“拥有指针”和“非拥有指针(默认)”。如果可以,用标准库的资源管理(如上面的例子)。或者把拥有指针用 GSL 库中的 owner 标记
    • 查找裸的 newdelete
    • 查找返回裸指针的资源分配函数(如 fopenmallocstrdup

    P.9:不要浪费时间或空间

    因为这是 C++。但如果花费的时间和空间是为了达成特定目标(例如开发速度,资源安全或简化测试),那么并不算浪费。

    “追求效率的另一个好处是,这个过程会迫使你更深入地理解问题。” —— Alex Stepanov

    反面例子

    struct X {
        char ch;
        int i;
        string s;
        char ch2;
    
        X& operator=(const X& a);
        X(const X&);
    };
    
    X waste(const char* p)
    {
        if (!p) throw Nullptr_error{};
        int n = strlen(p);
        auto buf = new char[n];
        if (!buf) throw Allocation_error{};
        for (int i = 0; i < n; ++i) buf[i] = p[i];
        // ... 操作 buffer ...
        X x;
        x.ch = 'a';
        // give x.s space for *p
        x.s = string(n);
        // copy buf into x.s
        for (gsl::index i = 0; i < x.s.size(); ++i)
            x.s[i] = buf[i];
        delete[] buf;
        return x;
    }
    
    void driver()
    {
        X x = waste("Typical argument");
        // ...
    }
    

    这是一个夸张的例子,但每一个错误(甚至更糟的)都在实际生产代码中出现过。结构体 X 的布局至少浪费了 6 个字节。显式声明的拷贝构造和拷贝赋值运算符抑制了移动操作,导致返回操作较慢(注意:这里不能保证 RVO 返回值优化)。对 buf 使用 newdelete 是多余的;如果真的需要一个局部字符串,应该使用局部 string。还有几处代码,完全没有必要实现得那么复杂。

    反面例子

    void lower(zstring s)
    {
        for (int i = 0; i < strlen(s); ++i) s[i] = tolower(s[i]);
    }
    

    这是一个真实生产代码的例子。在循环中的条件是 i < strlen(s)。这个表达式在每次循环迭代时都会被计算,这意味着 strlen 必须在每次循环中遍历字符串来计算字符串长度。虽然字符串内容改变了,但 tolower 不会影响字符串的长度,所以最好在循环之外缓存字符串长度,而不是每次循环都重新计算长度。

    一个浪费的例子并不会产生显著的影响,如果影响显著,通常可以轻易地发现并消除。但如果代码库中到处都是这样的代码,则很容易从量变到质变。这个规则的目的是在问题出现之前,消除 C++ 使用相关的大部分浪费。之后,我们可以考虑与算法和需求相关的浪费,但这超出了 C++ 核心指南的范围。

    代码检查建议

    有许多更具体的规则,旨在实现“简单,消除不必要浪费”这个整体目标。

    • 标记后置递增/递减运算符 operator++()operator--() 未使用的返回值,最好使用前置形式

    P.10:优先使用不可变数据

    • 不可变数据不会意外地改变
    • 更容易优化
    • 不会产生数据竞争(data race)

    见条款 Con: Constants and immutability

    P.11:封装混乱的结构,而不是让其散布在代码中

    • 混乱的代码容易藏着 bug
    • 好的接口更容易使用,也更安全
    • 混乱、低级的代码更容易产生更多混乱的代码(破窗理论)

    这里的低级(low-level)指的是代码的抽象层级较低,直接操作指针、malloc、realloc 等这样的低层函数

    例子

    int sz = 100;
    int* p = (int*) malloc(sizeof(int) * sz);
    int count = 0;
    
    for (;;) {
        // ... read an int into x, exit loop if end of file is reached ...
        // ... check that x is valid ...
        if (count == sz)
            p = (int*) realloc(p, sizeof(int) * sz * 2);
        p[count++] = x;
        // ...
    }
    

    这是一段低级、冗长、易错的代码:例如这里就没有考虑内存耗尽的问题。应该使用 vector:

    vector<int> v;
    v.reserve(100);
    // ...
    for (int x; cin >> x; ) {
        // ... check that x is valid ...
        v.push_back(x);
    }
    

    标准库和 GSL 是该思想的体现:vector、span、lock_guard、future 等关键抽象封装了低层的数组、union、类型转换等操作。通常库的设计/实现者远比我们更专业,花的时间也更多。我们应该使用库提供的高级抽象,而不是让低层细节搞乱我们的代码。

    代码检查建议

    查找混乱的代码,例如

    • 复杂的指针操作
    • casting outside the implementation of abstractions

    P.12:根据需要使用支持工具

    有的事情让机器来做更合适:计算机不会对重复的工作感到无聊和厌倦,而人类可以做一些更有价值的事情

    例子

    运行静态代码分析工具来确保代码遵循某些规范:如 coverity、clang-tidy、clang-format 等

    • 还有许多其他类型的工具,如代码仓库、构建工具等,这些不在 C++ 核心指南范围内

    • 不要依赖过于复杂、特殊的工具链,这会让可移植代码变得不可移植

    P.13:根据需要使用支持库

    使用设计/文档/支持良好的库可以节省时间和精力。无论是代码质量还是文档质量,通常要比自己写的好得多。库的开发维护成本是被所有用户平摊的(实际上大多数优秀的库都是可以免费使用的)。如果一个库有很多用户,也更容易保持更新并移植到新系统,并且相关的使用经验和知识也可以在其他项目复用,节约时间和精力。

    例子

    std::sort(begin(v), end(v), std::greater<>());
    

    除非你是排序算法的专家,并且有大量时间,否则你很难写出比上面那一行更正确、更快的排序代码。

    使用标准库(或其他基础库)不需要理由;相反,不用标准库最好给出充分的理由。

  • 相关阅读:
    自认为最好的rule_of_five
    Apache commons exec框架的简介说明
    工程管理系统源码+项目说明+功能描述+前后端分离 + 二次开发
    【PHP】imagettf 文字图片合成居中
    Python从入门到实践(七)函数
    03 编译Java虚拟机
    CPT-CY3/CY5/CY7/CY7.5/花菁染料CY3/Y5/CY7/CY7.5/抗Trop-2 IgG抗体偶联顺铂的制备
    【k8s】kubectl命令详解
    接物游戏demo
    序列化和反序列化
  • 原文地址:https://www.cnblogs.com/tengzijian/p/17604713.html