我的建议可以分为两大类:
一般的设计策略和带有特定细节的特定语言特性。
设计讨论的重点是“如何在两种不同的方法之间做一些事情”。应该使用继承还是模板?你应该选择公共继承还是私人继承?您应该选择私有继承还是组合?我应该选择成员函数还是非成员函数?您应该选择值传递还是引用传递?在这些选择点上做出正确的决定是很重要的,因为一个糟糕的决定可能不会立即产生后果,但可能要到开发的后期才会生效,这时纠正它可能是困难的、耗时的和昂贵的。
即使你完全知道该做什么,完全进入正轨还是可能有点棘手。什么是assignment操作符的适当返回类型(return type)?何时该令析构函数为virtual?当operatornew无法找到足够内存时该如何行事?榨出这些细节很是重要,因为如果疏忽而不那么做,几乎总是导致未可预期的、也许神秘难解的程序行为
习惯C++
条款01: 视 C++ 为一个语言联邦(C、object、template、STL)
为了理解C++,你必须认识其主要的次语言。幸运的是总共只有四个:
- C 。 说到底 C++ 仍是以 C 为基础。区块 (blocks)、语句 ( statements )、预处理器(preprocesor)、内置数据类型(built-indata types)、数组(arrays)、指针 ( pointers )等统统来自 C 。许多时候 C++ 对问题的解法其实不过就是较高级的 C 解法 (例如条款2 谈到预处理器之外的另一选择,条款13 谈到以对象管理资源 ) ,但当你以 C++ 内的 C 成分工作时,高效编程守则映照出 C 语言的局限:没有模板(templates),没有异常(exceptions),没有重载(overloading...
- Object-Oriented C++。这部分也就是C with Classes所诉求的:classes (包括构造函数和析构函数),封装 (encapsulation )、继承 (inheritance )、多态 (polymorphism)、virtual 函数(动态绑定)......等等。这一部分是面向对象设计之古典守则在 C++ 上的最直接实施。
- Template C++。这是C++ 的泛型编程 (generic programming)部分,也是大多数程序员经验最少的部分。Template 相关考虑与设计已经弥漫整个C++,良好编程守则中 “惟 template 适用” 的特殊条款并不罕见 (例如条款46 谈到调用 template functions 时如何协助类型转换)。实际上由于templates威力强大,它们带来崭新的编程范型(programming paradigm),也就是所谓的template meta programming (TMP,模板元编程) 。条款48 对此提供了一份概述,但除非你是 template 激进國队的中坚骨干,大可不必太担心这些。TMMP 相关规则很少与C++ 主流编程互相影响。
- STL。STL是个template程序库,看名称也知道,但它是非常特殊的一个。它对容器 (containers)、选代器(iterators)、算法(allgoritbhms)以及函数对象(function objects)的规约有极佳的紧密配合与协调,然而 templates 及程序库也可以以其他想法建置出来。STL 有自己特殊的办事方式,当你化同STL 一起工作,你必须遵守它的规约。
记住这四个次语言,当你从某个次语言切换到另一个,导致高效编程守则要求你改变策略时,不要感到惊讶。例如对内置 (也就是 C-like) 类型而言 pass-by-valve 通常比 pass-by-reference 高效,但当你从 C part of C++移往 Object-Oriented C++, 由于用户自定义(user-defined) 构造函数和析构函数的存在,pass-by-reference-to-const 往往更好。运用 Template C++时尤其如此,因为彼时你甚至不知道所处理的对象的类型。然而一旦跨入STL 你就会了解,迭代器和函数对象都是在 C 指针之上塑造出来的,所以对 STL 的迭代器和函数对象而言,旧式的 C pass-by-value 守则再次适用
C++ 高效编程守则视状况而变化,取决于你使用C++ 的哪一部分。
条款02: 尽量以const, enum, inline 替换 #define
#define ASPECTRATIO 1.653 标记名称 ASPECTRATIO 也许从未被编泽器看见;也许在编译器开始处理源码之前它就被预处理器移走了。于是名称 ASPECTRATIO 有可能没进入记号表(symbol table)内。于是当你运用此常量但获得一个编译错误信息时,可能会带来困惑,因为这个错误信息也许会提到 1.653 而不是 ASPECTRATI0。如果 ASPECTRATIO 被定义在一个非你所写的头文件内,你肯定对 1. 553 以及它来自何处毫无概念,于是你将因为追踪它而浪费时间。这个问题也可能出现在记号式调试器(symbolic debugger)中,原因相同:你所使用的名称可能并未进入记号表(symbol table)
常量:
解决之道是以一个常量替换上述的宏 (#define) :
const double AspectRatio = 1.653; //大写名称通常用于宏,因此这里改变名称写法。 作为一个语言常量,AspectRatio 肯定会被编译器看到,当然就会进入记号表内。此外对浮点常量 (floating point consa nt,就像本例) 而言,使用常量可能比使用 #define 导致较小量的码,因为预处理器“盲目地将宏名称ASPECTRATIO替换为 1.653” 可能导致目标码 (object code)出现多份 1.653,若改用常量 AspectRatio 绝不会出现相同情况。
有两种特殊情况值得说说。
第一是定义常量指针 (constant pointers)。由于常量定义式道常被放在头文件内 (以便被不同的源码含入), 因此有必要将指针 (而不只是指针所指之物)声明为const。例如若要在头文件内定义一个常量的 (不变的) char*-based 字符串,你必须写 const 两次:
const char* const authorName = "Scott Meyers"; 关于const 的意义和使用(特别是当它与指针结合时),条款3 有完整的讨论。 这里值得先提醒你的是,string 对象通常比其前辈 char*-based 合宜,所以上述的 authorName 往往定义成这样更好些:
const std::string authorName("Scott Meyers"); 第二个值得注意的是class 专属常量。为了将常量的作用域( scope )限制于class 内,你必须让它成为class的一个成员(member):而为确保此常量至多只有一份实体,你必须让它成为一个static成员:
class GamePlayer {
private:
static const int NumTurns = 5; //常量声明式
int scores[NumTurns]; //使用该常量
...
}; 然而你所看到的是NumTurns的声明式而非定义式。通常C++ 要求你对你所使用的任何东西提供一个定义式,但如果它是个class专属常量又是static 且为整数类型(integraltype,例如ints,chars,bools),则需特殊处理。只要不取它们的地址, 你可以声明并使用它们而无须提供定义式。但如果你取某个class 专属常量的地址,或纵使你不取其地址而你的编译器却 (不正确地)坚持要看到一个定义式,你就必须另外提供定义式如下:
const int GamePlayer::NumTurns; //NumTurns的定义: 下面告诉你为什么没有给予数值 请把这个式子放进一个实现文件而非头文件。由于class 常量己在声明时获得初值 ( 例如先前声明 Num Turns 时为它设初值 5),因此定义时不可以再设初值。
顺带一提,请注意,我们无法利用 #define 创建一个class 专属常量,因为 #defines并不重视作用域 (scope)。一旦宏被定义,它就在其后的编译过程中有效(除非在某处被#undef)。这意味 #defines 不仅不能够用来定义class 专属常量, 也不能够提供任何封装性,也就是说没有所谓 private #define 这样的东西。而 const 成员变量是可以被封装的,NumIurns就是。
旧式编译器也许不支持上述语法,它们不允许 static 成员在其声明式上获得初值。此外所谓的“in-class 初值设定〞也只允许对整数常量进行。如果你的编译器不支持上述语法,你可以将初值放在定义式:
class CostEstimate {
private:
static const double FudgeFactor; //static class常量声明
... //位于头文件内
}
const double //static class常量定义
CostEstimate::FudgeFactor= 1.35; //位于实现文件内 这几乎是你在任何时候唯一需要做的事。唯一例外是当你在class 编译期间需要一个 class 常量值,例如在上述的 GamePlayer::scores 的数组声明式中(是的,编译器坚持必须在编译期间知道数组的大小)。
这时候万一你的编译器 (错误地) 不允许 “static 整数型 class 常量” 完成 “in class初值设定”,可改用所谓的 "the enum hack” 补偿做法。其理论基础是: “一个属于枚举类型 (enumerated type)的数值可权充 ints 被使用”,于是GamePlayer可定义如下:
class GamePlayer {
private:
enum { NumTurns= 5 }; // "the enum hack"--令NumTurns
// 成为5的一个记号名称
int scores[NumTurns]; // OK
...
}; 基于数个理由 enum hack 值得我们认识。
第一,enum hack 的行为某方面说比较像 #define 而不像 const,有时候这正是你想要的。例如取一个 const 的地址是合法的,但取一个 enum 的地址就不合法,而取一个 #define 的地址通常也不合法。
如果你不想让别人获得一个 pointer 或 reference 指向你的某个整数常量,enum可以帮助你实现这个约束。 (条款18 对于 “通过撰码时的决定实施设计上的约束条件” 谈得更多。)此外虽然优秀的编译器不会为 “整数型 const 对象” 设定另外的存储空间 (除非你创建一个pointer 或reference 指向该对象),不够优秀的编译器却可能如此,而这可能是你不想要的。Enums 和 #defines 一样绝不会导致非必要的内存分配 。
认识 enum hack 的第二个理由纯粹是为了实用主义。许多代码用了它,所以看到它时你必须认识它。事实上 "enum back" 是template meta prograrming (模板元编程,见条款48)的基础技术。把焦点拉回预处理器。另一个常见的 #define 误用情况是以它实现宏 (macros)。宏看起来像函数,但不会招致函数调用 (function call) 带来的额外开销。下面这个宏夹带着宏实参,调用函数 f :
// 以 a 和 b 的较大值调用 f
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b)) 这般长相的宏有着太多缺点,光是想到它们就让人痛苦不堪。
无论何时当你写出这种宏,你必须记住为宏中的所有实参加上小括号,否则某些人在表达式中调用这个宏时可能会遭遇麻烦。但纵使你为所有实参加上小括号,看看下面不可思议的事情 :
int a = 5, b = 0;
CALL_WITH_MAX(++a, b); // a被累加一次
CALL_WITH_MAx(++a, b+10); // a被累加二次 在这里,调用f 之前,a的递增次数竟然取决于“它被拿来和谁比较” !
幸运的是你不需要对这种无聊事情提供温床。你可以获得宏带来的效率以及一般函数的所有可预料行为和类型安全性(type safety)——只要你写出template inline 函数 (见条款30)
template<typename T> //由于我们不知道
inline void call_With_Max(const T&a, const T&b) //T是什么,所以采用
{ //pass by reference-to-const.
f(a > b ? a : b); //见条款20.
} 这个template 产出一整群函数,每个函数都接受两个同型对象,并以其中较大者调用 f。这里不需要在函数本体中为参数加上括号,也不需要操心参数被核算( 求值 )多次 ... ... 等等。此外由于 calI_With_Max 是个真正的函数,它遵守作用域 ( scope ) 和访问规则。例如你绝对可以写出一个“class 内的 private inline函数” 。一般而言宏无法完成此事。
有了consts、enums 和inlines,我们对预处理器(特别是#define)的需求降低了,但并非完全消除。#include 仍然是必需品,而#ifdef/ #ifndef 也继续扮演控制编译的重要角色。目前还不到预处理器全面引退的时候,但你应该明确地给予它更长更频繁的假期
总结:
对于单纯常量,最好以const 对象或enums替换#defines
对于形似函数的宏 (macros),最好改用 inline 函数替换 #defines
条款03: 尽可能使用 const
如果关键字const 出现在星号左边,表示被指物是常量;
如果出现在星号右边,表示指针自身是常量;
如果出现在星号两边,表示被指物和指针两者都是常量。
如果被指物是常量,有些程序员会将关键字const 写在类型之前,有些人会把它写在类型之后、星号之前。两种写法的意义相同,所以下列两个函数接受的参数类型是一样的:
void f1(const Widget* pw); //f1获得一个指针,指向一个常量的(不变的)Widget对象
void f2(Widget const * pw); //f2也是 STL 迭代器系以指针为根据塑模出来,所以迭代器的作用就像个T* 指针。声明迭代器为const 就像声明指针为const 一样 (即声明一个 T*const 指针),表示这个迭代器不能指向不同的东西,但它所指的值是可以改动的。如果你希望迭代器所指的值不可被改动 (即希望STL模拟一个const T* 指针),你需要的是const iterator:
std::vector<int> vec;
...
const std::vector<int>::iterator iter = vec.begin(); //iter的作用像个T* const
*iter = 10; //没问题,改变iter所指物
++iter; //错误! iter是const
std::vector<int>::const_iterator cIter = vec.begin(); //cIter的作用像个const T*
*cIter = 10; //错误! *cIter是const
++cIter; //没问题,改变cIter const 最具威力的用法是面对函数声明时的应用。在一个函数声明式内,const可以和函数返回值、各参数、函数自身(如果是成员函数)产生关联。
令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。
举个例子,考虑有理数 (rational numbers,详见条款24)的 operator* 声明式:
class Rational { ... );
const Rational operator*(const Rational Ihs, const Rationals rhs); 唔,为什么返回一个 const 对象? 原因是如果不这样客户就能实现这样的暴行:
Rational a, b, c;
...
(a * b) = c; //在a * b的成果上调用operator= 如 果 a 和 b 又都是内置类型,这样的代码直截了当就是不合法。而一个 “良好的用户自定义类型” 的特征是它们避免无端地与内置类型不兼容 (见条款 18),因此允许对两值乘积做赋值动作也就没什么意思了。將 operator* 的回传值声明为 const 可以预防那个“没意思的赋值动作〞,这就是该那么做的原因。
至于const 参数,没有什么特别新颖的观念,它们不过就像local const对象一样,你应该在必要使用它们的时候使用它们。除非你有需要改动参数或local 对象,否则请将它们声明为const 。只不过多打6 个字符,却可以省下恼人的错误,像是 “ 想要键入== 却意外键成 = 的错误,一如稍早所述 。
const 成员函数
將const 实施于成员函数的目的,是为了确认该成员函数可作用于const 对象身上。这一类成员函数之所以重要,基于两个理由。
第一,它们使 class 接口比较容易被理解。这是因为,得知哪个函数可以改动对象内容而哪个函数不行,很是重要。
第二,它们使“操作 const 对象” 成为可能。这对编写高效代码是个关键,因为如条款20 所言,改善 C++ 程序效率的一个根本办法是以 pass by reference-to-const 方式传递对象,而此技术可行的前提是,我们有const 成员函数可用来处理取得 (并经修饰而成)的const 对象。
许多人漠视一件事实:两个成员函数如果只是常量性 (constness)不同,可以被重载。这实在是一个重要的C++ 特性。考虑以下class,用来表现一大块文字:
class TextBlock {
public:
...
const char& operator[](std::size_t position) const //operator[] for
{ return text[position]; } //const对象
char& operator[](std::size_t position) //operator[] for
{ return text[position]; } //non-const对象
private:
std::string text;
}; TextBlock的operator[]s可被这么使用:
TextBlock tb("Hello");
std::cout << tb[0]; //调用non-const TextBlock::operator[]
const TextBlock ctb("World");
sta::cout << ctb[0]; //调用const TextBlock::operator[] 附带一提,真实程序中const对象大多用于passed by pointer-to-const 或 passed by reference-to-const 的传递结果。上述的ctb例子太过造作,下面这个比较真实
void print (const TextBlock &ctb) //此函数中ctb是const
{
std::cout << ctb[0]; //调用const TextBlock::operator[]
...
} 只要重载operator[]并对不同的版本给予不同的返回类型,就可以令const 和non-const TextBlocks获得不同的处理
std::cout << tb[0]; //没问题--读一个non-const TextBlock
tb[0] = 'x'; //没问题--写一个non-const TextBlock
std::cout << ctb[0]; //没问题--读一个const TextBlock
ctb[0] = 'x'; //错误!--写一个const TextBlock 上述错误因 operator[] 的返回类型导致,至于operator[] 调用动作自身没问题。错误起因于企图对一个 “由 const 版本 operator[] 返回” 的 const char& 施行赋值动作,non-const operator[] 的返回类型是个 reference to char,不是 char。如果 operator[] 只是返回一个 char,下面这样的向子就无法通过编译:
tb[0] = 'x';
那是因为,如果函数的返回类型是个内置类型,那么改动函数返回值从来就不合法。纵使合法,C++ 以 by value 返回对象这一事实 (见条款20) 意味被改动的其实是 tb. text[0] 的一个副本,不是tb.text[0] 自身,那不会是你想要的行为。
让我们为哲学思辦喊一次暂停。成员函数如果是const 意味什么?
这有两个流行概念:bitwise constness (又称physical constness ) 和 logical constness
bitwise const 阵营的人相信,成员函数只有在不更改对象的任何成员变量( static除外)时才可以说是const。bitwise constness 正是C++ 对常量性 (constness)的定义,因此 const 成员函数不可以更改对象内任何 non-static 成员变量。
不幸的是许多成员函数虽然不十足具备const 性质却能通过bitwise 测试。更具体地说,一个更改了“指针所指物” 的成员函数虽然不能算是const,但如果只有指针(而非其所指物)隶属于对象,那么称此函数为 bitwise const 不会引发编译器异议。这导致反直观结果。
假设我们有一个 TextBlock-like class,它將数据存储为 char* 而不是string,因为它需要和一个不认识 string 对象的 C API 沟通:
class CTextBlock {
public:
...
char& operator[](std::size_t position) const //bitwise const声明,
{ return pText[position]; } //其实不适当
private:
char* pText;
}; 这个class 不适当地将其operator[]声明为const 成员函数,而该函数却返回一个 reference 指向对象内部值 ( 条款28 对此有深刻讨论 ) 。假设暂时不管这个事实,请注意,operator[]实现代码并不更改 pText。于是编译器很开心地为 operator 又产出目标码。它是bitwise const,所有编译器都这么认定。但是看看它允许发生什么事:
const CTextBlock cctb("Hello"); //声明一个常量对象。
char* pc = &cctb[0]; //调用const operator[]取得一个指针,指向cctb的数据
*pc = 'J'; //cctb现在有了“Jello”这样的内容 这其中当然不该有任何错误:你创建一个常量对象并设以某值,而且只对它调用 const 成员函数。但你终究还是改变了它的值。
这种情况导出所谓的logical constness。这一派拥护者主张,一个const 成员函数可以修改它所处理的対象内的某些bits,但只有在客戸端检测不出的情況下オ得如此。例如你的CTextBlock class 有可能高速缓存(cache)文本区块的长度以便应付询问:
class CTextBlock {
public:
...
std::size_t length() const;
private:
char* pText;
std::size_t textLength; // 最近一次计算的文本区块长度。
bool lengthIsValid; // 目前的长度是否有效。
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText); // 错误!在const成员函数内
lengthIsValid = true; // 不能赋值给textLength
} // 和lengthIsValid
return textLength;
} length的实现当然不是bitwise const,因为 textLength 和 lengthIsValid 都可能被修改。这两笔数据被修改对 const CTextBlock 对象而言虽然可接受,但编译器不同意。它们坚持bitwise constness。怎么办?
解决办法很简单:利用C++的一个与const 相关的关键字:mutable(可变的)。 mutable释放掉 non-static 成员变量的 bitwise constness 约束:
class CTextBlock {
public:
...
std::size_t length() const;
private:
char* pText;
mutable std::size_t textLength; //这些成员变量可能总是会被改变,
mutable bool lengthIsValid; //即使在const成员函数内
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText); //现在,可以这样,
lengthIsValid = true; //也可以这样。
}
return textLength;
} 在const 和non-const 成员函数中避免重复
对于“bitwise-constness 非我所欲” 的问题,mutable 是个解决办法,但它不能解决所有的 const 相关难题。举个例子,假设 TextBlock ( 和 CTextBlock ) 内的 operator[] 不单只是返回一个 reference 指向某字符,也执行边界检验 ( bounds checking)、志记访问信息(logged access info )、甚至可能进行数据完善性检验。 把所有这些同时放进 const 和 non-const operator[] 中,导致这样的怪物(暂且不管那将会成为一个“长度颇为可议” 的隐喻式inline 函数——见条款30):
class TextBlock {
public:
...
const char& operator[](std::size_t position) const
{
... // 边界检验 (bounds checking)
... // 志记数据访问(log access data)
... // 检验数据完整性(verify data integrity)
return text [position];
}
char& operator[](std::size_t position)
{
... // 边界检验(boundschecking)
... // 志记数据访问(log access data )
... // 检验数据完整性(verify data integrity)
return text[position];
}
private:
std::string text;
}; 将边界检验 ... ... 等所有代码移到另一个成员函数 (往往是个private)并令两个版本的 operator[] 调用它,是可能的,但你还是重复了一些代码,例如函数调用、两次 return 语句等等。
你真正该做的是实现 operator[] 的机能一次并使用它两次。也就是说,你必须令其中一个调用另一个。这促使我们将常量性转除 (casting away constnss)
就一般守则而言,转型(casting)是一个糟糕的想法,我將贡献一整个条款来谈这码事(条款 27),告诉你不要那么做,而且代码重复也不是什么令人愉快的经验。本例中 const operator[] 完全做掉了 non-const 版本该做的一切,唯一的不同是其返回类型多了一个 const 资格修饰。这种情况下如果將返回值的 const 转除是安全的,因为不论谁调用 non-const operator[] 都一定首先有个 non-const 对象,否则就不能够调用 non-const 函数。所以令 non-const operator[] 调用其 const 兄弟是一个避免代码重复的安全做法——即使过程中需要一个转型动作。下面是代码,稍后有更详细的解释:
class TextBlock {
public:
...
const char& operator[](std::sizet position) const //一如既往
{
...
...
...
return text[position];
}
char& operator[](std::size_t position) //现在只调用const op[]
{
return const_cast<char&>( //將op[]返回值的const转除
static_cast<const TextBlock&>(*this) //为*this加上const
[position]); //调用const op[]
}
...
}; 如你所见,这份代码有两个转型动作,而不是一个。我们打算让 non-const operator[]调用其const 兄弟,但 non-const operator[]内部若只是单纯调用 operator[],会递归调用自己。那会大概 ... 唔 ... 进行一百万次。为了避免无穷递归,我们必须明确指出调用的是const operator[],但C++缺乏直接的语法可以那么做。因此这里將 *this 从其原始类型 TextBlocks 转型为 const TextBlock&。是的,我们使用转型操作为它加上 const ! 所以这里共有两次转型 : 第一次用来为 *this 添加 const (这使接下来调用 operator[] 时得以调用 const 版本),第二次则是从 const operator[] 的返回值中移除const。
添加const的那一次转型强迫进行了一次安全转型(将 non-const 对象转为 const 对象),所以我们使用 static_cast。移除 const 的那个动作只可以藉由 const_cast 完成,没有其他选择 (就技术而言其实是有的; 一个 C-style 转型也行得通,但如我在条款 27 所说,那种转型很少是正确的抉择。如果你不熟悉 static_cast 或const_cast,条款27 提供了一份概要)。
至于其他动作,由于本例调用的是操作符,有我们渴望的“避免代码重复” 效果,因为它运用 const operator[] 实现出 non-const 版本。为了到达那个目标而写出如此难看的语法是否值得,只有你能决定,但“运用const 成员函数实现出其non-const 孪生兄弟” 的技术是值得了解的。
更值得了解的是,反向做法——令const 版本调用 non-const 版本以避免重复——并不是你该做的事。记住,const 成员函数承诺绝不改变其对象的逻辑状态 (logical state),non-const 成员函数却没有这般承诺。如果在const 函数内调用 non-const 函数,就是冒了这样的风险:你曾经承诺不改动的那个对象被改动了。 这就是为什么 “ const 成员函数调用 non-const 成员函数” 是一种错误行为:因为对象有可能因此被改动。
const 可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。编译器强制实施 bitwise constness, 但你编写程序时应该使用“概念上的常量性” (conceptual constness)
当const 和non-const 成员函数有着实质等价的实现时,令 non-const 版本调用 const 版本可避免代码重复
条款04: 确定对象被使用前已先被初始化
array(来自C part of C++)不保证其内容被初始化,而vector (来自 STL part of C++)却有此保证。
所谓static 对象,其寿命从被构造出来直到程序结束为止,因此stack 和 heap-based 对象都被排除。这种对象包括global 对象、定义于namespace 作用域内的对象、在clases 内、在函数内、以及在file 作用域内被声明为 static 的对象。 函数内的static对象称为local static对象(因为它们对函数而言是local),其他static 对象称为non-local static 对象。
C++ 对“定义于不同的编译单元内的non-local static 对象” 的初始化相对次序并无明确定义
class FileSystem { //来自你的程序库
public:
...
std::size_t numDisks() const; //众多成员函数之一
...
};
extern FileSystem tfs; //预留给客户使用的对象;
//tfs代表"the file system" class Directory { //由程序库客户建立
public:
Directory( params );
...
};
Directory::Directory( params )
{
...
std::size_t disks = tfs.numDisks(); //使用tfs对象
...
} Directory tempDir( params ); //为临时文件而做出的目录 现在 , 初始化次序的重要性显现出来了: 除非 tfs 在 tempDir 之前先被初始化,否则tempDir的构造函数会用到尚未初始化的tfs。但tfs和tempDir是不同的人在不同的时间于不同的源码文件建立起来的 ,它们是定义于不同编译单元内的 non-local static 对象。如何能够确定tfs会在tempDir 之前先被初始化?
幸运的是一个小小的设计便可完全消除这个问题。唯一需要做的是:将每个 non-local static 对象搬到自己的专属函数内(该对象在此函数内被声明为static)。 这些函数返回一个reference 指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。Design Patterns 迷哥迷姊们想必认出来了,这是Singleton 模式的一个常见实现手法
这个手法的基础在于: C++ 保证,函数内的 local static 对象会在 “该函数被调用期间” “首次遇上该对象之定义式” 时被初始化。所以如果你以 “函数调用” (返回一个reference指向local static对象)替换“直接访问non-local static对象” ,你就获得了保证,保证你所获得的那个reference 将指向一个历经初始化的对象。更棒的是,如果你从未调用non-local static 对象的 “仿真函数“,就绝不会引发构造和析构成本:真正的non-local static 对象可没这等便宜!
以此技术施行于tfs和tempDir 身上,结果如下
class FileSystem { ... }; //同前
FileSystem& tfs() //这个函数用来替换tfs对象;它在
{ //FileSystem class中可能是个static
static FileSystem fs; //定义并初始化一个local static对象,
return fs; //返回一个reference指向上述对象。
}
class Directory { ... };
Directory::Directory(params) //同前,但原本的reference to tfs
{ //现在改为tfs()
...
std::size_t disks = tfs().numDisks( );
...
}
Directory& tempDir() //这个函数用来替换tempDir对象;
{ //它在Directory class中可能是个static。
static Directory td; //定义并初始化local static对象,
return td; //返回一个reference指向上述对象。
} 这种结构下的reference-returning 函数往往十分单纯:第一行定义并初始化一个local static对象,第二行返回它。这样的单纯性使它们成为绝佳的 inlining 候选人,尤其如果它们被频繁调用的话 (见条款 30 )。但是从另一个角度看,这些函数 “ 内含 static 对象 ” 的事实使它们在多线程系统中带有不确定性。再说一次,任何一种non-const static 对象,不论它是local或non-local,在多线程环境下“等待某事发生” 都会有麻烦。处理这个麻烦的一种做法是:在程序的单线程启动阶段 (single-threaded startup portion )手工调用所有reference-returning 函数,这可消除与初始化有关的“竞速形势(raceconditions) ”
当然啦,运用reference-returning 函数防止“初始化次序问题” ,前提是其中有着一个对对象而言合理的初始化次序。如果你有一个系统,其中对象 A 必须在对象 B 之前先初始化,但 A 的初始化能否成功却又受制于B 是否己初始化,这时候你就有麻烦了。坦白说你自作自受。只要避开如此病态的境况,此处描述的办法应该可以提供你良好的服务, 至少在单线程程序中。
既然这样,为避免在对象初始化之前过早地使用它们,你需要做三件事。第一,手工初始化内置型non-member 对象。第二,使用成员初值列(member initialization lists )对付对象的所有成分。最后,在 “初始化次序不确定性” (这对不同编译单元所定义的non-local static 对象是一种折磨)氛围下加强你的设计为内置型对象进行手工初始化,因为C++不保证初始化它们。
构造函数最好使用成员初值列 (member initialization list ) ,而不要在构造函数本体内使用赋值操作(assignment)。初值列列出的成员变量,其排列次序应该和它们在class 中的声明次序相同。
为免除 “跨编译单元之初始化次序” 问题,请以local static 对象替换non-local static 对象。
构造/析构/赋值运算
条款 05:了解C++默默编写并调用哪些函数
什么时候empty class (空类)不再是个empty class 呢?当C++ 处理过它之后。 是的,如果你自己没声明,编译器就会为它声明 (编译器版本的)一个copy构造函数、 一个copy assignment操作符和一个析构函数。此外如果你没有声明任何构造函数, 编译器也会为你声明一个default构造函数。所有这些函数都是public 且inline (见条款30) 。
标准 string 有个copy构造函数,所以no2.namevalue的初始化方式是调用 string的copy构造函数并以no1.namevalve 为实参。另 一个成员 NamedObjects<int>::objectvalue 的类型是 int (因为对此 template 具现体而言是int),那是个内置类型,所以no2. objectvalve 会以“拷贝nol. objectvalue 内的每一个bits” 来完成初始化。
编译器可以暗自为class 创建default 构造函数、copy构造函数 、copy assignment 操作符,以及析构函数
条款 06: 若不想使用编译器自动生成的函数,就该明确拒绝
显式声明私有版本的复制构造函数和赋值运算符函数,可以防止编译器自动生成它们。
“将成员函数声明为 private 而且故意不实现它们” 这一伎俩是如此为大家接受,因而被用在C++ iostream程序库中阻止copying行为。是的,看看你手上的标准程序库实现码中的ios base, basic ios和 sentry。你会发现无论哪一个,其copy构造函数和copy assignment 操作符都被声明为private 而且没有定义。
将连接期错误移至编译期是可能的( 而且那是好事,毕竟愈早侦測出错误愈好), 只要將copy构造函数和copy assignment 操作符声明为private就可以办到,但不是在 HomeForsale 自身,而是在一个专门为了阻止copying动作而设计的base class 内。 这个 base class 非常简单:
class Uncopyable {
protected: // 允许derived对象构造和析构
Uncopyable () {}
~Uncopyable () { }
private:
Uncopyable (const Uncopyable&); // 但阻止copying
Uncopyable& operator= (const Uncopyable&);
}; 为求阻止HomeForsale对象被拷贝,我们唯一需要做的就是继承uncopyable:
class HomeForSale: private Uncopyable { //class不再声明
//copy构造函数或
}; //copy assign. 操作符 这行得通,因为只要任何人一一甚至是member 函数或friend 函数——尝试拷贝 HomeForsale 对象,编译器便试着生成一个 copy 构造函数和一个copy assignment 操作符,而正如条款12所说,这些函数的“编译器生成版” 会尝试调用其base class 的对应兄弟,那些调用会被编译器拒绝,因为其base class 的拷贝函数是private
为驳回编译器自动 (暗自)提供的机能,可将相应的成员函数声明为private 并且不予实现。使用像Uncopyable 这样的baseclass 也是一种做法
条款 07: 为多态基类声明 virtual 析构函数
当在基类指针上删除派生类对象时,如果基类的析构函数不是虚函数,将导致派生类对象的析构函数不会被调用,可能会产生不可预测的行为。
当 derived class 对象经由一个 base class 指针被删除,而该 base class 带着一个 non-virtual 析构函数,其结果未有定义——实际执行时通常发生的是对象的derived class没被销毁。
消除这个问题的做法很简单:给base class一个virtual析构函数。此后删除derived class 对象就会如你想要的那般。是的,它会销毁整个对象,包括所有derived class 成分:
class TimeKeeper {
public:
TimeKeeper( );
virtual ~TimeKeeper();
...
};
TimeKeeper* ptk = getTimeKeeper();
...
delete ptk; // 现在,行为正确。 任何class 只要带有virtual 函数都几乎确定应该也有一个virtual 析构函数。
如果 class 不含 virtual 函数,通常表示它并不意图被用做一个base class。当class 不企图被当作base class,令其析构函数为 virtual 往往是个馊主意。
欲实现出 virtual 函数,对象必须携带某些信息,主要用来在运行期决定哪一个 virtual 函数该被调用。这份信息通常是由一个所谓vptr ( virtual table pointer ) 指针指出。vptr指向一个由函数指针构成的数组,称为vtbl(virtual table):每一个带有virtual 函数的class都有一个相应的vtbl。当对象调用某一virtual函数,实际被调用的函数取决于该对象的 vptr 所指的那个vtbl——编译器在其中寻找适当的函数指针。
virtual 函数的实现细节不重要。重要的是如果Point class 内含virtual 函数,其对象的体积会增加:在32-bit 计算机体系结构中将占用64bits(为了存放两个ints) 至 96 bits (两个 ints 加上 vptr) : 在 64-bit 计算机体系结构中可能占用 64~128 bits,因为指针在这样的计算机结构中占64 bits。因此,为Point 添加一个vptr会增加其对象大小达50%~100% Point!对象不再能够塞入一个64-bit缓存器,而 C++ 的Point对象也不再和其他语言(如C)内的相同声明有着一样的结构(因为其他语言的对应物并没有vptr),因此也就不再可能把它传递至 (或接受自)其他语言所写的函数,除非你明确补偿vptr——那属于实现细节,也因此不再具有移植性。
即使class 完全不带virtual 函数,被“non-virtual 析构函数问题” 给咬伤还是有可能的。举个例子,标准 string 不含任何 virtual 函数 ,但有时候程序员会错误地把它当做 base class:
class Specialstring: public std::string { //馊主意!std::string有个
// non-virtual 析构函数
...
}; SpecialString* pss = new SpecialString ("Impending Doom");
std::string* ps;
ps = pss; // SpecialString* => std::string*
delete ps; // 未有定义!现实中*ps的Specialstring资源会泄漏,
// 因为specialstring析构函数没被调用。 相同的分析适用于任何不带virtual 析构函数的class,包括所有STL 容器如 vector , list , set , tr1::unordered map (见条款54)等等。如果你曾经企图继承一个标准容器或任何其他“带有non-virtual析构函数” 的class,拒绝诱惑吧!
有时候令class带一个pure virtual析构函数,可能颇为便利。还记得吗,pure virtual 函数导致 abstract (抽象)classes——也就是不能被实体化 (instantiated)的class。 也就是说,你不能为那种类型创建对象。然而有时候你希望拥有抽象class,但手上没有任何pure virtual函数,怎么办?唔,由于抽象class总是企图被当作一个base class 来用,而又由于base class应该有个virtual析构函数,并且由于pure virtual 函数会导致抽象class, 因此解法很简单:为你希望它成为抽象的那个class声明一个pure virtual 析构函数。
class AWOV { //AWOV = "Abstract w/o Virtuals"
public:
virtual ~AWOV( ) = 0; //pure virtual析构函数的定义
}; 这个class 有一个pure virtual 函数,所以它是个抽象class,又由于它有个virtual 析构函数,所以你不需要担心析构函数的问题。然而这里有个窍门: 你必须为这个 pure virtual 析构函数提供一份定义
AWOV::~AWOV() { } //pure virtual析构函数的定义 析构函数的运作方式是,最深层派生(most derived) 的那个class其析构函数最先被调用,然后是其每一个 base class 的析构函数被调用。
“给 base classes 一个 virtual 析构函数”,这个规则只适用于 polymorphic (带多态性质的)base classes 身上。这种 base classes 的设计目的是为了用来 “通过 base class 接口处理derived class对象”。TimeKeeper就是一个polymorphic base class,因为我们希望处理AtomicClock 和waterclock对象,纵使我们只有TimeKeeper指针指向它们。
并非所有base classes的设计目的都是为了多态用途。例如标准string和STL容器都不被设计作为 base classes 使用,更别提多态了。某些 classes 的设计目的是作为base clases使用,但不是为了多态用途。这样的classes如条款6的Uncopyable 和标准程序库的 input_iterator_tag(条款47),它们并非被设计用来 “经由 base class接口处置derived class 对象” ,因此它们不需要virtual 析构函数。
polymorphic (带多态性质的)base classes应该声明 一个virtual 析构函数。如果 class 带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数。
Classes 的设计目的如果不是作为base classes 使用,或不是为了具备多态性 (polymorphically),就不该声明virtual 析构函数。
条款 08: 别让异常逃离析构函数
在析构函数中不要抛出异常,否则可能导致未定义的行为。
C++ 并不禁止析构函数吐出异常,但它不鼓励你这样做。
class Widget {
public:
...
~Widget( ) { ... } //假设这个可能吐出一个异常
};
void doSomething()
{
std::vector<Widget> v;
...
} //v在这里被自动销毁 只要析构函数吐出异常,即使并非使用容器或arrays,程序也可能过早结束或出现不明确行为
如果你的析构函数必须执行一个动作,而该动作可能会在失败时抛出异常,该怎么办?举个例子,假设你使用一个class 负责数据库连接:
class DBConn { // 这个class 用来管理DBConnection 对象
public:
...
~DBConn () //确保数据库连接总是会被关闭
{
db.close ( );
}
private:
DBConnection do;
}; 只要调用close成功, 一切都美好。但如果该调用导致异常,DBConn析构函数会传播该异常,也就是允许它离开这个析构函数。那会造成问题,因为那就是拋出了难以驾致的麻烦。
两个办法可以避免这一问题。DBConn的析构函数可以:
如果close 拋出异常就结束程序。通常通过调用abort 完成:
DBConn: : ~DBConn ( )
{
try { db.close () ; }
catch (...) {
//制作运转记录,记下对close的调用失败;
std::abort( );
}
} 吞下因调用 close 而发生的异常
DBConn: : ~DBConn ( )
{
try { db.close () ; }
catch (...) {
//制作运转记录,记下对close的调用失败;
}
} 另一个较佳策略是重新设计DBConn 接口,使其客户有机会对可能出现的问题作出反应。例如DBconn自己可以提供一个close函数,因而赋予客户一个机会得以处理 “ 因该操作而发生的异常”。DBConn也可以追踪其所管理之DBConnection是否己被关闭,并在答案为否的情记下由其析构函数关闭之。这可防止遗失数据库连接。 然而如果 DBConnection 析构函数调用 close 失败,我们又将退回 “强迫结束程序” 或“吞下异常” 的老路:
class DBConn {
public:
...
void close( ) //供客户使用的新函数
{
db.close ( );
closed = true;
}
~DBConn ()
{
if (!closed) {
try {
db.close ( ); //关闭连接 (如果客户不那么做的话)
}
catch (...) { // 如果关闭动作失败,
//制作运转记水,记下对close的调用失败;// 记录下来并结束程序
... // 或吞下异常。
}
}
}
private:
DBConnection db;
bool closed;
}; 把调用 close 的责任从 DBConn 析构函数手上移到 DBConn 客户手上 (但 DBConn 析构函数仍内含一个“双保险” 调用)可能会给你“肆无忌惮转移负担” 的印象。 你甚至可能认为它违反条款18所提忠告 (让接口容易被正确使用)。实际上这两项 污名都不成立。如果某个操作可能在失败时抛出异常,而又存在某种需要必须处理该异常, 那么这个异常必须来自析构函数以外的某个函数。因为析构函数吐出异常就是危险,总会带来“过早结束程序” 或“发生不明确行为” 的风险。本例要说的是,由客户自己调用close 并不会对他们带来负担,而是给他们 一个处理错误的机会,否则他们没机会响应。如果他们不认为这个机会有用 (或许他们坚信不会有错误发生),可以忽略它,依赖DBConn析构函数去调用close。如果真有错误发生——如果close 的确抛出异常——而且DeConn吞下该异常或结束程序,客户没有立场抱怨,毕竟他们曾有机会第一手处理问题,而他们选择了放奔。
析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能地出异常,析构函数应该捕捉任何异常,然后吞下它们 (不传播)或结束程序。
如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class 应该提供一个普通函数 (而非在析构函数中)执行该操作。
条款 09: 绝不在构造和析构过程中调用virtual 函数
在构造和析构函数中调用虚函数,可能导致不正确的行为,因为此时派生类对象的子对象还未被构造或已被析构。
侦测“构造函数或析构函数运行期间是否调用virtual 函数” 并不总是这般轻松。如果Transaction 有多个构造函数,每个都需执行某些相同工作,那么避免代码重复的一个优秀做法是把共同的初始化代码(其中包括对 logTransaction 的调用)放进一个初始化函数如init 内:
class Transaction {
public:
Transaction ( )
{ init(); } // 调用non-virtual
virtual void logTransaction () const = 0;
...
private:
void init()
{
...
logTransaction (); // 这里调用virtual!
}
}; 其他方案可以解决这个问题。 一种做法是在class Transaction 内将 logTransaction函数改为non-virtual,然后要求derived class构造函数传递必要信息给 Transaction 构造函数,而后那个构造函数便可安全地调用 non-virtual logTransaction。像这样:
class Transaction {
public:
explicit Transaction (const std::string& logInfo);
void logTransaction (const std::string& logInfo) const; //如今是个non-virtual 函数
...
};
Transaction::Transaction (const std::string& logInfo) {
...
logTransaction (logInfo); //如今是个non-virtual调用
}
class BuyTransaction: public Transaction {
public:
BuyTransaction( parameters )
:Transaction (createLogString( parameters )) //将log信息传给base class构造函数
{ ... }
...
private:
static std::string createLogString ( parameters );
}; 换句话说由于你无法使用virtual 函数从base classes向下调用,在构造期间,你可以藉由 “令 derived classes 将必要的构造信息向上传递至 base class 构造函数 ” 替换之而加以弥补。
请注意本例之BuyTransaction 内的private static函数createLogstring的运用。是的,比起在成员初值列(member initialization list )内给予base class 所需数据, 利用辅助函数创建一个值传给base class构造函数往往比较方便(也比较可读)。令此函数为static,也就不可能意外指向 “ 初期未成熟之BuyTransaction 对象内尚末初始化的成员变量” 。这很重要,正是因为“那些成员变量处于未定义状态” ,所以 “ 在 base class 构造和析构期间调用的 virtual 函数不可下降至 derived classes”。
在构造和析构期间不要调用virtual 函数,因为这类调用从不下降至derived class(比起当前执行构造函数和析构函数的那层)
条款 10: 令 operator= 返回一个 reference to *this
将赋值运算符的返回值设为this,可以使连续赋值更加方便和直观。
int x, y, z;
x = y = z = 15; // 賦值连锁形式
x = (y = (z = 15)); // 转化成 为了实现“连锁赋值”,赋值操作符必须返回一个 reference 指向操作符的左侧实参。这是为 classes 实现赋值操作符时应该遵循的协议:
class Widget {
public:
...
Widget& operator=(const Widget& rhs) // 返回类型是个reference,
{ // 指向当前对象。
...
return *this; // 返回左侧对象
}
...
}; 这个协议不仅适用于以上的标准赋值形式,也适用于所有赋值相关运算,例如 :
class Widget {
public:
...
Widget& operator+=(const Widget& rhs) // 这个协议使用于
{ // +=,-=,*=,等等
...
return *this;
}
Widget& operator=(int rhs) // 此函数也适用,即使
{ // 此操作符的参数类型不定
...
return *this;
}
...
}; 这份协议被所有内置类型和标准程序库提供的类型如string, vector, complex, tr1::shared_ptr或即将提供的类型(见条款54)共同遵守
令赋值(assignment)操作符返回一个reference to *this
条款 11: 在 operator= 中处理“自我赋值”
在实现自我赋值时,需要正确处理指针和资源的情况,否则可能导致未定义的行为。
“自我赋值” 发生在对象被赋值给自己时:
如果你 尝试自行管理资源(如果你打算写一个用于资源管理的class就得这样做),可能会掉进 “在停止使用资源之前意外释放了它” 的陷阱。假设你建立一个 class 用来保存一个指针指向一块动态分配的位图 (bitmap ) :
class Bitmap { ... };
class Widget {
...
private:
Bitmap* pb; // 指针,指向一个从heap分配而得的对象
}; 下面是operator=实现代码,表面上看起来合理,但自我斌值出现时并不安全 ( 它也不具备异常安全性,但我们稍后才讨论这个主题)。
Widget&
Widget::operator=(const Widget& rhs) // 一份不安全的 operator= 实现版本.
{
delete pb; // 停止使用当前的bitmap
pb = new Bitmap(*rhs.pb); // 使用rhs‘s bitmap的副本
return *this; // 条款10
} 这里的自我赋值问题是,operator =函数内的 *this (赋值的目的端)和 rhs 有可能是同 一个对象。果真如此delete就不只是销毁当前对象的bitmap,它也销毁 rhs 的bitmap。在函数末尾,Widget它原本不该被自我賦值动作改变的——发现自己持有一个指针指向一个已被删除的对象!
欲阻止这种错误,传统做法是藉由operator=最前面的一个“证同測试(identity test )” 达到 “ 自我赋值” 的检验目的:
Widget& Widget::operator=(const Widget& rhs)
{
if (this == &rhs) return *this; // 证同测试( identity test ):
// 如果是自我赋值,就不做任何事。
delete pb;
pb = new Bitmap (*rhs.pb);
return *this;
} 这个新版本仍然存在异常方面的麻烦。如果"newBitmap" 导致异常(不论是因为分配时内存不足或因为Bitmap的copy构造函数抛出异常),widget 最终会持有一个指针指向一块被刪除的Bitmap。 这样的指针有害。你无法安全地删除它们,甚至无法安全地读取它们。唯一能对它 们做的安全事情是付出许多调试能量找出错误的起源。
让operator=具备“异常安全性”,往往自动获得“自我赋值安全”的回报,例如以下代码,我们只需注意在复制 pb 所指东西 之前别删除pb:
Widget& Widget::operator= (const Widget& rhs)
{
Bitmap* pOrig = pb; // 记住原先的pb
pb = new Bitmap(*rhs.pb); // 令 pb 指向 *pb 的一个复件(副本)
delete pOrig; // 删除原先的pb
return *this;
} 如果你很关心效率,可以把“证同测试” (identity test)再次放回两数起始处。 然而这样做之前先估计“自我赋值”的发生频率有多高?
这项测试也需要成本。它会使代码变大一些(包括原始码和目标码)并导入一个新的控制流(control flow)分支,而两者都会降低执行速度。Prefetching、caching和pipelining 等指令的效率都会因此降低。
在 operator= 两数内手工排列语句(确保代码不但“异常安全”而且“自我赋值安全” )的一个替代方案是,使用所谓的 copy and swap 技术。这个技术和“异常安全性” 密切关系,所以由条款 29 详细说明。然而由于它是一个常见而够好的 operator= 撰写办法,所以值得看看其实现手法像什么样子:
class Widget {
...
void swap(Widget& rhs); //交换 *this 和 rhs 的数据;详见条款29
};
Widget& Widget::operator=(const Widget& rhs) {
Widget temp(rhs); // 为 rhs 数据制作一份复件(副本)
swap(temp); // 将 *this 数据和上述复件的数据交换。
return *this;
} 这 个主题的另一个变奏曲乃利用以下事实:
(1) 某 class 的 copy assignment 操作符可能被声明为“以by value方式接受实参” ;
(2) 以 by valve 方式传递东西会造成一份复件/副本(见条款20)
Widget& Widget::operator=(Widget rhs) // rhs是被传对象的一份复件(副本)
{ // 注意这里是pass by value.
swap(rhs); // 将 *this 的数据和复件/副本的数据互换
return *this;
} 将“copying动作”从函数本体内移至“函数参数构造阶段”却可令编译器有时生成更高效的代码。
确保当对象自我赋值时operator= 有良好行为。其中技术包括比较“来源对象” 和“目标对象” 的地址、精心周到的语句顺序、以及copy-and-swap
条款 12:复制对象时勿忘其每一个成分
确保复制对象时,所有成员变量和基类的成员变量都被复制。
如果你声明自己的copying 函数,意思就是告诉编译器你并不喜欢缺省实现中的某些行为。编译器仿佛被冒犯似的,会以一种奇怪的方式回敬:当你的实现代码几乎必然出错时却不告诉你
考虑一个 class 用来表现顾客,其中手工写出 (而非由编译器创建 )copying 函数,使得外界对它们的调用会被记 (logged)下来:
void logCall (const std::strings funcName);
class Customer {
public:
...
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
...
private:
std::string name;
};
Customer::Customer(const Customer& rhs)
: name(rhs.name) { //复制rhs的数据
logCall ("Customer copy constructor");
}
Customer& Customer::operator=(const Customer& rhs) {
logCall("Customer copy assignment operator");
name = rhs.name; //复制rhs的数据
return *this;
} 这里的每一件事情看起来都很好,而实际上每件事情也的确都好,直到另一个成员变量加入战局
class Date { ... }; //日期
class Customer {
public:
...
private:
std::string name;
Date lastTransaction;
}; 这时候既有的 copying 函数执行的是局部拷贝(partial copy): 它们的确复制了顾客的name,但没有复制新添加的 lastTransaction。 大多数编译器对此不出任何怨言一一即使在最高警告级别中 (见条款 53) 。 这是编译器对 “你自己写出 copying 函数” 的复仇行为:既然你拒绝它们为你写出copying 函数,如果你的代码不安全,它们也不告诉你。
结论很明显:如果你为class添加一个成员变量,你必须同时修改 copying 函数。 (你也需要修改 class 的所有构造函数 (见条款 4 和条款 45)以及任何非标准形式的 operator= (条款 10 有个例子) 。如果你忘记,编译器不太可能提醒你)
一旦发生继承,可能会造成此主题最暗中肆虐的一个潜藏危机。试考虑:
class PrioritCustomer: public Customer { // 一个derived class
public:
...
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
...
private:
int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
return *this;
} PriorityCustomer的copying函数看起来好像复制了PriorityCustomer 内的每一样东西,但是请再看 一眼。是的,它们复制了PriorityCustomer声明的成员变量, 但每个 PriorityCustomer 还内含它所继承的 Customer 成员变量复件 (副本),而那些成员变量却未被复制。PriorityCustomer 的 copy 构造函数并没有指定实参传给其 base class 构造函数 (也就是说它在它的成员初值列表 (member initialization list) 中没有提到Customer) ,因此PriorityCustomer对象的Customer成分会被不带实参的 Customer 构造函数(即default构造函数——必定有一个否则无法通过编译)初始化。default 构造函数将针对 name 和 lastTransaction 执行缺省的初始化动作。
以上事态在 PriorityCustomer 的 copy assignment 操作符身上只有轻微不同。它不曾企图修改其base class 的成员变量,所以那些成员变量保持不变。
任何时候只要你承担起 “为 derived class 撰写copying 函数” 的重大责任,必须很小心地也复制其base class 成分。那些成分往往是private (见条款22),所以你无法直接访问它们,你应该让derived class 的copying函数调用相应的base class函数:
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer (rhs), //调用base class的copy构造函数
priority(rhs.priority)
{
logCall ("PriorityCustomer copy constructor");
}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall ("PriorityCustomer copy assignment operator");
Customer::operator=(rhs); //对base class成分进行赋值动作
priority = rhs.priority; //对dervied class进行赋值
return *this;
} 当你编写一个copying 函数,请确保
(1)复制所有 local 成员变量,
(2)调用所有 base classes 内适当的 copying 函数。
这两个 copying 函数往往有近似相同的实现本体,这可能会诱使你让某个函数调用另一个函数以避免代码重复。这样精益求精的态度值得赞赏,但是令某个copying 函数调用另 一个copying函数却无法让你达到你想要的目标。
令 copy assignment 操作符调用 copy 构造函数是不合理的,因为这就像试图构造一个已经存在的对象。这件事如此荒谬,乃至于根本没有相关语法。是有一些看似如你所愿的语法,但其实不是;也的确有些语法背后真正做了它,但它们在某些情况下会造成你的对象败坏,所以我不打算将那些语法呈现给你看。单纯地接受这个描述吧:你不该令 copy assignment 操作符调用 copy 构造函数。
同样,令 copy 构造函数调用 copy assignment 操作符——同样无意义。构造函数用来初始化新对象,而 assignment 操作符只施行于已初始化对象身上。对一个尚末构造好的对象赋值,就像在一个尚末初始化的对象身上做 “只对己初始化对象才有意义” 的事一样。
如果你发现你的 copy 构造函数和 copy assignment 操作符有相近的代码,消除重复代码的做法是,建立一个新的成员函数给两者调用。这样的函数往往是 private 而且常被命名为init。这个策略可以安全消除 copy 构造函数和 copy assignment 操作符之间的代码重复。
#include <iostream>
#include <cstring>
class MyClass {
private:
char* data;
// init函数用于初始化复制构造函数和复制赋值操作符中的重复代码
void init(const char* source) {
data = new char[std::strlen(source) + 1]; // 为字符数组分配内存
std::strcpy(data, source); // 复制内容
}
public:
// 构造函数
MyClass(const char* str) {
std::cout << "Constructor called" << std::endl;
init(str); // 调用init来初始化
}
// 复制构造函数
MyClass(const MyClass& other) {
std::cout << "Copy constructor called" << std::endl;
init(other.data); // 调用init来初始化
}
// 复制赋值操作符
MyClass& operator=(const MyClass& other) {
std::cout << "Copy assignment operator called" << std::endl;
if (this != &other) { // 防止自我赋值
delete[] data; // 先释放现有内存
init(other.data); // 调用init来初始化
}
return *this;
}
// 析构函数
~MyClass() {
std::cout << "Destructor called" << std::endl;
delete[] data; // 释放内存
}
// 输出内容
void printData() const {
std::cout << "Data: " << data << std::endl;
}
};
int main() {
MyClass obj1("Hello");
MyClass obj2 = obj1; // 调用复制构造函数
obj2.printData();
MyClass obj3("World");
obj3 = obj1; // 调用复制赋值操作符
obj3.printData();
return 0;
} 不要尝试以某个 copying 函数实现另一个 copying 函数。应该将共同机制放进第三个函数中,并由两个 coping 函数共同调用。
资源管理
内存只是你必须管理的众多资源之一,其他常见的资源还包括文件描述器(file descriptors)、互斥锁(mutex locks) 、图形界面中的宇型和笔刷、数据库连接、以及网络sockets。不论哪一种资源,重要的是,当你不再使用它时,必须将它还给系统。
条款 13:以对象管理资源
使用对象管理资源,可以确保在离开作用域时,资源能够被正确释放。
假设,这个程序库系通过一个工厂函数(factory function,见条款7)供应我们某特定的 Investment 对象 :
Investment* createInvestment();
void f()
{
Investment* pInv = createInvestment();
...
delete pInv;
} 为确保createInvestment 返回的资源总是被释放,我们需要将资源放进对象内, 当控制流离开 f,该对象的析构函数会自动释放那些资源。实际上这正是隐身于本条款背后的半边想法:把资源放进对象内,我们便可依赖 C++ 的 “析构函数自动调用机制” 确保资源被释放。
许多资源被动态分配于 heap 内而后被用于单一区块或函数内。它们应该在控制流离开那个区块或函数时被释放。标准程序库提供的 auto_ptr 正是针对这种形势而设计的特制产品。auto_ptr 是个 “类指针 (pointer-like)对象” ,也就是所谓 “智能指针” ,其析构函数自动对其所指对象调用delete。下面示范如何使用 auto_ptr以避免 f 函数潜在的资源泄漏可能性
std::auto_ptr<Investment> pInv(createInvestment()); 这个简单的例子示范“以对象管理资源” 的两个关键想法
- 获得资源后立刻放进管理对象(managing object)内
- 管理对象(managing object)运用析构函数确保资源被释放
由于auto_ptr 被销毁时会自动删除它所指之物,所以一定要注意别让多个 auto_ptr 同时指向同一对象。如果真是那样,对象会被删除一次以上,而那会使你的程序搭上驶向 “未定义行为” 的快速列车上。为了预防这个问题,auto_ptrs 有一个不寻常的性质:若通过copy构造函数或copy assignment 操作符复制它们,它们会变成null,而复制所得的指针将取得资源的唯一拥有权
std::auto_ptr<Investment> pInv1(createInvestment( )) ;
std::auto_ptr<Investment> pInv2(pInv1);
pInv1 = pInv2; “受auto_ptrs管理的资源必须绝对没有一个以上的auto_ptr 同时指向它”,意味着auto_ptrs 并非管理动态分配资源的神兵利器。举个例子,STL容器要求其元素发择“正常的” 复制行为,因此这些容器容不得auto_ptr。
auto_ptr 的替代方案是 “引用计数型智慧指针” ( reference-counting smart pointer; RCSP)。所谓RCSP 也是个智能指针,持续追踪共有多少对象指向某笔资源,并在无人指向它时自动删除该资源。RCSPs 提供的行为类似垃圾回收(garbage collection),不同的是RCSPs 无法打破环状引用 (cycles of references,例如两个其实己经没被使用的对象彼此互指,因而好像还处在“被使用” 状态)。
TR1的tr1::shared_ptr (见条款54)就是个RCSP,所以你可以这么写f
void f()
{
...
std::tr1::shared_ptr<Investment> pInv(createInvestment());
...
} 由于tr1::shared_ptrs的复制行为“一如预期”,它们可被用于STL容器以及其他 “auto_ptr之非正统复制行为并不适用” 的语境上
auto_ptr 和 tr1::shared_ptr 两者都在其析构函数内做 delete 而不是 delete[] 动作 (条款16 对两者的不同有些描述)。那意味在动态分配而得的 array 身上使用 auto_ptr 或 tr1::shared_ptr 是个馊主意。尽管如此,可叹的是,那么做仍能通过编译:
std::auto_ptr<std::string> aps(new std::string[10]); //会用上错误的delete形式
std::tr1::shared_ptr<int> spi(new int[1024]); 你或许会惊讶地发现,并没有特别针对 “C++ 动态分配数组“ 而设计的类似 auto_ptr 或 tr1::shared_ptr 那样的东西,甚至 TR1 中也没有。那是因为 vector 和 string 几乎总是可以取代动态分配而得的数组。如果你还是认为拥有针对数组而设计、类似auto_ptr 和tr1::shared_ptr 那样的classes 较好,看看Boost 吧(见条款55)。在那儿你会很高兴地发现 boost::scoped_array 和 boost::shared_array classes,它们都提供你要的行为。
防止资源泄漏,请使用 RAII 对象,它们在构造函数中获得资源并在析构函数中释放资源。
两个常被使用的RAII classes分别是tr1::shared_ptr和auto_ptr。前者通常是较佳选择,因为其copy行为比较直观。若选择auto_ptr,复制动作会使它(被复制物)指向 null。
条款 14: 在资源管理类中小心copying行为
并非所有资源都是 heap-based,对那种资源而言,像 auto_ptr 和 tr1::shared_ptr 这样的智能指针往往不适合作为资源掌管者 (resouce handlers)。既然如此,有可能偶而你会发现,你需要建立自己的资源管理类。
假设我们使用 C API 函数处理类型为Mutex 的互斥器对象 (mutex objects),共有 lock 和 unlock 两函数可用
void lock(Mutex* pm) ; //锁定pm所指的互斥器
void unlock(Mutex* pm); //将互斥器解除锁定 为确保绝不会忘记将一个被锁住的 Mutex 解锁,你可能会希望建立一个 class 用来管理机锁。这样的class 的基本结构由RAII 守则支配,也就是“资源在构造期间获得,在析构期间释放” :
class Lock {
public:
explicit Lock(Mutex* pm)
: mutexPtr(pm)
{ lock(mutexPtr); }
~Lock() { unlock(mutexPtr); }
private:
Mutex *mutexPtr;
}; Mutex m;
...
{
Lock m1(&m);
...
} 这很好,但如果Lock对象被复制,会发生什么事
Lock ml1(&m);
Lock ml2(ml1); “当一个RAIl 对象被复制,会发生什么事?” 大多数时候你会选择以下两种可能 :
- 禁止复制。许多时候允许 RAII 对象被复制并不合理。对一个像 Lock 这样的 class 这是有可能的,因为很少能够合理拥有 “同步化基础器物” ( synchronization primitives)的复件(副本)。如果复制动作对 RAII class 并不合理,你便应该禁止之。条款6 告诉你怎么做:将copying操作声明为private。对Lock而言看起来是这样:
class Lock: private Uncopyable { //禁止复制。见条款6。 public: ... }; - 对底层资源祭出 “引用计数法” (reference-count)。有时候我们希望保有资源,直到它的最后一个使用者(某对象)被销毁。这种情况下复制RAII对象时,应该將资源的“被引用数”递增。tr1::shared_ptr便是如此。
通常只要内含一个tr1::shared_ptr 成员变量,RAII classes 便可实现出 reference-counting copying行为。如果前述的Lock打算使用reference counting, 它可以改变 mutexPtr 的类型,将它从 Mutex* 改 为 tr1::shared_ptr<Mutex>。然而很不幸 tr1::shared_ptr 的缺省行为是 “当引用次数为0时删除其所指物”,那不是我们所要的行为。当我们用上一个 Mutex,我们想要做的释放动作是解除锁定而非刪除。
幸运的是tr1::shared_ptr 允许指定所谓的“刪除器” (deleter),那是一个函数或函数对象(function object),当引用次数为0时便被调用(此机能并不存在于auto_ptr——它总是将其指针删除)。删除器对 tr1::shared_ptr 构造函数而言是可有可无的第二参数,所以代码看起来像这样:
class Lock {
public:
explicit Lock(Mutex* pm) //以某个Mutex初始化shared_ptr
: mutexPtr(pm, unlock) //并以unlock函数为删除器。
{
lock(mutexPtr.get()); //条款15谈到"get”
}
private:
std::tr1::shared_ptr<Mutex> mutexPtr; //使用shared_ptr
}; 请注意,本例的 Lock class 不再声明析构函数。因为没有必要。条款5 说过 , class 析构函数 (无论是编译器生成的,或用户自定的) 会自动调用其 non-static 成员变量 (本例为 mutexPtr) 的析构函数。而 mutexPtr 的析构函数会在互斥器的引用次数为 0 时自动调用 tr1::shared_ptr 的删除器 (本例为 unlock)。
(当你阅读这个class 的原始码,或许会感谢其中有一条注释指出:你并没有忘记析构,你只是依赖了编译器生成的缺省行为。
- 复制底部资源。有时候,只要你喜欢,可以针对一份资源拥有其任意数量的复件(副本)。而你需要“资源管理类” 的唯一理由是,当你不再需要某个复件时确保它被释放。在此情况下复制资源管理对象,应该同时也复制其所包覆的资源。也就是说,复制资源管理对象时,进行的是 “深度拷贝” 。
某些标准字符串类型是由“指向heap内存” 之指针构成(那内存被用来存放字符串的组成字符)。这种字符串对象内含一个指针指向一块heap 内存。当这样一个字符串对象被复制,不论指针或其所指内存都会被制作出一个复件。这样的字符串展现深度复制 (deep copying) 行为。
- 转移底部资源的拥有权。某些罕见场合下你可能希望确保永远只有一个RAII 对象指向一个未加工资源(raw resource),即使RAII对象被复制依然如此。此时资源的拥有权会从被复制物转移到目标物。一如条款13 所述,这是 auto_ptr 奉行的复制意义
Copying 函数 (包括 copy 构造函数和 copy assignment 操作符)有可能被编译器自动创建出来,因此除非编译器所生版本做了你想要做的事 (条款5 提过其缺省行为),否则你得自己编写它们。某些情况下你或许也想支持这些函数的一般版本, 这样的版本描述于条款45
条款 15:在资源管理类中提供对原始资源的访问
在资源管理类中提供访问原始资源的方法,可以增加代码的灵活性和可维护性。
假设你希望以某个函数处理 Investment 对象,像这样:
std::tr1::shared_ptr<Investment> pInv(createInvestment());
int daysHeld(const Investment* pi); //返回投资天数 你想要这么调用它
int days = daysHeld(pInv); //错误 却通不过编译,因为daysHeld需要的是Investment*指针,你传给它的却是个类型为 tr1::shared_ptr<Investment>的对象。
这时候你需要一个函数可将 RAII class 对象 (本例为 tr1::shared_ptr) 转换为其所内含之原始资源(本例为底部之Investment*)。有两个做法可以达成目标: 显式转换和隐式转换。
tr1::shared_ptr和auto_ptr都提供一个get成员函数,用来执行显式转换, 也就是它会返回智能指针内部的原始指针 (的复件)
int days =daysHeld(pInv.get()); //很好,將pInv内的原始指针传给daysHeld 就像 (几乎) 所有智能指针一样,tr1::shared_ptr 和 auto_ptr 也重载了指针取值 (pointer dereferencing)操作符(operator->和operator*),它们允许隐式转换至底部原始指针
class Investment {
public:
bool isTaxFree() const;
...
};
Investment* createInvestment();
std::tr1::shared_ptr<Investment> pi1(createInvestment());
bool taxable1 = !(pi1->isTaxFree()); //经由operator->访问资源
...
std::auto_ptr<Investment> pi2(createInvestment());
bool taxable2 = !((*pi2).isTaxFree()); //经由operator*访问资源
... 某些RAII class设计者于是联想到“将油脂涂在滑轨上” ,做法是提供一个隐式转换函数。考虑下面这个用于字体的RAIl class (对 C API 而言字体是一种原生数据结构):
FontHandle getFont(); //这是个C API。为求简化暂略参数
void releaseFont(FontHandle fh);
class Font { //RAII class
public:
explicit Font(FontHandle fh)
: (fh) //采用pass-by-value
{}
~Font() { releaseFont(f); }
private:
FontHandle f;
}; 假设有大量与字体相关的C API,它们处理的是FontHandles,那么“将Font对象转换为 FontHandle” 会是一种很频繁的需求。Font class 可为此提供一个显式转换函数,像get 那样:
class Font {
public:
FontHandle get() const { return f; } //显式转换函数
...
}; 不幸的是这使得客户每当想要使用API 时就必须调用get:
void changeFontSize(FontHandle f, int newSize); //C API
Font f(getFont());
int newFontSize;
...
changeFontsize(f.get(), newFontsize); //明确地将Font转换为FontHandle 如此这般地到处要求显式转换,足以使人们倒尽胃口,不再愿意使用这个class,从而增加了泄漏字体的可能性
另一个办法是令Font 提供隐式转换函数,转型为FontHandle:
class Font {
public:
operator FontHandle() const //隐式转换函数
{ return f; }
...
}; Font f(getFont());
int newFontSize;
...
changeFontsize(f, newFontsize); //将Font隐式转换为FontHandle 但是这个隐式转换会增加错误发生机会。例如客户可能会在需要 Font 时意外创建一个FontHandle:
Font f1(getFont());
...
FontHandle f2 = f1; //喔欧!原意是要拷贝一个Font对象,
//却反而將f1隐式转换为其底部的FontHandle
//然后才复制它。 以上程序有个 FontHandle 由 Font 对象 f1 管理,但那个 FontHandle 也可通过直接使用 f2 取得,例 如当 f1 被销毀,字体被释放,而 f2 因此成为“虛吊的” (dangle)
是否该提供一个显式转换函数 (例如get 成员函数)将RAII class转换为其底部资源,或是应该提供隐式转换,答案主要取决于 RAII class 被设计执行的特定工作,以及它被使用的情况
RAII classes 并不是为了封装某物而存在 : 它们的存在是为了确保一个特殊行为——资源释放——会发生。如果一定要,当然也可以在这基本功能之上再加一层资源封裝,但那并非必要。 此外也有某些RAII classes结合十分松散的底层资源封装,藉以获得真正的封装实现。 例如 tr1::shared_ptr 将它的所有引用计数机构封裝了起来,但还是让外界很容易访向其所内含的原始指针。就像多数设计良好的classes 一样,它隐藏了客户不需要看的部分,但备妥客户需要的所有东西。
对原始资源的访问可能经由显式转换或隐式转换。一般而言显式转换比较安全, 但隐式转换对客户比较方便
条款 16:成对使用new和delete时要采取相同形式
std::string* stringArray = new std::string[100];
...
delete stringArray; 每件事看起来都井然有序。使用了new,也搭配了对应的 delete。但还是有某样东西完全错误 : 你的程序行为不明确 (未有定义)。最低限度,string Array 所含的 100 个 string 对象中的 99 个不太可能被适当删除,因为它们的析构函数很可能没被调用。
当你使用 new (也就是通过 new 动态生成一个对象),有两件事发生。第一,内存被分配出来 (通过名为 operator new 的函数,见条款49和条款51)。第二,针对此内存会有一个(或更多) 构造函数被调用。当你使用 delete,也有两件事发生:针对此内存会有一个(或更多)析构函数被调用,然后内存才被释放(通过名为 operator delete 的函数,见条款51)。delete的最大问题在于:即将被删除的内存之内究竟存有多少对象?这个问题的答案决定了有多少个析构函数必须被调用起来。
实际上这个问题可以更简单些:即将被删除的那个指针,所指的是单一对象或对象数组?这是个必不可缺的问题,因为单一对象的内存布局一般而言不同于数组的内存布局。更明确地说,数组所用的内存通常还包括“数组大小” 的记录,以便 delete 知道需要调用多少次析构的数。单一对象的内存则没有这笔记录。
游戏规则很简单:如果你调用new时使用[],你必须在对应调用delete 时也使用 []。如果你调用 new 时没有使用 [],那么也不该在对应调用 delete 时使用 []
typedef std::string AddressLines[4];
std::string* pal = new AddressLines;
delete pal; // 行为未有定义
delete [] pal; // 很好 为避免诸如此类的错误,最好尽量不要对数组形式做typedefs 动作。这很容易达成,因为C++标准程序库(条款54)含有string,vector等templates, 可将数组的需求降至几乎为零。例如你可以将本例的 AddressLines 定义为 “由 strings 组成的一个vector” ,也就是其类型为vector<string>
条款 17:以独立语句将 newed 对象置入智能指针
使用独立的语句将newed对象置入智能指针中,可以避免在异常处理时资源泄漏的问题。
int priority();
void processWidget(std::tr1::shared_ptr<widget> pw, int priority); processWidget 决定对其动态分配得来的 widget 运用智能指针 (这里采用 tr1::shared_ptr)
现在考虑调用 processWidget :
processwidget(new Widget, priority()); 等等,不要考虑这个调用形式。它不能通过编译。tr1::shared_ptr构造函数需要一个原始指针(raw pointer),但该构造函数是个explicit 构造函数,无法进行隐式转换,将得自"new Widget"的原始指针转换为processWidget 所要求的tr1::shared_ptr。如果写成这样就可以通过编译:
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority()); 令人惊讶的是,虽然我们在此使用“对象管理式资源” (object-managing resources ),上述调用却可能泄漏资源
编译器产出一个processWidget 调用码之前,必须首先核算即将被传递的各个实参。上述第二实参只是一个单纯的对 priority 函数的调用,但第一实参std::tr1::shared_ptr<Widget>(new Widget)由两部分组成:
- 执行"new Widget" 表达式
- 调用tr1::shared_ptr构造函数
于是在调用processWidget 之前,编译器必须创建代码,做以下三件事
- 调用priority
- 执行“new Widget”
- 调用tr1::shared_ptr构造函数
C++ 编译器以什么样的次序完成这些事情呢?弹性很大。这和其他语言如Java 和C# 不同,那两种语言总是以特定次序完成函数参数的核算。可以确定的是"new Widget” 一定执行于 tr1::shared_ptr 构造函数被调用之前,因为这个表达式的结果还要被传递作为 tr1::shared_ptr 构造函数的一个实参,但对 priority 的调用则可以排在第一或第二或第三执行。如果编译器选择以第二顺位执行它(说不定可因此生成更高效的代码,谁知道!),最终获得这样的操作序列:
- 执行"new Widget"
- 调用priority
- 调用tr1::shared_ptr构造函数
万一对 priority 的调用导致异常,会发生什么事? 在此情况下 "new Widget” 返回的指针将会遗失,因为它尚未被置入 tr1::shared_ptr 内 ,后者是我们期盼用来防卫资源泄漏的武器。是的,在对processWidget 的调用过程中可能引发资源泄漏,因为在“资源被创建(经由"new Widget")” 和“资源被转换为资源管理对象” 两个时间点之间有可能发生异常干扰。
避免这类问题的办法很简单:使用分离语句,分别写出(1)创建Widget, (2)将它置入一个智能指针内,然后再把那个智能指针传给processWidget:
std::tr1::shared_ptr<Widget> pw(new Widget); //在单独语句内以智能指针存储
//newed所得对象。
processWidget(pw, priority()); //这个调用动作绝不至于造成泄漏 以 上之所以行得通,因为编译器对于“ 跨越语向的各项操作〞没有重新排列的自由(只有在语句内它才拥有那个自由度)。在上述修订后的代码内,"new Widget” 表达式以及“对tr1::shared_ptr构造函数的调用”这两个动作,和“对priority的调用” 是分隔开来的,位于不同语句内,所以编译器不得在它们之间任意选择执行次序。
以独立语句将 newed 对象存储于(置入)智能指针内。如果不这样做,一旦异常被拋出,有可能导致难以察觉的资源泄漏。
设计与声明
“让接口容易被正确使用,不容易被误用” 。这个准则设立了一个舞台,让其他更专精的准则对付一大范围的题目,包括正确性、高效性、封裝性、维护性、延展性,以及协议的一致性
条款 18: 让接口容易被正确使用,不易被误用
首先必须考虑客户可能做出什么样的错误。假设你为一个用来表现日期的class 设计构造函数
class Date {
public:
Date(int month, int day, int year);
...
}; 许多客户端错误可以因为导入新类型而获得预防。真的,在防范 “不值得拥有的代码” 上,类型系统(type system)是你的主要同盟国。既然这样,就让我们导入简单的外覆类型(wrapper types)来区别天数、月份和年份,然后于Date构造函数中使用这些类型
struct Day {
explicit Dav(int d)
: val(d) { }
int val;
};
...
class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); //错误!不正确的类型
Date d(Day(30), Month(3), Year(1995)); //错误!不正确的类型
Date d(Month(3), Day(30), Year(1995)); //OK,类型正确 enums 不具备我们希望拥有的类型安全性,例如 enums 可被拿来当一个 ints 使用 (见条款2)。
比较安全的解法是预先定义所有有效的 Months
class Month {
public:
static Month Jan() { return Month(1); } //函数,返回有效月份
static Month Feb() { return Month(2); } //稍后解释为什么
... // 这些是函数而非对象
static Month Dec() { return Month(12); }
... // 其他成员函数
private:
explicit Month(int m); //阻止生成新的月份
... //这是月份专属数据
};
Date d(Month::Mar(), Day(30), Year(1995)); 如果 “以函数替换对象,表现某个特定月份“ 让你觉得诡异,或许是因为你忘记了non-local static 对象的初始化次序有可能出问题
“让 types 容易被正确使用,不容易被误用” 的表现形式: “除非有好理由,否则应该尽量令你的types 的行为与内置 types一致” 。客户已经知道像 int 这样的type 有些什么行为,所以你应该努力让你的types 在合样合理的前提下也有相同表现。例如,如果a 和b都是int,那么对a*b 赋值并不合法,所以除非你有好的理由与此行为分道扬镇,否则应该让你的types 也有相同的表现。是的,一旦怀疑,就请拿ints做范本。
Investment* createtnvestment (); //来自条款13;为求简化暂路参数。 为避免资源泄漏,createInvestment 返回的指针最终必须被删除,但那至少开启了两个客户错误机会:没有删除指针,或删除同一个指针超过一次
但万一客户忘记使用智能指针怎么办?许多时候,较佳接口的设计原则是先发制人,就令factory 函数返回一个智能指针:
std::tr1::shared_ptr<Investment> createInvestment(); 这便实质上强迫客户将返回值存储于一个tr1::shared_ptr内,几乎消除了忘记删除底部Investment 对象(当它不再被使用时)的可能性
返回 tr1::shared_ptr 让接口设计者得以阻止一大群客户犯下资源泄漏的错误,因为就如条款14所言,tr1::shared_ptr允许当智能指针被建立起来时指定一个资源释放函数(所谓删除器,"deleter”)绑定于智能指针身上
“从creat ernvestment 取得Investment*指针” 的客户将该指针传递给一个名为getRidOfInvestment 的函数,而不是直接在它身上动刀 (使用delete)。这样一个接口又开启通往另一个客户错误的大门,该错误是“企图使用错误的资源析构机制” (也就是拿delete 替换getRidOfInvestment )。
createInvestment 的设计者可以针对此问题先发制人:返回一个“将getRidOfInvestment绑定为删除器(deleter)” 的tr1::shared_ptr
tr1::shared_ptr 提供的某个构造函数接受两个实参:一个是被管理的指针,另一个是引用次数变成0时将被调用的“删除器“ 。这启发我们创建一个null tr1::shared_ptr 并以getRidOfInvestment作为其刪除器,像这样
std::tr1::shared_ptr<Investment> //企图创建一个null shared_ptr
pInv(0, getRidOfInvestment); 0 不是指针,是个int,是的,它可被转换为指针,但在此情况下并不够好, 因为 tr1::shared_ptr 坚 持要一个不折不扣的指针。转型 (cast) 可以解决这个问题
std::tr1::shared_ptr<Investment> //建立一个null shared_ptr并以
pInv(static cast<Investment*> (0), //getRidofInvestment 为删除器;
getRidOfInvestment); //条款27提到static_cast 如果我们要实现 createInvestment 使它返回一个 tr1::shared_ptr 并夾带 getRidOfInvestment 函数作为删除器,代码看起来像这样
std::tr1::shared_ptr<Investment> createInvestment()
{
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment* >(0),
getRidOfInvestment);
retVal = ...; //令retVal指向正确对象
return retVal;
} 当然啦,如果被pInv管理的原始指针(rawpointer)可以在建立pInv之前先确定下来 , 那么 “将原始指针传给 pInv 构造函数” 会比 “先将 pInv 初始化为 null 再对它做一次赋值操作” 为佳
tr1::shared_ptr 有一个特别好的性质是:它会自动使用它的“每个指针专属的删除器” ,因而消除另一个潜在的客户错误:所谓的 "cross-DLL problem"。这个问题发生于 “对象在动态连接程序库 (DLL) 中被 new 创建,却在另一个 DLL 内被 delete 销毁“。在许多平台上,这一类“跨DLL 之new/delete 成对运用” 会导致运行期错误。tr1::shared_ptr没有这个问题,因为它缺省的删除器是来自“tr1::shared_ptr 诞生所在的那个DLL”的delete
本条款并非特别针对tr1::shared_ptr,而是为了“让接口容易被正确使用,不容易被误用“ 而设。但由于tr1::shared_ptr如此容易消除某些客户错误,值得我们核计其使用成本。最常见的tr1::shared_ptr 实现品來自Boost (见条款55)。Boost 的shared_ptr 是原始指针 (raw pointer)的两倍大,以动态分配内存作为簿记用途和 “删除器之专属数据” ,以virtual 形式调用删除器,并在多线程程序修改引用次数时蒙受线程同步化(thread synchronization)的额外开销。(只要定义一个预处理器符号就可以关闭多线程支持)。总之,它比原始指针大且慢,而且使用铺助动态内存。在许多应用程序中这些额外的执行成本并不最著,然布其“降低客户错误” 的成效却是每个人都看得到。
条款19: 设计class 犹如设计type
在设计类时,需要考虑它的行为、不变量和语义,并与标准库中的类型保持一致。
几乎每一个 class 都要求你面对以下提问,而你的回答往往导致你的设计规范
- 新type 的对象应该如何被创建和销毁?这会影响到你的class 的构造函数和析构函数以及内存分配函数和释放函数(operator new,operator new[],operator delete和operator delete[],——见第8章)的设计,当然前提是如果你打算撰写它们
- 对象的初始化和对象的賦值该有什么样的差别?这个答案决定你的构造函数和賦值 (assignment) 操作符的行为,以及其间的差异。很重要的是别混淆了 “初始化” 和 “賦值” ,因为它们对应于不同的函数调用(见条款4)
- 新 type 的对象如果被 passed by value (以值传递),意味着什么?记住,copy 构造函数用来定义一个 type 的pass-by-value该如何实现
- 什么是新type 的 “合法值” ?对class的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class 必须维护的约束条件(invariants),也就决定了你的成员函数 (特别是构造函数、赋值操作符和所谓 "setter" 函数)必须进行的错误检查工作。它也影响函数抛出的异常、以及(极少被使用的)函数异常明细列(exception specifications)
- 你的新type需要配合某个继承图系(inheritance graph)吗?如果你继承自某些既有的 classes,你就受到那些 classes 的设计的束缚,特别是受到 “它们的函数是 virtual 或 non-virtual” 的影响 (见条款34和条款36)。如果你允许其他 classes 继承你的 class,那会影响你所声明的函数——尤其是析构函数——是否为 virtual(见条款7)
- 你的新type 需要什么样的转换?你的type 生存于其他海量types 之间,因而彼此该有转换行为吗?如果你希望允许类型 T1 之物被隐式转换为类型 TM2 之物,就必须在 class T1 内写一个类型转换函数 (operator T2)或在 class T2 内写一个 non-explicit-one-argument (可被单一实参调用)的构造函数。如果你只允许explicit 构造函数存在,就得写出专门负责执行转换的函数,且不得为类型转换操作符( type conversion operators)或non-explicit-one-argument 构造函数。(条款15有隐式和显式转换函数的范例)。
- 什么样的操作符和函数对此新 type 而言是合理的?这个问题的答案决定你將为你的 class 声明哪些函数。其中某些该是 menber 函数,某些则否(见条款23,24,46)。
- 什么样的标准函数应该驳回?那些正是你必须声明为 private 者(见条款6)。
- 谁该取用新type 的成员?这个提问可以帮助你决定哪个成员为public,哪个为 protected,哪个为private。它也帮助你决定哪一个classes和/或functions应该是 friends,以及将它们嵌套于另 一个之内是否合理。
- 什么是新type 的“未声明接口”(undeclared interface)?它对效率 、异常安全性 (见条款29)以及资源运用 (例如多任务锁定和动态内存) 提供何种保证?你在这些方面提供的保证将为你的class 实现代码加上相应的约束条件。
- 你的新type 有多么一般化?或许你其实并非定义一个新type,而是定义一整个types家族。果真如此你就不该定义一个新class,而是应该定义一个新的class template
- 你真的需要一个新type 吗?如果只是定义新的derived class 以便为既有的class 添加机能,那么说不定单纯定义一或多个non-member 函数或templates,更能够达到目标。
条款 20: 宁以pass-by-reference-to-const替换pass-by-value
使用引用传递参数,可以避免不必要的对象拷贝,提高程序性能。
class Person {
public:
Person(); //为求简化,省略参数
virtual ~Person(); //条款7告诉你为什么它是virtual
private:
std::string name;
std::string address;
};
class Student: public Person {
public:
Student();
~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
}; 现在考虑以下代码,其中调用函数validateStudent,后者需要一个student 实参 (by value)并返回它是否有效
bool validateStudent(Student s); //函数以by value方式接受学生
Student plato; //柏拉图,苏格拉底的学生
bool platoIsOK = validateStudent(plato); //调用函数 当上述函数被调用时,发生什么事?
无疑地 student 的 copy 构造函数会被调用,以 plato 为蓝本将 s 初始化。同样明显地,当validatestudent 返回s 会被销毁。因此,对此函数而言,参数的传递成本是“一次student copy构造函数调用,加上一次student析构函数调用”
最终结果是,以 by valve 方式传递一个 student 对象会导致调用一次 student copy 构造函数、一次 Person copy 构造函数、四次 string copy 构造函数。当函数内的那个 student 复件被销毀,每一个构造函数调用动作都需要一个对应的析构函数调用动作。 因此,以by valve方式传递一个student 对象,总体成本是 “六次构造函数和六次析构函数” !
如果有什么方法可以回避所有那些构造和析构动作就太好了。有的,就是pass by reference-to-const :
bool validatestudent (const Student&s); 这种传递方式的效率高得多:没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。修订后参数声明中的 const 是重要的。原先的 validateStudent 以by value 方式接受一个Student 参数,因此调用者知道他们受到保护,函数内绝不会对传入的Student 作任何改变;validateStudent 只能够对其复件(副本)做修改。现在Student 以by reference方式传递,將它声明为const 是必要 的,因为不这样做的话调用者会忧患 validateStudent 会不会改变他们传入的那个 Student
以by reference方式传递参数也可以避免slicing(对象切割)问题。当一个derived class对象以by valve方式传递并被视为一个base class 对象,base class的copy构造函数会被调用,而“造成此对象的行为像个derived class对象” 的那些特化性质全被切割掉了,仅仅留下一个base class对象。这实在不怎么让人惊讶,因为正是base class构造函数建立了它。但这几乎绝不会是你想要的。假设你在一组classes 上工作,用来实现 一个图形窗口系统
class Window {
public:
...
std::string name() const; //返回窗口名称
virtual void display() const; //显示窗口和其内容
};
class WindowWithScrollBars: public Window {
public:
...
virtual void display() const;
}; 所有Window对象都带有一个名称,你可以道过name函数取得它。所有窗口都可显示,你可以通过display函数完成它。display 是个virtual函数,这意味简易朴素的 base class Window 对象的显示方式和华丽高贵的 WindowWithScrollBars 对象的显示方式不同 (见条款34和条款36)。
现在假设你希望写个函数打印窗口名称,然后显示该窗口。下面是错误示范
void printNameAndDisplay(Window w) //不正确!参数可能被切割
{
std::cout << w.name();
w.display();
} 当你调用上述函数并交给它一个 WindowWithScrollBars 对象,会发生什么事呢
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb); 参数w会被构造成为一个Window对象;它是passed by value,还记得吗?而造成wwsb “之所以是个WindowWithScrollBars对象”的所有特化信息都会被切除。 在printNameAndDisplay函数内不论传递过来的对象原本是什么类型,参数w就像一个Window对象(因为其类型是Window)。因此在printNameAndDisplay内调用display 调用的总是Window::display,绝不会是WindowWithScrollBars::display。
解决切割 (slicing)问题的办法,就是以by reference-to-const 的方式传递w
void printNameAndDisplay(const Window& w) //很好,参数不会被切割
{
std::cout << w.name();
w.display();
} 如果窥视C++编译器的底层,你会发现,references 往往以指针实现出来,因此 pass by reference 通常意味真正传递的是指针。因此如果你有个对象属于内置类型 (例如int),pass by value 往往比pass by reference 的效率高些。对内置类型而言,当你有机会选择采用pass-by-value 或pass-by-reference-to-const 时,选择pass-by-value 并非没有道理。这个忠告也适用于 STL 的迭代器和函数对象,因为习惯上它们都被设计为 passed by value。迭代器和函数对象的实战者有责任看看它们是否高效且不受切割问题 (slicing problem) 的影响。这是 “规则之改变取决于你使用哪一部分 C++ (见条款1)“ 的一个例子。
内置类型都相当小,因此有人认为,所有小型types 都是pass-by-value的合格候选人,甚至它们是用户自定义的class 亦然。这是个不可靠的推论。对象小并不就意味其copy 构造函数不昂贵。许多对象——包括大多数STL 容器——内含的东西只比一个指针多一些,但复制这种对象却需承担“复制那些指针所指的每一样东西”。那将非常昂贵。
即使小型对象拥有并不昂费的copy 构造函数,还是可能有效率上的争议。某些编译器对待“内置类型” 和“用户自定义类型” 的态度截然不同,纵使两者拥有相同的底层表述(underlying representation)。举个例子,某些编译器拒绝把只由一个double 组成的对象放进缓存器内,却很乐意在一个正规基础上对光秃秃的doubles 那么做。 当这种事发生,你更应该以by reference 方式传递此等对象,因为编译器当然会将指针 (references的实现体) 放进缓存器内,绝无问题。
“小型的用户自定义类型不必然成为pass-by-value优良候选人”的另一个理由是,作为一个用户自定义类型,其大小容易有所变化。一个type 目前虽然小,将来也许会变大,因为其内部实现可能改变。甚至当你改用另一个C++ 编译器都有可能改变type 的大小。举个例子,在我下笔此刻,某些标准程序库实现版本中的 string 类型比其他版本大七倍。
一般而言,你可以合理假设“pass-by-value并不昂贵”的唯一对象就是内置类型和 STL 的选代器和函数对象。至于其他任何东西都请遵守本条款的忠告,尽量以 pass-by-reference-to-const 替换pass-by-value。
尽量以pass-by-reference-to-const 替换pass-by-value。前者通常比较高效,并可避免切割问题 (slicing problem)。以上规则并不适用于内置类型,以及STL 的迭代器和函数对象。对它们而言,pass-by-value 往往比较适当。
条款 21: 必须返回对象时,别妄想返回其reference
不要返回指向局部变量或临时变量的引用,否则可能导致未定义行为。
考虑一个用以表现有理数 (rational numbers)的class,内含一个函数用来计算两个有理数的乘积
class Rational {
public:
Rational (int numerator = 0, //条款24说明为什么这个构造函数
int denominator = 1); //不声明为explicit
...
private:
int n, d; //分子(numerator)和分母(denominator)
friend
const Rational //条款3说明为什么返回类型是const
operator* (const Rational& lhs,
const Rational& rhs);
}; 这个版本的 operator* 系以 by value 方式返回其计算结果 (一个对象),若非必要, 没有人会想要为这样的对象付出太多代价,问题是需要付出任何代价吗?
如果可以改而传递reference,就不需付出代价。但是记住,所谓reference 只是个名称,代表某个既有对象。任何时候看到一个reference 声明式,你都应该立刻问自己,它的另一个名称是什么?因为它一定是某物的另一个名称。以上述operator*为例,如果它返回一个 reference,后者一定指向某个既有的 Rational 对象,内含两个 Rational 对象的乘积。
我 们当然不可能期望这样一个 (内含乘积的) Rational 对象在调用 operator* 之前就存在。也就是说,如果你有
Rational a(1, 2); //a=1/2
Rational b(3, 5); //b=3/5
Rational c = a * b; //c应该是3/10 期望“原本就存在一个其值为3/10的Rational对象”并不合理。如果operator* 要返回一个reference指向如此数值,它必须自己创建那个Rational对象
函数创建新对象的途径有二:在stack 空间或在heap 空间创建之。如果定义一个 local 变量,就是在 stack 空间创建对象。根据这个策略试写 operator* 如下
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d); //警告!糟糕的代码!
return result;
} 你可以拒绝这种做法,因为你的目标是要避免调用构造函数,而result 却必须像任何对象一样由构造函数构造起来。更严重的是:这个函数返回一个reference指向 result,但result 是个local对象,而local对象在函数退出前被销毁了。因此,这个版本的operator* 并末返回reference 指向某个Rational,它返回的reference 指向一个“从前的”Rational:一个旧时的Rational; 一个曾经被当做Rational但如今已经成空、发臭、败坏的残骸,因为它己经被销毁了。任何调用者甚至只是对此函数的返回值做任何一点点运用,都将立刻墜入“无定义行为” 的恶地。事情的真相是,任何函数如果返回一个reference 指向某个local 对象,都将一败涂地。(如果函数返回指针指向一个local 对象,也是一样。)
于是,让我们考虑在heap 内构造一个对象,并返回reference指向它。Heap-based 对象由new创建,所以你得写一个heap-based operator*如下:
const Rational& operator*(const Rational& lhs,
const Rational& rhs)
{ // 警告!更糟的写法
Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
} 你还是必须付出一个“构造函数调用“代价,因为分配所得的内存将以一个适当的构造函数完成初始化动作。但此外你现在又有了另一个问题:谁该对着被你new 出来的对象实施 delete?
即使调用者诚实谨慎,并且出于良好意识,他们还是不太能够在这样合情合理的用法下阻止内存泄漏:
Rational w, x, y, z;
w = x * y * z; //与operator*(operator*(x, y), z)相同 这里,同一个语句内调用了两次operator*,因而两次使用new,也就需要两次 delete。但却没有合理的办法让operator* 使用者进行那些delete 调用,因为没有合理的办法让他们取得operator* 返回的references 背后隐藏的那个指针。这绝对导致资源泄漏。
但或许你注意到了,上述不论 on-the-stack 或 on-the-heap 做法,都因为对 operator* 返回的结果调用构造函数而受惩罚。也许你还记得我们的最初目标是要避免如此的构造函数调用动作。或许你认为你知道有一种办法可以避免任何构造函数被调用。或许你心里出现下面这样的实现代码,此法奠基于 “让 operator* 返回的 reference 指向一个被定义于函数内部的 static Rational 对象”
const Rational&operator*(const Rational& lhs, const Rational& rhs)
{
static Rational result; //static对象,此函数將返回其reference
result = ...; //将lhs乘以rhs,并将结果置于result内
return result;
} 就像所有用上static对象的设计一样,这一个也立刻造成我们对多线程安全性的疑感。不过那还只是它显而易见的弱点。如果想看看更深层的瑕疵,考虑以下面这些完全合理的客户代码
bool operator==(const Rational& lhs, //一个针对Rationals
const Rational& rhs); //而写的operator==
Rational a, b, c, d;
...
if ((a * b) == (c * d)) {
当乘积相等时,做适当的相应动作;
} else {
当乘积不等时,做适当的相应动作;
} 猜想怎么着?表达式((a * b) == (c * d))总是被核算为true,不论a,b,c和d的值是什么!
一旦將代码重新写为等价的函数形式,很容易就可以了解出了什么意外
if (operator==(operator*(a, b), operator*(c, d))) 注意,在operator== 被调用前,已有两个operator调用式起作用,每一个都返回 reference 指向 operator* 内部定义的 static Rational 对象。因此 operator== 被要求将“operator内的static Rational 对象值”拿来和“operator内的static Rational 对象值” 比较,如果比较结果不相等,那才奇怪呢 (泽注: 这里我补充说明:两次operator 调用的确各自改变了static Rational 对象值,但由于它们返回的 都是reference,因此调用端看到的永远是static Rational 对象的“现值” 。)
这应该足够说服你,欲令诸如operator* 这样的函数返回reference,只是浪费时间而己,但现在或许又有些人这样想:“唔,如果一个static 不够,或许一个static array 可以得分... ...”
首先你必须选择array 大小n。如果n太小,你可能会耗尽“用以存储函数返回值” 的空间,那么情况就回到了我们刚才讨论过的单— static 设计。但如果又太大,会因此降低程序效率,因为array 内的每一个对象都会在函数第一次被调用时构造完成。那么将消耗n个构造函数和n个析构函数——即使我们所讨论的函数只被调用一次。如果所谓“最优化” 是改善软件效率的过程,我们现在所谈的这些应该称为“恶劣化” 。最后,想一想如何将你需要的值放进array 内,而那么做的成本又是多少。在对象之间搬移数值的最直接办法是通过赋值(assignment) 操作,但赋值的成本几何?对许多types 而言它相当于调用一个析构函数(用以销毁旧值)加上一个构造函数 (用以复制新值)。但你的目标是避免构造和析构成本耶!面对现实吧,这个做法不会成功的。就算以vector 替换array 也不会让情况更好些。
一个“必须返回新对象” 的函数的正确写法是:就让那个函数返回一个新对象呗。对 Rational 的 operator* 而言意味以下写法 (或其他本质上等价的代码):
inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
} 当然,你需得承受operator* 返回值的构造成本和析构成本,然而长远来看那只是为了获得正确行为而付出的一个小小代价。但万一账单很恐怖,你承受不起,别忘了C++ 和所有编程语言一样,允许编译器实现者施行最优化,用以改善产出码的效率却不改变其可观察的行为。因此某些情况下operator* 返回值的构造和析构可被安全地消除。如果编译器运用这一事实(它们也往往如此),你的程序将继续保持它们该有的行为,而执行起来又比预期的更快。
我把以上的讨论浓缩总结为:当你必须在“返回一个reference和返回一个object” 之间抉择时,你的工作就是挑出行为正确的那个
绝不要返回pointer 或reference 指向一个local stack 对象,或返回reference 指向一个heap-allocated 对象,或返回pointer 或reference 指向一个local static 对象而有可能同时需要多个这样的对象。条款4 己经为“在单线程环境中合理返回reference 指向一个local static 对象” 提供了一份设计实例
条款 22: 将成员变量声明为private
将类的成员变量声明为private,可以减少类被错误使用的机会。
让我们从语法一致性开始 (同时请见条款18)。如果成员变量不是 public, 客户唯一能够访问对象的办法就是通过成员函数。如果public 接口内的每样东西都是函数,客户就不需要在打算访问class 成员时迷惑地试着记住是否该使用小括号(圆括号)。 他们只要做就是了,因为每样东西都是函数。
或许你不认为一致性的理由足以令人信服,那么这个事实如何:使用函数可以让你对成员变量的处理有更精确的控制。如果你令成员变量为public,每个人都可以读写它,但如果你以函数取得或设定其值,你就可以实现出“不准访问” 、“只读访问” 以及“读写访问” 。见鬼了,你去至可以实现“惟写访问” ,如果你想要的话:
class Accesslevels {
public:
...
int getReadOnly() const { return readOnly; }
void setReadwrite(int value) { readWrite = value; }
int getReadWrite() const { return readwrite; }
void setWriteOnly(int value) { writeOnly = value; }
private:
int noAccess; //对此int无任何访问动作
int readOnly; //对此int做只读访问(read-only access)
int readWrite; //对此int做读写访问(read-write access)
int writeOnly; //对此int做惟写访问(write-only access)
}; 如此细微地划分访问控制颇有必要,因为许多成员变量应该被隐藏起来。每个成员变量都需要一个getter 函数和setter 函数毕竟罕见。
还是不够说服你?是端出大口径武器的时候了:封装啦。如果你通过函数访问成员变量,日后可改以某个计算替换这个成员变量,而class 客户一点也不会知道class 的内部实现己经起了变化。
假设你正在写一个自动测速程序,当汽车通过,其速度便被计算并填入一个速度收集器内
class SpeedDataCollection {
...
public:
void addValue(int speed); //添加一笔新数据
double averageSoFar() const; //返回平均速度
...
}; 现在让我们考虑成员函数averageSoFar。做法之一是在class 内设计一个成员变量,记录至今以来所有速度的平均值。当averageSoFar被调用,只需返回那个成员变量就好。另一个做法是令averageSoFar 每次被调用时重新计算平均值,此函数有权力调取收集器内的每一笔速度值。
上述第一种做法 (随时保持平均值) 会使每一个 SpeedDataCollection 对象变大,因为你必须为用来存放目前平均值、累积总量、数据点数的每一个成员变量分配空间。 然而averageSoFar 却可因此而十分高效;它可以只是一个返回目前平均值的inline 函数 (见条款30)。相反地, “被询问才计算平均值“ 会使得 averageSoFar 执行较慢,但每一个SpeedDatacollection对象比较小。
谁说得出哪一个比较好?在一部内存吃紧的机器上(例如一台嵌入式路边侦测装置),或是在一个并不常常需要平均值的应用程序中, “每次需要时才计算” 或许是比较好的解法。但在一个频繁需要平均值的应用程序中,如果反应速度非常重要,内存不是重点,这时候“随时维持一个当下平均值” 往往更好一些。重点是,由于通过成员函数来访问平均值 (也就是封装了它),你得以替换不同的实现方式 (以及其他你可能想到的东西) ,客户最多只需重新编译。(如果遵循条款31所描述的技术,你甚至可以消除重新编译的不便性。)
将成员变量隐藏在函数接口的背后,可以为“所有可能的实现” 提供弹性。例如这可使得成员变量被读或被写时轻松通知其他对象、可以验证class 的约束条件以及函数的前提和事后状态、可以在多线程环境中执行同步控制......等等。来自Delphi 和C# 阵营的 C++ 程序员应该知道,这般能力等价于其他语言中的 "properties",尽管额外需要一组小括号。
封装的重要性比你最初见到它时还重要。如果你对客户隐藏成员变量 (也就是封装它们),你可以确保class 的约束条件总是会获得维护,因为只有成员函数可以影响它们。犹有进者,你保留了日后变更实现的权利。如果你不隐藏它们,你很快会发现,即使拥有class 原始码,改变任何public 事物的能力还是极端受到束缚,因为那会破坏太多客户码。public 意味不封装,几乎可以说,不封装意味不可改变,特别是对被广泛使用的 classes 而言。被广泛使用的 classes 是最需要封装的一个族群,因为它们最能够从“改采用一个较佳实现版本” 中获益。
protected 成员变量的论点十分类似。实际上它和 public 成员变量的论点相同,虽然或许最初看起来不是一回事。 “语法一致性” 和 “细微划分之访问控制” 等理由显然也适用于protected 数据,就像对public 一样适用。但封装呢?protected 成员变量的封装性是不是高过public 成员变量?答案令人惊讶:并非如此。
条款23 会告诉你,某些东西的封装性与 “当其内容改变时可能造成的代码破坏量” 成反比。因此,成员变量的封装性与“成员变量的内容改变时所破坏的代码数量” 成反比。所谓改变,也许是从 class 中移除它 (或许这有利于计算,就像上述的 averageSoFar) 。
假设我们有一个public 成员变量,而我们最终取消了它。多少代码可能会被破坏呢?唔,所有使用它的客户码都会被破坏,而那是一个不可知的大量。因此public 成员变量完全没有封装性。假设我们有一个protected 成员变量,而我们最终取消了它, 有多少代码被破坏?唔,所有使用它的derived classes 都会被破坏,那往往也是个不可知的大量。因此,protected 成员变量就像public 成员变量一样缺乏封装性,因为在这两种情况下,如果成员变量被改变,都会有不可预知的大量代码受到破坏。虽然这个结论有点违反直观,但经验丰富的程序库作者会告诉你,它是真的。 一旦你将一个成员变量声明为public 或protected 而客户开始使用它,就很难改变那个成员变量所涉及的一切。太多代码需要重写、重新测试、重新编写文档、重新编译。从封装的角度观之,其实只有两种访问权限:private(提供封装)和其他(不提供封装)
切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class 作者以充分的实现弹性。protected 并不比public 更具封装性。
条款 23: 宁以non-member、non-friend替换member函数
将非成员函数放在类外,可以降低耦合性和提高可重用性。
想象有个class 用来表示网页浏览器。这样的class 可能提供的众多函数中,有一些用来清除下载元素高速缓存区(cache of downloaded elements)、清除访问过的URLS 的历史记录(history of visited URLs) 、以及移除系统中的所有cookies
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies ();
...
}; 许多用户会想一整个执行所有这些动作,因此webBrowser 也提供这样一个函数
class WebBrowser {
public:
void clearEverything( ); //调用clearCache,clearHistory,
//和removeCookies
...
}; 当然, 这一机能也可由一个non-member 函数调用适当的member 函数而提供出来
void clearBrowser(WebBrowser& W)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
} 哪一个比较好呢? 是 member 函数 clearEverything 还是 non-member 函数 clearBrowser?
面向对象守则要求,数据以及操作数据的那些函数应该被捆绑在一块,这意味它建议member 函数是较好的选择。不幸的是这个建议不正确。这是基于对面向对象真实意义的一个误解。面向对象守则要求数据应该尽可能被封装,然而与直观相反地,member函数clearEverything带来的封装性比non-member函数clearBrowser低。此外,提供non-member 函数可允许对webBrowser 相关机能有较大的包裹弹性 (packaging flexibility),而那最终导致较低的编译相依度,增加webBrowser 的可延伸性。因此在许多方面non-member做法比member做法好。重要的是,我们必须了解其原因。
让我们从封裝开始讨论。如果某些东西被封装,它就不再可见。愈多东西被封裝, 愈少人可以看到它。而愈少人看到它,我们就有愈大的弹性去变化它,因为我们的改变仅仅直接影响看到改变的那些人事物。因此,愈多东西被封装,我们改变那些东西的能力也就愈大。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户。
现在考虑对象内的数据。愈少代码可以看到数据(也就是访问它),愈多的数据可被封装,而我们也就愈能自由地改变对象数据,例如改变成员变量的数量、类型等等。如何量测“有多少代码可以看到某 一块数据”呢?我们计算能够访问该数据的函数数量,作为一种粗糙的量测。愈多函数可访问它,数据的封装性就愈低。
条款22 曾说过,成员变量应该是private,因为如果它们不是,就有无限量的函数可以访问它们,它们也就毫无封装性。能够访问private 成员变量的函数只有class 的 member函数加上friend函数而己。如果要你在一个member函数(它不只可以访问class 内的 private 数据,也可以取用 private 函数、enums、typedefs 等等)和一个 non-member, non-friend 函数 (它无法访问上述任何东西)之间做抉择,而且两者提供相同机能,那么,导致较大封装性的是 non-member,non-friend 函数,因为它并不增加 “能够访问class 内之private 成分” 的函数数量。这就解释了为什么clearBrowser(一个non-member,non-friend 函数)比clearEverything (一个member函数)更受欢迎的原因:它导致 WebBrowser class 有较大的封装性。
在这一点上有两件事情值得注意。第一,这个论述只适用于non-member,non-friend 函数。friends 函数对class private 成员的访问权力和member 函数相同,因此两者对封装的冲击力道也相同。从封装的角度看,这里的选择关键并不在member 和non-member 函数之间,而是在member和non-member,non-friend函数之间。(当然,封装并非唯一考虑。条款24 解释当我们考虑隐式类型转换,应该在member 和non-member 函数之间抉择。)
第二件值得注意的事情是,只因在意封装性而让函数“成为class 的non-member” 并不意味它“不可以是另一个class 的menber”。这对那些习惯于“所有函数都必须定义于class 内” 的语言(如Eiffcl,Java,C#)的程序员而言,可能是个温暖的慰藉。例如我们可以令clearBrowser成为某工具类(utility class)的一个static member函数。只要它不是WebBrowser 的一部分(或成为其friend),就不会影响WebBrowser的private 成员封装性。
在C++,比较自然的做法是让clearBrowser 成为一个non-member 函数并且位于 WebBrowser所在的同一个namespace (命名空间)内
namespace WebBrowserStuff {
class WebBrowser {...};
void clearBrowser(WebBrowser& wb);
...
} 然而这不只是为了看起来自然而已。要知道,namespace 和classes 不同,前者可跨越多个源码文件而后者不能。这很重要,因为像clearBrowser 这样的函数是个 “提供便利的函数” ,如果它既不是members 也不是friends,就没有对WebBrowser 的特殊访问权力,也就不能提供“WebBrowser 客户无法以其他方式取得” 的机能。举个例子,如果clearBrowser 不存在,客户端就只好自行调用clearCache, clearHistory 和 removeCookies。
一个像WebBrowser 这样的class可能拥有大量便利函数,某些与书签(bookmarks)有关,某些与打印有关,还有一些与cookie 的管理有关... …通常大多数客户只对其中某些感兴趣。没道理一个只对书签相关便利函数感兴趣的客户却与.….. 呃......例如一个cookie 相关便利函数发生编译相依关系。分离它们的最直接做法就是将书签相关便利函数声明于一个头文件,将cookie 相关便利函数声明于另一个头文件,再将打印相关便利函数声明于第三个头文件,依此类推
// 头文件 "webbrowser.h"一 这个头文件针对classWebBrowser自身
// 及WebBrowser核心机能。
namespace WebBrowserStuff {
class WebBrowser { ... };
... // 核心机能,例如几乎所有客户都需要的
// non-member函数。
}
// 头文件 "webbrowserbookmarks.h"
namespace WebBrowserStuff {
... // 与书签相关的便利函数
}
// 头文件 "webbrowsercookies.h"
namespace WebBrowserStuff {
... // 与cookie相关的便利函数
}
... 注意,这正是C++ 标准程序库的组织方式。标准程序库并不是拥有单一、整体、 庞大的<C++StandardLibrary>头文件并在其中内含std命名空间内的每一样东西,而是有数十个头文件(<vector>,<algorithm>,<memory>等等),每个头文件声明std 的某些机能。如果客户只想使用vector 相关机能,他不需要#include <memory>;如果客户不想使用 list,也不需要 #include <list>。这允许客户只对他们所用的那一小部分系统形成编译相依 (见条款31,其中讨论降低编译依存性的其他做法)。以此种方式切割机能并不适用于class 成员函数,因为一个class 必须整体定义,不能被分割为片片段段。
将所有便利函数放在多个头文件内但隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-member,non-friend函数到此命名空间内。举个例子,如果某个WebBrowser 客户决定写些与影像下载相关的便利函数, 他只需要在WebBrowserStuff 命名空间内建立一个头文件,内含那些函数的声明即可。新函数就像其他旧有的便利函数那样可用且整合为一体。这是class 无法提供的另一个性质,因为class 定义式对客户而言是不能扩展的。当然啦,客户可以派生出新的 classes,但derived classes无法访问base class 中被封装的(即private)成员,于是如此的"扩展机能” 拥有的只是次级身份。此外一如条款7所说,并非所有classes都被设计用来作为base classes
宁可拿non-member,non-friend 函数替换member 函数。这样做可以增加封装性、包裹弹性(packaging flexibility)和机能扩充性。
条款 24:若所有参数皆需类型转换,请为此采用non-member函数
将类型转换函数定义为非成员函数,可以避免它们被错误调用或带来意外的副作用。
令classes 支持隐式类型转换通常是个糟糕的主意。当然这条规则有其例外,最常见的例外是在建立数值类型时。假设你设计一个class 用来表现有理数,允许整数“隐式转换” 为有理数似乎颇为合理。的确,它并不比C++ 内置从int 至double 的转换来得不合理,而还比C++ 内置从double 至int 的转换来得合理些。假设你这样开始你的Rational class:
class Rational {
public:
Rational(int numerator = 0, // 构造函数刻意不为explicit;
int denominator = 1); // 允许int-to-Rational隐式转换。
int numerator() const; // 分子(numerator)和分母(denominator)
int denominator() const; // 的访问函数(accessors)一见条款22。
private:
...
}; 你想支持算术运算诸如加法、乘法等等,但你不确定是否该由member 函数、 non-member 函数,或可能的话由 non-member friend 函数来实现它们。你的直觉告诉你,当你犹豫就该保持面向对象精神。你知道有理数相乘和 Rational class 有关,因此很自然地似乎该在Rational class 内为有理数实现operator*。条款23曾经反直觉地主张,将函数放进相关class 内有时会与面向对象守则发生矛盾,但让我们先把那放在一旁,先研究一下将 operator* 写成 Rational 成员函数的写法:
class Rational {
public:
...
const Rational operator*(const Rational& rhs) const;
}; (如果你不确定为什么这个函数被声明为此种形式,也就是为什么它返回一个 const by-value 结果但接受一个reference-to-const 实参,请参考条款3,20和21。)
这个设计使你能够将两个有理数以最轻松自在的方式相乘
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth; //很好
result = result * oneEighth; //很好 但你还不满足。你希望支持混合式运算,也就是拿Rationals 和...嗯... 例如 int 相乘。毕竟很少有什么东西会比两个数值相乘更自然的了——即使是两个不同类型的数值。
然而当你尝试混合式算术,你发现只有一半行得通
result = oneHalf * 2; //很好
result = 2 * oneHalf; //错误 当你以对应的函数形式重写上述两个式子,问题所在便一目了然了
result = oneHalf.operator*(2); //很好
result = 2.operator*(oneHalf); //错误! 是的,oneHalf是一个内含operator函数的class的对象,所以编译器调用该函数。然而整数2 并没有相应的class,也就没有operator成员函数。编译器也会尝试寻找可被以下这般调用的non-member operator*(也就是在命名空间内或在global 作用域内):
result = operator*(2, oneHalf); //错误! 但本例并不存在这样一个接受int 和Rational 作为参数的non-member operator*,因此查找失败
再次看看先前成功的那个调用。注意其第二参数是整数2,但 Rational::operator*需要的实参却是个Rational 对象。这里发生了什么事?为什么2在这里可被接受,在另一个调用中却不被接受?
因为这里发生了所谓隐式类型转换 (implicit type conversion)。编译器知道你正在传递一个int,而两数需要的是Rational; 但它也知道只要调用Rational构造函数并赋予你所提供的 int,就可以变出一个适当的 Rational 来。于是它就那样做了。换句话说此一调用动作在编译器眼中有点像这样
const Rational temp(2); //根据2建立一个暂时性的Rational对象。
result = oneHalf * temp; //等同于oneHalf.operator*(temp); 当然,只因为涉及non-explicit 构造函数,编译器才会这样做。如果Rational 构造函数是explicit,以下语句没有一个可通过编译
result = oneHalf * 2; //错误!(在explicit构造函数的情况下)
//无法将2转换为一个Rational
result = 2 * oneHalf; //一样的错误,一样的问题 这就很难让Rational class支持混合式算术运算了,不过至少上述两个句子的行为从此一致®。
然而你的目标不仅在一致性,也要支持混合式算术运算,也就是希望有个设计能让以上语句通过编译。这把我们带回到上述两个语句,为什么即使Rational 构造函数不是 explicit,仍然只有一个可通过编译,另一个不可以
result = oneHalf * 2; //没问题(在non-explicit构造函数的情况下)
result = 2 * oneHalf; //错误!(甚至在non-explicit构造函数的情况下) 结论是,只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。地位相当于“被调用之成员函数所隶属的那个对象”——即this对象——的那个隐喻参数,绝不是隐式转换的合格参与者。这就是为什么上述第一次调用可通过编译,第二次调用则否,因为第一次调用伴随一个放在参数列内的参数,第二次调用则否。
然而你一定也会想要支持混合式算术运算。可行之道终于拨云见日:让operator* 成为一个non-member 函数,俾允许编译器在每一个实参身上执行隐式类型转换
class Rational {
... //不包括operator*
};
const Rational operator* (const Rational& lhs, //现在成了一个
const Rational& rhs) //non-member函数
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; //没问题
result = 2 * oneFourth; //万岁,通过编译了! 这当然是个快乐的结局,不过还有一点必须操心:operator* 是否应该成为 Rational class 的一个friend 函数呢?
就本例而言答案是否定的,因为operator* 可以完全藉由Rational的public接口完成任务,上面代码己表明此种做法。这导出一个重要的观察:member函数的反面是non-member函数,不是friend函数。太多C++程序员假设,如果一个“与某class 相关” 的函数不该成为一个member (也许由于其所有实参都需要类型转换,例如先前的Rational 的operator* 函数),就该是个friend。本例表明这样的理由过于牵强。 无论何时如果你可以避免friend 函数就该避免,因为就像真实世界一样,朋友带来的麻烦往往多过其价值。当然有时候 friend 有其正当性,但这个事实依然存在:不能够只因函数不该成为member,就自动让它成为friend。
本条款内含真理,但却不是全部的真理。当你从Object-Oriented C++跨进 Template C++(见条款1)并让Rational成为一个class template而非class,又有一些需要考虑的新争议、新解法、以及一些令人惊讶的设计牵连。这些争议、解法和设计牵连形成了条款46。
如果你需要为某个函数的所有参数 (包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member
条 款 25: 考虑写出一个不抛异常的 swap 函数
为类实现一个不抛异常的swap函数,可以提高程序的健壮性和性能。
swap 是个有趣的函数。原本它只是STL 的一部分,而后成为异常安全性编程 (exception-safe programming,见条款29)的脊柱,以及用来处理自我賦值可能性(见条款11)的一个常见机制。由于swap如此有用,适当的实现很重要。然而在非凡的重要性之外它也带来了非凡的复杂度。本条款探讨这些复杂度及因应之道。
所 谓 swap (置换)两对象值,意思是将两对象的值彼此赋予对方。缺省情况下 swap 动作可由标准程序库提供的swap 算法完成。其典型实现完全如你所预期
namespace std {
template<typename T> //std::swap的典型实现;
void swap(T& a, T& b) //置换a和b的值
{
T temp(a);
a = b;
b = temp;
}
} 只要类型T支持copying (通过copy构造函数和copy assignment 操作符完成),缺省的swap 实现代码就会帮你置换类型为T的对象,你不需要为此另外再做任何工作。
这缺省的 swap 实现版本十分平淡,无法刺激你的肾上腺。它涉及三个对象的复制:a复制到temp,b复制到a,以及temp复制到b。但是对某些类型而言,这些复制动作无一必要; 对它们而言swap缺省行为等于是把高速铁路铺设在慢速小巷弄内。
其中最主要的就是“以指针指向一个对象,内含真正数据” 那种类型。这种设计的常见表现形式是所谓“pimpl 手法” (pimpl是"pointer to implementation” 的缩写,见条款31)。如果以这种手法设计Widget class,看起来会像这样
class WidgetImpl { //针对widget数据而设计的class;
public: //细节不重要。
...
private:
int a, b, c; //可能有许多数据,
std::vector<double> v; //意味复制时间很长。
...
};
class Widget { //这个class使用pimpl手法
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) //复制Widget时,令它复制其
{ //WidgetImpl对象
...
*pImpl = (rhs.pImpl); //细节,见条款10,11和12
...
}
...
private:
WidgetImpl* pImpl; //指针所指对象内含Widget数据
}: 一旦要置换两个Widget对象值,我们唯一需要做的就是置换其pImpl指针,但缺省的 swap 算法不知道这一点。它不只复制三个 Widgets,还复制三个 WidgetImpl 对象。非常缺乏效率!一点也不令人兴奋。
我们希望能够告诉 std::swap:当Widgets 被置换时真正该做的是置换其内部的 pImpl 指针。确切实践这个思路的一个做法是: 将std::swap 针对Widget 特化。下面是基本构想,但目前这个形式无法通过编译:
namespace std {
template<> //这是std::swap针对
void swap<Widget>(Widget& a, Widget& b) //“T是Widget”的特化版本
{
swap(a.pImpl, b.pImpl); //置换Widgets时只要置换它们的pImpl指针就好
}
} 这个函数一开始的"template<>" 表示它是std::swap的一个全特化(toral template specialization)版本,函数名称之后的"<Widget>" 表示这一特化版本系针对 “T是Widget” 而设计。换句话说当一般性的 swap template 施行于 Widgets 身上便会启用这个版本。通常我们不能够 (不被允许)改变 std 命名空间内的任何东西,但可以 (被允许) 为标准 templates (如swap)制造特化版本,使它专属于我们自己的 classes (例如Widget)。以上作为正是如此。
但是一如稍早我说,这个函数无法通过编译。因为它企图访问a 和b内的pImpl指针,而那却是private。我们可以将这个特化版本声明为friend,但和以往的规矩不太一样:我们令Widget 声明一个名为swap 的public 成员函数做真正的置换工作,然后将 std::swap 特化,令它调用该成员函数
class Widget { //与前同,唯一差别是增加swap函数
public:
...
void swap(Widget& other)
{
using std::swap; //这个声明之所以必要,稍后解释。
swap(pImpl, other.pImp1); //若要省换Widgets就置换其plmpl指针。
}
...
};
namespace std {
template<> //修订后的std::swap特化版本
void swap<Widget>(Widget& a,
Widget& b)
{
a.swap(b); //若要置换Widgets,调用其swap函数
}
} 这种做法不只能够通过编译,还与STL容器有一致性,因为所有STL 容器也都提供有public swap成员函数和std::swap特化版本(用以调用前者)。
然而假设Widget 和Widget Impl都是class templates 而非classes, 也许我们可以试试将Widiget Impl 内的数据类型加以参数化:
template<typename T> class WidgetImpl { ... };
template<typename T> class Widget { ... }; 在Widget 内(以及Widget Impl内,如果需要的话)放个swap成员函数就像以往一样简单,但我们却在特化 std::swap 时遇上乱流。我们想写成这样
namespace std {
template<typename T>
void swap<Widget<T> >(Widget<T>& a, //错误!不合法!
Widget<T>& b)
{ a.swap(b); }
} 看起来合情合理,却不合法。是这样的,我们企图偏特化 (partially specialize ) 一个function template(std::swap),但C++ 只允许对class templates 偏特化,在function templates 身上偏特化是行不通的。这段代码不该通过编译(虽然有些编译器错误地接受了它)。
当你打算偏特化一个function template 时,惯常做法是简单地为它添加一个重载版本,像这样:
namespace std {
template<typename T>
void swap(Widget<T>& a,
Widget<T>& b)
{ a.swap(b); }
} 一般而言,重载function templates 没有问题,但 std 是个特殊的命名空间,其管理规则也比较特殊。客户可以全特化 std 内的templates,但不可以添加新的templates (或 classes 或 functions 或其他任何东西) 到 std 里头。std 的内容完全由 C++ 标准委员会决定,标准委员会禁止我们膨胀那些已经声明好的东西。啊呀,所谓 “禁止” 可能会使你沮丧,其实跨越红线的程序几乎仍可编译和执行,但它们的行为没有明确定义。如果你希望你的软件有可预期的行为,请不要添加任何新东西到 std 里头。
那该如何是好?毕竟我们总是需要一个办法让其他人调用 swap 时能够取得我们提供的较高效的template 特定版本。答案很简单,我们还是声明一个non-member swap 让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。为求简化起见,假设Widget 的所有相关机能都被置于命名空间 WidgetStuff 内,整个结果看起来便像这样
namespace Widgetstuff {
... //模板化的WidgetImpl等等
template<typename T> //同前,内含swap成员函数
class Widget { ... };
...
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) //non-member swap函数
{ //这里并不属于std命名空间
a.swap(b) ;
}
} 现在,任何地点的任何代码如果打算置换两个Widget 对象,因而调用swap,C++ 的名称查找法则(name lookup rules; 更具体地说是所谓argument-dependent lookup 或 Koenig lookup 法则)会找到WidgetStuff 内的Widget 专属版本。那正是我们所要的。
Koenig lookup 法则:
当编译器在编译该函数时,不仅会在current scope 和global scope查找函数,并且会在形参声明的地方查找函数。1. ADL只对namespace起作用,把namespace A换成class A就会报错。
2. 编译器查找是有优先顺序的,current scope > global scope > 使用ADL,一旦找到便停止。
#include <iostream>
namespace A {
struct X {};
void func(X) {
std::cout << "A::func for A::X\n";
}
}
void func() {
std::cout << "Global func\n";
}
namespace B {
struct Y {};
void func(Y) {
std::cout << "B::func for B::Y\n";
}
void callFunc() {
Y y;
func(y); // 当前作用域中的 B::func 被调用
}
void callFuncA() {
A::X x;
func(x); // A::X 类型是命名空间 A 中定义的,会触发 ADL 调用 A::func
}
}
int main() {
B::callFunc(); // 调用 B::func
func(); // 调用全局的 func
A::X x;
func(x); // 通过 ADL 调用 A::func,因为 A::X 是 A 命名空间中的类型
B::callFuncA(); // 在 B::callFuncA 中,通过 ADL 调用 A::func
return 0;
} 这个做法对classes 和class templates 都行得通,所以似乎我们应该在任何时候都使用它。不幸的是有一个理由使你应该为clases特化std::swap (很快我会描述它), 所以如果你想让你的“class 专属版” swap在尽可能多的语境下被调用,你需得同时在该class 所在命名空间内写一个non-member 版本以及一个std::swap特化版本。
顺带一提,如果没有像上面那样额外使用某个命名空间,上述每件事情仍然适用 (也就是说你还是需要一个non-member swap用来调用member swap)。但,何必在 global 命名空间内塞满各式各样的class, template, function, enum, enumerant 以及typedef 名称呢?难道你对所谓“得体与适度” 失去判断力了吗?
目前为止我所写的每一样东西都和swap 编写者有关。换位思考,从客户观点看看事情也有必要。假设你正在写一个function template,其内需要置换两个对象值:
template<typename T>
void dosomething(T& obj1, T& obj2)
{
...
swap(obj1, obj2);
...
} 应该调用哪个swap?是std 既有的那个一般化版本?还是某个可能存在的特化版本? 亦或是一个可能存在的 T专属版本而且可能栖身于某个命名空间 (但当然不可以是std)内?你希望的应该是调用T专属版本,并在该版本不存在的情况下调用std 内的一般化版本。 下面是你希望发生的事
template<typename T>
void dosomething(T&obj1, T& obj2)
{
using std::swap; //令std::swap在此函数内可用
...
swap(obj1, obj2); //为T型对象调用最佳swap版本
...
} 一旦编译器看到对swap的调用,它们便查找适当的swap 并调用之。C++的名称查找法则 (name lookup rules)确保将找到global 作用域或T所在之命名空间内的任何T专属的swap。如果T是Widget 并位于命名空间WidgetStuff 内,编译器会使用“实参取决之查找规则” (argument-dependent lookup)找出WidgetStuff内的swap。如果没有T专属之 swap 存在,编译器就使用 std 内的 swap,这得感谢 using 声明式让 std::swap 在函数内曝光。然而即便如此编译器还是比较喜欢std::swap的T专属特化版,而非一般化的那个template,所以如果你己针对T将 std::swap特化,特化版会被编译器挑中。
因此,令适当的swap 被调用是很容易的。需要小心的是,别为这一调用添加额外修饰符,因为那会影响C++ 挑选适当函数。假设你以这种方式调用swap:
std::swap(obj1, obj2); //这是错误的swap调用方式 这便强迫编译器只认std 内的swap (包括其任何template特化),因而不再可能调用一个定义于它处的较适当T专属版本。啊呀,某些迷途程序员的确以此方式修饰 swap 调用式,而那正是 “你的 classes 对 std::swap 进行全特化” 的重要原因 : 它使得类型专属之 swap 实现版本也可被这些 “迷途代码” 所用 (这样的代码出现在某些标准程序库实现版中,如果你有兴趣不妨帮助这些代码尽可能高效运作)。
此刻,我们已经讨论过default swap、member swaps、non-member swaps、std::swap 特化版本、以及对 swap 的调用,现在让我把整个形势做个总结。
首先,如果 swap 的缺省实现码对你的class 或class template 提供可接受的效率, 你不需要额外做任何事。任何尝试置换 (swap)那种对象的人都会取得缺省版本,而那将有良好的运作。
其次,如果swap 缺省实现版的效率不足(那几乎总是意味你的class 或template 使用了某种pimpl 手法),试着做以下事情:
- 提供一个public swap 成员函数,让它高效地置换你的类型的两个对象值。稍后我将解释,这个函数绝不该拋出异常。
- 在你的class或template所在的命名空间内提供一个non-member swap,并令它调用上述 swap 成员函数。
- 如果你正编写一个class(而非class template),为你的class特化std::swap。并令它调用你的 swap 成员函数。
最后,如果你调用swap,请确定包含一个using声明式,以便让std:: swap在你的函数内曝光可见,然后不加任何namespace 修饰符,赤裸裸地调用swap。
唯一还未明确的是我的劝告:成员版 swap 绝不可抛出异常。那是因为 swap 的一个最好的应用是帮助 classes (和 class templates)提供强烈的异常安全性(exception-safety) 保障。条款29对此主题提供了所有细节,但此技术基于一个假设:成员版的swap绝不拋出异常。这一约束只施行于成员版!不可施行于非成员版,因为 swap 缺省版本是以 copy 构造函数和 copy assignment 操作符为基础,而一般情况下两者都允许拋出异常。因此当你写 下一个自定版本的 swap,往往提供的不只是高效置换对象值的办法,而且不抛出异常。一般而言这两个 swap特性是连在一起的,因为高效率的swaps 几乎总是基于对内置类型的操作(例如pimpl 手法的底层指针),而内置类型上的操作绝不会抛出异常
当 std::swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常。
如果你提供一个member swap,也该提供一个non-member swap 用来调用前者。对于classes (而非templates),也请特化std::swap。
调用 swap 时应针对 std::swap 使用 using 声明式,然后调用 swap 并且不带任何 “命名空间资格修饰”。
为“用户定义类型” 进行std templates全特化是好的,但千万不要尝试在std内加入某些对std 而言全新的东西
实现
大多数情况下,适当提出你的classes (和classs templates)定义以及 functions (和function templates)声明,是花费最多心力的两件事。 一旦正确完成它们,相应的实现大多直截了当。尽管如此,还是有些东西需要小心。
太快定义变量可能造成效率上的拖延:过度使用转型 (casts) 可能导致代码变慢又难维护,又招来微妙难解的错误:返回对象“内部数据之号码牌(handles)” 可能会破坏封裝并留给客户虛吊号码牌 (dangling handles);未考虑异常带来的冲击则可能导致资源泄漏和数据败坏;过度热心地inlining 可能引起代码膨胀;过度耦合(coupling)则可能导致让人不满意的冗长建置时间 (build times)
条款 26: 尽可能延后变量定义式的出现时间
只要你定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流 (control flow)到达这个变量定义式时,你便得承受构造成本:当这个变量离开其作用域时,你便得承受析构成本。即使这个变量最终并未被使用,仍需耗费这些成本,所以你应该尽可能避免这种情形
考虑下面这个函数,它计算通行密码的加密版本而后返回,前提是密码够长。如果密码太短,函数会丢出一个异常,类型为logic_error (定义于C++标准程序库,见条款54)
// 这个函数过早定义变量"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;
...
return encrypted;
} 但是这段代码仍然不够秾纤合度,因为 encrypted 虽获定义却无任何实参作为初值。这意味调用的是其default 构造函数。许多时候你该对对象做的第一次事就是给它个值,通常是通过一个赋值动作达成。条款4曾解释为什么“通过default构造函数构造出一个对象然后对它赋值” 比“直接在构造时指定初值” 效率差。那个分析当然也适用于此。举个例子,假设encryptPassword的艰难部分在以下函数中进行:
void encrypt(std::string& s); //在其中的适当地点对s加密 于是encryptPassword 可实现如下,虽然还不算是最好的做法
std::string encryptPassword(const std::string& password)
{
...
std::string encrypted;
encrypted = password;
encrypt(encrypted);
return encrypted;
} 更受欢迎的做法是以 password 作为 encrypted 的初值,跳过毫无意义的 default 构造过程
std::string encryptPassword(const std::string& password)
{
...
std::string encrypted(password);
encrypt(encrypted);
return encrypted;
} 这让我们联想起本条款所谓“尽可能延后” 的真正意义。你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。如果这样,不仅能够避免构造 (和析构)非必要对象,还可以避免无意义的default 构造行为。更深一层说,以“具明最意义之初值” 将变量初始化,还可以附带说明变量的目的。
“但循环怎么办?” 你可能会感到疑惑。如果变量只在循环内使用,那么把它定义于循环外并在每次循环迭代时赋值给它比较好,还是该把它定义于循环内?也就是说下面左右两个一般性结构,哪一个比较好
//方法A 定义于循环外
Widget w;
for (int i = 0; i < n; ++i) {
取决于i 的某个值;
...
} //方法B 定义于循环内
for (int i = 0; i < n; ++i) {
Widget w(取决于i的某个值);
...
} 这里我把对象的类型从 string 改为 Widget,以免造成读者对于 “对象执行构造、析构、或赋值动作所需的成本” 有任何特殊偏见。
在Widget 函数内部,以上两种写法的成本如下
做法A:1个构造函数 + 1个析构函数 + n个赋值操作
做法B:n个构造函数 + n个析构函数
如果classes的一个赋值成本低于一组构造+析构成本,做法A大体而言比较高效。尤其当 n 值很大的 时候。否则做法 B 或许较好。此外做法 A 造成名称 w 的作用域 (覆盖整个循环)比做法 B 更大,有时那对程序的可理解性和易维护性造成冲突。因此除非 (1) 你知道赋值成本比 “构造十析构” 成本低,(2) 你正在处理代码中效率高度敏感 (performance-sensitive)的部分,否则你应该使用做法B
条款 27: 尽量少做转型动作
转型(casts)破坏了类型系统(type system)。那可能导致任何种类的麻烦,有些容易辦识,有些非常隐晦。
通常有三种不同的形式,可写出相同的转型动作。C 风格的转型动作看起来像这样:
(T)expression //将expression转型为T 函数风格的转型动作看起来像这样
T(expression) 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 转型操作符。
dynanic_cast 主要用来执行“安全向下转型” (safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作(稍后细谈)。
reinterpret_cast 意图执行低级转型,实际动作(及结果)可能取决于编译器, 这这也就表示它不可移植。例如將一个pointer to int 转型为一个int。这一类转型在低级代码以外很少见。本书只使用一次,那是在讨论如何针对原始内存(raws 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),因而得以简化 “找出类型系统在哪个地点被破坏” 的过程。第二,各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。举个例子,如果你打算将常量性 (constness) 去掉,除非使用新式转型中的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)); 从某个角度来说,蓄意的“对象生成” 动作感觉不怎么像“转型” ,所以我很可能使用函数风格的转型动作而不使用static_cast。但我要再说一次,当我们写下一段日后出错导致“核心倾印” (core dump)的代码时,撰写之时我们往往“觉得” 通情达理,所以或许最好是忽略你的感觉,始终理智地使用新式转型。
许多程序员相信,转型其实什么都没做,只是告诉编译器把某种类型视为另一种类型。这是错误的观念。任何一个类型转换(不论是通过转型操作而进行的显式转换,或通过编译器完成的隐式转换) 往往真的令编译器编译出运行期间执行的码。例如在这段程序中:
int x, y;
double d = static_cast<double>(x)/y; 将 int x转型为double 几乎肯定会产生一些代码,因为在大部分计算器体系结构中,int 的底层表述不同于double 的底层表述。这或许不会让你惊讶,但下面这个例子就有可能让你稍微睁大眼睛了
class Base { ... };
class Derived: public Base { ... };
Derived d;
Base* pb = &d; //隐喻地将Derived*转换为Base* 这里我们不过是建立一个base class指针指向一个derived class对象,但有时候上述的两个指针值并不相同。这种情况下会有个偏移量 (offset )在运行期被施行于 Derived* 指针身上,用以取得正确的Base* 指针值
上个例子表明,单一对象(例如一个类型为Derived的对象)可能拥有一个以上的地址 (例如 “以Base* 指向它” 时的地址和 “以Derived* 指向它” 时的地址。C不可能发生这种事,Java不可能发生这种事,C# 也不可能发生这种事。但C++可能 ! 实际上一旦使用多重继承,这事几乎一直发生着。即使在单一继承中也可能发生。虽然这还有其他意源,但至少意味你通常应该避免做出 “对象在 C++ 中如何如何布局” 的假设。当然更不该以此假设为基础执行任何转型动作。例如,将对象地址转型为char*指针然后在它们身上进行指针算术,几乎总是会导致无定义(不明确 )行为。
对象的布局方式和它们的地址计算方式随编译器的不同而不同,那意味 “由于知道对象如何布局” 而设计的转型,在某一平台行得通,在其他平台并不一定行得通。这个世界有许多悲惨的程序员, 他们历经千辛万苦才学到这堂课。
另一件关于转型的有趣事情是 : 我们很容易写出某些似是而非的代码 (在其他语言中也许真是对的)。例如许多应用框架(application framneworks)都要求derived classes 内的virtual 函数代码的第一个动作就先调用base class 的对应函数。假设我们有个 Window base class 和一个 SpecialWindow derived class, 两者都定义了 virtual 函数onResize。进一步假设SpecialWindow的onResize函数被要求首先调用 Window的onResize。下面是实现方式之一,它看起来对,但实际上错
class Window {
public:
virtual void onResize( ) { ... }
...
);
class SpecialWindow: public Window {
public:
virtual void onResize( ) {
static_cast<Window>(*this).onResize();
...
}
...
}; 一如你所预期,这段程序将*this 转型为Window,对函数onResize 的调用也因此调用了 Window::onResize。但恐怕你没想到,它调用的并不是当前对象上的函数,而是稍早转型动作所建立的一个 “*this对象之base class 成分” 的暂时副本身上的onResize!
它是在 “当前对象之base class 成分” 的副本上调用 Window::onResize,然后在当前对象身上执行specialWindow 专属动作,这使当前对象进入一种“伤残” 状态:其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 的许多实现版本执行速度相当慢。例如至少有一个很普遍的实现版本基于“class 名称之字符串比较”,如果你在四层深的单继承体系内的某个对象身上执行 dynamic_cast, 刚才说的那个实现版本所提供的每 一次dynamic_cast 可能会耗用多达四次的 strcmp 调用,用以比较 class 名称。深度继承或多重继承的成本更高 ! 某些实现版本这样做有其原因(它们必须支持动态连接)。然而我还是要强调,除了对一般转型保持机敏与猜疑,更应该在注重效率的代码中对 dynamic_cast 保持机敏与猜疑。
之所以需要 dynamic_cast,通常是因为你想在一个你认定为 derived class 对象身上执行derived class 操作函数,但你的手上却只有一个“指向base” 的pointer 或 reference,你只能靠它们来处理对象。有两个一般性做法可以避免这个问题
第一,使用容器并在其中存储直接指向derived class 对象的指针 (通常是智能指针,见条款13),如此便消除了 “通过base class 接口处理对象” 的需要。假设先前的Window specialWindow继承体系中只有specialWindow 才支持闪烁效果,试着不要这样做:
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) { //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(); //不使用dynamic_cast
++iter)
(*iter)->link(); 当然啦,这种做法使你无法在同一个容器内存储指针“指向所有可能之各种 Window派生类” 。如果真要处理多种窗口类型,你可能需要多个容器,它们都必须具备类型安全性(type-safe)
另一种做法可让你通过base class 接口处理“所有可能之各种Window派生类”, 那就是在base class 内提供virtual 函数做你想对各个Window派生类做的事。举个例子,虽然只有 SpecialWindows 可以闪烁,但或许将闪烁函数声明于 base class 内并提供一份“什么也没做” 的缺省实现码是有意义的:
class Window {
public:
virtual void blink() { }
...
};
class Specialwindow: 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( ); 不论哪一种写法——“使用类型安全容器”或“将virtual 函数往继承体系上方移动” ——都并非放之四海皆准,但在许多情况下它们都提供一个可行的 dynamic_cast 替代方案。当它们有此功效时,你应该欣然拥抱它们
绝对必须避免的一件事是所谓的 “连串(cascading) dynamic_casts”,也就是看起来像这样的东西
class Window { ... };
... //derived classes定义在这里
typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();
iter != winPtrs.end(); ++iter)
{
if (SpecialWindow1 * psw1 =
dynanic_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++ 代码很少使用转型,但若说要完全摆脱它们又太过不切实际。例如p.118从int转型为double就是转型的一个通情达理的使用,虽然它并非绝对必 要 (那段代码可以重新写过,声明一个类型为 double 的新变量并以 x 值初始化)。就像面对众多蹊路可疑的构造函数数一样,我们应该尽可能隔离转型动作,通常是把它隐藏在某个函数内,函数的接口会保护调用者不受函数内部任何肮脏龌龊的动作影响。
如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast,如果有个设计需要转型动作,试着发展无需转型的替代设计。
如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
宁可使用C++-style (新式)转型,不要使用旧式转型。前者很容易辨识出来,并且也比较有着分门别类的职掌
条款 28: 避免返回 handles 指向对象内部成分
不要返回指向对象内部的引用或指针,否则可能破坏类的封装性。
假设你的程序涉及短形。每个短形由其左上角和右下角表示。为了让一个 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
}; 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。另一方面两个函数却都返回references 指向private 内部数据,调用者于是可通过这些references 更改内部数据!
这 立刻带给我们两个教训。第一,成员变量的封装性最多只等于 “返回其 reference” 的函数的访问级别。本例之中虽然ulhc和lrhc都被声明为private, 它们实际上却是 public,因为 public 函数 upperLeft 和 lowerRight 传出了它们的references。第二,如果const 成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。这正是 bitwise constness 的一个附带结果,见条款3
上面我们所说的每件事情都是由于“成员函数返回references”。如果它们返回的是指针或选代器,相同的情况还是发生,原因也相同。References 、指针和迭代器统统都是所谓的handles (号码牌,用于取得某个对象),而返回一个“代表对象内部数据” 的handle,随之而来的便是“降低对象封裝性” 的风险。同时,一如稍早所见,它也可能导致“虽然调用const 成员函数却造成对象状态被更改”。
通常我们认为,对象的 “内部” 就是指它的成员变量,但其实不被公开使用的成员函数 (也就是被声明为 protected 或者 private) 也是对象 “内部” 的一部分。因此也应该留心不要返回它们的handles。这意味你绝对不该令成员函数返回一个指针指向“访问级别较低” 的成员函数。如果你那么做,后者的实际访问级别就会提高如同前者 (访问级别较高者),因为客户可以取得一个指针指向那个“访问级别较低” 的函数,然后通过那个指针调用它。
然而“返回指针指向某个成员函数” 的情况毕竟不多见,所以让我们把注意力收回,专注于 Rectangle class 和它的 upperLeft 以及 lowerRight 成员函数。我们在这些函数身上遭遇的两个问题可以轻松去除,只要对它们的返回类型加上const 即可:
class Rectangle {
public:
...
const Point& upperLeft( ) const { return pData->ulhc; }
const Point& lowerRight( ) const { return pData->lrhc; }
...
}; 有了这样的改变,客户可以读取矩形的points,但不能涂写它们。这意味当初声明upperLeft和lowerRight为const 不再是个谎言,因为它们不再允许客户更改对象状态。至于封装问题,我们总是愿意让客户看到Rectangle 的外围Points, 所以这里是蓄意放松封装。更重要的是这是个有限度的放松:这些函数只有读取权。涂写权仍然是被禁止的。
但即使如此,upperLeft 和 lowerRight 还是返回了 “代表对象内部” 的 handles,有可能在其他场合带来问题。更明确地说,它可能导致dangling handles ( 空悬的号码牌):这种handles 所指东西 (的所属对象)不复存在。这种 “不复存在的对象” 最常见的来源就是函数返回值。例如某个函数返回GUI 对象的外框(bounding box), 这个外框采用矩形形式
class GUIObject { ... };
boundingBox(const GUIObject& obj);
GUIObject* pgo;
...
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft()); 对boundingBox 的调用获得一个新的、暂时的Rectangle 对象。这个对象没有名称,所以我们有权且称它为temp。随后upperLeft 作用于temp 身上,返回一个 reference 指向 temp 的一个内部成分,更具体地说是指向一个用以标示 temp 的 Points。于是pUpperLeft 指向那个Point 对象。目前为止一切还好,但故事尚未结束,因为在那个语句结束之后,boundingBox的返回值,也就是我们所说的temp, 将被销毀,而那间接导致temp 内的Points析构。最终导致pUpperLeft 指向一个不再存在的对象:也就是说一旦产出 pUpperLeft 的那个语句结束,pUpperLeft 也就变成空悬、虛吊 (dangling)!
这就是为什么函数如果“返回一个handle 代表对象内部成分”总是危险的原因。 不论这所谓的 handle 是个指针或迭代器或 reference,也不论这个handle 是否为 const,也不论那个返回handle 的成员函数是否为const 。这里的唯一关键是,有个handle 被传出去了,一旦如此你就是暴露在“handle比其所指对象更长寿” 的风险下。
这并不意味你绝对不可以让成员两数返回 handle。有时候你必须那么做。例如 operator[]就允许你“摘采”strings和vectors的个别元素,而这些operator[]s 就是返回references 指向 “容器内的数据” (见条款3),那些数据会随着容器被销毁而销毁。尽管如此,这样的函数毕竟是例外,不是常态。
避免返回handles (包括references、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const 成员函数的行为像个const,并将发生“虚吊号码牌”(dangling handles)的可能性降至最低。
条款29: 为 “异常安全” 而努力是值得的
异常安全函数 (Exception-safe functions)分为三种可能的保证:基本型、强烈型、不抛异常型 。“强烈保证” 往往能够以copy-and-swap 实现
在编写程序时应该注重异常安全,即确保在发生异常时程序仍能维持其基本承诺和不变量。
假设有个 class 用来表现夹带背景图案的 GUI 菜单。这个 class 希望用于多线程环境,所以它有个互斥器(mutex)作为并发控制(concurrency control) 之用
class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); //改变背景图像
...
private:
Mutex mutex;
Image* bgImage;
int imageChanges;
};
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 bqImage;
++imageChanges;
bgImage = new Image(imgSrc);
} 关于“资源管理类” (resource management classes)如Lock者,一个最棒的事情是,它们通常使函数更短。你看,不再需要调用unlock了不是吗?有个一般性规则是这么说的:较少的码就是较好的码,因为出错机会比较少,而且一旦有所改变,被误解的机会也比较少。
把资源泄漏拋诸脑后,现在我们可以专注解决数据的败坏了。此刻我们需要做个抉择,但是在我们能够抉择之前,必须先面对一些用来定义选项的术语
异常安全函数 (Exception-safe functions) 提供以下三个保证之一
- 基本承诺:如果异常被拋出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态 (例如所有的 class 约束条件都继续获得满足)。然而程序的现实状态 (exact state)恐怕不可预料。举个例子,我们可以撰写 changeBackground使得一旦有异常被抛出时,PrettyMenu 对象可以继续拥有原背景图像,或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况。如果想知道,他们恐怕必须调用某个成员函数以得知当时的背景图像是什么
- 强烈保证:如果异常被拋出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到 “调用函数之前” 的状态
和这种提供强烈保证的函数共事,比和刚才说的那种只提供基本承诺的函数共事,容易多了,因为在调用一个提供强烈保证的函数后,程序状态只有两种可能:如预期般地到达函数成功执行后的状态,或回到函数被调用前的状态。与此成对比的是,如果调用一个只提供基本承诺的函数,而真的出现异常,程序有可能处于任何状态——只要那是个合法状态
- 不抛掷(nothrow)保证,承诺绝不拋出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型 (例如 ints,指针等等) 身上的所有操作都提供 nothrow保证。这是异常安全码中一个必不可少的关键基础材料
如果我们假设,函数带着“空白的异常明细” (empty exception specification) 者必为nothrow 函数,似乎合情合理,其实不尽然。举个例子,考虑以下函数
int doSomething() throw(); 这并不是说 doSomething 绝不会抛出异常,而是说如果 doSomething 抛出异常,将是严重错误,会有你意想不到的函数被调用。实际上doSomething 也许完全没有提供任何异常保证。函数的声明式 (包括其异常明细——如果有的话)并不能够告诉你是否它是正确的、可移植的或高效的,也不能够告诉你它是否提供任何异常安全性保证。所有那些性质都由函数的实现决定,无关乎声明。
异常安全码(Exception-safe code)必须提供上述三种保证之一。如果它不这样做,它就不具备异常安全性。因此,我们的抉择是,该为我们所写的每一个函数提供哪一种保证?除非面对不具异常安全 性的传统代码 (我將在本条款末尾讨论那种情况),否则你应该只在一种情况下才不提供任何异常安全保证:你那“天才班” 需求分析团队确认你的应用程序有“泄漏资源” 并“在执行过程中带着败坏数据” 的需要。
一般而言你应该会想提供可实施之最强烈保证。从异常安全性的观点视之,nothrow函数很棒,但我们很难在C part of C++领域中完全没有调用任何一个可能抛出异常的函数。任何使用动态内存的东西(例如所有STL 容器)如果无法找到足够内存以满足需求,通常便会拋出一个bad_alloc异常 (见条款49)。是的,可能的话请提供nothrow 保证,但对大部分函数而言,抉择往往落在基本保证和强烈保证之间。
对 changeBackground 而言,提供强烈保证几乎不困难。首先改变 PrettyMenu 的bgImage成员变量的类型,从一个类型为Image*的内置指针改为一个“用于资源管理” 的智能指针 (见条款13)。坦白说,这个好构想纯粹只是帮助我们防止资源泄漏。它对“强烈之异常安全保证” 的帮助仅仅只是强化了条款13的论点:以对象 (例如智能指针) 管理资源是良好设计的根本。以下代码中我使用tr1::shared_ptr,因为它比auto_ptr 更直观的行为使它更受欢迎。
第二,我们重新排列changeBackground内的语向次序,使得在更换图像之后才累加 imageChanges。一般而言这是个好策略:不要为了表示某件事情发生而改变对象状态,除非那件事情真的发生了
class PrettyMenu {
...
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc)); //以"new Image" 的执行结果
//设定bgImage 内部指针
++imageChanges;
} 这里不再需要手动 delete 旧图像,因为这个动作已经由智能指针内部处理掉了。此外,删除动作只发生在新图像被成功创建之后。更正确地说,tr1::shared_ptr::reset 函数只有在其参数(也就是"new Image(imgsrc)"的执行结果) 被成功生成之后才会被调用。delete 只在reset 函数内被使用,所以如果从未进入那个函数也就绝对不会使用delete。也请注意,以对象(tr1::shared_ptr) 管理资源(这里是动态分配而得的 Image) 再次缩减了 changeBackground 的长度。
如我稍早所言,这两个改变几乎足够让changeBackground 提供强烈的异常安全保证。美中不足的是参数 imgSrc。 如果 Image 构造函数拋出异常,有可能输入流 (input stream) 的读取记号 (read marker) 己被移走,而这样的搬移对程序其余部分是一种可见的状态改变。所以changeBackground 在解决这个问題之前只提供基本的异常安全保证
然而,让我们把它放在一旁,佯装 changeBackground 的确提供了强烈保证 (我有信心你可以想出个什么办法顺利过渡,或许你可以改变它的参数类型,从istream 改为一个内含图像数据的文件名称)。有个一般化的设计策略很典型地会导致强烈保证,很值得熟悉它。这个策略被称为copy and swap。原则很简单:为你打算修改的对象(原件) 做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再將修改过的那个副本和原对象在一个不抛出异常的操作中置换 (swap)
实现上通常是将所有“隶属对象的数据” 从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object,即副本)。 这种手法常被称为pimpl idiom,条款31详细描述了它。对PrettyMenu而言,典型写法如下:
struct PMImpl {
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu {
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap; //见条款25
Lock ml(&mutex); //获得mutex的副本数据
std::tr1::shared_ptr<PMImpl>
pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); //修改副本
++pNew->imageChanges;
swap(pImpl, pNew);
} 此例之中我选择让 PMImpl 成为一个 struct 而不是一个 class,这是因为 PrettyMenu 的数据封装性己经由于 “pImpl 是private” 而获得了保证。如果令 PMImpl 为一个class,虽然一样好,有时候却不太方便(但也保持了面向对象纯度)。如果你要,也可以将PMImpl 联套于PrettyMenu内,但打包问题(packaging,例如“独立撰写异常安全码” )是我们这里所挂虑的事。
"copy-and-swap” 策略是对对象状态做出“全有或全无” 改变的一个很好办法,但一般而言它并不保证整个函数有强烈的异常安全性。为了解原因,让我们考虑 changeBackground 的一个抽象概念:someFunc。它使用 copy-and-swap 策略,但函数内还包括对另外两个函数 f1 和 f2 的调用
void someFunc ()
{
... //对local状态做一份副本
f1();
f2();
... //将修改后的状态置换过来
} 很显然,如果f1 或f2的异常安全性比“强烈保证” 低,就很难让someFunc 成为“强烈异常安全”。举个例子,假设f1 只提供基本保证,那么为了让someFunc 提供强烈保证,我们必须写出代码获得调用f1 之前的整个程序状态、捕捉f1的所有可能异常、然后恢复原状态。
如果f1和f2都是“强烈异常安全” ,情况并不就此好转。毕竟如果f1圆满结束,程序状态在任何方面都可能有所改变,因此如果f2随后抛出异常 ,程序状态和someFunc 被调用前并不相同,甚至当f2没有改变任何东西时也是如此。
问题出在“连带影响” (side cffects)。如果函数只操作局部性状态(local state, 例如someFunc 只影响其“调用者对象” 的状态),便相对容易地提供强烈保证。 但是当函数对“非局部性数据” (non-local data)有连带影响时,提供强烈保证就困难得多。举个例子,如果调用 f1带来的影响是某个数据库被改动了,那就很难 让someFunc 具备强烈安全性。一般而言在“数据库修改动作〞送出之后,没有什 么做法可以取消并恢复数据库旧观,因为数据库的其他客户可能己经看到了这一笔新数据。
这些议题想必会阻止你为函数提供强烈保证——即使你想那么做。另一个主题是效率。copy-and-swap 的关键在于 “修改对象数据的副本,然后在一个不抛异常函数中将修改后的数据和原件置换” ,因此必须为每一个即将被改动的对象做出一个副本,那得耗用你可能无法 (或无意愿)供应的时间和空间。是的, 大家都希望提供 “强烈保证“:当它可被实现时你的确应该提供它,但 “强烈保证” 并非在任何时刻都显得实际。
当 “ 强烈保证〞不切实际时,你就必须提供 “基本保证” 。现实中你或许会发现,你可以为某些函数提供强烈保证,但效率和复杂度带来的成本会使它对许多人而言摇摇欲坠。只要你曾经付出适当的心力试图提供强烈保证,万一实际不可行, 使你退而求其次地只提供基本保证,任何人都不该因此责难你。对许多函数而言,“异常安全性之基本保证” 是一个绝对通情达理的选择。
如果你写的函数完全不提供异常安全保证,情况又有点不同。因为他人可以合理假设你在这方面有缺失,直到你证明自己的清白。是的,你应当写出异常安全码。 不过你也可能有令人信服的理由。再次考虑先前出现的那份调用函数 f1和 f2 的someFunc 实现代码。假设f2完全没有提供异常安全保证,甚至连基本保证都没有,那便意味一旦f2抛出异常,程序有可能在f2内泄漏资源。这意味f2可能败坏数据结构,例如带序数组 ( sorted arrays)可能不再处于排序状态下、从某数据结构搬移至另一数据结构的对象有可能遗失...等等。someFunc 没办法补偿那些问题。 如果someFunc调用的函数没有提供任何异常安全保证,someFunc 自身也不可能提供任何保证。
这令我想到怀孕。一位女性若非怀孕,就是没怀孕。不可能说她 “部分怀孕”。同样道理,一个软件系统要不就具备异常安全性,要不就全然否定,没有所谓的“局部异常安全系统“。如果系统内有一个(惟有一个)函数不具备异常安全性,整个系统就不具备异常安全性,因为调用那个 (不具备异常安全性的)函数有可能导致资源泄漏或数据结构败坏。不幸的是许多老旧C++ 代码并不具备异常安全性,所以今天许多系统仍然不能够说是“异常安全” 的,因为它们并入了一些并非“异常安全” 的代码。
没有理由让这种情况永垂不朽。当你撰写新码或修改旧码时,请仔细想想如何让它具备异常安全性。首先是“以对象管理资源” (条款13),那可阻止资源泄漏。 然后是挑选三个“异常安全保证” 中的某一个实施于你所写的每一个函数身上。你应该挑选“现实可施作” 条件下的最强烈等级,只有当你的函数调用了传统代码,才别无选择地将它设为“无任何保证” 。将你的决定写成文档,这一来是为你的函数用户着想,二来是为将来的维护者着想。函数的“异常安全性保证” 是其可见接口的一部分,所以你应该慎重选择,就像选择函数接口的其他任何部分一样。
四十年前,满载 goto的代码被视为一种美好实践,而今我们却致力写出结构化控制流(structured control flows)。二十年前,全局数据(globally acccsible data) 被视为一种美好实践,而今我们却致力于数据的封装。十年前,撰写“未将异常考虑在内” 的函数被视为一种美好实践,而今我们致力于写出 “异常安全码”
异常安全函数 (Exception-safe functions)即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型 。
“强烈保证” 往往能够以copy-and-swap 实现出来,但“强烈保证” 并非对所有数都可实现或具备现实意义
函数提供的“异常安全保证” 通常最高只等于其所调用之各个函数的“异常安全保证” 中的最弱者。
条款 30: 透彻了解 inlining 的里里外外
了解编译器的inline实现机制,可以更好地理解inline函数的使用和性能影响。
当你inline 某个函数,或许编译器就因此有能力对它(函数本体)执行语境相关最优化。大部分编译器绝不会对着一个 “outlined 函数调用” 动作执行如此之最优化。
inline 函数背后的整体观念是,将 “对此函数的每一个调用” 都以函数本体替换之。我想不需要统计学博士来告诉你,这样做可能增加你的目标码 (object code)大小。在一台内存有限的机器上,过度热衷inlining 会造成程序体积太大(对可用空间市言)。 即使拥有虚内存,inline 造成的代码膨胩亦会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instruction cachehit rate) ,以及伴随这些而来的效率损失。
换个角度说,如果inline 函数的本体很小,编译器针对 “函数本体” 所产出的码可能比针对 “函数调用” 所产出的码更小。果真如此,將函数inlining 确实可能导致较小的目标码 (object code)和较高的指令高速缓存裝置击中率
inline 只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于class 定义式内,friend 函数也可被定义于class 内, 如果真是那样,它们也是被隐喻声明为inline,明确声明 inline 函数的做法则是在其定义式前加上关键字inline
我们发现inline 函数和templates 两者通常都被定义于头文件内 。这使得某些程序员以为function templates一定必须是inline。这个结论不但无效而且可能有害,值得深入看一看
inline函数通常一定被置于头文件内,因为大多数建置环境 (build environments) 在编译过程中进行inlining,而为了將一个“函数调用” 替换为“被调用函数的本体” ,编译器必领知道那个函数长什么样子。某些建置环境可以在连接期完成 inlining,少量建置环境如基于 NETCLI (Common Language Infrastructure:公共语言基础设施)的托管环境 (managed environment s)竞可在运行期完成inlining。然而这样的环境毕竟是例外,不是通例。Inlining 在大多数C++ 程序中是编译期行为。
Templates 通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。
Template的具现化与inlining无关。如果你正在写一个template而你认为所有根据此template 具现出来的函数都应该inlined,请将此template 声明为inline; 这就是上述std::max代码的作为。但如果你写的template 没有理由要求它所具现的每一个函数都是inlined,就应该避免将这个template 声明为inline (不论显式或隐式)
所有对virtual函数的调用(除非是最平淡无奇的)也都会使inlining 落空。这不该令你惊讶,因为virtual意味 “等待,直到运行期才确定调用哪个函数”,而inline 意味“热行前,先將调用动作替换为被调用西数的本体”。如果编译器不知道该调用哪个函数, 你就很难责各它们拒绝將函数本体inlining。
这些叙述整合起来的意思就是:一个表面上看似inline 的函数是否真是inline, 取决于你的建置环境, 主要取决于编译器。幸运的是大多数编译器提供了一个诊断级别:如果它们无法将你要求的函数inline 化,会给你一个警告信息(见条款53)。
有时候虽然编译器有意愿 inlining 某个函数,还是可能为该函数生成一个函数本体。举个例子,如果程序要取某个inline 函数的地址,编译器通常必须为此函数生成 一个outlined 函数本体。毕竟编译器哪有能力提出一个指针指向并不存在的函数呢? 与此并提的是,编译器通常不对 “通过函数指针而进行的调用” 实施inlining,这意味对inline 函数的调用有可能被inlined,也可能不被inlined,取决于该调用的实施方式:
inline void f( ) {...} //假设编译器有意愿inline“对f的调用”
void(*pf)() = f; //pf指向f
...
f(); //这个调用将被inlined,因为它是一个正常调用
pf(); //这个调用或许不被inlined,因为它通过函数指针达成 即使你从未使用函数指针,“未被成功inlined” 的inline 函数还是有可能缠住你,因为程序员并非唯一要求两数指针的人。有时候编译器会生成构造函数和析构函数的outine副本,如此一来它们就可以获得指针指向那些函数,在array内部元素的构造和析构过程中使用。
实际上构造函数和析构函数往往是inlining 的糟糕候选人——虽然漫不经心的情况下你不会这么认为。考虑以下Derived class构造函数
class Base {
public:
...
private:
std::string bm1, bm2;
};
class Derived: public Base {
public:
Derived() { } //Derived构造函数是空的,哦,是吗?
private:
std::string dm1, dm2, dm3;
} 这个构造函数看起来是inlining 的绝佳候选人,因为它根本不含任何代码。但是你的眼睛可能会欺骗你
如果有个异常在对象构造期间被拋出,该对象己构造好的那一部分会被自动销毁。在这些情况中C++ 描述了什么一定会发生,但没有说如何发生。“事情如何发生” 是编译器实现者的权责,不过至少有一点很清楚,那就是它们不可能凭空发生。你的程序内一定有某些代码让那些事情发生,而那些代码——由编译器于编译期同代为产生并安插到你的程序中的代码——肯定存在于某个地方。有时候就放在你的构造函数和析构函数内,所以我们可以想象,编译器为稍早说的那个表面上看起来为空的 Derived 构造函数所产生的代码,相当于以下所列
Derived::Derived() //“空白Derived构造函数”的观念性实现
{
Base::Base();
try { dm1.std::string::string(); }
catch( ... ) {
Base::~Base();
throw;
}
try { dm2.std::string::string(); }
catch( ... ) {
dm1.std::string::~string();
Base::~Base();
throw;
}
try { dm3.std::string::string(); }
catch ( ... ) {
dm2.std::string::~string();
dm1.std::string::~string();
Base::~Base();
throw;
}
} 这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更精致复杂的做法来处理异常。尽管如此,这已能准确反映Derived 的空白构造函数必须提供的行为。不论编译器在其内所做的异常处理多么精致复杂,Derived 构造函数至少一定会陆续调用其成员变量和base class 两者的构造函数,而那些调用 (它们自身也可能被inlined )会影响编译器是否对此空白函数inlining
相同理由也适用于Base 构造函数,所以如果它被inlined,所有替换 “Base 构造函数调用” 而插入的代码也都会被插入到 “Derived 构造函数调用” 内 (因为Derived构造函数调用了Base 构造函数)。如果 string 构造函数怡巧也被 inlined, Derived构造函数将获得五份 “string构造函数代码” 副本,每一份副本对应于 Derived 对象内的五个字符串 (两个来自继承,三个来自自己的声明)之一。现在或许很清楚了,“是否將Derived构造函数inline化” 并非是个轻松的决定。类似思考也适用于 Derived 析构函数,在那儿我们必须看到 “被Derived 构造函数初始化的所有对象” 被一一销毁,无论以哪种方式进行。
程序库设计者必须评佔“将函数声明为inline” 的冲击:inline 函数无法随着程序库的升级而升级。换句话说如果f是程序库内的一个inline 函数,客户将“f函数本体” 编进其程序中,一旦程序库设计者决定改变 f,所有用到 f 的客户端程序都必须重新编译。这往往是大家不愿意见到的。然而如果f是non-inline 函数,一旦它有任何修改,客户端只需重新连接就好,远比重新编译的负担少很多。如果程序库采取动态连接,升级版函数甚至可以不知不觉地被应用程序吸纳。
对程序开发而言,将上述所有考虑牢记在心很是重要,但若从纯粹实用观点出发,有一个事实比其他因素更重要:大部分调试器面对 inline 函数都束手无策。这对你应该不是太大的意外,毕竟你如何在 一个并不存在的函数内设立断点 (break point)呢?虽然某些建置环境勉力支持对inlined 函数的调试,其他许多建置环境仅仅只能“在调试版程序中禁止发生inlining”。
这使我们在决定哪些函数该被声明为inline 而哪些函数不该时,掌握一个合乎逻辑的策略。一开始先不要将任何函数声明为inline,或至少将inlining 施行范围局限在那些 “一定成为inline” (见条款46)或“十分平淡无奇” (例如 p.135 Person::age)的函数身上。慎重使用inline 便是对日后使用调试器带来帮助,不过这么一来也等于把自己推向手工最优化之路。不要忘记80-20经验法则:平均而言一个程序往往将 80%的执行时间花费在 20%的代码上。这是一个重要的法则, 因为它提醒你,作为一个软件开发者,你的目标是找出这可以有效增进程序整体效率的20%代码,然后将它inline 或竭尽所能地将它瘦身。但除非你选对目标,否则一切都是虛功。
将大多数inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级 (binary upgradability)更容易,也可使潜在的代码膨胩问题最小化,使程序的速度提升机会最大化。
不要只因为function templates 出现在头文件,就将它们声明为inline
条款 31: 将文件间的编译依存关系降至最低
Handle class Interface clas
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和Acdress的定义式。这样的定义式通常由#include指示符提供,所以Person定义文件的最上方很可能存在这样的东西:
#include <string>
#include "date.h"
#include "address.h" 不幸的是,这么一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件中有任何一个被改变,或这些头文件所倚赖的其他头文件有任何改变,那么每一个含入Person class 的文件就得重新编译,任何使用Person class 的文件也必须重新编译。这样的连串编译依存关系(cascading compilation dependencies )会对许多项目造成难以形容的灾难
你或许会奇怪,为什么C++ 坚持将class 的实现细目置于class 定义式中? 为什么不这样定义Person,将实现细目分开叙述?
namespace std {
class string; //前置声明(不正确,详下)
}
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;
...
}; 如果可以那么做,Person的客户就只需要在 Person接口被修改过时才重新编译。
这个想法存在两个问题。第一,string不是个 class,它是个 typedef (定义为basic_string<char>)。因此上述針対string而做的前置声明并不正确:正确的前置声明比较复杂,因为涉及额外的templates。然而那并不要紧,因为你本来就不该尝试手工声明一部分标准程序库。你应该仅仅使用适当的 #includes完成目的。标准头文件不太可能成为编译瓶颈,特别是如果你的建置环境允许你使用预编译头文件 (precompiled headers)。如果解析 (parsing)标准头文件真的是个问题,你可能需要改变你的接口设计,避免使用标准程序库中“引发不受欢迎之#includes” 那一部分。
关于 “前置声明每一件东西” 的第二个 (同时也是比较重要的) 困难是,编译器必须在编译期间知道对象的大小。考虑这个:
int main()
{
int x; //定义一个int
Person p(params); //定义一个Person
...
} 当编译器看到 ×的定义式,它知道必须分配多少内存 (通常位于stack内)才够持有一个int。没问题,每个编译器都知道一个int 有多大。当编译器看到p的定义式,它也知道必须分配足够空间以放置一个Person,但它如何知道 一个Person对象有多大呢?编译器获得这项信息的唯一办法就是询问class定义式。然而如果class 定义式可以合法地不列出实现细目,编译器如何知道该分配多少空间?
此问题在Smalltalk,Java等语言上并不存在,因为当我们以那种语言定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用。也就是说它们将上述代码视同这样子
int x:
Person* p; 针对Person 我们可以这样做: 把Person 分割为两个 classes,一个只提供接口,另一个负责实现该接口。如果负责实现的那个所谓implementation class取名为PersonImpl, Person将定义如下:
#include <string> //标准程序库组件不该被前置声明。
#include <memory> //此乃为了tr1::shared_ptr而含入:详后。
class PersonImpl; //Person实现类的前置声明。
class Date; //Person接口用到的classes的前置声明。
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::tr1::shared_ptr<PersonImpl> pImpl; //指针,指向实现物:
}; 在这里,main class (Person)只内含一个指针成员 (这里使用tr1::shared_ptr,见条款13),指向其实现类(PersonImpl)。这般设计常被称为pimpl idiom(pimpl是"pointer to implementation” 的缩写)。这种classes内的指针名称往往就是pImpl,就像上面代码那样。
这样的设计之下,Person的客户就完全与Dates, Addresses 以及Persons 的实现细目分离了。那些classes的任何实现修改都不需要 Person 客户端重新编译。此外由于客户无法看到Person 的实现细目,也就不可能写出什么“取决于那些细目” 的代码。这真正是 “接口与实现分离” !
这个分离的关键在于以“声明的依存性“替换 “定义的依存性” ,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每一件事都源自于这个简单的设计策略:
- 如果使用 object references 或 object pointers 可以完成任务,就不要使用 objects 。你可以只靠一个类型声明式就定义出指向该类型的 references 和 pointers:但如果定义某类型的objects,就需要用到该类型的定义式
- 如果能够,尽量以class 声明式替换class 定义式。注意,当你声明一个函数而它用到某个class 时,你并不需要该class 的定义:纵使函数以by value方式传递该类型的参数 (或返回值)亦然
当然,pass-by-value 一般而言是个糟糕的主意(见条款20),但如果你发现因为某种因素被迫使用它,并不能够就此为“非必要之编译依存关系” 导入正当性。
声明 today 函数和 clearAppointments 函数而无需定义 Date,这种能力可能会令你惊讶,但它并不是真的那么神奇。 一旦任何人调用那些函数,调用之前Date 定义式一定得先曝光才行。那么或许你会纳闷,何必费心声明一个没人调用的函数呢?嗯,并非没人调用,而是并非每个人都调用。假设你有一个函数库内含数百个函数声明,不太可能每个客户叫遍每一个函数。如果能够将“提供class 定义式” (通过 #include 完成)的义务从“函数声明所在” 之头文件移转到“内含函数调用” 之客户文件,便可将“并非真正必要之类型定义” 与客户端之间的编译依存性去除掉。
- 为声明式和定义式提供不同的头文件。为了促进严守上述谁则,需要两个头文 件, 一个用于声明式, 一个用于定义式。当然,这些文件必须保持 一致性,如果 有 个 声 明 式 被 改 变 了 ,两 个 文 件 都 得 改 变 。因 此 程 序 库 客 户 应 该 总 是 # i n c l u d e 一个声明文件而非前置声明若 千函数,程序库作者也应该提供这两个头文件。 举个例子,Date的客户如果希望声明today 和clearAppointments,他们不该 像先前那样以手 工方式前置声明 Da t e ,而是应该 # i nc l ude 适当的、内含声明式 的 头文 件:
#include "datefwd.h" //这个头文件内声明(但未定义)classDate。 Date today( ); //同前。 void clearAppointments(Date d);只 含 声 明 式 的 那 个 头 文件 名 为 " d a t e f wd . h " ,命 名 方 式 取 法 C + + 标 准 程 序 库头文件(见条款54)的 siosfwd>。<iosfwds内含iostream各组件的声明式, 其对应定义则分布在若干不同的头文件内,包括<sstream>, <streamibuf>, <fstream> 和 <iostream>。
<iosfwd>深具启发意义的另一个原因是,它分外彰显“本条款适用于 templates 也适用于 non-templates”。虽然条款30 说过,在许多建置环境 (build environments)中template 定义式通常被置于头文件内,但也有某些建置环境允许template 定义式放在“非头文件” 内,这么一来就可以将“只含声明式” 的头文件提供给templates,<iosfwd>就是这样一份头文件。
C++ 也提供关键字export,允许将template声明式和template定义式分割于不同的文件内。不幸的是支持这个关键字的编译器目前非常少,因此现实中使用这个关键字的经验也非常少。目前若要评论export 在高效C++编程中扮演什么角色,恐怕言之过早
像Person这样使用pimpl idiomn的classes,往往被称为Handle classes。也许 你会纳闷,这样的classes 如何真正做点事情。办法之 一是将它们的所有函数转交给 相 应 的 实 现 类 ( i m p l e m e n t a t i o n c l a s s e s )并 由 后 者 完 成 实 际 工 作 。例 如 下面 是 P e r s o n 两个成员两数的实现
#include "Person.h"
#include "PersonImp1.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 pImp1->name ( ); 请注意,Person构造函数以new(见条款16)调用PersonImpl 构造雨数,以 及 Pe r son :: name 函数内调用 Pe r son Impl : : name 。这是重要的,让 Pe r son 变成 一
个 Handle class 并不会改变它做的事,只会改变它做事的方法。
另 一个制作Handle class 的办法是,令Per son 成为 一种特殊的abstract base class (抽象基类),称为Interface class。这种class 的目的是详细一一描述derived classes的接又(见条款34),因此它通常不带成员变量,也没有构造两数,只有 一
个 v i r t u a l 析 构 函 数 (见 条 款 7 ) 以 及 一 组 p u r e v i r t u a l 函 数 , 用 来 叙 述 整 个 接 又 。
Interface classes类似Java和 .NET的Interfaces,但C++的Interface classes 并不需要负担Java和 .NET的Interface所需负担的责任。举个例子,Java和 .NET 都 不 允 许 在 I n t e r f a c e s 内 实 现 成 员 变 量 或 成 员 函 数 ,但 C t + 不 禁 止 这 两 样 东 西 。C + + 这种更为巨大的弹性有其用途,因为 一如条款 36 所言, “non-virtual 两数的实現” 对继承体系内所有classes 都应该相同,所以将此等函数实现为Interface class (其 中写有相应声明)的一部分也是合理的。
一 个针对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 来撰写应用程序,因 为 它 不 可 能 针 对 “ 内 含 p u r e v i r t u a l 函 数 ” 的 p e r s o n c l a s s e s 具 现 出 实 体 。 (然 而 却 有可能对派生自Person 的classes具现出实体,详下。)就像Handleclasses的客 户一样,除非Interface class 的接又被修改否则其客户不需重新编译。
Interface cass 的客户必须有办法为这种class创建新对象。他们通常调用 一个 特殊函数,此西数扮演 “真正将被具现化” 的那个derivedclasses 的构造两数角色。 这样的西数通常称为factory (工厂)函数 (见条款 13)或virtual 构造两数。它们 返回指针 (或更为可取的智能指针,见条款 18),指向动态分配所得对象,而该对 象支持 Interface class 的接又。这样的函数又往往在Interface class 内被声明为
static:
class Person public:
s t a t i c s t d : : t r 1 : : s h a r e d p t r < P e r s o n > 1 / 返 回 一个 t r 1 : : s h a r e d p t r , 指 向 create (const std::string& name,
11一个新的Per son,并以给定之参数 const Date& birthday,
1/ 初始化。条款 18 告诉你
const Address& addr);
1/为什么返回的是trl::shared ptr
};
std::string name; Date dateOfBirth; Address address;
1/ 创建一个对象,支持Per son 接又 std::trl::sharedptr<Person>pp(Person::create(name, dateofBirth,
std::cout << pp->name ()
<< " was born on "
< pp-›birthDate ()
< " and now lives at " << pp-›address ( ); 当然,支持Interface class 接又的那个具象类 (concreteclasses)必须被定义出 来,而且真正的构造函数必须被调用。一切都在virtual 构造函数实现码所在的文件内秘密发生。假设Interfaceclass Person有个具象的derivedclassRealPerson,后者提供维承而来的virtual 函数的实现
class RealPerson: public Person 1 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; 有 了 R e a l P e r s o n 之 后 , 写 出 P e r s o n : : c r e a t e 就 真 的 一点 也 不 稀 育
std::tri::shared ptr<Person> Person::create(const std::strings name,
const Dated birthday, const Address‹ addr)
return
s t d : : t r i : :shared ptr<Person> (new RealPerson (name, birthday, addr) ); 一个更现实的 Pe r son: : c rea t e 实现代码会创建不同类型的derived class 对象, 取决于诸如额外参数值、读自文件或数据库的数据、环境变量等等。
R e a l P e r s o n 示 范 实 现 I n t e r f a c e c l a s s 的 两 个 最 常 见 机 制 之 一: 从 I n t e r f a c e c l a s s (person)继承接又规格,然后实现出接又所發盖的西数。Interface class 的第二 个实现法涉及多重樂承,那是条款 40 探索的 主题。
Handle classes 和Interface classes解除了接又和实现之间的稱合关系,从而降 低文件问的编译依存性 (compilation dependencies)。如果你是犬儒学派 (译注: 犬儒学派希望过 一种符合自然的简朴生活,摈弃 一切社会习俗和人为引导的种种欲 望),我知道你正等着我有义务给出的旁注。 “ 所有这些戏法得付出多少代价?” 你咕哝着。答案是计算器科学中通常需要付出的那些:它使你在运行期丧失若干速 度,又让你为每个对象超额付出若干内存。
在Handle classes身上,成员函数必须通过implementationpointer取得对象数 据。那会为每 一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加 implementation pointer 的大小。最后,implementationpointer 必须初始化(在Handle class 构造西数内),指向一个动态分配得来的implementation object,所以你将蒙 受因动态内存分配 及其后的释放动作〕而来的额外开销,以及遭遇bada11oc异 常 (内存不足)的可能性。
至于Interface classcs,由于每个西数都是virtual ,所以你必须为每次两数调用 付出一个问接跳跃(indirect jump)成本(见条款7)。此外Interface class派生的 对象必须内含 一个vptr (virtual table pointer,再次见条款7),这个指针可能会增 加存放对象所需的内存数量-— 实际取决于这个对象除 了Interface class 之外是否 还有其他virtual 函数来源。
最后,不论Handle classes 或Interface classes,一旦脱离inline 西数都无法有 太 大 作 为 。 条 款 3 0 解 释 过 为 什 么 函 数 本 体 为 了 被 i n l i n e d 必 须 (很 典 型 地 ) 置 于 头 文件内,但Handle classes 和Interface classes 正是特别被设计用来隐藏实现细节如
两数本体。
然而,如果只因为若干额外成本便不考虑Handle classes和Interface classes, 将是严重的错误。Virtual 函数不也带米成本吗?你并不会想要弃绝它们对不对? (如 果 是 的 话 , 那 你 读 错 书 了。 ) 你 应 该 考 虑 以 渐 进 方 式 使 用 这 些 技 术 。 在 程 序 发
展过程中使用Handle classes 和Interface classes 以求实现码有所变化时对其客户带 来最小冲击。而当它们导致速度和/或大小差异过于重大以至于classes 之间的耬合 相形之下不成为关键时,就以具象类(concrete classes)替换Handle classes和Interface classes.
支持“编译依存性最小化” 的一般构想是:相依于声明式,不要相依于定义式。 基 于此 构 想 的 两 个 手段 是 H a n d l e c l a s s e s 和 I n t e r f a c e c l a s s e s 。
• 程序库头文件应该以“完全且仅有声明式” (full anddeclaration-only forms) 的 形式存在。这种做法不论是否涉及templates 都适用。