我的Effective C++读书笔记
谨作个人记录用,第一次读,还有很多不了解略过的部分,可能会有部分个人理解有所偏差
一.让自己习惯C++
1.视c++为一个语言联邦
经过多年的发展,C++已经发展成了一个强大的语言联邦。
总体而言,C++由以下4个部分组成
1.C
C语言部分的内容,包含区块,语句,预处理器,内置数据类型,数组,指针等,但是没有模板、异常和重载
2.Object-Oriented C
也就是C with class,包含构造函数和析构函数,继承,多态,虚函数等。
3.Template C++
泛型编程部分
4.STL
Standard Template Libary,一个template程序库。
学习C++的困难之处在于,它没有一套标准的通用准则,要时刻记住C++是由4个次语言组成的语言联邦,每个次语言都有自己的独特准则。
2.使用const,enum,inline替换#define
1.const替换#define的原因
由于#define的代码会被预处理器进行处理,编译器看不到被替换前的代码,这恰恰可能让它导致一些问题。由于它不会被编译器看到,反而会让错误发生时调试者难以定位错误发生的原因。
例如下面的语句:
#define ASPECT_RATIO 1.653
这会导致发生错误的时候,错误信息报告的是1.653而不是ASPECT_RATIO,所以调试者找不到错误发生的原因,尤其是在ASPECT_RATIO被定义在非调试者自己编写的头文件时,调试者会被这个错误信息弄得一头雾水。
解决方法是改写成常量
const double AspectRatio = 1.653 //因为全大写一般用于宏,这里改动了变量名
这样一来编译器就能识别该变量并且让AspectRatio加入记号表,错误信息就会更加准确。
2.const的注意事项
在使用const进行替换的时候,有两点需要注意
1.当这个常量是一个指针类型的时候,需要将指针本身也定义为const,这就是为什么有时会出现一次声明有两个const 的情况,他们指代的目标是不一样的,一个是指针本身,一个是指向的对象,例如:
const char* const authorName="Scott Meyers"
2.定义class的专属常量的时候(而不是实例),需要限定常量的作用于在class内,所以必须声明为static,这样这个常量就只能有至多一个实体了。例如:
class GamePlayer{ private:static const int NumTurns = 5;int scores[NumTurns]; }
需要注意的是,一般我们把声明写在头文件供其他文件使用,把实现写在实现文件中,而对于类的static成员,如果他是整型类型(包括int,char,bool等),可以在实现文件中不经过定义式直接使用(前提是不去取他们的地址)。但是如果是要取地址的情况或者编译器坚持需要一个定义式(尽管这是不正确的),那么必须在使用它之前先在实现文件中再提供一遍定义式,比如:
const int GamePlayer::NumTurns; //不需要设定初值,因为这只是一个定义式,值已经在声明的时候赋予了。
[^] 定义式用来把一些声明式遗漏的细节补充给编译器。
3.the enum hack的小技巧:
在某些比较旧式的编译器,可能会不允许声明式中直接赋值,所以只能把初值放在定义式上。
//头文件 class CostEstimate{ private:static const double FudgeFactor ;} //实现文件 ... const int CostEstimate::FudgeFactor=1.36;
但是这只能在编译器用不着该成员对象的值的时候才能生效,例如2.const的注意事项上面的GamePlayer类,这个类的scores成员对象需要使用NumTurns来确定数组的大小。这个时候为了应对这种编译器(尽管它是错误的),需要使用叫做the enum hack的小技巧:
class GamePlayer{ private:enum{NumTurns=5; //类似这种把NumTurns变成5的一个记号名称的方法叫the enum hack}int scores[NumTurns]; }
这种方法还有另外的一种好处:const定义的值是可以取地址的,但enum不能被取地址,如果不想让常量被取地址,使用enum可以帮助实现这个约束。
4.inline替换#define的原因
有时候#define会用来实现宏,比如
#define CALL_WITH_MAX(a,b) f((a)>(b)?(a):(b))
上面这个宏会选择a和b当中的最大值作为f函数的入参并返回结果。
尽管上面的每个变量都加上了括号,但是这个宏仍然无法避免一些问题。
int a=0; int b=5; CALL_WITH_MAX(++a,b) //a自增了两次 CALL_WITH_MAX(++a,b+10) //a自增了一次
这种#define的使用方式会让错误信息难以排查问题所在。使用template inline函数可以有效避免这个问题。
template<typename T> inline void callWithMax(const T& a,const T& b) {f(a>b?a:b); }
这个template可以实现相同的功能,并且他还有作用域和访问规则,比#define要更加优秀。
3.尽可能多的使用const
1.const函数的返回类型
const可以被用于限定函数返回一个常量:
class Rational {...}; const Rational operation* (const Rational& lhs,const Rational& rhs);
表面上看起来这种限定是无意义的,但是这可以避免用户写出这样的代码:
Rational a,b,c; ... (a*b)=c;
一个良好的用户自定义类型的特征就是避免无端的和内置类型不兼容,对于内置类型,对两数乘积赋值是无意义的,所以最好个噢诶用户自定义类型也预防这种无意义的操作。
2.const函数的参数
const参数可以限制函数的参数在中途进行变化,在不需要改动参数的值的情况下都应该这么做,这样可以预防类似把==写成=这样的错误,而只是付出了多打区区6个字符的代价。
3.const成员函数
1.区别
const用在成员函数上可以限制该成员函数是否能作用于const对象上面。还是以重载举例:
class TextBlock{ private:std::string text; public:...//operator[] for const对象const char& operator[] (std::size_t position) const{return text[position];}//operator[] for non-const对象char& operator[] (std::size_t position){return text[position];} }
于是
// 调用 non-const TextBlock::operator[] TextBlock tb("HELLO");std::cout << tb[0]; // 调用 const TextBlock::operator[] const TextBlock ctb("World");std::cout << ctb[0];
注意,上面的const operator[]不仅仅限制了成员函数,对返回类型也使用了const限定,这样一来同样的操作符用在const和非const对象上,返回的结果就会不一样。如下所示:
//接上面的代码 std::cout << tb[0]; //正确,返回的是char& tb[0]='x'; //正确,char& 可以更改引用的值 std::cout<< ctb[0]; //正确,返回的是const char& ctb[0]='x';//不正确,const char&不可以被修改
另外,假设non-const operator[] 返回的是char而非char&的话,赋值操作也是不被允许的,当返回对象不是引用而是值的时候,对返回值赋值是不合法的,即便合法,被改动的也会是text的副本而不是编写者原本想要的结果。
2.两种阵营
对于const 成员函数的定义,有两种阵营。bitwise const阵营和logical const阵营。
bitwise const阵营的人认为,只有一个成员函数不改动对象内的任何成员变量(static除外)时,才能被称之为const,即const 成员函数没有改变该对象的任何一个bit。实现bitwise const的编译器只需要检查函数内对成员变量的赋值即可,bitwise const也符合c++标准对const的定义。
然而,一些可以通过bitwise const编译器的编译的代码实际上会导致反直觉的结果。比如说,当对象持有的只是一个指针,而不是指针指向的所有物的时候,例如仿制上文中的TextBlock,但是不再使用std::string而是改用char * 代替时:
class CTextBlock{public :...char& operator[] (std::size_t position) const{return pText[position];}private:char* pText; } //上面的定义中 operator[]符合bitwise const编译器的要求,但是会使下面的代码变得合法 const CTextBlock cctb("Hello"); char* pc=&cctb[0]; *pc='J';//改动了cctb的值
上述代码能够被执行,但是编写者在创建了一个const对象并且只对其使用const函数的情况下,还是改变了它的内容。
这就致使了另外一派阵营logical constness的诞生。支持logical constness 的人认为,const函数应该允许修改它所处理的对象中的一些bits,但是只有在客户端不知情的前提下才可以这么做,比如为上面的CTextBlock添加一个高速缓存用来存储文本长度以便询问。
class CTextBlock{private:char* pText;bool lengthIsValid;std::size_t textLength;public :std::size_t CTextBlock::Length() const{if(!lengthIsValid){lengthIsValid=true;textLength=std::strLen(pText);}return textLength;} }
显然Length函数违反了bitwise const的标准,如果编译器不同意这样子赋值,但是编写者又想这样做,应该如何解决?
答案是添加mutable关键字,mutable可以释放掉non-static成员上面的bitwise-const约束:
//这些成员变量即便是在const函数内也可以被更改 mutable bool lengthIsValid; mutable std::size_t textLength;
编译器强制实施bitwise constness,但是编写程序时应该使用"概念上的常量性“(conceptual constness).
3.避免const和non-const的内容重复
假设上文TextBlock的operator[]是不再是简单的返回reference,还包括边界检验,记录,数据完善性检验等操作的话,那么:
class TextBlock{ private:std::string text; public:...//operator[] for const对象const char& operator[] (std::size_t position) const{...//边界校验...//志记数据访问...//数据完善性检验return text[position];}//operator[] for non-const对象char& operator[] (std::size_t position){...//边界校验...//志记数据访问...//数据完善性检验return text[position];} }
可以发现 const operator[]和non-const operator[]之间的代码几乎是重复的,当然,可以把边界检验,记录,数据完善性检验放到另外一个函数当中然后分别调用他们,但是更好的方式是一个函数调用另外一个。
在上述代码中,const 跟non-const的代码几乎完全一样,除了返回的类型多了一个const资格修饰,这个时候把返回值的const转除掉是安全的,因为不论谁调用non-const成员函数首先要提供non-const对象,所以可以让non-const成员函数去调用const兄弟:
class TextBlock{ private:std::string text; public:...//operator[] for const对象const char& operator[] (std::size_t position) const{...//边界校验...//志记数据访问...//数据完善性检验return text[position];}char& operator[] (std::size_t position){return const_cast<char&>( //把op[]的返回值的const转除掉static_cast<const TextBlock&>(*this) //为*this加上const[position]);} }
注意,我们应该让non-const调用const,而不是让const调用non-const,因为const 函数承诺不改变对象的逻辑状态,而non-const则没有这个要求。
4.const指针
如果const出现在*左边,说明被指向的对象是常量,出现在右边则说明指针本身是常量
char greeting[]="Hello" char* p=greeting; //non-const pointer,non-const data const char* p=greeting;//non-const pointer,const data char* const p=greeting;//const pointe,non-const data const char* const p=greeting;//const pointer,const data
4.确定对象被使用前先被初始化
在某些语言中,不存在“无初值对象”,但是c++不同。在某些平台上,读取未初始化的值会直接让程序终止运行,也有可能读取到一些“污染”的随机数据,无论如何,这都不是期望的结果。
在条款1中提到过,c++是4个子语言组成的语言联邦,如果使用的C part of C++这部分,那么不保证会发生初始化,但是在non C part of
C++,情况有所不同。这就是为什么array(来自C part of C++)不保证初始化,而vector(来自STL)可以保证初始化。
1.初始化
最好的办法就是在使用类之前永远把他的成员们都初始化。
对于内置类型,应该手工完成初始化,比如
int a=0;
对于非内置类型,初始化的责任应该落在构造函数上。
但是需要区分的是赋值和初始化之间的区别。以下面的例子为例:
class PhoneNumber {...}; class ABEntry{// ABEntry是 Address Book Entry的缩写public:ABEntry(const std::stirng& name ,const std::string& address,const std::list<PhoneNumber>& phones);private:std::string theName;std::string theAddress;std::list<PhoneNumber> thePhones;int numTimesConsulted;ABEntry::ABEntry(const std::stirng& name ,const std::string& address,const std::list<PhoneNumber>& phones){theName=name;theAddress=address;thePhones=phones;numTimesConsulted=0;} }
这个构造函数使用起来没有任何问题,也能达到预期的效果,但是构造函数里面发生的一切并不是初始化,而是赋值。
C++规定,成员变量的初始化在进入构造函数前就已经发生了。在进入ABEntry构造函数之前,就调用了这些成员的default初始化函数(内置类型除外)。
一个改动方式是:
ABEntry::ABEntry(const std::stirng& name ,const std::string& address,const std::list<PhoneNumber>& phones):theName(name),theAddress(address),thePhones(phones),numTimesConsulted(0) //这一行代码也叫成员初值列{}
上述代码和改动前能实现一模一样的效果,但是效率更高,因为他们没有浪费default初始化函数的操作去重新赋值。对于拥有default初始化函数的用户自定义类型,在default构造函数后再调用copy assignment赋值操作的效率低于直接调用copy构造函数/default构造函数,但是对于内置类型,赋值或者初始化的成本是相同的。
当成员变量是cosnt或者reference时,他们就一定需要初值而不能再被赋值了。为了避免记住哪些变量需要初始化,哪些不需要,最好的做法就是总是使用成员初值列。
但是,如果class拥有多个构造函数,又有多个成员变量和base classes,多份的成员初值列的编写将是重复繁琐的。这种情况可以把赋值表现和初始化一样好的成员拿出来改成赋值操作,放到一个(一般是private)函数当中供各个构造函数调用。
C++的成员初始化是按照声明的顺序进行的,在成员初值列中却可以调转顺序,这样可以通过编译但是在维护代码时可能让检查者很困惑,比如一个数组需要一个整数作为大小,但是这个整数在成员初始列中却排在数组的后面,在初值列很长时可能让检查者很困惑。
2.不同编译单元内定义的non-local static 对象的初始化次序
首先需要一点一点分析这个一连串词语组成的标题的含义。
static对象的声明周期,从构造出来一直持续到程序结束为止。函数内定义的static对象被称为local static,因为它对于函数而言是local的。其他被称为non-local static。在程序结束时,static对象的析构函数会被自动调用。
编译单元指产出单一目标文件的源码。基本上,它是单一源码文件加上其所韩茹的头文件(#include files).
假设有两个源码文件,都拥有non-local static 对象,但是其中一个的初始化使用了另外一个编译单元的non-local static对象。由于C++ 没有明确规定他们的初始化顺序,导致无法保证被使用的对象已经被初始化了。
解决方案是把non-local static替换成local static,也就是把这些对象放到一个函数中去,该函数返回该对象的引用,双方不再直接使用其他编译单元的static对象,而是改成通过对方提供的函数获得该对象的引用。很容易察觉到,这是著名的单例模式的常见实现。
作者关于static(无论是否为local)在多线程中的不稳定性的描述似乎在后来的C++版本中已经不复存在了。不再赘述。
二、构造,析构,赋值运算
5.了解C++默默编写并调用了哪些函数
在没有自己声明的情况下,编译器会自动为类声明一个default构造函数,一个copy构造函数,一个copy assignment操作符,一个析构函数。这些函数都是public而且inline的。如果声明了任意一个构造函数,那么编译器就不会提供无实参的构造函数。copy构造函数和copy assignment操作符只是单纯把参数对象所有的non-static成员复制给当前构造的对象。
对于内置类型,编译器会复制该成员的每一个bits过来,而对于非内置类型则会尝试调用他的copy构造函数。
但是并不是所有的情况下都会生成copy assignment操作符,只有编译器能够理解应该如何进行复制的情况下,operator = 才会被生成,包括但不限于:
1.该类持有reference成员,因为C++不允许reference引用的目标被修改,所以编译器直接修改引用的地址是不合法的,如果编译器改成修改reference引用对象的值,那么其他引用该对象的reference或者pointer将在不知情的情况下被修改了指向的内容。所以编译器陷入了两难处境,干脆拒绝不默认生成copy assignment操作符。
2.持有const成员,原因同上,修改const成员也是不合法的。
3.当base class把自己的copy assignment操作符设为private,那么编译器不会给它的derived classes生成copy assignment操作符,因为一般来说编译器认为子类应该可以处理父类的成员部分,但是他们无权调用base class的操作符,所以只能放弃生成copy assignment操作符。
6.若不想使用编译器自动生成的函数,就应该明确拒绝
这部分内容在C++11 中已经提供了delete关键字帮助完成,作者说的把欲隐藏函数设为私有,并且把该类叫做Uncopyable给其他类继承的方法似乎已经不再需要了。
7.为多态基类声明virtual析构函数
需要注意的是,编译器给class 默认生成的析构函数是non-virtual的。
在我们声明一个base class后,他可以有多个derived class,假设我们实现了一个factory,这个factory提供方法提供一个base class的指针,实际指向的是derived class的对象。如果我们通过base class 的指针来清空内存,最终得到的结果可能是只清除掉了base class部分的成员,但是derived class新增的那部分内存并没有得到释放。解决的方法也很简单,就是将析构函数声明为virtual即可。
如果一个class没有virtual函数,那么意味着它不打算被当做一个base class被继承。但是如果有需要的话,为了避免类的大小变大,最好不要给不打算当做base class的class加上virtual。derived class必须携带某些信息以决定那个virtual函数被调用,而这份信息由vptr(virtual table pointer)指出,这个指针指向一个由函数指针指针组成的数组vtbl(virtual table)。所以为了避免因为vptr造成的大小增加,不要盲目地把所有class 的析构函数设置为virtual。至少要有一个virtual函数,才应该给他的析构函数设置为virtual。
另外,不要去继承没有virtual函数的类,比如说标准库中的string或者STL中的所有容器(vector,list,set等等)。
给base class一个virtual析构函数,并不是所有情况都要这样,而是只有base class是用于多态用途才需要这样,即需要使用base class接口来实际操作derived class时才需要这么做。
8.不要让异常逃离析构函数
C++不禁止在析构函数抛出异常,但是也并不鼓励这么做。
假设有如下代码:
class Widget: {public:...~Widget(){...} }; ... void doSomething() {std::vector<Widget> v;... }
显然,在方法doSomething结束的时候,它创建的局部变量v也会被自动销毁,vector对于销毁的实现是把内部包含的所有Widget都进行销毁,假设v中有10个Widget,在析构第一个时发生异常,而后面的9个Widget仍然是要被销毁的,否则会导致内存泄漏。然而假设第二个又抛出了异常,假设第一个异常和第二个异常作用时间重叠,就会导致程序直接结束或者不明确行为。
在析构函数中捕获异常后,可以:
1.直接结束程序(std::abort),这样可以避免后续的不明确行为,但是它直接结束了程序。
2.捕获后记录异常数据,这样做可能导致不明确行为,但是它没有结束程序。宁愿吞下不明确行为的苦果也不能结束程序,这也是一个可选项。
为了保险起见,可以把那些可能导致异常的代码单独封装到一个函数去,供客户调用,尽管在析构函数中,依然要检查对应的成员是否已经被销毁,如果没有销毁,最后还是要进入上文提到的“二选一”的抉择中。但是这至少给了用户一个自己处理异常的机会,尽可能让异常在客户自己的函数中被处理而不是在析构函数中,在用户没有手动调用的情况下,析构函数也提供了一个“第二道防线”,如果在析构函数中导致了程序结束或者不明确行为,客户也没有立场抱怨,因为他们没有第一手处理掉可能发生的异常。
9.绝不在构造/析构函数当中调用virtual函数
尤其是从JAVA或者C#转来的程序员需要注意这件事。C++和这些语言有着不同之处。
由于在执行derived class的构造函数之前,首先执行的是base class的构造函数,如果在base class的构造函数调用virtual函数,客户实际上想要调用的可能是derived class的版本,但是最终执行的是base class的构造函数。这么做是因为编译器认为derived class重写的函数几乎必定会调用local变量,但是这些术语derived class的变量此时都还没有初始化,此时很可能导致未定义行为,所以最后编译器不会让客户调用derived class的版本,而是base class的版本。更本质的一个原因是,在base class构造期间,对象的类型就是base class而不是derived class,在这期间使用运行时类型信息,也都会显示对象的类型是base class。析构函数也是同样的道理。
如果要实现类似的功能,可以去掉该方法的virtual,derived class不是重写该方法,而是在初值成员列把自定义的信息传递给父类处理,构造自定义信息可以使用一个静态方法以防使用了未初始化变量。
10.令operator =返回 reference to *this
对于赋值操作,应该允许连锁赋值。
int x,y,z; x=y=z=15; //赋值采用右结合律相当于 x=(y=(z=15));
为了实现连续赋值,赋值操作符必须返回一个reference指向左侧的实参。这是为classes实现赋值操作符所应该实现的一项约定(尽管它不是强制的),这个约定不仅仅适用于标准赋值符,也适用于各种赋值的形式。比如:
class Widget{ public: ... Widget& operator+=(const Widget& rhs)//当然 -=,*=也是一样的 { ... return *this; } Widget& operator=(int rhs) //即便他的参数类型不符合协定,还是返回reference to *this { ... return *this; } }
11.在operator=中处理“自我赋值”
C++是允许自我赋值的,即允许把自己赋值给自己。
必须确保左右两边的参数实际指向同一个对象时,该函数依然能够保持正确合理的运行,尤其是在需要delete指针释放内存的时候,可以先新建一个指针指向旧的地址,等到指针指向新的对象后再释放旧指针的指向的内存。
12.复制对象时不要忘记每一个成分
Copying函数的时候,当你继承了一个base class,记得在成员初值列也把base class的copying函数补充进去。
不要用Copying函数调用copy assignment函数,或者用copy assignment函数调用copying函数,因为Copying函数是构造函数,Copy assignment函数是赋值函数,不能给一个已经构造的函数构造,或者给一个未构造的函数赋值。
三、资源管理
13.以对象管理资源
假设编写了一个类
class Investment {...}
同时有一个工厂函数可以返回特定的Investment对象。
Investment* createInvestment();
为了防止内存泄漏,在C++中一般来说调用者有责任删除掉动态分配的对象。
void f {Investment* pInv=createInvestment();...delete pInv; }
这一切看起来非常妥当,然而,不是所有情况下都可以很简易地释放内存的,比如说,f函数有可能会执行不到最后一条语句,比如一个过早的return语句。类似的情况也发生在循环内的提前continue或者break中,总之,如果函数的提前结束导致delete语句没有被执行,那么内存将会泄漏。谨慎的编写程序可能会改善这一问题,但是,当代码很长的时候,保证所有动态获取的资源在每一个分支内都被释放是非常困难的,而且还要考虑抛出异常的情况。
为了避免这些情况,我们需要一个管理对象帮助我们管理资源,通过对象的析构函数自动帮助我们释放内存,典型的例子有标准程序库提供的auto-ptr等。
1.auto_ptr
void f() { std::auto_ptr<Investment> pInv(createInvestment()); ...//一如既往地使用pInv //在f函数结束时,pInv被销毁,经过auto_ptr的析构函数自动地删除pInv }
为了预防多个auto_ptr指向同一个对象,导致被删除多次的情况,auto_ptr有一个与众不同的性质:如果通过copy构造函数或者copy assignment操作符去复制它,它将会变成null,而复制获得的指针将会获得该资源的唯一使用权。但是这一特质也导致了auto_ptr也许不是管理资源的最佳方案,比如说STL容器都要求自己的元素可以发挥“正常的”复制行为,所以这些容器不能放入智能指针。
2.shared_ptr
std::tr1::shared_ptr是引用计数型指针,在无人指向资源时自动删除它。但是shared_ptr无法接触环状引用。
void f() { std::tr1::shared_ptr<Investment> pInv(createInvestment()); ...//一如既往地使用pInv //在f函数结束时,pInv被销毁,经过auto_ptr的析构函数自动地删除pInv }
shared_ptr的复制行为则像我们一般的copy或者copy assignment的预期结果,所以STL容器可以接纳shared_ptr。
注意,shared_ptr或者auto_ptr进行的都是delete而非delete[]操作,所以不要用于数组:
std::auto_ptr<str::string> aps(new std::string[10]);//馊主意,这是错误的delete形式 std::tr1::shared_ptr<int>spi(new int[1024]);//相同问题
管理对象的关键点在于:
1.获得资源后立即放入管理对象(Resource Acquision Is Initialization即RAII)
2.管理对象通过析构函数释放资源。
14.在管理类当中小心copying行为
假设我们使用C_API管理互斥锁,有lock和unlock两个函数可用:
void lock(Mutex* pm); void unlock(mutes* pm);
为了保证绝不会忘记解锁一个锁住的对象,我们可能需要一个管理类来管理锁:
class Lock {public:explicit Lock(Mutex* pm):mutexPtr(pm){lock(mutexPtr);}~Lock(){unlock(mutePtr);}private:Mutex *mutexPtr; }
但是,如何应对对于Lock的复制呢?有如下选项:
1.禁止复制
有时候,禁止对管理类进行复制是合理的。
2.引用计数法
当我们希望保有资源直到最后一个使用者被销毁时,可以使用引用计数法,shared_ptr就是其中之一,不过shared_ptr默认会在无引用时释放资源内存,在本例中我们却只是希望它能够解锁该对象就好。所以需要shared_ptr的构造函数中的第二个参数deleter
class Lock{public:explicit Lock(Mutex* pm):mutexPtr(pm,unlock){lock(mutexPtr.get());}private:std::tr1::shared_ptr<Mutex> mutexPtr; }
本例中不需要手动写Lock的析构函数了,因为无论编译器默认编写的析构函数还是自定义的析构函数都会自动调用所有non_static成员的析构函数。
3.深度拷贝
不仅仅是指针,连指针指向的资源都在内存中复制一份。
4.转义底部资源的所有权
如果某些场合你希望永远只有一个RAII对象指向底部资源,你可以在复制时转移底部资源的所有权,就像auto_ptr所做的一样。
15.在资源管理类当中提供对原始资源的访问
APIs往往要求提供底部资源,所以一个RAII类应该提供一个“取得所管理的底部资源”的方法。
对原始资源的访问有可能经过隐式转换或者显式转换,隐式转换更加方便,但显式转换更加安全。
16.成对使用new和delete时使用相同形式
如果在new表达式中使用[],必须在响应的delete表达式当中也使用[]。如果在new表达式中不适用[],则不在delete表达式中使用[]
尽量不要对数组使用typedef,这可能导致编写代码时忘记使用正确的delete。
17.以独立语句将newed对象纳入智能指针。
假设有如下函数
int priority(); void processWidget(std::tr1::shared_ptr<Widget> pw,int priority);
再假设使用如下方法调用
processWidget(std::tr1::shared_ptr<Widget>(new Widget),priority());
在这个语句中,编译器产出processWidget的调用码之前,首先要确定实参,他会干三件事。
1.priority()
2.new Widget
3.调用shared_ptr构造函数
其中new Widget一定发生在调用shared_ptr构造函数之前,因为前者是后者的参数,但是priority()的调用就不一定了,它可能会在两者前后甚至中间,万一priority()在中间且调用异常,那么之前new出来的Widget就在还没有放入shared_ptr之前就遗失了。
以防万一,最好把智能指针的定义单独写在一行:
std::tr1::shared_ptr<Widget> pw(new Widget); processWidget(pw,priority())
四、设计与声明
18.让接口容易被使用,而不是被误用
促进正确使用的方法包括接口的一致性,以及和内置类型的行为兼容。
阻止误用的方法包括建立新的类型,限制类型上的从挨揍,舒服对象值,以及消除客户的资源管理责任。
19.设计class犹如设计type
和其他面向对象的语言一样,每当设计出一个新的class,同时也意味着生成了一个新的type。
要注意如下几点:
1.新的type的对象如何生成和销毁?如何设计构造函数和析构函数?
2.初始化和赋值的差别
3.新的对象如果被pass by value意味着什么?注意,copy构造函数决定了一个type的pass by value如何实现。
4.什么是新type的“合法值”
5.继承,注意virtual函数和non-virtual函数,尤其是在析构函数上面。
6.什么样的操作符和函数对于新type是合理的?
7.哪些标准函数应该驳回?(比如copy函数和copy assignment操作符)
8.哪些成员是私有,哪些是公有?
9.“未声明接口”
10.如果要实现的是一整个type家族,那么不应该设计一个class,而是要设计一个新的class template
11.如果能依靠derived class就能实现目标,那么就不必大费周章设计一个新的class
20.宁愿pass-by-reference-to-const 代替 pass-by-value
pass-by-value是c++继承自C的一种传递方式,但是它有着如下的缺点:
1.传递参数时调用了对象的copy函数,然后在函数结束的时候还要调用该对象的析构函数。copy函数实际上还把对象拥有的成员变量都调用了对应的copy函数,同理析构函数也调用了所有成员变量的析构函数,成本很高。
2.当一个derived class被视为一个base class对象传递的时候,调用的是base class 的copy函数而不是derived class 的copy函数。
因此尽量以pass-by-reference-to-const来代替pass-by-value,除非正在传递内置类型,STL的迭代器以及函数对象。
21.必须返回对象时,不要返回reference
不要让方法返回一个reference或pointer指向一个local对象,因为方法结束时local对象就已经通过析构函数摧毁了。
返回reference或pointer指向heap-allocated的对象同样是不可取的,因为这个没有函数去负责释放它的内存。
同理reference或pointer指向static local也可能导致错误,尤其是在同一语句中使用多次该方法并将返回值作为实参传递到其他方法去时。
22.把成员变量设为private
切记将成员变量声明为private,这可赋予客户访问输的一致性、可惜未划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
成员的封装性和“当内容改变时可能造成的代码破坏量成反比”。proteced并不比public更具备封装性。
23.宁可用non-member、non-friend替换member函数
如果要在一个member函数和一个non-member,non-friend函数之间做抉择,并且两者提供相同的技能,那么导致较大封装性的是non-member,non-friend函数,因为它并不增加“能够访问class中的private成分”的函数数量。因为member函数不仅仅可以访问class 中的private数据,也可以取用private 函数,enums,typedefs等等,但是non-member,non-friend函数则无法访问上述的任何资源。
另外,指引在意封装性而让函数成为class的non-member,并不意味着它“不可以是另外一个class的member”。比如我们可以把对应方法写成某工具类中的一个static member函数,只要他不是目标对象的member或者friend,就没有影响目标对象的封装性。应该让尽量少的函数可以接触到目标对象的成员。
24.如果所有的参数都需要类型转换,最好使用non-member函数
如果需要为某个函数的所有参数(包括this指针所指向的自身)进行类型转换,那么该函数必须是个non-member.
25.考虑写一个不拋异常的swap函数
标准库提供了一个默认的swap方法,绝大多数情况下他是非常好用的。
namespace std {template<typename T>void swap(T& a,T& b){T temp(a);a=b;b=temp;} }
只要你的类支持copy构造和copy assignment操作符,这个函数就可以使用。但是,这个方法调用了1次copy函数,2次copy assignment操作符,有些时候这是没有必要的,比如说当你的类只是含有一个指针时。
class WidgetImpl //针对Widget数据而设计的class {public:...//细节不重要private:int a,b,c; //可能有许多数据std::vector<double> v; //这意味着复制的时间会很长 } class Widget //这个class采用pimpl手法 {public:Widget(const Widget& rhs);Widget& operator=(const Widget& rhs){...*pImpl = *(rhs.pImpl); ...}...private:WidgetImpl* pImpl; //指针,所致对象内含有Widget数据 }
实际上,我们在交换Widget的时候,只需要交换两个pImpl的指针指向,但是默认的swap会复制三个WidgetImpl,这非常低效。我们希望告诉std::swap(),交换的类型是Widget的时候,只需要交换指针就好。
namespace std { template<> //编译暂时不能通过 void swap<Widget>(Widget & a,Widget& b) { swap(a.pImpl,b.pImpl); } }
该函数一开始的template<>表明它是swap的一个特化版本,只有T为Widget时会执行此版本swap方法,否则执行默认的swap,但是这个方法访问了a,b的private成员,所以它不能通过编译。
为了让swap可以最终访问private成员,我们可以给Widget补充一个public函数,并且让std::swap调用它
class Widget {public:...void swap(Widget& other){using std::swap;//稍后解释这个声明的必要性swap(pImpl,other.pImpl);}... }; namespace std{template<>void swap<Widget> (Widget& a,Widget& b){a.swap(b);} }
这个做法是可行的,而且和STL容器的处理方式一致,STL的容器也提供了std::swap的特化版本和自己的public swap方法。
但是,假设Widget不是一个class 而是一个 class Template:
template<typename T> class WidgetImpl{...}; template<typename T> class Widget{...};
当想要继续特化std::swap时却出现了问题。
namespace std{ template<typename T> void swap<Widget<T>> (Widget<T>& a,Widget<T>& b)//不合法 { a.swap(b); } }
这是因为对于C++的偏特化而言,只允许对class template偏特化,而不能对function template偏特化。
如果要类似的效果,可以试着重载函数。
namespace std{ template<typename T> void swap(Widget<T>& a,Widget<T>& b) { a.swap(b); } }
然而,std作为标准库原则上不能被添加新的template(特化不算添加),class或者方法,所以如果要避免未定义行为,最好避免这种写法。其中一个解决方案是,放弃对std::swap的特化,而是声明在自己的命名空间内。
namespace WidgetStuff{ ... template<typename T> class Widget{...}; ... template<typename T> void swap(Widget<T>& a,Widget<T>& b) { a.swap(b); } }
现在,任何地方的任何代码如果打算交换两个Widget,C++的名称查找法则(name lookup rules,更准确的说是argument-dependent-rules)会找到WidgetStuff中的swap版本并使用它。当然如果不加上这个命名空间WidgetStuff,在global命名空间中依然会拥有这些模板,也不影响使用,但这会让global命名空间太过拥挤。
那么,当想要进行交换的时候,假设我们正在写一个function tmeplate,需要置换其中两个值:
template<typename T> void doSomething(T& obj1,T& obj2) {...swap(obj1,obj2);... }
我们所希望做的是,尝试调用T类型的swap特化版本,并且在该方法不存在的情况下,调用std中的一般化版本。
所以应该这么做:
template<typename T> void doSomething(T& obj1,T& obj2) {...using std::swap;swap(obj1,obj2);... }
一旦swap被调用,C++基于实参的名称查找规则会查找global命名空间或者T类型所在命名空间任何T专属的swap,如果没有才会使用std的swap,这就是为什么需要在函数内使用using std::swap;即便如此编译器还是更喜欢std::swap的T专属特化,假设你已经针对T把std::swap特化,特化版将会被编译器挑选中。
记得不要用命名空间固定死swap的命名空间,这样名称查找规则就失效了。
std::swap(obj1,obj2);//错误
注意这个在自己命名空间定义的non-member的swap不应该抛出异常,因为swap函数应该具备强烈的异常安全性。
五、实现
26.尽可能地延后定义式的出现时间
考虑到函数可能的提前返回导致创造出来的临时变量白白析构,最好不要再函数的一开始就进行所有的定义,而是等到需要使用它,甚至它的初值已经准备好的情况下,再编写定义式。这样做可以增加程序的清晰程度,并且改善程序效率。
27.尽量少做转型动作
如果你曾经来自C,JAVA,C#阵营,请特别注意转型可能导致的危险,因为在那些语言中转型比较必要而且和C++相比并不危险。但在C++中,转型是一个必须打起十二分精神对付的操作。
首先回顾一下转型语法。
1.旧式转型
1.C风格转型
(T)expression
2.函数风格转型
T(expression)
上述两种旧式转型风格没有任何区别
2.新式转型(C++ style)
1.常量性转除
const_cast<T>(expression)
将对象的常量性移除(cast away),这也是唯一有此能力的C++操作符。
2.动态转型
dynamic_cast<T>(expression)
用来执行“安全向下转型”,唯一一个无法用旧式语法做到的转型方式
3.重解释转型
reinterpret_cast<T>(expression)
低级转型,实际操作可能取决于编译器,这意味着它不可移植。例如把pointer to int 转型为int,在低级代码以外非常少见。
4.静态转型
static_cast<T>(expression)
强制进行隐式转换,比如把non_const转型为const,int转double等等,但是不能把const转为non_const,因为那是const_cast的工作。
3.关于转型
许多程序员相信,转型实际上什么也没有做,只是告诉编译器需要把某个已经存在的对象当成某种class而已。但是实际上转型真的会产生运行时的代码。
int x,y; ... double d= static_cast<double>(x)/y;
因为int底层和double底层在绝大多数计算机体系中都不一样,所以int转型double肯定会产生一些代码,如果说上面这个例子还很好理解,接下来这个例子就比较特别了。
class Base{...}; class Derived:public Base{...}; Derived d; Base* pb=&d;
Base* pb=&d隐式地把Derived* 转换成 Base* ,然而这两个指针的值可能并不相同。这种情况会有一个偏移量在运行时施加于Derived指针上,用以取得正确的Base* 指针值。单一对象可能有多个地址(比如上述例子中以Base*指向它的地址和以Derived *指向它的地址),C,Java,C#中不可能发生这种事,但C++跟他们不一样。所以要避免做出“对象在C++中如何布局”的假设,因为在某一个平台可能这行得通,但是换了一个平台之后,就有几率出问题了。
另外,还需要避免写出一些似是而非的代码:
class Window//base calss {virtual void onResize(){...}... }; class SpecialWindow: public Window{public :virtual void onResize() {static_cast<Window>(*this).onResize();...} }
在SpecialWindow中,onResize试图把自己转型为base class然后调用base class 的onResize函数,再在后文中实现自己的专属行为。然而实际上由于static_cast的类型是Window,该方法将会返回一个新的Window临时对象,也就是说一个新的Window副本产生了,这意味着调用Window版本onResize的是一个临时对象,而后面SpecialWindow的专属行为则生效了,这将造成严重的问题。
在网上查看相关问题后,我得知改变static_cast的返回类型可以解决这一问题:
static_cast<Window&>(*this).onResize();
这把自己对象本身的值转换成一个Window的引用,cast返回的引用指向的是本对象的Baseclass部分,就可以调用Window版本的onResize了。
或者也可以这样:
static_cast<Window*>(this)->onResize();
把this指针转换成Window指针,cast返回的指针指向的是本对象的Baseclass部分,就可以调用Window版本的onResize了。
但是最好的解法还是像书上所说一样,尽可能少的转型,而是直接告诉编译器,自己想调用base class版本的onResize了。
virtual void onResize() { Window::onResize(); ... }
28.避免返回handle指向程序内部部分
避免返回handle(这里的handle指的是reference,指针或者迭代器)指向内部的成员,原因如下:
1.破坏了对象的封装性,一旦你在某个访问级别较宽松的函数当中获得了一个指向内部访问级别较严苛的成员的handle,那么这个成员实际上的访问级别就跟这个函数一样宽松了。
2.即便给返回类型限定了const让编译器禁止通过handle修改内部部分,也可能导致handle的声明周期比对象本身还要长,进而出现错误。
29.为异常安全而努力是值得的
1.异常的处理方式
具备异常安全性的函数会有如下三种对异常的处理方式。
1.基本承诺
如果异常被抛出,程序的任何失误仍然保持在有效状态下,没有任何对象或者数据结构会因此败坏,所有的对象都处于一种内部前后一致的状态(比如所有的class约束条件都会继续获得满足)。但程序的实际状态不可预料,客户必须调用某个成员函数来得知异常抛出后对象的当前内容。
2.强烈保证:
异常被抛出的话,程序的状态不会改变。调用这种函数就可以明确:程序要么完全成功,要么完全失败。
3.不拋掷保证(nothrow):
承诺该函数绝不抛出任何异常,该函数一定能完成原本承诺的功能。用于内置类型(比如ints,指针等等)身上的所有操作都提供nothrow保证。
2.实现
一般来说,不拋掷保证的函数是理想情况。但这几乎是不可能的,任何使用动态内存的东西(例如STL的容器)都可能因为内存不足导致bad_alloc异常。所以,多数情况下函数只能在基本承诺和强烈保证当中二选一。
当要实现强烈保证的时候,通常的伎俩是使用copy-and-swap,先给原件做出一个副本,然后再副本上做一切必要的修改,这时候如果出现任何异常,原件仍然保持不变,直到所有改变都成功,在把修改过得那个副本和源对象在一个不抛出异常的操作中置换swap(条款25中提到过swap是保证不抛出异常的)。
copy-and-swap一般是管用的,但要考虑如下的情况:
doSomething() {...//copy and doxxxf1();f2();...//doxxx and swap }
首先,如果f1,f2只能实现基本承诺而非强烈保证,但doSomthing意图实现强烈保证,那么这意味着需要在f1之前进行copy,f1之后进行swap,f2同理。即便f1,f2都是强烈保证,依然会有一个问题,f1顺利返回之后,程序状态已经被改变了,如果f2抛出了异常,那么必须回退到doSomething调用前的状态,而f1已经发生了,导致状态不一致。如果f1,f2修改的是局部状态(local state)还比较好解决,如果是非局部状态(non-local state)有连带影响时,比如数据库的改动,提供强烈保证就非常的困难。
另外,copy-and-swap手法进行了太多的copy副本的工作,耗费了时间和空间。所以,尽管强烈保证是我们所需要的特性,但并不是所有时候都值得为他付出代价,强烈保证并不是所有时候都实际的。
函数所调用的异常安全保证不可能高于它所调用的其他函数的异常安全保证的最低等级。系统也是同理,系统中只要有一个函数是不实现异常安全保证(即连上文中的基本承诺都不提供),那么就不可以说整个系统有异常安全性。异常安全性只有“有”和“没有”的区别,没有“部分有”这种说法。
30.了解inlining的里里外外
把大多数inlining限制在小型的、被频繁调用的函数身上。这可以使日后的调试过程和二进制升级(binary upgradability)更加容易,也可以使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
不要只因为function templates出现在头文件,就把他们声明为inline。
31.把文件间的编译依赖关系降至最低
1.编译依赖
假设有如下类:
class Person{public:Person(const std::string& name,const Date& birthday,const Address& addr);std::string name() const;std::string birthDate() const;std::string address() const;...private:std::string theName;Date theBirthDate;Address theAddress; };
如果没有string,Date和Address的定义式,这段代码无法通过编译。而这样的定义式经常由#include提供,所以经常来说Person定义文件上方应该有这些代码:
#include <string> #include "date.h" #include "address.h"
于是,这么一来Person定义文件和其含入的文件之间就形成了一种编译依存关系(compilation dependency)。如果这些头文件中任意一个被改变,或者他们所依赖的其他头文件有任何改变,那么所有含入Person class 的文件也要重新编译,任何使用Person class 的文件也必须重新编译。这会非常麻烦。
那么为什么不使用前置声明呢?
//错误 namespace std{class string; } class Date; class Address; class Person{public:Person(const std::string& name,const Date& birthday,const Address& addr);std::string name() const;std::string birthDate() const;... }
上述代码首先就弄错了string不是一个class而是一个typedef(定义为 basic_string < char >),正确的前置声明是什么并不重要,因为你本来就不应该尝试手工去声明一部分的标准程序库,而是应该采用#inlcude。事实上,标准库的头文件不太可能成为编译的瓶颈,特别是当你的建置环境支持预编译头文件的时候。
但是关于“前置声明每一个东西”的第二个(同时也是更加重要的一点)困难是,编译器在编译期间需要知道每一个成员的大小。
int main() { int x;//定义一个int Person p(params);//定义一个Person }
为了定义一个int,编译器首先需要知道一个int要有多大,才能给x分配内存,当然,所有的编译器都知道一个int有多大。对于Person,编译器得知这项信息的唯一方法就是访问Person的定义式(声明式只是表明变量的类型和名字,而定义式才是决定内存的分配,所以一个变量可以被声明多次,而只能定义一次)。
2.pimpl(pointer to implement)
那么为什么其他语言诸如java、Smalltalk不会出现这种问题呢?因为当我们声明一个成员对象时,它们的编译器会只分配一个指针的空间给该对象,也就是把代码视为这个样子:
int main() { int x; Person* p; }
c++虽然不会让编译器这么做,但我们可以手动编码完成类似的实现,一种叫做pimpl idiot的设计风格可以使用:那我们把Person分割为借口和实现类,把实现类取名为PersonImpl,Person.h的内容改成如下:
#include <string> #include <memory> class PersonImpl; class Date; class Address; class Person{public:Person(const std::string& name,const Date& birthday,const Address& addr);std::string name() const;std::string birthDate() const;std::string address() const;...private:std::tr1::shared_ptr<PersonImpl> pImpl; }
现在Person中只有一个指针成员指向实现类了。
于是编译的依赖关系从原来的:
main.cpp以及其他使用了Person的cpp 依赖 Person.h Person.cpp 依赖 Person.h 改动Person.h,Person.cpp main.cpp等其他cpp都受影响
变成了:
main.cpp及其他使用了Person的cpp 依赖 Person.h Person.cpp 依赖Person.h,PersonImpl.h (因为Person.cpp需要调用PersonImpl的方法,所以需要它的定义式) PersonImpl.cpp 依赖PersonImpl.h 改动PersonImpl.h ,只有PersonImpl.cpp,Person.cpp受到了影响 main.cpp及其他使用了Person的cpp,只依赖 Person.h而不是Person.cpp,所以不受影响
3.减少编译依赖
这一设计的关键在于把“定义的依存性”换成了“声明的依存性”,这也是编译依存性最小化的本质:尽可能让头文件自我满足,万一做不到,就让它和其他文件内的声明式(而非定义式)相依。以下的几个准则都是依据这一本质而来:
1.如果可以使用object reference 或者object pointer代替,那么不要使用objects
因为指针或者引用对于编译器来说占用内存的大小是固定的,对于编译器来说该成员是一个指针而不是某个具体的object,不需要找到定义式就可以直接使用,但是定义某个object的成员,就一定要用到定义式。
2.尽量用class声明式而非class 定义式
注意,当声明一个函数的参数或者返回类型需要某个class式,是不需要定义式的。只有调用这个函数的文件或者实现文件才需要使用定义式。
class Date; Date today(); void clearAppointments(Date d);//两者都合法,都不需要定义式
这样做就把”提供定义式“(通过#include)的义务从”函数声明所在“的头文件转移到”函数实现或者调用“的实现文件,就可以将”非真正必要之类型定义和客户端之间的编译依存性去除掉。
3.为声明式和定义式提供不同的头文件。
使用两个头文件分别用于声明式和定义式。当然如果有一个声明式被改变了,两个文件都得改变。这样做的话,使用者就可以总是#include一个声明文件而不是前置声明若干次。比如说Date的使用者希望声明today和clearAppointments,它不应该前置声明Date,而是应该include一个包含了对应的声明式的头文件。
#include "datefwd.h" Date today(); void clearAppointments(Date d);
datefwd.h中包含了class Date的声明式(但没有定义),其中fwd代表forward declaration即前置声明。
标准库中类似的例子是<iosfwd>,它包含iostream各种组件的声明式,但是他们的定义式分布在若干不同的头文件内,包括<sstream>,<streambuf>,<fstream>和<iostream>
4.实现
像上文中的Person这样的pimpl设计的实现一般被称为Handle class,应该如何实现它呢,一般有两种方法。
1.转交给实现类来实现
//Person.cpp #include "Person.h" #include "PersonImpl.h"//注意,因为需要使用PersonImpl的成员函数,所以需要它的定义式 //构造函数 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(); }
2.把Handle class 当做抽象基类
另一种方法是把Person当做特殊的abstract base class,称为 Interface class。这种class 的目的是详细意义描述derived classes的接口,因此它不带有成员变量,也没有构造函数,只有一个virtual析构函数(见条款7)以及一组pure virtual函数。
这种Interface class的射击类似Java 和 .NET的Interfaces,但是C++的Interface并不需要承担Java和.NET的Interface需要承担的责任。比如说Java和.NET不允许在Interfaces实现成员变量或者成员函数(其实Java已经可以了...),但是C++并不禁止。
class Person {public:virtual ~Person();virtual std::string name() const=0;virtual std::string birthDate() cosnt=0;virtual std::string address() const=0;... };
这个class 的客户必须以Person的pointers或者references来编写应用程序。因为这个类不可能为内含pure virtual函数的Person classed具现出实体,但是却有可能为派生自Person的classes具现出实体。除非Interface class的接口被修改,否则其客户不需要重新编译。
但是客户必须要有办法给这种class 创建新的对象。他们通常调用一个特殊函数。这个函数扮演“真正将要被具现化”的那个derived classes的构造函数的角色,又叫工厂函数或者virtual 构造函数。他们返回指针(最好是智能指针),指向动态分配所得的对象,而该对象支持Interface class的接口,该方法一般是static的:
class Person{public:...static std::tr1::shared_ptr<Person> create(const std::string & name,const Date& birthday,const Address& addr);... }
客户这样使用他们:
std::string anme; Date dateofBirth; Address address; ... std::tr1::shared_ptr<Person> pp(Person::create(name,dateOfBirth,address)); ...std::cout<<pp->name()<< "was born on"<<pp->birthDate()<<"and now lives at"<<pp->address();
然后我们需要一个Interface class的真正的具象类(concrete class),这个类提供接口的virtual函数的具体实现,而且要有一个构造函数
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() cosnt {...}; std::string birthDate() cosnt {...}; std::string address() const {...}; private: std::string theName; Date theBirthDate; Address theAddress; }
然后再实现Person::create这个工厂函数:
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)); }
无论是哪种实现会在运行时付出额外的代价,换取编译时的便利,所以需要在这之间做出取舍。
(其实我一直觉得不是应该尽量照顾运行时吗...)
六、继承与面向对象设计
32.确认你的public继承塑膜出is-a关系
继承分为private继承和public 继承。
假设D对象public继承了B对象,就相当于对编译器宣称“D is a B",即每一个D对象都是一个B对象,适用于B的任何规则一定能适用于D身上,反之则不成立。
但是 is-a并非唯一存在于classes之间的关系,另外两个常见的关系是has-a(有一个),和is-implemented-in-terms-of(根据某物实现出)。
33.避免遮掩继承而来的名称
先从一个和继承无关,但是和作用域有关的例子开始:
int x; void someFunc() { double x; std::cin>>x; }
编译器在someFunc的作用域内遭遇x名称时,他会在local作用域内查找是否存在x,找到的话就不会再找其他作用域。尽管someFunc的x是double类型而global的x是int类型,但是这不重要,C++的名称遮掩规则(name-hiding rules)所做的唯一事情就是:遮掩名称。至于名称是否应该是相同或者不同的类型,并不重要。本例中double的x就遮掩了一个int的x。
现在再看一个继承的例子,对于继承的class 来说,derived class的作用域包含在base class内:
class Base { private:int x; public:virtual void mf1()=0;virtual void mf2();void mf3();... }; class Derived:public Base{//public 继承public:virtual void mf1();void mf4(); }
现在我们假设mf4的内部实现是这样子的:
void Derived::mf4(){...mf2();... }
mf4内部调用了mf2,那么:
编译器会首先查找local作用域(本例中就是mf4覆盖的作用域)有没有mf2,没有找到
接着查找class Derived覆盖的作用域,结果还是没有找到
于是再查找class Base覆盖的作用域,发现了一个叫做mf2的函数,那么编译器就会使用这个函数并停止下一步查找
我们假设Base没有定义mf2这个函数,那么编译器就会在包含了Base的那个namespace(如果有这个命名空间的话)中寻找mf2,
还是没有的话就在global作用域中寻找。
如果上一个例子没有什么疑点,那么下面这个例子就特别一点了:
class Base { private:int x; public:virtual void mf1() = 0;virtual void mf1(int);virtual void mf2();void mf3();void mf3(dobule);... }; class Derived:public Base {public:virtual void mf1();void mf3();void mf4();... }
这段代码的运行会让第一次面对它的C++程序员大吃一惊,因为Derived 重载了Base的mf1和mf3,于是Base的所有名为mf1和mf3的函数在命名遮掩规则下都“消失”了,无论它是不是virtual,有没有带参数。
Derived d; int x; ... d.mf1();//有效,调用的是Derived::mf1 d.mf1(x);//错误,Derived::mf1遮掩了Base::mf1后,带参数的那个也被遮掩住了 d.mf2();//有效 调用Base::mf2 d.mf3();//有效 调用Derived::mf3 d.mf3(x);//错误,Derived::mf3遮掩了Base::mf3后,带有参数的那个也被遮掩住了
实际上,如果使用public继承又不打算继承那些重载函数,是违背了is-a的原则的。因此需要一个方法来推翻C++对“继承而来的名称”的缺省遮掩行为。可以使用using来达成目标:
class Base { private:int x; public:virtual void mf1() = 0;virtual void mf1(int);virtual void mf2();void mf3();void mf3(dobule);... }; class Derived:public Base {public:using Base::mf1;using Base::mf3;virtual void mf1();void mf3();void mf4();... }
using让Base的mf1和mf3在Derived作用域中都可见了,现在d.mf1(x)和d.mf3(x)都可以正常使用了。所以,当试图重新覆写base class的重载函数的时候,你必须为那些原本将被遮掩的名称引入using声明式,否则他们会被遮掩。
有时候,我们并不想继承base classes的所有函数,这在public继承下,这是绝对不能发生的,因为这违反了public继承所坚持的is - a关系,但是在private继承下它却可能是有意义的。
如果在上述的例子中,我们只需要Derived继承来自Base的mf1(),但是不需要带参数的mf1,也就是只需要重载函数的一部分被继承,这个时候using就不合适了,而转交函数会比较奏效:
class Derived:public Base {public:...virtual void mf1() //方便起见写成inline{Base::mf1();}... }
34.区分接口继承和实现继承
1.函数的继承
作为class 的设计者,有时候你会希望derived classes只继承成员函数的接口(也就是声明);有时候你又会希望derived classes同时继承函数的接口和实现,但是又可以覆写这些实现;有时候你又会希望derived classes同时继承函数的接口和实现的同时不允许覆写任何东西。
class Shape {public:virtual void draw() const=0;virtual void error(const std::string& msg);int objectID const;... }; class Rectangle:public Shape{...}; class Ellipse:public Shape{...};
因为draw函数是一个pure virtual函数,所以Shape是一个抽象的class,客户不能创建Shape的实体,而是只能创建它的derived class 的实体。根据例子可以看出Base class的如下特点:
1.成员函数的接口总是会被继承
由于public继承坚持的is-a特性,任何对base class为真的特性对derived class也一定为真。
2.声明pure virtual函数的目的是让derived class只继承接口
例子中的draw是一个pure virtual函数,pure virtual函数有如下特点:
1.derived class必须重新声明该函数
2.通常它们在抽象class中是没有定义的
所以pure virtual函数的目的是让derived class只继承接口,换句话说,pure virtual函数并不提供缺省的实现,而是由它的derived class自己实现它们。虽然pure virtual函数本身也是可以提供一个定义的,C++并不排斥给pure virtual函数提供实现代码,但是由于在子类中必须重新声明它,所以一般的调用是不会使用到pure virtual在base class的实现的,唯一的使用方式是明确指定其class的名称:
Shape* ps=new Shape;//错误 Shape是抽象的 Shape* ps1=new Rectangle; ps1->draw();//调用的是Rectangle::draw Shape* ps2=new Ellipse; ps2->draw();//调用的是Ellipse::draw ps1->Shape::draw();//调用的是Shape::draw ps1->Shape::draw();//调用的是Shape::draw
一般而言这项性质没什么用,但它确实在特殊情形下可以实现一些比impure virtual函数更安全的缺省实现
3.声明impure virtual函数的目的是让derived class同时继承接口和实现
如果derived class没有覆写impure virtual函数,那么就会默认继承base class 的实现。比如例子中的error声明为impure virtual意味着告诉derived class的设计者”你必须支持一个error函数,但是如果你不想自己写一个,可以使用Shape class提供的缺省版本“。
4.声明non-virtual函数的目的是为了令derived classes继承函数的接口以及一份强制性的实现
这意味着该函数不打算在derived classes中有不同的行为,其不变形凌驾于其特异性。
2.设计时需要避免的问题
1.把所有成员函数设计为non-virtual
这将不给derived class 留下任何特意化的余地
2.把所有成员函数设计为virtual
这是设计者立场不坚定的体现,就应该有一些特质是在该类和继承它的类中保持不变的。
35.考虑virtual函数以外的选择
假设我们正在设计一款游戏,游戏中的人物拥有一个函数计算他们的生命值。考虑到不同人物可能计算的方式不一样,可能会有这样的设计:
class GameCharacter { public: virtual int healthValue() const; ... }
这种设计是简洁明了的,但是其实还可以有其他的选择方案。
1.藉由Non-Virutal Interface手法实现Template Method模式
这个流派主张virutal函数应该总是为private的,这个流派的拥护者建议,保留healthValue为public成员函数,但是将他从virtual函数改变成non-virtual,在他的内部调用private virtual函数进行实际工作:
class GameCharacter{ public:int healthValue() const{...int retVal = doHealthValue();...return retVal;}...private:virtual int doHealthValue() const{...} };
这一设计让客户通过public non-virtual成员函数间接调用private virtual函数,称为non-virtual interface(NVI)手法。也就是Template Method(模板方法)设计模式的体现。(这个模板和c++模板无关)调用virtual函数的这个non-virtual接口又可以叫做外覆器(wrapper)。
NVI的优势在于确保virtual函数在进行真正工作之前可以设定好场景,调用结束后可以清理场景,比如说调用之前可以锁定互斥器,验证先决条件等等,调用之后可以接触互斥器,记录日志等等。NVI重写的virtual函数是private的,这意味着外部不能直接调用virtual函数,必须通过该类提供的Non-virtual接口间接调用它,这就让derived class享有“virtual函数如何实现”的权利的同时,让base class保留了“virtual函数如何被调用”的权利,用户只需要重写virtual函数自定义中间的部分即可,而不是重写所有的步骤。
2.藉由Funtion Pointers 实现Strategy模式
NVI手法虽然有他的优点,不过最终“计算生命值”依然是在virtual函数内进行了,下面介绍的这种方法更加特殊。它主张“人物健康指数的计算和人物本身无关”,这种计算完全不需要“人物”这个成分。
class GameCharacter;//前置声明 //默认的计算函数 int defaultHealthCalc(const GameCharacter& gc); class GameCharacter{public://定义HealthCalcFunc是一个函数指针,它的参数是reference to GameCharacter,返回类型是inttypedef int (*HealthCalcFunc)(const GameCharacter&);explicit GameCharacter (HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf){}int healthValue() const{return healthFunc(*this);}...private:HealthCalcFunc healthFunc; };
和virtual方法或者NVI风格相比,Strategy模式即策略模式提供了更多的弹性
1.同属于GameCharacter的不同实体可以使用不同的生命值计算函数
2.生命值计算函数可以在运行时替换
不过让生命值计算函数不再和人物本身有关联也意味着这个函数不能访问人物实体的non-public部分
3.藉由tr1::function来完成Strategy模式
更进一步,完成生命值计算的不一定非要是一个函数,也可以是一种“类似函数的事物”(比如函数对象),甚至可以是其他对象的成员函数,返回类型也未必一定是int,而是任何可以转换成int的类型。
tr1::function就是能解决这些问题的一种对象,该对象可以持有任何的“可调用物”(callable entity,也就是函数指针,函数对象,或者成员函数指针):
class GameCharacter;//前置声明 //默认的计算函数 int defaultHealthCalc(const GameCharacter& gc); class GameCharacter{public://定义HealthCalcFunc是一个函数指针,它的参数是reference to GameCharacter,返回类型是inttypedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;explicit GameCharacter (HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf){}int healthValue() const{return healthFunc(*this);}...private:HealthCalcFunc healthFunc; };
std::tr1::function和原本的函数指针的区别在于它是一个指向函数的泛化指针,这个函数的参数可以是任何能够转化成GameCharacter类型的引用,而返回类型也可以是任何可以转化成int的类型。
short calcHealth(const GameCharacter&);//健康计算函数,它的返回值为non-int struct HealthCalculator{int operator() const(const GameCharacter&) const{...} };//一个函数对象 class GameLevel{public:float health(const GameCharacter&) const;//成员函数,用来计算健康,返回值为non-int }; class EvilBadGuy:public GameCharacter{... }; class EyeCandyCharacter:public GameCharacter{... }; EvilBadGuy ebg1(calcHealth); EyeCandyCharacter eccl(HealthCalculator()); GameLevel currentLevel; ... EvilBadGuy ebg2( std::tr1::bind(&GameLevel::health,currentLevel,_1) );
可以看到GameCharacter可以采用返回其他类型的函数,一个函数对象,甚至别的类的成员函数来作为自己的生命值计算函数。这赋予了计算生命值时的极大弹性。
4.古典的Strategy模式
在传统的Strategy模式实现中,我们可以把virtual函数替换为另外一个继承体系内的virtual函数:
class GameCharacter; class HealthCalcFunc{ publci:...virtual int calc(const GameCharacter& gc) const{...}... }; HealthCalcFunc defaultHealthCalc; class GameCharacter{pbulci:explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc):pHealthCalc(phcf){}int healthValue() const{return pHealthCalc->calc(*this);}...private:HealthCalcFunc* pHealthCalc; }
36.绝不重新定义继承而来的non-virtual函数
假设有如下代码:
class B{public:void mf();... }; class D:public B{public:void mf();... };
当我们指定两个不同类型的指针指向同一个对象并调用mf时,结果可能会让大吃一惊:
D x; B* pB=&x; D* pD=&x; pB->mf(); pD->mf(); //两者调用的竟然不是同一个函数,pB调用B::mf,pD调用D::mf
这是因为mf是non-virtual函数,在编译器看来,non-virtual函数是静态绑定的,也就是说因为pB是一个B类型的指针,无论它运行时指向的对象实际上是什么类型,在调用mf函数时始终都调用的是B定义的版本。
而virtual函数则不同,执行的是动态绑定,只有在调用时才会确定具体执行的是哪个版本的方法。条款7就是本条款的一条特例,尽管它的重要性使它能单独被列出来。
37.绝不重新定义继承而来的缺省参数值
从上一条款(36.绝不重新定义继承而来的non-virtual函数)可知,不应该重新定义non-virtual函数,所以本条款只讨论virtual函数。
不能重新定义缺省参数值的原因是,virtual函数是动态绑定的,但是缺省参数值是静态绑定的。
class Shape{public :enum ShapeColor{Red,Green,Blue};virtual void draw(ShapeColor color=Red) const =0;... } class Rectangle:public Shape{public://错误的virtaul void draw(ShapColor color=Green) const;... } class Circle:public Shape {public:virtual void draw(ShapeColor color) const; }
现在考虑这些指针:
Shape* ps; Shape* pc= new Circle; Shape* pr=new Rectangle;
无论这些指针实际指向谁,他们的静态类型都是Shape。而动态类型则是运行时实际指向的对象,ps没有指向任何对象,pc的动态类型是Circle,pr的动态类型是Rectangle。如名称所示,动态类型可以在运行时中途改变,当指针指向其他对象时,它的动态类型就有可能变化。
但是如下的代码却和期望不符:
pr->draw()//调用Rectangle::draw,这符合期望,但是参数是ShapeColor::Red
原本我们重新定义了color的默认值,期望Rectangle执行draw时它的参数缺省为Green,但是最后执行的时候却依然是Shape所定义的Red。这说明,virtual函数虽然是动态绑定,但是它的参数缺省值却是静态绑定的。
C++为什么要这么做呢?主要还是为了运行时的效率考虑,如果缺省值是动态绑定,编译器就必须设法在运行期给virtual函数决定适当的缺省值,这比静态绑定机制更慢且更加复杂,所以C++做出了这样的取舍。
但是如果遵守上述不改动缺省值的规定,代码应该怎么写呢?下述的代码有着一定的缺点:
class Shape{public:enum ShapeColor{Red,Green,Blue};virtual void draw(ShapeColor color=Red) const = 0;... }; class Rectangle:public Shape { public:virtual void draw (ShapeColor color=Red) const;... };
上面代码的缺点是:代码重复且有相依性(with dependencies),如果哪一天Shape的color缺省值改动了,所有Derived class 的color缺省值全部都要跟着改动。这个时候可以尝试使用条款35所提到的virtual函数的代替设计,例如NVI:
class Shape{public:enum ShapeColor {Red,Green,Blue};void draw(ShapeColor color =Red) const{doDraw(color);}...private:virtual void doDraw(ShapeColor color) const = 0; }; class Rectangle:public Shape{public:...private:virtual void doDraw(ShapeColor color) const;... }
38.通过复合塑膜出has-a以及“根据某物实现出”关系
复合(composition),指的是某种类型的对象包含另一种类型的对象。在程序员中复合这个术语有很多同义词,包括layering(分层),containment(内含),aggregation(聚合)和embedding(内嵌)。
复合一般有两种用途,制造出has-a关系,或者is-implemented-in-terms-of。has-a关系顾名思义就是拥有,持有另一事物的意思。比如游戏角色持有武器,车辆持有车灯等等,这一点不难理解。is-implemented-in-terms-of则是内部使用了某种对象,最终实现某个效果,比如用list来实现自定义set,set内部持有list,但是list只是一个保存数据的容器,set自己编写了存取数据的逻辑,使它能够实现set的特性,或者用链表的Node和map来实现自定义LRU,LFU等等,了解数据结构相关知识的应该清楚,很多时候容器不只有一种实现方式,很多情况下一种容器都可以根据另外一种容器实现。
39.谨慎使用private继承
private继承并不意味着两个class之间有is-a的关系,换句话说,假设D以private的方式继承了B,编译器并不认为任何对B为真的事情,对D也为真。private继承相当于宣称D和B是is-implemented-in-terms-of的关系。
从private base class继承过来的所有成员,无论其原本是public,protected还是private,在private derived class中一律将成为private,因为所有成员都是private derived class 的实现枝节而已。private继承仅仅意味着D对象根据B对象实现而来,没有其他意义。
一般来说,尽量使用复合(composition)来实现is-implemented-in-terms-of而不是private继承。除非是derived class需要访问protected base class 的成员或者需要重新定义继承而来的virtual函数的时候,这么设计才合理。
对于致力于“对象尺寸最小化”的程序库开发者而言,private继承可以造成empty base 最优化。
40.谨慎使用多重继承
C++社群对于多重继承的观点分为两个阵营,其中之一认为如果单一继承是好的,那么多重继承一定是更好的。另一个阵营则主张,单一继承是好的,但是多重继承不值得拥有。
多重继承可能会导致歧义,即derived class调用函数或者访问成员时,如果多个base class都有这个成员或函数,编译器会不知道具体指的是哪个,这种情况需要把明确指出使用哪个base class 的成员才行。
钻石型多重继承:即继承的base class又继承了其他更上级base class,而有若干个base class的更上级base class是相同的。C++默认对于这种情况将会复制多份base class的重复内容,如果继承时特定指明是virtual public 继承,那么就只会复制一份。理论上对于所有的public继承,为了后续的维护,都应该改成virtual public继承,但是理论并不等于实践,因为virtual 继承所产生的对象往往比non-virtual继承所产生的对象要更加大。
一般要谨慎使用多重继承,但他的确有其用途所在,比如说“public继承某个Interface class”和"private继承某个协助实现的class"的结合。
七、模板与泛型编程
41.隐式接口和编译期多态
对于classes而言,接口是显式的,以函数签名为中心,多态通过virtual函数发生于运行期;而对于template参数而言,接口是隐式的,奠基于有效表达式。多态通过template具现化和函数重载解析发生于编译期。
42.了解typename的双重含义
对于template声明式而言,class和typename对于C++是没有任何区别的。
template<class T> class Widget; template<typename T> class Widget;//没有区别
但是并不是所有情况typename和class 都是等价的
假设我们有如下一段不能通过编译的代码:
template<typename C> void print2nd(const C& container) {if(container.size()>=2){C::const_iterator iter(container.begin());++iter;int value=*iter;std::cout << value;} }
问题出在C::const_iterator上面,对于一个template,C::const_iterato x这个名称具体取决于C到底是什么名称,这种名称叫做从属名称,如果某个类型定义在另一个类型内,又可以被叫做嵌套名称,所以C::const_iterator是嵌套从属名称,而int并不依赖任何一种模板类型,叫做非从属类型。问题就出在编译器不清楚C::const_iterator究竟是一类型还是一个静态变量,这造成编译器的解析困难,比如说如下情况
C::const_iterator *x;
一般人可能认为定义了一个C::const_iterator 的指针名称为x,但是C::const_iterator可能是一个静态变量,x可能是一个全局变量,结果*就成了乘法符号,那么这一语句就意味着C::const_iterator和x相乘。C++编译器必须考虑所有的可能情况,这就是为什么上述代码不能通过编译的原因。为了告诉编译器,C::const_iterator是一种类型,必须在前面加上typename作为提示,一般来说,任何当你想要在template中指涉一个嵌套从属类型名称,就必须在紧邻它的前一个位置放上一个typename关键字。
template<typename C> void print2nd(const C& container) {if(container.size()>=2){typename C::const_iterator iter(container.begin());++iter;int value=*iter;std::cout << value;} }
这一规则的例外是,typename不用出现在base classes list中的嵌套从属类型名称之前,也不可以在member initialization list(成员初值列)中作为base class修饰符,原因也许是因为在这些场所编译器不需要提醒也知道它们代表的意思。例如:
template<typename T> class Derived:public Base<T>::Nested {//不需要typenamepublic:explicit Derived(int x):Base<T>::Nested(x)//不需要typename{typename Base<T>::Nested temp;//需要typename} }
43.学习处理模板化基类中的名称
1.编译器会拒绝在模板化基类当中寻找名称
假设我们要写一个程序,给不同公司发送消息,信息可以不做加工,也可以加密,我们可以采用template的写法:
class CompanyA{public:...void sendCleartext(const std::string& msg);void sendEncrypted(const std::string& msg);... } class CompanyB {public:...void sendCleartext(const std::string& msg);void sendEncrypted(const std::string& msg);... } ... class MsgInfo {...};template<typename Company> class MsgSender{public:...void sendClear(const MsgInfo& info){std::string msg;//根据info产生信息Company c;c.sendCleartext(msg);}void sendSecret(const MsgInfo& info){std::string msg;//根据info产生信息Company c;c.sendEncrypted(msg);} }
但是假如我们为MsgSender设计一个Derived class,下面的代码无法通过编译:
template<typename Company> class LoggingMsgSender: public MsgSender<Company> {public:...void sendClearMsg(const MsgInfo& info){//补充日志...sendClear(info);//本语句无法通过编译//补充日志...} }
原因在于LoggingMsgSender继承了MsgSender<Company>,但是Company是个template参数,所以编译器并不确定base class是否拥有一个叫做sendClear的方法,这有点奇怪,MsgSender不是定义了一个叫做sendClear的方法吗?这个方法应该跟template参数无关才对。但是编译器需要考虑特化的情况,假设CompanyZ坚持使用加密发送的方式,那么CompanyZ就不应该有sendClear的方法:
class CompanyZ{ public: ... void sendEncrypted(const std::string& msg); ... }
于是MsgSender对于CompanyZ就不合适了,因为MsgSender提供的sendClear方法内部调用了Company的sendCleartext,但是其实CompanyZ是没有该方法的。这时候我们需要针对CompanyZ产生一个特化版本:
template<> class MsgSender<CompanyZ> { public: ... void sendSecret(const MsgInfo& info) { ... } }
这个全特化版本制定了当template参数是CompanyZ是MsgSender应该具有的特化版本是什么。
所以对于LoggingMsgSender这个Derived class,编译器担心出现template参数是CompanyZ的情况,因为这种情况下,Base class直接就没有sendClear这个方法,所以编译器以防万一拒绝了本次调用,也就是说编译器会拒绝在模板化基类当中寻找名称。
2.解决方案
为了解决这个问题,有三种方案:
1.在调用前加上this->
template<typename Company> class LoggingMsgSender: public MsgSender<Company> {public:...void sendClearMsg(const MsgInfo& info){//补充日志...this->sendClear(info);//加上this->//补充日志...} }
sendClear是一个非从属名称,但是this在模板类中是一个隐式的从属名称,所以this->sendClear也是从属名称,编译器会推迟到对象被实例化才去查找sendClear。
2.使用using
template<typename Company> class LoggingMsgSender: public MsgSender<Company> {public:using MsgSender<Company>::sendClear;...void sendClearMsg(const MsgInfo& info){//补充日志...sendClear(info);//补充日志...} }
使用using告知编译器要使用MsgSender<Company>的sendClear后,原本不会再基类查找名称的编译器就会去查找
3.指出该名称就在base class中(不推荐)
template<typename Company> class LoggingMsgSender: public MsgSender<Company> {public:...void sendClearMsg(const MsgInfo& info){//补充日志...MsgSender<Company>::sendClear(info);//补充日志...} }
这么做会导致调用的如果是virtual函数,上述指定范围的行为会导致不进行动态绑定,所以不推荐
44.将和参数无关的代码抽离出template
当编写某个函数,其中的一部分实现和另一个函数相同时,较好的办法是把相同的部分抽出来放进一个单独的函数,再在两个函数中调用这个单独的函数。当编写某个class,这个class 的某些部分和其他class相同,较好的方法是把相同的部分放在一个新的类中,然后用复合或者继承的方式把令其他的class取用这些共同的特性,而保留互异的部分在原来的class中。但是在编写template的代码的时候,代码膨胀比编写函数或者一个普通的class是更加难以察觉的,尽管编写的代码看起来数量上没有膨胀,但是编译后根据template产生的类或者方法的目标码实际上有很多的重复部分。
假设正在写一个关于矩阵的template,该矩阵有一个方法用来进行逆矩阵计算:
template<typename T,std::size_t n> class SquareMatrix{public:...void invert(); }
其中T是模板类型参数,n是模板非类型参数,然后执行下述代码:
SquareMatrix<double,5> sm1; sm1.invert(); SquareMatrix<double,10> sm2; sm2.invert();
这份代码会具现化出两个invert方法,但这两个方法一个操作5 * 5矩阵,另一个操作10 * 10矩阵,除此之外没有两个函数没有任何区别,这也是一种难以察觉的代码膨胀。它并不体现在编写的代码上,而是编译产生的目标码上面。
解决方案如下:
template<typename T> class SquareMatrixBase { protected: ...void invert(std::size_t matrixSize); ... }; template<typename T,std::size_t n> class SquareMatrix:private SquareMatrixBase<T> {private:using SquareMatrixBase<T>::invert;//SquareMatrix对invert进行了重载,要防止编译器看不到SquareMatrixBase的invertpublic:...void invert() {this->invert(n);}//使用模板化基类的方法,加上了this }
带参数的invert处于base class中,和SquareMatrix一样,SquareMatrixBase也是个template,但是它只有一个模板类型参数,也就是只确定模板的元素对象是什么类型,而尺寸不进行参数化。对于固定类型的元素,所有的矩阵都共享一个SquareMatrixBase,也共享同一个SquareMatrixBase的invert,这样就避免了代码膨胀。两者之间是private继承,表明只是用SquareMatrixBase来实现SquareMatrix,两者不是is-a关系。使用函数参数或者成员变量来替代非类型template参数,可以有效减少代码膨胀。
类型template参数也会造成代码膨胀,比如说template分别为int和long实现了两个具现版本,尽管int和long在某些平台上有相同的二进制表述。更有代表性的情况是,对于指针而言,绝大多数平台上,所有指针类型都有相同的二进制表述,所以如果在某个template中操作强型指针(即T*),可以尝试让他们调用另外一个操作无类型指针(即void *)的函数,如果你确实打算阻止因为类型template参数导致的代码膨胀的话。像很多c++标准库的实现版本确实为vector,list,deque等template做了这些事。
45.运用成员函数模板接受所有兼容类型
智能指针指的是行为像指针的对象,他们还可以提供普通指针所没有的机能。比如auto_ptr可以自动释放资源,再比如STL容器内基本都使用智能指针作为迭代器,因为一个普通指针不可能通过“++”操作符转移到linked list的下一个节点,但是list::iterators就做得到。
不仅仅是STL提供的智能指针,有时候我们也会想要用代码自定义自己的智能指针,而普通指针是支持隐式转换的,比如从Derived Class转换成Base Class,从non-const转换成const。
class Top{...}; class Middle:public Top{...}; class Bottom:public Middle{...}; Top* pt1=new Middle;//Middle*转换为Top* Top* pt2=new Bottom;//Bottom* 转换为 Top* const Top* pct2=pt1;//Top* 转换为const Top*
但是如果想要在用户自定的指针中完成上面的转换,就比较麻烦:
template<typename T> class SmartPtr{public:explicit SmartPtr(T* realPtr);//智能指针通常以内置的原始指针完成初始化... }; SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle);//SmartPtr<Middle>转换为SmartPtr<Top> SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom);//SmartPtr<Bottom>转换为SmartPtr<Top> SmartPtr<const Top> pct2=pt1;//SmarPtr<Top>转换为SmartPtr<const Top>
我们想要上述代码能够成立,但是虽然Top,Middle,Bottom之间是public继承代表的is-a关系,但是以它们为模板参数完成的SmartPtr之间并不具备这种继承的关系。很显然,转型部分应该和构造函数这部分相关,我们需要重写构造函数,来让新的构造函数能够适应这些转型,但是另外一个很显然的事实是,我们不可能写出所有的构造函数,因为这意味着,一旦日后添加了新的Derived class,我们又需要重新为SmartPtr补充新的构造函数,可维护性会大大降低。我们应该做的不是重写构造函数,而是做一个类似的事情,为SmartPtr写一个构造模板:
template<typename T>class SmartPtr{ public:template<typename U>SmartPtr(const SmartPtr<U>& other); //member template,生成copy构造函数的模板 }
根据一个SmartPtr<U>生成一个SmartPtr<T>,两者是一个template的不同具现体,这种写法又叫做泛化copy构造函数。上述泛化copy构造函数并没有被声明为explicit,因为应当允许隐式的转换,就像普通指针所做的一样。
但是我们并不希望SmartPtr<Top>可以转化为SmartPtr<Bottom>,或者SmartPtr<double>可以转化为SmartPtr<int>,也就是不是任意的SmartPtr<U>都可以生成一个SmartPtr<T>,而是只有在U可以转换为T的情况下才允许SmartPtr<U>转换SmartPtr<T>,所以必须在泛化copy构造函数中创建的成员函数群进行一个筛选。
假设自定义的智能指针和auto_ptr或者tr1:share_ptr一样,提供一个get函数返回自己指向的对象,那么我们可以在构造模板的实现中实现对隐式转换的约束:
template<typename T> class SmartPtr {public:template<typename U>SmartPtr(const SmartPtr<U>& other):heldPtr(other.get())//以other的heldPtr初始化this的heldPtr{...}T* get() const {return heldPtr;}...private:T* heldPtr;//持有的内置原始指针 }
因为条款41提到的对于template而言,接口是隐式的,奠基于有效表达式,所以只有在heldPtr(other.get())是有效表达式,也就是存在某个隐式转换可以把U *转换成T *时,才能够通过编译。
一个泛化的copy构造模板函数并不影响编译器为class生成缺省的copy构造函数。所以如果你想全方位的掌控copy相关的方方面面,除了泛化的copy构造模板函数,最好再声明一个正常的copy构造函数,这个道理对copy assignment赋值操作符也是适用的。
46需要类型转换时为模板定义非成员函数
假设现在设计一个有理数类,但是设计为一个模板类,分子分母的类型不是int而是模板类型参数:
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) {...}
以下代码将会无法通过编译:
Rational<int> oneHalf(1,2); Rational<int> result=oneHalf *2;//无法通过编译
这是因为*操作符是一个模板函数,对于模板函数一般通过它的实参来推断模板参数类型,对于第一个参数oneHalf很容易推断出T是int,但是对于第二个参数2,编译器看不出来2对应的Rational<T>当中的T到底是什么类型。尽管2是可以隐式转换成一个Rational的,但是在template实参类型推导的过程中,编译器不会考虑隐式转换,这是因为此时函数本身都没有被具现化出来,编译器不确定T到底是什么。
解决方法是,把*操作符设计为一个friend函数而不是一个函数模板:
template<typename T> class Rational {public:...friendconst Rational operator* (const Rational& lhs,const Rational& rhs); }; template<typename T> const Rational<T> operator* (const Rational<T>& lhs,const Rational<T>& rhs) {...}
这样做能够行得通的原因是,当oneHalf被构造出来的时候,class Rational<int>已经根据模板具现化出来了,友元函数operator*(接受Rational<int>作为参数) 也随之被具现化出来,operator * 并不是一个函数模板而是一个随着class template被具现化而被确定的函数,所以能够接受隐式转换。
47.使用traits classes表现类型信息
1.STL的迭代器分类
STL的迭代器具有5种分类:
1.Input迭代器
只能向前移动,只能用来读取,且只能读取一次
2.Output迭代器
只能向前移动,只能用来写入,且只能写入一次
3.forward迭代器
只能向前移动,可以用来读取或者写入多次
4.Bidirectional迭代器
可以前后移动
5.random access迭代器
可以进行迭代器算数,一口气向前向后移动任意距离,类似指针算数一样
C++给5个分类提供专属的卷标结构加以确认,而且他们之间有继承关系:
struct input_iterator_tag{}; struct output_iterator_tag{}; struct forward_iterator_tag:public input_iterator_tag{}; struct bidirectional_iterator_tag:public forward_iterator_tag{}; struct random_access_iterator_tag:public bidirectional_iterator_tag{};
2.advance函数
STL中提供了一个模板方法,将迭代器向前或者向后移动若干个单位
template<typename IterT,typename DistT> void advance(IterT& iter,DistT d);//迭代器向前移动d个单位,d<0的话那么向后移动
对于非random_access的迭代器,我们只能在内部一次次调用iter++或者iter--,而对于random_access迭代器,我们可以使用迭代器算数一步到位。换句话说,理想的实现应该是这样的:
template<typename IterT,typename DistT> void advance(IterT& iter,DistT d) {if(iter is a random access iterator){iter+=d;//只针对random access iterator进行迭代器算数运算}else{if(d>=0){while (d--) ++iter;}else{while (d++) }-- iter;} }
所以我们必须知道iter是不是一个random access,要取得类型的某些信息。这正是traits 所做的:帮助用户在编译期间知道某些类型信息
3.traits
Traits并不是什么关键字或者一个预先定义好的构件,而是C++程序员共同遵守的定义。该技术的要求之一是,它对内置类型和用户自定义类型的表现必须一样好。举个例子,如果advance受到的实参是一个const char*和一个int,那么advance必须仍然可以有效运作,那意味着traits技术必须可以施行于内置类型比如指针上面。
“traits必须能够施行于内置类型"意味着”类型内的嵌套信息“这种东西出局了,因为我们无法把信息嵌套在原始指针里面。所以类型的traits信息必须位于类型自身的外面。标准技术是把它放在一个template以及它的一个或者多个特化版本中,这样的templates在标准程序库中有若干个,和迭代器相关的被命名为Iterator_traits:
template<typename IterT> struct iterator_traits;
习惯上traits总是被实现为struct,但是它们常常被称呼为traits classes(...)
iterator_traits的运作方式是,针对没有一个类型IterT,在struct iterator_traits<IterT>内声明某个typedef名为iterator_category.用来确认IterT的迭代器分类。
首先对于用户自定义的迭代器类型,要求必须嵌套一个typedef,名为iterator_category。比如说deque的迭代器可以随机访问,所以针对deque迭代器设计的class看起来是这个样子:
template<...>//参数略写 class deque{public:class iterator {public:typedef random_access_iterator_tag iterator_category;...};... };
对于list的迭代器:
template<...> class List {public:class iterator {public:typedef bidirectional_iterator_tag iterator_category;...};... }
而对于iterator_trait本身而言只需要响应iterator class的嵌套式typedef就好:
template<typename IterT> struct iterator_traits {typedef typename IterT::iterator_category iterator_category;... };
其次,对于原始指针,因为其属于内部类型,它不可能嵌套typedef,所以iterator_traits专门提供一个偏特化版本。
template<typename IterT> struct iterator_traits<IterT *>//对于内置指针的偏特化 {typedef random_access_iterator_tag iterator_category;//因为内置指针支持迭代器运算,所以分类为 random_access_iterator_tag... }
这样一来,可以实现之前的伪代码了:
template<typename IterT,typename DistT> void advance(IterT& iter,DistT d) { if(typeid(typename std::iterator::traits<Iter>::iterator_category)==typeid(std::random_access_iterator_tag)){iter+=d;//只针对random access iterator进行迭代器算数运算}else{if(d>=0){while (d--) ++iter;}else{while (d++) }-- iter;} }
但是这么写还是有缺点的,iter的类型早在编译期就确定了,但是判断却拖到运行期才执行,我们可以利用重载解决这个问题,让advance调用一个重载函数,重载函数真正完成对应的任务。
template<typename IterT ,typename DistT> void doAdvance(IterT& iter,DistT d,std::random_access_iterator_tag) {iter+=d; }template<typename IterT ,typename DistT> void doAdvance(IterT& iter,DistT d,std::bidirectional_iterator_tag) {if(d>=0){while(d--) ++iter;}else {while(d++) --iter;} }template<typename IterT ,typename DistT> void doAdvance(IterT& iter,DistT d,std::input_iterator_tag) {if(d<0){throw std::out_of_range("Negative distance");}while(d--) ++iter; }
然后再advance中调用重载函数:
template<typename IterT ,typename DistT> void advance(IterT& iter,DistT d,std::input_iterator_tag) {doAdvance(iter,d,typename std::iterator::traits<IterT>::iterator_category) }
48.认识模板元编程
Template metaprograming(TMP,模板元编程)可将工作有运行期移往编译器,因而得以实现早期错误侦测和更高的执行效率。条款47提到的traits就是TMP的实现之一。
TMP可被用来生成“基于政策选择组合”(based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不合适的代码。
八、定制new和delete
49了解new-handle的行为
1.什么是new-handler
当new无法分配足够的内存给对象的时候,它会抛出异常,在一些旧式的编译器上它会返回null
当operator new抛出异常以反映一个未获得满足的内存需求之前,它会先调用客户指定的错误处理函数,一个所谓的new-handle。
namespace std{typedef void (*new_handle)();new_handler set_new_handler(new_handler p) throw(); }
通过typedef,定义了一个指针指向无参数void方法称为new handle,下面set_new_handler的用途从名字上看也已经很明显了,是一份异常明细, throw()表明该函数不会抛出任何异常。
用法如下:
void OutOfMem()//用来被调用的函数 {std::cerr<<"Unable to satisfy request for memory\n";std::abort(); } int main() {std::set_new_handler(outOfMem);int* pBigDataArray = new int[10000000000L]; }
那么假设系统无法给pBigDataArray分配那么巨大的内存,就会在输出一条语句后直接夭折。
2.new-handler应该具备的特质
但一个合格的new-handler不应该只输出一条语句,还应该有如下功能:
1.分配更多内存
这会让operator new的下一次分配内存的操作更有可能会成功。实现的其中一个方法是,程序一开始就分配一大片的内存,当new-hanlder第一次调用,把这些内存释放还给程序使用。
2.安装另一个new-handler
如果当前new-handler无法取得更多的内存,或许它知道另外哪个方法有这样的能力。那他应该通过set_new_handler替换掉自身,这样下一次new-handler执行的饿时候,将会调用最新安装的new-handler
3.卸除new-handler
也就是把null指针传给set_new_handler。一旦没有安装任何new-handler,operator new 会在内存分配不成功时抛出异常
4.抛出bad-alloc(或者派生自bad_alloc)的异常
这样的异常不会被operator new 捕捉,因此会被传播到内存索求处
5.不返回
通常调用abort或者exit
这些选择会让你在实现new-handler函数时拥有很大的弹性。
3.为class准备专属的new-handler
C++并没有提供对class专属的new-handler的支持,不过没有关系,我们可以自己实现出这种行为。
首先,我们需要准备一个资源管理对象,它能够通过析构函数自动释放掉自己的内存.
class NewHandlerHolder {public:explicit NewHandlerHolder(std::new_handler nh):handler(nh){}//成员初始化比构造函数更早,先把~NewHandlerHolder(){std::set_new_handler(handler);}//在析构函数中把global-new-handler还原private:std::new_handler handler;//禁止copying(见条款14)NewHandlerHolder(const NewHandlerHolder&); NewHandlerHolder& operator=(const NewHandlerHolder&); }
然后在对应的类中定义一个static的new_handler
class Widget{public:static std::new_handler set_new_handler(std::new_handler p) throw();static void* operator new(std::size_t size) throw(std::bad_alloc);private:static std::new_handler currentHandler; }
对于class内部的set_new_handler的实现,把它参数上的指针储存起来,并返回调用之前储存的指针,标准版set_new_handler也是这么做的
std::new_handler Widget::set_new_handler(std::new_handler p) throw() {std::new_handler oldHandler = currentHandler;currentHandler = p;return oldHandler; }
然后实现对应class 的operator new:
void* Widget::operator new(std::size_t size) throw(std::bad_alloc) {NewHandlerHolder h(std::set_new_handler(currentHandler));/*上述语句把global-new-handler设置为currentHAandler,但是把global-new-handler被设置之前的传递给NewHandlerHolder保存*/return ::operator new(size);/*如果这里内存不足,由于global-new-handler被设置为currentHAandler,会执行currentHAandler方法结束后调用NewHandlerHolder析构函数把gloabal-new-handler还原为之前保存的方法*/ }
后面作者还讲了一个设置模板基类的例子,但是我不太喜欢多重继承,以后再了解吧...
4.关于no-throw
之前提到,当new时发现内存不足,有的编译器会抛出异常,但早期的编译器会返回null。C++在规范确定抛出异常之前,已经有很多C++程序被写出来了,C++标准委员会不想抛弃这些“侦测是否为null”的群体,因此提供了另一形式的operator new来供应传统的“返回null”的行为。该形式被称为nothrow形式,因为他们使用了nothrow对象(定义于头文件<new>)
class Widget{...}; Widget* pw1=new Widget;// 如果分配失败,抛出bad_alloc if(pw1==0)...//该测试一定失败 Widget* pw2=new (std::nothrow) Widget;// 如果分配失败,抛出bad_alloc if(pw2==0)...//该测试可能会成功
但是no-throw对异常的强制保证性并不高,不建议使用
50.了解new和delete的合理替换时机
1.替换的理由
为什么要替换掉编译器自己提供的new和delete呢?有如下三种理由:
1.检测运用上的错误
各式各样的编程错误可能导致数据的“overrun”(写入点在分配区块尾端之后),或者“underruns"(写入点在分配区块起点之前)。如果自定义一个operator news,便可以超额分配内存,以额外的空间防止特定的byte patterns签名。operator delete便可以检查上述签名是否原封不动,如果为否则表示发生了overrun或者underrun,这个时候operator delete可以志记(log)那个事实以及那个惹是生非的指针。
2.强化效能
编译器所带的operator new和operator delete用于一般目的,他们不但可以被长时间执行的程序(比如web servers)接受,也可以被执行时间少于一秒的程序接受。他们必须处理一系列需求,包括大块内存,小块内存,大小混合型内存。它们必须接纳各种分配形态,范围从程序存货期间的少量区块动态分配,到大量短命对象的持续分配和归还。他们必须考虑碎片问题(fragmentation),这回导致程序无法满足大区块内存要求,即使彼时有总量足够到那时分散为许多小区块的自由内存。
这导致编译器自带的operator news和operator delete的表现在各种情况下都适度的好,但不对特定任何场景有最佳表现。定制版的operator new和operator delete性能一般胜过缺省版本,特定情况可能会快很多,并且需要的内存也会少最多50%。
3.收集使用上的统计数据
在定制news和deletes之前,需要先收集统计数据。程序如何使用其动态内存?分配区块的大小分布如何?寿命分布如何?他们倾向于先进先出还是后进先出或者随机次序来分配和归还?他们的运用形态是否随着程序运行的时间而改变?任何时候所使用的最大动态分配量(高水位)是多少?自行定义operator new和operator delete使我们可以轻松收集到这些信息。
2.如何替换
下面是一个促进并协助检测overrun或者underrun的被替换的operator new。其中还有不少小问题,我们稍后完善它:
static const int signature = 0xDEADBEEF; typedef unsigned char Byte; //下面的代码还有若干小错误 void* operator new(std::size_t size) throw(std::bad_alloc) {using namespace std;size_t realSize=size+2*sizeof(int);//添加2个signature的大小以超额分配内存void* pMem = malloc(realSize);if(!pMem) throw bad_alloc();//把signature写入内存的最前段落和最后段落*(static_cast<int*>(pMem))=signature;*(reinterpret_cast<int *> (static_cast<Byte*>(pMem) +realSize-sizeof(int))) =signature;return static_cast<Byte*>(pMem)+sizeof(int); }
这个operator new最大的问题就是没有遵守接下来的条款51所说的在new中循环地调用new_handler,即便抛开这个问题,仍然有一个关于齐位(alignment)的问题没有解决。
许多计算机体系结构要求特定的类型必须放在特定的内存地址上面。比如它可能要求指针的地址必须是4倍数或者doubles的地址必须是8倍数。如果不奉行这样的约束条件,有可能导致运行期硬件异常。部分体系结构没有那么强硬,但是如果齐位条件得到满足,他们的效率会更高,比如Intel x86.
C++要求所有的operator new 返回的指针都应该适当对齐(取决于数据类型)。malloc就是在这种要求下工作,所以operator new如果单纯返回一个来自malloc的指针时安全的。但是上述代码中返回的地址在malloc提供的地址基础上还偏移了一个int的大小,假设程序正处在一个“ints地址必须是4倍数,而double地址必须是8倍数)的机器上并试图new 一个double元素,就可能导致不符合预期的情况。
像齐位这样的技术细节,正可以区分出有专业质量的内存管理器。所以很多时候写一个自定义new和delete是非必要的。某些编译器已经在它们的内存管理函数中切换到调试状态和志记状态。快速浏览一下你的编译器文档,你可能会就此消除自行撰写new和delete的需要。许多平台上已经有商业产品可以替代编译器自带的内存管理器。如果需要它们来为你的程序提高技能和改善效率,只需要进行relink,当然前提是买下它们,或者到开源社区寻找合适的内存管理器。
51.编写new和delete时需要固守常规
operator new里面应该包含一个无穷的循环,不断尝试调用new-handler直到分配内存成功、申请了更多内存、handler被替换或者被卸除、抛出bad-alloc异常或派生物当中其中任意一件事情发生。
C++规定operator new即便是申请0byte内存的情况也必须返回一个合法指针,常用的伎俩是在申请0byte的时候改成申请1byte。::operator new也就是标准operator new有责任以某种合理方式处理0byte的情况。
当自定义operator new之后,它会被其他的derived class 继承,比如
class Base{public:stataic void* operator new(std::size_t size) throw(std::bad_alloc);... }; class Derived:public Base {...};//假设Derived 没有声明operator new Derived* p=new Derived;//调用的是Base::operator new
针对Base这个class设计的operator new,很可能其行为只是适合大小为size(X)的对象而设计,而不适合其他任何的Derived class。在这种情况下,常见的做法就是在operator new中检查申请内存量是不是符合预期,如果不是调用标准 operator new来解决。
void* Base::operator new(std::size_t size) throw(std::bad_alloc) {if(size != sizeof(Base))return ::operator new(size);//调用标准operator new... }
对于operator new [],你唯一能做的就是分配给它一块未加工的事情。因为你甚至不知道这个数组有多少元素,也不知道每个元素的大小,因为可能申请的是base class的operator new[]有可能被继承调用,实际上装的是derived class,而derived class一般来说比base class 更大,假设每个元素大小是sizeof(Base)是不正确的,另外传递给operator new【】 的size_t参数也未必是要分配的元素内存之和,还可能包括一个存储元素数量信息的空间。
对于operator delete相对比较简单,只需要知道C++要求删除null指针永远是安全的,伪代码比如:
void operator delete(void * rawMemory) throw() {if(rawMemory=0) return;//如果delete一个null指针,什么都不做... }
这个函数的member版本也比较简单,和operator new原理类似,也需要检查请求释放的内存量是否符合预期,否则调用::operator delete也就是标准operator delete:
void Base::operator delete(void* rawMemory,std::size_t size) throw() {if(rawMemory == 0) return;//检查null指针if(size!= sizeof(Base)){::operator delete(rawMemory);return;}...return; }
52.写了placement new也要写上对应的placement delete
1.什么是placement new和placement delete
当你执行如下语句的时候:
Widget* pw=new Widget;
有两个语句会被执行,首先是operator new,其次是Widget的default构造函数
假设operator new执行成功,default构造函数却抛出了异常,那么operator new分配的内存必须得到释放,而这个释放的任务不可能交给用户,因为构造函数抛出异常后,pw尚未被赋值,用户无法操控分配的内存,所以释放责任要落实到C++运行期系统上。
运行期系统会尝试调用operator new对应的operator delete版本,但是前提是他需要知道对应的operator delete版本是哪一个?对于正常签名式的operator new和operator delete,很容易找到对应的版本:
void* operator new(std::size_t) throw(std::bad_alloc);//正常operator new void operator delete(void* rawMemory) throw();//global作用域中的正常签名式 void operator delete(void* rawMemory,std::size_t size) throw();//class作用域中典型的签名式
然而,当你开始声明非正常的operator new,也就是带有其他参数的operator new,编译器就会迷惑应该使用哪个operator delete才能够正确释放内存。
举个例子,假设一个类写了非正常的operator new,同时又写了一个正常的operator delete:
class Widget { public: ...//非正常的newstatic void* operator new(std::size_t size,std::ostream& logStream) throw(std::bad_alloc);//class作用域中正常的deletestatic void operator delete(void* pMemory ,std::size_t size); }
可以看到Widget中的operator new额外接受了一个std::ostream来处理志记信息,这种除了应有的std::size_t之外还有其他参数的operator new就是所谓的placement new。
最出名的placement new已经被纳入C++标准程序库中,只要#include <new> 即可使用它,声明如下:
void* operator new(std::size_t,void* pMemory) throw();
那个额外参数“指向对象该被构造之处”,这个placement new 的用途之一是负责在vector的未使用空间上面创建对象,是最早的placement new 版本。这也是为什么这种operator new被称作placement new,因为它是“一个特定位置上的new”。狭义上的placement new特别指的就是这个带有额外一个void* 指针的参数,但是广义上的placement new指的是接受额外任意实参的operator new。
回到上面这个例子,编译器会寻找参数和new额外参数相一致的operator delete如果没有找到,则不会执行任何operator delete,换句话说,编译器寻找的delete应该是这样的:
void operator delete(void* pMemory,std::ostream& logStream) throw()
这种有额外参数的operator delete也就是placement delete。placement delete只会在“伴随着对应的placement new被触发的构造函数”异常时才会被调用,换句话说如果直接执行 delete pw,调用的仍然是正常形式的operator delete。
条款33提到过,成员函数的名称会掩盖其外围作用域中的相同名称,你必须小心在非故意的情况下让class专属的news掩盖住可能期望的其他news。
九、杂项讨论
53.不要忽略编译器的警告
不要习惯性的忽略编译器警告。很多人认为如果问题足够严重,编译器应该发出一个错误信息而非警告,这种观点在其他语言可能奏效,但是在C++中,可以打赌编译器作者对将要发生的事情有比你更加好的领悟。比如下面这个例子:
class B{public:virtual void f() const; }; class D:public B{public:virtual void f(); }
B::f是一个const函数,而D::f则不是。部分编译器可能会有如下警告:
D::f() hides virtual B::f()
经验不足的程序员对这个信息的反应是:“哦当然,D::f遮掩了B::f,这很正常”实际上是编译器在提醒你声明在B中的f并没有在D中被重新声明,而是被整个遮掩了(条款33详细说明了原因)。如果忽略这个警告,几乎肯定导致程序的错误行为,然后是很多调试行为,只是为了找出编译器其实早就侦测出来并告诉你的事情。
54.让自己熟悉包括tr1在内的多种程序库
1.TR1和C++的历史
C++Standard——定义C++语言及其标准程序库的规范——早在1998年就被标准委员会核准了。标准委员会在2003年发布了一个不那么重要的“错误修正版”,并且预计在2008年左右发布C++Standard2.0。日期上的不确定性让人们总是称呼下一版C++为“C++0x",意指200x版本的C++。
C++0x或许会覆盖某些有趣的语言新特性,但是大部分新技能都将会以标准程序库的扩充形式体现。如今我们已经能够知道某些新的程序库机能,因为它被详细叙述于一份称为TR1的文档内。TR1代表“Technical Report 1",那是C++程序库工作小组对该份文档的称呼。标准委员会保留了TR1被证实铭记于C++0x之前的修改权,不过目前已经不可能再接受任何重大改变了。就所有意图和目标而言,TR1宣称了一个新版C++的来临,我们也许可以称之为Standard C++ 1.1。不熟悉TR1技能而奢望称为一位高效率的C++程序员是不可能的,因为TR1提供的机能几乎对每一种程序库和每一种应用程序都带来利益。
2.C++ Standard1.0标准程序库的组成
1.STL(Standard Template Library,标准模板库)
覆盖容器(如vector,stirng,map),迭代器(iterators),算法(如find,sort,transfoem),函数对象(如 less,greater),各种容器适配器(如stack,priority_queue)和函数对象适配器(如mem_fun,not1)
2.Iostreams
覆盖用户自定缓冲功能、国际化I/O,以及预先定义好的对象如cin,cout,ceer,clog
3.国际化支持
包括多区域能力。比如wchar_t(通常是16bits/char)和wstring(由wchar_ts组成的strings)等类型都对促进Unicode有所帮助
4.数值处理
包括复数模板(complex)和纯数值数组(valarray)
5.异常阶层体系
包括作为base class的exception以及作为derived classes的logic_error和runtime_error,以及更深继承的各个classes。
6.C89标准程序库
1989年C标准程序库内的每个东西也都被覆盖于C++内。
3.TR1的组成
1.智能指针(smart pointers)
1.tr1::shared_ptr
如同内置指针一样工作,但会记录有多少个tr1::shared_ptr指向同一个对象,一旦最后一个这样的指针被销毁,即某对象的引用次数变成0时,自动销毁该对象。但是对于环形数据结构依然会造成资源泄漏
2.tr1::weak_ptr
不参与引用计数的计算,也就是说当最后一个tr1::shared_ptr被销毁,即便还有若干个tr1::weak_ptr指向它,该对象依然会被删除。
2.tr1::function
用来表示任何callable entity(可调用物,任何函数或者函数对象),只要其签名符合目标。比如说我们像注册一个callback函数,该函数接受一个int并且返回一个string,我们可以这么写:
void registerCallback(std::string func(int));
其中参数名称func是可有可无的,上述代码也可以这么声明
void registerCallback(std::string (int));
此处的std::string (int)是一个函数签名,tr1::function使上述的RegisterCallback有可能更加富弹性的接受任何可调用物,只要这个可调用物接受一个int或者任何可悲转换为int的东西
void registerCallback(std::tr1::function<std::string(int)> func)
3.tr1::bind
它能够做STL绑定器(binders)bind1st和bind2nd所做的每一件事情,而且还可以做的更多。和前任绑定器不同的是tr1::bind可以和const及non-const成员函数共同协同运作,可以和by-reference参数协同运作。而且它不需要特殊协助就看可以处理函数指针,所以我们调用tr1::bind之前不必再被ptr_fun,mem_fun或者mem_fun_ref搞得一团糟了。简单来说tr1::bind是第二代绑定工具,并且比前一代好用多了
剩下的tr1组件可以被分为两组。第一组提供彼此互不相干的独立机能:
4.Hash tables
用来实现sets,multisets,maps和multi-maps。每个新容器的接口都以其前任(TR1之前的)对应容器塑膜而成.如它们的名称所示:tr1::unordered_set,tr1::unordered_multiset,tr1::unordered_map,tr1::unordered_multimap。这些名称强调了以hash为基础的这些tr1容器内部并没有任何可预期的次序。
5.正则表达式
包括以正则表达式为基础的字符串查找和替换,或者从某个匹配字符串到另一个匹配字符串的注意迭代等等。
6.Tuples
这是标准程序库中pair的新一代制品。pair只能持有两个对象,但是tr1::tuple可以持有任意个数的对象。
7.tr1::array
本质上是一个“STL化”数组,也就是支持成员函数比如begin和end的数组。不过它的大小是固定的,并不适用动态内存
8.tr1::mem_fn
语句构造上与成员函数指针一致的东西。如同tr1::bind纳入并扩充了C++98的bind1st和bind2nd的能力一样,tr1::mem_fn纳入并扩充了C++98的mem_fun和mem_fun_ref的能力
9.tr1::reference_wrapper
一个“让references的行为更像对象”的设施。它可以造成容器“犹如持有references”,而你知道容器实际上只能持有对象或者指针。
10.随机数生成工具
它大大超越了C++从C标准程序库继承而来的rand
11.数学特殊函数
包括Laguerre多项式,Bessel函数,完全椭圆积分等等更多数学函数
12.C99兼容扩充
这是一大堆函数和模板(templates),用来把许多新的C99程序库特性带进C++。
第二组TR1组件由更加精巧的template技术(包括template metaprogramming,也就是模板元编程构成)
13.type traits
见条款47。可以指出T是否是个内置类型,是否提供virtual函数,是否是个empth class,是否可以隐式转换成类型U...等等,也可以显现给该给定类型之适当齐位,这对于定制型内存分配器的编写人员是十分关键的信息
14.tr1::result_of
这是一个template,用于推到函数调用的返回类型。当我们编写templates时,能够指涉(refer to)函数或者函数疤调用动作所返回的对象的类型往往很重要,但是该类型有可能以复杂的方式取决于函数的参数类型。tr1::result_of是的“指涉函数返回类型”变得失分容易。他自己也被TR1自身的若干组件采用
55.让自己熟悉Boost
Boost是一个C++开发者集结的社群,也是一个可自由下载的C++程序库群。它的网址是Boost C++ Libraries。现在你应该把它设为你的桌面数钱之一。
对比其他C++组织和网站,Boost有两件事是其他任何组织不能相比的。第一,他和C++标准委员会之间有独一无二的密切关系,并且对委员会深具影响力。因为Boost由委员会成员创设。所以Boost成员和委员会成员有很大的重叠。Boost的目标是作为一个“可做加入标准C++各种功能”的测试场。以TR1提案进入标准C++的14个新程序库中,超过三分之二奠基于Boost的工作成果。第二,它接纳程序库的过程要经过“讨论、琢磨、再次提交”的重重检查。
我的Effective C++读书笔记相关推荐
- Effective C++读书笔记 摘自 pandawuwyj的专栏
Effective C++读书笔记(0) Start 声明式(Declaration):告诉编译器某个东西的名称和类型,但略去细节. std::size_t numDigits(i ...
- Effective Java读书笔记(二)
Effective Java 读书笔记 (二) 创建和销毁对象 遇到多个构造器参数时要考虑使用构建器 创建和销毁对象 何时以及如何创建对象? 何时以及如何避免创建对象? 如何确保它们能够适时地销毁? ...
- Effective STL 读书笔记
Effective STL 读书笔记 标签(空格分隔): 未分类 慎重选择容器类型 标准STL序列容器: vector.string.deque和list(双向列表). 标准STL管理容器: set. ...
- more effective c++和effective c++读书笔记
转载自http://bellgrade.blog.163.com/blog/static/83155959200863113228254/,方便日后自己查阅, More Effective C++读书 ...
- Effective Java 读书笔记(七):通用程序设计
Effective Java 读书笔记七通用程序设计 将局部变量的作用域最小化 for-each 循环优于传统的 for 循环 了解和使用类库 如果需要精确的答案请避免使用 float 和 doubl ...
- Effective Java读书笔记完结啦
Effective Java是一本经典的书, 很实用的Java进阶读物, 提供了各个方面的best practices. 最近终于做完了Effective Java的读书笔记, 发布出来与大家共享. ...
- Effective Java 读书笔记(一)
前言: 开个新的坑位,<effective java>的读书笔记,之后有时间会陆陆续续的更新,读这本书真的感触满多,item01和item02就已经在公司的项目代码中看到过了.今天这篇主要 ...
- Effective C++ 读书笔记 Item1-Item4
目录 守则01:把C++看做一个语言的集合,而不是单一的语言 守则02:尽量使用const, enum, inline, 减少宏变量#define的使用 守则03: 尽可能使用const关键字 守则0 ...
- Effective C++读书笔记(一)
百度博客本来就垃圾,我以前发表的文章也全是废品.就在csdn这里放上我的读书笔记,自娱自乐下. 1 让自己习惯C++ 条款01:视C++为一个语言联邦 C语言同时支持过程形式(procedural). ...
- 【effective c++读书笔记】【第7章】模板和泛型编程(3)
条款46:需要类型转换时请为模板定义非成员函数 对条款24的例子进行模板化: #include<iostream> using namespace std;template<type ...
最新文章
- 如何将RDS的数据同步到本地自建数据库
- Spark源码分析之九:内存管理模型
- python3 byte int string 互转 转换
- 【转】Hibernate数据过滤
- 利用rpm包搭建lamp环境及论坛的创建
- STL11-stack容器
- Ubuntu 16.04升级Linux内核为4.7.0最快的方法
- Nginx配置SSL证书部署HTTPS网站
- esp8266教程:定时器之PWM
- 软件推荐之 QttabBar
- python 会议室预约系统解决方案_快思聪FUSION会议预约系统
- 蓝桥杯题库及答案python版_蓝桥杯试题库的历届真题版.doc
- bootice添加linux_使用BOOTICE 恢复系统启动项
- IOS企业应用出现无法验证,需要网络连接以在这台iPad上验证。接入互联网并重试
- oracle 删掉同义词,【oracle删除同义词】作文写作问答 - 归教作文网
- 微信小程序使用Socket
- go语言中同一个package下的文件相互引用怎么做?
- 【转】跨终端实践-天猫试戴的解决方案
- 什么是Nofollow
- [Redux/Mobx] redux的数据存储和本地储存有什么区别?