Effective C++ 总结(三)
五.实现
条款26:尽可能延后变量定义式的出现时间
如果你定义了一个变量且该类型带一个构造函数或析构函数,当程序到达该变量时,你要承受构造成本,而离开作用域时,你要承受析构成本。为了减少这个成本,最好尽可能延后变量定义式的出现时间。举例说明:
string encryptPassword(const string& password) { string encrypted; //(1) if (password.length() < MINIMUM_PASSWORD_LENGTH) { throw logic_error("Password is too short"); } //进行必要的操作,将口令的加密版本放进encrypted之中; string encrypted; //(2) return encrypted; }
encrypted应该在(2)处定义,因为如果在(1)处定义,如果抛出异常,那么encrypted的构造和析构还是要执行,浪费系统资源!
通过默认构造函数构造出一个对象然后对它赋值”比“直接在构造函数时指定初值”效率差。“尽可能延后”的真正意义应该是:你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。
//方法A:定义循环外 Widget w; for (int i = 0; i < n; ++i) { w = some value dependent on i; ... }//1个构造函数+1个析构函数+n个赋值操作; //方法B:定义循环外 for (int i = 0; i < n; ++i) { Widget w(some value dependent on i); ... }//n个构造函数+n个析构函数
除非:1.你知道赋值成本比“构造+析构”成本低;2.你正在处理代码中效率高度敏感的部分,否则应该使用方法B。
请记住:
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
条款27:尽量少做转型动作
C++规则的设计目标之一是,保证“类型错误”绝不可能发生。不幸的是,转型(casts)破坏了类型系统。那可能导致任何种类的麻烦,有些容易辨识,有些非常隐晦。
C风格的转型动作看起来像这样:
(T)expression //将expression转型为T
函数风格的转型动作看起来像这样:
T(expression) //将expression转型为T
C++还提供四种新式转型:
const_cast:通常被用来将对象的常量性转除;即去掉const。
dynamic_cast:主要用来执行“安全向下转型”,即:基类指针/引用到派生类指针/引用的转换。如果源和目标类型没有继承/被继承关系,编译器会报错;否则必须在代码里判断返回值是否为NULL来确认转换是否成功。有条件转换,动态类型转换,运行时类型安全检查(转换失败返回NULL)
reinterpret_cast:意图执行低级转型,将数据从一种类型的转换为另一种类型,也就是说将数据以二进制存在的形式进行重新解释。实际动作可能取决于编译器,这也就表示它不可移植。
static_cast:用来静态类型转换,强制类型转换,运行时不做类型检查,因而可能是不安全的。例如将non-const转型为const,int转型为double等等。 static_cast也可以进行基类和子类之间转换:其中子类指针转换成父类指针是安全的;但父类指针转换成子类指针是不安全的。(基类和子类之间的动态类型转换建议用dynamic_cast)
upcast:Just same as dynamic_cast. 由于不用做runtime类型检查,效率比dynamic_cast高;
downcast:不安全。不建议使用。
#include <iostream> using namespace std; class Base { public: virtual int foo(){ cout<<"Base"<<endl; return 0; }; }; class Derived:public Base { public: int foo(){ cout<<"Derived"<<endl; return 0; } }; int main() { Base *b=new Base; Derived *d1=static_cast<Derived*>(b); d1->foo(); // 正确 Derived *d2=dynamic_cast<Derived*>(b); d2->foo(); //错误 }
尽量使用新式转型:
- 它们很容易在代码中被辨识出来,因而得以简化“找出类型系统在哪个地点被破坏”的过程。
- 各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。
请记住:
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
- 宁可使用C++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的执掌。
条款28:避免返回 handls 指向对象内部成分
class Point { public: Point(int x, int y); void SetX(int newVal); void SetY(int newVal); private: int x_cor; int y_cor; }; struct RectData { Point ulhc; // 矩形左上角的点 Point lrhc; // 矩形右下角的点 }; class Rectangle { private: shared_ptr<RectData> pData; public: Point& upperLeft()const{ return pData->lrhc; } Point& lowerRight()const{ return pData->ulhc; } };
上面的代吗中Point是表示坐标系中点的类,RectData表示一个矩形的左上角与右下角点的点坐标。Rectangle是一个矩形的类,包含了一个指向RectData的指针。
我们可以看到了uppLeft和lowerRight是两个const成员函数,它们的功能只是想向客户提供两个Rectangle相关的坐标点,而不是让客户修改Rectangle。但是两个函数却都返回了references指向了private内部数据,调用者于是可以通过references更改内部数据。
这给了我们一些警示:成员变量的封装性只等于“返回其reference”的函数的访问级别;如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。
handles(号码牌,用于取得某个对象)指reference、指针和迭代器,它们返回一个“代表对象内部数据”的handle。
解决方法:我们可以对上面的成员函数返回类型上加上const
public: const Point& upperLeft()const{ return pData->lrhc; } const Point& lowerRight()const{ return pData->ulhc; }
但是函数返回一个handle代表对象内部成分还总是危险的,因为可能会造成dangling handles(空悬的号牌)。比如某个函数返回GUI对象的外框(bounding box)。
class GUIObject{ //.. }; const Rectangle boundingBox(const GUIObject &obj); GUIObject* pgo; const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
对boundingBox返回的是一个新的,暂时的Rectangle对象temp,而pUpperLeft指向的是temp的Points。
当const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());执行完之后,temp将会被销毁,从而导致temp内的Points析构,最终将导致pUpperLeft 指向一个不存在的对象,从而造成悬空、虚吊。
请记住:
- 避免返回handles(包括reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”(dangling handles)的可能性降至最低。
条款30:透彻了解inlining的里里外外
Inline(内联)函数,多棒的点子!它们看起来像函数,动作像函数,比宏好得多,可以调用它们又不需蒙受函数调用所招致的额外开销。你实际获得的比想象的还多,编译器有能力对执行语境相关最优化。然而编写程序就像现实生活一样,没有白吃的午餐。inline函数也不例外,这样做可能增加你的目标码。
如果 inline 函数的本体很小,编译器针对“函数本体”所产生的码可能比针对“函数调用”所产出的码更小。果真如此,将函数inlining确实可能导致较小的目标码和较高的指令高速缓存装置击中率。
记住,inline 只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于class定义式内,这样的函数通常是成员函数,friend函数也可被定义于class内,如果真是那样,它们也是被隐喻声明为inline。明确声明inline函数的做法则是在其定义式钱加上关键字inline。
Inline函数通常一定被置于头文件内,因为大多数建置环境(building environment)在编译过程中进行inlining,而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样子。
Template通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。
Template的具现化与inlining无关。如果你正在写一个template而你认为所有根据此template具现出来的函数都应该inlined,请将此template声明为inline;但如果你写的template没有理由要求它所具现的每一个函数都是inlined,就应该避免将这个template声明为inline。
一个表面上看似inline的函数是否真实inline,取决于你的建置环境(building environment),主要取决于编译器。
有的时候虽然编译器有意愿inlining某个函数,还是可能为该函数生成一个函数本体(函数指针,构造函数,析构函数)。比如,当程序要取某个inline函数的地址,编译器通常必须为此函数生成一个oulined函数本体,因为编译器没有能力让一个指针指向一个并不存在的函数。
inline void f(){..} void (*pf)()=f; .. f(); //这个将被inline pf(); //这个或许不被inline,因为它通过函数指针完成
对程序开发而言,将上述所有考虑牢记在新很是重要,但若从纯粹实用观点出发,有一个事实比其它因素更重要:大部分调试器面对inline函数都束手无策。
这使我们在决定哪些函数该被声明为inline而哪些函数不该时,掌握一个合乎逻辑的策略。一开始先不要将任何函数声明为inline,或至少将inlining施行范围局限在那些“一定成为inline”或“十分平淡无奇”的函数身上。
总结:
1. inline函数的调用,是对函数本体的调用,是函数的展开,使用不当会造成代码膨胀。
2. 大多数C++程序的inline函数都放在头文件,inlining发生在编译期。
3. inline函数只代表“函数本体”,并没有“函数实质”,是没有函数地址的(内联优化从目标文件中去掉了该函数的入口点,符号表中也没有该函数的名称)。
4. inlining在大多数编译器中编译期行为, inline 函数无法随着程序库的升级而升级。换句话说如果f 是程序库内的一个inline 函数,客户将”f 函数本体”编进其程序中,一旦程序库设计者决定改变f ,所有用到f 的客户端程序都必须重新编译。但如果f是non-inline函数,当修改f后,客户端只需要重新连接就好了。
值得注意的是:
1. 构造函数与析构函数往往不适合inline。因为这两个函数都包含了很多隐式的调用,而这些调用付出的代价是值得考虑的。可能会有代码膨胀的情况。
2. inline函数无法随着程序库升级而升级。因为大多数都发生在编译期,升级意味着重新编译。
3. 大部分调试器(VS2010可以)是不能在inline函数设断点的。因为inline函数没有地址。
请记住:
- 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,是程序的速度提升机会最大化。
- 另外,对function templates的inline也要慎重,保证其所有实现的函数都应该inlined后再加inline。
条款31:将文件间的编译依存关系降至最低
这个问题产生是源于希望编译时影响的范围尽量小,编译效率更高,维护成本更低,这一需求。
实现这个目标首先第一个想到的就是,声明与定义的分离,用户的使用只依赖声明,而不依赖定义(也就是具体实现)。
但C++的Class的定义式却不仅仅只有接口,还有实现细目(这里指实现接口需要的私有成员)。而有时候我们需要修改的通常是接口的实现方法,而这一修改可能需要添加私有变量,但这个私有变量对用户是不应该可见的。但这一修改却放在了定义式的头文件中,从而造成了,使用这一头文件的所有代码的重新编译。
于是就有了pimpl(pointer to implementation)的方法。用pimpl把实现细节隐藏起来,在头文件中只需要一个声明就可以,而这个poniter则作为private成员变量供调用。
这里会有个有意思的地方,为什么用的是指针,而不是具体对象呢?这就要问编译器了,因为编译器在定义变量时是需要预先知道变量的空间大小的,而如果只给一个声明而没有定义的话是不知道大小的,而指针的大小是固定的,所以可以定义指针(即使只提供了一个声明)。
这样把实现细节隐藏了,那么实现方法的改变就不会引起别的部分代码的重新编译了。而且头文件中只提供了impl类的声明,而基本的实现都不会让用户看见,也增加了封装性。
结构应该如下:
class PersonImpl; class Person { public: ... private: std::tr1::shared_ptr<PersonImpl> PersonImpl; };
这一种类也叫handle class
另一种实现方法就是用带factory函数的interface class。就是把接口都写成纯虚的,实现都在子类中,通过factory函数或者是virtual构造函数来产生实例。
声明文件时这么写:
class Person { public: static shared_ptr<Person> create(const string&, const Data&, const Adress&); };
定义实现的文件这么写
class RealPerson :public Person { public: RealPerson(...); virtual ~RealPerson(){} //... private: // ... };
有了RealPerson之后,就可以写出Person::create函数了:
std::tr1::shared_ptr<Person> Person::create(const string& name const Data& birthday <span style="white-space:pre"> </span> const Address& adrr) { return std::tr1::shared_ptr<Person>(new RealPerson(name,birthday,addr)); }
请记住:
- 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classed和Interface classes。
- 程序库头文件应该以“完全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及templates都适用
六.继承与面向对象设计
条款32:确定你的public继承塑模出is-a关系
以C++进行面向对象编程,最重要的一个规则是:public inheritance(公有继承)意味is-a(是一种)的关系。
如果你令class D以public形式继承class B,你便是告诉C++编译器(以及你的代码读者)说,每一个类型为D的对象同时也是一个类型为B的对象,反之不成立。你的意思是B比D表现出更一般化得概念,而D比B表现出更特殊化的概念。你主张:“B对象可派上用场的任何地方,D对象一样可以派上用场”,因为每一个D对象都是一种(是一个)B对象。反之如果你需要一个D对象,B对象无法效劳,因为虽然每个D对象都是一个B对象,反之并不成立。
在C++领域中,任何函数如果期望获得一个类型为基类的实参(而不管是传指针或是引用),都也愿意接受一个派生类对象(而不管是传指针或是引用)。(只对public继承才成立。)
好的接口可以防止无效的代码通过编译,因此你应该宁可采取“在编译期拒绝”的设计,而不是“运行期才侦测”的设计。
请记住:
- “public继承”意味is-a。适用于base classes身上的每一件事情一定也使用于derived classes身上,因为每一个derived classes对象也都是一个base classes对象。
条款33:避免遮掩继承而来的名称
C++的名称遮掩规则所做的唯一事情就是:遮掩名称。只要Derived class有和base class中相同的函数名,不管其返回值,参数类型,参数个数是否相同,也不管成员函数是纯虚函数,非纯虚函数或非虚函数。只要名称相同就发生覆盖。派生类的作用域嵌套在基类的作用域内。
(1)derived classes内的名称会遮掩base classes内的名称(即使变量的类型不同,或者函数的参数不同)。如下面代码所示:
class Base { private: int x; public: virtual void mfl() = 0; virtual void mfl(int); virtual void mf2(); void mf3 (); void mf3(double); }; class Derived: public Base { public: virtual void mfl(); void mf3 (); void mf4 (); … }; Derived d; int x; d.mfl(); //正确,调用Derived::mfl d.mfl(x); //错误,名称被覆盖 d.mf2(); //正确,调用Base::mf2 d.mf3(); //正确,调用Derived::mf3 d.mf3(x); //错去,名称被覆盖</span>
(2) 为了让被遮掩的名称再见天日,可使用using 声明式或转交函数( forwarding functions) 。
(a) 使用using声明式
class Base { private: int x; public: virtual void mfl() = 0; virtual void mfl(int); virtual void mf2(); void mf3 (); } void mf3(double); class Derived: public Base { public: using Base::mfl; //使用using 声明式 using Base: :mf3; //使用using 声明式 virtual void mfl(); void mf3 (); void mf4(); } Derived d int x; d.mf1 () ; //仍调用Derived: :mfl d.mf1 (x); //调用Base: :mfl d.mf2 () ; //调用Base: :mf2 d.mf3 ();//调用Derived: :mf3 d.mf3 (x); //调用Base: :mf3</span>
(b) 使用转交函数
class Derived: private Base ( public: virtual void mfl () //转变函数(forwading function) , { Base:: mfl ( );} //暗自成为inline } Derived d; int x; d.mfl(); //正确,调用Derived::mfl d.mfl(x); //错误,名称被遮掩
请记住:
- derived calsses内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
- 为了让被遮掩的名称再见天日,可使用using声明式或转交函数(forwarding function)。
条款34:区分接口继承和实现继承
(2) pure virtual 函数只具体指定接口继承。(要求继承者必须重新实现该接口)
(3) 简朴的(非纯) impure virtual 函数具体指定接口继承及缺省实现继承(继承者可自己实现该接口也可使用缺省实现)。
(4) non-virtual 函数具体指定接口继承以及强制性实现继承。(继承者必须使用该接口的实现)
class Shape { public: virtual void draw( ) const = 0; //pure virtual 函数 virtual void error(const std::string& msg); //简朴的(非纯) impure virtual 函数 int objectID ( ) const;// non-virtual 函数 };
请记住:
- 接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口。
- pure virtual函数只具体制定接口继承。
- 简朴的(非纯)impure virtual函数具体制定接口继承及缺省实现继承。
- non-virtual函数具体制定接口继承以及强制性实现继承。
条款35:考虑virtual函数以外的其它选择
本条款告诉程序员,当需要使用virtual 函数时,可以考虑其他选择。
Virtual函数的替代方案是:
(1) 使用non-virtual interface(NVI)手法。思想是:将virutal函数放在private中,而在public中使用一个non-virtual函数调用该virtual函数。优点是:可以做一些预处理、后处理工作。
class GameCharacter { public: int healthValue() const{ // 1. 子类不能重定义 ... // 2. preprocess int retVal = doHealthValue(); // 2. 真正的工作放到虚函数中 ... // 2. postprocess return retVal; } private: virtual int doHealthValue() const { // 3. 子类可重定义 ... } };
例如:
class Base { public: Base(int i):val(i){}; int healthValue() const { int retVal = doHealthValue(); return retVal; } private: virtual int doHealthValue() const { return val; } int val; }; class Derived: public Base { public: Derived(int i,int j):Base(i),val(j){}; private: virtual int doHealthValue() const { return val; } int val; }; int main() { Derived d(1,2); cout<<d.healthValue()<<endl; return 0; }
(2)将virtual函数替换为“函数指针成员变量”(这是Strategy设计模式中的一种表现形式),见下面代码。优点是每个对象拥有自己的函数实现,也可在运行时改变计算函数;缺点是:该函数不能访问类中的私有成员(若要访问,必须由公有成员提供接口)
class GameCharacter; int defaultHealthCalc(const GameCharacter& gc); // default algorithm class GameCharacter { public: typedef int (*HealthCalcFunc)(const GameCharacter&); explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf) { } int healthValue() const { return healthFunc(*this); } ... private: HealthCalcFunc healthFunc; };
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; };
条款36:绝不重新定义继承而来的non-virtual函数
//类的定义 class B{ public: void func(){ cout<<“B”;} virtual void func2(){ cout<<“B”;} }; class D:public B{ public: void func() { cout<<“D”;} virtual void func2(){ cout<<“D”;} }; //下面是对B和D的使用 D dObject; B* bPtr = &dObject; D* dPtr = &dObject; //下面这两种调用方式: bPtr->func(); //调用B::func dPtr->func(); //调用D::func bPtr->func2(); //调用D::func dPtr->func2(); //调用D::func
- 不要重新定义继承而来的non-virtual函数。
条款37:绝不重新定义继承而来的缺省参数值
对于non-virtual函数,上一条款说到,“绝不重新定义继承而来的non-virtual函数”,而对于继承一个带有缺省参数值的virtual函数,也是如此。即绝不重新定义继承而来的缺省参数值。因为:virtual函数系动态绑定(dynamically bound),而缺省参数值确实静态绑定(statically bound)。意思是你可能会在“调用一个定义于派生类内的虚函数”的同时,却使用基类为它所指定的缺省参数值。
class Shape{ public: enum Color{RED,GREEN,BLUE}; virtual void draw(Color color = RED)const = 0; ... }; class Circle:public Shape{ public: //竟然改变缺省参数值 virtual void draw(Color color = GREEN)const{ ... } }; Shape* pc = new Circle; //静态绑定为RED pc->draw(); //注意调用的是: Circle::draw(RED),也就是说,此处的draw函数是基类和派生类的“混合物” Circle* pc = new Circle; //静态绑定为GREEN
为什么缺省参数是静态绑定而不是动态绑定呢?主要原因是运行效率。如果缺省参数采用动态绑定,那么编译器就必须有某种方法在运行期为virtual函数决定适当的缺省参数,这样会使程序的执行效率低下并且实现机理更加复杂。
聪明的做法是考虑替代设计,如条款35中的一些virutal函数的替代设计,其中之一是NVI手法,令base class内的一个public non-virtual函数调用private virtual函数。
class Shape { public: enum ShapeColor{ Red, Green, Blue }; virtual void draw(ShapeColor color = Red)const { doDraw(color); } private: virtual void doDraw(ShapeColor color)const = 0; }; class Rectangle :public Shape { private: virtual void doDraw(ShapeColor color)const; };
请记住:
- 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual函数——你唯一应该覆写的东西——却是动态绑定。
条款38:通过符合塑模出has-a或“根据某物实现出”
复合或包含意味着has-a。如果我们想设计一个自己的set,我们思考后觉得可以用list来实现它,但是如果我把它设计出list的一个派生类,就会有问题,因为父类的所有行为在派生类都是被允许的,而list允许元素重复,而set则显然不行,所以set与list之间不符合is-a关系,我们可以把list设计为set的一个成员,即包含关系(has-a)。
条款39:通过符合塑模出has-a或“根据某物实现出”
明智而审慎地使用private继承
(1)如果class之间的继承关系是private。编译器不会自动将一个derived class对象转化为一个base class对象。由private base class继承而来的所有成员,在derived class中都会变成private属性,纵使它们在base class中原来是protected或public属性。
class Person {..} class Student:private Person{..} void eat(const Person&); Person p; Student s; eat(p); //正确 eat(s); //错误
(2)private继承“并不存在is-a关系”的classes,而是has-a或is-implemented-in-terms-of的继承模型。如果我们只想利用base class的一部分功能,而又不想把base class暴露给其他外部对象使用,可以使用private继承。
主要有三种使用场合:
(a)其中一个derived class 需要访问base class的protected成员;
(b)derived class 需要重新定义base class中一个或多个virtual函数。
(c)需要对empty classes的空间最优化,如下面的代码:
class Empty{ }; //empty class class HoldsAnyInt{ private: int x; Empty e; };//sizeof(HoldsAnyInt) =8。//对于大小为0的独立对象,通常C++会在对象内需要安插一个char, 并且有位对齐要求。 class HoldsAnyInt::private Empty{ private: int x; }; //sizeof(HoldsAnyInt) == sizeof(int),这个就是EBO(empty based optimization 空白基类优化)。
条款40:明智而审慎地使用多重继承
使用多重继承就要考虑歧义的问题(成员变量或者成员函数的重名)。最简单的情况的解决方案是显式的调用(诸如item.Base::f()的形式)。
复杂一点的,就可能会出现“钻石型多重继承”,以File为例:
class File { ... } class InputFile : public File { ... } class OutputFile : public File { ... } class IOFile : public InputFile, public OutputFile { ... }
这里的问题是,当File有个filename时,InputFile与OutputFile都是有的,那么IOFile继承后就会复制两次,就有两个filename,这在逻辑上是不合适的。解决方案就是用virtual继承:
class File { ... } class InputFile : virtual public File { ... } class OutputFile : virtual public File { ... } class IOFile : public InputFile, public OutputFile { ... }
但是virtual继承并不常用,因为:
1. virtual继承会增加空间与时间的成本。
2. virtual继承会非常复杂(编写成本),因为无论是间接还是直接地继承到的virtual base class都必须承担这些bases的初始化工作,无论是多少层的继承都是。
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。