实现 Implementations
大多数情况下,适当提出你的classes(和class templates)定义以及functions(和function templates)声明,是最复杂的两件事。一旦正确完成它们,相应的实现大多直截了当。尽管如此,还是有些东西需要注意。太快定义变量可能造成效率上的拖延;过度使用转型可能导致代码变慢且难以维护,还有一些难解的错误;未考虑异常带来的冲击则可能导致资源泄露和数据败坏;过度热心地inlining可能引起代码膨胀;过度耦合则可能导致让人不满意的冗长建置时间(build times)。
26、尽可能延后变量定义式的出现时间
Postpone variable definitions as long as possible.
只要定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流到达这个变量定义式时,便要承受构造成本,当这个变量离开其作用域时,便要承受析构成本。即使这个变量最终并未被使用,仍需要耗费这些成本,应该尽可能 避免这种情形。
如下代码,计算通行密码的加密版本后返回,前提是密码够长,如果密码太短,函数会抛出一个异常,类型为logic_error:
std::string encryptPassword(const std::string& password)
{
using namespace std;
string encrypted;
if(password.length() < MinimumPasswordLength)
{
throw logic_error(“Password is too short”);
}
… //必要动作,加密后的密码放入到encrypted内
return encrypted;
}
对象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;
… //必要动作,加密后的密码放入到encrypted内
return encrypted;
}
但是这段代码仍然不够完美,因为encrypted虽然定义却无任何实参作为初值。意味着调用的是default构造函数。许多时候应该对对象做的第一次事就是给它个值,通常通过一个赋值动作达成。假设如下函数为对数据加密:
void encrypt(std::string& s);
//最高效的实现如下:
std::string encryptPassword(const std::string& password)
{
…
std::string encrypted(password);
encrypt(encrypted);
return encrypted;
}
对于循环,比如有如下实现:
//方法A:定义于循环外
Widget w;
for(int I = 0; I < n; ++i)
{
w = …;
…
}
//方法B:定义于循环内
for(int I = 0; I < n; ++i)
{
Widget w(…);
…
}
做法A成本:1个构造函数+1个析构函数 + n个赋值操作
做法B成本:n个构造函数+n个析构函数
如果classes的一个赋值成本低于一组构造+析构成本,做法A大体比较高效。尤其当n值很大的时候。否则做法B或许较好。此外做法A造成名称w的作用域(覆盖整个循环)比做法B更大,有时候对程序的可理解性和易维护性造成冲突。因此除非直到赋值成本比构造+析构成本低,正在处理代码中效率高度敏感的部分,否则应该使用做法B。
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
27、尽量少做转型动作
Minimize casting.
C++规则的设计目标之一是保证“类型错误”绝不可能发生。理论上如果程序很干净地通过编译,就表示它并不企图在任何对象身上执行任何不安全、无意义、愚蠢荒谬的操作。但是转型破坏了类型系统。那可能导致任何种类的麻烦。
//C风格转型,旧式转型
(T)expression; //将expression转型为T
T(expression); //将expression转型为T
//C++提供的新式转型
const_cast<T>( expression);
dynamic_cast<T>( expression);
reinterpret_cast<T>( expression);
static_cast<T>( expression);
各有不同的目的:
const_cast通常被用来将对象的常量性转除(cast away the constness)。它也是唯一有此能力的C+±style转型操作符。
dynamic_cast主要用来执行“安全向下转型”(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语句执行的动作,也是唯一可能耗费重大运行成本的转型动作。
reinterpret_cast意图执行低级转型,实际动作及结果可能取决于编译器,这也就表示它不可移植。例如将pointer to int转型为一个int。这类转型在低级代码以外很少见。
static_cast用来强迫隐式转换(implict conversions),例如将non-const对象转为const对象,将int转为double等等。它也可以执行上述多种类型的反向转换,例如将void*指针转为typed指针,将pointer-to-base转为pointer-to-derived。但它无法将const转为non-const整个只有const_cast才可以。
旧式转型合法,但是新式转型较受欢迎。原因:它们很容易在代码中被辨识出来;各转型动作的目标愈窄化,编译器可能诊断出错误的运用。唯一使用旧式转型的时机是当要调用一个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。
关于转型很容易写出某些似是而非的代码。例如应用框架都要求derived classes内的virtual函数代码的第一个动作就先调用base class的对应函数。如有个Window基类和一个SpecialWindow派生类,两者都定义了virtual函数onResize。SpecialWindow的onResize函数被要求首先调用Window的onResize。下面实现看起来对实际是错误的:
class Window{
public:
virtual void onResize() { … } //基类onResize实现代码
…
};
class SpecialWindow:public Window{
public:
virtual void onResize() //派生类onResize实现代码
{
static_cast<Window>(*this).onResize(); //转型调用,错误的
…
}
};
这段代码中看起来将*this转型为Window,对函数onResize也调用了,但是调用的并不是当前对象上的函数。而是转型动作所建立的一个“*this对象的基类成分”的暂时副本身上的onResize!解决办法就是去掉转型动作如下:
class SpecialWindow:public Window{
public:
virtual void onResize() //派生类onResize实现代码
{
Window::onResize(); //调用Window::onResize作用与*this身上
…
}
};
dynamic_cast的许多实现版本执行速度相当慢。例如一个基于class名称的字符串比较,如果在四层深得单继承体系内的某个对象身上执行dynamic_cast,可能会造成多达四次的strcmp调用用以比较class名称。深度继承或多重继承的成本更高!在注重效率的代码中对dynamic_cast的使用要高度谨慎。之所以需要dynamic_cast,通常是因为想在一个认定为派生类对象身上执行派生类操作函数,但是只有一个指向基类的指针或引用,只能利用这个来处理对象。有两个一般性做法可以避免这个问题。
第一,使用容器并在其中存储直接指向派生类对象的指针(通常是智能指针)如此便消除了“通过基类接口处理对象”的需要。假设Window/SpecialWindow继承体系中只有SpecialWindow才支持闪烁效果,不要这样实现:
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)
{ //不要用dynamic_cast,影响效率
if(SpecialWindow* psw = dynamic_cast< SpecialWindow *>(iter->get()))
psw->blink();
}
应该改为:
typedef std::vector<std::tr1::shared_ptr<SpecialWindow> > VPW;
VPW winPtrs;
…
for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
(*iter)->blink();
}
但是如此做的话无法在同一个容器内存储指向所有可能的各种Window派生类指针。这样就需要多个容器,且都必须具备类型安全性。
第二种做法可通过基类接口处理“所有可能的各种Window派生类”,那就是在基类内提供virtual函数作想对各个Window派生类做的事。例如只有SpecialWindow可以闪烁,将闪烁函数声明在基类内并提供一份什么也没做的缺省实现码:
class Window{
public:
virtual void blink() { } //缺省实现代码啥也不做
…
};
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)
{
(*iter)->blink();
}
特别注意绝对必须避免就是所谓的连串的dynamic_cast,这样的代码又大又慢,而且基础不稳。优良的C++代码很少使用转型。
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
- 宁可使用C+±style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
28、避免返回handles指向对象内部成分
Avoid returning “handles” to object internals.
如有一个程序涉及矩形,每个矩形有左上角和右下角表示。将定义矩形的这些点存放在一个辅助的struct内,再让Rectangle去指向它:
class Point{
public:
Point(int x, int y);
…
void setX(int newVal);
void setY(int newVal);
};
Struct RectData{
Point ulhc; //左上
Point lrhc; //右下
};
class Rectangle{
…
private:
std::tr1::shared_ptr<RectData> pData;
};
Rectangle的使用者必须能够计算Rectangle的范围,所以这个class提供upperLeft函数和lowerRight函数,这两个函数返回references。代表底层的Point对象:
class Rectangle{
public:
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData-> lrhc; }
…
};
但是这样的设计虽然可通过编译,但却是错误的。因为虽然upperLeft函数和lowerRight函数被声明为const成员函数,目的是为了提供一个得知Rectangle相关坐标点的方法,而不是让调用者修改Rectangle。两个函数却都返回references指向private内部数据,调用者于是可通过这些references更改内部数据。如果它们返回的是指针或迭代器,相同的情况还是会发生。References、指针和迭代器都是所谓的handles。为了让调用者不能更改内部数据,对它们的返回值类型加上const:
class Rectangle{
public:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData-> lrhc; }
…
};
这样实现,不再允许调用者更改对象状态。但是两个函数还是返回了“代表对象内部”的handles,有可能在其他场合带来问题。可能导致dangling handles(空悬的句柄):这种handles所指东西(的所属对象)不复存在。例如某个函数返回GUI对象的外框(bounding box):
class GUIObject{ … };
const Rectangle boundingBox(const GUIObject& obj);
//使用
GUIObject* pgo; //对象pgo指向某个GUIObject
…
Const Point* pUpperLeft = &( boundingBox(*pgo).upperLeft());//取得一个指针指向外框左上点
对boundingBox的调用获得一个新的/暂时的Rectangle对象。这个对象没有名称。先称为temp。随后upperLeft作用与temp身上,返回一个reference指向temp的一个内部成分。但是在语句结束之后,boundingBox的返回值被销毁,这将导致temp内的Points析构。最终导致pUpperLeft指向一个不存在的对象。也就变成了空悬、虚吊(dangling)。
这并不意味绝对不可以让成员函数返回handle。有时候必须这样做,例如operator[]就允许“摘采”strings和vectors的个别元素,而这些operator[]s就是返回references指向“容器内的数据”,那些数据会随着容器被销毁而销毁。
- 避免返回handles(包括references、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊句柄”(dangling
handles)的可能性降至最低。
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); //加锁
delete bgImage; //去掉旧的背景图像
++imageChanges; //修改变更次数
bgImage = new Image(imgSrc); //加载新的背景图像
unlock(&mutex); //解锁
}
这个函数从异常安全性的观点来看很糟糕,异常安全性有两个条件:当异常抛出时,不泄露任何资源(上述代码一旦“new Image(imgSrc)”导致异常,对unlock的调用绝不会执行,互斥锁就被永远锁住);不允许数据败坏(如果“new Image(imgSrc)”抛出异常,bgImage就是指向一个已被删除的对象,imageChanges也已被累加,而实际并没有新的图像被成功安装)。
解决资源泄露问题可以使用对象管理资源,导入一个Lock class作为一种“确保互斥锁被及时释放”的方法:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex); //加锁
delete bgImage; //去掉旧的背景图像
++imageChanges; //修改变更次数
bgImage = new Image(imgSrc); //加载新的背景图像
}
异常安全函数提供三个保证之一:基本承诺(如果异常被抛出,程序内的任何事物仍然保持在有效状态下)、强烈保证(如果异常被抛出,程序状态不改变)、不抛掷保证(承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能)。
对changeBackground而言,可以通过提供强烈保证保证异常安全。首先改变PrettyMenu的bgImage成员变量的类型,从一个类型为Image*的内置指针改为一个“用于资源管理”的智能指针。重新排列changeBackground内的语句次序,使得在更换图像之后才累加imageChanges,这个实现不再需要手动delete旧图像,因为智能指针内部处理掉了:
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;
}
上述实现几乎足够让changeBackground提供强烈的异常安全保证。美中不足的是参数imgSrc。如果imgSrc构造函数抛出异常,有可能输入流得读取记号已被移走,而这样的搬移对程序其余部分是一种可见的状态改变。所以changeBackground在解决这个问题之前只提供基本的异常安全保证。一般化的设计策略很典型地会导致强烈保证,很值得熟悉它。这个策略被称为copy and swap。具体细节为打算修改的对象原件做出一份副本,然后再那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持为本改变状态。所以改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。
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(pImpl, pNew); //置换数据
}
- 异常安全函数即使发生异常也不会泄露资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
- “强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
- 函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的“异常安全保证”中的最弱者。
30、透彻了解inlining的里里外外
Understand the ins and outs of inlining.
Inline函数可以调用它们不需要蒙受函数调用所招致的额外开销,编译器最优化机制通常被设计用来浓缩那些“不含函数调用”的代码,所以对它执行语境相关最优化。但是inline函数会将对此函数的每一个调用都以函数本体替换,这回增加目标码(object code)大小。造成的代码膨胀会导致额外的换页行为,降低指令高速缓存装置的击中率以及伴随这些而来的效率损失。inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于class定义式内:
class Person{
public:
int age() const { return theAge; } //一个隐喻的inline申请
private:
int theAge;
};
明确的inline函数的做法是在其定义式前加上关键字inline。例如标准的max template这样实现出来:
template<typename T>
inline const T& std::max(const T& a, const T& b){ return a < b ? b : a; }
inline函数通常一定被置于头文件内,因为大多数构建环境(build environments)在编译过程中进行inlining,而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样子。某些构建环境可以在连接期完成inlining,少量构建环境可在运行期完成inlining。
大部分编译器拒绝将太多复杂(例如带有循环或递归)的函数inlining,而所有对virtual函数的调用也都会使inlining落空。意味着一个表面看似inline的函数是否真是inline,取决于构建环境。
- 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可以潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为function templates出现在头文件,就将它们声明为inline。
31、将文件间的编译依存关系降至最低
Minimize compilation dependencies between files.
假设对C++程序的某个class实现文件做了些轻微修改。然后重新构建这个程序,然后预计花数秒就好了,因为只有一个class被修改。但是真实情况是整个项目被重新编译和连接了。这个问题出在C++并没有把“将接口从实现中分离”这件事做得很好。Class的定义不只详细叙述了class接口,还包括十足的实现细目。例如:
class Person{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birtjDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthdate;
Address theAddress;
};
这里的class Person无法通过编译,因为编译器无法取得其实现代码所用到的classes string,Date和Address的定义式。定义式通常有#include指示符提供,所以在此文件最上方应该存在:
#include <string>
#include “date.h”
#include “address.h”
但是如此做的话Person定义文件和其含入文件之间形成了一种编译依存关系。如果这些头文件中有任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个含入Person class的文件就得重新编译,任何使用Person class的文件也必须重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。
如将实现细目置于class定义式中,这样只需要在Person接口被修改时才重新编译:
#include <string> //标准程序库不该被前置声明
#include <memory> //为了使用智能指针
class PersonImpl; // Person实现类的前置声明
class Date; // Person接口用到的classes的前置声明
class Address;
class Person{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birtjDate() const;
std::string address() const;
private:
std::tr1::shared_ptr< PersonImpl > pImpl; //指针,指向实现物
};
上述实现中class(Person)只内含一个指针成员,指向其实现类(PersonImpl)这种设计常被称为pointer to implementation。这种设计下Person的调用者就完全与Dates,Addresses以及Persons的实现细目分离了。那些classes的任何实现修改都不需要Person客户端重新编译。此外由于调用者无法看到Person的实现细目,也就不可能写出什么“取决于那些细目”的代码,真正的接口与实现分离。
这个分离的关键在于以“声明的依存性”替换“定义的依存性”,正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每一件事都源自于这个简单的设计策略:如果使用object references或object pointers可以完成任务,就不要使用objects。如果能够,尽量以class声明式替换class定义式。为声明式和定义式提供不同的头文件。
像Person这样使用pimpl idiom的classes往往被称为Handle classes。例如Person两个成员函数的实现:
#include “Person.h”
#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();
}
Person构造函数以new调用PersonImpl构造函数以及Person::name函数内调用PersonImpl::name。让Person变成一个Handle class并不会改变它做的事,只会改变它做事的方法。
另一个制作Handle class的办法是,令Person成为一种特殊的abstract base class(抽象基类),称为Interface class。这种class的目的是详细—描述派生类的接口,因此它通常不带成员变量,也没有构造函数,只有一个virtual析构函数以及一组pure virtual函数,用来叙述整个接口。
class Person{
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birtjDate() const = 0;
virtual std::string address() const = 0;
};
这个class的使用者必须以Person的pointers和references来撰写应用程序,因为它不可能针对“内含pure virtual函数”的Person classes创建出实体。然而却有可能对派生自Person的classes创建出实体。除非Handle classes的接口被修改否则其使用者不需要重新编译。
假设Interface class Person有个可以创建实体的派生类RealPerson,后者提供继承而来的virtual函数的实现:
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 birtjDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthdate;
Address theAddress;
};
//有了RealPerson之后,Person::create创建对象可以如下实现:
std::tr1::shared_ptr<Person> Person::create(std::string& name, const Date& birthday, const Address& addr)
{
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
Person::create实现代码可以根据额外的参数值等创建不同类型的派生类对象。
- 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle
classes和Interface classes。 - 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及template都适用。
上一篇: C++进阶_Effective_C++第三版(四) 设计与声明 Designs and Declarations
下一篇: C++进阶_Effective_C++第三版(六) 继承与面向对象设计 Inheritance and Object-Oriented Design