Effective 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:明智而审慎地使用多重继承
一、让自己习惯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<Investment> 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对象便可以实现引用计数行为。
shared_ptr允许自定义删除器,即当内部引用计数为0的时候自动调用用户提供的删除器进行删除,这是一个缺省的第二参数。
void end_connection(connection *p)
{
disconnection (*p);
}
void f(destination &d)
{
connection c=connect(&d);
shared_ptr<connection> 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<Widget>(new Widget),priority());
这里在执行函数调用之前会发生三件事:
- 调用priority()
- 执行new Widget
- 调用std::tr1::shared_ptr构造函数
事实也确实如此,但问题的关键在于C++编译器将会以什么样的执行次序完成这些事情呢?都有可能,完完全全取决于编译器。而唯一可以确定的是“执行new Widget”一定会在“调用std::tr1::shared_ptr构造函数”之前执行,因为前者是后者的参数。那问题就来了,第一条语句会什么时候执行呢?第一?第二?第三?问题出在第二顺位上:
- 执行new Widget
- 调用priority()
- 调用std::tr1::shared_ptr构造函数
如果priority()调用异常会发生什么事?在这种情况下第一步得到的指针会被遗失,因为它还没有被置入shared_ptr,而这就发生了内存泄漏。这是因为在“资源被创建”和"资源被转换为资源管理对象"两个时间点之间存异常干扰导致的。而假如这样的问题真的发生,你可以设想一下你到时候能分析出原因的可能性。
避免这种操作的办法也很简单,单独写就行了:
std::tr1::shared_ptr<Widget> pw(new Widget);
processWidget(pw,priority());
✔以独立语句将newed对象存储于智能指针当中。如果不这样做,一旦异常被抛出,有可能导致难以察觉的内存泄漏。
四、设计与声明
条款18:让接口容易被正确使用,不易被误用
1、保证参数一致性:
class Date{
public:
Date(int month,int day,int year);
};
如果你设计出这种接口,那么用户有可能会从两个方面犯错:
- 传递次序出错:Date d(30,3,2023);
- 无效数据:Date d(3,30,2023);
既然这样,让我们导入简单的外覆类型来区别天数,月份和年份,然后于Date中使用这些类型:
然而,这仅仅解决了第一个问题,为了使数据合法,还应该有其他设计:
2、保证接口行为一致性:
内置数据类型(ints, double…)可以进行加减乘除的操作,STL中不同容器也有相同函数(比如size,都是返回其有多少对象),所以,尽量保证用户自定义接口的行为一致性。
3、如果一个接口必须有什么操作,那么在它外面套一个新类型
employee* createmp();//其创建的堆对象要求用户必须删除
如果用户忘记使用资源管理类,就有错误使用这个接口的可能,所以必须先下手为强,直接将 createmp() 返回一个资源管理对象,比如智能指针share_ptr 等等:
tr1::share_ptr<employee> createmp();
如此就避免了误用的可能性。
4、有些接口可以定制删除器,就像 STL 容器可以自定义排序,比较函数一样
tr1::share_ptr<employee> p(0, my_delete());//error! 0 不是指针
tr1::share_ptr<employee> p(static_cast<employee*>(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);
...
};
当然,我们需要重载乘法运算符来实现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
去调用这个成员函数。
然而若Widget
和WidgetImpl
是类模板,情况就没有这么简单了,因为 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<b){
return;
}
...
}
在这个函数里,string对象并没有被完全使用,如果a<b,则意味着你浪费了string对象的构造成本和析构成本,所以最好延后变量的定义式,直到你确实需要它。
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<int*> (&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 <iostream>
#include <assert.h>
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 <iostream>
#include <assert.h>
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<Tson*>(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 <iostream>
#include <vector>
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<int*>(&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<RectData> 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
设置),接着程序会直接崩溃。
当异常被抛出时,带有异常安全性的函数会:
- 不泄漏任何资源。
- 不允许数据败坏。
考虑以下PrettyMenu
的ChangeBackground
函数:
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
会发生资源泄漏,bgImage
和imageChanges
也会发生数据败坏。
通过以对象管理资源,使用智能指针和调换代码顺序,我们能将其变成一个具有强烈保证的异常安全函数:
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<PersonImpl> pImpl;
};
// person.cpp 负责实现类
class PersonImpl {
public:
int data{ 0 };
};
Person::Person() {
pImpl = std::make_shared<PersonImpl>();
}
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<Person> Create();
...
};
但此时Create函数还无法使用,需要在派生类中给出Person类中的函数的具体实现:
class RealPerson : public Person {
public:
RealPerson(...) { ... }
virtual ~RealPerson() {}
void Print() override { ... }
private:
int data{ 0 };
};
完成Create函数的定义:
static std::shared_ptr<Person> Person::Create() {
return std::make_shared<RealPerson>();
}
毫无疑问的是,句柄类和接口类都需要额外的开销:句柄类需要通过 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;
}
就像这段代码所表现的名称遮掩一样,基类和继承类也可以理解成简单的作用域问题:
void someFunc()
{
mf2();
}
当编译器看到mf2时,编译器的做法是查找各个作用域,看有没有某个名为mf2的声明式:首先查找local作用域,没找到就查找其外围作用域,也就是class Derived覆盖的作用域,还是没有找到就再往外移动,找基类中有没有,如果依然没有找到,会找基类的命名空间下有没有这个函数,最后往global作用域找去。
可以借用using语句,将基类中某个名称为xxx的函数在子类中全部可见:
using Base::mf1;
条款34:区分接口继承和实现继承
- 接口继承和实现继承不一样。在public继承下,派生类总是继承基类的接口。
- 声明一个纯虚函数的目的,是为了让派生类只继承函数接口。
- 声明简朴的非纯虚函数的目的,是让派生类继承该函数的接口和缺省实现。
- 声明非虚函数的目的,是为了令派生类继承函数的接口及一份强制性实现。
通常而言,我们不会为纯虚函数提供具体实现,然而这样做是被允许的,并且用于替代简朴的非纯虚函数,提供更平常更安全的缺省实现。
条款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 <iostream>
#include <vector>
using namespace std;
class Shape
{
public:
virtual void Draw(int x = 1) = 0;
};
void Shape::Draw(int x){
cout<<"Shape : " <<x<<endl;
}
class Rectangle : public Shape
{
public:
virtual void Draw(int x = 2) override{
cout<<"Rectangle : " <<x<<endl;
}
};
class Circle : public Shape
{
public:
virtual void Draw(int x) override{
cout<<"Circle : " <<x<<endl;
}
};
int main()
{
std::shared_ptr<Shape> rectangle = std::make_shared<Rectangle>();
std::shared_ptr<Shape> circle = std::make_shared<Circle>();
rectangle->Draw();
circle->Draw();
return 0;
}
//运行结果:
Rectangle : 1
Circle : 1
条款38:通过复合塑模出 has-a 或“根据某物实现出”
这里其实还是在介绍一种设计模式——组合模式
条款39:明智而审慎地使用private继承
private继承的特点:
- 如果类之间是private继承关系,那么编译器不会自动将一个派生类对象转换为一个基类对象。
- 由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;
...
};
使用后者比前者好的原因有以下几点:
- private继承无法阻止派生类重新定义虚函数,但若使用public继承定义
WidgetTimer
类并复合在Widget
类中,就能防止在Widget
类中重新定义虚函数。 - 可以仅提供
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 { ... };
然而由于虚继承会在派生类中额外存储信息来确认成员来自于哪个基类,虚继承通常会付出更多空间和速度的代价,并且由于虚基类的初始化责任是由继承体系中最底层的派生类负责,就导致了虚基类必须认知其虚基类并且承担虚基类的初始化责任。因此我们应当遵循以下两个建议:
- 非必要不使用虚继承。
- 如果必须使用虚继承,尽可能避免在虚基类中放置数据。
多重继承可用于结合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 ""; }
};