Effective C++

Preface

最近在读《Effective C++》,这本著作的作者(Meyers)和译者(侯捷)都是C++大师,书中的55个条款让我受益良多。由此我想记下一些自己的总结,帮助我在未来想要回顾某一条款的内容时,最大限度地节约我的时间。

以下条款有以下几点需要注意:

不考虑多线程

书中没有C++11后的新特性,个人会参考一些资料进行补充

打⭐️的条款是个人认为比较重要而常用的

该书建议多读几遍

一、让自己习惯C++

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

C++ 是一个语言联邦,其由四个主要的次语言构成:

  • C: C++ 仍然以C为基础语法,因此C可以说是C++ 最重要的一部分。
  • Object-Oriented C++: C++ 引入了面向对象的设计理念,C with Class。
  • Template C++:C++ 泛型编程,进一步拔高了 classclassfunctionfunction的复用性。
  • STL:C++ 标准模板库,最常用的一套template程序库,包含函数、算法、容器、迭代器。

条款02:尽量以const, enum, inline 替换#define

  • classclass定义一个(静态)常量,使用const/static const

  • classclass定义一个(静态)常量,使用enum hack,如以下示例:

    class GamePlayer {
    private:
    	enum {NumTurns = 5};             	// enum hack
    	// static const int NumTurns = 5;	// 是声明式而非定义式
        
    	int scores[NumTurns]
    }

    取enum的地址是不合法的(不存在指针指向),enum不会导致非必要的内存分配。

  • 当需要多次调用某个函数时,可以考虑使用inline/static inline,而非#define函数宏

条款03:尽可能使用const ⭐️

const关键字的作用其实很简单:使得被修饰的objectobjectfunctionfunction变成可读不可写(read onlyread\ only)。

那么怎样才叫尽可能使用? => 只要 [某个值始终不发生变化] 是一个事实,你就应该使用const


  • const指针用法:

    const在星号左边,表示被指objectobject是常量;const在星号右边,表示指针自身是常量。

  • const迭代器用法:

    const std::vector<int>::iterator iter;     // 相当于T* const,指针自身是常量
    std::vector<int>::const_iterator iter;     // 相当于const T*,被指object是常量
  • const成员函数:

    const成员函数只能被const对象调用,在const成员函数内只能修改mutable修饰的成员变量。

    class CTextBlock {
    private:
        char* pText;
        mutable std::size_t textLength;        // 总是可被更改的变量
        mutable bool lengthIsValid;            // 即使是在const成员函数内
    public:
        ...
    	std:size_t length() const {
            if (!lengthIsValid) {
                textLength = std:strlen(pText);
                lengthIsValid = true;
            }
        }
    }

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

  • C内置类型的变量:使用前需要进行手动初始化,否则其初始值不固定(不一定是0或NULL)

  • 类内成员变量:用member initialization list(成员初值列)按类内声明次序初始化,如下所示

    class ABEntry {
    private:
        std::string name;
        std::string address;
        std::list<PhoneNumber> phones;
        int numTimesConsulted;             // int是内置类型,初始化效率无影响
    public :
        ABEntry (const std::string& _name, const std::string& _address,
                const std::list<PhoneNumber> _phones)
          : name(_name),				   // member initialization list
        	address(_address),
        	phone(_phone),
        	numTimesConsulted(0)          
        { }                                // 构造函数内不需要做任何动作
    }

    如果在构造函数内对成员变量赋值,成员变量(非内置类型)会先调用default构造函数初始化,再立即调用copy assignment(赋值)操作符对变量进行赋值,因而效率较低。

    而使用member initialization list初始化,成员变量(非内置类型)只会调用一次copy构造函数。

  • 用reference returning防止 [文件间] 的初始化次序问题

      

二、构造/析构/赋值运算

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

编译器会自动为class创建copy构造函数、析构函数以及操作符,同时如果你没有声明任何构造函数,编译器也会为你声明一个default构造函数,这些自动创建的函数都是publicinline的。

class Empty {
public:
    Empty() {}                                   // 默认构造函数 default constructor
    ~Empty() {}                                  // 析构函数            destructor
    
    Empty (const Empty& rhs) {}                  // 拷贝构造函数    copy constructor
    Empty& operator=(const Empty& rhs) {}        // 赋值操作符      copy assignment operator
    
    Empty (const Empty&& rhs) {}                 // 移动构造函数 C++11, move constructor
    Empty& operator=(Empty&&) {}                 // 移动拷贝函数 C++11, move assignment operator
} 

注意:唯有当这些函数需要被调用,才会被编译器创建出来。

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

承接上一个条款,如果你不需要自动创建的函数,你可以把这些函数声明在private内,并且不实现。

class HomeForSale {
private:
    // 在private内只有声明没有定义,并且形参连名字都没有
    HomeForSale(const HomeForSale&);
    HomeForSale& operator=(const HomeForSale&);
public:
    ...
}

在C++ 11中,禁止编译器默认生成的函数,建议声明在private内并且同时使用=delete

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

  • 带有多态性质的基类必须将析构函数声明为virtual函数。

    防止指向子类的基类指针在被释放时只销毁了base classbase\ class而没销毁derived classderived\ class,导致内存泄漏。

  • 普通的基类,如果设计目的不是为了多态用途(如STL),则不该声明virtual析构函数。

    在C++ 11中,可以在类中声明为final,这样禁止派生可以防止误继承造成上述问题。

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

析构函数一般情况下不应抛出异常,因为很大可能发生各种未定义的问题,包括但不限于内存泄漏、程序崩溃、所有权锁死等。如果某些操作真的很容易抛出异常(如资源的归还等),请把这些操作移到析构函数之外,提供一个普通函数做类似的清理工作,在析构函数中只负责记录,我们需要时刻保证析构函数能够执行到底。

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

base classbase\ class构造期间virtual函数不会下降到derived classderived\ class阶层,同理适用于析构过程

当一个子类对象在构造过程中,首先调用的是基类的构造函数,在基类构造期间,该对象会一直被视为base classbase\ class类型,自然在基类构造函数中调用的虚函数将会是基类的虚函数版本,而不是子类的虚函数。

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

Weight& operator=(const Weiget& rhs) {     // 返回一个reference,指向当前对象
	...
    return *this;                          // return reference to *this
}
// 同样适用于 operator +=, -=, *=, \=等等。

在设计接口时一个重要的原则是:让自己的接口和内置类型相同功能的接口尽可能相似

令operator= 返回一个reference to *this,这样做可以让你的赋值操作符实现“连等”的效果:

x = y = z = 10;

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

使用copy and swapcopy\ and\ swap技术

// copy and swap
Weight& operator=(Weight rhs) {            // 按值传递,形参产生实参副本
    swap(rhs);  						   // 使用swap()将形参数据与*this数据交换
    return *this;
}

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

如果你为一个classclass声明了copy构造函数和copy assignment操作符,需要注意:

  • 每当你新增一个成员变量,你必须同时要修改该classclass的所有构造函数以及任何形式的operator= 赋值操作符(如+=、-=、*=等),在这些函数里面加上该新增的成员变量的copy。
  • 如果是derived classderived\ class,确保在拷贝构造函数中调用了base classbase\ class的拷贝构造,在operator=赋值操作符调用base classbase\ class的operator=赋值操作符。

除此之外,拷贝构造函数和拷贝赋值操作符,他们两个中任意一个不要去调用另一个,这虽然看上去是一个避免代码重复好方法,但是是荒谬的。其根本原因在于拷贝构造函数在构造一个对象——这个对象在调用之前并不存在;而赋值操作符在改变一个对象——这个对象是已经构造好了的。因此前者调用后者是在给一个还未构造好的对象赋值;而后者调用前者就像是在构造一个已经存在了的对象。比较好的代码复用方式如下:

class Weight {
private:
    void init(const Weight& rhs) {...}
    
public:
    Weight(const Weight& rhs) {
        init(rhs);
    }
    Weight& operator= (const Weight& rhs) {
        init(rhs);
    }
}

三、资源管理

条款13: 以对象管理资源 ⭐️

资源获取时机便是初始化时机Resource Acquisition Is Initialization,RAIIResource\ Acquisition\ Is\ Initialization,RAII

使用资源管理类(RAII classRAII\ class),例如智能指针auto_ptrshare_ptr来管理资源(share_ptr往往是较佳选择),
可以避免出现因异常导致丢失对资源的控制权或因忘记释放资源而造成的资源泄漏(heapbasedheap-based):

std::auto_ptr<Investment> pInv(createInvestment());
std::share_ptr<Investment> pInv(createInvestment());
  • auto_ptrshare_ptr的析构函数都能自动对其所指向的对象调用delete,释放所指对象内存。

  • auto_ptr管理的资源只能同时存在1个auto_ptr指向它
    auto_ptr发生copy时,右值auto_ptr变成nullptr,左值auto_ptr变成该资源的唯一指向。

  • share_ptr,也叫引用计数型智慧指针(ReferenceCounting Smart Pointer,RCSPReference-Counting\ Smart\ Pointer,RCSP),可以持续追踪有多少对象指向某笔资源,当无人指向它时自动删除该资源,行为类似于垃圾回收。

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

智能指针比较适合管理heapbasedheap-based资源,当其不满足需要时,可以自定义资源管理类RAII classRAII\ class,但此时需要考虑RAIIRAII对象的copying行为的实现:

  • 禁止copying:类似于条款06的做法,把copy函数放在private内,并且不实现。
  • 复制底部资源:深拷贝
  • 转移底部资源:类似auto_ptr,copy时做转移,保证只有一个指向
  • reference-counting:类似share_ptr,做引用计数

share_ptr支持定制删除器deleter(),这可以防范DLL问题,可以用来自动解除互斥锁(mutex)等等。

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

  • shared_ptrauto_ptr
    • 都提供一个get()成员函数,用来执行显式转换,也就是它会返回智能指针内部的原始指针的副本
    • 都重载了operator->operator*指针取值操作符,允许智能指针隐式转换为原始指针
  • 自定义资源管理类RAII classRAII\ class
    • 提倡编写 get() 函数,提供显式转换
    • 谨慎考虑重载operator->operator*,隐式转换会增加错误风险(如:产生虚吊指针)。

条款16:成对使用newdelete时要采用相同形式

  • 对于单一对象:请成对使用newdelete
  • 对于对象数组:请成对使用new xxx[]delete[] xxx

不成对使用容易造成Unfined Behavior.

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

因为C++的函数参数核算次序是不固定的(主要跟编译器有关),因此如果核算次序如下:

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

如果step2中的priority()函数在执行过程中抛出异常,那么step1中的内存将发生泄漏

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

std::tr1::shared_ptr<Widget> pw(new Widget); // 独立语句将new对象置入智能指针
processWidget(pw, priority());               // 以保证这个函数不会发生内存泄漏

四、设计与声明

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

本条款的核心在于如何帮助你的客户在使用你的接口时避免他们犯错误

  • 提供行为一致的接口

    • 例如每个STL容器都有一个名为size的函数,其功能都是返回容器当前大小
    • 通用操作接口应表现出与内置类型的一致性,如重载operator *而不是设计mul()函数
  • 使用外覆类型(wrapper)提醒调用者传参错误检查,将参数的附加条件限制在类型本身

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

如果想要设计出优秀的classclass,你应该在设计时思考以下问题:

  • 对象该如何创建销毁:包括构造函数、析构函数以及new和delete操作符的重构需求。
  • 对象的构造函数与赋值行为应有何区别:构造函数和赋值操作符的区别,重点在资源管理上。
  • 对象被拷贝时应考虑的行为:拷贝构造函数的设计。
  • 对象的合法值是什么?最好在语法层面、至少在编译前应对用户做出监督。
  • 新的类型是否应该复合某个继承体系,这就包含虚函数的覆盖问题。
  • 新类型和已有类型之间的隐式转换问题,这意味着类型转换函数和非explicit函数之间的取舍。
  • 新类型是否需要重载操作符,需要重载哪些操作符?
  • 什么样的接口应当暴露在外,而什么样的技术应当封装在内?(publicprivate
  • 新类型的效率、资源获取归还、线程安全性和异常安全性如何保证?
  • 这个类是否具备template的潜质:如果有的话,就应改为模板类。

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

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

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

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

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

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


五、实现

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

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

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

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

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

条款31:将文件的编译依存关系降至最低


六、继承与面向对象设计

条款32:确定你的public继承塑膜出is-a关系

is-aderived classderived\ class is a special base classspecial\ base\ class,也就是说子类是一种特殊的基类
public继承主张:能够施行于base classbase\ class对象上的任何事情,都能施行于derived classderived\ class对象上。

条款33:避免遮掩继承而来的名称⭐️

本条款也很好理解—我们知道:内层作用域的同名变量会遮掩外层作用域的同名变量

int x;					           // global作用域
int main() {
    double x;                      // main函数作用域
    x = 2.5;			           // main函数的double x遮掩了global中的int x
    std::cout << x << std::endl;   // 2.5
}

这个名称遮掩规则对于继承来说同样适用,可以认为**derived classderived\ class作用域被嵌入在base classbase\ class作用域内**。

namespace Base {
    // ...
	namespace Drived {
        // ...
    }
}

因此当derived classderived\ classbase classbase\ class中的虚函数覆写,override后base classbase\ class中对应的虚函数将被遮掩。

你可以使用using 声明式或转交函数(inline forwarding functions)让被遮掩的名称重见天日。

条款34:区分接口继承和实现继承⭐️

在本条款中,我们主要讨论public继承下,不同类型接口—纯虚函数、虚函数和非虚函数背后隐藏的设计逻辑

  • base classbase\ class中声明一个prue virtual函数, 是为了让derived classderived\ class强制继承该函数接口

    如果你想要derived classderived\ class实例化,必须提供该函数接口的实现

  • base classbase\ class中声明一个imprue virtual函数,是为了让derived classderived\ class强制继承该函数接口和缺省实现

    如果你不想使用该缺省实现,可以override该虚函数,实现遮掩覆盖

  • base classbase\ class中声明一个non-virtual函数,是为了让derived classderived\ class强制继承该函数接口以及已经规定好的实现

    derived classderived\ class中不允许对该函数接口做任何更改(条款36要求我们不得override类的非虚函数)

一种特殊用法:在基类中为纯虚函数提供一份缺省实现,然后在子类中使用基类名::显示调用,如下所示:

class Airport {...} ;  //机场

class Airplane {
public:
    virtual void fly(const Airport& destination) = 0;
}
// pure virtual函数实现
void Airplane::fly(const Airport& destination) {
    // ...缺省实现
}


class AirPlaneA: public Airplane {
    virtual void fly(const Airport& destination) {
        Airplane::fly(destination);  // 显示调用pure virtual函数实现
    }
}
class AirPlaneB: public Airplane {
    virtual void fly(const Airport& destination) {
        Airplane::fly(destination);  // 显示调用pure virtual函数实现
    }
}

class AirPlaneC: public Airplane {
    virtual void fly(const Airport& destination);
}
// 覆写fly函数接口
void AirplaneC::fly(const Airport& destination) {
    // ...C飞机飞往目的地的方式
}

这样显示调用缺省实现可以提醒用户,以防用户设计子类时忘记实现该接口。

从这里我们可以看出,将纯虚函数、虚函数区分开的并不是在父类有没有实现——纯虚函数也可以有实现,其二者本质区别在于父类对子类的要求不同,前者在于从编译层面提醒子类主动实现接口,后者则侧重于给予子类自由度对接口做个性化适配。非虚函数则没有给予子类任何自由度,而是要求子类坚定的遵循父类的意志,保证其所有子类都继承基类接口的实现

条款35:考虑virtual函数以外的其它选择

本条款想要忠告的是:当你想overridevirtual函数时,不妨往Strategy设计模式的方向考虑

  • 或者将virtual函数作为一个属性抽离出来,然后转移到另一个继承体系的virual函数

条款36:绝不重新定义继承而来的non-virtual函数⭐️

本条款解释了为什么不能在子类中overridenon-virtual函数:

条款37:绝不重新定义继承而来的缺省参数值⭐️

本条款解释了为什么不能在子类中overridevirtual函数的缺省参数值:

条款38:通过复合塑膜出has-a或is-implemented-in-term-of

条款39:明智而审慎地使用private继承

条款40:明智而审慎地使用多重继承

七、模板与泛型编程

条款41:了解隐式接口和编译期多态

条款42:了解typename的双重意义

条款43:学习处理模板化基类内的名称

条款44:将与参数无关的代码抽离

条款45:运用成员函数模板接受所有兼容类型

条款46:需要类型转换时请为模板定义非成员函数

条款47:请使用traits classes表现类型信息

条款48:认识template元编程


八、定制newdelete

条款49:了解new-handler的行为

条款50:了解newdelete的合理替换时机

条款51:编写newdelete时需固守常规

条款52:写了placement new也要写placement delete

九、杂项讨论

条款53:不要轻忽编译器的警告

条款54:让自己熟悉包括TR1在内的标准程序库

条款55:让自己熟悉Boost

一些查阅

记录一些读书时散发的问题思考:

  • 关键字static 的作用:

    1. 修饰局部变量,修饰为静态变量–其生命周期发生变化:

    普通的的局部变量,存放在Stack段;
    用malloc、new等函数声明的局部变量,由用户手动控制,存放在Heap段,用free、delete释放;
    未初始化的全局变量和静态变量存放在BSS段,初始化后的全局变量和静态变量存放在Data段。

  • 重载operator()lambdalambda表达式的作用与区别:

    功能上都是创建一个函数对象(仿函数,Functor),但是lambdalambda匿名函数对象;因此还是有区别的:

    • 对象性质不同:重载 operator() 的函数对象是一个具有状态的对象,可以在其成员变量中保存一些信息,而lambda 匿名函数不具有状态,只是一个纯粹的函数,不能在其内部保存任何信息。
    • 作用域不同:重载 operator() 的函数对象可以在多个函数或者方法之间共享,也可以作为成员变量或者全局变量使用。而 lambda 匿名函数通常只能在定义它的函数或者方法内部使用。

    参考自:重载operator()的意义

  • 为什么C++不提倡使用vector<bool>

    因为vector<bool>实际上是vector<bit>,我们知道,为了C++ 数据类型都必须是可寻址的(单个字节才可以寻址),因此在C++中普通的bool都是占1个字节(8bits)的。而在vector<bool>中的每个bool只占1个bits,这意味着无法将其地址分配给 bool*,因此bool *pb =&v[0]; 不是有效代码

    // CLASS vector<bool>
    template<class _Alloc>
    	class vector<bool, _Alloc>
    		: public _Vb_val<_Alloc>
    	{	// varying size array of bits
    		...
    	}

    参考自:https://stackoverflow.com/questions/17794569/why-isnt-vectorbool-a-stl-container

    比较好的代替写法,就是vector<uint8_t>, vector<unsigned char>,还有std::basic_string<bool>

  • 关键字explicit的作用:

    explicit用于修饰类构造函数,当一个类构造函数被声明为 explicit 时,它将不再被用于隐式转换

    class Example {
    public:
        explicit Example(int value) : m_value(value) {}
    
    private:
        int m_value;
    };
    
    void foo(Example ex) {
        // Do something with ex
    }
    
    int main() {
        // This will not compile because the constructor is explicit
        // foo(5);
        
        // This will compile because we explicitly call the constructor
        foo(Example(5));
    }

Reference

https://normaluhr.github.io/2020/12/31/Effective-C++/