五.实现

太快定义变量造成效率上的拖延;过度使用转型可能导致代码变慢又难维护;返回对象“内部数据之号码牌”可能会破坏封装并留给客户虚吊号码牌;未考虑资源异常带来的冲击则可能导致资源泄漏和数据败坏;过度热心地inlining可能引起代码膨胀;过度耦合则可能导致让人不满意的亢长建置时间。本章会给出避免这些问题的办法

条款26.尽可能延后变量定义式的出现时间

考虑下面这个函数:

std::string encryptedPassword(const std::string& password)
{
      using namespace std;
      string encrypted;
      if(password.length() < MinimumPasswordLength){
            throw logic_error("Password is too short");
      }
      ... //将加密密码写入到encrypted;
      return encrypted;
}

该函数有一个问题,当异常被丢出后变量encrypted没有被使用,但却消耗了构造成本。考虑直接在构造时指定初值,跳过毫无意义的default构造过程:

std::string encryptedPassword(const std::string& password)
{
      ...
      std::string encrypted(password); //通过copy构造函数定义并初始化
      encrypt(encrypted)
      return encrypted;
}     

由此带来“尽可能延后”的真正意义:不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。这样不仅能够避免构造(析构)非必要对象,还可以避免无意义的default构造行为。
在循环中,考虑:

//方法A                                    //方法B
Widget w;
for(int i = 0; i < n; ++ i){               for(int i = 0; i < n; ++ i){
      w = 取决于 i 的某个值;                      Widget w(取决于i的某个值);
      ...                                        ...
}                                          }

做法A:1个构造函数 + 1个析构函数 + n个赋值操作;
做法B:n个构造函数 + n个析构函数
如果classes的一个赋值成本低于一组构造+析构成本,做法A大体比较高效,尤其是当A比较大时,否则做法B比较好。此外,做法A导致名称w的作用域更大,有时会对程序的可理解性和易维护性带来冲突,因此除非你知道赋值成本比“构造+析构”成本低,(2)你正在处理代码中效率高度敏感的部分,否则应该使用做法B。
请记住

1.尽可能延后变量定义式的出现,这样可增加程序的清晰度并改善程序效率。

条款27.尽量少做转型动作

1)

C++规则的设计目标之一是,保证“类型错误”决不可能发生。但不幸的是,转型破坏了类型系统,可导致未定义的麻烦。首先回忆三种不同形式的转型语法:

//旧式转型
(T)expression //C风格
T(expression) //函数风格
//新式转型
const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)

其中新式转型各有不同的目的:
const_cast 通常用来将对象的常量性转除,也仅有它能完成这个工作
dynamic_cast 主要用来执行“安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型,它是唯一无法由旧式语法执行的动作,也是唯一可能耗费大运行成本的转型动作。
reinterpret_cast 意图执行低级转型,实际动作取决于编译器,这也就表示不可移植。例如将一个pointer to int 转型到int,这一类转型在低级代码以外很少见。
static_cast 用来强迫隐式转换,例如将non-const对象转为const对象,获奖int转换为double等等。还可以用来执行上述多种转换的反向转换,例如将void*指针转为typed指针,将pointer-to-base转换为pointer-to-derived。但它无法将const转化为non-const。
旧式转型仍然合法,但是新式转型更受欢迎,因为它们更容易被辨认出来;此外,转型目标的窄化使得编译器更可能诊断出潜在的错误。

2)

值得注意的是,任何一个类型转换往往真的令编译器编译出运行期间执行的码。例如:

int x, y;
...
double d = static_cast<double> (x) / y;

在大部分计算器体系结构中,int底层表述不同于double。此外见下面这个例子:

class Base {...};
class derived: public Base { ... };
derived d;
Base* pb = &d; //隐式地将derived*转换为Base*

上述仅是对子类父类的类型转换,但是有时候两个指针值并不相同。这种情况下会有个偏移量(offset)在运行期间被施行于derived指针身上,用以取得正确的Base指针。这个例子表明同一对象可能拥有一个以上的地址,这是C++语言的“特色”。实际上一旦使用多重继承,这事几乎一直发生。这偏移量意味着对象的布局方式和它们的地址计算方式随编译器的不同而不同,那意味着“由于直到对象如何布局”而设计的转型,在某一平台可行,但其他平台不可行。
关于转型的有趣事情还有,见下:

class Window{
public:
      virtual void onResize() { ... }
      ...
};
class SpecialWindow: public Window{
public:
      virtual void onResize() {
            static_cast<Window>(*this).onResize(); //将*this转型为Window,然后调用其专属方法onResize();
            ...//这里是SpecialWindow专属行为。
      }
      ...
};

这段代码看似完成了工作:将this转型为Window,对函数onResize的调用也因此调用了Window::onResize。但实际上,它调用的不是当前对象身上的函数,而是稍早转型动作所建立的一个“this对象之base class成分”的暂时副本身上的onResize!**换句话说,是在当前对象的base class成分的副本上调用Window::onResize,然后在当前对象上执行SpecialWindow专属动作。这会导致:如果Window::onResize修改了对象内容,当前对象其实没有被改动,改动的是副本。然而若SpecialWindow::onResize内如果也修改对象,则当前对象真的会被改动。解决办法是:

class SpecialWindow: public Window{
public:
      virtual void onResize(){
            Window::onResize(); //调用Window::onResize作用于*this身上
            ...
      }
      ...
}

在使用dynamic_cast的实现意图时应该注意到,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>> VPW;
VPW winPtrs;
...
for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++ iter){   //不希望使用
      if(SpeicalWindow *psw = dynamic_cast<SpecialWindow *> (iter->get()))
            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();
}  

另一种做法可以通过base class接口处理“所有可能之各种Window派生类”,就是在base class内提供virtual函数做想对各个Window派生类做的事。举个例子,虽然只有SpeicalWindow可以闪烁,但或许将闪烁函数声明于base class内并提供一份“什么也没做”的缺省实现代码是有意义的:

class Window{
public:
      virtual void blink() {}
      ...
};
class SpeicalWindow: public Window{
public:
      virtual 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_casts,这样会导致产生的代码又大又慢,并且不具备鲁棒性,基础不稳。
请记住

1.如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有设计需要转型动作,试着发展无需转型的替代设计。
2.如果转型是必要的,试着将它隐藏于某个函数背后,客户随后可以调用该函数,而不需亲自操作转型代码。
3.宁可使用C++新式转型,不要使用旧式转型,前者很容易辨识,且有着不错的职能分工。

条款28.避免返回handles指向对象内部成分

通常我们认为,对象的“内部”指它的成员变量,但其实不被公开使用的成员函数也是对象“内部”的一部分。因此也留心不要返回它们的handles,这会降低对象的封装性,有时会产生调用const成员函数却造成对象状态被更改的情况。

1)

若你不想返回它们的handles以导致内部数据被修改,那么只需对函数的返回类型加上const即可:

class Rectangle {
public:
      ...
      const Point& upperLeft() const { return pData->ulhc; }
      const Point& lowerRight() const { return pData->lrhc; }
      ...
};

然而该方法依然返回了代表“对象内部的handles”,对有些情况,调用函数有时会存在一个指向函数内部暂时的类对象,zhixiang此时若返回指向该暂时对象内部的handles,在该对象被销毁后最终会导致返回的handles指向空。这也是为何函数“返回一个handle代表对象内部成分”总是危险的原因。无论这个handle是指针、迭代器或reference,也不论这个handle是否为const,这里的唯一关键是有个handle被传出去了,就会带来handle比其所指对象更长寿的风险。但有时返回handles却是必须的,如operator[]访问string或者vector的内部个别元素。
请记住

1.避免返回handles(包括reference、指针、迭代器)指向对象内部,遵守这条守则会增加封装性,帮助const成员函数的行为更具备const性质,并将发生“虚吊号码牌”的可能性降至最低。

条款29.为“异常安全”而努力是值得肯定的

1)

假设有个class用来表现夹带背景图案的GUI菜单,这个class希望用于多线程环境,所以它有个互斥器作为并发控制之用。见如下代码:

class PrettyMenu{
public:
      ...
      void changeBackground(std::istream& imgSrc); //改变背景图像
      ...
private:
      Mutex mutex;
      Image* bgImage;
      int imageChanges;
};
//changeBackground的可能实现
void PrettyMenu::changeBackground(std::istream& image)
{
      lock(&mutex);  //取得互斥器;
      delete bgImage;  //摆脱旧图像
      ++ imageChanges;      //修改图像变更次数
      bgImage = new Image(imgSrc);  //安装新的背景图像
      unlock(&mutex);   //释放互斥
}

当异常被抛出时,带有异常安全的函数会:
不泄露任何资源。 上述代码没有做到这一点,一旦new Image(imgSrc) 导致异常,对unlock的调用就绝不会执行,于是互斥器永远得不到释放。
不允许数据败坏。 如果new Image(imgSrc)抛出异常,bgImage就指向一个已被删除的对象,而imageChanges也已被累加。
因此,利用条款14可以优化:

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
      Lock m1(&mutex); //获得互斥器并确保它稍后被释放
      delete bgImage;
      ++ imageChanges;
      bgImage = new Image(imgSrc);
}

2)

异常安全函数提供一下三个保证之一:
基本承诺: 如果异常被抛出,程序内的任何事物仍然保持在有效状态下,没有任何对象和数据被败坏,所有对象都处于一种内部前后一致的状态。
强烈保证: 如果异常被抛出,程序状态不改变。如果函数成功,则完全成功;如果失败,程序会回复到“调用函数之前”的状态。
不抛掷: 承诺绝不抛出异常,因为总是能够完成它们原先承诺的功能。
可能的话,请提供nothrow保证,但对大部分函数而言,抉择往往落在基本保证和强烈保证之间。
对changeBackground而言,提供强烈保证几乎不困难。首先改变PrettyMenu的bgImage成员变量的类型,从一个类型为Image*的内置指针改为一个“用于资源管理”的智能指针。这一操作可以防止资源泄漏。第二,重新排列changeBackground内的语句次序,使得在更换图像之外才累加imageChanges。

class PrettyMenu{
      ...
      std::tr1::shared_ptr<Image> bgImage;
      ...
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
      Lock m1(&mutex);
      bgImage.reset(new Image(imgSrc));  
      ++ imageChanges;
}

3)

copy-and-swap设计原则:为你打算修改的对象做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,源对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和源对象在一个不抛出异常的操作中置换。
实现上通常是将所有“隶属对象的数据”从源对象放进另一个对象,然后赋予源对象一个指针,指向所谓的实现对象(即副本)。这种手法常被称为pimpl idiom,对PrettyMenu而言,典型写法:

struct PMImpl{
      std::tr1::shared_ptr<Image> bgImage;
      int imageChanges;
};
class PrettyMenu{
      ...
private:
      Mutex mutex;
      std::tr1::shared_ptr<PMImpl>;
};
void prettyMenu::changeBackground(std::istream& imgSrc)
{
      using std::swap;      
      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;
}

请记住

1.异常安全函数即使发生异常也不会泄露资源或允许任何数据败坏,这样的函数区分为三种:基本型,强烈型,不抛掷型;
2.“强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义;
3.函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的“异常安全保证”中的最弱者。

条款30.透彻了解inlining的里里外外

inline函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之,这样会增加目标码大小。若过度追求inlining会造成程序体积太大,带来效率损失。

1)

inline函数通常一定被置于头文件内,因为大多数建置环境在编译过程中进行inlining,为了将“函数调用”替换为“被调用函数的本体”,编译器必须直到函数长什么样子。值得注意的是,inlining在大多数C++程序是编译器行为。
Templates通常也放入头文件中,因为它一旦被使用,编译器为了将它具体化,需要知道它长什么样子。Template的具现化与inlining无关,没有必要请勿使用inline。

2)

大部分编译器拒绝将太过复杂的函数inlining,而所有对virtual函数的调用也都会使inlining落空。因为virtual意味“等待,直到运行期结束才确定调用哪个函数”,而inline意味着“执行前,先将调用动作替换为函数本体”。如果编译器不知道该调用哪个函数,就很难责备它们拒绝将函数本体inlining。

3)

实际上构造函数和析构函数往往使inlineing的候选人,考虑以下构造函数:

class Base{
public:
      ...
private:
      std::string bm1, bm2;
};
class Derived: public Base{
public:
      Derived() { } //这个构造函数为空,看起来是inline的最佳候选人
      ...
private:
      std::string dm1, dm2, dm3;
};

C++对于“对象被创建和被销毁时发生什么事?”做了各式各样的保证。当你使用new,动态创建的对象被其构造函数自动初始化;当使用delete,对象的析构函数自动调用...值得注意到是,这些行为都不会凭空产生,编译器可能会在编译器代为产生完成这些功能的代码并安插到你程序的代码中,肯定存在于某个地方——通常就放在你的构造函数和析构函数中。
因此,程序库设计者必须评估“将函数声明为inline”的冲击;inline函数无法随着程序库的升级而升级。
请记住

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 birthday() const;
      std::string address() const;
      ...
private:
      std::string theName; //实现细目
      Date theBirthDate; //实现细目
      Address theAddress; //实现细目
};

上述代码还需添加头文件才可通过编译:

#include <string>
#include "date.h"
#include "address.h"

这不可避免的就会在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 birthdayDate() const;
      std::string address() const;
      ...
private:
      std::tr1::shared_ptr<PersonImpl> pImpl;
};

上述代码中,Person类只含一个指针成员指向其实现类(PersonImpl),这般设计称为pimpl idiom。这样就完成了将Dates,Address以及Person的实现细目分离了。这个分离的关键性在于以“声明的依存性”替换“定义的依存性”,这是编译依存性最小化的本质。这来源于以下策略:
i 如果能够使用object reference或object pointers可以完成任务,不要使用objects;
i 如果能够,尽量以class声明式替换class定义式。 注意,当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以by value方式传递该类型的参数:

class Date;
Date today();
void clearAppointments(Date d);

i 为声明式和定义式提供不同的头文件。 为了促进严守准则,需要两个头文件,一个用于声明式,一个用于定义式。程序库客户应该总是#include一个声明文件而非前置声明若干函数:

#include "datefwd.h"
Date today();
void clearAppointments(Date d);

1)

像Person这样使用pimpl idiom的class,往往被称为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。

2)

另一个Handle class的办法是,令Person成为一种特殊的abstract base class,称为Interface class。这种class的目的时详细描述derived classes的接口,因此它通常不带成员变量,也没有构造函数,只有一个virtual析构函数以及一组pure virtual函数用来叙述整个接口。一个针对Person而写的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;
      ...
};

这个class的客户必须以Person的pointers和references来撰写应用程序,必须有办法为这种class创建新对象。他们通常调用一个特殊函数,此函数扮演“真正将被具现化”的那个derived classes的构造函数角色,这样的函数称为factory函数或virtual析构函数。他们返回指针,指向动态分配所得对象,而该对象支持Interface class的接口。这样的函数往往在Interface class中被声明为static:

class Person {
public:
      ...
      static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr); //返回一个tr1::shared_ptr指向一个新的Person,并给以参数。
      ...
};

客户会这样使用:

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();
...

下面给出create函数的具体实现。首先必须定义支持Interface class接口的具象类,再调用真正的构造函数,一切都在virtual构造函数实现码中发生。假设Interface class Person有个具象的derived class 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 birthDate() const;
      std::string address() const;
private:
      std::string theName;
      Date theBirthDate;
      Address theAddress;
};

之后写出Person::create:

std::tr1::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr)
{
      return std::tr1::shared_ptr<Person> (new RealPerson(name, birthday, addr));
}

RealPerson实现了Interface class的两个机制之一: 从Interface class继承接口规格,然后实现出接口所覆盖的函数。Interface class的实现法设计多重继承,那是条款40的主题。
Handle classes和Interface class解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。

3)

在Handle classes身上,成员函数必须通过implementation pointer取得对象数据,那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation pointer的大小。最后,implementation pointer必须初始化指向一个动态分配得来的implementation object,所以将蒙受因动态分配而来的额外开销,以及遭遇bad_alloc异常的可能性。
至于Interface class,由于每个函数都是virtual,所以必须为每次函数调用付出一个间接跳跃成本。此外Interface class派生的对象必须内含一个vptr,这个指针可能会增加存放对象所需的内存数量——实际取决于这个对象除了Interface class之外是否还有其他virtual 函数来源。
最后,上述二者都极度依赖inline函数。是否使用Handle class和Interface class取决于具体的应用场景。
请记住

1.支持编译依存性最小化的一般构想:相依于声明式,不要相依于定义式。基于此构想的两个手段时Handle classes和Interface class。
2.程序库文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否设计templates都适用。

posted @ 2020-11-18 18:56  Viecgg  阅读(132)  评论(0)    收藏  举报