《Effective C++》第三版 第五章 实现

19 篇文章 0 订阅
4 篇文章 0 订阅

条款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 更大,有时对程序的可理解性和易维护性造成冲突!因此,除非两种情况:

  1. 你知道赋值成本比 “构造 + 析构” 成本低
  2. 你正在处理代码中效率高度敏感的部分

否则你应该使用做法 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才办得到。
尽量用新式转型

旧式转型仍然合法,但新式转型比较受欢迎。
原因是:

  1. 它们很容易在代码中被辨识出来(不论是人工还是使用工具比如 grep),因而得以简化 “找出类型系统在哪个地点被破坏” 的过程。
  2. 各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。

    比如说,你打算将常量性去掉,除非使用新式转型中的 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。进一步假设 SpecialWindowonResize 函数被要求首先调用 WindowonResize 下面是实现方式之一,看起来是对的,实际上是错的:

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” 的 pointerreference ,你只能靠它们来处理对象,有两个一般性做法可以避免这个问题。

  1. 使用容器并在其中存储直接指向 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)

  1. 通过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 函数调用” 的东西取而代之。

总结:

  1. 优良的 C++ 代码很少使用转型,但若要说完全摆脱它们又太过不切实际,例如从 int 转型为 double 就是转型的一个通情达理的使用。虽然它并非绝对必要。(比如说可以声明一个类型为 double 的新变量并以 x 值初始化)。
  2. 就像面对众多蹊跷可疑的构造函数一样,我们应该尽可能隔离转型动作,通常是把它隐藏在某个函数内,函数的接口会保护调用者不受函数内部任何肮脏龌龊的动作影响。
请记住
  • 如果可以,尽量避免转型动作,特别是在注重效率的代码中避免 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; }
	...
};

这样的设计可通过编译,但却是 错误 的。实际上它是 自我矛盾 的。一方面upperLeftlowerRight 被声明为 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)!

这立刻带给我们两个教训:

  1. 成员变量的封装性最多只等于 “返回其 reference” 的函数的访问级别。本例之中虽然 ulhclrhc 都被声明为 private 它们实际上却是 public,因为 public 函数 upperLeftlowerRight 传出了它们的 references
  2. 如果const成员函数传出一个 reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以改那笔数据。这是 bitwise constness (不变性)的一个附带结果。

上面我们所说的每件事都是由于 “成员函数返回 references”。如果它们返回的是指针或迭代器,同样的情况还是发生,原因也相同References、指针和迭代器统统都是所谓的 handles (号码牌,用来取得某个对象),而返回一个 “代表对象内部数据” 的 handle,随之而来的便是 “降低对象封装性” 的风险。同时,一如稍早所见,它也可能导致 “虽然调用const 成员函数却造成对象状态被更改”。

解决问题:不要这样做就解决了

通常我们认为,对象的 “内部” 就是指它的成员变量,但其实不被公开使用的成员函数(也就是被声明为protectedprivate 者)也是对象 “内部” 的一部分。因此也应该留心不要返回它们的 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,但不能涂写它们。这意味当初声明 upperLeftupperRightconst 不再是个谎言,因为它们不再允许客户更改对象状态(不能改变返回的Point)。至于封装问题,我们总是愿意让客户看到 Rectangle 的外围Points,所以这里是蓄意放松封装。更重要的是这是个有限度的放松:这些函数只有读取权。涂写权仍然是被禁止的。

但即使如此,upperLeftlowerRight 还是返回了 “代表对象内部” 的 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 的一个内部成分,更具体的说是指向一个用以标示 tempPoints

  • 于是 pUpperLeft 指向那个 Point 对象。

  • 目前一切还好,但故事尚未结束。因为在那个语句结束之后,boundingBox 的返回值,也就是我们说 temp 将被销毁,那间接导致 temp 内的 Ponits 析构。 最终导致 pUpperLeft 指向一个不再存在的对象;也就是说一旦产出 pUpperLeft 的那个语句结束,pUpperLeft 也就变成空悬、虚吊(dangling)!

总结
  • 以上就是为什么函数如果 “返回一个 handle 代表对象内部成分” 总时危险的原因。不论这所谓的 handle 是个指针或迭代器或reference,也不论这个handle是否为const,也不论那个返回 handle 的成员函数是否为 const。这里唯一关键是,有个handle 被传出去了,一旦如此你就是暴露在 “handle 比其所指对象更长寿” 的风险下。
  • 这并不意味着你绝对不可以让成员函数返回 handle。有时候你必须那么做。例如 operator [] 就允许你“采摘” stringsvectors 的个别元素,而这些 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;	//背景图像被改变的次数
};

下面是 PrettyMenuchangeBackground 函数的一个可能实现:

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 对象可以继续拥有原背景图像,或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况。如果想知道,他们恐怕必须调用某个成员函数以得知当时的背景图像是什么。

  • 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到 “调用函数之前” 的状态。

    和这种提供强烈保证的函数共事,比和刚才说的那种只提供基本承诺的函数共事,容易多了,因为在调用一个提供强烈保证的函数后,程序状态只有两种可能

    1. 如预期般地到达函数成功执行后的状态
    2. 或回到函数被调用前的状态

    与此成对比的是,如果调用一个只提供基本承诺的函数,而真的出现异常,程序有可能处于任何状态 —— 只要那是个合法状态。

  • 不抛掷 (nothrow) 保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如 ints, 指针等等)身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。

案例待补充。。。。。。。

这个条例告诉我们该怎么做

我们没有理由让这种“异常不安全”的代码永垂不朽!所以当你写新代码或者改旧代码的时候,请仔细思考让它具备异常安全性。

  1. 首先是 “以对象管理资源”,那可以阻止资源泄露(条款13)。
  2. 然后是挑选三个 “异常安全保证” 中的某一个实施与你缩写的每一个函数身上。你应该挑选 “现实可施作” 条件下最强烈等级,只有当你的函数调用了传统代码,才别无选择地将它设为 “无任何保证”。
  3. 将你的决定写成文档,这一来是为你的函数用户着想,而来是为将来的维护者着想。

函数的 “异常安全性保证” 是其可见接口的一部分,所以你应该慎重选择,就像选择函数接口的其他任何部分一样。

四十年前,满载 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 stringDateAddress的定义式。这样的定义式通常由 #include 指示符提供,所以Person定义文件的最上方很可能存在这样的东西:

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

不幸的是,这么一来便是在 Person 定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。

如果这些头文件中有任何一个被改变,或这些头文件所倚赖的其他头文件有任何改变,那么每一个含入 Person class 的文件就得重新编译,任何使用 Person class 的文件也必须重新编译。

这样的连串编译依存关系(cascading compilation dependencies)会对许多项目造成难以形容的灾难。

待写。。。。。。

请记住
  • 支持 “编译依存性最小化” 的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle classesInterface classes
  • 程序库头文件应该以 “完全且仅有声明式”(full and declaration-only forms) 的形式存在。这周做法不论是否涉及 templates 都适用。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值