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(expression)
- dynamic_cast(expression)
- reinterpret_cast(expression)
- static_cast(expression)
各有不同用途:
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可继续持有原背景或赋给默认背景图案。
强烈保证: 一旦异常抛出,程序状态不变。这样的函数,只要成功,就完全成功;如果失败,程序会恢复到“调用函数之前”状态。
不抛掷保证(nothrow): 承诺绝不抛出异常,因为它们总是能完成它们原先承诺的功能。 C++内置类型都能提供nothrow保证。
事实上最先考虑的应该是第三个保证,因为只有不能完全做到它我们才会考虑前两个保证。
但是在C part of C++领域中很难完全不调用可能抛出异常的函数。任何使用动态内存的东西(比如STL),若内存不足以满足需求,通常抛出bad_alloc异常(请见以后的条款49)。
所以只能说尽可能提供nothrow保证,但很多情况只能做到「基本保证」或「强烈保证」。
那我们如何为之前讨论的changeBackground函数提供「强烈保证」呢?首先改变PrettyMenu的成员bgImage的类型,从Image*改为智能指针:
class PrettyMenu{
...
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc){
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc));
++imageChanges;
//不再需要手动delete,智能指针已经完成
}
这个操作几乎足够让此函数提供「强烈保证」,美中不足的是参数imgSrc。比如若Image构造函数抛出异常,可能输入流(input stream)的读取记号(read marker)已被移走,而这种搬移对程序其余部分是一种可见的状态改变。
有一个设计策略叫做copy and swap,可以改善问题,原则是这样的:
为打算修改的对象原件做一份副本,再在副本上作需要的修改。一旦有任何异常抛出,原件保持不变。若所有改动皆成功则将修改的副本和原件在一个不抛出异常的操作中置换(swap)
对于PrettyMenu写法如下:
struct PMImpl{ //稍后说明为毛是结构体
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu{
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc){
using std::swap; //条款25
Lock ml(&mutex); //获得mutex副本数据
std::tr1::shared_ptr<PMImpl>
pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); //修改副本
++pNew->imageChanges;
swap(pImpl,pNew); //置换同时释放mutex
}
令PMImpl成为一个class,有时候不太方便(但保持了面向对象纯度)。之所以让PMImpl成为一个struct是因为PrettyMenu已经保证了pImpl是private。你也可以将PMImpl嵌套在PrettyMenu中,但打包问题会有所顾虑。
copy-and-swap关键在于“修改对象数据的副本,然后在一个不抛异常的函数中将修改后的数据和原件置换”,因此必须为每一个即将被改动的对象做出一个副本,那样会耗费大量的时间和空间。因此“强烈保证”并非在任何时刻显得实际,但此时你也应提供“基本保证“。
请记住
异常安全函数(Exception-safe functions)即使发生异常也不会泄漏资源或允许任何数据结构败坏,这样的函数提供的保证分为:基本型、强烈型、不抛异常型。
“强烈保证”往往可以copy-and-swap实现,但“强烈保证”并非对所有函数都有现实意义
条款30: 透彻了解inlining的里里外外
Understand the ins and outs of inlining
Inline函数,很棒 ,看起来、动作像函数,比define好得多,可调用它们还不承担调用的额外开销。
其实你知道的太少了,“免除调用成本”只是故事的一部分而已。 编写程序和生活一样,「没有白吃的午餐」,要知道inline背后的原理是 每一次调用都以函数本体在调用点直接展开 ,这样做会增加你的目标码(object code)大小。 在一台内存有限的机器上,过度使用inlining会导致程序体积相对太大。即使拥有虚内存,inline造成的代码膨胀也会导致额外的换页(paging),降低指令高速缓存装置的击中率,效率损失
换个角度:很短的inline函数,编译器针对它产出的目标码可能比“函数调用”的产出码更小,这样才可能带来更高的效率(提高指令高速缓存装置的击中率)。
记住,inline只是对编译器的一个申请,不是强制命令。这项申请可隐喻提出,也可明确提出。 隐喻方式是将函数定义于class内:
class Person{
public:
...
int age() const { return theAge; } //隐喻的inline申请
...
private:
int theAge;
};
我们会在条款46中提到friend函数可定义在class内,这样的话它们也被隐喻声明为inline。
明确声明inline函数的做法是在定义式前加关键字inline。例如来自的max template往往这样实现:
template <typename T>
inline const T& std::max(const T& a,const T& b) //手动声明inline
{ return a<b?b:a; }
inline函数通常被置于头文件中,因为大部分build environments在编译期中进行inlining,所以编译器必须事先知道inline函数长啥样。
大部分编译器拒绝将太过复杂(例如循环或递归)的函数inlining,且对virtual函数的调用也会使inlining落空(除非太过简单),因为virtual意味着”等待,知道运行期才确定调用哪个函数”,而inline意味着“执行前,先将调用动作替换为被调函数本体”。
注意,有时候会发生这样的事: 程序要取某inline函数的地址,编译器通常必须为此函数生成一个outlined函数本体,毕竟编译器没办法提出一个指向不存在的函数的指针。你可以理解为,编译器通常不对“通过函数指针而进行的调用”实施inlining,这意味着是否进行inlining取决于调用方式:
inline void f() {...} //假设编译器有意愿inline“对f的调用”
void (*pf)() = f; //指向f的pf指针
...
f(); //将被inlined,是正常调用
pf(); //或许不被inlined,因为是通过函数指针达成
实际上构造函数和析构函数往往是inlining的糟糕候选者,考虑以下dervied class构造函数:
class Base{
public:
...
private:
std::string bm1,bm2; //base成员1和2
};
class Derived: public Base{
public:
Derived() {} //别以为构造函数是空的
...
private:
std::string dm1,dm2,dm3; //derived成员1-3
};
你或许觉得这个构造函数什么也没有,是inlining绝佳候选人,然而你错了。
想象一下,C++对于“对象被创建和被销毁时发生了什么”做了各式保证。如果有个异常在对象构造期间被抛出,该对象已构造好的一部分会被自动销毁。这种情况中C++描述了发生了什么异常,但没说是如何发生的。很清楚的是它们异常不会凭空产生,一定是某些代码使它们发生,它们可能由编译器于编译期间代为生成并插入你的构造函数和析构函数中。所以,编译器替之前说的表面看起来为空的Derived构造函数生成的代码,相当于下列:
Derived::Derived(){
Base::Base(); //初始化Base成分
try{ dm1.std::string::string();} //试图构造dm1
catch(...){ //抛出异常就
Base::~Base(); //销毁base class成分,并
throw; //传播该异常
}
try{ dm2.std::string::string();} //试图构造dm2
catch(...){ //抛出异常就
dm1.std::string::~string(); //销毁dm1,
Base::~Base(); //销毁base class成分,并
throw; //传播该异常
}
try{ dm3.std::string::string();} //试图构造dm3
catch(...){ //抛出异常就
dm1.std::string::~string(); //销毁dm1,
dm2.std::string::~string(); //销毁dm2,
Base::~Base(); //销毁base class成分,并
throw; //传播该异常
}
}
这段代码并不能代表编译器真正生成的,因为编译器会以更精致复杂的办法来处理异常,但这足以准确反映空白构造函数必须提供的行为。Derived构造函数至少一定会陆续调用其成员变量和base class两者的构造函数,而那些调用(它们自身可能会被inlined)会影响编译器是否对此空白函数inlining。
相同理由适用于Base构造函数。如果它被inlined,所有替换“Base构造函数调用”的代码会被插入Derived内调用Base构造函数的地方。若string构造函数恰巧也被inlined,Derived构造函数将获得5份_string构造函数代码_的副本,每一份对应Derived对象内的五个字符串(两个来自继承,三个来自自己的声明)之一。现在你很清楚了,“是否将Derived构造函数inline化”并非一个轻松的决定,类似思考适用于它的析构函数。
程序设计者必须评估将函数声明为inline的冲击: inline函数无法随着程序库的升级而升级。也就是说如果f是程序内一个inline函数,客户将f函数本体编入程序中,一旦程序库设计者决定改变f,所有用到f的客户端程序都必须重编译,这很麻烦。 若f是non-inline函数,一旦它有任何更改,客户端只需重新连接就好。若程序库采用动态链接,升级版函数甚至可以不知不觉被应用程序吸纳。
请记住:
将大多数inlining限制在小型、被频繁调用的函数身上,这可使日后的调试过程和二进制升级更容易,并使潜在的代码膨胀问题最小化。
不要只因为function templates出现在头文件,就将它们声明为inline。
条款31: 将文件间的编译依存关系降至最低
Minimize compilation dependencies between files
说明:此条款阅读起来会比较生涩难理解。
假设在编写C++程序时,你对某个class的实现文件(xxx.cpp)做了轻微修改(只改private部分的实现,不修改class的接口)。当按下“Build”键后你意识到整个世界都被重编译链接了。
问题就出在C++没把“将接口从实现中分离”做得很棒。 Class的定义式不仅详述了函数接口,还包含实现细目:
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; // 实现细目
};
由于class内存在“Date”,“Address”这样的自定义类型,所以必须包含它们的头文件:
#include <string>
#include "date.h"
#include "address.h"
Unfortunately,这样Person的定义文件便与这三个include来的头文件形成了编译依存关系(compilation dependency)。意味着这些头文件任何一个有一个被修改;或是它们所倚赖的其它头文件有任何改变,那么每一个含入、使用Person class的文件便需重编译。
你或许觉得应该用前置声明来替代之前的做法:
//不使用include
namespace std{
class string; //其实不正确
}
class Date; //前置
class Address; //前置
这样一来,似乎就可以解决编译依存问题。但是存在两个问题:
string不是个class,它是一个typedef (原型为basic_string),因此上述对string的前置声明不对。而正确声明它比较繁杂,需涉及额外的templates。但这并不重要,对于一些标准程序库的组件你本不该手工声明,应仅用#include完成目的。
关于前置声明的第二个困难,C++编译器需在编译期间知道对象的大小。考虑下面的代码:
class Person;
int main()
{
int x; //定义一个x
Person p(params); //定义一个Person
...
}
编译器看到x的定义时,它知道必须分配多少内存(在stack中)给一个int。但编译器获得Person对象大小信息的办法是询问Person的class定义式。问题出在如果class定义式可合法地不列出实现细目,编译器无从得知。假设上述代码中就只有一句 class Person;
来声明Person类的「存在」,而没有任何实现细节,此时编译无法通过。
但是你可以以指针来代替:
class Person;
int main()
{
int x; //定义一个x
Person* p; //定义一个指针指向Person对象
...
}
针对Person,我们将它分割为两个classes,一个提供接口,另一个实现接口,并将负责实现的所谓implementation class取名personImpl:
#include <string> //标准库组件不应前置声明
#include <memory> //支持tr1::shared_ptr
class PersonImpl; //Person实现类前置声明
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; //指向实现物,
//std::tr1::shared_ptr详见条款13
};
这种main class(Person)内含一个指针成员指向实现类的设计成为pimpl idiom(pointer to implementation)。
通过这样的设计,Person的客户便完全与Date,Address以及Person的实现细目分离了。那些classes的任何修改都不需要Person客户端重新编译,这是真正的“接口与实现分离”。
这种分离的关键在于以「声明的依存性」替换「定义的依存性」:现实中让.h头文件尽可能自我满足,有需求就让它与其它文件内的声明式(非定义式)相依。
其它的事情都源自这些简单的策略:
- 若使用object reference或object pointers可完成任务,就别使用objects。
- 如果能,尽量以class声明式替换class定义式。 (很特别的一点:若声明一个函数并且它用到某个class,即使函数以by value传递class参数你也只需一个该class的声明式:)
class Date;
Date today();
void clearAppointments(Date d);
- 为声明式和定义式提供不同的头文件。 为促进严守上述准则,需两个头文件,一个用于声明式,一个用于定义式。当然,如果有个声明式被改变了,两个文件都得改变。举个例子,Date的客户若希望声明today和clearAppointments,他们可以直接include内含声明式的头文件:
#include "datefwd.h" //内部仅声明class Date无定义式
Date today();
void clearAppointments(Date d);
像Person这样使用pimpl idiom的classes,往往被称为Handle classes。你可以将它的所有函数转交给相应的实现类完成实现:
#include "Person.h" //正在实现Person class,所以必须include其class定义式
#include "PersonImpl.h"
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;
}
另一种制作Handle class的办法是,令Person成为特殊的abstract base class(抽象基类),成为interface class,这种class的目的是详细一一描述dervied classes的接口,因此它通常不带成员变量,也没有构造函数,只有一个虚析构函数以及一组pure virtual函数:
class Person{
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
};
这个class的客户必须以Person的指针或引用来撰写程序,因为它不能针对“内含纯虚函数”的Person class具现出实体(但可能对derived from Person的classes具现实体,详下)。和Handle class一样,除非interface class借口被修改否则其客户不需重新编译。
interface class的客户通常调用成为factory(工厂)的函数或virtual构造函数,它们返回指针,指向动态分配所得对象,而该对象支持interface class的接口,这种函数往往在interface class中声明为静态:
class Person{
public:
...
static std::tr1::shared_ptr<Person>
create(const std::string& name,
const Date& birthday,
const Address& addr);
...
//条款18告诉你为什么返回智能指针
};
客户这样使用它们:
std::string name;
Date dateOfBirth;
Address address;
...
//创建一个对象,支持Person接口
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 Person有个具象的继承类RealPerson提供函数实现:
class RealPerson: public Person{
public:
RealPerson(const std::string& name,const Date& birthday,
const Address& addr)
:theName(name),theBirthDate(birthday),theAddress(addr)
{}
virtual ~RealPerson() {}
std::string name() const; //函数的实现码不现实于此
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
有了RealPerson,写出Person::create就很容易了:
std::tr1::shared_ptr<Person>
create(const std::string& name,
const Date& birthday,
const Address& addr)
{
return
std::tr1::shared_ptr<Person>(new RealPerson(name,birthday,addr));
}
请记住
- 支持“编译依存最小化”的一般构想是:相依于声明式,而不是定义式,基于此构想的两个手段是Handle classes和Interface classes。
- 程序库头文件应以“完全且仅有声明式”的形式存在。
OVER
EffectiveC++ 第5章 实现的更多相关文章
- EffectiveC++ 第7章 模板与泛型编程
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的"可能比较准确"的「翻译」. Chapter 7 模版与泛型编程 Templates and Gen ...
- EffectiveC++ 第1章 让自己习惯C++
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的"可能比较准确"的「翻译」. Chapter 1 让自己习惯C++ 条款 1 :视 C++为一个语言 ...
- EffectiveC++ 第2章 构造/析构/赋值运算
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的"可能比较准确"的「翻译」. Chapter 2 构造 / 析构 / 赋值 条款 05:了解C++ ...
- EffectiveC++ 第3章 资源管理
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的"可能比较准确"的「翻译」. Chapter 3 资源管理 条款13: 以对象管理资源 有时即使你顺 ...
- EffectiveC++ 第4章 设计与声明
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的"可能比较准确"的「翻译」. Chapter4 设计与声明 Designs and Declarat ...
- EffectiveC++ 第6章 继承与面向对象设计
我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的"可能比较准确"的「翻译」. Chapter 6 继承与面向对象设计 Inheritance and ...
- 《Django By Example》第五章 中文 翻译 (个人学习,渣翻)
书籍出处:https://www.packtpub.com/web-development/django-example 原作者:Antonio Melé (译者@ucag注:大家好,我是新来的翻译, ...
- ASP.NET MVC with Entity Framework and CSS一书翻译系列文章之第二章:利用模型类创建视图、控制器和数据库
在这一章中,我们将直接进入项目,并且为产品和分类添加一些基本的模型类.我们将在Entity Framework的代码优先模式下,利用这些模型类创建一个数据库.我们还将学习如何在代码中创建数据库上下文类 ...
- 《Django By Example》第四章 中文 翻译 (个人学习,渣翻)
书籍出处:https://www.packtpub.com/web-development/django-example 原作者:Antonio Melé (译者注:祝大家新年快乐,这次带来<D ...
随机推荐
- Python操作db2
官方文档:https://www.ibm.com/support/knowledgecenter/en/SSEPGG_9.5.0/com.ibm.db2.luw.apdv.python.doc/doc ...
- python接口自动化-post请求1
一.查看官方文档 1. 学习一个新的模块,直接用 help 函数就能查看相关注释或案例内容,例如 具体信息如下,可查看 python 发送 ge t和 post 请求的案例: F:\test-req- ...
- web框架开发-分页器(Paginator)
Django有自带的分页器,可以将数据分在不同的页面中,并提供一些属性和方法实现对分页数据的操作.分页功能的类位于django/core/paginator.py中. 常用方法 # 分页器 # pag ...
- SQL 撤销索引、表以及数据库
通过使用 DROP 语句,可以轻松地删除索引.表和数据库. SQL DROP INDEX 语句 我们可以使用 DROP INDEX 命令删除表格中的索引. 用于 Microsoft SQLJet (以 ...
- w3m 使用总结
安装 sudo apt install w3m终端 w3m www.baidu.com 即可打开w3m是个开放源代码的命令行下面的网页浏览器.一般的linux系统都会自带这个工具,可以通过它在命令行下 ...
- (十五)The Search API
Now let’s start with some simple searches. There are two basic ways to run searches: one is by sendi ...
- c++stack类的用法
官方解释: LIFO stack Stacks are a type of container adaptor, specifically designed to operate in a LIFO ...
- Linux内存管理 (9)mmap(补充)
之前写过一篇简单的介绍mmap()/munmap()的文章<Linux内存管理 (9)mmap>,比较单薄,这里详细的梳理一下. 从常用的使用者角度介绍两个函数的使用:然后重点是分析内核的 ...
- C语言的3种参数传递方式
参数传递,是在程序运行过程中,实际参数就会将参数值传递给相应的形式参数,然后在函数中实现对数据处理和返回的过程,方法有3种方式 值传递 地址传递 引用传递 tips: 被调用函数的形参只有函数被调用时 ...
- jexus部署webapi或mvc报错处理
1路径错误:因为Windows和Linux的路径问题大小写问题. 解决: 修改jexus下的jws把export MONO_IOMAP=all注释去掉放出来. 2, 解决: 卸载