研二上这学期真是从头忙到尾呀,后悔自己暑假没有平衡好生活和学习。最近终于搞完了科研任务和一些其他杂事,有时间来沉淀一下自己了。准备把C++路线整个走一遍,以备战明年的实习和校招,加油吧,少年!
之前粗略地记录了C++ primer第五版,就不重复花费时间了。虽然之前也看过effective c++,但当时理解还不够,这次准备重点理解好《effective c++》和《effective stl》这两本书。
🆗,那就一起来揭开,the 55 specific ways to improve your programs and designs. —— Scott Meyers
导读
声明(declaration)告诉编译器某个东西的名称和类型。
定义(definition)提供编译器一些声明所遗漏的细节。于对象而言,定义是编译器为此对象拨发内存的地点;于函数而言,定义提供了代码本体。对类class而言,定义列出它们的成员。
初始化(initialization)是“给予对象初值”的过程。对用户自定义类型的对象来说,初始化由构造函数执行。
区别“copy构造”和“copy赋值”
“=” 既可以表示赋值,也可以表示拷贝构造。而区分也是很简单的,如果有新对象被定义,那么一定会有构造函数调用,就一定是copy构造。
copy构造函数是一个尤其重要的函数,它定义一个对象如何 passed by value(以值传递),例如:
1 | bool hasAcceptableQuality(Widget w); |
这里参数w以 by value 的方式传递,借用的就是Widget的拷贝构造函数。
建议就是:以by value传递用户自定义类型通常是个坏主意,pass-by-reference-to-const往往是较好的选择。
指针和引用类型的命名规范:指针(pt,pointer to T),引用(rt,reference to T)。
让自己习惯C++
条款01:视C++为一个语言联邦
C++现在已是一个多重泛型编程语言,支持:过程形式、面向对象形式、函数形式、泛型形式以及元编程形式等。
C语言的局限:没有模板、没有异常、没有重载。。。
C++面向对象方面:类(构造函数和析构函数)、封装、继承、多态、virtual函数(动态绑定)等。
STL,是一个模板程序库,对容器、迭代器、算法以及函数对象的规约有极佳的密切配合。
条款02:尽量以const, enum, inline 替代 #define
#define定义的记号名称会被预处理器处理,却不会被编译器看到,这样调试时会无法追踪到这个记号,而导致浪费时间。
使用const的注意事项:
- 确定指针是否有必要定义为
const
- const变量作为类成员(作用域在类内),若只需要保留一份,则加
static
使用enum hack补偿做法: enum的行为更像#define。取const地址是合法的,而取#define和enum的地址是不合法的。如果不想让别人获得一个pointer或者reference指向整数常量,enum就可以实现这个约束。
1 | enum { numTurn = 5 }; |
对于常量,最好以const对象或者enums,替换#define。
对于形似函数的宏,最好改用inline函数,替换#define。
条款03:尽可能使用const
const允许定义一个语义约束,即指定一个“不该被改动”的对象。
修饰指针及内容时,原则是“左物右指”,即星号左边的const表示所指物是常量,而星号右边的const表示指针本身是常量。
STL的迭代器以指针为根据塑造,类似于一个 T* 指针
。注意的是,声明迭代器为const相当于声明指针为const(即,T* const),表示迭代器不可更换指向,但所指物的指是可以改变的。如果希望迭代器所指的东西不可被改动(即,希望const T*),那么应该使用 const_iterator
. 例如:
1 | std::vector<int> vec; |
const成员函数 是很重要的一类成员函数,因为:
- 使class接口容易被理解,哪些函数可以改动对象内容,而哪些不能。
- 使“操作const对象”成为可能。
例如,用const重载operator []来对不同版本的对象返回不同的返回类型。试想,如果const对象调用operator [] 没有得到const相应类型,那么就可以被修改了,这样是不对的。而重载两种 operator [] 又要避免代码冗余,就可以使用转型。
1 | class TextBlock{ |
让non-const 调用 const成员函数,non-const版本中有两个转型,第一个static_cast<const TextBlock>
则是为了调用const版本的成员函数,防止non-const版本循环调用。第二个const_cast<char&>
用于消除const成员函数返回值类型的const部分。
小结,当 const 和 non-const 成员函数有着实质等价的实现时,令 non-const 版本调用 const 版本能够避免代码重复。
条款04:确定对象被使用前已先被初始化
C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。这意味着,构造函数内的操作其实是赋值,而非初始化。初始化发生的时机更早,发生于成员的default构造函数被自动调用的时候。而在对象构造时初始化的一个较佳写法是,使用成员初值列表(member initialization list)替换赋值动作:
1 | ABEntry::ABEntry(const std::string &name, const std::string &address, |
这种方式通常更高效,省去了赋值的过程,成员的初值由它们的default构造函数初始化。
规定一下,在成员初值列表中总是按声明的顺序列出所有成员变量,以免还得记住哪些成员变量可以无需初值。
C++有十分固定的“成员初始化次序”,即(1)基类总是早于派生类被初始化;(2)类的成员变量总是以声明的次序被初始化。
一个问题是,不同编译单元内的non-local static对象的初始化相对次序是不明确的,不过可以使用单例模式解决,即不直接调用non-local static对象,而是调用其专属函数(首次调用时初始化,否则返回引用)。这种方式也是将 non-local static 对象转化成了 local static 对象。
构造、析构、赋值运算
条款05:了解C++默默编写并调用了哪些函数
如果写了一个空类,那么编译器会自动为其编写构造、析构、copy构造和copy赋值函数(都是public且inline的),当这些函数被需要时,它们就会被编译器创建。类似于:
1 | class Empty{ |
注意,编译器产生的析构函数是 non-virtual ,除非这个类的基类自身声明有virtual析构函数(这种情况下该函数的虚属性来自于基类)。关于虚析构函数的讨论见:虚析构函数 | 百度百科
至于 copy构造 和 copy赋值 操作符,编译器创建的版本仅是单纯地“将来源对象的每一个 non-static 成员变量拷贝到目标对象”。
如果用户自己声明了构造函数,那么编译器将不再为类创建default构造函数。
如果用户定义的类中包含“reference成员”或者“const成员”,或者其基类的copy赋值操作符为private,那么编译器将拒绝自动创建copy赋值函数。reference和const成员是不可改变的,编译器不会自动生成copy赋值函数而导致这样错误的行为。此时就必须由用户自己定义copy赋值函数。
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
假如有一个类是唯一的,那么就不允许任何对该对象的拷贝和赋值。通常如果不希望class支持某一特定机制,只要不声明对应函数就行了,但这个策略却对 copy构造
和 copy赋值
行不通。
为驳回编译器自动提供的机能,通常有两种方式:
将相应的成员函数声明为 private 并且不予实现。
1
2
3
4
5
6
7
8class HomeForSale{
public:
...
private:
...
HomeForSale(const HomeForSale&); // 仅声明
HomeForSale& operator=(const HomeForSale&);
};这样编译器可以防止客户拷贝HomeForSale对象,而且若其成员函数或者friend函数调用该对象的拷贝,连接器也会由于缺少定义而拒绝这一行为。
使用像 Uncopyable 这样的 base class.
1
2
3
4
5
6
7
8class Uncopyable{
protected:
Uncopyable(){}
~Uncopyable(){}
private: // 阻止copying行为
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};要组织HomeForSale对象被拷贝,唯一需要的就是继承Uncopyable.
1
2
3class HomeForSale: private Uncopyable{
...
};
条款07:为多态基类声明virtual析构函数
当使用工厂(factory)函数,返回基类指针时,其指向的对象必须位于heap中。因此,为避免内存和其他资源被泄露,每一个对象都应该被适当地delete掉。
C++指出,当派生类对象经由一个基类指针被删除,而该基类带着一个 non-virtual 析构函数时,其结果未定义——实际执行时通常发生的是对象的derived部分没有被销毁。造成一个诡异的“局部销毁”对象。
解决这个问题也很简单:给base class一个virtual析构函数。这样删除派生类对象就会完整地销毁整个对象,包括所有派生的部分。
1 | class TimeKeeper{ |
像TimeKeeper这样的基类除了析构函数之外通常还有其他virtual函数,使得派生类得到客制化。同样地,任何class只要带有virtual函数都几乎确定应该有一个virtual析构函数。
当某个基类具备虚析构函数时,它的大小就有了变化。为了体现出虚函数,对象必须携带某些信息,用来在运行期间决定哪一个virtual函数该被调用。这份信息通常由一个所谓vptr(virtual table pointer)指针指出。vptr指向一个由函数指针构成的数组,成为vtbl(virtual table);每一个带有virtual函数的类都有一个相应的vtbl,当对象调用某一virtual函数,实际被调用的函数取决于该对象的vptr所指向的那个函数指针。
继承一个带有 non-virtual 析构函数的基类,是很容易出现的一种错误行为。例如,某个具体的字符串类直接继承了STL中的string类,注意string类的析构函数是 non-virtual 的。所以,当继承一个基类时,需要注意其析构函数是否为 virtual.
总结一下,如果要作为基类使用且具备多态性质,那么就应该拥有一个 virtual 析构函数。
条款08:别让异常逃离析构函数
C++不禁止析构函数吐出异常,但它不鼓励这么做。
Tips:为保证用户对资源类对象不忘记调用close方法(例如数据库连接),可以创建一个管理资源的类,并在其析构函数中调用close.
1 | class DBConn{ |
即,不让用户使用new,delete来调用堆中的资源。这样,用户就能写出这样的代码:
1 | { |
当DBConn离开作用域之后,就会析构,进而自动调用DBConnection的close()方法了。
然而,如果close调用导致异常,那么DBConn析构函数会传播异常。这样通常可以abort终止该异常,或者吞下异常。但是这样导致用户无法对该异常做出反应。更好的做法是提供一个普通函数,让用户有机会对可能出现的问题做出反应。这里在DBConn中设计一个close函数:
1 | class DBConn{ |
这样将调用close的责任从DBConn析构函数手上转移到DBConn客户手上,但是DBConn析构函数中仍含有一个“双保险”的调用,以防粗心的客户忘记使用close方法。
条款09:绝不在构造和析构过程中调用virtual函数
不应该在构造和析构函数期间调用virtual函数,这类调用从不下降至派生类。例如,基类的构造和析构中使用了virtual函数,那么派生类对象的基类构造期间,其对象类型是基类而不是派生类,虚函数只会被编译器解析至基类。
如果基类构造和析构中的虚函数是pure虚函数还好,大多数系统会中止程序;而如果是正常的虚函数,那么在构造和析构时总是会调用基类版本的虚函数,这会让你百思不得其解。
解决方法是将虚函数作为正常的函数,然后“令派生类将必要的信息向上传递至基类的构造函数”。例如:
1 | class Transaction{ |
条款10:令operator=返回一个reference to *this
赋值运算是右结合的,并且能够连锁运算。为了实现“连锁运算”,赋值操作符就必须返回一个reference指向操作符的左侧实参。例如:
1 | class Widget{ |
这个协议不仅适用于标准赋值形式,也适用于所有赋值相关运算(+=, -=, *= 等等)。
条款11:在operator=中处理“自我赋值”
“自我赋值”发生在对象赋值给自己时。这有点愚蠢,但是合法。例如a[i] = a[j],当i==j时,其实就是自我赋值。
自我赋值并不一定是安全的,例如:
1 | Widget::operator=(const Widget& rhs){ |
这样如果rhs本身就是this指向的对象,那么rhs.pb首先将被删除,然后new Bitmap将用一个“已删除的”对象拷贝构造,这是不应该发生的。
如果想要阻止这种“自我赋值的不安全性”,那么可以在函数最前面加一个“证同测试(identity test)”来检验是否遇上相同对象。或者用一个临时对象先保存要删除pb,然后在函数结束前删除。但其实这两个方式都会降低效率。
还有一个替换方案就是 “copy and swap”,这是一个常见而够好的operator=
撰写方法:
1 | Widget::operator=(const Widget& rhs){ |
其实,由此引出的是,任何函数如果操作一个以上的对象,都要考虑“自我赋值的安全”,即其中多个对象是同一个时,其行为是否仍热正确。
条款12:复制对象时勿忘其每个成分
当编写了一个copying函数,就应该确保:
- 复制所有local成员变量
- 调用所有base class内适当的copying函数(考虑清楚是默认构造基类 or 自定义构造基类)
如果copy构造
和copy赋值
中有相近的代码,消除重复是一个很好的想法,但是其实两者谁调用谁都说不通,所以消除copy构造和赋值代码重复的一个方法是,建立一个新的成员函数给两者调用。这样的函数往往是private而且常常命名为init
。类似于:
1 | class Widget{ |
资源管理
条款13:以对象管理资源
在获取了资源后,就有责任在不需要的时候释放掉,考虑一个标准的函数f:
1 | void f(){ |
f函数正常获取了动态分配内存资源,并且在函数最后释放掉了。不过这个过程中还是存在着许多“隐患”,比如“…”过程中提前return了,或者循环中的goto或者continue语句导致跳过了资源释放的环节,那么就很可能导致内存泄漏的问题。
一个确保获取的资源总是能够被释放的方法是:将资源放到对象内。这样当控制流离开f函数后,对象的析构函数会自动释放那些资源。
许多资源被动态分配在heap内,然后被用在单一区块或者函数中。其实这些资源应该在离开作用域之后被释放。
标准程序库提供了 auto_ptr
就是针对以上情况而特制的解决方案。auto_ptr
是一个“类指针对象”,也就是所谓的智能指针
。它的析构函数将自动对其所指对象调用delete
。能够避免f函数潜在的资源泄露(使用auto_ptr需要包含头文件memory):
1 | void f1(){ // 手动资源管理 |
“以对象管理资源”有两个关键想法:
- 获得资源后立即放进管理对象。对于资源管理对象来说,“资源获取的时机就是初始化时机”(Resource Acquisition Is Initialization; RAII),有时候获取的资源被拿来初始化或者赋值,不管那种做法,都应该在获得资源后立马放到管理对象中。
- 管理对象运用析构函数确保资源被释放。对象离开作用域将被销毁,其析构函数会自动调用。
需要注意的是,auto_ptr
虽然好用,但是不应该让多个 auto_ptr 同时指向同一对象。否则该对象被多次销毁,会发生未定义行为。为此,它有一个不寻常的特性:通过copy构造或者copy赋值来赋值它们时,它们会变成null,而复制得到的指针将取得资源的唯一拥有权。
1 | std::auto_ptr<Investment> pInv1(createInvestment()); // 创建pInv1 |
auto_ptr这种行为限制了 受其管理的资源必须绝没有一个以上auto_ptr同时指向它。
不过这种方式的限制并不能使问题得到解决,如果客户并没有在资源获得时就放入管理对象,那么就无法限制对象的唯一权,还是会发生内存泄露,例如:
1 | // 测试auto_ptr同时指向一个对象 |
另外,在C++17中,auto_ptr已经被删除了。其代替方案是“引用型智慧指针”(Reference-Counting Smart Pointer;RCSP)。这也是个智能指针,会持续追踪共有多少对象指向某个资源,并在无人指向它时自动删除该资源。TR1中的tr1::shared_ptr
就是个RCSP,于是可以这么写f函数:
1 | void f(){ |
shared_ptr 和 auto_ptr仅仅是个例子。本条款最重要的就是使用RAII对象来防止资源泄露,它们在构造函数中获得资源,并在析构函数中释放资源。
条款14:在资源管理类中小心copying行为
对于非heap-based资源,智能指针往往不适合作为资源管理者。那么有时,就需要建立自己的资源管理类。例如,为确保不会忘记将一个锁住的Mutex解锁,我们希望建立一个class来管理锁的机制:
1 | class Lock{ |
然后,这里存在一个一般性的问题是:“当一个RAII对象被复制,会发生生么事?”大多情况下,会有两种选择:
- 禁止复制。可以通过条款6的方式,将copying操作声明为private来禁止RAII对象被复制。
- 引用计数法。当希望保有资源,直到它的最后一个使用者被销毁时,复制RAII对象,应该将资源的“被引用数”递增,可以基于shared_ptr实现,而且可以通过其“删除器”指定当引用计数为0时的动作(对于mutex来说,引用为0时是unlock而不是删除)。
- 复制底部资源。不过这样的复制应该是深度拷贝,也就是创建资源的复件。
- 转移底部资源的拥有权。可以确保永远只有一个RAII对象指向一个资源。
条款15:在资源管理类中提供对原始资源的访问
资源管理类将资源管理起来,也要提供相应的接口来提供对原始资源的访问。对于智能指针(例如 tr1::shared_ptr 和 auto_ptr)而言,有两种方式来获取原始资源:
显式转换:智能指针中提供了一个
get
成员函数,可以返回智能指针内部的指向原始资源的指针。1
2
3int daysHeld(const Investment* pi); // 函数原型,返回投资天数
int days = daysHeld(pInv.get()); // pInv.get()返回原始资源指针隐式转换:几乎所有智能指针都重载了指针取值操作符(operator-> 和 operator*),允许隐式转换至底部原始指针。如果要直接获取原始资源,还可以重载operator(),但也会造成不小心错误转换的问题。
1
2
3
4
5
6
7
8
9class Investment{
public:
bool isTaxFree() const; // 一个成员函数
};
Investment* createInvestment(); // factory函数
std::tr1::shared_ptr<Investment> pi1(createInvestment());
bool taxable1 = !(pi1->isTaxFree()); // 使用operator->隐式转换
std::auto_ptr<Investment> pi2(createInvestment());
bool taxable2 = !((*pi2).isTaxFree()); // 使用operator*隐式转换
一般来说,显式转换比较安全,而隐式转换对客户比较方便。
条款16:成对使用new和delete时要采取相同形式
考虑这样的行为:
1 | string *stringArray = new string[100]; |
很明显,new的是一个100个string元素的数组,而delete仅正确删除了数组中的第一个对象,另外99个不太可能被正确删除,因为它们的析构函数没有被调用。
关于new和delete:
使用new动态生成对象时,有两件事发生:(1)通过名为 operator new 的函数将内存分配出来;(2)针对此内存会有一个(或更多)构造函数被调用。
当使用delete时,也有两件事发生:(1)针对此内存有一个(或更多)析构函数被调用;(2)通过名为 operator delete 的函数释放内存。
然而,delete的最大问题在于:即将被删除的内存之中究竟有多少对象?这关系到有多少个析构函数必须被调用。这个问题也可以简化为:即将被删除的指针,所指向的是单一对象还是对象数组?这是因为,数组所在的内存中通常还包括“数组大小”的记录,delete如果知道是数组,就能知道需要调用多少次析构函数。
所以,当你调用new时使用[ ],就必须在对应调用delete时也使用[ ]。
1 | string *stringPtr1 = new string; |
条款17:以独立语句将newed对象放入智能指针
考虑这样的语句:
1 | int priority(); |
prcoessWidget
需要传入智能指针,就直接在传参时初始化了。但是令人惊讶的是,这样的调用可能会造成隐晦的资源泄露。这是因为调用processWidget前,编译器必须创建代码,做以下三件事:
(1)调用priority;(2)执行“new Widget”;(3)调用tr1::shared_ptr构造函数
但是,C++编译器以什么样的顺序执行这三个语句是不确定的(只能确定的是,(2)优先于(3)执行,但(1)可能排在1,2或3的次序来执行)。假设调用priority在第二步执行,万一priority的调用导致异常,那么“new Widget”返回的指针将会遗失,因为它还没有置入tr1: :shared_ptr中。
为避免这样的问题,应该使用分离语句,用独立的语句将newed对象置入智能指针中。否则,一旦异常被抛出,有可能导致难以察觉的资源泄露。
1 | std::tr1::shared_ptr<Widget> pw(new Widget); // 独立语句 |
设计与声明
条款18:让接口容易被正确使用,不易被误用
想设计出一个“易用而不易出错的”接口,必须考虑客户可能出现什么样的错误。具体地,可以从两个方面着手改善:
“促进正确使用”:包括接口一致性,以及与内置类型的行为兼容。
“阻止误用”:包括建立新类型、限制类型上的操作、束缚对象值,以及消除客户的资源管理责任。
例如,用一个class表示日期,考虑这样的构造函数:
1
2
3
4class Date{
public:
Date(int month, int day, int year);
};这样有可能客户会不记得那个参数是月,而哪个是日,客户可能会迷糊。一个好的办法是引入外覆类型来区别天数、月份和年份,然后在Date构造函数中使用这些类型:
1
2
3
4
5
6
7
8
9
10
11struct Day/Month/Year{
explicit Day/Month/Year(int x):val(x){}
int val;
};
class Date{
public:
Data(const Month& m, const Day& d, const Year& y);
};
Date d(Month(1), Day(7), Year(2023)); // 这样调用更加明确,错误的类型将导致异常,防止接口被误用
条款19:设计class犹如设计type
如何设计高效的class,需要面对以下的问题:
- 新type的对象应该如何被创建和销毁?创建销毁是单一对象还是数组。
- 对象的初始化和赋值有什么样的差异?
- 什么是新type的”合法值“?这意味着它的成员函数必须错误检查工作,以及考虑可能抛出的异常。
- 新的type需要配合某个继承体系吗?特别是受到”它们的函数是virtual还是non-virtual“的影响。
- 新的type需要什么样的转换?涉及到类型转换。
- 什么样的操作符和函数对此新type而言是合理的?这会决定你将为该class声明哪些函数。
- 什么样的标准函数应该被驳回?哪些函数(包括编译器自动创建的)应该被声明为private。
- 谁该取用新type的成员?这个问题将决定哪个成员为public,哪个为protected,哪个为private。
- 你的新type有多么一般化?如果足够一般化,就应该定义一个新的class template。
- 你真的需要一个新的type吗?
条款20:宁以 pass-by-reference-to-const 替换 pass-by-value
缺省情况下,C++以 by value 方式传递对象至函数,函数参数以实际参数的复件为初值,而调用端所获得的也是函数返回值的一个复件。这些复件是由对象的copy构造函数产出的,这使得 pass-by-value 成为昂贵的(费时的)操作。
1 | // pass-by-value |
我们知道,在C++中有以下三种情况需要调用拷贝构造函数:
- 一个对象作为函数参数,以值传递的方式传入函数体;
- 一个对象作为返回值,以值传递的方式从函数返回;
- 一个对象用于给另一个对象进行初始化。
另一方面,pass-by-reference-to-const 这种方式就高效得多:没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。其中的const声明也是很重要的,这样就不用担心调用者会对传入的对象进行修改,除非确实需要修改才不加const。
而且,pass-by-reference-to-const 还有一个好处是:可以避免对象切割(slicing)的问题。下面作为一个例子:
1 | class Window{ |
注意到 display
是一个虚函数,这意味着两种窗口的展示方式会不同。现在假设你希望写一个函数打印窗口名称并显示该窗口,如果这么写将是一个错误的示范:
1 | void printWindow(Window w){ |
当调用以上函数,并传递一个 WindowWithScrollBars 对象时,参数w会被构造成一个Window对象,而所有 WindowWithScrollBars 对象所特有的信息将会被切除。
解决对象切割(slicing)问题的办法就是,以 pass-by-reference-to-const 的方式传递w:
1 | void printWindow(const Window& w){ // 引用传递,对象参数不会被切割 |
由于 reference 往往以指针实现,这意味着 pass-by-reference 真正传递的是指针(引用是别名形式,不过其内涵仍是指针)。所以,传进来的窗口是什么类型,w就表现的是哪种类型。
不过,虽然 pass-by-reference-to-const 更加高效,且能够避免对象切割的问题。但是,这种规则并不适用于内置类型,以及STL的迭代器和函数对象,对它们而言,pass-by-value 往往比较合适。
条款21:必须返回对象时,别妄想返回其reference
领悟到了 pass-by-reference-to-const 的高效后,如果一味执着地追求,那么一定会犯下一个致命错误:传递一些references指向其实并不存在的对象。
在函数返回时,一定要注意以下三点:
绝不要返回指向一个 local stack 对象的 pointer 或者 reference。这很容易理解,本地栈对象离开函数体就销毁了。
不要返回指向一个 heap-allocated 对象的reference。
返回堆对象的引用,那么谁去负责它的资源释放呢?对于外层看来就是一个对象,客户大概率不会想到去释放这部分资源(或者说,释放一个看似普通对象而非指针是比较迷惑的),若不处理,则必然都是内存泄露。比如释放堆对象的引用就需要这么做:
1
2
3
4
5friend const Student& clonedStudent(const Student& cs){ // 返回堆对象的引用
Student *clone_s = new Student(cs);
return *clone_s;
}
delete &clonedStudent(wlz); // 释放资源当然,返回堆对象的指针也需要很好的资源管理,但至少对于客户来说,能够清晰地知道 “这个指针指向了一部分资源,需要进行管理”。
不要返回 pointer 或者 reference 指向一个 local static 对象而可能同时需要多个这样的对象。这会在多线程中出现安全性问题。
同时,结合到条款10,应该得到这样的结论:
- class中不需要产生新对象的操作,例如重载赋值型操作(=,-=,+=,*=等等),应返回 reference to *this。因为赋值函数不用产生新对象,而引用的返回可以保证“连锁赋值”操作。
- 对于需要产生新对象的函数,遵守上述三条规则。例如class中的运算操作(*,+等)。
条款22:将成员变量声明为private
仅记住两点即可:
- 切记将成员变量声明为 private 。这可以赋予客户访问数据的一致性(public全为接口)、可细微划分访问控制、允许约束条件获得保证,并提供class作者以充分的实现弹性。
- protected 并不比 public 更具封装性。
条款23:宁以 non-member、non-friend 替换 member 函数
考虑用一个class来表示网页浏览器。在class可能提供的众多函数中,有一些用来清除下载元素高速缓冲区、清除访问过的URLs、以及移除系统中所有的cookies:
1 | class WebBrowser{ |
许多用户会想,再建立一个功能来一整个执行所有的清除动作,因此这个类还可以提供一个这样的函数:
1 | class WebBrowser{ |
或者,这一功能也可以由一个 non-member 函数调用适当的 member 函数来完成,即:
1 | void clearEverthing(WebBrowser& wb){ |
那么,这两种实现,哪一个更好呢?
事实是,使用 non-member,non-friend 函数代替 member 函数会更好,这样能够提高封装性(它不增加“能够访问class内private部分”的函数数量)、包裹弹性和机能扩充性。
条款24:若所有参数都需类型转换,请为此采用 non-member 函数
让class支持隐式类型转换通常是一个糟糕的主意,但是也有意外,比如在建立数值类型时。假设设计了一个class用来表示有理数,那么允许整数“隐式转换”为有理数是合理的。假如这样开始了“有理数”类的构建:
1 | class Rational{ |
此时,想要支持算术运算符(如+,*等),但不确定该由 member 函数、non-member 函数,或者 non-member friend 函数来实现时,应该保持面向对象的精神。实现乘法与有理数有关,所以应该在类中成员函数为有理数实现 operator*。比如,这么实现了:
1 | class Rational{ |
这种方式可以应对两个有理数的相乘:
1 | Rational oneEight(1, 8); |
但是,当进行混合运算时,比如用有理数与int进行相乘,却只有一半能行得通:
1 | result = oneHalf * 2; // (1)正确, oneHalf.operator*(2), 2会隐式转换为Rational |
我们尝试找出(1)之所以成功的原因,oneHalf调用operator*,而参数是2,编译器知道函数需要的是Rational,而由于Rational的构造函数并没有explicit,所以可以将int隐式转换到Rational。
这样得出的一个结论是:只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。
那么让Rational进行混合运算的方法也拨云见日了,即:让operator*成为一个 non-member 函数,接受所有运算符的参与者,以允许编译器在任何一个实参上执行隐式类型转换。
1 | class Rational{ |
如果需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member 函数。
条款25:考虑写出一个不抛异常的swap函数
swap是一个有趣的函数。它原来只是STL的一部分,后来成为异常安全性编程(条款29)的脊柱,以及用来处理自我赋值(条款11)可能性的一个常见机制。swap的典型实现如下:
1 | namespace std{ |
只要类型T支持copying(copy构造和copy赋值操作符),swap就会帮你完成置换的操作。不过这样的实现对于“pimpl手法”来说是没有必要的。所谓“pimpl手法”指的是:“以指针指向一个对象,内含真正数据”的类型。比如用这种方式来设计Widget,那么就会是这个样子:
1 | class WidgetImpl{ // Implment类,包含真正数据 |
当需要交换Widget对象时,实际上只要交换它们的 Impl 指针就可以了,但是上述实现的典型swap并不知道。这样的话,swap不仅会复制3个Widget,还有3个WidgetImpl对象,这样的效率比较低。我们希望告诉swap,置换Widget时,其实只需要置换内部的Impl指针即可。
(书中这段讲的感觉有点乱,需要消化一下)
这里直接上结论:
- 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
- 如果提供一个 member swap,也该提供一个 non-member swap 用来调用前者。对于class(而非template),需要特化std::swap。
- 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
- 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
实现
条款26:尽可能延后变量定义式的出现时间
只要你定义了一个变量而其类型具有一个构造函数或析构函数,那么就得承受它带来的构造和析构成本。即使这个变量最终并未使用,仍需耗费这些成本,所以应该尽可能避免这种情形。
考虑这样的情景:一个函数计算通行密码的加密版本然后返回,前提是密码足够长;如果密码太短,函数就会丢出一个异常,类型为logic_error。
1 | string encryptPassword(const string& password){ |
那么情况就是,如果有个异常被丢出,对象encrypted就没有用,但仍得付出encrypted的构造和析构成本,所以最好延后encrypted的定义式,直到确实需要它。进一步地,应该尝试延后这份定义直到能够给它初值为止,这样可以避免无意义的default构造行为(“默认构造后再赋值”比“直接在构造的时候指定初值”效率低)。这样可以增加程序的清晰度,并改善程序效率。
条款27:尽量少做转型动作
转型通常可能导致种种麻烦,有些容易识别,有些则非常隐晦。然而,在C++中转型是一个你会想带着极大尊重去亲近的一个特性。
来回顾一下转型语法,可以分为“旧式转型”和“新式转型”两种:
“旧式转型”:
1 | (T)expression // C风格的转型 |
“新式转型”:指C++提供的四种新式转型
1 | const_cast<T>( expression ) |
- const_cast 通常被用来将对象的常量性转除(cast away the constness)。它也是唯一有此能力的C++style转型操作符。
- dynamic_cast 主要用来执行“安全向下转型(safe downcasting)”,也就是用来决定对象是否归属继承体系中的某个类型。
- reinterpret_cast 意图执行低级转型,实际动作(及结果)可能取决于编译器,这也代表它不可移植。例如将一个 pointer to int 转型为一个 int,这类转型在低级代码以外非常少见。
- static_cast 用来强迫隐式转换(implicit conversion),例如将 non-const 对象转换为 const 对象,将 int 转换为 double 等。它也可以用来执行上述多种转换的反向转换,例如将 void* 指针转换为 typed 指针,将 pointer-to-base 转换为 pointer-to-derived。但是它无法将 const 转换为 non-const ,这个只有const_cast能够做到。
许多人认为转型只是告诉编译器把某种类型视为另一种类型。这是错误的观念,其实任何一个类型转换往往会令编译器编译出运行期间执行的码,因为不同类型的底层实现是不同的。
而且因为转型,我们很容易写出似是而非的代码。例如许多应用框架都要求derived class内的virtual函数代码第一个动作就先调用base class的对应函数。假设这么进行了实现,它看起来对,但实际上错:
1 | class Window{ |
这段程序将*this
转型为Window
,对函数onResize的调用也因此调用了Window::onResize。但恐怕你没有想到,这样调用的并不是当前对象上的函数,而是稍早转型动作所建立的一个“*this对象的base class成分”的暂时副本身上的onResize. 换句话来说就是,它在“当前对象的base class成分”的副本上调用Window::onResize,然后在当前对象身上执行SpecialWindow的专属动作。
如果Window::onResize修改了对象内容(也有可能,因为onResize此时是一个non-const成员函数),就会使得当前对象进入到一个“伤残”状态:其 base class 成分的更改没有落实,而 derived class 成分的更改落实了。
解决方法就是拿掉转型动作,取而代之的是你真正想要表达的:令base class的onResize函数作用到当前对象的身上。
1 | class SpecialWindow: public Window{ |
这个例子也说明,如果你发现自己打算转型,这可能就是一个警告⚠:可能会发生错误。
如果使用的是 dynamic_cast 就更是如此。而且 dynamic_cast 的许多实现版本执行速度相当慢,深度继承或者多重继承时,它的成本更高。
之所以需要dynamic_cast,通常是因为想在一个认定为 derived class 对象身上执行 derived class 操作函数,但是手上却只有一个“指向 base”的pointer或reference,只能靠它们来处理对象。有两个一般性方法可以避免这个问题:
使用容器,并在其中存储直接指向 derived class 对象的指针(通常是智能指针,以管理资源)。
1
2
3
4
5
6typedef vector< tr1::shared_ptr<SpecialWidon> > VPSW;
VPSW winPtrs;
...
for(VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end() ++iter){
(*iter)->blink();
}当然,这种做法无法在同一容器中存储“所有Window派生类的指针”。如果要处理多种窗口类型,可能需要多个容器,都必须具备类型安全性。
在 base class 内提供virtual函数做你想对各个派生类做的事。例如,虽然只有SpeicalWindow可以闪烁,但将闪烁函数声明于base class内,并提供一份”什么也没做“的缺省实现码也是有意义的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Window{
public:
virtual void blink(){} // 缺省实现代码“什么也没做”
};
class SpecialWindow: public Window{
public:
virtual void blink(){ ... } // 在该类内 blink 进行一些动作
};
typedef vector< tr1::shared_ptr<Window> > VPW;
VPW winPtrs;
...
for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter){
(*iter)->blink();
}
总结一下,本条款需要注意的就是:如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast ,如果有个设计需要转型,试着发展无需转型的替代设计;如果转型是必要的,试着将其隐匿在某个函数后,而不需要客户将转型放到自己的代码中。宁可使用新型转型,不要使用旧式转型,前者更容易分辨且有分门别类的职掌。
条款28:避免返回handles指向对象内部成分
考虑这样的程序设计,每个矩形由左上角和右上角表示。为了让Rectangle对象尽可能小,会将其放到一个辅助的struct内,再让Rectangle指向它:
1 | class Point{ |
Rectangle要返回左上角和右下角的数据给客户,而为了提高效率返回了Point引用。这样虽然能够通过编译,确实自我矛盾的,因为Point本应该是内部私有数据,但是经由public函数返回了引用,这样Point对于客户相当于是public的了,可以通过引用来更改内部数据。
这给我们带来两个教训:
- 成员变量的封装性最多只等于“返回其reference”的函数的访问级别;
- 如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数调用者就能够修改那笔数据。
一般地,如果成员函数返回指针或者迭代器,情况也是一样的。Reference, 指针和迭代器都是所谓的“handles”(号码牌,用来获取某个对象),返回一个“代表对象内部数据”的handle,随之而来的就是“降低对象封装性”的风险。
其实对于上述矩形例子的情况,对返回值加上const就能够保证内部数据不被外部修改:
1 | const Point& upperLeft() const { return pData->ulhc; } |
尽管如此,返回“代表内部对象的handles”也可能在其他场合带来问题,即可能导致dangling handles(空悬的号码牌):handles所指东西不复存在。
种种,并不代表你绝不可以让成员函数返回handles。但是,尽量避免返回handles指向对象内部,可以增加封装性,帮助const成员函数的行为像个const,并将发生“空悬号码牌”的可能性降到最低。
条款29:为“”异常安全“而努力是值得的
假设有一个class用来表现带背景图案的GUI菜单,同时这个class希望用于多线程环境,所以它配有一个互斥器(mutex)作为并发控制:
1 | class PrettyMenu{ |
这个函数仅仅完成了“基础功能”,但是从“异常安全性”的观点来看,却实现的非常糟糕。所谓“异常安全”有两个条件,而这个函数并没有满足其中任何一个。考虑到当异常被抛出时,带有异常安全性的函数往往会:
- 不泄露任何资源。上述代码没有做到,因为一旦“new Image()”导致了异常,那么unlock就不会调用,于是互斥器就永远被锁住了。
- 不允许数据破坏。如果“new Image()”抛出异常,那么
bgImage
就会指向一个已被删除的对象,而且imageChanges
也被累加,但是实际上并没有新的图像被安装应用。
对于“资源泄露”,第三章已经介绍了很多方法,这里对于非heap-based的mutex,可以借鉴条款14引入的Lock类进行互斥器资源管理:
1 | void PrettyMenu::changeBackground(std::istream& imgSrc){ |
解决了第一个问题,就可以专注于如何处理可能的数据破坏了。异常安全函数(Exception-safe functions)提供以下三个保证之一:
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下,不会有任何数据被破坏。但是唯一的问题就是程序的现实状态可能无法预测,例如更换背景的函数,异常抛出后回到之前的状态或者默认的状态都是有效的。
- 强烈保证:如果异常被抛出,程序状态不改变。这意味着如果函数失败,程序会回复到“调用函数之前”的状态。
- 不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总能够完成承诺的功能。例如,作用在内置类型(ints, 指针等)身上的所有操作都提供nothrow保证。
对于大部分函数而言,考虑异常安全会在基本保证和强烈保证中进行抉择。至于changeBackground来说,提供强烈保证并不困难:(1)改变PrettyMenu的bgImage成员变量的类型,从Image*指针改为一个“用于资源管理”的指针,使用std::tr1::shared_ptr。(2)对语句重新排序,当更换背景图像之后,在累加imageChanges变量。改变结果如下:
1 | class PrettyMenu{ |
这两个修改足以为changeBackground函数提供异常安全保证,唯一美中不足的就是参数imgSrc,如果输入流参数被移走,那么就会导致Image构造函数抛出异常。
注意,这里不再需要手动delete旧图像,因为这个动作在智能指针内部处理掉了,当新的背景图像创建成功后,智能指针更换指向前,就会自动释放旧的背景图像。
一般地,有个经典的策略也能够承诺强烈保证,即:copy and swap.
原则很简单:给你打算修改的对象(原件)做出一份副本,然后在那个副本身上做一切必要的修改。若有任何修改抛出异常,原对象仍保持不变。等所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。
copy and swap在实现上通常是将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个实现对象,这种手法也就是所谓的”pimpl”,对于PrettyMenu而言,典型的写法是这样的:
1 | struct PMImpl{ |
在这个例子中,之所以让PMImpl成为一个struct而不是一个class,是因为PrettyMenu的数据封装性已经由“pImpl是一个private成员”而得到了保证。
当然,有的时候某些原因必然会阻止你为函数提供强烈保证,其中一个就是效率。copy-and-swap的关键在于“修改对象数据的副本,然后在一个不抛出异常的函数中将修改后的副本与原件置换”,而为原有对象做出副本,就得消耗一定的时间和空间成本。当你无法提供这样的成本时,强烈保证就不那么实际了,这时就必须提供基本保证。
条款30:透彻了解inlining的里里外外
inline函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。
但是,过于热衷inlining也会导致:增加目标码(object code)的大小,代码膨胀导致额外的换页行为,降低告诉缓存装置的命中率,以及伴随而来的效率损失。不过,如果inline函数的本体很小,编译器针对“函数本体”所产出的码可能比针对“函数调用”所产出的码更小,从而提高效率。
inline只是对编译器的一个申请,不是强制命令。而且这项申请既可以明确提出,也可以隐喻提出:
明确提出,用inline关键字加以修饰。例如,标准的max template:
1
2
3
4template<typename T>
inline const T& max(const T& a, const T& b){
return a < b ? b : a;
}隐喻提出,该方式是将函数定义在class的定义式内。即,类内定义的成员函数已被隐含地指定为Inline函数了。
1
2
3
4
5
6
7
8
9
10
11
12class Person{
public:
int age() const { return theAge; } // age其实被隐含指定为inline
inline string name() const; // 在类外定义,如需内联需明确提出为inline
private:
int theAge;
string theName;
};
inline string Person::name() const{
return theName;
}但要注意的是,如果类的成员函数在类内声明,而在类外定义,那么编译器并不认为它是一个内联函数(例如Person::name)。所以建议就是不要在类中编写大量的代码,而是应该将类的声明和定义分开,只有成员规模小且调用频率高时,才指定为inline函数。
条款31:将文件间的编译依存关系降至最低
C++项目代码应该做到“接口与实现分离”,而其关键在于“以声明的依存性”替换“定义的依存性”。这也是编译依存性最小化的本质——程序头文件应该以“完全且仅有声明式”的形式存在:头文件自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。
令编译依存性最小化,可以源于以下的设计策略:
如果使用 object reference 或 object pointers 可以完成任务,就不要使用 objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers;但如果定义某类型的objects,就需要用到该类型的定义式。例如以下写法:
1
2
3
4
5
6int main()
{
int x; // 定义一个int
Person p( params ); // (1)定义一个Person
Person *p; // 定义一个Person指针
}当编译器看到p的定义式(1)时,它要知道应该在stack中分配多少空间才能放置一个Person,于是就会询问class定义式。而如果时定义式(2),那么对class Person的任何实现上的修改都不需要Person客户端重新编译了。
如果能够,尽量以class声明式替换class定义式。值得注意的是,当你声明一个函数而它用到某个class时,你并不需要该class的定义,纵使函数以by value方式传递该类型的参数(或返回值)也是一样的。
1
2
3class Date; // class声明式
Date today(); // 声明函数,返回Date对象
void clearAppointments(Date d); // 声明函数,参数为Date对象声明
today
和clearAppointments
函数时是不需要Date类型的定义式的。只有当有人需要调用这些函数时,调用之前才需要曝光Date的定义式。为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必须保持一致性。举个例子,如果客户想要声明today和clearAppointments函数,那么不应该以手工前置的方式声明Date类型,而是应该#include适当含有声明的头文件:
1
2
3
Date today();
void clearAppointments(Date d);这个致函声明式的头文件名为“datefwd.h”,命名方式同于标准程序库头文件的<iosfwd>, <iosfwd>内含iostream各组件的声明式,其对应的定义式则分布在若干不同的头文件中。
支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。而基于此构想的两个手段是 Handle class 和 Interface class.
Handle class: 将所有函数转交给相应的实现类并由后者完成实际工作,也就是 “pointer to implementation”的手法,例如下面是Person两个成员函数的实现:
1
2
3
4
5
6
7
8
Person::Person(const std::string& name, const Date& birthday, const Address& addr):pImpl(new PersonImpl(name, birthday, addr)){}
std::string Person::name() const{
return pImpl->name();
}可以看到,实际工作是由其实现类完成的。
Interface class: 仅作为描述derived class的接口,不携带成员变量,也没有构造函数,只有一个virtual析构函数以及一组 pure virtual 函数,用来叙述整个接口。例如,一个针对Person而写的 Interface class 看起来会是这样的:
1
2
3
4
5
6
7
8class Person{
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
};Interface class 的客户同时必须有办法为这种class创建对象,这通常需要工厂(factory)函数或者virtual构造函数,它们返回指针(或更为可取的智能指针),指向动态分配的对象,而该对象支持 Interface class 的接口。这样的函数往往在 Interface class 内被声明为static。
static成员函数,不会获得对象的引用,也不能访问对象的非static成员。函数调用的结果不访问或者修改任何对象(非static)数据成员,那么将这样的成员函数声明为static成员函数比较好。
例如,Person可能提供如下工厂函数,以让客户获取真正的Person对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37class Person{
public:
...
// create工厂函数返回一个shared_ptr,指向一个真实的Person派生类
static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
};
// 定义静态成员函数——工厂函数
std::tr1::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr){
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
// 这里也需要RealPerson的声明,可以构建一个personpwd.h头文件,包含各种定义式的声明
}
// 一个具象的Person类
class RealPerson: public Person{
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr):theName(name), theBirthDate(birthday),theAddress(addr){}
virtual ~RealPerson{}
// 下列函数的实现码不在这里显示
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
// 客户可能会这么使用
std::string name;
Date dateOfBirth;
Address address;
// 创建一个对象,支持Person接口。调用static函数create,拷贝构造shared_ptr。
std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
// 通过Person接口来使用这个对象
std::cout<< pp->name() << "was born on " << pp->birthDate() << " and now lives at " << pp->address() <<endl;
// 离开作用域后,该对象会由智能指针自动销毁
注意,如果只因为若干额外成本便不考虑Handle class 和 Interface class,那么将是严重的错误。应该考虑以渐进的方式使用这些技术。在程序发展过程中使用 Handle class 和 Interface class 以求实现码有所变化时对其客户带来最小冲击。而当它们导致速度和/或大小差异过于重大以至于classes之间的耦合相形之下不成为关键时,就以具象类(concrete class)替换Handle class 和 Interface class.
继承与面向对象设计
条款32:确定你的public继承塑模出 is-a 关系
纵使你会忘记其他的每一件事,也要记住为数不多的重要的事情。以C++进行面向对象编程,最重要的一个规则是:public inheritance(公开继承)意味 “is-a” 的关系。
如果令class D(”Derived”)以public形式继承class B(”Base”),这就意味着,每一个D类型的对象同时也是一个B类型的对象,而反之不成立。B是比D更一般化的概念,D属于B。
public继承和 is-a 之间的等价关系听起来简单,但在一些现实情况中,你的直觉可能会误导你。例如,企鹅🐧是一种鸟,这是事实;而鸟可以飞,这也是事实。但如果用public继承来表述这层关系,那么企鹅应该会有能够飞的功能,这与现实又是违背的。其原因在于,我们的描述并不严谨,应该要承认一个前提事实:有些鸟并不会飞。那么应该这样写出继承关系:
1 | class Bird{ |
条款33:避免遮掩继承而来的名称
我们知道,对于同一名称而言,局部作用域中的名称会覆盖全局作用域的相同名称。
对于继承而说,derived class 作用域被嵌套在 base class 作用域内,同时“名称可见性”与上述相同:derived class 内的名称会掩盖 base class 内的名称。
1 | class Base{ |
这段代码带来的行为是诡异的。以作用域为基础的“名称遮掩规则”并没有改变,所以 base class 内所有名为mf1和mf3的函数都被 derived class 内的mf1和mf3掩盖掉了。于是,客户的某些调用将会出现错误:
1 | Derived d; |
可见,不论 base class 和 derived class 内的同名函数是否有不同的参数类型、是否为virtual函数,其名称掩盖规则都是适用的。那么这样就会引出两种情况:
想要继承 base class 并加上重载函数:那么必须为那些原本会被遮掩的每个名称引入一个using声明式。
1
2
3
4
5
6
7
8class Derived: public Base{
public:
using Base::mf1; // 让Base class内名为mf1和mf3的所有东西在Derived作用域中都可见(且public)
using Base::mf3;
virtual void mf1();
void mf3();
void mf4();
};这样声明之后,客户再调用不同形式的mf1和mf3都是可行的了。
不想继承Base class中的所有函数:例如只想继承无参版本的mf1。当然,这在public继承中是绝对不可能发生的,这违反了“is-a”的关系。但是这在private继承中是有意义的,这需要一个转交函数(forwarding function):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class Base{
public:
virtual void mf1() = 0;
virtual void mf1(int);
};
class Derived: private Base{
public:
virtual void mf1(){ // 转交函数
Base::mf1(); // 注意,这个类内定义的成员函数也是默认为inline的
}
};
...
Derived d;
int x;
d.mf1(); // 正确,调用Derived::mf1,但是转交给了Base::mf1()处理
d.mf1(x); // 错误!Base::mf1有参版本被遮掩了
条款34:区分接口继承和实现继承
表面上直接了当的public继承概念,实际上由两部分组成:函数接口继承 和 函数实现继承。作为class的设计者,可能会面临以下情况:
- 希望 derived class 只继承成员函数的接口(即声明);
- 希望 derived class 同时继承函数的接口和实现,但又希望能够覆写(override)它们所继承的实现;
- 希望 derived class 同时继承函数的接口和实现,并且不允许覆写任何东西。
为清楚地感觉上述选择的差异,考虑以下的例子:
1 | class Shape{ |
Shape是一个抽象类,而且强烈影响了所有以public形式继承它的 derived class,因为:
成员函数的接口总会被继承。例如Shape中的纯虚函数具备两个特征:在抽象类中通常没有定义,且必须被任何“继承了它们”的具象类重新声明。
声明一个纯虚函数,是为了让 derived class 只继承函数接口。
纯虚函数draw的声明要求Shape的具象类必须提供一个draw函数,而不管如何被实现的。不过,令人意外的是,我们可以为 pure virtual 函数提供定义,但调用它的唯一途径是“调用时明确指出其class名称”:
1
2
3Shape *ps = new Ellipse;
ps->draw(); // 调用 Ellipse::draw
ps->Shape::draw(); // 调用 Shape::draw一般而言,这种性质的用途有限,但是它可以实现一种机制:为(非纯)impure virtual 函数提供更一般更安全的缺省实现。
声明(非纯)impure 的虚函数,是为了让 derived class 继承该函数的接口和缺省实现。
例如Shape中的error函数,它表示每个class都必须支持一个“当遇到错误可以调用的”函数,每个class可以自由处理错误;但如果class不想对错误做出任何特殊处理,也可以回到Shape提供的缺省错误处理行为。
不过,这样的缺省实现也有可能造成隐患:当派生类中某些可以支持缺省实现,而派生类的另一些必须特化实现时。例如,model A飞机的fly函数可以缺省实现,但是model B飞机的飞行方式与A不同,不能利用缺省fly来实现。这个时候通常有两种方式来切断“virtual函数接口”与“缺省实现”之间的关系:
将 impure 虚函数更改为纯虚函数,并提供一份 protected non-virtual 的缺省实现。支持缺省实现的派生类继承该接口并以缺省实现实施,必须特化的派生类可以实现纯虚函数进行特殊处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class Airplane{
public:
virtual void fly(const Airport& destination) = 0;
...
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination){
... // 缺省行为,飞到目的地
}
class ModelA: public Airplane{
public:
virtual void fly(const Airport& destination){
defaultFly(destination); // 缺省实现,以inline函数实现
}
};
class ModelB: public Airplane{
public:
virtual void fly(const Airport& destination);
};
void ModelB::fly(const Airport& destination){ ... } // 将ModelB飞向目的地其中,defaultFly为protected且non-virtual,因为客户不用知道飞机如何飞的,而且缺省实现也不应该被任何一个derived class 重新定义,故不需要virtual。但有些人反对这种方式,是考虑到这样对class的命名空间进行了污染(提供了函数接口和缺省实现)。那么下面的方式就会解除这样的名称污染问题。
将 impure 虚函数更改为纯虚函数,为该纯虚函数提供定义以作为缺省实现,但调用时注意要指明class的名称。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Airplane{
public:
virtual void fly(const Airport& destination) = 0; // 作为纯虚函数
};
void Airplane::fly(const Airport& destination){
... // 缺省行为,飞向目的地
}
class ModelA: public Airplane{
public:
virtual void fly(const Airplane& destination){
Airplane::fly(destination); // 缺省实现,inline
}
};
class ModelB: public Airplane{
public:
virtual void fly(const Airport& destination);
};
void ModelB::fly(const Airport& destination){ ... } // 将ModelB飞向目的地这样相当于合并了fly和defaultFly两个函数,不过丧失了“让两个函数享有不同保护级别”的机会。
声明 non-virtual 函数,是为了让 derived class 继承接口以及一份强制性实现。Base class 的成员函数为non-virutal表明它不打算在 derived class 中有不同的行为。实际上,一个non-virtual成员函数所表现的不变性(invariant)凌驾其特异性(specialization)。
pure virtual函数,impure virtual函数,non-virutal函数之间的差异,使你得以精确指定你想要 derived class 继承的东西:只继承接口,或是继承接口和一份缺省实现,或是继承接口和一份强制实现。
条款35:考虑virtual函数以外的其他选择
假设你在游戏设计中提供一个函数 healthValue,它返回一个整数,表示角色的健康程度。由于不同人物可能以不同方式计算其健康指数,因此将 healthValue 声明为一个 virtual 是一个再明白不过的做法:
1 | class GameCharacter{ |
这里 healthValue 并未声明为 pure virtual,这暗示着将会有一个计算健康程度的缺省实现。
不过,也可以考虑其他替代方案,能够帮助自己跳脱面向对象设计的常轨道:
通过 Non-Virtual Interface 手法实现 Template Method 模式:
该流派主张任何 virtual 函数应该几乎总是 private。于是较好的做法就是:保留 healthValue 为public成员函数,但让它成为 non-virtual,并调用一个 private virtual 函数(例如,doHealthValue)进行实际工作:
1
2
3
4
5
6
7
8
9
10
11
12
13class GameCharacter{
public:
int healthValue() const{
... // 做一些事前工作
int retVal = doHealthValue(); // 做真正的事情
... // 做一些事后工作
return retVal;
}
private:
virtual int doHealthValue() const{
... // 缺省算法
}
};这段代码中,两个在类内定义的函数都被暗自实现为 inline。让客户“通过 public non-virtual 成员函数间接调用 private virtual 函数”,这也就是 non-virtual interface(NVI) 手法。这个 non-virtual 函数(healthValue)称为 virtual 函数的外覆器(wrapper)。
NVI手法涉及在 derived class 内重新定义 private virtual 函数,赋予它们“如何实现机能”的控制手段。其优点在于:外覆器能够确保一个 virtual 函数被调用之前能够设定好场景,并在调用结束之后清理场景。“事前工作”可以包括锁定互斥器(locking a mutex)、制造运转日志记录项、验证class约束条件、验证函数先决条件等。而“事后工作”可以包括互斥器解除锁定、验证函数的事后条件、再次验证class的约束条件等。
注意的是,NVI手法下 virtual 函数也没必要一定为 private,有时候需要是 protected 甚至 public。
通过 Function Pointers 实现 Strategy模式:
另一种设计主张“人物健康指数的计算与人物类型无关”,这样的计算不需要“人物”这个成分。例如,我们可以要求每个人物的构造函数接受一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算:
1
2
3
4
5
6
7
8
9
10
11
12
13
14class GameCharacter; // 前置声明
// 计算健康指数的缺省算法
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter{
public:
typedef int (*HealthCalcFunc) (const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf){}
int healthValue() const{
return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};这是 Strategy 设计模式的一个简单应用,与“基于GameCharacter继承体系内的virtual函数”相比,它提供了一些有趣的弹性:
同一人物类型的不同实体可以有不同的健康计算函数,例如:
1
2
3
4
5
6
7
8
9
10class EvilBadGuy: public GameCharacter{
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf){}
...
};
int loseHealthQuickly(const GameCharacter&); // 健康指数计算1
int loseHealthSlowly(const GameCharacter&); // 健康指数计算2
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly); // 相同类型的人物搭配不同的健康计算方式某已知人物的健康指数计算函数可以在运行期间变更。例如GameCharacter可提供一个成员函数 setHealthCalculator,用来替换当前的健康指数计算函数。
以 tr1::function 成员变量替换 virtual 函数,因而允许使用任何可调用物(callable entity)搭配一个兼容于需求的签名式。这也是Strategy的某种形式。
将继承体系内的 virtual 函数替换为另一个继承体系内的 virtual 函数。这是Strategy设计模式的传统实现手法。
以上并没有彻底列出 virtual 函数的所有替换方案,它们各有优缺点,为避免陷入面向对象设计路上因常规而形成的凹洞中,这值得我们花时间加以研究。
条款36:绝不重新定义继承而来的 non-virtual 函数
考虑这样的情况,class D 由 class B 以public形式派生而来,class B定义有一个public成员函数mf,其参数和返回值都不重要(为简单,标记为void),而class D也有自己版本的mf函数,实现起来大概是这样:
1 | class B{ |
如果有以下行为:
1 | D x; |
你会比较惊奇地发现 class D 对象通过继承体系的不同指针指向,使用mf竟然会导致不同版本的调用。造成这种两面行为的原因是:non-virtual 函数(如B::mf 和 D::mf)都是静态绑定(statically bound)的。意思就是,pB被声明为一个pointer-to-B,通过pB调用的non-virtual函数永远是B所定义的版本,即使pB指向一个类型为“B派生类”的对象。
但是另一方面,virtual函数却是动态绑定(dynamically bound),所以如果mf是个virtual函数,不论通过pB或pD调用mf,都会导致调用D::mf,因为pB和pD真正指向的都是一个类型为D的对象。
public继承是is-a的关系,而且non-virtual函数的“不变性”是凌驾于“特异性”之上的。如果重新定义了base class的non-virtual函数,就与设计相矛盾。这给我们的警示就是:任何情况下都不该重新定义一个继承而来的non-virtual函数。
条款37:绝不重新定义继承而来的缺省参数值
让我们整理一下思路,将讨论简化。
- 只能继承两种函数:virtual 和 non-virtual函数;
- 重新定义一个继承而来的 non-virtual 函数永远是错误的。
那么我们对于函数继承的讨论可以安全地限制在“继承一个带有缺省参数值的virtual函数”。
这种情况下,本条款成立的理由就直接而明确了,因为virtual函数是动态绑定的,而缺省参数值却是静态绑定的。这意味着如果重新定义继承而来的缺省参数值,可能会在“调用一个定义于derived class内的virtual函数”的同时,却使用了base class为它指定的缺省参数值。例如:
1 | class Shape{ |
现在考虑这些指针:
1 | Shape *ps; // 静态类型为Shape |
正如预期的那样,pr指向Rectangle,但是经由pr调用draw的缺省参数并不是定义Rectangle时指定的Green,而是Shape(静态绑定的基类类型)指定的Red。
这样的问题不光是指针,引用也会引起。其关键在于draw是一个virtual函数,而其中的缺省参数值在derived class中被重新定义了。
那么如果遵守这条规则,并尝试解决这个问题:继承一个含有缺省参数值的virtual函数。那么又会导致“重复定义缺省参数值” 或者 代码重复:
1 | class Shape{ |
比较机智的做法是使用替代的设计(条款35列出了virtual的替代设计),例如使用NVI(non-virtual interface)手法:令 base class 内的一个 non-virtual 函数调用 private virtual 函数,后者可以被 derived class 重新定义。这里可以让 non-virtual 函数指定缺省参数,而 private virtua 函数负责真正的工作:
1 | class Shape{ |
由于 non-virtual 函数绝不应该被 derived class 覆写,这个设计清楚地使得 draw 函数的 color 缺省参数值总是为 Red。
条款38:通过复合塑模出has-a或“根据某物实现出”
复合(composition)是类型之间的一种关系,当某种类型的对象内含其他类型的对象时,便是这种关系。例如:
1 | class Address{ ... }; |
Person对象有一个名称、一个地址以及语音和传真两笔电话号码。复合的术语也有许多同义词,包括 layering(分层)、containment(内含)、aggregation(聚和)和 embedding(内嵌)。
实际上,复合存在两种意义:
- has-a(有一个):当复合发生在应用域内的对象之间,就会表现出 has-a 的关系。
- is-implemented-in-terms-of(根据某物实现出):当复合发生在实现域内,则表现出 is-implemented-in-terms-of 的关系。
程序中处理的两个不同领域:
应用域:程序中的对象相当于你所塑造的世界中的某些事物,例如人、汽车、视频帧等。
实现域:程序中的对象纯碎是实现细节上的人工制品,比如缓冲区(buffers)、互斥器(mutexes)等。
区分 is-a 和 has-a 比较简单,而区分 is-a 和 is-implemented-in-terms-of 有时则比较困难。例如,要想自己实现一个set数据类型,并复用 linked list 作为底层数据结构。你可能会萌发一个想法:让Set继承List。
1 | template<typename T> |
但实际上这样的想法是完全错误的!public继承描述的是 is-a 的关系,可这完全说不通,list允许重复元素,而Set中不会存放任何一个相同的元素。所以public继承并不适合塑模他们的关系。针对此问题,更好的描述是,Set是根据List实现出的,即:
1 | template<typename T> |
条款39:明智而审慎地使用private继承
之前已经知道了 public 继承意味这 “is-a” 的关系,而且编译器会在必要时刻(为了让函数调用成功)会将派生类暗自转换为基类。但如果是private继承,就不会这样了:
1 | class Person{ ... }; |
这个例子表明,private继承不意味这 is-a 的关系,private继承所表明的规则如下:
- 如果classes之间的继承关系是private,编译器不会自动将一个derived class对象(例如 Student)转化为一个base class对象(例如 Person)。
- 由private base class 继承而来的所有成员,在derived class中都会变成private属性,纵使它们在base class中原本是protected或者public属性。
进一步地,private继承意味着 implemented-in-terms-of(根据某物实现出)。如果让 class D 以private形式继承 class B,你的用意是为了采用class B内已经具备的特性,而不是D和B对象存在有任何观念上的关系。private继承纯粹只是一种实现技术,这就是为什么继承自一个 private base class 的每样东西在你的 class 内都是private,因为它们都只是实现枝节而已。
可以发现,复合(composition)和 private继承有时可以表达相同的意思,那么应该怎么样做取舍呢?答案很简单:尽可能使用复合,必要时才使用private继承。所谓必要时候,主要是当 protected成员和/或virtual函数牵扯进来的时候。例如,我们修改 Widget class,让它记录每个成员函数的调用次数。运行期间周期性地审查这份信息,我们可以复用定时器来完成这项功能,定时器Timer的主要定义如下:
1 | class Timer{ |
我们可以调整任意频率,重新定义 virtual onTick 函数以取出Widget的当时状态。而让 Widget 重新定义 Timer 中的virtual函数就需要继承Timer。但 public 继承显然是不合适的,它们之间并不是 “is-a” 的关系,所以这里更适合的是使用 private 继承来完成功能:
1 | class Widget: private Timer{ |
private继承可以实现功能,但其实也并不是一个很好的设计。我们应该尽量使用复合的设计来取代,这里当时也是可以行得通的,只需要在Widget中声明一个嵌套式的 private class,该 class 以 public 形式继承Timer并重新定义onTick,然后将这样的class复合在Widget中:
1 | class Widget{ |
这个设计仅比 private 继承稍稍复杂一点,涉及到 public 继承和复用,并引入一个新的类(WidgetTimer)。
稍微整理一下,private继承主要用于“当 ‘derived class’ 想访问 ‘base class’ 中的protected成分,或为了重新定义一或多个virtual函数”,但这两个classes之间的概念关系是 is-implemented-in-terms-of 而非 is-a。但是,有一种激进情况涉及空间最优化,可能会促使你选择“private继承”而不是“public继承+复合”。
这种激进的情况是这样的:你所处理的class不带任何数据。这样的class没有non-static成员变量,没有virtual函数(因为虚函数会为每个对象带来一个vptr),也没有virtual base class。于是这种 empty class 对象不适用任何空间,然而C++规定凡是独立(非附属)对象都必须有非零大小,所以如果你这么做:
1 | class Empty{}; // 没有数据,该类对象应该不使用任何内存 |
然而我们测试它们的大小,却能发现 sizeof(HoldsAnInt) > sizeof(int)。在大多数编译器中 sizeof(Empty)会获得1,因为面临大小为0的独立(非附属)对象时,C++通常会默默安插一个char到空对象内。然而由于齐位的需求,可能会造成编译器为 HoldsAnInt 加上一些衬垫(padding)。实际中得到的各对象大小如下:
1 | sizeof(Empty): 1 |
那么,如果 HoldsAnInt 附属在空白类上呢?继承Empty,而不是内含一个这样类型的对象:
1 | class HoldsAnInt: private Empty{ |
可以发现,sizeof(HoldsAnInt) == sizeof(int)。这就是所谓的 EBO(empty base optimization; 空白基类最优化),如果客户非常在意空间,那么就值得注意EBO。另外,EBO一般只有在单一继承(而非多重继承)下才可行。
现实中,“empty class”并不是真正的empty,不然就失去了存在的意义。虽然它们没有non-static成员,但往往内含typedefs, enums, static成员变量,或non-virtual函数。EBO的广泛实践,使得这样的继承很少增加derived class 的大小。
条款40:明智而审慎地使用多重继承
多重继承(Multiple Inheritance; MI)比单一继承(Single Inheritance; SI)复杂,也会导致较多的歧义。例如:
从一个以上的 base classes 继承相同的名称(如函数、typedef等)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14class BorrowableItem{ // 图书馆允许你借某些东西
public:
void checkOut(); // 离开时进行检查
};
class ElectronicGadget{
private:
bool checkOut() const; // 执行自我检测,返回是否测试成功
};
class MP3Player: public BorrowableItem, public ElectronicGadget{
...
};
MP3Player mp;
mp.checkOut(); // 导致歧义!到底调用的是哪个checkOut呢?当然这里ElectornicGadget中的checkOut是private,无法调用。要明确调用哪个base clas的checkOut函数也可以明确地指出类名进行调用。
菱形继承问题:继承一个以上的 base classes,但这些 base classes 在继承体系中又有更高级的 base classes。
这样的继承方式必须面临一个问题:是否打算让 base class 内的成员变量经由每一条路径都被复制?即,数据冗余。
C++对于这个问题支持两种方案:
执行复制(缺省做法):IOFile将从每个基类继承一份,所以其对象内会有两份fileName成员变量。
不复制(采用“virtual 继承”):令直接继承File(含公共数据的那个类)的类采用 virtual 继承。
1
2
3
4class File { ... };
class InputFile: virtual public File{ ... };
class OutputFile: virtual public File{ ... };
class IOFile: public InputFile, public OutputFile{ ... };但是也得为virtual 继承付出代价。使用virtual继承的类(如InputFile)所产生的对象比 non-virtual 的更大,而且访问 virtual base class 的成员变量时的速度也会慢下来。
virtual继承的原理:
。。。
virtual继承会增加大小、速度、初始化(及赋值)和复杂度等成本。如果 virtual base classes 不带任何数据,将是最具实用价值的情况。多重继承有时也有正当用途,例如涉及到“public 继承某个 Interface class”和“private 继承某个协助实现的class”两相结合的时候。