实现这一章主要解决以下问题:
- 太快定义变量会导致低效率
- 过度使用转型代码变慢且难维护
- 类返回句柄会破坏封装
- inlining可能导致的代码膨胀
- 文件依存可能导致编译速度慢
构造对象是需要成本的,占用的资源会直到生命周期结束,尽可能在需要的时候再定义对象,减少尚未使用时的资源占用;直接拷贝构造和构造后赋值的效率问题,前者要好于后者。
程序执行到一个对象的定义,就会产生构造和析构成本,有些对象可能你根本不会使用,但是程序还是默默做了这些无用操作。有时候这种不会使用的对象并不是这么明显,如:
std::string encryptPassword(const std::string& password)
{
using namespace std;
string encrypted;
if(password.length()blink();
}
接口仍然是Window(基类),但是不再需要dynamic_cast,相较于方法一,不同的派生类只需要对应实现对应的blink即可。
最后,作者给出了几点经验:
- 连串(cascading)dynamic_cast产生的代码又大又慢,应该用virtual方法代替他
- 转型动作最好隐藏在函数内,免得玷污了操作者的手
handle被翻译为号码牌(用于取得某个对象),引用迭代器和指针都是handle。
假设你正在定义一个矩形,矩形可以由左上角和右上角的点表示,为了让这个矩形所占空间足够小,你可能决定将这两个点放在一个结构体中,再让Rectangle去指向它:
class Point{
public:
Point(int x,int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
struct RectData{
Point ulhc;//upper left-hand corner
Point urhc;//upper right-hand corner
};
class Rectangle{
...
private:
std::shared_ptr pData;
};
用户可能会以只读方式获取两个点,因此定义了两个返回对应点的方法:
class Rectangle{
public:
...
Point& upperLeft()const{return pData->ulhc;}//这里声明为const是为了让const Rectangle也可以用这个方法
Point& lowerRight()const{return pData->lrhc;}
...
};
假设用户指向想要获取这个点而不是修改这个点,这样返回handle的隐患在于:
Point coord1(0,0);
Point coord2(100,100);
const Rectangle rec(coord1,coord2);//const Rectangle表示这个矩形不想被改变
rec.upperLeft().setX(50);//因为暴露了其handle,他并没有做到“矩形不想被改变”的设想
这也就告诉我们:
- 成员变量的封装性最多只等于“返回其handle”的函数访问级别
- 若const成员函数传出一个handle,所指的数据与自身有关但是又被存于别处,则其const属性可能被改变
对于第二个可以将引用改成const引用;对于第一个没有办法避免,而且存在严重的问题,返回一个空悬(dangling)指针(临时对象)。总之避免返回handle指向对象内部,可以增加封装性,帮助const对象为真的const,同时降低了空悬指针的情况。当然对于operator[]是个例外,但不是常态。
条款29 为“异常安全”而努力是值得的异常是某段程序预期外的事件,如数据库断开、错误的输入。出现异常后,程序将会终止执行,并进入相应的异常处理模块。还是以书中的例子作为说明:
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;
++imgChanges;
bgImage=new Image(imgSrc);
unlock(&mutex);
}
从异常安全性的角度来看,上述程序非常糟糕。糟糕之处在于,当new申请内存失败时:
- 互斥锁永远不会释放(问题1)
- 对象状态被污染(被改变的次数+1,事实上他并没有成功)(问题2)
新标准下,直接使用lock_gurad更为简洁。
异常安全函数(Exception-safe functions)是在异常出现时调用的函数,该函数处理异常有三个等级。
- 基本保证
- 强烈保证
- 不投掷(nothrow)保证
从上到下依次严格。基本保证是指程序任何事物发生异常后保持在有效状态下,是一个合法性保证;强烈保证是指异常发生后要么成功,要么失败返回原状态,是一个原子态保证;不投掷保证是指异常永远不会发生,如int、指针,是异常代码的关键保障。
一个异常安全的代码必须是上述等级中的一个,否则不是一个异常安全代码。如果可能,我们当然希望每个功能函数都不抛出异常,即不投掷保证,如果你的代码涉及到动态分配内存,你几乎无法保证不抛出std::bad_alloc异常,因此我们代码通常是在前两个保证中做出选择。
回到刚刚那个例子,为了解决问题1,我们可以使用RAII互斥锁解决,实现代码如下:
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
std::lock_guard lockGurad(lo);
delete bgImage; //删除原对象
++imgChanges;
bgImage=new Image(imgSrc);
}
注意到这种方式可能会有内存泄露的风险,因此可以将指针bgImage用只能指针来管理:
class PrettyMenu
{
...
std::shared_ptr bgImage;
...
}
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
std::lock_guard lockGurad(lo);
++imageChanges;
bgImage.reset(new Image(imgSrc));
}
稍微更改以下++imageChanges;的位置就可以解决问题2。
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
std::lock_guard lockGurad(lo);
bgImage.reset(new Image(imgSrc));
++imageChanges;
}
解决了这两个问题,基本上可以满足强烈保证,虽然它到导致了istream流记号的读取记号被移走(严格意义上,他也不是一个强烈保证)。其实有一个更加典型的策略用来实现强烈保证,那儿就是copy and swap策略,copy原对象,对副本做操作,最后swap到原对象。
//这个类包含所有资源,包括图像对象和变换次数
struct PMImpl{
std::shared_ptr bgImage;
int imageChanges;
};
class PrettyMenu{
...
private:
std::mutex mtx;
std::shared_ptr pImpl;
};
void PrettyMenu::changeBackground(std::istream & imgSrc)
{
//目标是替换原对象的图片
using std::swap;
std::lock_guard lockguard(lo); //解决互斥锁潜在问题
std::shared_ptr pNew(new PMImpl(*pImpl));//pNew指向旧对象资源
pNew->bgImage.reset(new Image(imgSrc)); //pNew拷贝旧资源
++pNew->imageChanges; //pNew累加状态
swap(pImpl,pNew); //swap完成旧资源更新
}
实现上开辟新的资源空间并用指针指向这块区域,然后利用这个指针拷贝旧对象资源(或者更新状态),最后将新资源和旧资源进行互换。作者将这个实现方法叫做pimpl idoim 指向实现(副本) 。为什么要另外将资源放在一个结构体内?注意是两个问题,一是资源为什么要独立成PMImpl而不是放在PrettyMenu;二是为什么不是类,原因是独立成PMImp有利于我们独立异常安全代码,放在结构体是因为PrettyMenu已经完成封装,直接设置成结构体更加方便。
copy-and-swap策略可以实现强烈的异常安全性,但是整个函数的异常安全性等级遵循“木桶理论”。
void someFunc()
{
...//新对象指针指向原状态
f1();
f2();
...//swap之
}
f1、f2的异常等级会影响someFunc的整体异常等级。pimpl idoim实现是需要空间和时间,如果实现这个带来的是效率和复杂度的激增,那么你最起码也要提供一个基本保证。
条款30 透彻了解inlining的里里外外inline函数是否真正inline取决于编译器。
其优点是:
- 调用开销免除
- 比宏要好
缺点是:
- 代码膨胀
- 编译负担
- inline函数无法随着库函数升级而升级,因为全部要重新编译
其他事项:
- Inline函数一定放在头文件中,因为编译器需要进行替换
- 不要只因为function template出现在头文件将其声明为inline
作者举了几个例子:
- 构造和析构可以内联,但往往无效。这是因为编译器为了完成这两部分工作可能增加了一些调用,大概率会导致inline失效
- 如果用户尝试取函数地址,那么编译器也会拒绝inline
inline void f(){}
void (*pf)()=f;
...
f();//inline
pf();//函数指针形式,编译器不进行inline
作者给出的inline策略是:一开始不要将任何函数声明为inline,或者inlining 范围缩小至一定要inline或非常平淡无奇的函数。最后作者给出一个80-20法则:平均而言一个程序往往将80%的执行时间花费在20%的代码上。
条款31 将文件间的编译依存关系降至最低这个条款是为了提高编译速度的。
下面是Person类的定义:
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;
}
编译无法通过,这是因为编译器必须取得代码实现所有到的classes,string、Date和Address的定义,这样的定义通常是由预处理指令#include
提供的,补上这些头文件:
#include
#include "date.h"
#include "address.h"
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;
};
这样一来,Person定义文件和#include
引入的文件之间形成了一种编译依存关系(compilation dependency),一旦引入文件发生改变就会导致引用他们的文件发生重新编译。看了半天,其实他是说声明和实现放在同一个头文件中这种情况,这也是为什么我们要实现接口和实现分属两个不同文件的原因,因为只要接口不变,#include改变实现就只会影响到#include本身源文件的重新编译,避免连串编译依存关系(cascading compilation dependencies)。
编译器获得对象大小的唯一方法是询问class定义式。这个问题在Java、Smalltalck等语言并不存在,因为这类语言只分配足够空间给一个指针(用于指向该对象)使用,这种实现方式叫做pimpl idiom(pointer to implementation)。
#include
#include
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::shared_ptr pImpl;
这样一来,Person类的使用者,在修改任何实现都不会引起重新编译,分离的关键在于“声明依赖性”替换“定义依赖性”,只要声明不变,客户使用这个类就不进行编译。作者总结的几点:
- 如果使用object references或object pointers可以完成任务,就不要使用object
- 如果能够,尽量以class声明式替代class定义式
- 为声明和实现提供不同文件
另一种方法是让Person成为抽象基类,也可以叫做接口类(Interface class),这个暂时不了解,有空回来看。