实现这一章主要解决以下问题:
- 太快定义变量会导致低效率
- 过度使用转型代码变慢且难维护
- 类返回句柄会破坏封装
- inlining可能导致的代码膨胀
- 文件依存可能导致编译速度慢
条款26 尽可能延后变量定义式的出现时间
这个条款简单来说就是告诉我们定义变量是需要成本的,顺带还提了一下直接拷贝构造和构造后赋值的效率问题。
程序执行到一个对象的定义,就会产生构造和析构成本,有些对象可能你根本不会使用,但是程序还是默默做了这些无用操作。有时候这种不会使用的对象并不是这么明显,如:
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;
}
这还不够好,因为encrypted进行了默认构造后赋值,其实可以跳过毫无意义的default 构造:
std::string encryptPassword(const std::string &password)
{
...
std::string encrypted;
encrypted=password;
encrypt(encrypted);
return encrypted;
}
其实可以做的更好:
std::string encryptPassword(const std::string &password)
{
...
std::string encrypted(password);
encrypt(encrypted);
return encrypted;
}
循环中的构造该如何处理?
Widget w;
for(int i=0;i<n;i++)
{
w=fun(i);
}
for(int i=0;i<n;i++)
{
Widget w=fun(i);
}
做法A:一个构造 + 一个析构 + n个赋值 做法B: n个构造 + n个析构
这时候就看你自己权衡“赋值”成本与“构造+析构”成本了。
条款27 尽量少做转型动作
C++的设计目标之一就是保证“类型错误”绝不可能发生。旧时转型风格(old-style casts):
(T) expression
T(expression)
C-style几乎允许你将任何类型转换成任何其他类型,这对于C++领域,太过宽泛,如pointer-to-const-object 转型为pointer-to-non-const-object 和pointer-to-base-object 转型成pointer-to-derived-class-object 。为了更加清晰的表达C++中的转换,C++导入了四种新的转型操作符(cast operators):
- const_cast 常量性移除(cast away the constness)
- dynamic_cast 安全向下转型(safe downcasting),唯一一个C-style cast不能完成的转型,用在继承关系的转换
- reinterpret_cast 执行低级转型,如pointer to int 转换成int
- static_cast 强制隐式转换(implicit conversion)注意它不能完成const到non-const的转换,这个转换只有const_cast能完成
新式转换的优点是,容易辨识和避免转换错误。作者在这里谈到了唯一使用旧时转型的情况:当我要调用一个explicit构造函数将一个对象传递给一个函数时。例子如下:
class Widget{
public:
explicit Widget(int size);
...
};
void doSomeWork(const Widget &w);
doSomeWork(Widget(15));
doSomeWork(static_cast<Widget>(15));
将int转换成Widget看上去并不是一个转型动作,不如使用C-style类型来的简洁。转型操作不是简单的将一个事物视作另一个事物,而是会让编译器真正产生运行期间执行的代码:
int x,y;
double d=static_cast<double>(x)/y;
再来看一个例子:
class Base{...};
class Derived:public Base{...};
Derived d;
Base * pb=&d;
一个派生类对象可能被基类或者同类指针指向,请注意!派生类需要一个偏移量来保证使用到基类,对象是如何布局的和编译实现有关。有很多应用框架(application frameworks)都要求派生类中的virtual函数代码第一个动作就执行基类的函数:
class Window{
public:
virtual void onResize(){...}
...
};
class SpecialWindow:public Window{
public:
virtual void onResize(){
static_cast<Window>(*this).onResize();
...
}
}
虽然在派生类中主动使用转型动作,我们期望onResize作用于派生类对象,可事实上它作用转型动作动作产生的一个*this对象的base class 成分,请注意是副本进行了onResize,派生类实体的base成分是不确定,对于onResize而言就是,派生类成分确定,但是基类成员并不确定。
dynamic_cast在许多时间版本执行时间都相当慢,层次较深和多重继承执行效率更是如此。那为什么还需要dynamic_cast呢?有时候是不得不这样做,通常是因为你想在一个你认定为派生类对象身上指针派生类操作,但是你手头上只有指向基类的指针或引用,你只能靠它们处理对象。
假设我们想要使用Window来调用SpecialWindow的Blink方法:
class Window{...};
class SpecialWindow:public Window{
public:
void blick();
...
}
typedef
std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for(VPW::iterator iter=winPtr.begin();iter!=winPtrs.end();++iter)
{
if(SpecialWindow * psaw=dynamic_cast<SpecialWindow *>(iter->get()))
psw->blink();
}
)
我们知道这存在效率问题,所以作者提供了两种避免使用dynamic_cast对象的方法:
- 使用容器并在其中存储直接指向派生类对象的指针
- 通过基类接口处理所有派生类
第一种处理如下:
typedef std::vector<std::shared_ptr<SpecialWindow>> VPSW;
VSPW winPtr;
...
for(VSPW::iterator iter=winPtrs.begin();iter!=winPtrs.end());++iter)
{
*(iter)->blink();
}
简单来说,就是更改接口为派生类而不是基类。缺点非常明显,我们只能处理一类的派生类。
第二种处理灵活使用了动态绑定概念:
class Window{
public:
virtual void blink(){}
...
};
class Special:public Window{
public:
virtual void blink(){...}
...
}
typedef std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for(VPW::iterator iter=winPtrs.begin();iter!=winPtrs.end();++iter)
{
(*iter)->blink();
}
接口仍然是Window(基类),但是不再需要dynamic_cast,相较于方法一,不同的派生类只需要对应实现对应的blink即可。
最后,作者给出了几点经验:
- 连串(cascading)dynamic_cast产生的代码又大又慢,应该用virtual方法代替他
- 转型动作最好隐藏在函数内,免得玷污了操作者的手
条款28 避免返回handles指向对象内部成分
handle被翻译为号码牌(用于取得某个对象),引用迭代器和指针都是handle。
假设你正在定义一个矩形,矩形可以由左上角和右上角的点表示,为了让这个矩形所占空间足够小,你可能决定将这两个点放在一个结构体中,再让Rectangle去指向它:
class Point{
public:
Point(int x,int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
struct RectData{
Point ulhc;
Point urhc;
};
class Rectangle{
...
private:
std::shared_ptr<RectData> pData;
};
用户可能会以只读方式获取两个点,因此定义了两个返回对应点的方法:
class Rectangle{
public:
...
Point& upperLeft()const{return pData->ulhc;}
Point& lowerRight()const{return pData->lrhc;}
...
};
假设用户指向想要获取这个点而不是修改这个点,这样返回handle的隐患在于:
Point coord1(0,0);
Point coord2(100,100);
const Rectangle rec(coord1,coord2);
rec.upperLeft().setX(50);
这也就告诉我们:
- 成员变量的封装性最多只等于“返回其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)是在异常出现时调用的函数,该函数处理异常有三个等级。
从上到下依次严格。基本保证是指程序任何事物发生异常后保持在有效状态下,是一个合法性保证;强烈保证是指异常发生后要么成功,要么失败返回原状态,是一个原子态保证;不投掷保证是指异常永远不会发生,如int、指针,是异常代码的关键保障。
一个异常安全的代码必须是上述等级中的一个,否则不是一个异常安全代码。如果可能,我们当然希望每个功能函数都不抛出异常,即不投掷保证,如果你的代码涉及到动态分配内存,你几乎无法保证不抛出std::bad_alloc异常,因此我们代码通常是在前两个保证中做出选择。
回到刚刚那个例子,为了解决问题1,我们可以使用RAII互斥锁解决,实现代码如下:
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
std::lock_guard<std::mutex> lockGurad(lo);
delete bgImage;
++imgChanges;
bgImage=new Image(imgSrc);
}
注意到这种方式可能会有内存泄露的风险,因此可以将指针bgImage用只能指针来管理:
class PrettyMenu
{
...
std::shared_ptr<Image> bgImage;
...
}
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
std::lock_guard<std::mutex> lockGurad(lo);
++imageChanges;
bgImage.reset(new Image(imgSrc));
}
稍微更改以下++imageChanges;的位置就可以解决问题2。
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
std::lock_guard<std::mutex> lockGurad(lo);
bgImage.reset(new Image(imgSrc));
++imageChanges;
}
解决了这两个问题,基本上可以满足强烈保证,虽然它到导致了istream流记号的读取记号被移走(严格意义上,他也不是一个强烈保证)。其实有一个更加典型的策略用来实现强烈保证,那儿就是copy and swap策略,copy原对象,对副本做操作,最后swap到原对象。
struct PMImpl{
std::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu{
...
private:
std::mutex mtx;
std::shared_ptr<pMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream & imgSrc)
{
using std::swap;
std::lock_guard<std::mutex> lockguard(lo);
std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pImpl,pNew);
}
实现上开辟新的资源空间并用指针指向这块区域,然后利用这个指针拷贝旧对象资源(或者更新状态),最后将新资源和旧资源进行互换。作者将这个实现方法叫做pimpl idoim 指向实现(副本) 。为什么要另外将资源放在一个结构体内?注意是两个问题,一是资源为什么要独立成PMImpl而不是放在PrettyMenu;二是为什么不是类,原因是独立成PMImp有利于我们独立异常安全代码,放在结构体是因为PrettyMenu已经完成封装,直接设置成结构体更加方便。
copy-and-swap策略可以实现强烈的异常安全性,但是整个函数的异常安全性等级遵循“木桶理论”。
void someFunc()
{
...
f1();
f2();
...
}
f1、f2的异常等级会影响someFunc的整体异常等级。pimpl idoim实现是需要空间和时间,如果实现这个带来的是效率和复杂度的激增,那么你最起码也要提供一个基本保证。
条款30 透彻了解inlining的里里外外
inline函数是否真正inline取决于编译器。
其优点是:
缺点是:
- 代码膨胀
- 编译负担
- inline函数无法随着库函数升级而升级,因为全部要重新编译
其他事项:
- Inline函数一定放在头文件中,因为编译器需要进行替换
- 不要只因为function template出现在头文件将其声明为inline
作者举了几个例子:
- 构造和析构可以内联,但往往无效。这是因为编译器为了完成这两部分工作可能增加了一些调用,大概率会导致inline失效
- 如果用户尝试取函数地址,那么编译器也会拒绝inline
inline void f(){}
void (*pf)()=f;
...
f();
pf();
作者给出的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 <string>
#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 <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::shared_ptr<PersonImpl> pImpl;
这样一来,Person类的使用者,在修改任何实现都不会引起重新编译,分离的关键在于“声明依赖性”替换“定义依赖性”,只要声明不变,客户使用这个类就不进行编译。作者总结的几点:
- 如果使用object references或object pointers可以完成任务,就不要使用object
- 如果能够,尽量以class声明式替代class定义式
- 为声明和实现提供不同文件
另一种方法是让Person成为抽象基类,也可以叫做接口类(Interface class),这个暂时不了解,有空回来看。
|