五、实现
(26):尽可能延后变量定义式的出现时间
只要你定义一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流(control flow)到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受其析构成本。
std::string encryptPassword(const std::string& password)
{
using namespace std;
string encrypted;
if(password.length() < MinimumPasswordLength)
{
throw logic_error("Password is too short");
}
...
return encrypted;
}
如果抛出异常,encrypted变量就没有用到,所以最好延后encrypted的定义式,直到需要它:
std::string encryptPassword(const std::string& password)
{
using namespace std;
if(password.length() < MinimumPasswordLength)
{
throw logic_error("Password is too short");
}
string encrypted;
...
return encrypted;
}
为了提高效率可以使用如下做法:
std::string encryptPassword(const std::string& password)
{
... //检查length,如前
std::string encrypted; //default-construct encrypted
encrypted = password; //赋值给encrypted
encrypt(encrypted);
return encrypted;
}
更受欢迎的做法是跳过毫无意义的default构造过程:
std::string encryptPassword(const std::string& password)
{
... //检查length,如前
std::string encrypted(password); //通过copy构造函数定义并初始化
encrypt(encrypted);
return encrypted;
}
你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。更深一层说,以“具明显意义之初值”将变量初始化,还可以附带说明变量的目的。
对应循环如何处理:
//方法A:定义于循环外 //方法B:定义于循环外
Widget w;
for(int i = 0;i < n;++i) for(int i = 0;i < n;++i)
{ {
w = 取决于i的某个值; Widget w(取决于i的某个值);
... ...
} }
在Widget函数内部,以上两种写法的成本如下:
A :1个构造函数+1个析构函数+n个赋值操作
B:n个构造函数+n个析构函数
除非你知道赋值成本比“析构+构造”成本低,你处理代码中效率高度敏感(performance-sensitive)的部分,否则你应该使用做法B。
请记住:
尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
(27):尽量少做转型动作
(T)expression和T(expression)为两种“旧式转型(old-style casts)”。
C++提供了四种新式转型为:
const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)
新式转型比旧式转型更受欢迎,第一,他们很容易在代码中被辨别出来。第二,各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。
我唯一使用旧式转型的时机是,当我要调用一个explicit构造函数将一个对象传递给一个函数时。
class Widget {
public:
explicit Widget(int size);
...
};
void doSomeWork(const Widget& w);
doSomeWork(Widget(15)); //以一个int加上“函数风格”的转型动作创建一个Widget。
doSomeWork(static_cast<Widget>(15)) //以一个int加上“C++风格”的转型动作创建一个Widget。
任何一个类型转换(不论是通过转型操作而进行的显示转换,或通过编译器完成的隐式转换)往往真的令编译器编译出运气期间执行的码。
对象的布局方式和它们的地址计算方式随编译器的不同而不同,那意味“由于知道对象如何布局”而设计的转型,在某一平台行得通,在其他平台并不一定行的通。
另一个关于转型的有趣事情是:我们容易写出某些似是而非的代码(在其他语言中也许真是对的)。
class Window { //base class
public:
virtual void onResize() { ... } //base onResize实现代码
...
}
class SpecialWindow:public Window { //derived class
public:
virtual void onResize() { //derived onResize实现代码
static_cast<Window>(*this).onResize(); //将*this转型为Window,然后调用其onResize;这不可行
... //这里进行SpecialWindow专属行为
}
...
};
一如你所预期,这段程序将*this转型为Window,对函数onResize的调用也因此调用了Window::onResize。但恐怕你没想到,它调用的并不是当前对象的函数,而是稍早转型动作所建立的一个“*this对象之base class成分”的暂时副本身上的onResize!(译注:函数就是函数,成员函数只有一份,“调用起哪个对象身上的函数”有什么关系呢?关键在于成员函数都有个隐藏的this指针,会因此影响成员函数操作的数据。) 再说一次,上述代码并非在当前对象身上调用Window::onResize之后又在该对象身上执行SpecialWindow专属动作。不,它是在“当前对象之base class成分”的副本上调用Window::onResize,然后执行SpecialWindow专属动作。如果Window::onResize修改了对象内容(不能说没有可能性,因为onResize是个non-const成员函数),当前对象其实没被改动,改动的是副本。然而SpecialWindow::onResize内如果也修改对象,当前对象真的会被改动。这使当前对象进入一种“伤残”状态:其base class成分的更改没有落实,而derived class成分的更改到时落实了。
解决之道是拿掉转型动作,代之以你真正想说的话:
class SpecialWindow:public Window {
public:
virtual void onResize() {
Window::onResize(); //调用Window::onResize作用于*this身上
...
}
}
dynamic_cast的许多实现版本指向速度相当慢。之所以需要dynamic_cast,通常是因为你想在一个你认定为derived class对象身上指向derived class操作函数,但你的手上却只有一个“指向base”的pointer或reference,你只能靠它们来处理对象。有两个一般性做法可以避免这个问题。
第一,使用容器并在其中存储直接指向derived class对象的指针(通常是智能指针),如此便消除了“通过base class接口处理对象”的需要。
class Window { ... };
class SpecialWindow:public Window {
public:
void blink();
...
};
typedef std::vector<std::tr1::shared_ptr<Window>> VPM;
VPM winPtrs;
...
for(VPM::iterator iter = winPtrs.begin();iter != winPtrs.end();++iter)
{
if(SpecialWindow * psw = dynamic_cast<SpecialWindow *>(iter->get()))
psw->blink();
}
应该改为如下:
typedef std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSM;
VPSM winPtrs;
...
for(VPSM::iterator iter = winPtrs.begin();iter != winPtrs.end();++iter)
{
(*iter)->blink(); //这样写比较好,不用dynamic_cast
}
另一种如下:
class Window {
public:
virtual void blink() {}
...
};
class SpecialWindow:public Window {
public:
void void blink() { ... };
...
};
class SpecialWindow:public Window {
public:
void blink();
...
};
typedef std::vector<std::tr1::shared_ptr<Window>> VPSM;
VPSM winPtrs;
...
for(VPSM::iterator iter = winPtrs.begin();iter != winPtrs.end();++iter)
{
(*iter)->blink(); //这样写比较好,不用dynamic_cast
}
不论哪一种写法——“使用类型安全器”或“将virtual函数往继承体系上方移动”——都并非放之四海而皆准,但在许多情况下它们都提供一个可行的dynamic_cast替代方案。
绝对必须避免的一件事是所谓的“连串(cascading)dynamic_casts”。
请记住:
如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
宁可使用C++-style(新式)转型,不要使用旧式转型。前者很容易辨别出来,而且也比较有着分门别类的职掌。
(28):避免返回handles指向对象内部成分
class Point {
public:
Point(int x,int y);
...
void setX(int newVal);
void setY(int newVal);
};
strcut RectData { //这些“点”数据用来表现一个矩形
Point ulhc; //ulhc="upper left-hand coner"(左上角)
Point lrhc; //lrhc="lower right-hand coner"(右下角)
};
class Rectangle {
public:
...
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
...
private:
std::tr1::shared_ptr<RectData> pData;
};
这样的设计可通过编译,但却是错误的。一方面upperLeft和lowerRight被声明为const成员函数,因为它们的目的只是为了提供客户一个得知Rectangle相关坐标点的方法,而不是让客户修改Rectangle。另一方面两个函数却都返回reference指向private内部数据,调用者于是可通过这些reference更改内部数据。
这立刻带给我们两个教训:成员变量的封装性最多只等于“返回其reference”的函数的访问级别。本例之中虽然ulhc和lrhc都被声明为private,它们实际上却是public,因为public函数upperLeft和lowerRight传出了它们的reference。如果成员函数传出一个reference,后者所指数据与对象自身有关联,而它有被存储于对象之外,那么这个函数的调用者可以修改那笔数据。
如果它们返回的是指针或迭代器,相同情况也会发生,原因也相同。Reference、指针和迭代器统统都是所谓的handles(号码牌,用来取得某个对象),而返回一个“代码对象内部数据”的handle,随之而来的便是“降低对象封装性风险”。同时,它也可能导致“虽然调用const成员函数却造成对象状态被更改”。
“内部”不仅指成员变量,其实不被公开使用的成员函数也算。
通过在返回类型加上const即解决问题:
class Rectangle {
public:
...
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
...
};
上面的函数仍然返回了“代表对象内部”的handle,有可能在某些场合带来问题。更明确说,它可能导致dangling handles(空悬的号码牌):这种handles所指东西(的所属对象)不复存在。这种“不复存在的对象”最常见的来源就是函数返回值。
class GUIObject { ... };
class Rectangle boundingBox(const GUIObject& obj); //以by value方式返回一个矩形
调用如下:
GUIObject* pgo;
...
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft);
一旦产出pUpperLeft的那个语句结束,pUpperLeft也就变成空悬、虚吊(dangling)!
请记住:
避免返回handles(包括referneces、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”(dangling handles)的可能性降至最低。
(29):为“异常安全”而努力是值得的
class PrettyMenu {
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); //释放互斥器
}
从“异常安全性”的观点来看,上面的函数很糟。
当异常被抛出时,带有异常安全性的函数会:
不泄露任何资源。 不允许数据败坏。
使用资源管理类Lock很容易的防止资源泄露:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
delete bgImage; //摆脱旧的背景图像
++imageChanges; //修改图像变更次数
bgImage = new Image(imgSrc); //安装新的背景图像
}
异常安全函数(Exception-safe functions)提供以下三个保证之一:
基本承诺:如果异常抛出,程序内的任何事物仍然保持在有效状态下。
强烈保证:如果抛出异常,程序状态不改变。
nothrow保证:承诺不抛出异常,因为它们总是能够完成原先承诺的功能。
不要为了表示某件事情发生而改变对象状态,除非那件事情真的发生了。
class PrettyMenu {
...
std::tr1::shared_ptr<Image> bgImage;
...
}
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc)); //以“new Image”的执行结果设定bgImage内部指针
++imageChanges;
}
copy and swap策略可以导致强烈保证。
实现上通常是将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object,即副本)。这种手法常被称为pimpl idiom。
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;
Lock ml(&mutex);
std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pIml,pNew);
}
"copy-and-swap"策略是对对象状态做出“全有或全无”改变的一个很好办法,但一般而言它并不保证整个函数有强烈的异常安全性。
void someFunc()
{
...
f1();
f2();
...
}
如果f1()或f2(0的异常安全性比“强烈保证”低,就很难让someFunc成为“强烈异常安全”。
“强烈保证”需要保留一个对象的副本,有时不切实际,你就必须提供“基本保证”。
请记住:
异常安全函数(Exception-safe functions)即使发生异常也不会泄露资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
“强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备实现意义。
函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
(30):透视了解inlining的里里外外
inline函数可以“免除函数调用成本”。inline函数将“对此函数的每一个调用”都有函数体替换之,可能增加你的目标码(object code)大小。
inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。
class Person {
...
int age() const { return theAge; } //一个隐喻的inline申请:age被定义于class定义式内。
...
private:
int theAge;
}
这样的函数通常是成员函数,有时friend函数也可以,只有定义于class内。
明确申请inline如下:
template<typename T>
inline const T& std::max(const T& a,const T& b)
{ return a < b ? b : a; }
大部分编译器拒绝太过复杂(例如带有循环或递归)的函数inlining,而所有对virtual函数的调用(除非最平淡无奇)也会使inling落空。virtual意味“等待,直到运行期才确定调用哪个函数”,而inline意味“执行前,先调用动作替换为被调用函数的本体”。如果编译器不知道该调用哪个函数,你就很难责备他们拒绝将函数本体inlining。
这些叙述整合起来的意思就是:一个表面上看似inline的函数是否真inline,取决于你的建置环境,主要取决于编译器。
编译器通常不对“通过函数指针而进行的调用”实施inlining,这以为这inline函数的调用有可能被inlined,也可能不被inlined,取决于该调用的实施方式:
inline void f() { ... }
void (*pf)() = f;
...
f(); //这个调用将被inlined,因为它是一个正常调用。
pf(); //这个调用或许不被inlined,因为它通过函数指针完成。
实际上构造函数和析构函数往往是inlining的糟糕候选人。C++对于“对象被创建和被销毁时发生什么事”做了各式各样的保证。当你使用new,动态创建的对象被其构造函数自动初始化;当你使用delete,对应的析构函数会被调用。当你创建一个对象,其每一个base calss及每一个成员变量都会被自动构造;当你销毁一个对象,反向程序的析构行为亦会自动发生。异常抛出时,也有相应的处理程序。
Derived::Derived() //“空白Derived构造函数”的观念性实现
{
Base::Base(); //初始化“Base成分”
try { dml.std::string::string(); } //试图构造dml。
catch (...) { //如果抛出异常就销毁base class成分,并传播该异常
Base::~Base();
throw;
}
try { dm2.std::string::string(); } //试图构造dm2。
catch (...) { //如果抛出异常就销毁dml,销毁base class成分,并传播该异常
dml.std::string::~string();
Base::~Base();
throw;
}
...
}
这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更精致复杂的做法来处理异常。
inline函数无法随着程序库的升级而升级。
请记住:
将大多数inlining现在在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binaery upgradability)更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
不要只因为function template出现在头文件,就将它们声明为inline。
(31):将文件间的编译依存关系降至最低
对某个C++类文件进行轻微修改就要重新编译,问题出在C++并没有“将接口从实现中分离”这事做的很好。Class的定义式不只详细叙述了class接口,还包括十足的实现细目。
class Person {
public:
Person(const std::string& name,cosnt 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; //实现细目
}
上面的Person类不能通过编译,需要#include<string> #include "date.h" #include "address.h",这样一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果任何一个改变,Person将重新编译。
namespace std {
class string;
}
class Date;
class Address;
class Person {
public:
Person(const std::string& name,cosnt Date& birthday,const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
}
如果这样做Person的客户就只需要在Person接口被修改时才重新编译。
存在两个问题:第一,string不是个class,它是个typedef(定义为basic_string<char>)。第二,当编译器看到Person定义式时,它也知道必须分配足够空间以放置一个Person。编译器获得这项信息的唯一办法是询问class的定义式。
将一个指针指向Person就可以解决问题二。把Person分割为两个classes,一个只提供接口,另一个负责接口实现。
Person将定义如下:
#include <string> //标准程序库组件不该被前置声明。
#include<memory> //此乃为了tr1::shared_ptr而含入;
class PersonImpl; //Person实现类的前置声明。
class Date; //Person接口用到的classes的前置声明。
class Address;
class Person {
public:
Person(const std::string& name,cosnt 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只内含一个指针成员(这里使用tr1::shared_ptr),指向实现类(PersonImpl)。这般设计常被称为pimpl idiom(pimpl是“pointer to implementation”的缩写)。这样便实现了“接口与实现分离”。这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件的声明式(而非定义式)相依。其他每一件事都源自这个简单的设计与策略:
如果使用object references或object pointers可以完成任务,就不要使用objects。
如果能够,尽量以class声明式替换class定义式。
为声明式和定义式提供不同的头文件:
为了促进严守上述准则,需要两个头文件,一个拥有声明式,一个用于定义式。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。
像Person这样使用pimpl idiom的classes,往往被称为Handle classes。
#include "Person.h" //我们正在实现Person class,所以必须#include其class定义式(声明式?)
#include "PersonImpl.h" //我们也必须#include PersonImpl的class定义式,否则无法调用其成员函数;注 //意,PersonImpl有着和Person完全相同的成员函数,两者接口完全相同。
class Person::Person(const std::string& name,cosnt 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。Interface class的两个最常见机制之一:从Interface class继承接口规格,然后实现出接口所覆盖的函数。Interface class的第二个实现涉及多重继承。
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
}
调用方法(factoy(工厂)函数):
static :
class Person {
public:
...
static std::tr1::shared_ptr<Person> //返回一个tr1::shared_ptr,指向一个新的Person,并以给定之参
create(const std::string& name,cosnt Date& birthday,const Address& addr); //数初始化
...
}
客户会这样使用它们:
std::string name;
Date dateOfBirth;
Address address;
...
//创建一个对象,支持Person接口
std::tr1::shared_ptr<Person> pp(Person::create(name,dayeOfBirth,address));
...
std::cout << pp->name() //通过Person的接口使用这个对象
<< " was born on "
<< pp->birthDate()
<< " and now lives at "
<< pp->address();
... //当pp离开作用域,对象会被自动删除。
Interface class Person有个具象的derived class RealPerson,后者提供继承而来的virtual函数的实现:
class RealPerson : public Person {
public:
RealPerson(const std::string& name,cosnt 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,cosnt Date& birthday,const Address& addr)
{
return std::tr1::shared_ptr<Person>(new RealPerson(name,birthday,addr));
}
Handle classes和Interface classes解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性(compilation dependencies)。
请记住:
支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
程序库头文件应该以“完全且仅有声明式”(full and declaration-ony forms)的形式。这种做法不论是否涉及templates都适用。