Skip to content

改善程序与设计的55个具体做法。

1 让自己习惯 C++

条款 01:视 C++ 为一个语言联邦

C++ 的四个次语言:

  1. C。区块、语句、与处理、内置数据类型、数组、指针等。
  2. 面向对象的 C++。类、封装、继承、多态、虚函数等。
  3. 模板 C++。泛型编程、模板元编程等。
  4. STL。C++标准模板库,容器、迭代器、算法、分配器、适配器、仿函数。

C++ 是四个次语言组成的语言联邦,每个语言都有自己的规约。

C++ 高效编程守则视状况而变化,取决于你用 C++ 的哪部分。

条款 02:尽量以 constenuminline 替换 #define

即:宁可以编译器替换预处理器。

原因:

  1. 错误信息难以追踪;
  2. 预处理直接替换可能导致目标码出现多份相同代码。

常量替换 #define 的两种特殊情况:

  1. 常量指针

    char*-base字符串,必须写 const 两次:

    const char* const authorName = "Scott Meyers";

    string 通常比char*-base更合适:

    const std::string authorName = "Scott Meyers";

  2. class 专属常量

    cpp
    class GamePlayer {
    private:
        static const int NumTurns = 5;  // 常量声明式
        int scores[NumTurns];           // 使用该常量
        // ...
    };

    通常 C++ 要求你对你所使用的任何一种东西提供一个定义式,但如果它是个 class 专属常量且为整数类型(例如 ints,chars,bools),只要不取它们的地址,可以声明并使用它们而无需提供定义式。

    但如果要取某个 class 专属常量的地址,或者你的编译器(不正确地)坚持要看到一个定义式,你就必须另外提供定义式如下(在非头文件中):

    cpp
    const int GamePlayer::NumTurns; // 常量定义式(声明时获得初值,不可再设初值)

旧的编译器也许不支持上述语法,它们不允许 static 成员在其声明式上获得初值。此外所谓的“in-class 初值设定”也只允许对整数常量进行。如果编译器不支持上述语法,可以将初值放在定义式:

cpp
class CostEstimate
{
private:
    static const double FudgeFactor;  // static class 常量声明
    // ...
};

// static class 常量定义,位于实现文件内
const double CostEstimate::FudgeFactor = 1.35;

例外是当你在 class 编译期间需要一个class常量值,例如上述的 GamePlayer::scores 的数组声明式中(编译器必须在编译期间知道数组的大小)。如果编译器不允许在“static 整数型 class 常量”完成“in-class 初值设定”,可改用 "the enum hack" 补偿做法:

cpp
class GamePlayer
{
private:
    enum {NumTurns = 5};        // the enum hack
    int scores[NumTurns];       // 使用该常量
    // ...
};
  1. enum hack 的行为某方面说比较像 #define 而不像 const。例如取一个const的地址是合法的,但取一个enum的地址就不合法,而取一个#define的地址通常也不合法。如果你不想让别人获得一个 pointer 或 reference 指向你的某个整数常量,enum可以帮助你实现这个约束。优秀的编译器不会为“整数型 const 对象”设定另外的存储空间(除非你创建一个 pointer 或 reference 指向该对象),不够优秀的编译器却可能如此。enum#defines一样绝不会导致非必要的内存分配。
  2. “enum hack”是 templatemetaprogramming(模板元编程)的基础技术。

#define 带参宏可以使用 tempalte inline 函数替代,避免额外的函数调用开销。

  1. 对于单纯的常量,最好以const对象或enums替换 #defines;
  2. 对于形似函数的宏(macros),最好改用inline函数替换#defines。

条款 03:尽可能使用 const

cpp
char greeting[] = "Hello";
char* p = greeting;                 // non-const pointer, non-const data
const char* p1 = greeting;          // non-const pointer, const data
char* const p2 = greeting;          // const pointer, non-const data
const char* const p3 = greeting;    // const pointer, const data

函数返回一个常量值,往往可以降低客户错误而造成的意外,而又不至于放弃安全性和高效性,如 if (a * b = c)

const 成员函数

目的:为了确认该成员函数可作用于 const 对象身上。 const 函数之所以重要,基于两个理由:

  1. 使得 class 接口比较容易理解;
  2. 操作 const 对象成为可能(大多数用于 passed-by-pointer-to-const 或 passed-by-reference-to-const)。

两个成员函数如果只是常量性(constness)不同,可以被重载。

两种 const 概念:

  1. bitwise constness(physical constness),不更改对象内的任何一个 bit, C++ 对常量性的定义
  2. logical constness

许多成员函数虽然不具备十足的const性质,却能通过 bitwise 测试。更具体地说,一个更改了“指针所指物”的成员函数虽然不能算是const,但如果只有指针(而非所指物)隶属于对象,那么称此函数为 bitwise const 不会引发编译器异议。

当 const 函数返回一个 reference 指向其(const)对象的内部值,其实现代码并不改变内部对象,但能获取到内部对象的指针,通过该指针可以改变该const对象的内部成员。

这种情况导出所谓的 logical constness。这一派拥护者主张,一个 const 成员函数可以修改它所处理的对象内的某些 bits,但只有在客户端侦测不出的情况下才得如此,例如多线程访问const对象的内部变量,需要对资源进行加锁,需要将 std::mutex 成员声明为 mutable。 mutable 释放掉 non-static 成员变量的 bitwise constness 约束。

在 const 和 non-const 成员函数中避免重复

可以用non-const版本调用const版本,只是要两次转型。不能用const版本调用non-const版本。

cpp
class TextBlock {
public:
    // ...
    const char& operator[](std::size_t position) const
    {
        // ...
        return text[position];
    }
    char& operator[](std::size_t position)
    {
        return
            const_cast<char&>( // 将op[]返回值的const转除
              static_cast<const TextBlock&>(*this) // 为*this加上const
                [position] // 调用const op[]
        );
    }
}
  1. 将某些东西声明为 const 可以帮助编译器侦测出错误的用法。const 可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体;
  2. 编译器强制实施 bitwise constness,但你编写程序时应该使用“概念性上的常量”;
  3. 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。

条款 04:确定对象被使用前已先被初始化

通常如果你使用 C part of C++而且初始化可能招致运行期成本那么久不保证发生初始化。一旦进入 non-C parts of C++,规则有些变化,这就很好地解释了为什么数组不保证其内容被初始化,而 vector 却有此保证。

最佳的解决办法是:永远在使用对象之前先将它初始化。

  1. 对于无任何成员的内置类型,必须手工初始化;
  2. 对于内置类型以外的其它东西,初始化的责任落在构造函数上,确保每个构造函数都将对象的每一个成员初始化。

注意区别赋值(assignment)和初始化(initialize)。C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。 在构造函数里的 “=” 操作,其实是赋值。但对于内置类型来说,不保证一定在你所看到的那个赋值动作的时间点之前获得初值。

构造函数较佳的写法是使用 member initialization list(成员初始化列表)替换赋值动作。赋值版本的构造函数首先调用 default 构造函数为成员变量设置初值,然后立即对他们赋予新值;而成员初始化列表中对各个成员变量的实参被作为各成员变量的构造函数的实参进行拷贝构造

规定:总是在初值列表中列出所有成员变量,以免还需记住哪些成员变量可以无需初值。

如果成员变量时const或者reference他们就一定需要初值,不能被赋值。

C++ 有非常固定的“成员初始化次序”:base classes 早于 derived classes 被初始化,而 class 的成员变量总是以其声明次序被初始化。

C++ 对“定义于不同编译单元内的 non-local static 对象”的初始化次序并无明确定义,所以可能会在一个类的内部访问未初始化的 non-local static 对象。解决方案是将每个 non-local static 对象搬到自己的专属函数内部,该对象在此函数内部被声明为 static,函数返回一个引用指向它所含的对象。换句话说 non-local static 被 local static 对象替代了。这是 Singleton 模式的一种常见的实现手法。

  1. 为内置对象进行手工初始化,因为 C++ 不保证初始化他们;
  2. 构造函数最好使用成员初始化列(member initialization list),而不要在构造函数本体内使用赋值操作(assignment)。初值列列出的成员变量,其排列次序应该和他们在 class 中的声明次序相同;
  3. 为免除“跨编译单元之初始化次序”问题,请以 local static 对象替换 non-local static 对象。

2 构造/析构/赋值运算

条款 05:了解 C++ 默认编写并调用了哪些函数

如果你自己没有声明,编译器会为一个类声明(编译器版本的)一个 copy 构造函数、一个 copy assignment 操作符和一个析构函数。此外,如果你没有声明任何构造函数,编译器也会为你声明一个 default 构造函数。所有这些函数都是 public 且 inline 的(本书于 06 年出版,还没有 C++11)。

侯捷 C++11/14 课程中讲到

C++中,当我们设计与编写一个类时,若不显著申明,则类会默认为我们提供如下几个函数: 1. 构造函数 (A()) 2. 析构函数(~A()) 3. 拷贝构造函数 (A(A&)) 4. 拷贝赋值函数(A& operator=(A&)) 5. 移动构造函数(A(A&&)) 6. 移动赋值函数(A& operator=(A&&)

注意:拷贝函数如果涉及指针就要区分浅拷贝(指针只占 4 字节,浅拷贝只把指针所占的那 4 个字节拷贝过去)和深拷贝(不仅要拷贝指针所占的字节,还要把指针所指的东西也要拷贝过去);

默认提供全局的默认操作符函数: 1. operator 2. operator & 3. operator && 4. operator * 5. operator-> 6. operator->* 7. operator new 8. operator delete

惟有当这些函数被需要(被调用),它们才会被编译器创建出来。

default 构造函数和析构函数用来进行调用 base classes 和 non-static 成员变量的构造函数和析构函数之类的工作。注意,编译器产出的析构函数时 non-virtual 的,除非这个 class 的 base class 自身声明有 virtual 析构函数。

至于 copy 构造函数和 copy assignment 操作符,编译器常见的版本只是单纯地将来源对象的每一个 non-static 成员变量拷贝到目标对象。

编译器默认生成的 copy assignment 操作符其行为基本上与 copy 构造函数一致,但一般而言只有当生成的代码合法且有适当的机会证明它有意义,其表现才会如前。如果上述条件有其一不符合,编译器将拒绝为 class 生成 operator= 。

  1. reference 和 const 成员变量,只能在声明时赋予初值且不能更改,编译器将拒绝生成 operator=;
  2. 某个 base classes 将 copy assignment 操作符声明为 private,编译器将拒绝为其 derived classes 生成一个 copy assignment 操作符。

编译器可以暗自为 class 创建 default 构造函数、copy 构造函数、copy assignment 操作符,以及析构函数。

条款 06:若不想使用编译器自动生成的函数,就该明确拒绝

为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为 private 并且不予实现。使用像 Uncopyable 这样的 base class 也是一种做法。

C++11 之后提供 =delete 关键字显式删除,告知编译器不生成函数默认的缺省版本。

条款 07:为多态基类声明 virtual 析构函数

C++ 明确指出,当 derived class 对象经由一个由 base class 指针被删除,而该 base class 带着一个 non-virtual 析构函数,其结果未有定义——实际执行的时候通常发生的是对象的 derived 成分没被销毁。

一个 class 如果不包含 virtual 函数,通常表示它并不意图被用作一个 base class,令其析构函数为 virtual 会增加其对象大小(虚表)。

  1. Polymorphic(带多态性质的)base classes 应该声明一个 virtual 析构函数。如果 class 带有任何 virtual 函数,他就应该拥有一个 virtual 析构函数;
  2. Classes 的设计目的如果不是作为 base classes 使用,或者不是为了具备多态性(polymorphically),就不该声明 virtual 析构函数。

补充:

抽象类不能被实例化,但纯虚函数可以有实现,且必须在类的定义之外实现。另外,包含纯虚函数的类为抽象类,被继承后,在派生类析构函数被调用时抽象类析构函数也将被调用,因此必须有实现。

声明一个 pure virtual 函数的目的是为了让 derived classes 只继承函数接口,派生类必须提供实现

可以为 pure virtual 函数提供定义,但使用时需要指明所属类。

cpp
#include <iostream>

class A
{
public:
    virtual void func1() = 0;
    virtual ~A() = 0;  // 纯虚析构函数
};

class B : public A
{
public:
    virtual void func1() { A::func1(); }
};

void A::func1() { std::cout << "A::func1()" << std::endl; }

/*******************************************************************************
 * 纯虚析构函数必须实现,不然链接器报错
 * [build] /usr/bin/ld: CMakeFiles/02_07.dir/02_07.cpp.o: in function `B::~B()':
 * [build] /mnt/e/WorkSpace/Exercise/effective-cpp/02_07.cpp:20: undefined reference to `A::~A()'
 *****************************************************************************/
A::~A() {} 

int main()
{
    A* pa = new B();
    pa->func1();
    pa->A::func1();
    return 0;
}

条款 08:别让异常逃离析构函数

如果程序遭遇一个“于析构期间发生的错误”后无法继续执行,“强迫结束程序”是个合理选项。毕竟它可以阻止异常从析构函数中传播出去(会导致不明确的行为)。

  1. 析构函数绝对不要抛出异常。如果一个被析构函数调用的函数可能抛出异常。析构函数应该捕捉该任何异常,然后吞下它们(不传播)或结束程序;
  2. 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么 class 应该提供一个普通函数(而非在析构函数中)执行该操作。

条款 09:绝不在构造和析构过程中调用 virtual 函数

在 base class 构造期间,virtual 函数不是 virtual 函数,因为 base class 的构造函数执行更早于 derived class 构造函数,在 base class 构造期间,对象的类型是 base class 而不是 derived class。

同理也适用于析构函数。

在构造和析构期间不要调用 virtual 函数,因为这类调用从不下降至 derived class(比起当前执行构造函数和析构函数的那层)。

条款 10:令 operator= 返回一个 reference to *this

为了实现“连锁赋值”,赋值操作符必须返回一个 reference 指向操作符左侧的实参,这是你为 classes 实现赋值操作时应该遵循的协议。这个协议不仅适用于以上的标准赋值形式,也适用于所有赋值相关运算,如:+=、-=、*=等。

令 operator= 返回一个 reference to *this。

条款 11:在 operator= 中处理“自我赋值”

自我赋值可能会导致指针指向一个已经被删除的对象。

  1. 证同自测

    cpp
    Test& operator=(const Test& rhs)
    {
        if (_pm == rhs._pm)  // identity test
        {
            return *this;
        }
        delete _pm;
        _pm = new Men(*rhs._pm);  // 创建副本
        return *this;
    }

    证同测试仍然存在异常方面的麻烦。当 new Men 抛出异常时会导致 Test 持有一个被删除过的指针。

  2. 异常安全版本

    cpp
    Test& operator=(const Test& rhs)
        {
            Men* pm_t = _pm;          // 记住原先的 pm
            _pm = new Men(*rhs._pm);  // 创建副本
            delete pm_t;              // 删除原先的pm
            return *this;
        }

    这样,即使 new Men 抛出异常,Test 所持有的的指针仍指向原处。如果关心效率,可以把证同自测放回函数起点处。但证同自测同样会增加成本:使代码变大(源码和目标码)并导入一个新的控制流分支。

  3. Copy and swap 技术

    cpp
    void swap(Test& rhs)
    { 
        /*...*/
    }
    Test& operator=(const Test& rhs)
    {
        Test tmp(rhs);
        swap(tmp);
        return *this;
    }

    或者:

    cpp
    Test& operator=(Test rhs) // 本身是一份副本
    {
        swap(rhs);
        return *this;
    }

    这种方法牺牲了清晰性,但将“copy 动作”从函数本体内移至“函数参数构造阶段”却可令编译器有时生成更高效的代码。

  1. 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及 copy-and-swap;
  2. 确定任何函数如果操作一个以上的对象,而其中多个对象时同一个对象时,其行为仍然正确。

条款 12:复制对象时勿忘其每一个成分

  1. Copying 函数应该确保复制“对象内的所有成员变量”及“所有 base 成分”;
  2. 不要尝试以某个 copying 函数实现另一个 copying 函数。应该将共同的机能放进第三个函数中,并由两个 copying 函数共同调用。

3 资源管理

条款 13:以对象管理资源

“以对象管理资源”的两个关键想法:

  1. 获得资源后立即放进管理对象内。实际上“以对象管理资源”的观念常被称为“资源取得时机便是初始化时机”(Resource Acquisition Is Initialization, RAII);
  2. 管理对象运用析构函数确保资源被释放。
  1. 为防止资源泄露,请使用 RAII 对象,他们在构造函数中获得资源并在析构函数中释放;
  2. 两个常被使用的 RAII class 分别是 shared_ptr 和 auto_ptr。前者通常是较佳的选择,因为其 copy 行为比较直观,若选择 auto_ptr,复制动作会使它(被复制物)指向 null。

条款 14:在资源管理类中小心 copying 行为

  1. 禁止复制;
  2. 对低层资源祭出“引用计数法”(reference-count);
  3. 复制底部资源(深拷贝);
  4. 转移底部资源的拥有权。
  1. 复制 RAII 对象必须一并复制它所管理的资源,所以资源的 copying 行为决定 RAII 对象的 copying 行为;
  2. 普遍而常见的 RAII class copying 行为是:抑制 copying、施行引用计数法(reference counting)。不过其他行为也可能被实现。

条款 15:在资源管理类中提供对原始资源的访问

  1. APIs 往往要求访问原始资源(raw resources),所以每一个 RAII class 应该提供一个“取得”其所管理资源的方法;
  2. 对原始资源的访问可能经由显示转换或隐式转换。一般而言显示转换比较安全,但隐式转换对客户比较方便。

条款 16:成对使用 new 和 delete 时要采取相同的形式

new/delete 只调用一次构造/析构函数,new[]/delete[] 根据数组大小,多次调用构造/析构函数,分配/释放的空间大小是整个数组的大小。

如果析构的对象里面没有指针类型的成员,直接使用 delete 释放 new[]的对象并不会导致内存泄漏。

对 new 出的对象使用 delete[],可能会导致读取到错误的“数组大小”,然后多次调用析构函数,但实际对象不是一个数组。

当以 new 创建 typedef 类型对象时,也需要注意使用对应的形式删除之。

如果你在 new 表达式中使用[],必须在相应的 delete 表达式中也使用[];如果你在 new 表达式中不使用[],一定不要在在相应的 delete 表达式中使用[]。

条款 17:以独立的语句将 newed 对象置入智能指针

对于以下语句:

cpp
processWidget(std::shared_ptr<Widget>(new Widget), priority());

在编译器调用 processWidget 之前要做的三件事:

  1. 调用 priority ()
  2. 执行 new Widget
  3. 调用 shared_ptr 的构造函数

C++ 编译器完成这些事情次序的弹性很大。可以确定的是 new Widget 一定执行于 shared_ptr 构造函数之前,但对 priority () 的调用可以在任何一步,考虑如下执行顺序:

  1. 执行 new Widget
  2. 调用 priority ()
  3. 调用 shared_ptr 的构造函数

若执行 priority () 时抛出异常,new Widget 返回的指针将会遗失,造成资源泄露。避免这类问题的办法:

cpp
std::shared_ptr<Widget> pw(new Widget);  // 在单独语句内以智能指针存储 newed 所得对象
processWidget(pw, priority());

以独立语句将 newed 对象存储于(置于)只能指针中,如果不之这样做,一旦异常被抛出,有可能导致难以察觉的资源泄露。

4 设计与声明

条款 18:让接口容易被正确使用,不易被误用

  1. 好的接口很容易被正确使用,不容易被误用。你应该在你所有的接口中努力达成这些性质;
  2. “促进正确使用”的办法包括接口一致性,以及与内置类型的行为兼容;
  3. “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户资源管理责任;
  4. share_ptr 支持定制删除器(custom deleter),这可防范 DLL 问题,可被用来自动解除互斥锁等等。

条款 19:设计 class 犹如设计 type

在设计 class 时你需要了解的问题:

  1. 新 type 的对象应该如何被创建和销毁?
  2. 对象的初始化和对象的赋值该有什么样的差别?
  3. 新的 type 的对象如果是 passed by value(以值传递),意味着什么?
  4. 什么是新 type 的“合法值”?
  5. 你的新 type 需要配合某个继承图系(inheritance graph)吗?virtual or non-virtual
  6. 你的新 type 需要什么样的转换?隐式或者显示
  7. 什么样的操作符和函数对此新 type 而言是合理的?
  8. 什么样的标准函数应该被驳回? = delete
  9. 谁该取用新的 type 成员? public/protected/private
  10. 什么是新 type 的“未声明接口”(undeclared interface)?
  11. 你的新 type 有多一般化? class or class template
  12. 你真需要一个新的 type 吗?non-member 函数或 templates

class 的设计就是 type 的设计,在定义一个新的 type 之前,请确定你已经考虑过本条款覆盖的所有讨论主题。

条款 20:宁以 pass-by-reference-to-const 替换 pass-by-value

  1. 尽量以 pass-by-reference-to-const 替换 pass-by-value。前者通常比较高效,并可避免切割问题(slicing problem);
  2. 以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象,对它们而言 pass-by-value 往往比较适当。
cpp
#include <iostream>
using namespace std;
int fun1(int a){
	return a;
}
int fun1(const int& a){
	return a;
}
class Less{
public:
	bool operator() (int a,int b) const
	{
		return a < b;
	}
};
bool fun3(int a,int b,Less fun){
		return fun(a,b);
}
bool fun4(int a,int b,const Less& fun){
	return fun(a,b);
}
int main(){
	
	return 0;
}

编译器不进行优化得到的汇编代码:

x86asm
fun1(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi  ; 传值
        mov     eax, DWORD PTR [rbp-4]  ; 取值
        pop     rbp
        ret
fun1(int const&):
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi ; 传地址
        mov     rax, QWORD PTR [rbp-8] ; 取地址,& 底层以指针实现
        mov     eax, DWORD PTR [rax]   ; 取值
        pop     rbp
        ret
Less::operator()(int, int) const:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     DWORD PTR [rbp-12], esi
        mov     DWORD PTR [rbp-16], edx
        mov     eax, DWORD PTR [rbp-12]
        cmp     eax, DWORD PTR [rbp-16]
        setl    al
        pop     rbp
        ret
fun3(int, int, Less):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     edx, DWORD PTR [rbp-8]
        mov     ecx, DWORD PTR [rbp-4]
        lea     rax, [rbp-9]           ; Less() 地址
        mov     esi, ecx
        mov     rdi, rax
        call    Less::operator()(int, int) const
        leave
        ret
fun4(int, int, Less const&):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     QWORD PTR [rbp-16], rdx ; 传入的参数为指向 Less() 地址的指针
        mov     edx, DWORD PTR [rbp-8]
        mov     ecx, DWORD PTR [rbp-4]
        mov     rax, QWORD PTR [rbp-16]  ; Less() 地址
        mov     esi, ecx
        mov     rdi, rax
        call    Less::operator()(int, int) const
        leave
        ret

可以看出,对于内置类型和函数对象,引用传递比以值传递多了一次解引用,Iterator 同理。

条款 21:必须返回对象时,别妄想返回其 reference

  1. 绝不要返回 pointer 或 reference 指向一个 local stack 对象(会导致未定义的行为)
  2. 绝不要返回 reference 指向一个 heap-allocated 对象(可能会导致资源泄露)
  3. 绝不要返回 pointer 或 reference 指向一个 local static 对象,而有可能同时需要多个这样的对象。

条款 22:将成员变量声明为 private

  1. 切记将成员变量声明为 private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性;
  2. protected 并不比 public 更具封装性。

条款 23:宁以 non-member、non-friend 替换 member 函数

条款 22 层说过,成员变量应该是 private,因为如果它不是,就有无限数量的函数可以访问它们,它们也就毫无封装性。能够访问 private 成员变量的函数只有 class 的 member 函数加上 friend 函数而已。如果要在一个 member 函数(它不止可以访问 class 内的 private 数据,也可以取用 private 函数、enums、typedefs 等等)和一个 non-member ,non-friend 函数(它无法访问上述任何东西)之间做抉择,而且两者的机能相同,那么导致较大封装性的是 non-member、non-friend 函数,因为它们并不增加“能够访问 class 内之 private 成分”的数量。

这个论述只适用于 non-member、non-friend 函数。friends 函数对 class private 成员的访问权力和 member 函数相同,因此两者对封装的冲击力道也相同。

宁可拿 non-member、non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性和机能扩充性。

条款 24:若所有参数皆需类型转换,请为此采用 non-member 函数

operator* 写成 Rational 成员函数的写法:

cpp
class Rational
{
public:
    Rational(int numerator = 0,
            int denominator = 1);
    int numerator() const;
    int denominator() const;
    
    const Rational operator*(const Rational& rhs) const;
private:
    // ...
}

存在的问题:

cpp
Rational oneEight(1,8);
Rational oneHalf(1,2);
Rational result;
result = oneEight * oneHalf; // 正确
resulr = onHalf * 2;  // 正确,隐式转换
resulr = 2 * onHalf;  // 错误

不存一个在能够接收 int 和 Rational 作为参数的 non-member operator*,因此查找失败。

non-member 函数:

cpp
const Rational operator*(const Rational& lhs,
                       const Rational& rhs)
{
    return Rational(lhs.numerator() * rhs.numerator(),
                   lhs.denominator() * rhs.denominator());
}

如果你需要为某个函数的所有参数(包括 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member,

条款 25:考虑写出一个不抛异常的 swap 函数

  1. std::swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常;
  2. 如果你提供一个 member swap ,一应该提供一个 non-member swap 用来调用前者。对于 classes(而非 templates ),也请特化 std::swap;
  3. 调用 swap 时应针对 std::swap 使用 using 声明,然后调用 swap 并且不带任何“命名空间紫隔修饰”;
  4. 为“用户定义类型”进行 std templates 全特化是好的,但千万不要尝试在 std 内加入某些对 std 而言全新的

自 C++11 起,引入了两个特殊的成员函数:移动构造函数和移动赋值运算符。编译器默认生成移动构造函数和移动赋值运算符需要满足的条件:

  1. 如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数(这三者之一,表示程序员要自己处理对象的赋值或释放问题),编译器就不会为它生成默认的移动构造函数和移动构造操作符;
  2. 如果类中没有提供移动构造函数和移动赋值操作符,且编译器不会生成默认的,那么我们在代码中通过 std::move() 调用的移动构造行为或者移动赋值行为将被转换为调用拷贝构造函数或者拷贝赋值操作符;
  3. 如果一个类没有显示定义自己的拷贝构造函数、拷贝赋值运算符和析构函数,且类的每个非静态成员都可移动时,编译器才会生成默认的移动构造函数和移动构造操作符;
  4. 如果显示地定义了移动构造函数和移动构造操作符,则拷贝构造函数和拷贝赋值操作符会被隐式删除(因此开发人员必须在需要时实现拷贝构造函数和拷贝构造操作符)

在使用移动构造之前,标准库的 swap() 函数:

cpp
template<class T>
void swap(T&a, T&b)
{
    T temp = a; // 拷贝构造
    a = b;
    b = temp;
}

使用 move() 函数以后:

cpp
template<class T>
void swap(T &a, T &b) {
    T temp = std::move(a); // 移动构造
    a = std::move(b);
    b = std::move(temp);
}

5 实现

条款 26:尽可能延后变量定义式的出现时间

不只应该延后变量的定义,直到非得使用变量为止,甚至应该尝试延后这份定义直到能够给他初始是参为止。如果这样,不仅能够避免构造(和析构)非必要对象,还可以避免无意义的 default 构造行为。

如果在循环内使用变量,将变量定义于循环外(A)和定义于循环内(B)的两种做法的成本如下:

  1. 做法 A:1 个构造函数 + 1 个析构函数 + n 个赋值操作
  2. 做法 B:n 个构造函数 + n 个析构函数

如果类的一个赋值成本低于一组析构+构造成本,做法 A 大体而言比较高效。尤其当 n 值很大的时候。否则做法 B 或许较好。此外做法 A 造成变量名称的作用域比做法 B 更大,有时那对程序的可理解性和易维护性造成冲突。

因此,除非(1)你知道赋值成本比“构造+析构”成本低,(2)你在处理代码中效率高度敏感(performance-sensitive)的部分,否则你应该使用做法 B。

尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。

条款 27:尽量减少转型动作

转型语法:

  1. C 风格: (T)expression
  2. 函数风格:T(expression)
  3. C++ 风格:
    1. const_cast<T>(expression) :通常被用来将对象的常量性移除(cast away the constness)。它也是唯一有此能力的 C++-style 转型操作符。
    2. dynamic_cast<T>(expression):主要用来执行“安全向下转型”(safe downcasting),也就是用来决定对象对否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。
    3. reinterpret_cast<T>(expression):意图执行低级转型,实际动作(及结果)可能取决于编译器,这也就表示它不可移植。
    4. static_cast<T>(expression) 用来强迫隐式转换(impact conversions),例如将 non-const 对象转为 const 对象,或将 int 转为 double 等等。它也可以用来执行上述多种转换的反向转换,但它无法将 const 转为 non-const——这个只有 const_cast 才能办到。

新式转型的好处:

  1. 简化“找出类型系统在哪个地点被破坏”的过程;
  2. 各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。
  1. 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计;
  2. 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
  3. 宁可使用 C++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。

条款 28:避免返回 handles 指向对象内部成分

避免返回 handles (包括 references、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像一个 const,并将发生“虚吊号码牌”(dangling handles)的可能性降至最低。

条款 29:为“异常安全”而努力是值得的

c++
class PrettyMenu
{
public:
    ...
    void changeBlackground(std::istream& imgSrc);  // 改变背景图像
    ...
private:
    Mutex mutex;        // 互斥器
    Image* bgImage;     // 目前的背景图像
    int imageChanges;   // 背景图像被改变的次数
}

void PrettyMenu::changeBackgound(std::istream& imgSrc)
{
    lock(&mutex);
    delete bgImage;
    ++ imageChanges;
    bgImage = new Image(imgSrc);
    unlock(&mutex);
}

上述函数从“异常安全性”的观点来看很糟糕,“异常安全”有两个条件,当异常被抛出时,带有异常安全的函数会:

  1. 不泄露任何资源。上述代码一旦 new Image(imgSrc) 导致异常,对 unlock 的调用就绝对不会执行,于是互斥器就永远被把持住了;
  2. 不允许数据败坏。如果 new Image(imgScr) 抛出异常,bgImage 就指向一个已被删除的对象。

解决资源泄露的问题很容易(以对象管理资源),现在专注与解决资源败坏问题。

异常安全函数提供一下三个保证之一:

  1. 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态。然而程序的现实状态恐怕不可预料。
  2. 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需要有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。
  3. 不抛掷保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。

“copy and swap”策略是对对象做出“全有或全无”改变的一个很好办法,但一般而言它并不保证整个函数有强烈的异常安全性。问题出在“连带影响”。如果函数只操作局部性状态(local state),便相对容易地提供强烈保证。但是当函数对“非局部性数据”有连带影响时,提供强烈保证就困难得多。

  1. 异常安全函数(Exception-safe functions)即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
  2. “强烈保证”往往能够以“copy and swap”实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
  3. 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。

条款 30:透彻了解 inlining 的里里外外

inline 只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出也可以明确提出

基于 VitePress 构建