文章目录
条款26:尽可能延后变量定义式的出现时间
原因
只要你定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序控制流(control flow)到达这个变量定义式时,你边要承受构造成本,当这个变量离开其作用域时,你便承受析构成本。即使这个变量最终并未被使用,仍需消耗这些成本,所以应该避免这些情形。
案例
考虑以下函数:
//这个函数过早的定义变量 "encrypted"
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
在此函数中并非完全未被使用后,但如果有个异常被丢出,它就会真的没有被使用。也就说如果函数 encryptPassword
丢出异常,你仍得付出变量 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
构造函数。许多时候你应该对对象做的第一件事就是给它个值,一般是通过一个赋值动作完成。
条款 4 解释过为什么 “通过 default
构造函数构造出一个对象然后对它赋值” 比 “直接在构造函数时指定初值” 效率差!
通过
default
构造函数在给它赋值的操作 成本是:先调用default
构造函数,再对它们赋予新值(调用copy assignment
操作符)
直接在构造函数时指定初值的成本是:调用一次copy
构造函数(复制构造函数)
相比两者,单只调用一次copy
构造函数的比较高效的,有时候甚至高效很多!(详见 条款 4)
假设 encryptPassword
的艰难部分在以下函数中进行:
void encrypt(std::string& s); //在其中的适当地点对s加密
于是 encryptPassword
可实现如下(虽然不是最好的做法)
//这个函数延后"encrypted"的定义,直到需要它为止
//但此函数仍然有着不该有的效率低落
std::string encryptPassword(const std::string& password)
{
... //检查 length,如前
std::string encrypted; //default-construct encryted
encrypted = password; //赋值给 encrypted
encrypt(encrypted);
return encrypted;
}
更好的解决办法是以 password
作为 encrypted
的初值,跳过毫无意义的 default
构造过程:
//终于,这是定义并初始化 encrypted 的最佳做法
std::string encryptPassword(const std::string& password)
{
... //检查长度
std::string encrypted(password); //通过 copy 构造函数 定义并初始化
encrypt(encrypted);
return encrypted;
}
其实本条款 “尽可能延后” 的真正意义是:你不只应该延后变量的定义,也要延后到使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。
这样不仅能够避免构造(非构造)非必要对象,还可以避免无意义的 default
构造行为。更深一层说,以 “具明显意义之初值” 将变量初始化,还可以附带说明变量的目的。
但循环怎么办?
- 比如说:变量只在循环内使用
- 那么把它定义于循环外并在每次循环迭代时赋值给它比较好,还是该把它定义于循环内?
- 下面两个一般性结构,哪一个比较好?
//方法A: 定义于循环外
Widget w;
for (int i = 0; i < n; ++i)
{
w = 取决于i的某个值;
...
}
//方法B: 定义于循环内
for (int i = 0; i < n; ++i)
{
Widget w(取决于i的某个值);
...
}
定义成 Widget
的原因是更能看到 构造和析构成本!
对 Widget
内部来说,以上两种写法的成本:
- 做法A:1个构造函数 + 1个析构函数 + n个赋值操作
- 做法B:n个构造函数 + n个析构函数
综上所述,可以知道:
- 如果
classes
的一个赋值成本低于一组 构造+析构成本,那么做法 A 大体而言比较高效,尤其当 n 的值很大时。 - 否则 B 做法比较好。
另外,做法 A 造成名称 w 的作用域(覆盖整个循环)比做法 B 更大,有时对程序的可理解性和易维护性造成冲突!因此,除非两种情况:
- 你知道赋值成本比 “构造 + 析构” 成本低
- 你正在处理代码中效率高度敏感的部分
否则你应该使用做法 B。
请记住
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
条款27:尽量少做转型动作
原因
C++的规则设计目标直以是,保证 “类型错误” 绝不可能发生。理论上如果你的程序很 “干净地” 通过编译,就表现它并不企图在任何对象身上执行任何不安全、无意义、愚蠢荒谬的操作。这是一个极具价值的保证!
不幸的是,转型(casts
)破坏了类型系统,那可能导致任何种类的麻烦,有些容易辨识,有些非常隐晦!
C#/Java/C 语言中的转型(casting)比较必要而无法避免,也比较不危险(与C++相比)
C++转型
通常来说,C++的转型有三种不同的形式,可写出相同的转型风格。
C风格的转型动作看起来像:
(T)expression //将 expression 转型为T
函数风格的转型动作看起来像:
T(expression) //将 expression 转型为T
两种形式并无差别,纯粹只是小括号的摆放位置不同而已。我称此二种形式为“旧式转型”(old-style casts)
C++还提供四种新式转型(常常被称为 new-style 或 C+±style casts):
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
。这一类转型在低级代码以外很少见。本书只使用一次,那是在讨论如何针对原始内存(raw memory)写出一个调试用的分配器(debugging allocator)时,见条款50。static_ cast
用来强迫隐式转换(implicit conversions),例如将non-const
对象转为const
对象(就像条款3所为),或将int 转为double
等等。它也可以用来执行上述多种转换的反向转换,例如将void*
指针转为typed
指针,将pointer-to
-base
转为pointer-to
-derived
。 但它无法将const
转为non-const
一这个只有const_ cast
才办得到。
尽量用新式转型
旧式转型仍然合法,但新式转型比较受欢迎。
原因是:
- 它们很容易在代码中被辨识出来(不论是人工还是使用工具比如 grep),因而得以简化 “找出类型系统在哪个地点被破坏” 的过程。
- 各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。
比如说,你打算将常量性去掉,除非使用新式转型中的
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
蓄意的 “对象生成” 动作感觉不怎么像 “转型”,所以我很可能使用函数风格的转型动作而不使用 static_cast
。但是要注意,我们在写代码的时候某些地方觉得通情达理,但可能日后出错导致 “核心倾印”(core dump )。所以最好是忽略你的感觉,始终使用新式转型。
任何一个类型转换(不论是通过转型操作的显示转换,还是通过编译器完成的隐式转换)往往令编译器编译出运行期间执行的码。比如:
int x,y;
...
double d = static_cast<double>(x)/y; //x除以y, 使用浮点数除法
将 int x
转型为 double
几乎肯定会产生一些代码,因为在大部分计算器体系结构中,int
的底层表述不同于 double
的底层表述。比如看下面的例子:
class Base { ... };
class Derived: public Base { ... };
Derived d;
Base* pb = &d; //隐喻地将 Derived* 转换为 Base*
这里我们不过是建立一个 base class
指针指向一个 derived class
对象,但有时上述的两个指针值并不相同,这种情况会有个偏移量(offest)在运行期被施行于 Derived*
指针上,用于取得正确的 Base*
指针值。
上个例子表明,单一对象(例如一个类型为Derived
的对象)可能拥有一个以上的地址(例如“以Base*
指向它” 时的地址和 “以Derived*
指向它” 时的地址。C不可能发生这种事,Java不可能发生这种事,C#也不可能发生这种事。但C++可能!实际上一旦使用多重继承,这事几乎一直发生着。即使在单一继承中也可能发生。虽然可能还有其他意义,但这意味着你通常 应该避免做出 “对象在C++中如何布局” 的假设。当然更不该以此假设为基础执行任何转型操作。
例如,将对象的地址转型为
char*
指针,然后在它们身上进行指针算术,几乎总是会导致无定义行为(不明确行为)。
注意:这里说的是,转型需要一个偏移量。对象的布局方式和它们的地址计算方式,会因为编译器的不同而不同,那意味着 “由于知道对象如何布局”而设计的转型,在某个平台行得通,但在其他平台不一定能行得通!
尽量避免转型
某些转型代码在其他语言中也许可以正常运行,但是在C++中可能会有问题!!!
例如许多应用框架(application frameworks)都要求 derived classes
内的 virtual
函数代码的第一个动作就先调用 base class
的对应函数。
假设我们有个 Window base class
和一个 Special Window derived class
,两者都定义了 virtual
函数 onResize
。进一步假设 SpecialWindow
的 onResize
函数被要求首先调用 Window
的 onResize
下面是实现方式之一,看起来是对的,实际上是错的:
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
! 然后在当前对象身上执行 SpecialWindow
专属动作。
函数旧式函数,成员函数只有一份,“调用起哪个对象身上的函数” 有什么关系呢?关键在于成员函数都有个隐藏的
this
指针,会因此影响成员函数操作的数据!
如果,Window::onResize
修改了对象内容(因为onResize
是个 non-const
成员函数),对于当前对象其实没有改动,改动的是副本。然而 SpecialWindow::onResize
内如果也修改对象,那么当前对象真的会被改动,这使当前对象进入一种 “伤残” 状态:其base class
成分的更改没有落实,而 derived class
成分的更改倒是落实了。
解决之道是,拿掉转型动作,代之你真正想说的话!你并不想哄骗编译器将 *this
视为一个 base class
对象,你只是想调用 base class
版本的 onResize
函数,令它作用于当前对象身上,所以请这么写:
class SpecialWindow: public Window
{
public:
virtual void onResize()
{
Window::onResize(); //调用 Window::onResize作用于 *this 身上
...
}
};
这个例子也说明了,如果你发现自己打算转型,那活脱是个警告信号:你可能正在将局面发展到错误的方向上, 如果你用的是 dynamic_cast
更是如此。
注意:dynamic_cast
的许多实现版本执行速度相当慢!!!
例如,至少有一个很普遍的实现版本基于 “class 名称之字符串比较”,如果你在四层深的单继承体系内的某个对象身上执行
dynamic_cast
,刚才说的(四层单继承)实现版本所提供每一次dynamic_cast
可能会耗用多达四次的strcmp
调用,用以比较class 名称。
深度继承或多重继承的成本更高!!某些实现版本这样做有其原因(它们必须支持动态连接)。然而我还是要强调,除了对一般型保持机敏与猜疑,更应该在注重效率的代码中对dynamic_casts
保持机敏与猜疑。
dynamic_cast 转型的替代设计
之所以需要dynamic_cast
通常是因为你想在一个你认为 derived class
对象身上执行 derived class
操作函数,但你的手上却只有一个 “指向base
” 的 pointer
或 reference
,你只能靠它们来处理对象,有两个一般性做法可以避免这个问题。
- 使用容器并在其中存储直接指向
derived class
对象的指针(通常是只能指针,条款13),如此便消除了 “通过base class
” 接口处理对象的需要。假设先前的Window/SpecialWindow
继承体系中只有SpecialWindows
才支持闪烁效果,试着不要这样做:
class Window { ... };
class SpecialWindow: public Window
{
public:
void blink();
...
};
typedef std::vector<std::tr1::shared_ptr<Window>> VPW; //关于 tr1::shared_ptr 见条款13
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>> VPSW;
VPSW winPtrs;
...
for(VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) //这样写比较好,不使用dynamic_cast
(*iter)->blink();
这种做法使你无法在同一个容器内存储不同的指针拉!比如 “指向所有可能各种 Window
派生类”。如果真要处理多种窗口类型,你可能需要多个容器,它们都必须具备类型安全性(type-safe)
- 通过
base class
接口处理 “所有可能各种Window
派生类”,那就是在base class
内提供virtual
函数做你想对各个Window
派生类做的事。
比如说:虽然只有
SpecialWindows
可以闪烁,但或许将闪烁函数声明于base class
内并提供一份 “什么也没做” 的缺省实现码是没有意义的:
class Window
{
public:
virtual void blink() {} //缺省代码实现 “什么也没做”;
... //条款34告诉你为什么 缺省实现代码可能是个馊主意
};
class SpecialWindow: public Window
{
public:
virtual void blink() {...}; //在此 class 内,blink 做某些事
...
};
typedef std::vector<std::tr1::shared_ptr<Window>> VPW; //容器,内含指针,指向所有可能的 Window 类型
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) //注意 这里没有 dynamic_cast
(*iter) -> blink();
上面无论哪一种写法——“使用类型安全容器” 或者 “将 virtual
函数往继承体系上方移动”——都并非放之四海而皆准,但在许多情况下它们都提供一个可行的 dynamic_cast
替代方案。
绝对必须避免“连串(cascading)dynamic_casts”,也就是看起来像这样的:
class Window {...};
...
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
if(SpecialWindow1 * psw1 = dynamic_cast<SpecialWindow1*>(iter->get())) { ... }
else if (SpecialWindow2 * psw2 = dynamic_cast<SpecialWindow2*>(iter->get())) { ... }
else if (SpecialWindow3 * psw3 = dynamic_cast<SpecialWindow3*>(iter->get())) { ... }
...
}
这样产生出来的代码有大又慢,而且基础不稳,每次 Window class
继承体系一有改变,所有这一类代码都必须再次检阅看看是否需要修改。例如一旦加入新的derived class
或许上述连串判断中需要加入新的条件分支。这样的代码应该总是以某些 “基于 virtual
函数调用” 的东西取而代之。
总结:
- 优良的 C++ 代码很少使用转型,但若要说完全摆脱它们又太过不切实际,例如从
int
转型为double
就是转型的一个通情达理的使用。虽然它并非绝对必要。(比如说可以声明一个类型为double
的新变量并以x
值初始化)。 - 就像面对众多蹊跷可疑的构造函数一样,我们应该尽可能隔离转型动作,通常是把它隐藏在某个函数内,函数的接口会保护调用者不受函数内部任何肮脏龌龊的动作影响。
请记住
- 如果可以,尽量避免转型动作,特别是在注重效率的代码中避免
dynamic_casts
,如果有个设计需要转型动作,试着发展无需转型的替代设计。 - 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
- 宁可使用 C++ style(新式)转型,不要使用旧式转型,前者很容易辨识出来,而且也比较有着分门别类的职责。
条款28:避免返回 handler 指向对象内部成分
问题以及原因
- 假设你的程序涉及矩形。
- 每个矩形由其左上角和右下角表示。
- 为了让一个
Rectangle
对象尽可能小,我们不把矩形的点数据存放在Rectangle
对象内,而是放在一个辅助的struct
内再让Rectangle
去用它:
class Point { //这个class用来表述“点”
public:
Point (int x, int y) ;
...
void setX (int newVal) ;
void setY (int newVal) ;
...
};
struct RectData { //这些“点”数据用来表现-一个矩形
Point ulhc; //ulhc = "upper left-hand corner" (左上角)
Point lrhc; //lrhc = "lower right-hand corner" (右下角)
};
class Rectangle {
...
private:
std::tr1::shared_ptr<RectData> pData; //关于 tr1: :shared_ ptr, 见条款13
};
Rectangle
的客户必须能够计算 Rectangle
的范围,所以这个 class
提供 upperLeft
函数和 lowerRight
函数。
Point
是个用户自定义类型,所以根据条款20给我们的忠告(它说以 by reference
方式传递用户自定义类型往往比以 by value
方式传递更高效),这些函数按条款返回references
, 代表底层的 Point
对象:
class Rectangle {
public:
...
Point& upperLeft( ) const { return pData- >ulhc; }
Point& lowerRight( ) const { return pData->lrhc; }
...
};
这样的设计可通过编译,但却是 错误 的。实际上它是 自我矛盾 的。一方面upperLeft
和 lowerRight
被声明为 const
成员函数,因为它们的目的只是为了提供客户一个得知 Rectangle
相关坐标点的方法,而不是让客户修改 Rectangle
(见条款3)。另一方面两个函数却都返回 references
指向 private
内部数据,调用者于是可通过这些references更改内部数据 !!
例如:
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); //rec是个const矩形 从(0,0)到(100,100)
rec.upperLeft().setX(50); //现在 rec 却变成 从 (50,0) 到 (100,100)
注意,upperLeft
的调用者能够使用被返回的 reference
(指向rec
内部的 Point
成员变量) 来更改成员。但rec
其实应该是不可变的(const
)!
这立刻带给我们两个教训:
- 成员变量的封装性最多只等于 “返回其
reference
” 的函数的访问级别。本例之中虽然ulhc
和lrhc
都被声明为private
它们实际上却是public
,因为public
函数upperLeft
和lowerRight
传出了它们的references
- 如果
const
成员函数传出一个reference
,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以改那笔数据。这是bitwise constness
(不变性)的一个附带结果。
上面我们所说的每件事都是由于 “成员函数返回 references
”。如果它们返回的是指针或迭代器,同样的情况还是发生,原因也相同。References
、指针和迭代器统统都是所谓的 handles
(号码牌,用来取得某个对象),而返回一个 “代表对象内部数据” 的 handle
,随之而来的便是 “降低对象封装性” 的风险。同时,一如稍早所见,它也可能导致 “虽然调用const
成员函数却造成对象状态被更改”。
解决问题:不要这样做就解决了
通常我们认为,对象的 “内部” 就是指它的成员变量,但其实不被公开使用的成员函数(也就是被声明为protected
或 private
者)也是对象 “内部” 的一部分。因此也应该留心不要返回它们的 handles
。
这意味你绝对不该令成员函数返回一个指针指向 “访问级别较低” (比如说private
)的成员函数。如果你那么做,后者(如:private
)的实际访问级别就会提高如同前者(如:public
),因为客户可以取得一个指针指向那个 “访问级别较低”(如:private
) 的函数,然后通过那个指针调用它(调用private
)。
然而 “返回指针指向某个成员函数” 的情况毕竟不多见,所以让我们把注意力收回,专注于 Rectangle class
和它的 upperleft
以及 lowerRight
成员函数。我们在这些函数身上遭遇的两个问题可以轻松去除,只要对它们的返回类型加上 const
即可:
class Rectangle {
public:
...
const Point& upperLeft( ) const { return pData->ulhc; }
const Point& lowerRight( ) const { return pData->lrhc; }
...
};
有了这样的改变,客户可以读取矩形的 Points
,但不能涂写它们。这意味当初声明 upperLeft
和 upperRight
为 const
不再是个谎言,因为它们不再允许客户更改对象状态(不能改变返回的Point
)。至于封装问题,我们总是愿意让客户看到 Rectangle
的外围Points
,所以这里是蓄意放松封装。更重要的是这是个有限度的放松:这些函数只有读取权。涂写权仍然是被禁止的。
但即使如此,upperLeft
和 lowerRight
还是返回了 “代表对象内部” 的 handles
,有可能在其他场合带来问题。更明确地说,它可能导致 dangling handles
(空悬的号码牌)!!!
啥叫空号码牌? 也就是说这种 handles
所指的东西(所属对象) 不复存在。这种 “不复存在的对象” 最常见的来源就是函数返回值。例如某个函数返回 GUI 对象的外框(bounding box),这个外框采用矩形形式:
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj); //以 by value 方式返回一个矩形 条款3谈过为什么返回类型是 const
现在客户可能这么使用这个函数:
GUIObject* pgo; //让 pgo 指向某个 GUIObject
...
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft()); //取得一个指针指向外框左上点
-
对
boundingBox
的调用获得一个新的、暂时的Rectangle
对象。这个对象没有名称,所以我们权且称它为temp
。随后upperLeft
作用于temp
的一个内部成分,更具体的说是指向一个用以标示temp
的Points
。 -
于是
pUpperLeft
指向那个Point
对象。 -
目前一切还好,但故事尚未结束。因为在那个语句结束之后,
boundingBox
的返回值,也就是我们说temp
将被销毁,那间接导致temp
内的Ponits
析构。 最终导致pUpperLeft
指向一个不再存在的对象;也就是说一旦产出pUpperLeft
的那个语句结束,pUpperLeft
也就变成空悬、虚吊(dangling)!
总结
- 以上就是为什么函数如果 “返回一个
handle
代表对象内部成分” 总时危险的原因。不论这所谓的handle
是个指针或迭代器或reference
,也不论这个handle
是否为const
,也不论那个返回handle
的成员函数是否为const
。这里唯一关键是,有个handle
被传出去了,一旦如此你就是暴露在 “handle 比其所指对象更长寿” 的风险下。 - 这并不意味着你绝对不可以让成员函数返回
handle
。有时候你必须那么做。例如operator []
就允许你“采摘”strings
和vectors
的个别元素,而这些operator[]s
就是返回references
指向 “容器内的数据”(条款3),那些数据会随着容器被销毁而销毁。这种函数毕竟是例外,不是常态!(比如你取得某个vector
中的元素引用,在vector
被销毁的时候取得的元素引用也被销毁)
请记住
- 避免返回
handles
(包括references
、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const
成员函数的行为像个const
,并将发生 “虚吊号码牌”(dangling handles)的可能性降至最低。
条款29:为“异常安全”而努力是值得的
异常安全
异常安全性:如果这个程序中有一部分是不安全的,那么这个程序就是不安全的。没有局部安全,只有全局不安全。当这个程序所有地方都是安全的,那么这个程序才是安全的。还是通过一个例子来说明吧!
异常安全案例
资源泄露
假设有个 class
用来表现夹带背景图案的 GUI 菜单单。这个class
希望用于多线程环境,所以它有个互斥器(mutex
)作为并发控制(concurrency control)之用:
class PrettyMenu
{
public:
...
void changeBackground (std:: istream& imgSrc); //改变背景图像
...
private:
Mutex mutex; //互斥器
Image* bgImage; //目前的背景图像
int imageChanges; //背景图像被改变的次数
};
下面是 PrettyMenu
的 changeBackground
函数的一个可能实现:
void PrettyMenu:: changeBackground (std::istream& imgSrc)
{
lock(&mutex); //取得互斥器(见条款14)
delete bgImage; //摆脱旧的背景图像
++imageChanges; //修改图像变更次数
bgImage = new Image (imgSrc); //安装新的背景图像
unlock (&mutex); //释放互斥器
从“异常安全性”的观点来看,这个函数很糟。“ 异常安全”有两个条件,而这个函数没有满足其中任何一个条件。
当异常被抛出时,带有异常安全性的函数会:
- 不泄漏任何资源。上述代码没有做到这一点,因为一旦"
new Image (imgSrc)
"导致异常,对unlock
的调用就绝不会执行,于是互斥器就永远被把持住了。 - 不允许数据败坏。如果"
new Image (imgSrc)
"抛出异常,bgImage
就是指向一个已被删除的对象,imageChanges
也已被累加,而其实并没有新的图像被成功安装起来。(但从另一个角度说,旧图像已被消除,所以你可能会争辩说图像还是 “改变了”)。
异常安全
解决资源泄漏的问题很容易,因为条款13讨论过如何以对象管理资源,而条款14也导入了 Lock class
作为一种“确保互斥器被及时释放”的方法:
void PrettyMenu::changeBackground (std::istream& imgSrc)
Lock ml(&mutex); //来自条款14:获得互斥器并确保它稍后被释放
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrC);
}
- 关于“资源管理类”(resource management classes)如
Lock
者,一个最棒的事情是,它们通常使函数更短。你看,不再需要调用unlock
了不是吗? - 有个一般性规则是这么说的:较少的码就是较好的码,因为出错机会比较少,而且一旦有所改变,被误解的机会也比较少。
把资源泄漏抛诸脑后,现在我们可以专注解决数据的败坏了。此刻我们需要做个抉择,但是在我们能够抉择之前,必须先面对一些用来定义选项的术语。
异常安全函数(Exception-safe functions)提供以下三个保证:
-
基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有的
class
约束条件都继续获得满足)。然而程序的现实状态(exact state)恐怕不可预料。举个例子,我们可以撰写
changeBackground
使得一旦有异常被抛出时,PrettyMenu
对象可以继续拥有原背景图像,或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况。如果想知道,他们恐怕必须调用某个成员函数以得知当时的背景图像是什么。 -
强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到 “调用函数之前” 的状态。
和这种提供强烈保证的函数共事,比和刚才说的那种只提供基本承诺的函数共事,容易多了,因为在调用一个提供强烈保证的函数后,程序状态只有两种可能
- 如预期般地到达函数成功执行后的状态
- 或回到函数被调用前的状态
与此成对比的是,如果调用一个只提供基本承诺的函数,而真的出现异常,程序有可能处于任何状态 —— 只要那是个合法状态。
-
不抛掷 (nothrow) 保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如
ints
, 指针等等)身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。
案例待补充。。。。。。。
这个条例告诉我们该怎么做
我们没有理由让这种“异常不安全”的代码永垂不朽!所以当你写新代码或者改旧代码的时候,请仔细思考让它具备异常安全性。
- 首先是 “以对象管理资源”,那可以阻止资源泄露(条款13)。
- 然后是挑选三个 “异常安全保证” 中的某一个实施与你缩写的每一个函数身上。你应该挑选 “现实可施作” 条件下最强烈等级,只有当你的函数调用了传统代码,才别无选择地将它设为 “无任何保证”。
- 将你的决定写成文档,这一来是为你的函数用户着想,而来是为将来的维护者着想。
函数的 “异常安全性保证” 是其可见接口的一部分,所以你应该慎重选择,就像选择函数接口的其他任何部分一样。
四十年前,满载 goto
的代码被视为一种美好实践,而今我们却致力写出结构化控制流(structured control flows)。二十年前,全局数据(globally accessible data)被视为一种美好实践,而今我们却致力于数据的封装。十年前,撰写 “未将异常考虑在内” 的函数被视为一种美好实践,而今我们致力于写出 “异常安全码”。
时间不断前进。我们与时俱进!
请记住
- 异常安全函数(Exception-safe functions)即使发生异常也不会泄露资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
- “强烈保证” 往往能够以 copy-and-swap 实现出来,但 “强烈保证” 并非对所有函数都可实现或具备现实意义。
- 函数提供的 “异常安全保证” 通常最高只等于其所调用之各个函数的 “异常安全保证” 中的最弱者。
条款30:透彻了解 inlining 的里里外外
Inline 函数好与坏
它们看起来像函数,动作像函数,比宏好得多(见条款2),可以调用它们又不需蒙受函数调用所导致的额外开销,实际上你获得的比想象中还要多,因为 “免除函数调用成本” 只是故事的一部分而已。
编译器最优化机制 通常被设计用来浓缩那些 “不含函数调用” 的代码,所以当你 inline
某个函数,或许编译器就因此有能力对它(函数本体)执行语境相关最优化。大部分编译器绝不会对着一个 “outlined
函数调用” 动作执行如此之最优化。
然而编写程序就像现实生活一样,没有白吃的午餐。inline
函数也不例外。inline
函数背后的整体观念是,将 “对此函数的每一个调用” 都以函数本体替换之。我想不需要统计学博士来告诉你,这样做可能增加你的目标码(object code)大小。在一台内存有限的机器上,过度热衷 inlining
会造成程序体积太大(对可用空间而言)。
即使拥有虚内存, inline
造成的代码膨胀亦会导致额外的换页行为(paging),降低指令高速缓存装置的击中率( instruction cache hit rate),以及伴随这些而来的效率损失。
换个角度说,如果 inline
函数的本体很小,编译器针对“函数本体”所产出的码可能比针对“函数调用”所产出的码更小。果真如此,将函数 inlining
确实可能导致较小的目标码(objectcode)和 较高的指令高速缓存装置击中率!
Inline 里里外外详解
待写 。。。。。。。。
请记住
- 将大多数
inlining
限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary upgradability)更容易,也可使前在的代码膨胀问题最小化,使程序的速度提升机会最大化。 - 不要只因为 function templates 出现在头文件,就将它们声明为
inline
。
条款31:将文件间的编译依存关系降至最低
问题描述
假设你对C++程序的某个class实现文件做了些轻微修改。注意,修改的不是 class
接口,而是实现,而且只改 private
成分。然后重新建置这个程序,并预计只花数秒就好。毕竟只有一个 class
被修改。
你按下"Build" 按钮或键入make (或其他类似命令),然后大吃一惊,然后感到窘困,因为你意识到整个世界都被重新编
译和连接了!当这种事情发生,难道你不气恼吗?
问题出在C++并没有把“将接口从实现中分离”这事做得很好。Class 的定义式不只详细叙述了class 接口,还包括十足的实现细目。
例如:
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; //实现细目
};
这里的 class Person
无法通过编译——如果编译器没有取得其实现代码所用到的 classes string
,Date
和Address
的定义式。这样的定义式通常由 #include
指示符提供,所以Person定义文件的最上方很可能存在这样的东西:
#include <string>
#include "date . h"
#include ""address . h"
不幸的是,这么一来便是在 Person
定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。
如果这些头文件中有任何一个被改变,或这些头文件所倚赖的其他头文件有任何改变,那么每一个含入 Person class
的文件就得重新编译,任何使用 Person class
的文件也必须重新编译。
这样的连串编译依存关系(cascading compilation dependencies)会对许多项目造成难以形容的灾难。
待写。。。。。。
请记住
- 支持 “编译依存性最小化” 的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle classes 和 Interface classes。
- 程序库头文件应该以 “完全且仅有声明式”(full and declaration-only forms) 的形式存在。这周做法不论是否涉及
templates
都适用。