EffectiveC++笔记 第5章
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的“可能比较准确”的「翻译」。
Chapter 5 实现 Implementations
适当提出属于你的class定义以及各种functions声明相当花费心思。一旦正确完成它们,相应的实现大多直截了当。尽管如此,还是要小心很多细节。
条款26 : 尽可能延后变量定义式的出现时间
当你定义了一个变量,其类型带有构造函数和析构函数,当程序控制流(control flow)到达此变量定义式时,你需要承担构造成本;此变量离开作用域时,便需要承担析构成本———即使你自始至终都没有用过它。所以你应避免这种情况
你会问:怎么可能定义「不被使用的变量」?下面考虑一个函数,作用是计算通行密码的加密版本后返回,但前提是密码足够长。若太短,函数会抛出异常,类型为logic_error
//此函数过早定义变量“encrypted” std::string encryptPassword(const std::string& password) { using namespace std; string encrypted; if(password.length()<MinimumPasswordLength){ throw logic_error("Password is too short"); } ... //诸如将加密后的密码放进encrypted内的动作 }
这里存在的问题是,如果抛出了异常,那encrypted就真的没被使用———然而你还得付出构造和析构的成本。 看起来较好的解决方案是这样的:
...
if(password.length()<MinimumPasswordLength){
throw logic_error("Password is too short");
}
string encrypted; //延后定义式,直到真正需要它
...
其实效率不够高,因为encrypted虽获定义却无实参作初值。更好的做法是“直接在构造时指定初值”,这样的效率高于default构造函数(构造对象再对它赋值)。<我们在条款4讨论过效率问题>
现在我们一步一步进行分析。假设将函数encryptPassword的加密部分用 void encrypt(std::string& s);
实现,于是encryptPassword实现如下:
//此版本虽延后了定义,但仍效率低下: std::string encryptPassword(const std::string& password) { ... //检查length,同前 std::string encrypted; //default constructor,无意义 encrypted = password; encrypt(encrypted); return encrypted; }
更受欢迎的做法是直接将password作为encrypted初值,跳过无意义默认构造:
std::string encrypted(password);
现在我们大概能理解「尽可能延后」的深层含义:你应尝试延后这份变量定义直到能够给它初值实参为止。
但遇到循环怎么办?若我们只在循环内用到变量,是该将它定义与循环外并在每次循环迭代赋值给它,还是将其定义于循环内? :
//A方案,定义于循环外: //方法B,定义于循环内 : Widget w; for(int i=0;i<n;++i){ for(int i=0;i<n;++i){ Widget w(表达式取决于i值); w = 表达式;(取决于i值) ... } }
首先看A和B做法的成本:
- A: 1个构造函数 + 1个析构函数 + n个赋值操作
- B: n个构造函数 + n个析构函数
我们可以理清:
A的适用情况:
class的一个赋值成本低于一组构造+析构成本 ;否则做法B较好另外,A造成名称w作用域大于B,有潜在对程序可理解性和易维护性的冲突。
结论
除非你知道赋值成本小于“析构+构造” ;
你正在处理代码中对性能高度敏感(performance-sensitive)部分。
否则你该使用做法B。
条款27: 尽量少做转型动作 Minimize casting
很不幸,转型(casts)可能导致各种麻烦,有的显而易见,有的非常隐晦。
让我们复习一下转型的语法:
- C风格:
(T)expression 将expression转为T - 函数风格:
T(expression) 将expression转为T
它们并无差别,只是小括号位置不同而已。我们可以称这两种为「旧式转型」(old-style casts)。
C++还提供了四种新式转型:
- const_cast
- dynamic_cast
- reinterpret_cast
- static_cast
各有不同用途:
const_cast通常用来将对象的常量性质除去(cast away the constness)(不是真正除去)。它是唯一有此能力的C++-style转型操作符。
dynamic_cast主要用作“ 安全向下转型 safe downcasting ”,能决定对象是否属于继承体系中某个类型。它是唯一无法用旧式语法执行的动作,并可能耗费大量运行成本。(后面会讨论)
reinterpret_cast执行低级转型,实际动作及结果取决于编译器,这意味它不可移植。
static_cast用来强迫隐式转换(implicit conversions)。例如将non-const对象转为const对象,将int转为double等。但将const转为non-const只有const_cast做得到
新式转型较受欢迎:
- 它们易被辨识(不论人工还是工具)
- 可以缩小转型动作的选择范围。比如想去掉常量性(constness),只有const_cast能办到
使用旧式转型一般是调用explicit构造函数将对象传给一个函数:
class Widget{ public: explicit Widget(int size); ... }; void doSomeWork(const Widget& w); doSomeWork(Widget(15)); //函数式 doSomeWork(static_cast<Widget>(15)); //新式
使用第一种的原因可能是你觉得比较这样自然,不像第二种蓄意的“生成对象”。但是为了以后代码的可靠性,还是老老实实用新式转型吧。
另外,C++的指针会产生偏移(offset)现象:
class Base { ... } class Derived: public Base { ... }; Dervied d; Base* pb = &d; //隐式转换
注意上方代码,有时候引用d和指针pb两个指针(d其实相当于常量指针)的值并不相同。这种情况下,在运行期间会有一个偏移量(offset)被施于Dervied* 指针身上,以获取正确的Base*指针值。
上面的论述表明,单一对象(例如这里的Derived对象)可能拥有一个以上的地址(例如对象“以Base* 指向它”时的地址和“以Derived*指向它”时的地址)。其它语言几乎不可能出现这种情况,然而神奇的C++可以!!实际上C++碰到多重继承,这事儿其实一直在发生———即使单一继承也可能发生。
请注意了,对于偏移量(offset),对象布局和它们的地址计算方式随编译器不同而不同————则意味着可能你设计的转型在某平台可用,在另一平台不一定可用!
另一件关于转型的有趣的问题:许多应用框架(application frameworks)要求dervied class内的virtual函数代码第一语句即调用其base class的对应函数
我们很容易写一些看起来很对的转型代码:
class Window{ public: virtual void onResize() {...} //基类onResize实现 ... }; class SpecialWindow: public Window{ //derived class public: virtual void onResize(){ static_cast<Window>(*this).onResize(); /* 试图将*this,即当前对象指针转为父类,然后调用其 onResize,not ok! */ ... //SpecialWindow专属动作 } };
我们在代码中用了新式转型(实际上用旧式也是有问题的)。 一如你预期,程序确实将* this转型为Window基类,对onResize的调用也因此调用了Window::onResize。但你一定没想到,它调用的并不是当前对象的函数,而是转型动作「早期」,程序建立的一个 ”this类型但只包含其base class Window成分的对象”*的暂时副本身上的onResize!
我们再理解一遍 :上述代码并非「在当前对象身上调用Window::onResize后又在该对象身上执行SpecialWindow专属动作」。不不不,它是「在”当前对象之base class成分”的副本上调用Window ::onResize」,然后才在当前对象上执行SpecialWindow专属动作。会出现啥问题呢??假设onResize函数作用是改变对象的某内容,调用它时,首先转型*this指针为Window然后调用Window的onResize,并对Window成分进行专属操作。 但实际上此时调用的是「含有Window成分」对象副本的onResize,动作根本没有落实到真正的base class成分上;但SpecialWindow的onResize会真的改动原对象!想象一下,这会使当前对象进入“伤残”状态
解决之道是拿掉转型动作,别去哄骗编译器将*this视为一个base class对象:
class SpecialWindow: public Window{ public: virtual void onResize(){ Window::onResize(); //调用Window域的onResize作用于*this ... } ... };
现在谈谈dynamic_cast。它的许多实现版本执行速度很慢。一个普遍的实现版本基于“class之字符串比较”,如果你在四层深的单继承体系内某对象身上执行dynamic_cast,这个实现版本每层的一次dynamic_cast可能会耗用四次strcmp调用来比较class名称!深度继承和多重继承成本更高。(有些版本为了必须实现的动态链接必须这么做)但你应在注重效率的代码中思量是否要使用dynamic_cast。
通常用到dynamic_cast的场景为:
你想在一个dervied class对象身上执行专属于此之类自己的函数,但你只有一个base class类型的pointer或reference。
有两个一般性做法可以避免窘境:
通过STL直接储存指向dervied class对象的指针(通常为智能指针,见条款13)。
假设之前的Window/SpecialWindow继承体系中,SpecialWindow有专属函数void blink()
,不要这么写代码:
class Window {...}; class SpecialWindow: public Window{ public: void blink(); ... }; typedef std::vector<std::tr1::shared_ptr<Window> > VPW; VPW winPtrs; ... for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end();++iter){ if(SpecialWindow* psw=dynamic_cast<SpecialWindow*>(iter->get())) //dynamic_cast效率低下 psw->blink(); }
应这么写:
typedef std::vector<std::tr1::shared_ptr<SpecialWindow> > VPSW; VPSW winPtrs; ... for(VPSW::iterator iter = winPtrs.begin(); iter!=winPtrs.end();++iter) (*iter)->blink(); //不用dynamic_cast的实现
当然,这种写法会使你无法在一个容器内储存「可指向所有base派生类」的指针。若确实需要处理多种类型,可能需要多个容器,他们必须具备类型安全性。
另一种做法可让你通过base class接口处理所有base派生类,那就是在base里提供一个virtual函数。这类似Java里的抽象类:
class Window{ public: virtual void blink(){} //默认实现代码「什么也没做」,交给子类实现。以后会告诉你这可能是馊主意 ... }; class SpecialWindow: public Window{ public: virtual void blink() {...}; //子类的blink里做一些事。 ... }; typedef std::vector<std::tr1::shared_ptr<Window> > VPW; VPW winPtrs; //容器内含base类型指针 ... for(VPW::iterator iter = winPtrs.begin(); iter!=winPtrs.end();++iter) (*iter)->blink();
上述两种方法并非具有强大的普遍性,但是很多时候你应该以此替代dynamic_cast。
有一个你绝对,必须避免的东西:连串(cascading)dynamic_casts。也就是看起来像这样的东西:
class Window {...}; ... typedef std::vector<std::tr1::shared_ptr<Window> > VPW; VPW winPtrs; ... for(VPW::iterator iter = winPtrs.begin(); iter!=winPtrs.end();++iter) { if(SpecialWindow1* pswl = dynamic_cast<SpecialWindow1*>(iter->get())) {...} else if(SpecialWindow2* psw2 = dynamic_cast<SpecialWindow2*>(iter->get())) {...} else if(SpecialWindow3* psw3 = dynamic_cast<SpecialWindow3*>(iter->get())) {...} ... }
这样产生的代码又肿又慢,基础不稳。例如一旦加入新的dervied class,上述连串判断可能就要加入新的分支。这样的代码应以“基于virtual函数调用”的东西取代。
优良的C++代码很少使用转型,但完全摆脱转型操作不切实际。
最后,请记住:
- 尽量避免转型,特别是在注重效率的代码中避免dynamic_cast。
- 如果转型必要,试着将它隐藏在某个函数后,客户只需调用函数,而不用将具体实现加进他们的代码里。
- 宁用C++-style(新式)转型。
条款28: 避免返回handles指向对象内部成分
Avoid returning “handles” to object internals
现在假设你的程序涉及矩形,每个矩形由左上角和右下角的点坐标确定。为了让一个Rectangle对象尽可能小,你可能决定把定义的点放在辅助点struct内
class Point{ //此类表示“点” public: Point(int x,int y); ... void setX(int newVal); void setY(int newVal); ... }; struct RectData{ //这些“点”数据用来表现矩形 Point ulhc; //"upper left-hand comer"(左上角) Point lrhc; //"lower right-hand comer"(右下角) }; class Rectangle{ ... private: std::tr1::shared_ptr<RectData> pData; };
使用Rectangle的客户需要计算Rectangle范围,所以此类提供upperLeft和lowerRight函数来返回左上角和右下角的坐标。根据条款20的讨论,我们让函数返回引用,代表底层的Point对象:
class Rectangle{ public: ... Point& upperLeft() const { return pData->ulhc; } Point& lowerRight() const { return pData->lrhc; } ... }
这种设计有一个重大缺陷:虽然两个函数被设计为const从而不能修改类成员函数,但是它所返回的reference却可以直接指向private内部数据,例如:
Point coord1(0,0);
Point coord2(100,100);
const Rectangle rec(coord1,coord2); //一个const矩形
rec.upperLeft().setX(50); //??一个const矩形的值竟然被改变了?
伙计,rec应该是不可变的啊!这给我们一个教训:
成员变量的封装性最多等于「返回其reference」的函数的访问级别
如果类似的函数返回指针或迭代器的,相同的事情还是会发生。原因很简单,references、pointers和迭代器统统是所谓的 handles(号码牌,用来取得某对象)。所以返回一个“代表对象内部数据”的handle会带来降低对象封装性的风险。
之前的问题可以通过一个开头的修饰符轻松解决:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
这样一来,返回的引用的权限仅为“只读”。
可即使如此,在其它场合可能还是会有问题。它可能导致_dangling handles(空悬的号码牌);_也就是说handles所指物(的所属对象)不复存在。这种问题常见来源是函数返回值。例如某函数返回GUI对象的外框,这个框采用矩形形式:
class GUIObject {...}; const Rectangle boundingBox(const GUIObject& obj); //以by value形式返回矩形
现在客户可能这么使用此函数:
GUIObject* pgo;
... //让pgo指向某个GUIObject
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
//取得一个指针指向外框左上角坐标
上述的操作中,boundingBox返回一个临时的Rectangle副本对象,它没有名称,暂且称它temp。随后upperLeft作用于temp并返回一个reference指向temp内部的Point成分,然后指针pUpperLeft指向那个Point对象。然鹅。。。在语句结束后,temp将会被销毁,间接导致temp内的Points析构,最终导致pUpperLeft指向不存在的对象。
这就是为啥函数返回一个handle总是危险的原因。但这不是说你绝不能让成员函数返回handles,有时你必须这么做。比如operator[]返回的引用允许你取得string对象或vector对象的个别元素。
条款29: 为“异常安全”而努力是值得的 Strive for exception-safe code.
假设有一个class表现带背景图案的GUI菜单。这个class希望用于多线程环境,所以它有个互斥器(mutex)作为并发控制(concurrency control)之用:
class PrettyMenu{ public: ... void changeBackground(std::istream& imgSrc); //修改菜单背景 ... private: Mutex mutex; //互斥器 Image* bgImage; //目前的背景图案 int imageChanges; //记录背景被改次数 }; //下面是changeBackground的可能实现 void PrettyMenu::changeBackground(std::istream& imgSrc){ lock(&mutex); //取得互斥器(见条款14) delete bgImage; //摆脱旧背景图案 ++imageChanges; //增加次数 bgImage = new Image(imgSrc); //安装新背景 unlock(&mutex); //释放互斥器 }
从“异常安全性”的两个条件来看,这个函数很糟:
不泄漏任何资源 一旦
new Image(imgSrc)
导致异常,对unlock的调用就不会执行,于是互斥器就永远被把持住了。不允许数据败坏 若
new Image(imgSrc)
抛出异常,bgImage即指向一个已被删除的对象,imageChanges也被累加,然而其实没有新图像被成功安装。
- - - -
解决资源泄漏很容易,以前的条款14曾讨论过,导入Lock class作为一种「确保互斥器几被及时释放」的方法:
void PrettyMenu::changeBackground(std::istream& imgSrc){ Lock ml(&mutex); //来自条款14 delete bgImage; ++imageChanges; bgImage = new Image(imgSrc); }
如Lock流的“资源管理类(resource management classes)”通常能使代码更短。在上例的体现在于省去了unlock函数。
- - - -
接下来解决数据的败坏。
首先,异常安全函数(exception-safe functions)提供以下三个保证之一:
基本承诺: 一旦异常抛出,程序内任何事物仍保持在有效状态下。没有任何对象或数据结构因此败坏,处于前后一致状态。但现实状态(exact state)不可预料,比如我们可以设计changeBackground为,一旦抛出异常,PrettyMenu可继续持有原背景或赋给默认背景图案。
强烈保证: 一旦异常抛出,程序状态不变。这样的函数,只要成功,就完全成功;如果失败,程序会恢复到“调用函数之前”状态。
持续更新中................................
上一篇: Const 关键字详解
下一篇: 盘点:激动人心的人机大战