【C++】万字详解Effective C++

文章目录

  • 引言
    • 一、让自己习惯C++
      • 条款01:视C++为一个语言联邦
      • 条款02:尽量以const,enum,inline代替#define的使用
      • 条款03:尽可能使用const
      • 条款04:确定对象被使用前已先被初始化
    • 二、构造/析构/赋值运算
      • 条款05:了解C++默默编写并调用哪些函数
      • 条款06:若不想使用编译器自动生成的函数,就该明确拒绝
      • 条款07:为多态基类声明virtual析构函数
      • 条款08:别让异常逃离析构函数
      • 条款09:绝不在构造和析构过程中调用virtual函数
      • 条款10:令operator=返回一个reference to *this
      • 条款11:在operator=中处理“自我赋值”
      • 条款12:复制对象时勿忘其每一个成分
    • 三、资源管理
      • 条款13:以对象管理资源
      • 条款14:在资源管理类中小心coping行为
      • 条款15:在资源管理类中提供对原始资源的访问
      • 条款16:成对使用 new 和 delete 时要采取相同形式
      • 条款17:以独立语句将newed对象置入智能指针
    • 四、设计与声明
      • 条款18:让接口容易被正确使用,不易被误用
      • 条款19:设计 class 犹如设计 type
      • 条款20:宁以 pass-by-reference-to-const 替换 pass-by-value
      • 条款21:必须返回对象时,别妄想返回其reference
      • 条款22:将成员变量声明为private
      • 条款23:宁以non-member、non-friend替换member函数
      • 条款24:若所有参数皆需类型转换,请为此采用non-menmber函数。
      • 条款25:若所有参数皆需类型转换,请为此采用non-menmber函数。
    • 五、实现
      • 条款26:尽可能延后变量定义式的出现时间
      • 条款27:尽量少做转型动作
      • 条款28:避免返回handles指向对象的内部成分
      • 条款29:为”异常安全“而努力是值得的
      • 条款30:透彻了解inlining的里里外外
      • 条款31:将文件间的编译依存关系降至最低
    • 六、继承与面向对象设计
      • 条款32:确定你的public继承塑模出 is-a 关系
      • 条款33:避免遮掩继承而来的名称
      • 条款34:区分接口继承和实现继承
      • 条款35:考虑virtual函数以外的其他选择
      • 条款36:绝不重新定义继承而来的non-virtual函数
      • 条款37:绝不重新定义继承而来的缺省参数值
      • 条款38:通过复合塑模出 has-a 或“根据某物实现出”
      • 条款39:明智而审慎地使用private继承
      • 条款40:明智而审慎地使用多重继承
    • 七、模板与泛型编程
      • 条款41:了解隐式接口
      • 条款42:了解typename的双重意义
      • 条款43:学习处理模板化基类内的名称
      • 条款44:将与参数无关的代码抽离templates
      • 条款45:运用成员函数模板接受所有兼容类型
      • 条款46:需要类型转换时请为模板定义非成员函数
      • 条款47:请使用 traits classes 表现类型信息
      • 条款48:认识template元编程
    • 八、定制new和delete
      • 条款49:了解 new-handler 的行为
      • 条款50:了解 new 和 delete 的合理替换时机
      • 条款51:编写 new 和 delete 时需固守常规
      • 条款52:写了 placement new 也要写 placement delete
    • 九、杂项讨论
      • 条款53:不要轻忽编译器的警告
      • 条款54:让自己熟悉包括 TR1 在内的标准程序库
      • 条款55:让自己熟悉 Boost

引言

本文为前段时间读《Effective C++(第三版,侯捷译)》时做的个人笔记。

怎么说呢?我觉得这本书里东西有些是现在习以为常,早就在使用的,有些东西确实不常用,甚至跟个人使用习惯相悖,但是仔细想想也确实如书中所建议的一样,还有一些跟设计模式相关的,如果你读过设计模式相关的资料,应该对这部分内容不陌生。后面关于萃取器和模板元编程的部分这本书只是泛泛而谈,如果你了解过STL,你会发现这部分完全有能力抽出来单独讲讲,这可以说是一个宏大的篇章。因此后面的部分更像是抛砖引玉,仅仅是为你指引方向,却不深入,如果感兴趣还需要你自己去了解。

在此给出此书的电子版网址:Effective C++

一、让自己习惯C++

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

在设计之初,C++只是在C语言上加上了一些面向对象的特性,其最初的名字“C with Classes”也反映了这个关系。

如今,伴随着时间的流逝,C++已经发展成了一个同时支持过程形式,面向对象形式,函数形式,泛型形式,元编程形式的语言。我们该如何理解这个语言呢?最简单的办法是将C++视为一个由相关语言组成的联邦而非单一语言。

也就是说将C++这个主语言,理解成几个次语言的集合,在其某个次语言中,各种守则与通例都比较简单他,直观易懂,而当你从一个次语言切换到另一个次语言时,守则可能会改变。这样的次语言可以分为以下四个:

  • C语言:说到底,C++也是从C发展而来,身上自然带着C的基因。很多时候C++对问题的解法不过是较为高级的C解法;
  • 面向对象C++: 这部分也是“C with Classes"所述求的;
  • Template C++:这是C++的泛型编程部分,template威力强大,它带来了崭新的编程泛型,也就是所谓的模板元编程;
  • STL :STL是一个template库,它对容器,迭代器,算法,分配器以及函数对象的规约有极佳的紧密配合与协调;

在这个条款中,作者给了一种自己的划分方式帮助你理解现代C++,而等你实际接触C++一段时间后再慢慢去代入理解会发现,也许这种划分为4个次语言的方式能够更容易的帮助你理解C++。

条款02:尽量以const,enum,inline代替#define的使用

❌ 缺点一:#define定义常量

#define ASPECT 1.653

当预处理器遇到#define语句时,执行的大多只是简单的文本替换。

当你以#define语句去定义某个常量时,在编译器开始处理源码之前这条语句可能已经被预处理器给处理了,于是相关的记号名称有可能并没有进入符号表内。

当你的编译器报错的时候,提示的也仅仅是1.653而不是ASPECT,到时候你可能会一头雾水,某种意义上,这种方式阻碍了你快速定位错误

当你的这个常量被定义到某个并非你所写的头文件时,你看到它的第一眼可能会对1.653来自何处毫无概念,于是,你还要因为追踪它而浪费时间(现代编译器在使用上已经相当便利,能够帮你快速定位,但是之前的编译器可没这么智能)。

解决之道就是以一个常量替换上述的#define:

const double ASPECT =  1.653;

而当你的class里需要一个const常量时,为了限制其作用域必须在class内部,并且此常量之多只有一份实体,建议将其声明为class内部的一个私有静态成员常量:

class GamePlayer{
    private:
    	static const int NUM = 5;//常量声明式
    ...
};

这里是个声明式而非定义式。只要你不取地址,你可以只声明并使用而无需提供定义式。但如果你取某个class常量的地址,或纵使你不取其地址而你的编译器坚持要看到一个定义式,你就需要提供:

const int GamePlayer::NUM;

这个式子需要放到实现文件而非头文件。class常量已经在声明时获得初值,因此定义时不可以再设置初值。

早期的编译器不允许class内部设置初值,而如果这种情况下,你的class在编译时一定会用到某个常量值时,你可以用enum hack代替它:

class GamePlayer{
    private:
    	enum{ NUM = 5 };
    	int scores{NUM};
    ...
}

enum hack的行为某种意义上比较像#define而非const:例如对一个const取地址是合法的,但对一个enum取地址就不合法;还有,如果你不想让别人用一个指针或者引用指向你的const,enum可以帮你实现这个约束。优秀的编译器不会为整型const对象设定另外的存储空间,但是不够优秀的编译器却可能不会这么做,而enum和#define一样。绝不会导致非必要的内存分配。

❌缺点二:#define定义宏函数

具体请参见:宏的误用

这部分经常会产生令人意想不到的错误,现代C++中也支持泛型编程,建议使用模板函数去代替这部分函数的使用。

条款03:尽可能使用const

const的一件奇妙的事情是:它允许你指定一个语义约束,即指定一个不该被改动的对象,而编译器会强制实施这项约束。它允许你告诉编译器和其他程序员某个值不应该被改变,而你确实也应该说出来,它可以让你在编译期间获得编译器的帮助,确保这条约束不会被违反。

具体的使用规则请参见:const 关键字使用

const int * a;//和 int const * a 是一样的,只区分const在*号的哪一边
int * const a;

如果你还不明白上述代码什么意思或者过一段时间会忘记,这里给出一个帮助你记忆的手段:

Bjarne在他的《The C++ Programming Language》里面给出过一个助记的方法: 把一个声明从右向左读。

int * const a : ( * 读成 pointer to ) a is a const pointer to int // a 是一个指针,这个指针的地址不能改变,即指针的指向不能改变,但是可以利用这个指针改变值。

const int * a : a is a pointer to const int; //a是一个指针,这个指针指向的地址存放的是一个不允许更改的int。即指针可以指向别的位置(指针的指向可以改变),但是就是不能通过该指针改变地址中的值。

希望可以帮助到你。

令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。

很多时候程序员在设计一个类的时候,之所以没考虑那么多的原则,很大一部分原因是你既是这个类的设计者,又是这个类的使用者,基于此事实,你比其他任何人都更了解你的类,也就避免了很多错误的用法。但这实际上是不对的,因为一些项目往往需要很多人同时完成,或者说你的代码日后需要不断维护,而几个月后设计者本身可能也会忘了具体的使用规则,再或者由于人员更替,你的代码日后交给其他同事去更新维护,这些都是很容易出问题的。

const Rational operator* (const Rational& lhs,const Rational& rhs);

如果上式是一个实现两个有理数相乘的函数,很多程序员第一次看到这个声明不免斜着眼睛说:为什么返回一个const?原因是如果不这样,客户就能实现这样的暴行:

(a*b)  = c;

你也许会狡辩:为什么会有人想对两个数值的乘积做赋值操作?但事实上很多人可能会无意识的这么做,也许只是少打了一个=:

if(a* b = c)//这是很有可能的事情,毕竟人难免有失误的时候

而这时候,将函数的返回值声明为const 可以预防这个没有实际意义的赋值动作,这即是原因。

const成员函数

基于以下两个原因,将const作用于成员函数很重要:

  • 它们使class接口更容易理解,得知哪个函数可以改动对象内容,哪个函数不能,这是很重要的;
  • 它们使“操作const对象”成为可能,这对编写高效代码很关键;

const是函数签名的一部分,同样的函数,可以实现const 版本的和non-const版本,在同时实现两个版本时,const对象只能调用const版本的,非const对象只能调用非const版本的:

class Test
{
public:
    void show(int a) const
    {
        cout << "const版本: " << a << endl;
    }

    void show(int a)
    {
        cout << "non-const版本: " << a << endl;
    }
};

int main()
{
    Test      test1;
    const Test test2;
    int       a = 1;
    test1.show(a);
    test2.show(a);
}

运行结果:

non-const版本: 1

const版本: 1

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

如上述,由于const是函数签名的一部分,所以针对同样的函数,你可以实现两种版本:const与non-const版本。有时候这两个版本的函数内部实现其实是一样的,而把函数实现在两种版本的函数里写两次,实在有些不妥。你应当做的是实现其中一个版本,然后用另一个版本去调用它。而是谁调用谁呢?

const函数向你保证在其实现当中,绝对不会更改成员变量,而non-const函数无法做出这样的保证。换句话说const函数是稳定的,non-const函数是不稳定的。如果实现non-const函数,令const函数去调用non-const函数,用稳定的去调用不稳定的,其结果必然还是不稳定的;而令不稳定的去调用一个稳定的,却并没有什么不妥,那么答案就显而易见了。

那么,成员函数如果是const意味着什么?这就有两个流行的概念:bitwise constness (即:physical constness)和logical constness。

bitwise constness

该阵营的人相信,成员函数只有不更改对象的任何成员变量时才可以说是const。也就是说它不更改对象内的任何一个bit。

这种观点的好处是容易侦测违法点:编译器只需寸照成员变量的赋值动作即可。bitwise constness正是C++对常量性的定义,因此const成员函数不可以更改对象内任何non-static成员变量。

不幸的是有些成员函数虽然不具备const性质,却能通过bitwise测试。更具体的说,一个更改了“指针所指物”的成员函数不能算是const,但是如果这是个class with pointer(带有指针的class),这个指针属于这个类的成员变量,那么将该函数声明为bitwise const 不会引发编译器错误。这就导致反直观的结果:假设我们有个类CtestBlock,它内部含有一个char*:

class CTestBlock
{
public:
    char& operator[](std::size_t posion) const
    {
        return pText[posion];
    }

private:
    char* pText;
}

这个class将operator[]函数声明为const函数,编译器也不会报错。但是看看它允许发生什么事:

const CTestBlock cctb("hello");
char* pc = &cctb[0];
*pc = 'j';//现在cctb的值为‘jello'

你终究还是改变了const常量cctb的值。

logical constness

上述这种情况导出所谓的 logical constness。这一派拥护者主张,一个const成员函数可以修改它所处理的对象内的某些bits,但是只有在客户端侦测不出的情况下才能如此:

class CTestBlock
{
public:
    std::size_t length() const
    {
        if (!lengthIsVaild)
        {
            textLength    = std::strlen(pText);
            lengthIsVaild = true;
        }
    }

private:
    char*       pText;
    std::size_t textLength;//错误!在const成员函数内不能改变成员变量的值
    bool        lengthIsVaild;
}

length函数的实现当然不是bitwise constness,因为它对成员变量做了更改。这种修改对const CTextBlock对象而言虽然是可以接受的,但是编译器不同意。他们坚持bitwise constness。怎么办?

解决方案很简单:利用mutable关键字释放掉non-static成员变量的bitwise constness约束:

class CTestBlock
{
public:
    std::size_t length() const
    {
        if (!lengthIsVaild)
        {
            textLength    = std::strlen(pText);
            lengthIsVaild = true;
        }
    }

private:
    char*       pText;
    mutable std::size_t textLength;//错误!在const成员函数内不能改变成员变量的值
    mutable bool        lengthIsVaild;
}

请记住:

✔ 将某些东西声明为const可以帮助编译器侦测出错误哦用法,const可被施加于任何作用于内的对象,函数参数,函数返回类型,成员函数本体。

✔ 编译器强制实施bitwise constness,但是编写程序时应该使用 logical constness。

✔ 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。

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

如果你使用C语言(参见条款1,C++的子语言),就不保证发生初始化;但是一旦进入C++部分,规则就有些变化。这就很好的解释了为什么array(来自C part of C++)不保证其内容被初始化,而vector有此保证。这似乎是个无法一概而论的事情,但是最佳的处理办法是:永远在对象使用之前先将其初始化。

对于无任何成员的内置类型,你必须手动完成此事。而对于内置类型以外的其他东西,初始化的责任就落在了构造函数身上,规则很简单:确保每一个构造函数都将对象的每一个成员初始化。

C++的对象的成员变量的初始化动作发生在构造函数本体之前,因此请使用列表初始化的方式进行初始化。

class CTestBlock
{
public:
    CTestBlock(){a = 0;}//实际上先调用a的默认构造函数,在调用a的拷贝构造函数
    CTestBlock():a(0){}//调用a的拷贝构造函数
private:
   int a;
}

C++有着十分固定的成员初始化次序:基类更早于子类初始化,class内部成员变量总是以其声明的次序被初始化。

所谓static对象,其生命周期从被构造出来直到程序结束为止。函数内的static对象被称为local static对象,因为他们对于函数而言是local的,其他static对象称为non-local static对象。程序结束时static 对象会被销毁,析构函数会在main借结束时被自动调用。

所谓编译单元是指产出单一目标文件的那些源码,基本上它是单一源码文件加上其所含入的头文件。

C++对于定义于不同编译单元内的non-local static 对象的初始化相对次序并无明确定义。而一个小小的设计能够完全消除这个问题:将每个non-local static对象搬运到一个专属函数中,返回一个reference指向它所含的对象,然后用户表调用这些函数,而不直接涉及这些对象。换句话说,non-local static对象通过该涉及变成了local对象。如果你对涉及模式比较熟悉,你可能会恍然大悟,这也是Singleton模式的常见的实现手法。

这个手法的基础在于:C++保证,函数内的local static对象会在该函数被调用期间首次遇上该对象的定义式时被初始化,如果你以“函数调用”的形式替换直接访问non-local static对象,你就获得了保证,保证你所获得的的那个引用指向一个历经初始化的对象。更棒的是:如果你从未调用该函数,就绝对不会引发这个对象的构造成本和析构成本,真正的non-local static对象可没有这种便宜!

任何一种non-const static对象,不论它是local还是non-local,在多线程环境下“等待某事发生“都会有麻烦。处理这个麻烦的一种做法是:在程序的单线程启动阶段手动调用所有的reference-returning函数,这可消除与初始化有关的竞速趋势。

请记住:

✔ 为内置型对象进行手工初始化,因为C++不保证初始化它们;

✔ 构造函数最好使用成员初值列,而不妖使用在构造函数中赋值的操作;初值列列出的成员变量顺序,其排列顺序应该与class中的声明次序相同;

✔ 为免除“跨编译单元之初始化顺序“问题,请以local static对象替换non-local static对象;

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

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

class Empty{ };

什么时候这个类才不再是个空类呢?当C++处理它之后。

编译器会为它声明一个构造函数,一个拷贝构造函数,一个拷贝赋值函数,一个析构函数,C++11之后还会有一个移动赋值函数,一个移动构造函数,所有这些自动生成的函数都是public且inline的。

如果你对这块还不了解,请参考:C++ Big Five

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

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

✔ 为驳回编译器自动暗自提供的功能,可将相应的成员函数声明为private并且不予实现。使用集成像Uncopyable这样的base class也是一种做法。也可以使用=delete,抑制这类函数的生成。

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

如果类被考虑用作base class,则其析构函数需要设置为virtual。换句话说,任何class只要带有virtual函数都几乎确定应该也有一个virtual析构函数。

欲体现出virtual函数,对象必须携带某些信息,主要用来在运行期间决定哪一个virtual函数该被调用。这份信息通常是由一个所谓vptr(virtual table pointer)指针指出。vptr指向一个由函数指针构成的数组,称为vtbl(virtual table);每一个带有virtual 函数的class都有一个相应的vtbl。当对象调用某一virtual函数,实际被调用的函数取决于该函数的vptr所指的那个vtbl-编译器在其中寻找适当的函数指针。

具体实现细节参见:浅析C++中的虚函数

这里想要体现的是,如果你没有任何合适的理由,将一个不打算用作base类的类的析构函数无端的写成虚函数,会导致其内存布局发生变化(因为要增加虚函数表),从而导致可能的错误。

如果你不了解你所继承的base类长什么样子,谨慎继承。不然有可能会被误伤。比如:标准string类不含有任何virtual函数,而如果你设计一个类去继承该类:

class SpecialString : public std::string{
    ...
};

如果使用者这么去使用这个类:

SpecialString* pss = new SperialString("hello");
std::string * ps;
...
ps = pss;
...
delete ps;//未有定义!可能会引起内存泄漏,因为SpecialString析构函数没有被调用

相同的分析适用于任何不带virtual析构函数的class,包括stl的容器等等。

有时候令class带一个pure virtual析构函数,可能颇为便利。如果你希望拥有抽象class,但手上没有任何pure virtual函数怎么办?由于抽象函数总是被用作base class,也就是说这个抽象类的析构函数应当是虚函数,所以,不妨将这个抽象类的析构函数设置为纯虚函数。

class CTestBlock
{
public:
    virtual ~CTestBlock() = 0;
};

这里有个窍门:你必须为这个纯虚析构函数提供一份定义:

~CTestBlock::CTestBlock(){}//是的,纯虚函数可以有定义,很多人都不知道这一点

如果你不为其提供定义,将来你的派生类析构的时候,最后一定会调用基类的析构函数,而到那时候,你的连接器会发出抱怨。

请记住:

✔ 带多态性质的base classes应该声明一个virtual析构函数。如果class带有任何虚函数,它就应该拥有一个虚析构函数。

✔ Class的设计目的如果不是作为base class使用,或不是为了具备多态性,就不该声明virtual析构函数。

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

不要在析构函数里抛异常,因为这可能导致同时抛出两个或更多异常,这对C++来说是无法处理的。

✔在抛出异常时,如果当前代码块没有处理这个异常,则会把这个异常抛给上一层调用进行处理,问题在于在返回上一层之前,局部变量需要调用自己的析构函数来释放掉,如果在析构函数中再次抛异常,此时相当于同时抛两个异常,就会引发未定义的行为。此外,例如vector这样的 STL,在作为局部函数被销毁时会调用其中每一个元素的析构函数来进行销毁,如果在销毁元素的过程中抛异常也会导致同时抛出多个异常。

✔有两种解决方法,但是其实都不是很优雅,一是把析构函数中的抛异常改为直接终止程序,比如通过std::abort();二是不抛异常,直接使用 try…catch 把异常处理掉。

✔相对来说,如果一定要在释放资源的过程中抛异常,比较好的方法是在一个新定义的、非析构的函数,比如close(),在这个函数里释放资源并抛异常,这样用户就可以自己掌握异常处理的时机和方式了。

总之,永远不要在析构函数里抛出异常。

class DBConn
{
public:
    ... 
    void close()
    {
        db.close();
        closed = true;
    }
    ~DBConn()
    {
        if (!closed)
        {
            try
            {
                db.close();
            }
            catch (...)
            {
                ...
            }
        }
    }

private:
    DBConnection db;
    bool         closed;
};

把调用close的责任从DBConn析构函数手上移到DBConn客户手上,即便是有错误发生——如果close的确抛出异常——而且DBConn吞下该异常或结束程序,客户没有立场抱怨,毕竟他们曾有机会第一手处理问题,而他们选择了放弃。

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

这个问题要从继承谈起,如果你的基类在构造函数和析构函数中调用了虚函数,根据初始化的顺序,当你定义一个子类时,这个子类会先调用父类的构造函数,当这个子类被销毁时,它最后也会调用父类的析构函数,但是虚函数在这个过程中就可能出现问题。

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

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

正常情况,你返回一个值确实不会有问题,但是C++支持这样:

int x,y,z;
x = y = z = 15;//赋值连锁形式

为了实现连锁赋值,赋值操作符必须返回一个reference指向操作符的左侧实参,这是你为classes实现赋值操作符时应该遵循的协议。

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

w = w;//这看起来有点蠢,但它合法
a[i] = a[j];//自我赋值有时候可并不容易看出来,存在潜在的自我赋值

例如,operator=这么写:

Widget& Widget::operator=(const Widget& rhs){
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

这么看表面上不会有什么问题,但是如果遇到了自我赋值就会出现问题,传统的解决方案是在最前面做一个“证同测试”达到“自我赋值”的检验目的:

Widget& Widget::operator=(const Widget& rhs){
    if(this == &rhs) return *this;
    
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

但是这个新版本仍然存在异常方面的麻烦。更明确的说,如果“new Bitmap"导致异常,widget最终会持有一个指针指向被删除的Bitmap,这样的指针有害,你无法安全地删除它们,甚至无法安全地读取它们,唯一能对它们做的安全事情是付出许多调试能量找出错误的起源。

令人高兴的是,让operator具备“异常安全性”往往自动获得“自我赋值安全”。因此愈来愈多人对“自我赋值”的处理态度是倾向于不去管它,把焦点放在实现“异常安全性”上。例如:

Widget& Widget::operator=(const Widget& rhs){
    Bitmap* pOrig = pb;
    pb = new Bitmap(*rhs.pb);
    delete pOrig;
    return *this;
}

可以发现,在解决异常安全问题的同时,这种实现也同时解决了拷贝复制自身的安全问题。当然此时效率不是最佳的,因为额外进行了一次内存申请和释放,并调用了一次Bitmap的构造函数。如果需要追求效率,可以试着像之前那样进行一次相同检测,但深入一步讲,这里其实存在一个 trade-off,因为相同检测也有代价,会导致源代码和目标代码略微增大,也会导致控制流的分叉,这些都可能导致运行时的效率下降,比如导致指令预取成功率降低,缓存命中率降低等等,需要结合拷贝赋值自身这种情况发生的频率来衡量,涉及到的性能优化问题不在此展开。

用 “copy and swap” 的方式实现拷贝赋值函数

copy and swap 的实现方式是一个同时保证了“拷贝复制自身”和“异常安全”两个问题的方案,这在实际场景中很常见:

class Widget {
    ...
    void swap(Widget& rhs); // exchange *this’s and rhs’s data; ... // see Item 29 for details
};
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs); // make a copy of rhs’s data
    swap(temp); // swap *this’s data with the copy’s
    return *this;
}

下面是一个变种写法,利用到了函数传递的拷贝构造函数,但实质上没有区别:

Widget& Widget::operator=(Widget rhs) // 注意:这里是pass by value
{
    swap(rhs); // swap *this’s data with the copy’s
    return *this;
}

相对来说第一种写法看上去更清晰,但是第二种写法更容易利用编译器进行优化,总体而言差别不大。

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

这个条款正如其字面意思,当你决定手动实现拷贝构造函数或拷贝赋值运算符时,忘记复制任何一个成员都可能会导致意外的错误。

当使用继承时,继承自基类的成员往往容易忘记在派生类中完成复制,如果你的基类拥有拷贝构造函数和拷贝赋值运算符,应该记得调用它们:

class PriorityCustomer : public Customer {
public:
    PriorityCustomer(const PriorityCustomer& rhs);
    PriorityCustomer& operator=(const PriorityCustomer& rhs);
    ...

private:
    int priority;
}

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
    : Customer(rhs),                // 调用基类的拷贝构造函数
      priority(rhs.priority) {
    ...
}

PriorityCustomer::PriorityCustomer& operator=(const PriorityCustomer& rhs) {
    Customer::operator=(rhs);       // 调用基类的拷贝赋值运算符
    priority = rhs.priority;
    return *this;
}

注意,不要尝试在拷贝构造函数中调用拷贝赋值运算符,或在拷贝赋值运算符的实现中调用拷贝构造函数,一个在初始化时,一个在初始化后,它们的功用是不同的。

请记住

✔Copying函数应该确保复制对象内的所有成员变量以及所有base class成分;

✔ 不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数中,并由两个copying函数共同调用。

三、资源管理

条款13:以对象管理资源

void f(){
    Investment* pInv = createInvestment();
    ...
    delete pInv;
}

比如在这个函数中,如果中间部分有过早的返回语句,或者退出函数的语句,将会导致delete函数永远无法被执行,也就引起了所谓的内存泄漏。

也许你会说你的代码足够小心,不会犯这么低级的错误,但扪心自问一下,这部分代码未来大概率会或由于业务功能的调整,或由于人事变动,或因为代码架构需要修改等种种原因做出调整,而每回调整代码,当事人都或多或少的需要留心这种问题,久而久之,你就会意识到“单纯依赖f实现资源的释放“并不是一个明智的决定。

而为了确保资源永远被正确释放,我们需要把资源放进对象内,依赖对象的析构函数实现对资源的自动管理。标准库中的autoptr就是这样的一个产品(原书此处关于智能指针的内容已经过时,在 C++11 中,通过专一所有权来管理RAII对象可以使用std::unique_ptr,通过引用计数来管理RAII对象可以使用std::shared_ptr。),这种产品背后的两个关键设计思想是:

  • RAII: 资源取得时机便是初始化的时机,详情参考:浅析RAII思想
  • 管理对象运用析构函数确保资源被释放:一旦对象离开作用域,其资源马上被释放。

autoptr被销毁时会自动删除它所指之物,所以一定要注意别让多个autoptr同时指向同一对象,以防被删除多次。为了防止这个行为,autoptr的复制行为不同于普通的copy构造函数:

std::auto_ptr pInv2(pInv1);//现在PInv2指向对象,pInv1被设为null

这一诡异的复制行为,受限于其底层条件:受autoptrs管理的资源必须绝对没有一个以上的autoptr同时指向它,这意味着autoptr并非管理动态分配资源的神兵利器。STL容器要求其元素发挥正常的复制行为,因此这些容器可容不得autoptr。

而”引用计数型智能指针“shared_ptr则能很好的解决这个问题。

不管是autoptr还是shared_ptr,在其构造函数内的删除都是delete,而非delete []。这意味着那些动态分配的array身上使用智能指针并不是个好主意,但不幸的是:这种代码却会编译通过。

请记住

✔ 为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。

✔ 两个常被使用的RAII classes分别是tr1::shared_ptr和auto_ptr.前者通常是较佳的选择,因为其copy行为比较正常。

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

RAII思想是资源管理类的脊柱,autoptr和shared_ptr也将这个观念表现在heap_based资源上。但并非所有的资源都是heap_based,对于那种资源而言,这种智能指针往往不适合作为资源管理者,有些时候,你需要建立自己的资源管理类。

这时候,请谨慎考虑这种管理类的复制行为。通常情况你的选择有两种:

  • 禁止复制。
  • 对底层资源祭出引用计数法。

因此,通常只要内含一个tr1::shared_ptr成员变量,RAII对象便可以实现引用计数行为。

【C++】万字详解Effective C++_第1张图片

shared_ptr允许自定义删除器,即当内部引用计数为0的时候自动调用用户提供的删除器进行删除,这是一个缺省的第二参数。

void end_connection(connection *p)
{
    disconnection (*p);
}
 
void f(destination &d)
{
    connection c=connect(&d);
    shared_ptr p(&c,end_connection);
    ....
    
    //当f函数退出或者异常退出,p都会调用end_connection函数
}

请记住

✔ 复制RAII对象必须一并复制它所管理的资源,所以资源的coping行为决定RAII对象的coping行为。

✔ 常见的RAII对象的coping行为是:抑制coping,施行引用计数法。不过其他行为也都可能被实现。

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

管理类存放的是资源的指针,我们无法从管理类直接得到一个资源对象(只能得到一个指针,通过指针找到对象)。所以我们最好用显式转化或者隐式转换(自动类型转换)来得到一个资源对象:

class employee{...};
 
class manager
{
    ...
private:
public:
    employee* e;
    ...
    employee get() const  
	{
		return *e;
	}
    //这是显示转化
    
    operator employee() const
    {
        return *e;
    }
    ...
};
 
manager m(...);
employee emp = m.get();//调用显式
 
employee m1 = m;
//隐式,manager 对象转换成了 employee 对象 

请记住

✔ 客户往往要求访问原始资源,所以每一个RAII对象应该提供一个访问其所管理之资源的方法。

✔ 对原始资源的访问可能经由显示转换显式转换或隐式转换,一般而言显式转换比较安全,但隐式转换对客户比较方便。

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

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

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

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

这里在执行函数调用之前会发生三件事:

  • 调用priority()
  • 执行new Widget
  • 调用std::tr1::shared_ptr构造函数

事实也确实如此,但问题的关键在于C++编译器将会以什么样的执行次序完成这些事情呢?都有可能,完完全全取决于编译器。而唯一可以确定的是“执行new Widget”一定会在“调用std::tr1::shared_ptr构造函数”之前执行,因为前者是后者的参数。那问题就来了,第一条语句会什么时候执行呢?第一?第二?第三?问题出在第二顺位上:

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

如果priority()调用异常会发生什么事?在这种情况下第一步得到的指针会被遗失,因为它还没有被置入shared_ptr,而这就发生了内存泄漏。这是因为在“资源被创建”和"资源被转换为资源管理对象"两个时间点之间存异常干扰导致的。而假如这样的问题真的发生,你可以设想一下你到时候能分析出原因的可能性。

避免这种操作的办法也很简单,单独写就行了:

std::tr1::shared_ptr pw(new Widget);
processWidget(pw,priority());

✔以独立语句将newed对象存储于智能指针当中。如果不这样做,一旦异常被抛出,有可能导致难以察觉的内存泄漏。

四、设计与声明

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

1、保证参数一致性:

class Date{
    public:
        Date(int month,int day,int year);
};

如果你设计出这种接口,那么用户有可能会从两个方面犯错:

  1. 传递次序出错:Date d(30,3,2023);
  2. 无效数据:Date d(3,30,2023);

既然这样,让我们导入简单的外覆类型来区别天数,月份和年份,然后于Date中使用这些类型:

【C++】万字详解Effective C++_第2张图片

然而,这仅仅解决了第一个问题,为了使数据合法,还应该有其他设计:

【C++】万字详解Effective C++_第3张图片

2、保证接口行为一致性:

内置数据类型(ints, double…)可以进行加减乘除的操作,STL中不同容器也有相同函数(比如size,都是返回其有多少对象),所以,尽量保证用户自定义接口的行为一致性。

3、如果一个接口必须有什么操作,那么在它外面套一个新类型

employee* createmp();//其创建的堆对象要求用户必须删除

如果用户忘记使用资源管理类,就有错误使用这个接口的可能,所以必须先下手为强,直接将 createmp() 返回一个资源管理对象,比如智能指针share_ptr 等等:

tr1::share_ptr createmp();

如此就避免了误用的可能性。

4、有些接口可以定制删除器,就像 STL 容器可以自定义排序,比较函数一样

tr1::share_ptr p(0, my_delete());//error! 0 不是指针

tr1::share_ptr p(static_cast(0), my_delete());//定义一个 null 指针

第一个参数是被管理的指针,第二个是自定义删除器。

请记住:

✔好的接口容易被正确使用,不容易被误用。

✔“促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。

✔“阻止误用”的办法包括建立新类型,限制类型上的操作,束缚对象值以及消除客户的资源管理责任。

✔tr1::shared 支持定制型删除器,这可防范DLL问题,可被用来自动解锁互斥锁等等。

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

对于每一个 class 都要精心设计,要考虑其构造析构函数,初始化和赋值,继承,类型转换,运算符重载,值传递等问题。

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

函数的传值以及返回值默认情况下是值传递的形式,但是这种方式在传递的时候由于会调用对象的构造函数,而构造完之后的对象占用一定的内存,因此其实并不是最好的方式。推荐采用const T&的形式传递,原因如下:

const:为了安全,保证函数内只有对象的访问权,防止误写;

&:为了效率,传引用不会引起对象的拷贝构造,直接传地址仅仅占用4个或8个字节的内存,这可比多数情况下pass by value占用的字节要少得多;

如果不能传const T&,也要尽量考虑const T,T&的形式。

另外,值传递还有另外一个缺点:有可能造成对象切割:

class base_class
{
    virtual void func() const;
    ...
};
 
class derived_class
{
    virtual void func() const;
    ...
};
 
void print_class(base_class b);//这是一个打印函数
 
derived_class d;
print_class(d);

当我们把 d 传入后,参数 b 被构造成了一个父类对象,调用 virtual 函数的时候不会调用子类函数。但我们传入的是子类对象。

一般而言,你可以合理假设pass by value并不昂贵的对象就是内置类型,STL迭代器和函数对象。至于其他任何东西都请遵守本条款的忠告。

请记住:

✔尽量以pass by reference to const 替换pass by value;

✔以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言,pass by value更妥当;

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

不要试图返回local对象的引用,因为其生命周期在出了所在的函数作用域之后就会消亡。

const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
    Rational* result = new Rational();
    return *result;
}

如果你考虑在heap内构造一个对象,并返回reference指向它,Heap-based对象由new创建,如上。这样的话你还是得付出调用一次构造函数的代价,因为分配所得的内存将一个适当的构造函数完成初始化动作。但是新的问题出现了,谁该对这个被你new出来的对象执行delete?

w = x * y * z;//这行代码调用了两次operator*,谁来负责两次delete?有delete的时机吗?这绝对会导致内存泄漏!

不要期望客户永远按照你设定的方式去进行使用接口,你应该保证你的接口在通常情况下不会出现问题。

有些人可能会对上面的代码做出改良:

const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
	static Rational result;
    result =...;
    return result;
}

这绝对也是一个糟糕的设计!因为客户代码完完全全有可能这么写:

if((a*b) == (c*d)){...}

里面的判断式永远为true! 你应该为提出这个念头而感到脸红。

请记住:

✔绝对不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local stati对象而有可能同时需要多个这样的对象。

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

首先,从语法一致性上,一个类中的所有成员变量不是public,那么客户在访问的时候就只能通过成员函数,而无需打算访问时疑惑是用小圆点.还是使用函数,对于客户而言,都是函数。

其次,使用函数可以让你对成员变量的处理更精确。如果你令你的成员变量时public,这意味着所有的客户都可以无所顾忌的取得或设定成员变量;但如果你以函数取得或设定其值,你就可以实现出“不准访问”,”只读访问“,以及“读写访问”,甚至是“只写访问”。

最后,最重要的一点:封装。如果你通过函数访问成员,日后当你为了某个命名规范,或者单纯想替换掉这个成员变量时,客户端的代码一点也不需要修改。

你可能会狡辩道:那声明为protected不是也一样吗?假设我们有一个public成员变量,而我们最终取消了它,那么有多少代码会被破坏呢?所有使用它的客户代码都会被破坏!因此public成员变量完全没有封装性。而假设这个成员变量时protected呢?所有使用到它的子类会被破坏,可见,protected和public一样也是缺乏封装性的。

不知道你怎么理解访问权限。我认为C++的访问权限实际上只有两种:public和private。在用户的视角,public就是public,private和protected被他一同视作了private;在子类的视角,private就是private,public和protected被他一起视作了public。只不过是视角不同而已。

请记住:

✔ 切记将成员变量声明为private。这可赋予客户访问数据的一致性,可细微划分访问控制,允许约束条件获得保证,并提供class作者以充分的实现弹性;

✔ protected并不比public更具封装性;

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

如果你有一个类,有很多执行动作:

class test{
    public:
    void action1();
    void action2();
    void action3();
}

如果用户需要一下执行所有的动作呢?于是test也提供一个这样的函数:

class test{
    public:
    void action1();
    void action2();
    void action3();
    void clearEverything();//调用action1,2,3
}

当然这一功能也可以由一个non-menber函数提供;

void clearEverything(test& wb){
	wb.action1();
	wb.action2();
	wb.action3();
}

现在问你:哪一种方式更好?

面向对象守则要求,数据应该与操作数据的算法绑定到一起,这意味着建议member函数是较好的选择。不幸的是这个建议并不正确。这是基于面向对象真实意义的一个误解。

面向对象守则要求数据应该尽可能的被封装,然而与直观相反地,member函数clearEverything带来的封装性比non-member函数低。

如何认识封装?如果某些东西被封装,他就不再可见。愈多东西被封装,愈少的人可以看见它。愈少的人看见它,我们就有愈大的弹性空间去改变它,因为我们的改变仅仅直接影响看到改变的那些人事物。因此,越多的东西被封装,我们改变那些东西的能力也就越大。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户。

所以,从这个角度讲,member函数增大了访问class内private成分的能力,导致较大封装性的是non-member、non-friend函数。

friend函数与member函数对成员变量的访问能力相同,这里选择的关键并不是member函数与non-member函数,而是member函数与non-member、non-friend函数之间。

如果你觉得一个全局函数并不自然,也可以考虑将ClearEverything函数放在工具类中充当静态成员函数,或与WebBrowser放在同一个命名空间中:

namespace WebBrowserStuff {
    class WebBrowser { ... };
    void ClearEverything(WebBrowser& wb) { ... }
}

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

现在我们手头上拥有一个Rational类,并且它可以和int隐式转换:

class Rational {
public:
    Rational(int numerator = 0, int denominator = 1);//构造函数刻意不为explicit,允许发生隐式转换;
    ...
};

当然,我们需要重载乘法运算符来实现Rational对象之间的乘法:

class Rational {
public:
    ...
    const Rational operator*(const Rational& rhs) const;
};

将运算符重载放在类中是行得通的,至少对于Rational对象来说是如此。但当我们考虑混合运算时,就会出现一个问题:

Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf / oneEight;

result = oneHalf * 2;    // 正确
result = 2 * oneHalf;    // 报错

假如将乘法运算符写成函数形式,错误的原因就一目了然了:

result = oneHalf.operator*(2);    // 正确
result = 2.operator*(oneHalf);    // 报错

在调用operator*时,int类型的变量会隐式转换为Rational对象,因此用Rational对象乘以int对象是合法的,但反过来则不是如此。

所以,为了避免这个错误,我们应当将运算符重载放在类外,作为非成员函数:

const Rational operator*(const Rational& lhs, const Rational& rhs);

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

由于std::swap函数在 C++11 后改为了用std::move实现,因此几乎已经没有性能的缺陷,也不再有像原书中所说的为自定义类型去自己实现的必要。不过原书中透露的思想还是值得一学的。

如果想为自定义类型实现自己的swap方法,可以考虑使用模板全特化,并且这种做法是被 STL 允许的:

class Widget {
public:
    void swap(Widget& other) {
        using std::swap;
        swap(pImpl, other.pImpl);
    }
    ...

private:
    WidgetImpl* pImpl;
};

namespace std {
    template<>
    void swap<Widget>(Widget& a, Widget& b) {
        a.swap(b);
    }
}

注意,由于外部函数并不能直接访问Widget的private成员变量,因此我们先是在类中定义了一个 public 成员函数,再由std::swap去调用这个成员函数。

然而若WidgetWidgetImpl是类模板,情况就没有这么简单了,因为 C++ 不支持函数模板偏特化,所以只能使用重载的方式:

namespace std {
    template<typename T>
    void swap(Widget<T>& a, Widget<T>& b) {
        a.swap(b);
    }
}

但很抱歉,这种做法是被 STL 禁止的,因为这是在试图向 STL 中添加新的内容,所以我们只能退而求其次,在其它命名空间中定义新的swap函数:

namespace WidgetStuff {
    ...
    template<typename T>
    class Widget { ... };
    ...
    template<typename T>3
    void swap(Widget<T>& a, Widget<T>& b) {
        a.swap(b);
    }
}

我们希望在对自定义对象进行操作时找到正确的swap函数重载版本,这时候如果再写成std::swap,就会强制使用 STL 中的swap函数,无法满足我们的需求,因此需要改写成:

using std::swap;
swap(obj1, obj2);

这样,C++ 名称查找法则能保证我们优先使用的是自定义的swap函数而非 STL 中的swap函数。

C++ 名称查找法则:编译器会从使用名字的地方开始向上查找,由内向外查找各级作用域(命名空间)直到全局作用域(命名空间),找到同名的声明即停止,若最终没找到则报错。 函数匹配优先级:普通函数 > 特化函数 > 模板函数

五、实现

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

void test(int a,int b){
    string encryted;
    if(a

在这个函数里,string对象并没有被完全使用,如果a

string encryted;
encryted("pass");

string encryted("pass");//同样,这种做法要比先声明再赋值要好;

你不止应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。如果这样,不仅能够构造和析构非必要对象,还可以避免无意义的default构造行为,更深一层次说,以“具明显意义之初值”将变量初始化,还可以附带说明变量的目的。

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

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

const_cast
const_cast 是用来将const变量转化为非 const 的一种手段。而且,在四种 casting 手段中,只有 const_cast 有这种能耐!。其最常见于函数的匹配上,比如:

void fun(int* ptr);

函数 fun 要求传入的参数是一个指向 int 类型的指针,也就是这个指针指向的内容可能是可以改变的。当我们手里只有一个const int 类型的变量 a 时,是没办法传给这个函数的:

const int a = 10;
fun(&a);  //错误

这是因为 fun 函数没办法保证它不对参数 ptr 所指向的内容进行修改。这时候我们可以把 a 的const属性给去掉:

int * b= const_cast (&a)  // 尖括号内表示想转化的类型
fun(b) //没错误
void fun(int* ptr) {
	*ptr = -1;
}

int main() {
	const int a = 10;
	int* b = const_cast<int*>(&a);
	fun(b);

	cout << "adress a=" << &a << " a =" << a << endl;
	cout << "adress b=" << b << " *b =" << *b << endl;
}

运行结果:

Adress a=0x61fe14 a =10

Adress b=0x61fe14 *b =-1

a和 *b 确实是同一个东西,因为他们呆在同一个地址中。那为什么 a 和 *b的值不一样呢?

因为“对于一个常量,编译器会将所有用到该变量的地方用初始值替换。也就是当编译器遇到 const常量时,会直接转成立即数,而不是去内存里取值。” 所以,a 和 *b 确实是同一个东西,只不过在用的时候,编译器 把看见 a 的地方都换成了10,而用到 b 的时候,就去内存里面取值。

dynamic_cast

dynamic_cast 主要用来将执行 “安全的向下转型”(当然也可以向上转换,而且是安全的,放到 static_cast 一起论述)。

向下转型指的是将基类的对象转化成子类;而安全是相对于 static_cast 来说的,因为 dynamic_cast 在转型的时候做了类型的检查。

在C++中,由于多态的存在,父类的指针可以指向子类的对象。换句话说,现在给你一个父类的指针,你能确定它是指向父类还是子类的吗?如果你确定它是指向子类,那就可以把这个父类的指针用 dynamic_cast 来向下转型成为子类。那为什么要转成子类,维持父类的状态不香?答案是子类通常具有父类没有的特性,因此,子类的“权限更高”,比如:

#include 
#include 
 
using namespace std;
 
// 我是父类
class Tfather
{
public:
	virtual void f() { cout << "father's f()" << endl; }
};
 
// 我是子类
class Tson : public Tfather
{
public:
	void f() { cout << "son's f()" << endl; }
 
	int data; // 我是子类独有成员
};
 
int main()
{ 
	Tfather father;
	Tson son;
	son.data = 123;

	Tfather *pf;

	/* 父类指针指向了子类对象 */
	pf = &son;
	pf->data;//编译器直接报错!!!
	
	system("pause");
}

用父类的指针没法取出data啊!这时候 dynamic_cast 派上用场了:我把它转成子类就行!

#include 
#include 

using namespace std;

// 我是父类
class Tfather
{
public:
	virtual void f() { cout << "father's f()" << endl; }
};

// 我是子类
class Tson : public Tfather
{
public:
	void f() { cout << "son's f()" << endl; }

	int data; // 我是子类独有成员
};

int main()
{
	Tfather father;
	Tson son;
	son.data = 123;

	Tfather *pf;

	/* 父类指针指向了子类对象 */
	pf = &son;
	/* 将父类的指针向下转成子类 */
	Tson *ps = dynamic_cast(pf);
	cout << ps->data << endl;

	system("pause");
}

用 dynamic_cast 来把父类转化成子类。对于这种情况: static_cast 对于这种转化不会返回一个NULL的指针,而 dynamic_cast 会返回一个NULL指针来警告这种错误!因此,这也是“安全”与“不安全”的由来。

最后值得注意的是:dynamic_cast 在将父类 cast 到子类时,父类必须要有虚函数,否则编译器会报错。

但是dynamic_cast的效率往往不尽如人意,如果可以,尽量避免转型,试着发展无需转型的替代设计。

static_cast

static_cast 的主要用途有:

  • 内置类型的转换,比如把double类型的数据转换成int类型的数据。
  • 将空指针(nulptr)转化成目标类型的指针。
  • 将各种类型的指针转化成void* 类型的指针。
  • 进行对象的上行转换(子类到父类,安全)和下行(父类到子类,不安全)转换。

reinterpret_cast

​ reinterpret_cast 用来进行无关类型的转化,转化之后的对象在内存中的比特位与原始对象相同。比如:

#include 
#include 
using namespace std;

class A {
public:
	int a;
	double b;
	string c;
};

int main() {

	A objA;
	objA.a = 120;
	objA.b = 1.2;
	objA.c = "hello";

	int* r = reinterpret_cast(&objA);  // 直接天马行空乱转,程序正常
	cout << &objA << " " << r << endl;

	return 0;
}

运行结果:

0x61fdd0 0x61fdd0

总的来说, reinterpret_cast 能完成:

  • 任意类型指针的转化(如上面的例子,无安全检查)。
  • 指针到整型的转化(没试过)。
  • 整型到指针的转化(没试过)

这个转换在低级代码(和硬件相关)以外很少见

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

考虑以下Rectangle类:

struct RectData {
    Point ulhc;
    Point lrhc;
};

class Rectangle {
public:
    Point& UpperLeft() const { return pData->ulhc; }
    Point& LowerRight() const { return pData->lrhc; }

private:
    std::shared_ptr pData;
};

这段代码看起来没有任何问题,但其实是在做自我矛盾的事情:我们通过const成员函数返回了一个指向成员变量的引用,这使得成员变量可以在外部被修改,而这是违反 logical constness 的原则的。换句话说,你绝对不应该令成员函数返回一个指针指向“访问级别较低”的成员函数。

改成返回常引用可以避免对成员变量的修改:

nconst Point& UpperLeft() const { return pData->ulhc; }
const Point& LowerRight() const { return pData->lrhc; }

但是这样依然会带来一个称作 dangling handles(空悬句柄) 的问题,当对象不复存在时,你将无法通过引用获取到返回的数据。

class GUIObject{...};
const Rectangle boundingBox(const GUIObject& obj);

GUIObject* pgo;
const Point* pupp = &(boundingBox(*pgo).upperLeft());//这是一个临时变量,导致悬挂指针

因此建议采用最保守的做法,返回一个成员变量的副本:

Point UpperLeft() const { return pData->ulhc; }
Point LowerRight() const { return pData->lrhc; }

✔ 避免返回handlers(包括reference,指针,迭代器)指向对象内部,遵守这个条款可增加封装性,帮助cosnt成员函数的行为像个const,并将发生悬挂指针的可能性降到最低。

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

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

基本承诺: 如果异常被抛出,程序内的任何事物仍然保持在有效状态下,没有任何对象或数据结构会因此败坏,所有对象都处于一种内部前后一致的状态,然而程序的真实状态是不可知的,也就是说客户需要额外检查程序处于哪种状态并作出对应的处理。

强烈保证: 如果异常被抛出,程序状态完全不改变,换句话说,程序会回复到“调用函数之前”的状态。

不抛掷(nothrow)保证: 承诺绝不抛出异常,因为程序总是能完成原先承诺的功能。作用于内置类型身上的所有操作都提供 nothrow 保证。

原书中实现 nothrow 的方法是throw(),不过这套异常规范在 C++11 中已经被弃用,取而代之的是noexcept关键字:

int DoSomething() noexcept;

注意,使用noexcept并不代表函数绝对不会抛出异常,而是在抛出异常时,将代表出现严重错误,会有意想不到的函数被调用(可以通过set_unexpected设置),接着程序会直接崩溃。

当异常被抛出时,带有异常安全性的函数会:

  1. 不泄漏任何资源。
  2. 不允许数据败坏。

考虑以下PrettyMenuChangeBackground函数:

class PrettyMenu {
public:
    ...
    void ChangeBackground(std::vector<uint8_t>& imgSrc);
    ...
private:
    Mutex mutex;        // 互斥锁
    Image* bgImage;     // 目前的背景图像
    int imageChanges;   // 背景图像被改变的次数
};

void PrettyMenu::ChangeBackground(std::vector<uint8_t>& imgSrc) {
    lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
    unlock(&mutex);
}

很明显这个函数不满足我们所说的具有异常安全性的任何一个条件,若在函数中抛出异常,mutex会发生资源泄漏,bgImageimageChanges也会发生数据败坏。

通过以对象管理资源,使用智能指针和调换代码顺序,我们能将其变成一个具有强烈保证的异常安全函数:

void PrettyMenu::ChangeBackground(std::vector<uint8_t>& imgSrc) {
    Lock m1(&mutex);
    bgImage.reset(std::make_shared<Image>(imgSrc));

    ++imageChanges;
}

另一个常用于提供强烈保证的方法是我们所提到过的 copy and swap,为你打算修改的对象做出一份副本,对副本执行修改,并在所有修改都成功执行后,用一个不会抛出异常的swap方法将原件和副本交换:

struct PMImpl {
    std::shared_ptr<Image> bgImage;
    int imageChanges;
};

class PrettyMenu {
    ...
private:
    Mutex mutex;
    std::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::ChangeBackground(std::vector<uint8_t>& imgSrc) {
    Lock m1(&mutex);

    auto pNew = std::make_shared<PMImpl>(*pImpl);    // 获取副本
    pNew->bgImage.reset(std::make_shared<Image>(imgSrc));
    ++pNew->imageChanges;

    std::swap(pImpl, pNew);
}

当一个函数调用其它函数时,函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的“异常安全保证”中的最弱者。

强烈保证并非永远都是可实现的,特别是当函数在操控非局部对象时,这时就只能退而求其次选择不那么美好的基本承诺,并将该决定写入文档,让其他人维护时不至于毫无心理准备。

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

inline函数,调用它们不需要蒙受函数调用所招致的额外开销。其背后的整体观念是:将对此函数的每一个调用都以函数本体替换之,这样做可能增加你的目标码大小。在一台内存有限的机器上,过度热衷inline函数会导致额外的换页行为,降低指令告诉缓存装置的击中率,以及伴随而来的效率损失。

virtual意味着等待,直到运行期才确定调用哪个函数。inline意味着执行前,先将调用动作替换为被调用的函数本体。

如果程序要取某个inline函数的地址,编译器通常必须为此函数生成一个outline函数本体。毕竟编译器哪有能力提出一个指针指向并不存在的函数呢?

✔ 将大多数inlining限制在小型,被频繁调用的函数身上,可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。

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

C++ 坚持将类的实现细节放置于类的定义式中,这就意味着,即使你只改变类的实现而不改变类的接口,在构建程序时依然需要重新编译。这个问题的根源出在编译器必须在编译期间知道对象的大小,如果看不到类的定义式,就没有办法为对象分配内存。也就是说,C++ 并没有把“将接口从实现中分离”这件事做得很好。

用“声明的依存性”替换“定义的依存性”:

我们可以玩一个“将对象实现细目隐藏于一个指针背后”的游戏,称作 pimpl idiom(pimpl 是 pointer to implemention 的缩写):将原来的一个类分割为两个类,一个只提供接口,另一个负责实现该接口,称作句柄类(handle class):

// person.hpp 负责声明类

class PersonImpl;

class Person {
public:
    Person();
    void Print();
    ...
private:
    std::shared_ptr pImpl;
};

// person.cpp 负责实现类

class PersonImpl {
public:
    int data{ 0 };
};

Person::Person() {
    pImpl = std::make_shared();
}

void Person::Print() {
    std::cout << pImpl->data;
}

这样,假如我们要修改Person的private成员,就只需要修改PersonImpl中的内容,而PersonImpl的具体实现是被隐藏起来的,对它的任何修改都不会使得Person客户端重新编译,真正实现了“类的接口和实现分离”。

如果使用对象引用或对象指针可以完成任务,就不要使用对象本身:

你可以只靠一个类型声明式就定义出指向该类型的引用和指针;但如果定义某类型的对象,就需要用到该类型的定义式。

如果能够,尽量以类声明式替换类定义式:

当你在声明一个函数而它用到某个类时,你不需要该类的定义;但当你触及到该函数的定义式后,就必须也知道类的定义:

class Date;                     // 类的声明式
Date Today();
void ClearAppointments(Data d); // 此处并不需要得知类的定义

为声明式和定义式提供不同的头文件:

为了避免频繁地添加声明,我们应该为所有要用的类声明提供一个头文件,这种做法对 template 也适用:

#include "datefwd.h"            // 这个头文件内声明 class Date
Date Today();
void ClearAppointments(Data d);

此处的头文件命名方式"datefwd.h"取自标准库中的。

上面我们讲述了接口与实现分离的其中一个方法——提供句柄类,另一个方法就是将句柄类定义为抽象基类,称作接口类(interface class):

class Person {
public:
    virtual ~Person() {}
    virtual void Print();
    ...
};

为了将Person对象实际创建出来,我们一般采用工厂模式。可以尝试在类中塞入一个静态成员函数Create用于创建对象:

class Person {
public:
    ...
    static std::shared_ptr Create();
    ...
};

但此时Create函数还无法使用,需要在派生类中给出Person类中的函数的具体实现:

class RealPerson : public Person {
public:
    RealPerson(...) { ... }
    virtual ~RealPerson() {}
    void Print() override { ... }

private:
    int data{ 0 };
};

完成Create函数的定义:

static std::shared_ptr Person::Create() {
    return std::make_shared();
}

毫无疑问的是,句柄类和接口类都需要额外的开销:句柄类需要通过 pimpl 取得对象数据,增加一层间接访问、指针大小和动态分配内存带来的开销;而接口类会增加存储虚表指针和实现虚函数跳转带来的开销。

而当这些开销过于重大以至于类之间的耦合度在相形之下不成为关键时,就以具象类(concrete class)替换句柄类和接口类。

请记住:

✔ 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handler Classes和Interface Classes。

✔ 程序库头文件应该以“完全且仅有声明式”的形式存在,这种做法不论是否涉及template都适用。

六、继承与面向对象设计

“public继承”意味着“is-a"关系;virtual函数意味着”接口必须被继承“;non-virtual函数意味着”接口和实现都必须被继承“。

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

public关系意味着is-a关系。但是面向对象中所谓的is-a关系可能和现实世界中的不太一样,因此你不能简单的以现实世界中的模型去套用之。

比如让你实现两个类:企鹅和鸟,企鹅是一种鸟,生物学上的确如此,于是你也在你的代码里这么去设计,让企鹅类继承与鸟类。但是企鹅会飞吗?不会。那么定义在鸟类中的fly()函数怎么办?你可能会狡辩道:那我在企鹅类里也实现fly函数,但是函数内部直接报错“I can not fly"。但是你有没有想过,这样做表达的意思其实不是企鹅不会飞,而是企鹅会飞,但尝试那么做是一种错误。这时候你好像明白了,你说那再实现两个类”会飞的鸟“和”不会飞的鸟“,然后让企鹅继承自不会飞的鸟就行了,问题似乎解决了,但是飞行只是一种属性,如果再出现其他特殊的属性,从而出现类似的问题怎么办?

再比如让你实现长方形和正方形两个类,你马上就能想起来初中学过的图形知识:正方形是一种特殊的长方形,于是让正方形类继承自长方形类。但是如果长方形类中有某个函数条件判断中只有当长和高相等时,某个动作才会被执行,这种函数如果下沉到正方形类中将永远会被执行。

这些都无法满足严格的is-a关系。其根本原因是public背后的is-a关系的实质是凡是可以作用于基类身上的动作,都能作用于子类身上,并且不会报错。也许你能实现像前面所说的企鹅和鸟,长方形和正方形的例子,代码可以编译通过,但是殊不知运行的时候确可能产生意想不到的错误。这并不是一个好的行为,还不如将这些错误的行为在编译期间就暴露出来。

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

int x = 1;
void someFunc()
{
	double x;
	std::cin>>x;
}

就像这段代码所表现的名称遮掩一样,基类和继承类也可以理解成简单的作用域问题:

【C++】万字详解Effective C++_第4张图片

void someFunc()
{
	mf2();
}

当编译器看到mf2时,编译器的做法是查找各个作用域,看有没有某个名为mf2的声明式:首先查找local作用域,没找到就查找其外围作用域,也就是class Derived覆盖的作用域,还是没有找到就再往外移动,找基类中有没有,如果依然没有找到,会找基类的命名空间下有没有这个函数,最后往global作用域找去。

可以借用using语句,将基类中某个名称为xxx的函数在子类中全部可见:

using Base::mf1;

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

  1. 接口继承和实现继承不一样。在public继承下,派生类总是继承基类的接口。
  2. 声明一个纯虚函数的目的,是为了让派生类只继承函数接口。
  3. 声明简朴的非纯虚函数的目的,是让派生类继承该函数的接口和缺省实现。
  4. 声明非虚函数的目的,是为了令派生类继承函数的接口及一份强制性实现。

通常而言,我们不会为纯虚函数提供具体实现,然而这样做是被允许的,并且用于替代简朴的非纯虚函数,提供更平常更安全的缺省实现。

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

这个章节实际上是介绍了一些设计模式相关的知识:模板方法模式与策略模式

藉由非虚接口手法实现 template method:

非虚接口(non-virtual interface,NVI) 设计手法的核心就是用一个非虚函数作为 wrapper,将虚函数隐藏在封装之下:

class GameCharacter {
public:
    int HealthValue() const {
        ...    // 做一些前置工作
        int retVal = DoHealthValue();
        ...    // 做一些后置工作
        return retVal;
    }
    ...
private:
    virtual int DoHealthValue() const {
        ...    // 缺省算法
    }
};

NVI手法的一个优点就是在 wrapper 中做一些前置和后置工作,确保得以在一个虚函数被调用之前设定好适当场景,并在调用结束之后清理场景。如果你让客户直接调用虚函数,就没有任何好办法可以做这些事。

NVI手法允许派生类重新定义虚函数,从而赋予它们“如何实现机能”的控制能力,但基类保留诉说“函数何时被调用”的权利。

在NVI手法中虚函数除了可以是private,也可以是protected,例如要求在派生类的虚函数实现内调用其基类的对应虚函数时,就必须得这么做。

藉由函数指针实现 Strategy 模式:

参考以下例子:

class GameCharacter;
int DefaultHealthCalc(const GameCharacter&);        // 缺省算法
class GameCharacter {
public:
    using HealthCalcFunc = int(*)(const GameCharacter&);    // 定义函数指针类型
    explicit GameCharacter(HealthCalcFunc hcf = DefaultHealthCalc)
        : healthFunc(hcf) {}
    int HealthValue() const { return healthFunc(*this); }
    ...
private:
    HealthCalcFunc healthFunc;
};

同一个人物类型的不同实体可以有不同的健康计算函数,并且该计算函数可以在运行期变更。

这间接表明健康计算函数不再是GameCharacter继承体系内的成员函数,它也无权使用非public成员。为了填补这个缺陷,我们唯一的做法是弱化类的封装,引入友元或提供public访问函数。

藉由 std::function 完成 Strategy 模式

std::function是 C++11 中引入的函数包装器,使用它能提供比函数指针更强的灵活度:

class GameCharacter;
int DefaultHealthCalc(const GameCharacter&);        // 缺省算法
class GameCharacter {
public:
    using HealthCalcFunc = std::function<int(const GameCharacter&)>;    // 定义函数包装器类型
    explicit GameCharacter(HealthCalcFunc hcf = DefaultHealthCalc)
        : healthFunc(hcf) {}
    int HealthValue() const { return healthFunc(*this); }
    ...
private:
    HealthCalcFunc healthFunc;
};

看起来并没有很大的改变,但当我们需要时,std::function就能展现出惊人的弹性:

// 使用返回值不同的函数
short CalcHealth(const GameCharacter&);

GameCharacter chara1(CalcHealth);

// 使用函数对象(仿函数)
struct HealthCalculator {
    int operator()(const GameCharacter&) const { ... }
};

GameCharacter chara2(HealthCalculator());

// 使用某个成员函数
class GameLevel {
public:
    float Health(const GameCharacter&) const;
    ...
};

GameLevel currentLevel;
GameCharacter chara2(std::bind(&GameLevel::Health, currentLevel, std::placeholders::_1));

古典的 Strategy 模式:

在古典的 Strategy 模式中,我们并非直接利用函数指针(或包装器)调用函数,而是内含一个指针指向来自HealthCalcFunc继承体系的对象:

class GameCharacter;

class HealthCalcFunc {
public:
    virtual int Calc(const GameCharacter& gc) const { ... }
    ...
};

HealthCalcFunc defaultHealthCalc;

class GameCharacter {
public:
    explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc)
        : pHealthCalc(phcf) {}
    int HealthValue() const { return pHealthCalc->Calc(*this); }
    ...
private:
    HealthCalcFunc* pHealthCalc;
};

这个设计模式的好处在于足够容易辨认,想要添加新的计算函数也只需要为HealthCalcFunc基类添加一个派生类即可。

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

非虚函数和虚函数具有本质上的不同:非虚函数执行的是静态绑定(statically bound,又称前期绑定,early binding),由对象类型本身(称之静态类型)决定要调用的函数;而虚函数执行的是动态绑定(dynamically bound,又称后期绑定,late binding),决定因素不在对象本身,而在于“指向该对象之指针”当初的声明类型(称之动态类型)。

前面我们已经说过,public继承意味着 is-a 关系,而在基类中声明一个非虚函数将会为该类建立起一种不变性(invariant),凌驾其特异性(specialization)。而若在派生类中重新定义该非虚函数,则会使人开始质疑是否该使用public继承的形式;如果必须使用,则又打破了基类“不变性凌驾特异性”的性质,就此产生了设计上的矛盾。

综上所述,在任何情况下都不该重新定义一个继承而来的非虚函数。

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

这个条款提到一件很重要的事:virtual函数是动态绑定的不假,但是如果virtual函数带有缺省值,则那个缺省值是静态绑定的。

#include 
#include 
using namespace std;

class Shape
{
public:
    virtual void Draw(int x = 1)  = 0;
};

void Shape::Draw(int x){
    cout<<"Shape : " < rectangle = std::make_shared();
    std::shared_ptr circle    = std::make_shared();
    rectangle->Draw();
    circle->Draw();
    return 0;
}

//运行结果:
Rectangle : 1
Circle : 1

条款38:通过复合塑模出 has-a 或“根据某物实现出”

这里其实还是在介绍一种设计模式——组合模式

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

private继承的特点:

  1. 如果类之间是private继承关系,那么编译器不会自动将一个派生类对象转换为一个基类对象。
  2. 由private继承来的所有成员,在派生类中都会变为private属性,换句话说,private继承只继承实现,不继承接口。

private继承的意义是“根据某物实现出”,如果你读过条款 38,就会发现private继承和复合具有相同的意义,事实上也确实如此,绝大部分private继承的使用场合都可以被“public继承+复合”完美解决:

class Timer {
public:
    explicit Timer(int tickFrequency);
    virtual void OnTick() const;
    ...
};

class Widget : private Timer {
private:
    virtual void OnTick() const;
    ...
};

替代为:

class Widget {
private:
    class WidgetTimer : public Timer {
    public:
        virtual void OnTick() const;
        ...
    };
    WidgetTimer timer;
    ...
};

使用后者比前者好的原因有以下几点:

  1. private继承无法阻止派生类重新定义虚函数,但若使用public继承定义WidgetTimer类并复合在Widget类中,就能防止在Widget类中重新定义虚函数。
  2. 可以仅提供WidgetTimer类的声明,并将WidgetTimer类的具体定义移至实现文件中,从而降低Widget的编译依存性。

然而private继承并非完全一无是处,一个适用于它的极端情况是空白基类最优化(empty base optimization,EBO),参考以下例子:

class Empty {};

class HoldsAnInt {
private:
    int x;
    Empty e;
};

一个没有非静态成员变量、虚函数的类,看似不需要任何存储空间,但实际上 C++ 规定凡是独立对象都必须有非零大小,因此此处sizeof(HoldsAnInt)必然大于sizeof(int),通常会多出一字节大小,但有时考虑到内存对齐之类的要求,可能会多出更多的空间。

使用private继承可以避免产生额外存储空间,将上面的代码替代为:

class HoldsAnInt : private Empty {
private:
    int x;
};

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

多重继承是一个可能会造成很多歧义和误解的设计,因此反对它的声音此起彼伏,下面我们来接触几个使用多重继承的场景。

最先需要认清的一件事是,程序有可能从一个以上的基类继承相同名称,那会导致较多的歧义机会:

class BorrowableItem {
public:
    void CheckOut();
    ...
};

class ElectronicGadget {
public:
    void CheckOut() const;
    ...
};

class MP3Player : public BorrowableItem, public ElectronicGadget {
    ...
};

MP3Player mp;
mp.CheckOut();          // MP3Player::CheckOut 不明确!

如果真遇到这种情况,必须明确地指出要调用哪一个基类中的函数:

mp.BorrowableItem::CheckOut();      // 使用 BorrowableItem::CheckOut

在使用多重继承时,我们可能会遇到要命的“钻石型继承(菱形继承)”。

class File { ... };

class InputFile : public File { ... };
class OutputFile : public File { ... };

class IOFile : public InputFile, public OutputFile { ... };

这时候必须面对这样一个问题:是否打算让基类内的成员变量经由每一条路径被复制?如果不想要这样,应当使用虚继承,指出其愿意共享基类:

class File { ... };

class InputFile : virtual public File { ... };
class OutputFile : virtual public File { ... };

class IOFile : public InputFile, public OutputFile { ... };

然而由于虚继承会在派生类中额外存储信息来确认成员来自于哪个基类,虚继承通常会付出更多空间和速度的代价,并且由于虚基类的初始化责任是由继承体系中最底层的派生类负责,就导致了虚基类必须认知其虚基类并且承担虚基类的初始化责任。因此我们应当遵循以下两个建议:

  1. 非必要不使用虚继承。
  2. 如果必须使用虚继承,尽可能避免在虚基类中放置数据。

多重继承可用于结合public继承和private继承,public继承用于提供接口,private继承用于提供实现:

// IPerson 类指出要实现的接口
class IPerson {
public:
    virtual ~IPerson();
    virtual std::string Name() const = 0;
    virtual std::string BirthDate() const = 0;
};

class DatabaseID {  ...  };

// PersonInfo 类有若干已实现的函数
// 可用以实现 IPerson 接口
class PersonInfo {
public:
    explicit PersonInfo(DatabaseID pid);
    virtual ~PersonInfo();
    virtual const char* TheName() const;
    virtual const char* TheBirthDate() const;
    virtual const char* ValueDelimOpen() const;
    virtual const char* ValueDelimClose() const;
    ...
};

// CPerson 类使用多重继承
class CPerson: public IPerson, private PersonInfo {
public:
    explicit CPerson(DatabaseID pid): PersonInfo(pid) {}

    virtual std::string Name() const {       // 实现必要的 IPerson 成员函数
        return PersonInfo::TheName();  
    }

    virtual std::string BirthDate() const {  // 实现必要的 IPerson 成员函数
        return PersonInfo::TheBirthDate();  
    }
private:
    // 重新定义继承而来的虚函数
    const char* ValueDelimOpen() const {  return "";  }
    const char* ValueDelimClose() const {  return "";  }
};

七、模板与泛型编程

泛型编程的作用:写出的代码和其所处理的对象类型彼此独立。

条款41:了解隐式接口

面向对象编程总是以显式接口(接口针对特定的具体数据类型或对象)和运行时多态(virtual)。泛型编程中虽然这两者依然存在,但是其重要性变低了,反而是隐式接口和编译期多态被移到前面了。你应该不陌生“运行期多态”和“编译期多态”之间的差异,因为它类似于“哪一个重载函数该被调用”(发生在编译期)和“哪一个virtual函数被绑定”(发生在运行期)之间的差异。

✔ classes和template都支持接口和多态;

✔ 对classes而言接口是显式的,以函数签名为中心。多态则是通过virtual函数发生于运行期。

✔ 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。

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

在模板声明式中,使用classtypename关键字并没有什么不同,但在模板内部,typename拥有更多的一重含义。

为了方便解释,我们首先需要引入一个模板相关的概念:模板内出现的名称如果相依于某个模板参数,我们称之为从属名称(dependent names);如果从属名称在类内呈嵌套状,我们称之为嵌套从属名称(nested dependent name);如果一个名称并不倚赖任何模板参数的名称,我们称之为非从属名称(non-dependent names)

考虑以下模板代码:

template<typename C>
void Print2nd(const C& container) {
    if (container.size() >= 2) {
        C::const_iterator iter(container.begin());// C::const_iterator就是一个从属名称
        ++iter;
        int value = *iter;
        std::cout << value;
    }
}

这段代码看起来没有任何问题,但实际编译时却会报错,这一切的罪魁祸首便是C::const_iterator。此处的C::const_iterator是一个指向某类型的嵌套从属类型名称(nested dependent type name),而嵌套从属名称可能会导致解析困难,因为在编译器必须知道C之前是什么,但是没有任何办法知道C::const_iterator是否为一个类型,这就导致出现了歧义状态,而 C++ 默认假设嵌套从属名称不是类型名称。

显式指明嵌套从属类型名称的方法就是将typename关键字作为其前缀词:

typename C::const_iterator iter(container.begin());

同样地,若嵌套从属名称出现在模板函数声明部分,也需要显式地指明是否为类型名称:

template<typename C>
void Print2nd(const C& container, const typename C::iterator iter);

这一规则的例外是,typename不可以出现在基类列表内的嵌套从属类型名称之前,也不可以在成员初始化列表中作为基类的修饰符:

template<typename T>
class Derived : public Base<T>::Nested {    // 基类列表中不允许使用 typename
public:
    explicit Derived(int x)
        : Base<T>::Nested(x) {                 // 成员初始化列表中不允许使用 typename
        typename Base<T>::Nested temp;
        ...
    }
    ...
};

假设我们正在撰写一个function template例子,它接受一个迭代器,而我们打算为该迭代器指的对象做一份副本,我们可以这么写:

template
void work(IterT iter)
{
	typename std::iterator_traits::value_type temp(*iter);
    typedef typename std::iterator_traits::value_type value_type;//和上面一句一样
	...
}

这个语句声明一个变量temp,使用IterT对象所指物的相同类型,并将temp初始化为iter所指物。如果IterT是vector::iterator,temp的类型就是int。

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

class MsgInfo { ... };

template<typename Company>
class MsgSender {
public:
    void SendClear(const MsgInfo& info) { ... }
    ...
};

template<typename Company>
class LoggingMsgSender : public MsgSender<Company> {
public:
    void SendClearMsg(const MsgInfo& info) {
        SendClear(info);        // 调用基类函数,但是,这段代码无法通过编译
    }
    ...
};

很明显,由于直到模板类被真正实例化之前,编译器并不知道MsgSender具体长什么样,有可能它是一个全特化的版本,而在这个版本中或许根本不存在SendClear函数。由于 C++ 的设计策略是宁愿较早进行诊断,所以编译器会拒绝承认在基类中存在一个SendClear函数。

为了解决这个问题,我们需要令 C++“进入模板基类观察”的行为生效,有三种办法达成这个目标:

第一种:在基类函数调用动作之前加上this->

this->SendClear(info);

第二种:使用using声明式:

using MsgSender<Company>::SendClear;
SendClear(info);

第三种:明白指出被调用的函数位于哪个基类内:

MsgSender<Company>::SendClear(info);

第三种做法是最不令人满意的,如果被调用的是虚函数,上述的明确资格修饰(explicit qualification)会使“虚函数绑定行为”失效。

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

在编写templates时,重复往往是隐晦的,毕竟只存在一份template源码,所以你必须训练自己去感受当template被具现化多次时可能发生的重复:

template<typename T, std::size_t n>
class SquareMatrix {
public:
    void Invert();
    ...
private:
    std::array<T, n * n> data;
};

现在考虑以下代码:

SquareMatrix sm1;
SquareMatrix sm2;

这里会具现化两份invert。这些函数并非完全相同,因为其中一个操作的是5*5矩阵,而另一个操作的是10*10矩阵,但除了常量5和10,两个函数的其他部分完全相同,这是template引起代码膨胀的一个典型例子。

第一次修改为:

template<typename T>
class SquareMatrixBase {
protected:
    void Invert(std::size_t matrixSize);
    ...
private:
    std::array<T, n * n> data;
};

template<typename T, std::size_t n>
class SquareMatrix : private SquareMatrixBase<T> {  // private 继承实现,见条款 39
    using SquareMatrixBase<T>::Invert;              // 避免掩盖基类函数,见条款 33

public:
    void Invert() { this->Invert(n); }              // 调用模板基类函数,见条款 43
    ...
};

Invert并不是我们唯一要使用的矩阵操作函数,而且每次都往基类传递矩阵尺寸显得太过繁琐,我们可以考虑将数据放在派生类中,在基类中储存指针和矩阵尺寸。修改代码如下:

template<typename T>
class SquareMatrixBase {
protected:
    SquareMatrixBase(std::size_t n, T* pMem)
        : size(n), pData(pMem) {}
    void SetDataPtr(T* ptr) { pData = ptr; }
    ...
private:
    std::size_t size;
    T* pData;
};

template<typename T, std::size_t n>
class SquareMatrix : private SquareMatrixBase<T> {
public:
    SquareMatrix() : SquareMatrixBase<T>(n, data.data()) {}
    ...
private:
    std::array<T, n * n> data;
};

然而这种做法并非永远能取得优势,硬是绑着矩阵尺寸的那个版本,有可能生成比共享版本更佳的代码。例如在尺寸专属版中,尺寸是个编译期常量,因此可以在编译期藉由常量的广传达到最优化;而在共享版本中,不同大小的矩阵只拥有单一版本的函数,可减少可执行文件大小,也就因此降低程序的 working set(在“虚内存环境”下执行的进程所使用的一组内存页),并强化指令高速缓存区内的引用集中化(locality of reference),这些都可能使程序执行得更快速。究竟哪个版本更佳,只能经由具体的测试后决定。

同样地,上面的代码也使用到了牺牲封装性的protected,可能会导致资源管理上的混乱和复杂,考虑到这些,也许一点点模板代码的重复并非不可接受。

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

C++ 视模板类的不同具现体为完全不同的的类型,但在泛型编程中,我们可能需要一个模板类的不同具现体能够相互类型转换。

考虑设计一个智能指针类,而智能指针需要支持不同类型指针之间的隐式转换(如果可以的话),以及普通指针到智能指针的显式转换。很显然,我们需要的是模板拷贝构造函数:

template<typename T>
class SmartPtr {
public:
    template<typename U>//注意这里
    SmartPtr(const SmartPtr<U>& other)
        : heldPtr(other.get()) { ... }

    template<typename U>
    explicit SmartPtr(U* p)
        : heldPtr(p) { ... }

    T* get() const { return heldPtr; }
    ...
private:
    T* heldPtr;
};

使用get获取原始指针,并将在原始指针之间进行类型转换本身提供了一种保障,如果原始指针之间不能隐式转换,那么其对应的智能指针之间的隐式转换会造成编译错误。

模板构造函数并不会阻止编译器暗自生成默认的构造函数,所以如果你想要控制拷贝构造的方方面面,你必须同时声明泛化拷贝构造函数和普通拷贝构造函数,相同规则也适用于赋值运算符:

template<typename T>
class shared_ptr {
public:
    shared_ptr(shared_ptr const& r);                // 拷贝构造函数

    template<typename Y>
    shared_ptr(shared_ptr<Y> const& r);             // 泛化拷贝构造函数

    shared_ptr& operator=(shared_ptr const& r);     // 拷贝赋值运算符

    template<typename Y>
    shared_ptr& operator=(shared_ptr<Y> const& r);  // 泛化拷贝赋值运算符

    ...
};

请记住:

✔ 请使用member function template(成员函数模板)生成“可接受所有兼容类型”的函数

✔ 如果你声明member templates 用于泛化copy构造或者拷贝赋值操作,你还是需要声明正常的copy构造函数和拷贝赋值函数。

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

条款24中给出了一个例子,现在我们将那个运算符重载函数编写成函数模板的形式:

template<typename T>
class Rational {
public:
    Rational(const T& numerator = 0, const T& denominator = 1);

    const T& Numerator() const;
    const T& Denominator() const;

    ...
};

template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) {
   return Rational<T>(lhs.Numerator() * rhs.Numerator(), lhs.Denominator() * rhs.Denominator());
}


Rational<int> oneHalf(1, 2);
Rational<int> result = oneHalf * 2;     // 无法通过编译!

最后一行代码无法通过编译。我们分析以下过程:

当编译器执行到这一行代码时,调用非成员函数的运算符重载*,第一个参数前面已经声明为Rational,这跟operator*的第一个参数相同,这当然没有任何问题,问题在于第二个参数。第二个参数是个int,事实上,你希望编译器调用Rational的构造函数将int转换为Rational,而这就是问题所在:模板实参在推导过程中,从不将隐式类型转换纳入考虑。虽然以oneHalf推导出Rational类型是可行的,但是试图将int类型隐式转换为Rational是绝对会失败的。

由于模板类并不依赖模板实参推导,所以编译器总能够在Rational具现化时得知T,因此我们可以使用友元声明式在模板类内指涉特定函数:

template
class Rational {
public:
    ...
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
    ...
};

在模板类内,模板名称可被用来作为“模板及其参数”的简略表达形式,因此下面的写法也是一样的:

template<typename T>
class Rational {
public:
    ...
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
    ...
};

当对象oneHalf被声明为一个Rational时,Rational类于是被具现化出来,而作为过程的一部分,友元函数operator*也就被自动声明出来,其为一个普通函数而非模板函数,因此在接受参数时可以正常执行隐式转换。

为了使程序能正常链接,我们需要为其提供对应的定义式,最简单有效的方法就是直接合并至声明式处:

friend const Rational operator*(const Rational& lhs, const Rational& rhs) {
    return Rational(lhs.Numerator() * rhs.Numerator(), lhs.Denominator() * rhs.Denominator());
}

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

traits classes 可以使我们在编译期就能获取某些类型信息,它被广泛运用于 C++ 标准库中。traits 并不是 C++ 关键字或一个预先定义好的构件:它们是一种技术,也是 C++ 程序员所共同遵守的协议,并要求对用户自定义类型和内置类型表现得一样好。

设计并实现一个 trait class 的步骤如下:

  1. 确认若干你希望将来可取得的类型相关信息。
  2. 为该类型选择一个名称。
  3. 提供一个模板和一组特化版本,内含你希望支持的类型相关信息。

以迭代器为例,标准库中拥有多种不同的迭代器种类,它们各自拥有不同的功用和限制:

  1. input_iterator_tag:单向输入迭代器,只能向前移动,一次一步,客户只可读取它所指的东西。
  2. output_iterator_tag:单向输出迭代器,只能向前移动,一次一步,客户只可写入它所指的东西。
  3. forward_iterator_tag:单向访问迭代器,只能向前移动,一次一步,读写均允许。
  4. bidirectional_iterator_tag:双向访问迭代器,去除了只能向前移动的限制。
  5. random_access_iterator_tag:随机访问迭代器,没有一次一步的限制,允许随意移动,可以执行“迭代器算术”。

标准库为这些迭代器种类提供的卷标结构体(tag struct)的继承关系如下:

struct input_iterator_tag {};

struct output_iterator_tag {};

struct forward_iterator_tag : input_iterator_tag {};

struct bidirectional_iterator_tag : forward_iterator_tag {};

struct random_access_iterator_tag : bidirectional_iterator_tag {};

iterator_category作为迭代器种类的名称,嵌入容器的迭代器中,并且确认使用适当的卷标结构体:

template< ... >
class deque {
public:
    class iterator {
    public:
        using iterator_category = random_access_iterator;
        ...
    }
    ...
}

template< ... >
class list {
public:
    class iterator {
    public:
        using iterator_category = bidirectional_iterator;
        ...
    }
    ...
}

为了做到类型的 traits 信息可以在类型自身之外获得,标准技术是把它放进一个模板及其一个或多个特化版本中。这样的模板在标准库中有若干个,其中针对迭代器的是iterator_traits

template<class IterT>
struct iterator_traits {
    using iterator_category = IterT::iterator_category;
    ...
};

为了支持指针迭代器,iterator_traits特别针对指针类型提供一个偏特化版本,而指针的类型和随机访问迭代器类似,所以可以写出如下代码:

template<class IterT>
struct iterator_traits<IterT*> {
    using iterator_category = random_access_iterator_tag;
    ...
};

当我们需要为不同的迭代器种类应用不同的代码时,traits classes 就派上用场了:

template<typename IterT, typename DisT>
void advance(IterT& iter, DisT d) {
    if (typeid(std::iterator_traits<IterT>::iterator_category)
        == typeid(std::random_access_iterator_tag)) {
        ...
    }
}

但这些代码实际上是错误的,我们希望类型的判断能在编译期完成。iterator_category是在编译期决定的,然而if却是在运行期运作的,无法达成我们的目标。

在 C++17 之前,解决这个问题的主流做法是利用函数重载(也是原书中介绍的做法):

template<typename IterT, typename DisT>
void doAdvance(IterT& iter, DisT d, std::random_access_iterator_tag) {
    ...
}   


template<typename IterT, typename DisT>
void doAdvance(IterT& iter, DisT d, std::bidirectional_iterator_tag) {
    ...
}

template<typename IterT, typename DisT>
void doAdvance(IterT& iter, DisT d, std::input_iterator_tag) {
    if (d < 0) {
        throw std::out_of_range("Negative distance");       // 单向迭代器不允许负距离
    }
    ...
}

template<typename IterT, typename DisT>
void advance(IterT& iter, DisT d) {
    doAdvance(iter, d, std::iterator_traits<IterT>::iterator_category());
}

在 C++17 之后,我们有了更简单有效的做法——使用if constexpr

template<typename IterT, typename DisT>
void Advance(IterT& iter, DisT d) {
    if constexpr (typeid(std::iterator_traits<IterT>::iterator_category)
        == typeid(std::random_access_iterator_tag)) {
        ...
    }
}

条款48:认识template元编程

模板元编程(Template metaprogramming,TMP)是编写基于模板的 C++ 程序并执行于编译期的过程,它并不是刻意被设计出来的,而是当初 C++ 引入模板带来的副产品,事实证明模板元编程具有强大的作用,并且现在已经成为 C++ 标准的一部分。实际上,在条款 47 中编写 traits classes 时,我们就已经在进行模板元编程了。

由于模板元程序执行于 C++ 编译期,因此可以将一些工作从运行期转移至编译期,这可以帮助我们在编译期时发现一些原本要在运行期时才能察觉的错误,以及得到较小的可执行文件、较短的运行期、较少的内存需求。当然,副作用就是会使编译时间变长。

模板元编程已被证明是“图灵完备”的,并且以“函数式语言”的形式发挥作用,因此在模板元编程中没有真正意义上的循环,所有循环效果只能藉由递归实现,而递归在模板元编程中是由 “递归模板具现化(recursive template instantiation)” 实现的。

常用于引入模板元编程的例子是在编译期计算阶乘:

template<unsigned n>            // Factorial = n * Factorial
struct Factorial {
    enum { value = n * Factorial<n-1>::value };
};

template<>
struct Factorial<0> {           // 处理特殊情况:Factorial<0> = 1
    enum { value = 1 };
};

std::cout << Factorial<5>::value;

模板元编程很酷,但对其进行调试可能是灾难性的,因此在实际应用中并不常见。我们可能会在下面几种情形中见到它的出场:

  1. 确保量度单位正确。
  2. 优化矩阵计算。
  3. 可以生成客户定制之设计模式(custom design pattern)实现品。

八、定制new和delete

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

operator new无法满足某一内存分配需求时,会不断调用一个客户指定的错误处理函数,即所谓的 new-handler,直到找到足够内存为止,调用声明于std中的set_new_handler可以指定这个函数。new_handlerset_new_handler的定义如下:

namespace std {
    using new_handler = void(*)();
    new_handler set_new_handler(new_handler) noexcept;    // 返回值为原来持有的 new-handler
}

一个设计良好的 new-handler 函数必须做以下事情之一:

让更多的内存可被使用: 可以让程序一开始执行就分配一大块内存,而后当 new-handler 第一次被调用,将它们释还给程序使用,造成operator new的下一次内存分配动作可能成功。

安装另一个 new-handler: 如果目前这个 new-handler 无法取得更多内存,可以调换为另一个可以完成目标的 new-handler(令 new-handler 修改“会影响 new-handler 行为”的静态或全局数据)。

卸除 new-handler:nullptr传给set_new_handler,这样会使operator new在内存分配不成功时抛出异常。

抛出 bad_alloc(或派生自 bad_alloc)的异常: 这样的异常不会被operator new捕捉,因此会被传播到内存分配处。

不返回: 通常调用std::abortstd::exit

有的时候我们或许会希望在为不同的类分配对象时,使用不同的方式处理内存分配失败情况。这时候使用静态成员是不错的选择:

public:
    static std::new_handler set_new_handler(std::new_handler p) noexcept;
    static void* operator new(std::size_t size);
private:
    static std::new_handler currentHandler;
};

// 做和 std::set_new_handler 相同的事情
std::new_handler Widget::set_new_handler(std::new_handler p) noexcept {
    std::new_handler oldHandler = currentHandler;
    currentHandler = p;
    return oldHandler; 
}

void* Widget::operator new(std::size_t size) {
    auto globalHandler = std::set_new_handler(currentHandler);  // 切换至 Widget 的专属 new-handler
    void* ptr = ::operator new(size);                           // 分配内存或抛出异常
    std::set_new_handler(globalHandler);                        // 切换回全局的 new-handler
    return globalHandler;
}

std::new_handler Widget::currentHandler = nullptr;

Widget的客户应该类似这样使用其 new-handling:

void OutOfMem();

Widget::set_new_handler(OutOfMem);
auto pw1 = new Widget;              // 若分配失败,则调用 OutOfMem

Widget::set_new_handler(nullptr);
auto pw2 = new Widget;              // 若分配失败,则抛出异常

实现这一方案的代码并不因类的不同而不同,因此对这些代码加以复用是合理的构想。一个简单的做法是建立起一个“mixin”风格的基类,让其派生类继承它们所需的set_new_handleroperator new,并且使用模板确保每一个派生类获得一个实体互异的currentHandler成员变量:

template<typename T>
class NewHandlerSupport {       // “mixin”风格的基类
public:
    static std::new_handler set_new_handler(std::new_handler p) noexcept;
    static void* operator new(std::size_t size);
    ...                         // 其它的 operator new 版本,见条款 52
private:
    static std::new_handler currentHandler;
};

template<typename T>
std::new_handler NewHandlerSupport<T>::set_new_handler(std::new_handler p) noexcept {
    std::new_handler oldHandler = currentHandler;
    currentHandler = p;
    return oldHandler;
}

template<typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size) {
    auto globalHandler = std::set_new_handler(currentHandler);
    void* ptr = ::operator new(size);
    std::set_new_handler(globalHandler);
    return globalHandler;
}

template<typename T>
std::new_handler NewHandlerSupport<T>::currentHandler = nullptr;

class Widget : public NewHandlerSupport<Widget> {
public:
    ...                         // 不必再声明 set_new_handler 和 operator new
};

注意此处的模板参数T并没有真正被当成类型使用,而仅仅是用来区分不同的派生类,使得模板机制为每个派生类具现化出一份对应的currentHandler

这个做法用到了所谓的 CRTP(curious recurring template pattern,奇异递归模板模式) ,除了在上述设计模式中用到之外,它也被用于实现静态多态

template <class Derived> 
struct Base {
    void Interface() {
        static_cast<Derived*>(this)->Implementation();      // 在基类中暴露接口
    }
};

struct Derived : Base<Derived> {
    void Implementation();                                  // 在派生类中提供实现
};

除了会调用 new-handler 的operator new以外,C++ 还保留了传统的“分配失败便返回空指针”的operator new,称为 nothrow new,通过std::nothrow对象来使用它:

Widget* pw1 = new Widget;                   // 如果分配失败,抛出 bad_alloc
if (pw1 == nullptr) ...                     // 这个测试一定失败

Widget* pw2 = new (std::nothrow) Widget;    // 如果分配失败,返回空指针
if (pw2 == nullptr) ...                     // 这个测试可能成功

nothrow new 对异常的强制保证性并不高,使用它只能保证operator new不抛出异常,而无法保证像new (std::nothrow) Widget这样的表达式不会导致异常,因此实际上并没有使用 nothrow new 的必要。

请记住:

✔ set_new_handler允许客户指定一个函数,在内存分配无法获得满足时被调用;

✔ Nothrow new是一个颇为局限的工具,因为它只适用于内存分配,后续的构造函数调用还是可能抛出异常。

条款50:了解 new 和 delete 的合理替换时机

以下是常见的替换默认operator newoperator delete的理由:

用来检测运用上的错误: 如果将“new 所得内存”delete 掉却不幸失败,会导致内存泄漏;如果在“new 所得内存”身上多次 delete 则会导致未定义行为。如果令operator new持有一串动态分配所得地址,而operator delete将地址从中移除,就很容易检测出上述错误用法。此外各式各样的编程错误可能导致 “overruns”(写入点在分配区块尾端之后)“underruns”(写入点在分配区块起点之前),以额外空间放置特定的 byte pattern 签名,检查签名是否原封不动就可以检测此类错误,下面给出了一个这样的范例:

static const int signature = 0xDEADBEEF;              // 调试“魔数”
using Byte = unsigned char;

void* operator new(std::size_t size) {
    using namespace std;
    size_t realSize = size + 2 * sizeof(int);         // 分配额外空间以塞入两个签名

    void* pMem = malloc(realSize);                    // 调用 malloc 取得内存
    if (!pMem) throw bad_alloc();

    // 将签名写入内存的起点和尾端
    *(static_cast<int*>(pMem)) = signature;
    *(reinterpret_cast<int*>(static_cast<Byte*>(pMem) + realSize - sizeof(int))) = signature;

    return static_cast<Byte*>(pMem) + sizeof(int);    // 返回指针指向第一个签名后的内存位置
}

实际上这段代码不能保证内存对齐,并且有许多地方不遵守 C++ 规范,我们将在条款 51 中进行详细讨论。

为了收集使用上的统计数据: 定制 new 和 delete 动态内存的相关信息:分配区块的大小分布,寿命分布,FIFO(先进先出)、LIFO(后进先出)或随机次序的倾向性,不同的分配/归还形态,使用的最大动态分配量等等。

为了增加分配和归还的速度: 泛用型分配器往往(虽然并非总是)比定制型分配器慢,特别是当定制型分配器专门针对某特定类型之对象设计时。类专属的分配器可以做到“区块尺寸固定”,例如 Boost 提供的 Pool 程序库。又例如,编译器所带的内存管理器是线程安全的,但如果你的程序是单线程的,你也可以考虑写一个不线程安全的分配器来提高速度。当然,这需要你对程序进行分析,并确认程序瓶颈的确发生在那些内存函数身上。

为了降低缺省内存管理器带来的空间额外开销: 泛用型分配器往往(虽然并非总是)还比定制型分配器使用更多内存,那是因为它们常常在每一个分配区块身上招引某些额外开销。针对小型对象而开发的分配器(例如 Boost 的 Pool 程序库)本质上消除了这样的额外开销。

为了弥补缺省分配器中的非最佳内存对齐(suboptimal alignment): 许多计算机体系架构要求特定的类型必须放在特定的内存地址上,如果没有奉行这个约束条件,可能导致运行期硬件异常,或者访问速度变低。std::max_align_t用来返回当前平台的最大默认内存对齐类型,对于malloc分配的内存,其对齐和max_align_t类型的对齐大小应当是一致的,但若对malloc返回的指针进行偏移,就没有办法保证内存对齐。

在 C++11 中,提供了以下内存对齐相关方法:

// alignas 用于指定栈上数据的内存对齐要求
struct alignas(8) testStruct { double data; };

// alignof 和 std::alignment_of 用于得到给定类型的内存对齐要求
std::cout << alignof(std::max_align_t) << std::endl;
std::cout << std::alignment_of<std::max_align_t>::value << std::endl;

// std::align 用于在一大块内存中获取一个符合指定内存要求的地址
char buffer[] = "memory alignment";
void* ptr = buffer;
std::size_t space = sizeof(buffer) - 1;
std::align(alignof(int), sizeof(char), ptr, space);

在 C++17 后,可以使用std::align_val_t来重载需求额外内存对齐的operator new

void* operator new(std::size_t count, std::align_val_t al);

为了将相关对象成簇集中: 如果你知道特定的某个数据结构往往被一起使用,而你又希望在处理这些数据时将“内存页错误(page faults)”的频率降至最低,那么可以考虑为此数据结构创建一个堆,将它们成簇集中在尽可能少的内存页上。一般可以使用 placement new 达成这个目标(见条款 52)。

为了获得非传统的行为: 有时候你会希望operator newoperator delete做编译器版不会做的事情,例如分配和归还共享内存(shared memory),而这些事情只能被 C API 完成,则可以将 C API 封在 C++ 的外壳里,写在定制的 new 和 delete 中。

条款51:编写 new 和 delete 时需固守常规

我们在条款 49 中已经提到过一些operator new的规矩,比如内存不足时必须不断调用 new-handler,如果无法供应客户申请的内存,就抛出std::bad_alloc异常。C++ 还有一个奇怪的规定,即使客户需求为0字节,operator new也得返回一个合法的指针,这种看似诡异的行为其实是为了简化语言其他部分。

根据这些规约,我们可以写出非成员函数版本的operator new代码:

void* operator new(std::size_t size) {
    using namespace std;

    if (size == 0)      // 处理0字节申请
        size = 1;       // 将其视为1字节申请

    while (true) {
        if (...)        // 如果分配成功
            return ...; // 返回指针指向分配得到的内存

        // 如果分配失败,调用目前的 new-handler
        auto globalHandler = get_new_handler(); // since C++11

        if (globalHandler) (*globalHandler)();
        else throw std::bad_alloc();
    }
}

operator new的成员函数版本一般只会分配大小刚好为类的大小的内存空间,但是情况并不总是如此,比如假设我们没有为派生类声明其自己的operator new,那么派生类会从基类继承operator new,这就导致派生类可以使用其基类的 new 分配方式,但派生类和基类的大小很多时候是不同的。

处理此情况的最佳做法是将“内存申请量错误”的调用行为改为采用标准的operator new

void* Base::operator new(std::size_t size) {
    if (size != sizeof(Base))
        return ::operator new(size);    // 转交给标准的 operator new 进行处理

    ...
}

注意在operator new的成员函数版本中我们也不需要检测分配的大小是否为0了,因为在条款 39 中我们提到过,非附属对象必须有非零大小,所以sizeof(Base)无论如何也不能为0。

如果你打算实现operator new[],即所谓的 array new,那么你唯一要做的一件事就是分配一块未加工的原始内存,因为你无法对 array 之内迄今尚未存在的元素对象做任何事情,实际上你甚至无法计算这个 array 将含有多少元素对象。

operator delete的规约更加简单,你需要记住的唯一一件事情就是 C++ 保证 “删除空指针永远安全”

void operator delete(void* rawMemory) noexcept {
    if (rawMemory == 0) return;

    // 归还 rawMemory 所指的内存
}

operator delete的成员函数版本要多做的唯一一件事就是将大小有误的删除行为转交给标准的operator delete

void Base::operator delete(void* rawMemory, std::size_t size) noexcept {
    if (rawMemory == 0) return;
    if (size != sizeof(Base)) {
        ::operator delete(rawMemory);    // 转交给标准的 operator delete 进行处理
        return;
    }

    // 归还 rawMemory 所指的内存
}

如果即将被删除的对象派生自某个基类而后者缺少虚析构函数,那么 C++ 传给operator deletesize大小可能不正确,这或许是“为多态基类声明虚析构函数”的一个足够的理由,能作为对条款 7 的补充。

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

placement new 最初的含义指的是“接受一个指针指向对象该被构造之处”的operator new版本,它在标准库中的用途广泛,其中之一是负责在 vector 的未使用空间上创建对象,它的声明如下:

void* operator new(std::size_t, void* pMemory) noexcept;

我们此处要讨论的是广义上的 placement new,即带有附加参数的operator new,例如下面这种:

void* operator new(std::size_t, std::ostream& logStream);

auto pw = new (std::cerr) Widget;

当我们在使用 new 表达式创建对象时,共有两个函数被调用:一个是用以分配内存的operator new,一个是对象的构造函数。假设第一个函数调用成功,而第二个函数却抛出异常,那么会由 C++ runtime 调用operator delete,归还已经分配好的内存。

这一切的前提是 C++ runtime 能够找到operator new对应的operator delete,如果我们使用的是自定义的 placement new,而没有为其准备对应的 placement delete 的话,就无法避免发生内存泄漏。因此,合格的代码应该是这样的:

class Widget {
public:
    static void* operator new(std::size_t size, std::ostream& logStream);   // placement new

    static void operator delete(void* pMemory);                             // delete 时调用的正常 operator delete
    static void operator delete(void* pMemory, std::ostream& logStream);    // placement delete
};

另一个要注意的问题是,由于成员函数的名称会掩盖其外部作用域中的相同名称(见条款 33),所以提供 placement new 会导致无法使用正常版本的operator new

class Base {
public:
    static void* operator new(std::size_t size, std::ostream& logStream);
    ...
};

auto pb = new Base;             // 无法通过编译!
auto pb = new (std::cerr) Base; // 正确

同样道理,派生类中的operator new会掩盖全局版本和继承而得的operator new版本:

class Derived : public Base {
public:
    static void* operator new(std::size_t size);
    ...
};

auto pd = new (std::clog) Derived;  // 无法通过编译!
auto pd = new Derived;              // 正确

为了避免名称遮掩问题,需要确保以下形式的operator new对于定制类型仍然可用,除非你的意图就是阻止客户使用它们:

void* operator(std::size_t) throw(std::bad_alloc);           // normal new
void* operator(std::size_t, void*) noexcept;                 // placement new
void* operator(std::size_t, const std::nothrow_t&) noexcept; // nothrow new

一个最简单的实现方式是,准备一个基类,内含所有正常形式的 new 和 delete:

class StadardNewDeleteForms{
public:
    // normal new/delete
    static void* operator new(std::size_t size){
        return ::operator new(size);
    }
    static void operator delete(void* pMemory) noexcept {
        ::operator delete(pMemory);
    }

    // placement new/delete
    static void* operator new(std::size_t size, void* ptr) {
        return ::operator new(size, ptr);
    }
    static void operator delete(void* pMemory, void* ptr) noexcept {
        ::operator delete(pMemory, ptr);
    }

    // nothrow new/delete
    static void* operator new(std::size_t size, const std::nothrow_t& nt) {
        return ::operator new(size,nt);
    }
    static void operator delete(void* pMemory,const std::nothrow_t&) noexcept {
        ::operator delete(pMemory);
    }
};

凡是想以自定义形式扩充标准形式的客户,可以利用继承和using声明式(见条款 33)取得标准形式:

class Widget: public StandardNewDeleteForms{
public:
    using StandardNewDeleteForms::operator new;
    using StandardNewDeleteForms::operator delete;

    static void* operator new(std::size_t size, std::ostream& logStream);
    static void operator detele(std::size_t size, std::ostream& logStream) noexcept;
    ...
};

九、杂项讨论

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

  1. 严肃对待编译器发出的警告信息。努力在你的编译器的最高(最严苛)警告级别下争取“无任何警告”的荣誉。
  2. 不要过度依赖编译器的警告能力,因为不同的编译器对待事情的态度不同。一旦移植到另一个编译器上,你原本依赖的警告信息可能会消失。

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

如今 TR1 草案已完全融入 C++ 标准当中,没有再过多了解 TR1 标准库的必要。

条款55:让自己熟悉 Boost

Boost 是若干个程序库的集合,并且当中的许多库已经被 C++ 吸纳为标准库的一部分。不过在现在的 Modern C++ 时代,是否该在项目中使用 Boost 仍然有一定的争议,一些 Boost 组件并无法做到像 C++ 标准库那样高性能,零开销抽象,但毫无疑问的是,Boost 的参考价值是无法忽视的,你可以在 Boost 中找到许多非常值得学习和借鉴的实现。

你可能感兴趣的:(#,侯捷C++,c++,java,开发语言,Effective,C++)