条款26:尽可能延后变量定义式的出现时间
只要定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流到达和离开这个变量时,就要承担构造和析构的成本。
//这个函数过早定义变量“encrypted”
stringencryptPassword(const std::string& password) {
using namespace std;
string encrypted;
if(password.length() <MinimumPasswordLengt) {
throw logic_error(“Password is tooshort”)
}
…//必要动作,将一个加密后的密码置入encrypted内。
return encypted;
}
如果encryptPassword抛出异常,encrypted仍然有构造和析构的成本。
//这个函数延后“encrypted”的定义,直到真正需要它
stringencryptPassword(const std::string& password) {
using namespace std;
if(password.length() <MinimumPasswordLengt) {
throw logic_error(“Password is tooshort”)
}
string encrypted;
…//必要动作,将一个加密后的密码置入encrypted内。
return encypted;
}
Encrypted虽然定义延后,却无任何参数作为初值。这意味调用的是default构造函数。许多时候你对该对象做的第一个动作就是给它个值,通常通过赋值动作完成。
//终于,这是定义并初始化encrypted的最佳做法
stringencryptPassword(const std::string& password) {
using namespace std;
if(password.length() <MinimumPasswordLengt) {
throw logic_error(“Password is tooshort”)
}
string encrypted(password);//通过copy构造函数定义并初始化。
encrypt(encrypted);
return encypted;
}
password作为encrypted的初值,跳过毫无意义的default构造函数过程
对于循环的做法:
//方法A:定义于循环外
Widget w;
for(int i = 0;i < n; i++) {
w = 取决于某个i的值;
}
//方法B:定义于循环内
for(int i = 0;i < n; i++) {
Widget w(取决于i的某个值);
}
做法A:1个构造 + 1个析构 +n个赋值
做法B:n个构造 + n个析构
除非(1)你知道赋值成本比“构造+析构”低;(2)正在处理代码中效率高度敏感的部分,否则你应该使用做法B。
请记住:
尽可能延后变量定义式的出现。这样做可以增加程序代的清晰度并改善程序效率。
条款27:尽量少做转型动作
转型方式:
旧式转型(old-style casts)/c风格
(T) expresstion
T (expression)
新式转型(c++-style casts)/c++风格
const_cast<T> (expression):通常被用来将对象的常量性转除,唯一有此技能的c++转型操作符。
dynamic_cast<T> (expression):主要用来执行“安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型。唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。
reinterpret_cast<T> (expression):试图执行低级转型(例如将一个指向整数变量的指针转换为整数),实际动作及结果可能与编译器相关,这也就表示了它不可移植性。
static_cast<T> (expression):用于强制隐式类型转换,编译器执行的任何类型的转换都可以由static_cast显式完成(除了将const转换为非const)。
假设有一个基类Window,它有一个派生类SpecialWindow,它们都有一个OnResize()的成员函数,在派生类的OnResize()中想要先调用基类的OnResize(),下面实现方式之一,看起来是对的,但是实际是错的:
void SpecialWindow::OnResize() { static_cast<Window>(*this).OnResize(); … // 进行SpecialWindow专属行为 }
因为static_cast生成的是一个临时的基类的对象,这个对象并不是真正组成派生类的那个基类,而是它的一个副本(这个副本的成员变量值与派生类对象中基类的成分是一样的,但地址不同),调用OnResize()变更的结果是这个副本的成员变量变了,但派生类中包含的基类的成员变量却是没有任何变化的。这使得当前对象进入以一种“伤残”状态,其中base部分的更改没有得到落实,而derivedclass成分更改倒是得到落实了。解决之道就是拿掉转型动作:
void SpecialWindow::OnResize() { Window::OnResize(); … // 进行SpecialWindow专属行为 }
有的程序员认为,转型其实什么也没有做,只是告诉编译器把某种类型视为另一种类型而已。如果说的是指针,这样理解是正确的,但如果说的是全部的话(包括变量),恐怕就不妥了,比如static_cast<double>(a)/ b,这一类经典的整数除法->小数除法转换,转换前后在底层产生的代码是绝对不同的。
请记住:
1. 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
2. 如果转型是必要的,试着将它隐藏于某个函数背后,客户可以随后调用这个函数,而不需要将转型放在他们自己的代码里。
3. 宁可使用C++风格的新式转型,少用C风格转型,因为前者很容易辨识出来,而且也比较有着分门别类的职掌。
条款28:避免返回handles指向对象内部成分
先看例子:
Class Point
public:
Point(intx, inty);
Void setX(int newVal);
Void setY(int newVal);
};
Struct RectData{
Point ulhc;
Point lrhc;
};
Class Rectangle{
public:
Point &upperLeft() const {return pData->ulhc;}
Point &lowerRight() const {return pData->lrhc;}
...
private:
std::tr1::shared_ptr<RectData> pData;
};
Rectangle的客户必须能够计算Rectangle的范围,所以类中提供的upperLeft(),lowerRight()这两个方法,并不希望客户来修改,所以两个方法使用了const关键字。另一方面,两个函数返回的是reference指向privatepData的内部数据。因此,调用者完全可以通过这reference更改内部数据:
Point coord1(0,0);
Point coord2(100,100);
Const Rectanglerec(coord1,coord2);
rec.upperLeft().setX(50);
这立刻带给我们两个教训:第一,成员变量的封装性最多只等于“返回其reference”的函数级别,即成员变量的封装性会被引用破坏。第二,如果const成员函数传出一个reference,后者所指的数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。这也是bitwiseconstness的附带结果,具体条款3。
为了限制修改,可以对进行const关键字限定返回值:
Class Rectangle{
public:
const Point& upperLeft() const {returnpData->ulhc;}
const Point& lowerRight() const {returnpData->lrhc;}
...
private:
std::tr1::shared_ptr<RectData> pData;
};
但是这样做也不是很好,因为upperLeft和lowerRight还是返回了“代表对象内部“的handles,有可能在其他场合带来问题。更明确的说会导致”danglinghandles(空悬的号码牌):这种handles所指的对象不存在了“。例如:
Class GUIObject{};
Const RectangleboundingBox(const GUIObject& obj);
现在,客户可能这么使用这个函数:
GUIObject* pgo;
Const Point* pUpperLeft= &(boundingBox(*pgo).upperLeft());
对boundingBox的调用获得一个新的、暂时的Retangle对象。随后uperLeft作用于该temp对象上。语句结束后,temp对象销毁,间接导致temp内的Points析构,最终导致uperLeft指向一个不再存在的对象。uperLeft也变成空悬、虚吊danglinghandles了。
这就是为什么函数如果“返回一个handle代表对象内部成分“总是危险的原因。但这并不意味着绝对不可以让函数返回内部的handles。有时候你必须这样做。例如返回容器内的数据等,这样的函数毕竟是例外,不是常态。
请记住:
避免返回handles(包括reference、指针、迭代器)指向对象的内部。遵守这个条款可增加封装性,帮助const成员函数更加像一个const,并将“虚吊号码牌“的可能性降低到最低。
条款29:为“异常安全”而努力是值得的
某个class用来表现夹带背景图案的GUI菜单单,这个class用于多线程环境,所以它有个互斥器(mutex)作为并发控制用:
classPrettyMenu{
public:
...
void changeBackground(std::istream&imgSrc);
...
private:
Mutex mutex;
Image* bgImage;
int imageChanges;
};
void PrettyMenu::changeBackground(std::istream&imgSrc)
{
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}
从异常安全性的角度看,这个函数很糟。因为没有满足异常安全的两个条件:
a. 不泄露任何资源。上述代码没有做到这一点,因为一旦“new Image(imgSrc)”导致异常,对unlock就不会调用,于是互斥器就永远被把持住了。
b. 不允许数据破坏。如果“new Image(imgSrc)”抛出异常,bgImage就指向一个已被删除的对象,imageChanges也已被累加,而其实并没有新的图像被成功安装起来。
解决资源泄漏的问题很容易,因为条款13已经教会我们“以对象去管理资源”,而条款14也导入了Lockclass作为一种“确保互斥器被及时释放”的方法:
voidPrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex); //来自条款14;
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
关于“资源管理类”如Lock,一个最棒的事情是,它们通常使函数更短。较少的代码就是较好的代码,因为出错的机会比较少。一旦有所改变,被误解的机会也比较少。
把资源泄露抛诸脑后,现在我们专注的了解数据败坏的问题。此刻,我们需要做个抉择,在抉择之前我们需要了解异常安全函数提供以下的三个保证之一:
a.基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有的class约束条件都继续获得满足)。然而程序的现实状态恐怕不可预料。如changeBackground使得一旦有异常被抛出时,PrettyMenu对象可以继续拥有原背景图像,或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况。如果想知道,它们恐怕必须调用某个成员函数以得知当时的背景图像是什么。
b.强烈保证:如果异常被抛出, 程序状态不改变。如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。
c.不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(如ints,指针等等)身上的所有操作都提供nothrow保证。
对changeBackground而言,首先,从一个类型为Image*的内置指针改为一个“用于资源管理”的智能指针(见条款13)。第二,重新排列changeBackground内的语句次序,使得在更换图像之后再累加imageChanges。一般而言,不要为了表示某件事件发生而改变对象的状态,除非那件事情真的发生了。
classPrettyMenu{
...
std::tr1::shared_ptr<Image> bgImage;
...
};
voidPrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc));
++imageChanges;
}
copy and swap方法很容易得到“强烈保证”等级的异常安全性:为计划修改的对象(原件)复制出一份副本,然后在副本上做一切必要的修改,修改成功后再将修改过的副本和原对象在一个不抛出异常的操作中转换。这样即使修改过程中抛出了异常,则原件并未改变。copyand swap方法在实现上通常采用所谓的“pimpl idiom”方法:将所有“隶属对象的数据”从原对象中放进另一个对象内,然后赋予原对象一个指针,指向那个实现对象。对PrettyMenu而言,典型写法如下:
struct PMImpl{
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};
classPrettyMenu{
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
voidPrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap;
Lock ml(&mutex);
std::tr1::shared_ptr<PMImpl> pNew(newPMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));//修改副本
++pNew->imageChanges;
swap(pImpl, pNew); //置换数据
}
copy and swap 策略是对对象状态做出“全有或全无”改变的一个很好的方法,但一般而言它并不保证整个函数有强烈的异常安全性。例如someFunc函数,它使用copy-and-swap策略,但函数内还包括另外两个函数f1和f2的调用:
void someFunc()
{
… //对local状态做一份副本
f1();
f2();
… //将修改后的状态置换过来
}
很显然,如果f1或f2的异常性比“强烈保证”低,那么就很难让someFunc成为“强烈异常安全”。
请记住:
1.异常安全函数(Exception-safe functions)即时发生异常也不会泄露资源或允许任何数据结构破坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
2.“强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
3.函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
条款30:透彻了解inlining的里里外外
inline函数是特殊的函数,它有宏的优点,却克服了宏的缺点,又不需要蒙受函数调用所招致的额外开销,编译器会对inline函数本体执行语境相关最优化。但使用inline函数会导致目标码(objectcode)变大,因为对inline函数的调用都会以函数本体替换。在内存有限的机器上,不宜过多使用inline函数。即使拥有虚拟内存,inline造成的代码膨胀亦会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instructioncache hit rate),以及伴随而来的效率损失。如果inline函数本体很小,编译器对“函数本体”产出的码可能比“函数调用”所产出的码更小;那么将函数inlining确实会减小目标码和提高高速指令高速缓存装置的击中率。
inline函数只是一个申请,而不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义在class定义式内,或者friend函数定义在class内,他们被隐喻声明为inline。明确声明inline函数做法是在其定义式前面加上关键字inline。
Inline函数通常一定被置于头文件内,因为大多数build environment在编译过程进行inlining,而为了讲一个“函数调用”替换为“被调用函数的本体”,编译器需要函数长什么样子。Inlining在大多数c++程序中是编译期行为。
大部分编译器拒绝将太过复杂的函数inlining,而所有的virtual函数都不能inlining,因为virtual意味着“等待,直到运行期才确定调用哪个函数”,而inline意味“执行前,先将动作替换为被调用函数的本体”。如果编译器不知道该调用哪个函数,你就很难责备它们拒绝将函数本体inlining。
有些时候,即使编译器有意愿inlining某个函数,但还是会产生一个函数的本体,例如某个函数要使用某个inline函数的地址(指向函数的指针),编译器通常必须为此函数生成一个outlined函数本体。这意味着inline函数的调用有可能被inlined,也可能不被inlined,取决于该调用实施方式:
inline void f(){…}
void (* pf)() =f;
…
f(); //这个调用被inlined,因为它是一个正常调用
pf(); //这个调用或许不被inlined,因为它通过函数指针达成
实际上构造函数和析构函数是inling的糟糕候选人。考虑一下代码:
class Base{
public:
...
private:
std::string bm1, bm2;
};
class Derived: public Base{
public:
Derived(){} // Derived构造函数是空函数,是吗?
...
private:
std::string dm1, dm2, dm3;
};
这个构造函数看起来是inlining的绝佳候选人,因为它根本不任何代码,但你的眼睛可能会欺骗你.我们来看它的等价代码:
Derived::Derived(){//"空白Derived构造函数"的观念性实现
Base::Base();//初始化"Base成分"
try{
dm1.std::string::string();
}catch(...){
Base::~Base();
throw;
}
try{
dm2.std::string::string();
}catch(...){
dm1.std::string::~string();
Base::~Base();
throw;
}
try{
dm3.std::string::string();
}catch(...){
dm2.std::string::~string();
dm1.std::string::~string();
Base::~Base();
throw;
}
}
这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更精致复杂的做法来处理异常。尽管如此,这已能准确反映Derived的空白构造函数必须提供的行为。
请记住:
1. 将大多数inlining限制在小型,被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
2. 不要因为function templates出现在头文件,就将它们声明为inline。
条款31:将文件间的编译依存关系降至最低
C++并没有把“将接口从实现中分离”做得很好,class定义式不只详细叙述了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 the BirthDate;
Address theAddress;
};
这样的class Person是无法通过编译的,通常还需要#include成员的定义式:
#include<string>
#include"date.h"
#include"address.h"
不幸的是,这样便在Person定义文件和其含入文件之间形成一种编译依存关系。如果这些头文件中有任何一个发生变化,或这些头文件所依赖的其他头文件有任何变化,那么每一个包含Personclass的文件就得重新编译,任何使用Person class的文件也必须重新编译。这种连串编译依存关系对项目造成的灾难时难以形容的。
针对Person我们可以这样做:把Person分割成两个classes,一个只提供接口,另一个负责实现该接口:
#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;//指针,指向实现物
};
这样的设计称为pimpl idiom(pimpl是pointerto implementation缩写)。Person的客户和Date、Address以及Person的实现细目分离了。classes的任何实现修改都不要客户端重新编译。此外,客户还无法看到Person的实现细目,也就不会写出“取决于那些细目的代码”,真正实现了“接口与实现分离”。这个分离的关键在于“声明的依存性”替换了“定义的依存性”,这正是编译依存性最小化的本质:现实中,让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明(而非定义)相依。以下每一件事都是源于这个简单的设计策略:
- 如果使用object references或object pointers可以完成任务,就不要使用object。因为只靠一个类型声明式就定义出指向该类型的references和pointers,而定义某类型的objects需要使用该类型的定义式。
- 如果可以,尽量以class声明式替换class定义式。注意,当声明函数而它使用某个class时,并不需要该class的定义;即使是by value方式传递该类型参数或返回值,都不需要class定义。但是在使用这些函数时,这些classes在调用函数前一定要先曝光。客户终究是要知道classes的定义式,但是这样做的意义在于:将“提供class定义式”(通过#include完成)的义务从“函数声明所在”头文件转移到“内含函数调用”的客户文件。
- 为声明式和定义式提供两个不同的头文件。程序中,不应该让客户给出前置声明,程序作者一般提供两个头文件,一个用于声明式,一个用于定义式。在C++标准库的头文件中(条款54),<iosfwd>内含iostream各组件的声明式,其对应定义分布在不同文件件,包括<sstream>,<streambuf>,<fstream>,<iostream>。
另一种实现Handle class的办法,那就是令Person成为一种特殊的abstractbase class(抽象基类),称作Interface class。这样的class只是描述derivedclasses接口(条款 34),通常不带成员变量,也没有构造函数,只有一个virtual析构函数( 条款 7)和这一组pure virtual函数,用来叙述整个接口。针对Person而写的Interfaceclass或许看起来像这样:
classPerson{
public:
virtual ~Person();
virtual std::string name() const=0;
virtual std::string birthDate()const=0;
virtual std::string address()const=0;
……
};
这个class的客户必须以Person的pointers和references来使用Person,就像Handleclasses客户一样,除非interface class的接口被修改,否则其客户不需要重新编译。Interfaceclass的客户必须有办法为class创建新对象,此函数扮演“真正将被具体化”的那个derived classes的构造函数的角色。这样的函数通常叫做工厂函数factory(条款13)或virtual构造函数,它们返回指针(或更为可取的智能指针,条款18),指向动态分配所得对象,这个对象支持Interfaceclass接口。这样的函数通常在Interface class声明为static:
class Person{
public:
……
static std::tr1::shared_ptr<Personcreate(……)
……
};
Handle classes和Interface classes解除了接口和实现之间的耦合关系,从而降低了编译依存性。但是为了也带了一些代价:使你丧失了运行期间若干速度,又开辟了超出对象若干内存。
· 在Handle classes身上,成员函数通过implementationpointer取得对象数据。这样为访问增加了一层间接性,每一个对象消耗的内存也必须增加implementation pointer的大小。implementationpointer的初始化,将蒙受因动态内存分配(及其后的释放动作)而来的额外开销,以及可能遭遇bad_alloc异常。
· 在Interface classes身上,每个函数都是virtual的,所以每次函数调用要付出一个间接跳跃(indirectjump)成本。此外其派生对象会有一个vptr(virtual table pointer,条款7),可能会增加对象所需内存数量。
请记住:
1. 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义时。基于此构想的两个手段是Handle classes和Interface classes。
2. 程序库头文件应该以“完全且仅有声明式”(full and declaration-only forms)的形式存在。不论是否这几templates,这种做法都是适用。