条款01:视C++ 为一个语言联邦
View C++ as a federation of languages.
今天的C++ 已经是一个同时支持过程形式(procedural)、面向对象形式(object-oriented)、函数形式(functional)、泛型形式(generic)、元编程形式(metaprogramming)的语言。将C++ 视为一个由相关语言组成的联邦而非单一语言。
(1)C:说到底C++ 仍是以C为基础。C语言的局限:没有模板(templates),没有异常(exceptions),没有重载(overloading)……
(2)Object-Oriented C++:类、封装、继承、多态、virtual函数……等等
(3)Template C++:泛型编程(自己最没经验的部分)
(4)STL。
次语言的切换时,高校编程守则的策略可能不同:
内置(C-like)类型:传值优于传引用;在STL中亦是如此,因为迭代器和函数对象都是在C指针之上塑造出来的。
Object-Oriented C++:由于用户自定义构造函数和析构函数的存在,pass-by-reference-to-const更好;Template C++亦是如此。
· C++ 高效编程守则视状况而变化,取决于你使用C++ 的哪一部分。
条款02:尽量以const,enum, inline替换 #define
Prefer consts,enums, and inlines to #defines.
宁可以编译器替换预处理器,因为或许 #define不被视为语言的一部分。
#define ASPECT_RATIO 1.653
宏通常用大写名称标记,记号名称ASPECT_RATIO也许从未被编译器看见;也许在编译器开始处理源码之前它就被预处理器移走了。于是记号名称ASPECT_RATIO有可能没进入记号表(symbol table)内。当运用此常量但获得编译错误时,错误信息可能会提到1.653而不是ASPECT_RATIO;也可能ASPECT_RATIO在别的头文件里,为追踪带来麻烦。
解决之道:
const double AspectRatio = 1.653;
作为一个语言常量,AspectRatio肯定会被编译器看到,当然就会进入记号表内。此外,使用常量可能比使用#define导致较小量的码,因为预处理器"盲目地将宏名称ASPECT_RATIO替换为1.653"可能导致目标码出现多份1.653,而用常量AspectRatio绝不会出现相同情况。
class专属常量:为了将常量的作用域限制于class内,你必须让它成为class的一个成员;而为确保此常量至多只有一份实体,你必须让它成为一个static成员:
class CostEstimate {
private:
static const double FudgeFactor;//staticclass常量声明
... //位于头文件内
};
const double //static class常量定义
CostEstimate::FudgeFactor = 1.35;//位于实现文件内
我们无法利用#define创建一个class专属常量,因为#defines并不重视作用域。一旦宏被定义,它就在其后的编译过程中有效(除非在某处被#undef)。这意味#defines不仅不能够用来定义class专属常量,也不能够提供任何封装性,也就是说没有所谓private#define(概念上)这样的东西;而const成员变量是可以被封装的。
当class编译期间需要一个class常量值,如类的数组声明式中,编译器坚持必须在编译期间知道数组的大小,可采用“theenum hack”补偿做法。其理论基础是:"一个属于枚举类型(enumerated type)的数值可权充int被使用"。
class GamePlayer {
private:
enum { NumTurns = 5 }; //"the enum hack" - 令NumTurns成为5的一个记号名称.
int scores[NumTurns]; //这就没问题了.
...
};
enum hack的行为某方面说比较像 #define而不像const,例如取一个const的地址是合法的,但取一个enum的地址或#define的地址通常都不合法。如果你不想让别人获得一个pointer或reference指向你的某个整数常量,enum可以帮助你实现这个约束。enums和#defines一样绝不会导致非必要的内存分配。
· 对于单纯常量,最好以const对象或enums替换#defines。
另一个常见的#define误用情况是以它实现宏(macros)。宏看起来像函数,但不会招致函数调用带来的额外开销。
//以a和b的较大值调用f
#defineCALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
int a = 5, b = 0;
CALL_WITH_MAX(++a, b); //比较前a自增1次,比较后因返回a再次自增,a被累加二次!
CALL_WITH_MAX(++a, b+10); //比较前a自增1次,比较后因返回b,a只被累加一次
记住为宏中的所有实参加上小括号;但以上例子所带来的麻烦是“a的递增次数竟取决于它被拿来和谁比较”。
用template inline函数可以获得宏带来的效率以及一般函数的所有可预料行为和类型安全性(type safety):
template<typenameT>
inlinevoid callWithMax(const T& a, const T& b) //template c++,采用pass by reference-to-const.
{
f(a > b ? a : b);
}
这个template产出一整群函数,每个函数都接受两个同型对象,并以其中较大者调用f。这里不需要在函数本体中为参数加上括号,也不需要操心参数被核算(求值)多次……等等。此外由于callWithMax是个真正的函数,它遵守作用域和访问规则。例如你绝对可以写出一个"class内的private inline函数"。一般而言宏无法完成此事。
有了consts、enums和inlines,我们对预处理器(特别是#define)的需求降低了,但并非完全消除。#include仍然是必需品,而#ifdef/#ifndef也继续扮演控制编译的重要角色。
条款03:尽可能使用const
Use const whenever possible.
const允许你告诉编译器和其他程序员某值应该保持不变。
如果关键字const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在星号两边,表示被指物和指针两者都是常量。
1. char greeting[] = "Hello";
2. char* p = greeting; //non-const pointer, non-const data
3. const char* p = greeting; //non-const pointer, const data
4. char* const p = greeting; //const pointer, non-const data
5. const char* const p = greeting; //const pointer, const data
如果被指物是常量,既可以关键字const写在类型之前,又可以把它写在类型之后、星号之前。两种写法的意义等价:
1. void f1(const Widget* pw); //f1获得一个指针,指向一个常量Widget对象..
2. void f2(Widget const * pw); //f2也是
STL迭代器系以指针为底层塑模出来,所以迭代器的作用就像个T*指针。声明迭代器为const就像声明指针为const一样(即声明一个T* const 指针),表示这个迭代器不得指向不同的东西,但它所指的东西的值是可以改动的。如果希望迭代器所指的东西(数据)不可被改动(即希望STL模拟一个const T* 指针),则用const_iterator:
1. std::vector<int> vec;
2. ...
3. const std::vector<int>::iterator iter = vec.begin( ); //T* const
4. *iter = 10; //没问题,改变iter所指物
5. ++iter; //错误!iter是const
6. std::vector<int>::const_iterator cIter = vec.begin( );// const T*
7. *cIter = 10; //错误! *cIter是const
8. ++cIter; //没问题,改变cIter。
令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。
1. class Rational { ... };
2. const Rational operator* (const Rational& lhs,const Rational& rhs);
为什么返回一个const对象?原因是如果不这样客户就能实现这样的行为:
1. Rational a, b, c;
2. ...
3. (a * b) = c; //在a * b的成果上调用operator=
如果a和b都是内置类型,这样的代码直截了当就是不合法。而一个"良好的用户自定义类型"的特征是它们避免无端地与内置类型不兼容。将operator* 的回传值声明为const可以预防那个荒唐的赋值动作。
const成员函数
将const实施于成员函数的目的,是为了确认该成员函数可作用于const对象身上。这类成员函数可以得知哪个函数可以改动对象内容而哪个函数不行,很是重要。
两个成员函数如果只是常量性不同,可以被重载。这实在是一个重要的C++特性(前几天的面试刚碰到过):
1. class TextBlock {
2. public:
3. ...
4. const char& operator[](std::size_t position) const
5. { return text[position]; } // operator[] for const对象.
6. char& operator[](std::size_t position)
7. { return text[position]; } // operator[] for non-const对象.
8. private:
9. std::string text;
10. };
11.
12. TextBlock tb("Hello");
13. std::cout << tb[0]; //调用non-const TextBlock::operator[]
14. const TextBlock ctb("World");
15. std::cout << ctb[0]; //调用const TextBlock::operator[]
只要重载operator[]并对不同的版本给予不同的返回类型,就可以令const和non-const TextBlocks获得不同的处理:
1. std::cout << tb[0];//没问题 - 读一个non-const TextBlock
2. tb[0] = 'x'; //没问题 - 写一个non-const TextBlock
3. std::cout << ctb[0];//没问题 - 读一个const TextBlock
4. ctb[0] = 'x'; //错误! - 写一个const TextBlock
上述错误只因operator[] 的返回类型以致,至于operator[] 调用动作自身没问题。
请注意,non-const operator[] 的返回类型是个reference tochar,不是char。如果operator[]只是返回一个char,下面这样的句子就无法通过编译:
1. tb[0] = 'x';
返回类型是内置类型的函数,改动函数返回值不合法。纵使合法,C++以值传递意味被改动的其实是tb.text[0]的一个副本,不是tb.text[0]自身。
l 将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
成员函数如果是const意味什么?
bitwise const:成员函数只有在不更改对象之任何成员变量(static除外)时才可以说是const,也就是说它不更改对象内的任何一个bit。bitwise constness正是C++ 对常量性的定义,因此const成员函数不可以更改对象内任何non-static成员变量。
不幸的是许多成员函数虽然不十足具备const性质却能通过bitwise测试:
1. class CTextBlock {
2. public:
3. ...
4. char& operator[](std::size_t position) const // bitwise const声明,
5. { return pText[position]; } // 但其实不适当.跟之前相比少了一个const!
6. private:
7. char* pText;
8. };
operator[]实现代码并不更改私有变量pText,于是编译器为operator[]产出目标码,并认定它是bitwiseconst。
1. const CTextBlock cctb("Hello");//声明一个常量对象。
2. char* pc = &cctb[0];//调用const operator[]取得一个指针, 指向cctb的数据。
3. *pc = 'J'; //cctb现在有了 "Jello" 这样的内容。
以上代码没有任何错误:创建一个常量对象并设以某值,而且只对它调用const成员函数。但终究还是改变了它的值。
这种情况导出所谓的logical constness:一个const成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此,也就是自己可以改用户不能改。
1. class CTextBlock {
2. public:
3. ...
4. std::size_t length() const;
5. private:
6. char* pText;
7. std::size_t textLength; //最近一次计算的文本区块长度。
8. bool lengthIsValid; //目前的长度是否有效。
9. };
10. std::size_t CTextBlock::length() const
11. {
12. if (!lengthIsValid) {
13. textLength = std::strlen(pText);
14. //错误!在const成员函数内不能对私有变量textLength进行修改
15. lengthIsValid = true;
16. //错误!在const成员函数内不能对私有变量lengthIsValid进行修改
17. }
18. return textLength; }
解决办法很简单:利用C++ 中的mutable(可变的)修饰符,mutable释放掉non-static成员变量的bitwiseconstness约束:
1. class CTextBlock {
2. public:
3. ...
4. std::size_t length() const;
5. private:
6. char* pText;
7. mutable std::size_t textLength; //这些成员变量可能总是会被更改,
8. mutable bool lengthIsValid; //即使在const成员函数内。
9. }; //现在,刚才的length函数就可以了~
l 编译器强制实施bitwise constness,但你编写程序时应该使用"概念上的常量性"(conceptual constness)。
在const和non-const成员函数中避免重复
对于"bitwise-constness非我所欲"的问题,mutable是个解决办法,但它不能解决所有的const相关难题。举个例子,假设TextBlock(和 CTextBlock)内的operator[] 不单只是返回一个reference指向某字符,也执行边界检验(boundschecking)、志记访问信息(loggedaccess info.)、甚至可能进行数据完善性检验。把所有这些同时放进const和non-const operator[] 中,导致两个版本的operator[]及大量的代码重复。
真正该做的是实现operator[]的机能一次并使用它两次,也就是说,令其中一个调用另一个。本例中constoperator[]完全做掉了non-const版本该做的一切,唯一的不同是其返回类型多了一个const资格修饰。
1. class TextBlock {
2. public:
3. ...
4. const char& operator[](std::size_t position) const //一如既往
5. {
6. ...
7. return text[position];
8. }
9. char& operator[](std::size_t position) //现在只调用const op[]
10. {
11. return
12. const_cast<char&>( //将op[]返回值的const转除
13. static_cast<const TextBlock&>(*this)//为*this加上const
14. [position] //调用const op[]
15. );
16. }
17. ...
18. };
这里共有两次转型:第一次用来为 *this添加const(这使接下来调用operator[]时得以调用const版本),第二次则是从constoperator[]的返回值中移除const。
添加const的那一次转型强迫进行了一次安全转型(将non-const对象转为const对象),所以我们使用static_cast。移除const的那个动作只可以藉由const_cast完成,没有其他选择。
简单来说就是non-const版本为了调用const版本先转换常量性,应用const版本功能完毕后,为了符合non-const的返回值,再去除常量性。
const成员函数承诺绝不改变其对象的逻辑状态(logicalstate),non-const成员函数却没有这般承诺。如果在const函数内调用non-const函数,就是冒了这样的风险:你曾经承诺不改动的那个对象被改动了。这就是为什么"const成员函数调用non-const成员函数"是一种错误行为:因为对象有可能因此被改动。
l 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。
条款04:确定对象被使用前已先被初始化
Make sure that objects are initializedbefore they're used.
关于"将对象初始化"这事,C++ 似乎反复无常。在某些语境下内置类型和类的成员变量保证被初始化,但在其他语境中却不保证。
读取未初始化的值会导致不明确的行为。它可能让你的程序终止运行,可能污染了正在进行读取动作的那个对象,可能导致不可测知的程序行为,以及许多令人不愉快的调试过程。
最佳处理办法就是:永远在使用对象之前先将它初始化。无论是对于内置类型、指针还是读取输入流,你必须手工完成此事。
l 为内置型对象进行手工初始化,因为C++不保证初始化它们。
内置类型以外的任何其他东西,初始化则由构造函数完成,确保每一个构造函数都将对象的每一个成员初始化。
这个规则很容易奉行,重要的是别混淆了赋值和初始化。考虑一个用来表现通讯簿的class,其构造函数如下:
1. class PhoneNumber { ... };
2. class ABEntry { //ABEntry = "Address Book Entry"
3. public:
4. ABEntry(const std::string& name, const std::string& address,
5. const std::list<PhoneNumber>& phones);
6. private:
7. std::string theName;
8. std::string theAddress;
9. std::list<PhoneNumber> thePhones;
10. int numTimesConsulted;
11. };
12. ABEntry::ABEntry(const std::string& name, const std::string& address,
13. const std::list<PhoneNumber>& phones)
14. {
15. theName = name; //这些都是赋值(assignments),
16. theAddress = address;//而非初始化(initializations)。
17. thePhones = phones;
18. numTimesConsulted = 0;
19. }
这会导致ABEntry对象带有你期望(你指定)的值,但不是最佳做法。C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。在ABEntry构造函数内,theName, theAddress和thePhones都不是被初始化,而是被赋值。初始化的发生时间更早,发生于这些成员的default构造函数被自动调用之时(比进入ABEntry构造函数本体的时间更早)。
使用所谓的member initialization list(成员初值列)替换赋值动作会更好:
1. ABEntry::ABEntry(const std::string& name, const std::string& address,
2. const std::list<PhoneNumber>& phones)
3. :theName(name),
4. theAddress(address), //现在,这些都是初始化(initializations)
5. thePhones(phones),
6. numTimesConsulted(0)
7. { } //现在,构造函数本体不必有任何动作
这个构造函数和上一个的最终结果相同,但通常效率较高。对大多数类型而言,比起先调用default构造函数然后再调用copy assignment操作符,单只调用一次copy构造函数是比较高效的,有时甚至高效得多。对于内置型对象如numTimesConsulted,其初始化和赋值的成本相同,但为了一致性最好也通过成员初值列来初始化。同样道理,甚至当你想要default构造一个成员变量,你都可以使用成员初值列。假设ABEntry有一个无参数构造函数,我们可将它实现如下:
1. ABEntry::ABEntry( )
2. :theName(), //调用theName的default构造函数;
3. theAddress(), //为theAddress做类似动作;
4. thePhones(), //为thePhones做类似动作;
5. numTimesConsulted(0)//记得将numTimesConsulted显式初始化为0
6. { }
请立下一个规则,规定总是在初值列中列出所有成员变量,并总是使用成员初值列。
C++ 有着十分固定的"成员初始化次序",base classes早于其derived classes,而class的成员变量总是以其声明次序被初始化。回头看看ABEntry,其theName成员永远最先被初始化,然后是theAddress,再来是thePhones,最后是numTimesConsulted,即使它们在成员初值列中以不同的次序出现。为避免某些可能存在的晦涩错误(两个成员变量的初始化带有次序性,如初始化array时需要指定大小,因此代表大小的那个成员变量必须先有初值),当你在成员初值列中条列各个成员时,最好总是以其声明次序为次序。
l 构造函数最好使用成员初值列(memberinitialization list),而不要在构造函数本体内使用赋值操作(assignment)。初值列列出的成员变量,其排列次序应该和它们在class中的声明次序相同。
不同编译单元内定义之non-local static对象的初始化次序
static对象:函数内的static对象称为localstatic对象,其他static对象称为non-localstatic对象。程序结束时static对象会被自动销毁,也就是它们的析构函数会在main()结束时被自动调用。
编译单元(translation unit):产出单一目标文件(single object file)的那些源码,基本上它是单一源码文件加上其所含入的头文件(#include files)。
真正的问题是:如果某编译单元内的某个non-localstatic对象的初始化动作使用了另一编译单元内的某个non-local static对象,它所用到的这个对象可能尚未被初始化,因为C++ 对"定义于不同编译单元内的non-local static对象"的初始化次序并无明确定义。
假设你有一个FileSystem class,它让互联网上的文件看起来好像位于本机(local)。由于这个class使世界看起来像个单一文件系统,你可能会产出一个特殊对象,位于global或namespace作用域内,象征单一文件系统:
1. class FileSystem { //来自你的程序库
2. public:
3. ...
4. std::size_t numDisks() const;//众多成员函数之一
5. ...
6. };
7. extern FileSystem tfs; //预备给客户使用的对象,tfs代表"the file system"
现在假设某些客户建立了一个class用以处理文件系统内的目录(directories)。很自然他们的class会用上theFileSystem对象:
1. class Directory { //由程序库客户建立
2. public:
3. Directory( params );
4. ...
5. };
6. Directory::Directory( params )
7. {
8. ...
9. std::size_t disks = tfs.numDisks();//使用tfs对象
10. ...
11. }
进一步假设,这些客户决定创建一个Directory对象,用来放置临时文件:
1. Directory tempDir( params ); //为临时文件而做出的目录
除非tfs在tempDir之前先被初始化,否则tempDir的构造函数会用到尚未初始化的tfs。但tfs和tempDir是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元内的non-local static对象。
C++ 对"定义于不同的编译单元内的non-localstatic对象"的初始化相对次序并无明确定义。这是有原因的:决定它们的初始化次序相当困难,非常困难,根本无解。
一个小小的设计便可完全消除这个问题:将每个non-localstatic对象搬到自己的专属函数内,并将该对象在此函数内被声明为static,这些函数返回一个reference指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。这是Singleton模式的一个常见实现手法。
C++ 保证,函数内的local static对象会在该函数被调用期间首次遇上该对象之定义式时被初始化。如果你从未调用non-local static对象的"仿真函数",就绝不会引发构造和析构成本!
以此技术施行于tfs和tempDir身上,结果如下:
1. class FileSystem { ... }; //同前
2. FileSystem& tfs() //这个函数用来替换tfs对象;它在
3. { //FileSystem class中可能是个static。
4. static FileSystem fs; //定义并初始化一个local static对象,
5. return fs; //返回一个reference指向上述对象。
6. }
7. class Directory { ... }; //同前
8. Directory::Directory( params )//同前,但原本的reference to tfs
9. { //现在改为tfs()
10. ...
11. std::size_t disks = tfs().numDisks( );
12. ...
13. }
14. Directory& tempDir() //这个函数用来替换tempDir对象;
15. { //它在Directory class中可能是个static。
16. static Directory td; //定义并初始化local static对象,
17. return td; //返回一个reference指向上述对象。
18. }
这么修改之后,这个系统程序的客户唯一不同的是他们现在使用tfs()和tempDir()而不再是tfs和tempDir,也就是说他们使用函数返回的"指向static对象"的references,而不再使用static对象自身。这些函数内含static对象的事实使它们在多线程系统中带有不确定性。
l 为免除"跨编译单元之初始化次序"问题,请以local static对象替换non-local static对象。
条款05:了解C++默默编写并调用哪些函数
Knowing what functions C++ silentlywrites and calls
一个 emptyclass(空类)什么时候将不再是 empty class(空类)?
答案是当 C++ 处理过它之后。如果你自己不声明一个拷贝构造函数,一个 copyassignment运算符和一个析构函数,编译器就会声明一个它自己的版本。此外,如果你没有构造函数,编译器就会为你声明一个缺省构造函数,所有这些函数都被声明为 public 和 inline。因此:
class Empty{};
在本质上和你这样写是一样的:
class Empty {
public:
Empty() { ... } // default constructor
Empty(const Empty& rhs) { ... } // copy constructor
~Empty() { ... } // destructor
Empty& operator=(const Empty& rhs) {... } // copy assignmentoperator
};
这些函数只有在它们被调用的时候编译器才会创建。下面的代码会促使每一个函数生成:
Empty e1; // default constructor & destructor
Empty e2(e1); // copy constructor
e2 = e1; // copy assignment operator
缺省构造函数和析构函数主要是给编译器一个地方放置 “幕后代码”的,如调用基类和非静态数据成员的构造函数和析构函数。注意,生成的析构函数是non-virtual的,除非它所在的类是从一个基类继承而来,而基类自己声明了一个 virtual destructor虚拟析构函数(这种情况下,函数的virtualness(虚拟性)来自基类)。对于拷贝构造函数和拷贝赋值运算符,编译器生成版本只是简单地将来源对象的每一个non-static成员对象拷贝至目标对象。
template<typename T>
public:
NamedObject(const char *name, const T& value);
NamedObject(const std::string& name, const T& value);
...
private:
std::string nameValue;
T objectValue;
};
如果你已经为一个要求实参的类设计了构造函数,你就无需担心编译器会再添加一个default构造函数而遮掉你的版本。NamedObject 既没有声明拷贝构造函数也没有声明拷贝赋值运算符,所以编译器将生成这些函数(如果它们被调用的话):
NamedObject<int> no1("SmallestPrime Number", 2);
NamedObject<int> no2(no1); // calls copy constructor
编译器生成的拷贝构造函数会用 no1.nameValue 和 no1.objectValue 分别初始化 no2.nameValue 和 no2.objectValue。 nameValue 的类型是 string,标准 string 类型有一个拷贝构造函数,所以将以 no1.nameValue 作为参数调用 string 的拷贝构造函数初始化 no2.nameValue。而另一方面,NamedObject<int>::objectValue 的类型是 int(在这个模板实例化中 T 是 int),而 int 是 内置类型,所以将通过拷贝 no1.objectValue 的每一个bits初始化 no2.objectValue。编译器为 NamedObject<int> 生成的拷贝赋值运算符本质上与拷贝构造函数有同样的行为。
例如,假设 NamedObject 如下定义,nameValue 是一个 reference to string,而 objectValue 是一个const T:
template<class T>
class NamedObject {
public:
//以下构造函数不再接受const名称,因为nameValue如今是个//reference-to-non-const string
NamedObject(std::string& name, const T& value);
...// 如前,假设并未声明operator=
private:
std::string& nameValue; // 这里是reference
const T objectValue; // 这里是const
};
现在,考虑下面会发生什么:
std::string newDog("Persephone");
std::string oldDog("Satch");
NamedObject<int> p(newDog, 2);
NamedObject<int> s(oldDog, 36);
p = s; // 现在p的成员变量会发生什么?
注意nameValue ,C++ 并没有提供使一个reference引用指向另一个对象的方法。此时C++ 拒绝编译那一行赋值代码。如果你希望一个包含引用成员的类支持赋值赋值,你必须自己定义拷贝赋值运算符。对于含有const 成员的类,编译器会有类似的行为(就像本例中的 objectValue)。更改 const 成员是不合法的,所以编译器隐式生成的赋值函数无法确定该如何处理。
最后还有一种情况,如果基类的拷贝赋值运算符声明为 private,编译器拒绝为从它继承的派生类生成隐式拷贝赋值运算符。毕竟,编译器为派生类生成的拷贝赋值运算符想象中可以处理其 baseclass 成分,但它们当然无法调用那些派生类无权调用的成员函数。
· 编译器可以隐式生成一个 class(类)的 default constructor(缺省构造函数),copy constructor(拷贝构造函数),copy assignmentoperator(拷贝赋值运算符)和 destructor(析构函数)。
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
Explicitly disallow the use ofcompiler-generated functions you do not want
房地产代理商出售房屋,服务于这样的代理商的软件系统自然要有一个类来表示被出售的房屋.每一件房产都是独特的,因此最好让类似这种企图拷贝 HomeForSale对象的行为不能通过编译:
class HomeForSale { ... };
HomeForSale h1;
HomeForSale h2;
HomeForSale h3(h1); // 企图拷贝h1,不该通过编译
h1 = h2; //企图拷贝h2,不该通过编译
阻止这一类代码的编译并非那么简单。因为如果你不声明拷贝构造函数和拷贝赋值运算符,而有人又想调用它们,编译器就会替你声明它们。另一方面,如果你声明了这些函数,你的类依然会支持拷贝,而我们此时的目的却是防止拷贝!
解决这个问题的关键是所有的编译器生成的函数都是 public的。为了防止生成这些函数,你必须自己声明它们,但是没有理由把它们声明为public的。相反,应该将拷贝构造函数和拷贝赋值运算符声明为private的。通过明确声明一个成员函数,可以防止编译器生成它自己的版本,而且将这个函数声明为 private的,可以成功防止别人调用它。
声明成员函数为 private 却故意不去实现它确实很好,在 C++ 的iostreams 库里,就有几个类用此方法防止拷贝。比如,标准库的实现中 ios_base,basic_ios 和 sentry,其拷贝构造函数和拷贝赋值运算符被声明为 private 而且没有被定义。
将这个窍门用到HomeForSale 上,很简单:
class HomeForSale {
public:
...
private:
...
HomeForSale(const HomeForSale&); // 只有声明
HomeForSale& operator=(constHomeForSale&);
};
你会注意到,我省略了函数参数的名称。参数名称并非必要,只不过大家总是习惯写出来。毕竟,函数不会被实现,更少会被用到,有什么必要指定参数名称呢?有了上述类定义,编译器将阻止客户拷贝 HomeForSale objects对象的企图,如果你不小心在成员函数或友元函数中这样做,连接器会提出抗议。
将连接时错误提前到编译时间也是可行的(早发现错误毕竟比晚发现好)。在一个为 prevent防止拷贝而特意设计的基类中,声明拷贝构造函数和拷贝赋值操作符为private就可办到。这个基类本身非常简单:
class Uncopyable {
protected:
Uncopyable() {} // 允许derived对象构造和析构
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&); // 但阻止copying
Uncopyable& operator=(const Uncopyable&);
};
为了阻止HomeForSale对象被拷贝,我们唯一需要做的就是继承Uncopyable:
class HomeForSale: private Uncopyable {
... // class不再声明copy构造函数或copy赋值操作符
};
Uncopyable 的实现和使用包含一些微妙之处,比如,从 Uncopyable 继承不必是 public的,而且 Uncopyable 的析构函数不必是virtual的。因为 Uncopyable 不包含数据,所以它符合emptybase class optimization的条件,但因为它总是扮演基类,因此使用这项技术可能导致多重继承。通常,你可以忽略这些微妙之处,而且仅仅像此处演示的这样来使用 Uncopyable。你还可以使用在 Boost里的noncopyable类。
· 为了拒绝编译器自动提供的机能,将相应的member functions(成员函数)声明为 private,而且不要给出 implementations(实现)。使用一个类似 Uncopyable 的 base class(基类)是方法之一。
条款07:为多态基类声明virtual析构函数
Declare destructors virtual inpolymorphic base classes
建立一个 TimeKeeper基类,并为不同的计时方法建立派生类:
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
class AtomicClock: public TimeKeeper { ... };//原子钟
class WaterClock: public TimeKeeper { ... };//水钟
class WristWatch: public TimeKeeper { ... };//腕表
TimeKeeper* getTimeKeeper();
//返回一个指针,指向一个TimeKeeper派生类的动态分配对象
TimeKeeper *ptk = getTimeKeeper(); //从TimeKeeper继承体系获得一个动态分配对象
... //运用它
delete ptk; //释放它,避免资源泄漏
很多客户只是想简单地取得时间而不关心如何计算的细节,所以一个 factoryfunction(工厂函数)——返回一个指向新建派生类对象的基类指针的函数——可以被用来返回一个指向计时对象的指针。与工厂函数的惯例一致,getTimeKeeper 返回的对象建立在堆上的,所以为了避免泄漏内存和其它资源,每一个返回的对象被适当delete掉是很重要的。
C++ 规定:当一个派生类对象通过使用一个指向non-virtual析构函数的基类的指针被删除时,则这个对象的派生部分没被销毁。如果 getTimeKeeper 返回一个指向 AtomicClock对象的指针,则对象的 AtomicClock 部分(也就是在 AtomicClock class中声明的数据成员)很可能不会被析构,AtomicClock 的析构函数也不会运行。然而,基类部分(也就是 TimeKeeper 部分)很可能已被析构,这就导致了一个诡异的局部销毁对象,导致泄漏资源。
消除这个问题很简单:给基类一个 virtual析构函数。于是,删除一个派生类对象的时候就将析构整个对象,包括所以的派生类成分。
类似 TimeKeeper 的基类一般都包含除了析构函数以外的其它virtual函数,因为virtual函数的目的就是允许派生类实现的定制化。例如,TimeKeeper 可以有一个virtual函数getCurrentTime,它在各种不同的派生类中有不同的实现。任何类只要带有virtual函数都几乎确定应该也有一个virtual析构函数。
如果一个类不包含virtual函数,这通常预示不打算将它作为基类使用。当一个类不打算作为基类时,令其析构函数为virtual通常是个坏主意。考虑一个表现二维空间中的点类:
class Point { // a 2D point
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x, y;
};
如果一个 int 占用 32 bits,一个 Point 对象 正好适用于 64-bit 缓存器。而且,这样一个 Point 对象 可以被作为一个 64-bit 量传递给其它语言写的函数,比如 C 或者FORTRAN。而当Point 的析构函数为virtual时,要表现出virtual函数,对象必须携带额外的信息,用于在运行时确定该对象应该调用哪一个virtual虚拟函数。这一信息通常由被称为 vptr ("virtual table pointer")的指针指出,vptr 指向一个被称为 vtbl("virtual table")的函数指针数组;每一个带有 virtual函数的类都有一个相关联的 vtbl。当在一个对象上调用 virtual函数时,实际的被调用函数通过下面的步骤确定:找到对象vptr 指向的 vtbl,然后在 vtbl 中寻找合适的函数指针。
如果 Point类 包含一个 virtual函数,会为 Point 加上 vptr,将会使对象大小增长 50-100%! Point对象不再适合64-bit 寄存器。而且,Point对象在 C++ 和其它语言(比如 C)中不再具有相同的结构,因为其它语言中的对应物没有 vptr。结果,Points 不再可能传入其它语言写成的函数或从其中传出,并失去可移植性。
无故地将所有析构函数声明为 virtual,和从不把它们声明为 virtual一样是错误的。实际上,很多人总结过这条规则:当且仅当一个类中包含至少一个虚拟函数时,则在类中声明一个虚拟析构函数。
· 多态基类应该声明virtual析构函数。如果一个类带有任何 virtual函数,它就应该有一个virtual析构函数。
即使完全没有virtual函数,也有可能纠缠于 non-virtual析构函数问题。例如,标准 string 类型不包含 virtual函数,但是程序员有时将它当作基类使用:
class SpecialString: public std::string {
... //bad idea!std::string有个non-virtual析构函数
};
如果在程序中将一个指向 SpecialString 的指针转型为一个指向 string 的指针,然后delete 那个string指针,将导致内存泄漏,行为不明确:
SpecialString *pss = newSpecialString("Impending Doom");
std::string *ps;
...
ps = pss; // SpecialString* => std::string*
...
delete ps; /*未有定义!现实中*ps的SpecialString资源会泄漏,因为SpecialString析构函数未被调用。*/
不要企图继承标准容器(例如,vector,list,set,tr1::unordered_map)或任何其他“带有non-virtual析构函数”的类。C++ 不提供类似 Java 的 final classes或 C# 的 sealed classes那样的禁止派生机制。
有时候,给一个类提供一个 pure virtual析构函数能提供一些便利。pure virtualfunctions函数导致抽象类,也就是说你不能创建这个类型的对象。然而有时候你希望类是抽象的,但没有任何 pure virtual函数。怎么办呢?
解决方案很简单:在你想要变成抽象的类中声明一个 pure virtual析构函数:
class AWOV { // AWOV = "Abstract w/oVirtuals"
public:
virtual ~AWOV() = 0; // declare pure virtual destructor
};
这个类有一个 purevirtual函数,所以它是抽象的,又因为它有一个 virtual析构函数,所以你不必担心析构函数问题。然而,你必须为 purevirtual析构函数提供一个定义:
AWOV::~AWOV() {} // pure virtual析构函数的定义
析构函数的工作方式是:最深层派生的那个类其析构函数最先被调用,然后调用其每一个基类)的析构函数。编译器会生成一个从其派生类的析构函数对 ~AWOV 的调用动作,所以你不得不为这个函数提供一份定义,不然连接器会发出抱怨。
为基类提供virtual析构函数的规则仅仅适用于 polymorphic(带多态性质的)基类上。这种基类的设计目的就是为了用来“通过基类接口处理派生类对象”。TimeKeeper 就是一个多态基类,因为即使我们只有类型为 TimeKeeper 的指针指向它们时,也期望能够操作 AtomicClock 和 WaterClock对象。
并非所有的基类的设计目的都是为了多态用途。例如,无论是标准 string还是 STL容器都不被设计成基类使用,更别提多态了。某些类虽然被设计用于基类,但并非用于多态用途。如Uncopyable 和标准库中的 input_iterator_tag,它们并非被设计用来“经由基类接口处理派生类对象”,因此不需要virtual析构函数。
· 不是设计用来作为基类或为了具备多态性的类,就不应该声明 virtual析构函数
条款08:别让异常逃离析构函数
Prevent exceptions from leavingdestructors
C++ 不禁止但不鼓励从析构函数引发异常。考虑:
class Widget {
public:
...
~Widget() { ... } // 假设这里可能吐出一个异常
};
void doSomething()
{
std::vector<Widget> v;
...
} // v在这里被自动销毁
当 vector v 被析构时,它有责任析构它包含的所有 Widgets。但假设在那些调用期间,先后有两个Widgets抛出异常,对于 C++ 来说,这太多了。在两个异常同时存在的情况下,程序若不是结束执行就是导致不明确行为。在本例中将导致不明确行为,使用标准库的任何其他容器(如list,set)或TR1的任何容器甚至array,也会出现相同情况。C++ 不喜欢析构函数吐出异常。
如果你的析构函数需要执行一个可能失败而抛出一个异常的操作,该怎么办呢?假设使用一个class负责数据库连接,为了确保客户不会忘记在 DBconnection对象上调用 close(),一个合理的想法是创建一个用来管理DBConnection资源的类,并在其析构函数中调用close:
class DBConn { // 这个类用来管理DBConnection对象
public: // objects
...
~DBConn() // 确保数据库连接总是会被关闭
{ db.close();}
private:
DBConnection db;
};
它允许客户像这样编程:
{ // 打开一个区块(block)
DBConn dbc(DBConnection::create());
// 建立DBConnection并交给DBConn对象以便管理
... // 通过DBConn的接口使用DBConnection对象
} //在区块结束点,DBConn对象被销毁,因而自动为DBConnection对象调用close
只要调用 close 成功,一切都美好。但是如果这个调用导致一个异常,DBConn 的析构函数将传播那个异常,也就是允许它离开析构函数。这就产生了问题,因为析构函数抛出了一个烫手的山芋。
有两个主要的方法避免这个麻烦。
· Terminatethe program:如果 close 抛出异常就终止程序,一般是通过调用 abort。
DBConn::~DBConn()
{
try { db.close(); }
catch (...) {
制作运转记录,记下对close的调用失败;
std::abort();
}
}
它有一个好处是:阻止异常从析构函数中传播出去(那会导致不明确的行为)。也就是说,调用 abort 可以预先制“不明确行为”于死地。
· Swallowthe exception:吞下因调用close而发生的异常。在此例中将在第一种方法下去掉abort那句语句。
通常,将异常吞掉是个坏主意,因为它隐瞒了“某些动作失败”的重要信息!然而,有些时候,吞下异常比冒程序过早终止或不明确行为的风险更可取。程序必须能够在遭遇到一个错误并忽略之后还能继续可靠地运行,这才能成为一个可行的选择。
· 析构函数应该永不引发异常。如果析构函数调用了可能抛出异常的函数,析构函数应该捕捉所有异常,然后不传播它们或者终止程序。
以上方法的问题都在于两者无法对引起 close 抛出异常的情况做出回应。
一个更好的策略是重新设计 DBConn 的接口,以使客户有机会对可能发生的问题做出回应。
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 的析构函数中仍内含一个“双保险调用”)。如果某个操作可能在失败时抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。这是因为析构函数)引发异常是危险的,永远都要冒着程序过早终止或 不明确行为的风险。在本例中,让客户自己调用 close 并不是强加给他们的负担,而是给他们一个处理错误的机会。他们可以忽略它,依靠 DBConn 的析构函数去调用 close。如果真有错误发生,close的确抛出异常而且DBConn吞下该异常或结束程序,客户没有立场抱怨,毕竟他们曾有机会第一手处理问题,而他们选择了放弃。
· 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(非析构函数)执行该操作。
条款09:绝不在构造和析构过程中调用virtual函数
Never call virtual functions duringconstruction or destruction
先概述重点:你不应该在构造或析构期间调用 virtual函数,因为这样的调用不会如你想象那样工作,而且会让你很郁闷。作为 Java 或 C# 程序员,也要更加注意本条款,因为这是C++与它们不相同的一个地方。
假设你有一套模拟股票交易的类继承体系,例如,购入、出售订单等。这样的交易一定要经过审计,所以每一个交易对象被创建,在一个审查日志中就需要创建一个相应的条目。下面是一个看起来似乎合理的解决问题的方法:
class Transaction { // 所有交易的基类
public:
Transaction();
virtual void logTransaction() const = 0; // 做出一份因类型不同而不同的日志记录
...
};
Transaction::Transaction() // 基类构造函数之实现
{
...
logTransaction(); // 最后动作是志记这笔交易
}
class BuyTransaction: public Transaction { //derived class
public:
virtual void logTransaction() const;
...
};
class SellTransaction: public Transaction {// derived class
public:
virtual void logTransaction() const;
...
};
考虑执行这行代码时会发生什么:
BuyTransaction b;
很明显一个 BuyTransaction 的构造函数会被调用,但是首先,一个 Transaction 的 构造函数必须先被调用,派生类对象中的基类成分先于派生类自身成分被构造之前构造。Transaction 的构造函数的最后一行调用 virtual函数 logTransaction,,被调用的 logTransaction 版本是在 Transaction 中的那一个,而不是 BuyTransaction 中的那一个,即使被创建的对象类型是 BuyTransaction。基类构造期间,virtual函数从来不会向下匹配到派生类。
更根本的原因:在一个派生类对象的基类构造期间,对象的类型是基类,而不是派生类。不仅 virtual函数会解析到基类,而且若使用到 runtime type information(运行时类型信息)的语言构件(例如,dynamic_cast和 typeid),也会将那个对象视为基类类型。本例中,当 Transaction 的 构造函数正打算初始化一个 BuyTransaction对象的基类部分时,该对象的类型是Transaction 。这样的对待是合理的:这个对象的 BuyTransaction专属部分还没有被初始化,所以最安全的做法是视它们不存在。对象在派生类构造函数开始执行前不会成为一个派生类对象。同样的道理也适用于析构函数。
在上面的示例代码中,Transaction 的构造函数造成了对一个 virtual函数的直接调用,这很明显而且容易看出违反本条款。这一违背是如此显见,以致一些编译器会给出一个关于它的警告(另一些则不会)。
在构造或析构期间调用 virtual函数的问题并不总是如此容易被察觉。如果 Transaction 有多个构造函数,每一个都必须完成一些相同的工作,为避免代码重复将共通的初始化代码,包括对 logTransaction 的调用,放入一个初始化函数中,叫做 init:
class Transaction {
public:
Transaction()
{ init(); } // 调用non-virtual...
virtual void logTransaction() const = 0;
...
private:
void init()
{
...
logTransaction(); // 这里调用virtual!
}
};
这个代码在概念上和早先那个版本相同,但是它更阴险,因为一般来说它会躲过编译器和连接程序的抱怨。其实还是在构造函数内调用了virtual。避免这个问题的唯一办法就是确保你的构造函数或析构函数决不在被创建或析构的对象上调用 virtual函数,而它们所调用的所有函数也服从同样的约束。
如何确保在每一次 Transaction继承体系中的一个对象被创建时,都会调用 logTransaction 的正确版本呢?将 Transaction 中的 logTransaction 转变为一个 non-virtual函数,然后要求派生类构造函数将必要的信息传递给 Transaction 构造函数,而后那个函数就可以安全地调用 non-virtual的 logTransaction。如下:
class Transaction {
public:
explicit Transaction(const std::string& logInfo);
void logTransaction(conststd::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信息传递给基类构造函数
...
private:
static std::string createLogString(parameters );
};
换句话说,由于你不能在基类的构造过程中使用 virtual函数向下调用,你可以改为让派生类将必要的构造信息上传给基类构造函数作为补偿。
在此例中,注意 BuyTransaction 中那个 private static 函数 createLogString 的使用。使用一个辅助函数创建一个值传递给基类构造函数,通常比通过在成员初值列给基类它所需数据更加便利(也更加具有可读性)。将那个函数设置为static,就不会有偶然触及到一个新生的 BuyTransaction object对象的仍未初始化的数据成员的危险。
· 在构造或析构期间不要调用 virtual函数,因为这样的调用从不下降至派生类(比起当前执行构造函数和析构函数的那层)。
条款10:令operator=返回一个reference to *this
Have assignment operators return areference to *this
关于赋值的一件有意思的事情是你可以把它写成连锁形式。
int x, y, z;
x = y = z = 15; // 赋值连锁形式,相当于x = (y = (z = 15));
这里,15 赋给 z,然后将这个赋值的结果(最新的 z)赋给 y,然后将这个赋值的结果(最新的 y)赋给 x。
为了实现“连锁赋值”,赋值操作符必须返回一个reference指向操作符的左侧实参。这是为类实现赋值操作符时应该遵循的协议:
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共同遵守。因此除非有个标新立异的好理由,不然还是随众吧。
· 让赋值操作符返回一个reference to *this。
条款11:在operator=中处理“自我赋值”
Handle assignment to self in operator=
自我赋值发生在对象被赋值给自己时,它合法,所以不要认定客户绝不会这么做。此外赋值动作不是那么简单能一眼辨识出来:
a[i] =a[j]; // 如果i==j
*px = *py;// 如果px、py指向同一个东西
这些不太明显的 自我赋值是由 aliasing(别名)(有不止一个方法引用一个对象)造成的。通常,使用引用或者指针操作相同类型的多个对象需要考虑那些对象可能相同的情况。实际上,如果两个对象来自同一个继承体系,甚至不需要声明为相同类型就可能造成别名,因为一个基类的引用或者指针可以指向一个派生类对象:
class Base { ... };
class Derived: public Base { ... };
void doSomething(const Base& rb, Derived* pd);
// rb和*pd有可能其实是同一对象
如果你试图自己管理资源(如果正在写一个资源管理类),你可能会落入用完一个资源之前就已意外地将它释放的陷阱。例如,假设你建立一个类用来保存一个指针指向一块动态分配的位图:
class Bitmap { ... };
class Widget {
...
private:
Bitmap *pb; // 指向一个从heap分配而得的对象的指针
};
传统做法是在 operator= 的开始处通过 identity test(证同测试)来达到“自我赋值”的目的:
Widget& Widget::operator=(constWidget& rhs)
{
if (this == &rhs) return *this; // 证同测试:如果是自我赋值,就不做任何事
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
如果缺少证同测试那句语句, *this(赋值的目标)和 rhs 可能是同一个对象。果真如此 delete 不仅会销毁当前对象的bitmap,也会销毁 rhs 的 bitmap。在函数的结尾,Widget(原本不该被自我赋值动作改变的)发现自己持有一个指向已删除对象的指针。
加上证同测试那句语句可保证“自我赋值安全性”,但不具备“异常安全性”。更明确地说,如果 "new Bitmap" 表达式引发一个异常(可能因分配时内存不足或者因为 Bitmap 的 copy构造函数抛出异常),Widget 最后会持有一个指针指向一块被删除的Bitmap。这样的指针是不能安全地删除,不能安全地读取。
幸亏,使 operator=具备“异常安全性”往往自动获得“自我赋值安全”。因而可以将焦点集中于达到异常安全性。本例中,我们只要注意复制pb所指东西之前别删除pb:
Widget& Widget::operator=(constWidget& rhs)
{
Bitmap *pOrig = pb; // 记住原先的pb
pb = new Bitmap(*rhs.pb); // 令pb指向*pb的一个副本
delete pOrig; // 删除原先的pb
return*this;
}
现在,如果"new Bitmap"抛出一个异常,pb(以及它所在的 Widget)保持原状。即使没有证同测试,这里的代码也能处理 自我赋值,因为我们做了一个原始bitmap的拷贝,删除原始bitmap,然后指向我们作成的拷贝。这可能不是处理自我赋值的最有效率的做法,但它能够工作。
另一个确保异常和自我赋值安全的方法是使用被称为 "copy and swap" 的技术。这是一个写 operator= 的常见且够好的方法:
class Widget {
...
void swap(Widget& rhs); // 交换*this和rhs数据
...
};
Widget& Widget::operator=(constWidget& rhs)
{
Widget temp(rhs); // 为rhs数据制作一份副本
swap(temp); // 将*this数据和上述复件的数据交换
return *this;
}
以下的变种利用了如下事实:(1)一个类的 copy赋值操作符可以被声明为“以值传递方式接受实参”;(2)通过值传递方式传递会生成一份副本:
Widget& Widget::operator=(Widget rhs)
{ // rhs是被传对象的一份副本,注意这里是值传递,将*this的数据和副本数据互换
swap(rhs);
return *this;
}
这个方法在灵活的祭坛上牺牲了清晰度,但是通过将拷贝操作从函数体中转移到函数参数构造阶段中,有时能使编译器产生更有效率的代码倒也是事实。
· 当一个对象自我赋值的时候,确保 operator= 行为良好。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及 copy-and-swap。
· 如果两个或更多对象相同,确保任何操作多于一个对象的函数行为正确。
条款14:在资源管理类中小心copying行为
Think carefully about copying behaviorin resource-managing classes
条款13介绍了作为资源管理类支柱的 Resource Acquisition IsInitialization (RAII) 原则,并描述了 auto_ptr 和 tr1::shared_ptr 在基于堆的资源上运用这一原则的表现。然而,并非所有的资源都是基于堆的,对于这样的资源,像 auto_ptr 和 tr1::shared_ptr 这样的智能指针往往不适合作为资源掌管者。在这种情况下,有时可能要根据你自己的需要去创建自己的资源管理类。
例如,假设使用 C API 提供的 lock 和 unlock 函数去操纵 Mutex 类型的互斥对象:
void lock(Mutex *pm); // 锁定pm所指的互斥器
void unlock(Mutex *pm); // 将互斥器解除锁定
为了确保不会忘记解锁一个被你加了锁的 Mutex,你希望创建一个类来管理锁。RAII 原则规定了这样一个类的基本结构,也就是“资源在构造期间获得,在析构期间释放”:
class Lock {
public:
explicit Lock(Mutex *pm):mutexPtr(pm)
{ lock(mutexPtr); } // 获得资源
~Lock() { unlock(mutexPtr); } // 释放资源
private:
Mutex *mutexPtr;
};
客户对Lock的用法符合RAII方式:
Mutex m; // 定义互斥器
...
{ // 建立一个区块用来定义critical section
Lock ml(&m); // 锁定互斥器
... // 执行critical section内的操作
} // 在区块最末尾,自动解除互斥器锁定
critical section:每个线程中访问临界资源的那段程序称为临界区(Critical Section)(临界资源是一次仅允许一个线程使用的共享资源)。每次只准许一个线程进入临界区,进入后不允许其他线程进入。不论是硬件临界资源,还是软件临界资源,多个线程必须互斥地对它进行访问。
这没什么问题,但是如果一个 Lock 对象被拷贝应该发生什么?
Lock ml1(&m); // 锁定m
Lock ml2(ml1); // 将ml1复制到ml2身上,这会发生什么?
当一个 RAII 对象被拷贝的时候应该发生什么?大多数情况下,你可以从下面各种可能性中挑选一个:
禁止拷贝:在很多情况下,允许 RAII 被拷贝并不合理。当拷贝对一个 RAII 类没有什么意义的时候,你应该禁止它,通过声明拷贝操作为私有。对于 Lock,看起来也许像这样:
class Lock: private Uncopyable { // 禁止复制
public:
... // 如前
};
对底层的资源引用计数:有时我们希望保有资源直到最后一个使用它的对象被销毁。在这种情况下,拷贝一个 RAII 对象应该增加引用这一资源的对象数目, tr1::shared_ptr正是如此。
通常,RAII 类只需要包含一个 tr1::shared_ptr 数据成员就能够实现引用计数的拷贝行为。例如Lock 要使用引用计数,他可能要将 mutexPtr 的类型从 Mutex* 改变为 tr1::shared_ptr<Mutex>。然而tr1::shared_ptr 的缺省行为是当引用计数变为 0 的时候将它删除,但这不是我们要的,我们想要将它解锁,而不是删除。
幸运的是,tr1::shared_ptr 允许指定所谓的"deleter"(删除器)——当引用计数变为 0 时调用的一个函数或者函数对象(这一功能是 auto_ptr 所没有的,auto_ptr 总是删除它的指针)。deleter是 tr1::shared_ptr 的构造函数可有可无的第二参数,所以,代码看起来就像这样:
class Lock {
public:
explicit Lock(Mutex *pm):mutexPtr(pm, unlock)
// 以某个mutex初始化shared_ptr并以unlock函数为删除器
{ lock(mutexPtr.get());}
private:
std::tr1::shared_ptr<Mutex> mutexPtr; };
// 使用shared_ptr替换raw pointer
注意 Lock 类没有声明析构函数。类的析构函数(无论它是编译器生成还是用户定义)会自动调用这个类的non-static成员变量的析构函数(本例为mutexPtr)。当互斥体的引用计数变为 0 时,mutexPtr 的析构函数会自动调用tr1::shared_ptr 的deleter(本例为unlock)。
拷贝底层资源:有时就像你所希望的你可以拥有一个资源的多个副本。在这种情况下,拷贝一个资源管理对象也要同时拷贝被它包覆的资源。也就是说,拷贝资源管理对象需要进行的是“深层拷贝”。
某些标准字符串类型是由“指向heap内存”的指针构成,那内存用来存放字符串的组成字符。这样的字符串对象包含一个指针指向一块heap内存。当一个string 对象被拷贝,这个副本应该由那个指针和它所指向的内存组成。这样的字符串展现深度复制行为。
传递底层资源的所有权。在某些特殊场合,你可能希望确保只有一个 RAII 对象引用一个裸资源(raw resource),而当这个 RAII 对象被拷贝的时候,资源的所有权从被拷贝的对象传递到拷贝对象。就像 Item 13 所说明的,这就是使用 auto_ptr 时“拷贝”的含意。
拷贝RAII 对象必须一并拷贝它所管理的资源,所以资源的拷贝行为决定了 RAII 对象的拷贝行为。
普通的 RAII 类的拷贝行为是:阻止拷贝、引用计数,但其它行为也是有可能的。
条款15:在资源管理类中提供对原始资源的访问
Provide access to raw resources inresource-managing classes
很多 API 直接涉及资源,所以除非你计划坚决放弃使用这样的 API(太不实际),否则,你就要经常绕过资源管理类而直接处理原始资源(raw resources)。
例如使用类似 auto_ptr 或 tr1::shared_ptr 这样的智能指针来保存 createInvestment 这样的 factory 函数的结果,并希望以某个函数处理Investment对象:
std::tr1::shared_ptr<Investment> pInv(createInvestment());
int daysHeld(const Investment *pi); // 返回投资天数
int days = daysHeld(pInv); // 错误!
daysHeld 要求一个Investment* 指针,但是你传给它一个类型为 tr1::shared_ptr<Investment> 的对象。你需要一个将 RAII 类(本例为 tr1::shared_ptr)对象转化为它所包含的原始资源(本例为底部之Investment*)的函数。有两个常规方法来做这件事:显式转换和隐式转换。
tr1::shared_ptr 和 auto_ptr 都提供一个 get 成员函数进行显示转换,也就是说,返回一个智能指针对象内部的原始指针(或它的一个副本):
int days = daysHeld(pInv.get());// 将pInv内的原始指针传给daysHeld
就像几乎所有智能指针一样,tr1::shared_ptr和 auto_ptr 也都重载了指针解引用操作符(operator-> 和 operator*),它们允许隐式转换到底部原始指针:
class Investment { // investment继承体系的根类
public:
bool isTaxFree() const;
...
};
Investment* createInvestment(); // factory函数
std::tr1::shared_ptr<Investment> pi1(createInvestment());
bool taxable1 = !(pi1->isTaxFree()); // 经由operator->访问资源
...
std::auto_ptr<Investment>pi2(createInvestment());
bool taxable2 = !((*pi2).isTaxFree()); // 经由operator*访问资源
...
再考虑以下这个用于字体的RAII类(对C CPI而言字体是一种原生数据结构):
FontHandle getFont(); // C API,为求简化略参数
void releaseFont(FontHandle fh); // 来自同一组C API
class Font { // RAII class
public:
explicit Font(FontHandle fh): f(fh){} // 值传递获得资源
~Font() { releaseFont(f); }
private:
FontHandle f; // 原始字体资源
};
假设有大量与字体相关的C API,它们处理的是FontHandle,这就需要频繁地将 Font 对象转换为 FontHandle。Font 类可以提供一个显式的转换函数,比如get:
FontHandle get() const {return f; }
不幸的是,这就要求客户每次与 API 通信时都要调用 get:
void changeFontSize(FontHandle f, intnewSize); // C API
Font f(getFont());
int newFontSize;
...
changeFontSize(f.get(), newFontSize); // 显式地将Font转换为FontHandle
一些程序员可能发现对显式请求这个转换的需求足以令人郁闷而避免使用这个类。另一个可选择的办法是为 Font 提供一个隐式转换函数,转型为FontHandle:
operator FontHandle()const { return f; }
这样就可以使对C API的调用简单自然:
changeFontSize(f, newFontSize); // 隐式转换,与上例作对照
不利的方面是隐式转换增加错误发生机会。例如,一个客户可能会在有意使用 Font 的地方意外地产生一个 FontHandle:
Font f1(getFont());
...
FontHandle f2 = f1;
// 原意是复制一个Font对象,却反而将f1隐式转换为其底部的FontHandle然后才复制
当 f1被销毁,字体将被释放,f2则被悬挂(dangle)。
最好的设计就是坚持“使接口易于正确使用,不易被误用”。通常,类似get的一个显式转换函数是更可取的方式,因为它将意外的类型转换的机会减到最少。而有时候通过隐式类型转换将提高使用的自然性。
RAII类的存在并非为了封装什么东西而是为了确保资源释放这一特殊行为的发生。此外,一些 RAII类将实现的真正封装和底层资源的宽松封装结合在一起如tr1::shared_ptr 封装了它引用计数的全部机制,但它依然提供对它所包含资源的简单访问。就像大多数设计良好的类,它隐藏了客户不需要看到的,但它也让客户确实需要访问的东西可以被利用。
· API 经常需要访问原始资源,所以每一个 RAII 类都应提供取得它所管理资源的方法。
· 访问可以通过显式转换或者隐式转换进行。通常,显式转换更安全,而隐式转换对客户来说更方便。
条款16:成对使用new和delete时要采取相同形式
Use the same form in corresponding uses ofnew and delete
当你对一个指针使用 delete,唯一能够让delete知道内存中是否存在一个“数组大小记录”的方法就是由你来告诉它。如果你在使用的 delete 中加入了方括号,delete 就认定那个指针指向一个数组。否则,就认定指向一个单一的对象。
std::string *stringPtr1 = new std::string;
std::string *stringPtr2 = newstd::string[100];
...
delete stringPtr1;
delete [] stringPtr2;
对 stringPtr1 使用了delete []形式和对 stringPtr2 没有使用delete []形式都会发生令人不愉快的未定义行为。
规则很简单:如果你在 new 表达式中使用了[],你也必须在相应的 delete表达式中使用[];如果你在 new 表达式中没有使用 [],在匹配的delete 表达式中也不要使用[]。
当你写的一个类中包含一个指向动态分配的内存的指针,而且提供了多个构造函数的时候,这条规则尤其重要,因为那时你必须小心地在所有的构造函数中使用相同形式的new初始化那个指针成员。否则你怎么知道在析构函数中应该使用哪种形式的delete呢?
这个规则对于喜好typedef的人也很值得注目,因为这意味着一个 typedef 的作者必须说清楚,当用new创建一个 typedef 类型的对象时,应该使用哪种形式的delete。例如,考虑以下typedef:
typedef std::string AddressLines[4]; // 每个人的地址有4行,每行是一个string
std::string *pal = new AddressLines;
// 注意"new AddressLines"返回一个string*,就像“new string[4]”一样
delete pal; // 行为未有定义!
delete [] pal; // fine
最好尽量不要对数组形式做typedef动作。这很容易达成,因为C++标准程序库包含 string 和 vector,而且那些模板将对动态分配数组的需要减少到几乎为零。例如,这里,AddressLines可以被定义为一个string的vector,也就是说,类型为 vector<string>。
· 如果你在 new表达式中使用了[],你必须在对应的 delete表达式中使用[]。如果你在new表达式中没有使用[],你也不必在对应的 delete表达式中不使用[]。
条款17:以独立语句将newed对象置入智能指针
Store newed objects in smart pointers instandalone statements
假设我们有一个函数用来揭示处理程序的优先权,另一个函数用来在某动态分配所得的Widget上进行某些带有优先权的处理:
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构造函数需要一个原始指针,但该构造函数是个explicit构造函数,无法进行隐式转换,所以不能从一个由"new Widget"返回的原始指针隐式转换到 processWidget 所需要的 tr1::shared_ptr。如果写成这样就可以通过编译:
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());
令人惊讶的是,尽管我们在这里使用了对象管理资源,这个调用还是可能泄漏资源。
在编译器能生成一个对 processWidget 的调用之前,必须首先核算即将被传递的各个实参。在调用 processWidget之前,编译器必须为这三件事情生成代码:
· 调用 priority。
· 执行 "new Widget"。
· 调用 tr1::shared_ptr 的构造函数。
C++ 编译器允许在一个相当大的范围内决定这三件事被完成的顺序(这里与 Java 和 C# 等语言的处理方式不同,那些语言里函数参数总是按照一个特定顺序被计算)。可以确定的是"new Widget"一定在 tr1::shared_ptr 构造函数被调用之前执行,因为这个表达式的结果要作为一个参数传递给 tr1::shared_ptr 的构造函数,但是 priority 的调用可以排在第一第二或第三个执行。如果编译器选择第二个执行它(或许能生成更有效率的代码),我们最终得到这样一个操作顺序:
1. 执行 "new Widget"。
2. 调用 priority。
3. 调用 tr1::shared_ptr 的构造函数。
现在如果对 priority 的调用引发一个异常将发生什么?在这种情况下,从 "new Widget" 返回的指针被丢失,因为它没有被存入我们期望能阻止资源泄漏的tr1::shared_ptr。由于一个异常可能插入“资源被创建(经由newWidget)”和“资源被转换为资源管理对象”两个时间点之间,所以调用processWidget可能会发生一次泄漏。
避免这类问题的方法很简单:使用分离语句,分别写出(1)创建Widget,(2)将它置入一个智能指针内,然后再把那个智能指针传给processWidget:
std::tr1::shared_ptr<Widget> pw(newWidget);
// 在单独语句内以智能指针存储newed所得对象
processWidget(pw, priority()); // 这个调用动作绝不至于造成泄漏
这样做之所以行得通,是因为编译器对于跨越语句的各项操作没有重新排列的自由(只有在语句内才拥有那个自由度)。"new Widget" 表达式以及tr1::shared_ptr构造函数调用这两个动作,与 priority的调用在不同的语句中,所以编译器不得在它们之间任意选择执行次序。
· 以独立语句中将 new 出来的对象存入智能指针。如果疏忽了这一点,当异常发生时,有可能导致难以察觉的资源泄漏。
条款17:让接口容易被正确使用,不易被误用
Make interfaces easy to use correctly andhard to use incorrectly
C++ 被淹没于接口中。函数接口、类接口、模板接口。每一个接口都是客户与你的代码互动的手段。在理想情况下,如果使用某个接口而没有得到预期的行为,这个代码不该编译通过,反过来,如果代码可以编译,那么它做的就是客户想要的。
开发易于正确使用,而难以错误使用的接口需要你考虑客户可能造成的各种错误。例如,假设你正在设计一个用来表现日期的类的构造函数:
class Date {
public:
Date(int month, int day,int year);
...
};
客户可能很容易地造成以错误顺序传递参数或传递非法日期的错误:
Date d(30, 3, 1995); // Oops! Should be"3, 30" , not "30, 3"
Date d(2, 20, 1995); // Oops! Should be"3, 30" , not "2, 20"
很多客户错误都可以通过引入新类型来预防。确实,类型系统是你阻止那些不合适的代码通过编译的主要支持者。我们可以引入简单的外覆类型来区别日,月和年,并将这些类型用于 Data 的构造函数。
struct Day { //Month和Year与之类似
explicit Day(int d) :val(d) {} :
intval;
};
class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); // error! wrong types
Date d(Day(30), Month(3), Year(1995)); //error! wrong types
Date d(Month(3), Day(30), Year(1995)); //okay, types are correct
一旦放置了正确的类型,限制其值有时候是通情达理的。例如,月仅有12个合法值,所以 Month 类型应该反映这一点。方法之一是用一个枚举来表现月,但是枚举不具备类型安全性。例如枚举能被作为整数使用。一个安全的解决方案是预先确定合法的 Month 的集合:
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));
防止可能的客户错误的另一个方法是限制类型内能够做的事情,常见的限制是加上const。实际上,除非你有很棒的理由,否则就让你的类型行为与内置类型保持一致。客户已经知道像 int 这样的类型如何表现,所以你应该努力使你的类型在合理的前提下有同样的表现。例如,如果 a 和 b 是 int,给 a*b 赋值是非法的。
避免无端和内置类型不相容的真正原因是为了提供行为一致的接口。很少有特性比一致性更易于引出易于使用的接口,也很少有特性比不一致性更易于加剧接口的恶化。STL容器的接口在很大程度上(虽然并不完美)是一致的,而这使得它们相当易于使用。例如,每一种 STL 容器都有一个名为size的成员函数可以知道容器中有多少对象。与此对比的是 Java,在那里你对数组使用length属性,对String使用length方法,而对List却要使用size方法,在 .NET 中,Array有一个名为Length的属性,而ArrayList却有一个名为Count的属性。一些开发人员认为集成开发环境(IDEs)能补偿这些琐细的矛盾,但他们错了。矛盾在开发者工作中强加的精神折磨是任何IDE都无法完全消除的。
· 促进正确使用的方法包括接口的一致性,以及与内置类型的行为兼容。
任何一个要求客户记住某些事情的接口都是有错误使用倾向的,因为客户可能忘记做那些事情。例如,条款13介绍的factory函数,它返回一个指向动态分配的 Investment 继承体系中的对象的指针。
Investment* createInvestment();
为了避免资源泄漏,createInvestment返回的指针最后必须被删除,但这就为至少两种类型错误创造了机会:删除指针失败,或删除同一个指针一次以上。
你可以将createInvestment的返回值存入一个类似auto_ptr 或tr1::shared_ptr 智能指针,从而将使用delete的职责交给智能指针,但仍忘记使用智能指针,不如让factory函数在第一现场即返回一个智能指针:
std::tr1::shared_ptr<Investment>createInvestment();
这就从根本上强制客户将返回值存入一个tr1::shared_ptr,几乎完全消除了当底层的 Investment 对象不再使用的时候忘记删除的可能性。
· 预防错误的方法包括创建新的类型,限定类型的操作,约束对象的值,以及消除客户的资源管理职责。
假设从 createInvestment得到一个Investment*指针的客户期望将这个指针传给一个名为getRidOfInvestment的函数,而不是对它使用delete。tr1::shared_ptr 提供了一个需要两个参数(被管理的指针、当引用计数变为零时要调用的deleter)的构造函数。这启发我们创建一个以getRidOfInvestment 为deleter的null tr1::shared_ptr的方法:
std::tr1::shared_ptr<Investment> pInv(0,getRidOfInvestment);
这不会通过编译。tr1::shared_ptr的构造函数坚决要求它的第一个参数应该是一个指针,而0不是一个指针,它是一个int。当然,它能转型为一个指针,但那在当前情况下并不够好,tr1::shared_ptr坚决要求一个真正的指针。用强制转型解决这个问题,因此createInvestment的实现代码看起来是这样:
std::tr1::shared_ptr<Investment>createInvestment()
{
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0),
getRidOfInvestment);
retVal =... ; // 令retVal指向正确对象
returnretVal;
}
tr1::shared_ptr的一个特别好的特性是它自动逐指针地使用deleter以消除另一种潜在的客户错误——“cross-DLL问题。”这个问题发生在:一个对象在一个动态链接库(dynamicallylinked library (DLL))中通过 new 被创建,在另一个不同的 DLL中被删除。在许多平台上,这样的cross-DLL new/delete 对会引起运行时错误。tr1::shared_ptr 可以避免这个问题,因为它缺省的deleter只将 delete用于这个tr1::shared_ptr被创建的 DLL 中。这就意味着,例如,如果 Stock 是一个继承自 Investment 的类,而且 createInvestment 被实现如下,
std::tr1::shared_ptr<Investment>createInvestment()
{return std::tr1::shared_ptr<Investment>(new Stock);}
返回的tr1::shared_ptr能在DLL之间进行传递,而不必关心cross-DLL问题。指向这个 Stock 的 tr1::shared_ptr 将保持对“当这个 Stock 的引用计数变为零的时候,哪一个 DLL 的delete应该被使用”的跟踪。
tr1::shared_ptr是一个消除某些客户错误的简单方法,值得我们核计其使用成本。最通用的 tr1::shared_ptr 实现来自于 Boost,其shared_ptr的大小是原始指针的两倍,以动态分配内存用于簿记用途和deleter专属数据,当调用它的deleter时使用一个virtual函数来调用,并在多线程程序修改引用次数时蒙受线程同步化的额外开销(你可以通过定义一个预处理符号来使多线程支持失效。)。在缺点方面,它比一个原始指针大且慢,而且要使用辅助动态内存。在许多应用程序中,这些附加的运行时开销并不显著,而对客户错误的减少却是每一个人都看得见的。
· 好的接口易于正确使用,而难以错误使用。你应该在你的所有接口中为这个特性努力。
· tr1::shared_ptr 支持自定义 deleter。这可以防止 cross-DLL 问题,能用于自动解锁互斥体(mutex)等。
条款19:设计class 犹如设计type
Treat class design as type design
在 C++ 中,就像其它面向对象编程语言,可以通过定义一个新的类来定义一个新的类型。作为一个C++开发者,你的大量时间就这样花费在扩张你的类型系统。这意味着你不仅仅是一个类的设计者,而且是一个类型的设计者。重载函数和运算符,控制内存分配和回收,定义对象的初始化和终结过程——这些全在你的掌控之中。因此你应该在类设计中倾注大量心血,就如语言设计者在语言内置类型设计中所倾注的大量心血。
设计良好的类是有挑战性的,因为设计良好的类型是有挑战性的。良好的类型拥有简单自然的语法,符合直觉的语义,以及一个或更多高效的实现。那么,如何才能设计高效的类呢?首先,你必须理解你所面对的问题。实际上每一个类都需要你面对下面这些问题,其答案通常就导向你的设计规范:
· 新类型的对象应该如何创建和销毁?如何做这些将影响到你的类的构造函数和析构函数,以及内存分配和回收函数(operator new,operator new[],operator delete,和 operator delete[])的设计,除非你不写它们。
· 对象的初始化和对象的赋值应该有什么不同?这个问题的答案决定了你的构造函数和赋值运算符的行为以及它们之间的不同。
· 值传递(passed by value)对于新类型的对象意味着什么?拷贝构造函数定义了一个新类型的传值如何实现。
· 新类型的合法值是什么?通常,对于一个类的数据成员来说,仅有某些值的组合是合法的。那些数值集决定了你的类必须维护的约束条件。也决定了必须在成员函数内部进行的错误检查,特别是构造函数,赋值运算符,以及"setter"函数。它可能也会影响函数抛出的异常,以及(极少被使用的)函数异常明细(exceptionspecification)。
· 你的新类型需要配合某个继承图系中?如果你从已经存在的类继承,你就受到那些类的设计约束,特别受到它们的函数是virtual还是non-virtual的影响。如果你希望允许其他类继承你的类,将影响到你是否将函数声明为virtual,特别是你的析构函数。
· 你的新类型允许哪种类型转换?你的类型身处其它类型的海洋中,所以是否要在你的类型和其它类型之间有一些转换?如果你希望允许 T1 类型的对象隐式转型为 T2 类型的对象,你就要么在T1类中写一个类型转换函数(如operator T2),要么在 T2 类中写一个non-explicit-one argument构造函数。如果你只允许显示构造函数存在,就得写出专门负责执行转换的函数,且不得为类型转换操作符或non-explicit-oneargument构造函数。
· 对于新类型哪些运算符和函数是合理的?这个问题的答案决定你为你的类声明哪些函数。其中一些是成员函数,另一些不是。
· 哪些标准函数应该驳回?你需要将那些都声明为 private。
· 你的新类型中哪些成员可以被访问?这个问题的可以帮助你决定哪些成员是 public,哪些是 protected,以及哪些是 private。它也可以帮助你决定哪些类 和/或 函数应该是友元,以及一个类嵌套在另一个类内部是否有意义。
· 什么是新类型的未声明接口 "undeclaredinterface"?它对于效率,异常安全,以及资源使用(例如,多任务锁定和动态内存)提供哪种保证?你在这些领域提供的保证将为你的类的实现代码加上相应的约束条件。
· 你的新类型有多大程度的通用性?也许你并非真的要定义一个新的类型,也许你要定义一整个类型家族。如果是这样,你就不该定义一个新的类,而应该定义一个新的类模板。
· 一个新的类型真的是你所需要的吗?是否你可以仅仅定义一个新的继承类,以便让你可以为一个已有的类增加一些功能,也许通过简单地定义一个或更多非成员函数或模板能更好地达成你的目标。
· 类设计就是类型设计。定义高效的类是有挑战性的。在C++中用户自定义类生成的类型最好可以和内建类型一样好。
条款20:宁以pass-by-reference-to-const替换pass-by-value
Prefer pass-by-reference-to-const to pass-by-value
缺省情况下,C++以传值方式将对象传入或传出函数(这是一个从C继承来的特性)。除非你另外指定,否则函数的参数就会以实际参数的副本进行初始化,而函数的调用者会收到函数返回值的一个复件。这个复件由对象的拷贝构造函数生成,这就使得传值成为一个代价不菲的操作。例如,考虑下面这个类继承体系:
class Person {
public:
Person(); // 为求简化,省略参数
virtual ~Person();
...
private:
std::string name;
std::string address;
};
class Student: public Person {
public:
Student(); // 再次省略参数
~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
};
现在,考虑以下代码,在此我们调用函数validateStudent,它得到一个Student实参(以传值方式),并返回它是否有效:
bool validateStudent(Student s); // 函数以by value方式接受Student
Student plato;
bool platoIsOK = validateStudent(plato); //call the function
很明显,Student的拷贝构造函数被调用,用plato来初始化参数s。同样明显的是,当 validateStudent返回时,s就会被销毁。所以这个函数的参数传递代价是一次Student的拷贝构造函数的调用和一次Student的析构函数的调用。
但这还不是全部。Student对象内部包含两个string对象,Student对象还要从一个 Person对象继承,Person对象内部又包含两个额外的string对象。最终,以传值方式传递一个Student对象的后果就是引起一次Student的拷贝构造函数的调用,一次Person的拷贝构造函数的调用,以及四次string的拷贝构造函数调用。当Student对象的拷贝被销毁时,每一个构造函数的调用都对应一个析构函数的调用,所以以传值方式传递一个Student的全部代价是六个构造函数和六个析构函数!
这是正确和值得的行为。毕竟,你希望全部对象都得到可靠的初始化和销毁。尽管如此,pass by reference-to-const方式会更好:
bool validateStudent(const Student& s);
这样做非常有效:没有任何构造函数和析构函数被调用,因为没有新的对象被构造。修改后参数声明中的const是非常重要的,原先validateStudent以by-value方式接受一个Student参数,所以调用者知道函数绝不会对它们传入的Student做任何改变,validateStudent只能改变它的复件。现在Student以引用方式传递,同时将它声明为const是必要的,否则调用者必然担心validateStudent改变了它们传入的Student。
以传引用方式传递参数还可以避免切断问题(slicing problem)。当一个派生类对象作为一个基类对象被传递(传值方式),基类的拷贝构造函数被调用,而那些使得对象行为像一个派生类对象的特化性质被“切断”了,只剩下一个纯粹的基类对象例如,假设你在一组实现一个图形窗口系统的类上工作:
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的事实清楚地告诉你:基类的Window对象的显示方法有可能不同于专门的WindowWithScrollBars对象的显示方法。现在,假设你想写一个函数打印出一个窗口的名字,并随后显示这个窗口。以下是错误示范:
void printNameAndDisplay(Window w) //incorrect! 参数可能被切割
{
std::cout << w.name();
w.display();
}
考虑当你用一个 WindowWithScrollBars 对象调用这个函数时会发生什么:
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
参数w将被作为一个Window对象构造——它是被传值的,而且使wwsb表现得像一个 WindowWithScrollBars对象的特殊信息都被切断了。在printNameAndDisplay中,全然不顾传递给函数的那个对象的类型,w将始终表现得像一个Window 类的对象(因为其类型是Window)。因此在printNameAndDisplay中调用display将总是调用 Window::display,绝不会是WindowWithScrollBars::display。绕过切断问题的方法就是以passby reference-to-const方式传递w:
void printNameAndDisplay(const Window& w)
{ // 参数不会被切割
std::cout << w.name();
w.display();
}
现在传进来的窗口是什么类型,w就表现出那种类型。用指针实现引用是非常典型的做法,所以pass by reference实际上通常意味着传递一个指针。由此可以得出结论,如果你有一个内置类型对象(一个int),以传值方式传递它常常比传引用方式更高效;同样的建议也适用于 STL 中的迭代器和函数对象。
一个对象小,并不意味着调用它的拷贝构造函数就是廉价的。很多对象(包括大多数STL容器)内含的东西只比一个指针多一些,但是拷贝这样的对象必须同时拷贝它们指向的每一样东西,那将非常昂贵。即使当小对象有一个廉价的拷贝构造函数,也会存在性能问题。一些编译器对内置类型和用户自定义类型并不一视同仁,即使他们有同样的底层表示。例如,一些编译器拒绝将仅由一个double组成的对象放入一个寄存器中,即使通常它们非常愿意将一个纯粹的double 放入那里。当这种事发生,你以传引用方式传递这样的对象更好一些,因为编译器理所当然会将一个指针(引用的实现)放入寄存器。
小的用户定义类型不一定是传值的上等候选者的另一个原因是:作为用户定义类型,它的大小常常变化。通常情况下,你能合理地假设传值廉价的类型仅有内置类型及STL中的迭代器和函数对象。对其他任何类型,请尽量以pass-by-reference-to-const替换pass-by-value。
· 尽量以pass-by-reference-to-const替换pass-by-value。前者更高效且可以避免切断问题。
· 这条规则并不适用于内建类型及STL中的迭代器和函数对象类型。对于它们,pass-by-value通常更合适。
条款21:必须返回对象时,别妄想返回其reference
Don’t try to return a reference when youmust return an object
一旦程序员抓住对象传值的效率隐忧,很多人就会一心一意根除传值的罪恶。他们不屈不挠地追求传引用的纯度,但他们全都犯了一个致命的错误:他们开始传递并不存在的对象的引用。考虑一个用以表现有理数的类,包含一个函数计算两个有理数的乘积:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
...
private:
int n, d; // 分子与分母
friend
const Rational operator*(const Rational& lhs, const Rational& rhs);
};
operator* 的这个版本以传值方式返回它的结果,需要付出对象的构造和析构成本。如果你能用返回一个引用来代替,就不需付出代价。但是,请记住一个引用仅仅是一个名字,一个实际存在的对象的名字。无论何时只要你看到一个引用的声明,应该立刻问自己它是什么东西的别名,因为它必定是某物的别名。以上述operator*为例,如果函数返回一个引用,它必然返回某个既有的而且包含两个对象相乘产物的Rational对象引用。
当然没有什么理由期望这样一个对象在调用operator*之前就存在。也就是说,如果你有
Rational a(1, 2); // a = 1/2
Rational b(3, 5); // b = 3/5
Rational c = a * b; // c should be 3/10
期望原本就存在一个值为3/10的有理数对象并不合理。如果operator*返回一个reference指向如此数值,它必须自己创建那个Rational对象。
函数创建新对象仅有两种方法:在栈或在堆上。如果定义一个local变量,就是在栈空间创建对象:
const Rational& operator*(constRational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
} //糟糕的代码!
这个函数返回一个指向result的引用,但是result是一个局部对象,在函数退出时被销毁了。因此这个operator*的版本不会返回指向一个Rational的引用,它返回指向一个过时的Rational,因为它已经被销毁了。任何调用者甚至只是对此函数的返回值做任何一点点运用,就立刻进入了未定义行为的领地。这是事实,任何返回一个指向局部变量引用(或指针)的函数都是错误的。
考虑一下在堆上构造一个对象并返回指向它的引用的可能性。基于堆的对象通过使用new创建:
const Rational& operator*(constRational& 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*的客户没有合理的办法进行那些调用,因为他们没有合理的办法取得隐藏在通过调用operator*返回的引用后面的指针。这绝对导致资源泄漏。
无论是在栈还是在堆上的方法,为了从operator*返回的每一个 result,我们都不得不容忍一次构造函数的调用,而我们最初的目标是避免这样的构造函数调用。我们可以继续考虑基于 operator*返回一个指向staticRational对象引用的实现,而这个static Rational对象定义在函数内部:
const Rational& operator*(constRational& lhs, const Rational& rhs)
{
static Rational result; // static对象,此函数返回其reference
result= ... ; // 将lhs乘以rhs,并将结果置于result内
return result;
} //又一堆烂代码!
bool operator==(const Rational& lhs,const Rational& rhs);
// 一个针对Rational所写的operator==
Rational a, b, c, d;
...
if ((a * b) == (c * d)) {当乘积相等时,做适当的相应动作;}
else {当乘积不等时,做适当的相应动作}
除了和所有使用static对象的设计一样可能引起的线程安全(thread-safety)的混乱,上面不管 a,b,c,d 的值是什么,表达式 ((a*b) == (c*d)) 总是等于 true!如果代码重写为功能完全等价的另一种形式,很容易了解出了什么意外:
if (operator==(operator*(a, b), operator*(c, d)))
在operator==被调用前,已有两个起作用的operator*调用,每一个都返回指向 operator*内部的staticRational对象的引用。两次operator*调用的确各自改变了staticRational对象值,但由于它们返回的都是reference,因此调用端看到的永远是static Rational对象的“现值”。
一个必须返回新对象的函数的正确方法就是让那个函数返回一个新对象。对于Rational的 operator*,这就意味着下面这些代码或在本质上与其等价的代码:
inline const Rational operator*(constRational& lhs, const Rational& rhs)
{return Rational(lhs.n * rhs.n, lhs.d * rhs.d);}
当然,你可能付出了构造和析构operator*的返回值的成本,但是从长远看,这只是为正确行为付出的很小代价。但万一代价很恐怖,你可以允许编译器施行最优化,用以改善出码的效率却不改变其可观察的行为。因此某些情况下operator*返回值的构造和析构可被安全的消除。如果编译器运用这一事实(它们也往往如此),程序将保持应有行为,而执行起来又比预期的更快。
总结:如果需要在返回一个引用和返回一个对象之间做决定,你的工作就是让那个选择能提供正确的行为。让你的编译器厂商去绞尽脑汁使那个选择成本尽可能地低廉。
· 绝不要返回一个local栈对象的指针或引用,绝不要返回一个被分配的堆对象的引用,如果存在需要一个以上这样的对象的可能性时,绝不要返回一个局部 static 对象的指针或引用。
条款22:将成员变量声明为private
Declare data members private
首先,我们将看看为什么数据成员不应该声明为 public;
然后,我们将看到所有反对public数据成员的理由同样适用于protected数据成员。
最后导出了数据成员应该是private的结论。
那么,为什么不应该声明public数据成员?以下有三大理由:
1.语法一致性: 如果数据成员不是public的,客户访问一个对象的唯一方法就是通过成员函数。如果在public接口中的每件东西都是函数,客户就不必绞尽脑汁试图记住当他们要访问一个类的成员时是否需要使用圆括号,他们只要使用就可以了,因为每件东西都是一个函数。
2.精确控制成员变量的处理:如果你让一个数据成员为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; // no access
int readOnly; // read-only access
int readWrite; // read-write access
int writeOnly; // write-only access
};
3.封装:如果你通过一个函数实现对数据成员的访问,你可以以后改以某个计算来替换这个数据成员,使用你的类的人不会有任何察觉。
例如,假设你正在写一个自动测速程序,当汽车通过,其速度便被计算并填入一个速度收集器内:
class SpeedDataCollection {
...
public:
void addValue(int speed); // 添加一笔新数据
double averageSoFar() const; // 返回平均速度
...
};
现在考虑成员函数averageSoFar的实现:办法之一是在类中用一个数据成员来实时变化迄今为止收集到的所有速度数据的平均值。无论何时averageSoFar被调用,它需返回那个数据成员的值。另一个方法是在每次调用averageSoFar时重新计算,通过分析集合中每一个数据值做成这些事情。
谁能说哪一个最好?在内存非常紧张的机器(如,一台嵌入式路边侦测设装置)上,或是一个很少需要平均值的应用程序中,每次都计算平均值可能是较好的解决方案;在一个频繁需要平均值的应用程序中,速度比较重要,且内存不成问题,保持一个实时变化的平均值更为可取。重点在于通过一个成员函数访问平均值(也就是说将它“封装”),你能替换这两个不同的实现(也包括其他你可能想到的)。
封装可能比它最初显现出来的更加重要。如果你对你的客户隐藏你的数据成员(也就是说,封装它们),你就能确保类的约束条件总能被维持,因为只有成员函数能影响它们。此外,你预留了日后变更实现的权利。如果你不隐藏你将很快发现,即使你拥有类的源代码,你改变任何一个public的东西的能力也是非常有限的,因为有太多的客户代码将被破坏。public意味着没有封装,没有封装意味着不可改变,尤其是被广泛使用的类。被广泛使用的类是最需要封装的,因为它们可以从一种更好的实现中得益。
l 切记声明数据成员为private。它为客户提供了访问数据的一致,细微划分的访问控制,允许约束条件获得保证,而且为类的作者提供了实现上的弹性。
为什么不应该声明protected数据成员?
反对protected数据成员的理由是类似的。关于语法一致性和细微划分之访问控制等理由显然也适用于protected数据,就连封装性上protected数据成员也不比public数据成员更好。
某些东西的封装性与“当其内容改变时可能造成的代码破坏量“成反比。所谓改变,也许是从类中移除它(就像上述的averageSoFar)。
假设我们有一个public数据成员,随后我们移除了它,所有使用了它的客户代码,其数量通常大得难以置信,因此public数据成员是完全未封装的。但是,假设我们有一个protected数据成员,随后我们移除了它。现在有多少代码会被破坏呢?所有使用了它的派生类,典型情况下,代码的数量还是大得难以置信,因此protected数据成员就像public数据成员一样没有封装。在这两种情况下,如果数据成员发生变化,被破坏的客户代码的数量都大得难以置信。一旦你声明一个数据成员为public或protected,而且客户开始使用它,就很难再改变与这个数据成员有关的任何事情。有太多的代码不得不被重写,重测试,重文档化,或重编译。从封装的观点来看,实际只有两个访问层次:private(提供了封装)与其他(没有提供封装)。
protected并不比 public的封装性强。
条款23:宁以non-member、non-friend替换member函数
Prefer non-member non-friend functions tomember functions
想象一个用来表示网页浏览器浏览器的类。这样一个类可能提供的大量函数中,有一些用来清空下载元素高速缓存区、清空访问过的URLs历史,以及从系统移除所有cookies的功能:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
很多用户希望能一起执行全部这些动作,所以WebBrowser可能也会提供一个函数去这样做:
class WebBrowser {
public:
...
void clearEverything();
// calls clearCache, clearHistory, and removeCookies
...
};
当然,这个功能也能通过非成员函数调用适当的成员函数来提供:
void clearBrowser(WebBrowser& wb)
{wb.clearCache();wb.clearHistory();wb.removeCookies();}
那么哪个更好呢,成员函数clearEverything还是非成员函数clearBrowser?
面向对象原则指出:数据和对它们进行操作的函数应该被绑定到一起,而且建议成员函数是更好的选择。不幸的是,这个建议是不正确的。面向对象原则指出数据应该尽可能被封装,与直觉不符的是,成员函数clearEverything居然会造成比非成员函数clearBrowser更差的封装性。此外,提供非成员函数允许WebBrowser相关功能的更大的包装弹性,可以获得更少的编译依赖和增加WebBrowser的扩展性。因而,在很多方面非成员函数比成员函数更好。
我们将从封装开始。封装为我们提供一种改变事情的弹性,而仅仅影响有限的客户。结合对象内的数据考虑,越少有代码可以看到数据(访问它),数据的封装性就越强,我们改变对象数据的的自由也就越大,比如,数据成员的数量、类型,等等。如何度量有多少代码能看到数据呢?我们可以计算能访问数据的函数数量:越多函数能访问它,数据的封装性就越弱。
我们说过数据成员应该是private,否则它们根本就没有封装。对于private数据成员,能访问他们的函数数量就是类的成员函数加上友元函数,因为只有成员和友元函数能访问 private成员。假设在一个成员函数(能访问的不只是一个类的private数据,还有 private 函数,枚举,typedefs等等)和一个提供同样功能的非成员非友元函数(不能访问上述那些东西)之间选择,能获得更强封装性是非成员非友元函数。这就解释了为什么clearBrowser(非成员非友元函数)比clearEverything(成员函数)更可取:它能为WebBrowser获得更强的封装性。
在这一点,有两件事值得注意。首先,这个论证只适用于非成员非友元函数。友元能像成员函数一样访问一个类的private成员,因此同样影响封装。从封装的观点看,选择不是在成员和非成员函数之间,而是在成员函数和非成员非友元函数之间。
第二,只因关注封装而让函数成为类的非成员并不意味着它不可以是另一个类的成员。这对于习惯了所有函数必须属于类的语言(例如,Eiffel,Java,C#等等)的程序员是一个适度的安慰。例如,我们可以使clearBrowser成为某工具类的static成员函数,只要它不是WebBrowser的一部分(或友元),它就不会影响WebBrowser的private成员的封装。
在C++中,比较自然的做法是使clearBrowser成为与 WebBrowser在同一个namespace中的非成员函数:
namespace WebBrowserStuff {
classWebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
namespace能跨越多个源文件而类不能。这是很重要的,因为类似clearBrowser的函数是提供便利的函数。如果既不是成员也不是友元,他们就没有对WebBrowser的特殊访问权力,所以不能提供任何一种WebBrowser客户无法以其它方法得到的机能。例如,如果clearBrowser不存在,客户可以直接调用clearCache,clearHistory和 removeCookies本身。
一个类似WebBrowser的类可以有大量的方便性函数,一些是书签相关的,另一些打印相关的,还有一些是cookie管理相关的,等等。通常多数客户仅对其中一些感兴趣。没有理由让一个只对书签相关便利函数感兴趣的客户在编译时依赖其它函数。分隔它们直截了当的方法就是将头文件分开声明:
// header "webbrowser.h" – 针对WebBrowser自身及其核心机能
namespace WebBrowserStuff {
class WebBrowser { ... };
... // 核心机能,如人人都会用到的non-member函数
}
// header "webbrowserbookmarks.h"
namespace WebBrowserStuff {
... // 书签相关的便利函数
}
// header "webbrowsercookies.h"
namespace WebBrowserStuff {
... // cookie相关的便利函数
}
...
这正是C++标准程序库的组织方式。标准程序库并不是拥有单一整体而庞大的<C++StandardLibrary>头文件并内含std namespace中的所有东西,它们在许多头文件中(例如,<vector>,<algorithm>,<memory>等等),每一个都声明了std中的一些机能。这就允许客户在编译时仅仅依赖他们实际使用的那部分系统。当机能来自一个类的成员函数时,用这种方法分割它是不可能的,因为一个类必须作为一个整体来定义,它不能四分五裂。
将所有方便性函数放入多个头文件中,但隶属于一个namespace中,意味着客户能容易地扩充便利函数的集合,要做的只是在namespace中加入更多的非成员非友元函数。例如,如果一个 WebBrowser的客户决定写一个关于下载图像的便利函数,仅仅需要新建一个头文件,包含那些函数在WebBrowserStuff namespace中的声明,这个新函数现在就像其它便利函数一样可用并被集成。这是类不能提供的另一个特性,因为类定义对于客户是不能扩展。当然,客户可以派生新类,但是派生类不能访问基类中被封装的(private)成员,所以这样的“扩充机能”只是次等身份。
· 用非成员非友元函数取代成员函数。这样做可以提高封装性,包装弹性,和机能扩充性。
条款24:若所有参数皆需类型转换,请为此采用non-member函数
Declare non-member functions when typeconversions should apply to all parameters
让一个类支持隐式类型转换通常是一个不好的主意。当然,这条规则有一些例外,最普通的一种就是在创建数值类型时。例如,如果你设计一个用来表现有理数的类,允许从整数到有理数的隐式转换看上去并非不合理:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
// 非explicit,允许int-to-Rational隐式转换int-to-Rational
int numerator() const; // 分子和分母的访问函数
int denominator() const;
private:
...
};
应该支持算术运算,比如加法,乘法等等,但不能确定是通过成员函数、非成员函数、还是非成员的友元函数来实现它们。当你摇摆不定的时候,你应该坚持面向对象的原则。于是有理数的乘法与Rational类相关,所以在Rational类的内部实现有理数的operator*似乎更加正常,我们先让operator*成为Rational的一个成员函数:
class Rational {
public:
...
const Rational operator*(const Rational& rhs) const;
};
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth; // fine
result = result * oneEighth; // fine
这个设计让你在有理数相乘时不费吹灰之力,但你还希望支持混合模式的操作,以便让 Rational能够和其它类型(如int)相乘。毕竟两个数相乘很正常,即使它们碰巧是不同类型的数值。
result = oneHalf * 2; // fine
result = 2 * oneHalf; // error!
只有一半行得通,但乘法必须是可交换的。当以对应的函数形式重写上述两个式子,问题所在便一目了然:
result = oneHalf.operator*(2);
result = 2.operator*(oneHalf);
对象oneHalf是一个包含 operator* 的类实例,所以编译器调用那个函数。然而整数2没有operator*成员函数。编译器同样要寻找可被如下调用的非成员operator*(也就是说,在 namespace 或全局范围内的operator*):
result = operator*(2, oneHalf);
但在本例中,没有非成员的接受int和Rational的operator*函数,所以搜索失败。再看那个成功的调用,它的第二个参数是整数2,而Rational::operator*却持有一个 Rational对象作为它的参数。这里发生了隐式类型转换。编译器知道你传递一个int而那个函数需要一个Rational,通过用你提供的int调用Rational的构造函数,它们能做出一个相配的Rational。换句话说,它们将那个调用或多或少看成如下这样:
const Rational temp(2); // 根据2建立一个临时Rational对象
result = oneHalf * temp; // 等同于oneHalf.operator*(temp);
当然,编译器这样做仅仅是因为提供了一个非explicit构造函数。如果Rational的构造函数是explicit,那两句语句都将无法编译,但至少语句的行为保持一致。
这两个语句一个可以编译而另一个不行的原因在于,当参数列在参数列表中的时候,才有资格进行隐式类型转换。现在支持混合运算的方法或许很清楚了:让operator*作为非成员函数,因此就允许将隐式类型转换应用于所有参数:
class Rational {... // 不包括operator*};
const Rational operator*(const Rational& lhs, const Rational& rhs)
{return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator()* rhs.denominator());}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; // fine
result = 2 * oneFourth; // it works!
另外,仅仅因为函数不应该作为成员并不自动意味着它应该作为友元。
· 如果你需要在一个函数的所有参数(包括被 this 指针所指向的那个)上使用类型转换,这个函数必须是一个非成员。
ffective C++读书笔记(15)
条款25:考虑写出一个不抛异常的swap函数
Consider support for a non-throwing swap
swap是一个有趣的函数。最早作为STL的一部分被引入,后来它成为异常安全编程(exception-safeprogramming)的支柱,和用来处理自我赋值可能性的常见机制。因为 swap太有用了,所以正确地实现它非常重要,但是伴随它不同寻常的重要性而来的,是一系列不同寻常的复杂性。
swap两个对象的值就是互相把自己的值赋予对方。缺省情况下,swap动作可由标准程序库提供的swap算法完成,其典型的实现完全符合你的预期:
namespace std {
template<typename T> // std::swap的典型实现,置换a和b的值
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}
只要你的类型支持拷贝(通过拷贝构造函数和拷贝赋值运算符),缺省的swap实现就能交换类型为T的对象,而不需要你做任何特别的支持工作。它涉及三个对象的拷贝:从a到temp,从 b到a,以及从temp到b。对一些类型来说,这些赋值动作全是不必要的。
这样的类型中最重要的就是那些由一个指针组成,这个指针指向包含真正数据的类型。这种设计方法的一种常见的表现形式是"pimpl手法"("pointerto implementation")。如果以这种手法设计Widget 类,可能就像这样:
class WidgetImpl { // 针对Widget数据设计的类
public:
...
private:
int a, b, c; // 可能有很多数据,意味着复制时间很长
std::vector<double> v;
...
};
class Widget { // 这个类使用pimpl手法
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)
{ // 复制Widget时,令其复制WidgetImpl对象
...
*pImpl = *(rhs.pImpl);
...
}
...
private:
WidgetImpl *pImpl; // 指针,所指对象内含Widget数据
};
为了交换这两个Widget对象的值,我们实际要做的就是交换它们的pImpl指针,但是缺省的交换算法不仅要拷贝三个Widgets,而且还有三个WidgetImpl对象,效率太低了。当交换 Widgets的是时候,我们应该告诉std::swap我们打算执行交换的方法就是交换它们内部的 pImpl指针。这种方法的正规说法是:针对Widget特化std::swap。
class Widget {
public:
...
void swap(Widget&other)
{
using std::swap; // 此声明是必要的
swap(pImpl, other.pImpl); // 若要置换Widget就置换其pImpl指针
}
...
};
namespace std {
template<> // 这是std::swap针对“T是Widget”的特化版本
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b); // 若要置换Widget, 调用其swap成员函数
}
}
这个函数开头的"template<>"表明它是std::swap的一个全特化版本,函数名后面的"<Widget>"表明这一特化版本针对“T是Widget” 而设计。换句话说,当通用的swap模板用于Widgets时,便会启用这个版本。通常,我们改变std namespace中的内容是不被允许的,但允许为为标准模板(如swap)制造特化版本,使它专属于我们自己的类(如Widget)。
我们在Widget内声明一个名为swap的public成员函数去做真正的置换工作,然后特化 std::swap去调用那个成员函数。这样不仅能够编译,而且和STL容器保持一致,所有STL容器都既提供了public swap成员函数,又提供了std::swap的特化来调用这些成员函数。
可是,假设Widget和WidgetImpl是类模板而不是类,或许我们可以试图将WidgetImpl中的数据类型加以参数化:
template<typename T>
class WidgetImpl { ... };
template<typename T>
class Widget { ... };
以下是方案1:
namespace std {
template<typenameT>
void swap<Widget<T> >(Widget<T>&a, Widget<T>& b)
{ a.swap(b); }
} //错误,不合法!
尽管C++允许类模板的偏特化(partialspecialization),但不允许函数模板这样做。
以下是方案2:
namespace std {
template<typename T> // std::swap的一个重载版本
void swap(Widget<T>& a, Widget<T>&b)
{ a.swap(b); }
} //这也不合法
通常,重载函数模板没有问题,但是std是一个特殊的命名空间,其规则也比较特殊。它认可完全特化std中的模板,但它不认可在std中增加新的模板(或类,函数,以及其它任何东西)。
正确的方法,既使其他人能调用swap,又能让我们得到更高效的模板特化版本。我们还是声明一个非成员swap来调用成员swap,只是不再将那个非成员函数声明为std::swap的特化或重载。例如,如果Widget相关机能都在namespace WidgetStuff中:
namespace WidgetStuff {
... // 模板化的WidgetImpl等等
template<typename T> // 内含swap成员函数
class Widget { ... };
...
template<typename T> // non-member swap函数,这里并不属于std命名空间
voidswap(Widget<T>& a, Widget<T>& b)
{a.swap(b);}
}
现在,如果某处有代码打算置换两个Widget对象,调用了swap,C++的名字查找规则将找到WidgetStuff中的Widget专用版本。
现在从客户的观点来看一看,假设你写了一个函数模板来交换两个对象的值,哪一个swap应该被调用呢?std中的通用版本,还是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版本来调用。如果T是namespaceWidgetStuff中的Widget,编译器会利用参数依赖查找(argument-dependent lookup)找到WidgetStuff中的swap;如果T专用swap不存在,编译器将使用std中的swap,这归功于此函数中的using声明式使std::swap在此可见。尽管如此,相对于通用模板,编译器还是更喜欢T专用的std::swap特化,所以如果std::swap对T进行了特化,则特化的版本会被使用。
需要小心的是,不要对调用加以限定,因为这将影响C++挑选适当函数:
std::swap(obj1, obj2); // the wrong way to callswap
这将强制编译器只考虑std中的swap(包括任何模板特化),因此排除了定义在别处的更为适用的T专用版本被调用的可能性。
总结:
首先,如果swap的缺省实现为你的类或类模板提供了可接受的性能,你不需要做任何事。任何试图交换类型的对象的操作都会得到缺省版本的支持,而且能工作得很好。
第二,如果swap缺省实现效率不足(这几乎总是意味着你的类或模板使用了某种pimpl手法),就按照以下步骤来做:
1. 提供一个public的swap成员函数,能高效地交换你的类型的两个对象值,这个函数应该永远不会抛出异常。
2. 在你的类或模板所在的同一个namespace中,提供一个非成员的swap,用它调用你的swap成员函数。
3. 如果你写了一个类(不是类模板),为你的类特化std::swap,并令它调用你的swap 成员函数。
最后,如果你调用swap,确保在你的函数中包含一个using 声明式使std::swap可见,然后在调用swap时不使用任何namespace修饰符。
警告: 绝不要让swap的成员版本抛出异常。这是因为swap非常重要的应用之一是为类(以及类模板)提供强大的异常安全(exception-safety)保证。如果你写了一个swap的自定义版本,那么,典型情况下你提供一个更有效率的交换值的方法,也保证这个方法不会抛出异常。这两种swap的特型紧密地结合在一起,因为高效的交换几乎总是基于内置类型(如pimpl手法下的指针)的操作,而对内置类型的操作绝不会抛出异常。
· 如果 std::swap 对于你的类型来说是低效的,请提供一个 swap 成员函数,并确保你的 swap 不会抛出异常。
· 如果你提供一个成员 swap,请同时提供一个调用成员swap的非成员swap。对于类(非模板),还要特化 std::swap。
· 调用swap时,请为std::swap使用一个using声明式,然后在调用 swap时不使用任何namespace修饰符。
· 为用户定义类型全特化 std 模板是好的,但绝不要试图往std中加入任何全新的东西。
条款26:尽可能延后变量定义式出现的时间
Postpone variable definitions as long as possible
只要你定义了一个带有构造函数和析构函数的类型变量,当控制流程到达变量定义时,你会承受构造成本,而当变量离开作用域时,你会承受析构成本。如果有最终并未被使用的变量造成这一成本,你就要尽你所能去避免它。
不要认为自己不会定义一个不使用的变量。考虑下面这个函数,它计算通行密码的加密版本然后返回,前提是密码够长。如果密码太短,函数就会抛出一个定义在C++标准程序库中的logic_error类型异常:
// 这个函数过早定义变量encrypted
std::string encryptPassword(const std::string& password)
{
using namespace std;
string encrypted;
if (password.length() <MinimumPasswordLength) {
throw logic_error("Passwordis too short");
}
... // 必要动作,将一个加密后的密码置入变量encrypted
return encrypted;
}
如果抛出了一个异常,对象encrypted在这个函数中就是无用的。换句话说,即使encryptPassword抛出一个异常,你也要为构造和析构encrypted付出代价。因此你最好将encrypted的定义推迟到你确信你真的需要它的时候,即判断是否会抛出异常之后。
这还不够,因为定义encrypted的时候没有任何初始化参数。这就意味着很多情况下将使用它的缺省构造函数。缺省构造一个对象然后赋值比用你真正需要它持有的值初始化它效率更低。例如,假设encryptPassword的核心部分是在这个函数中完成的:
voidencrypt(std::string& s); // 在其中的适当地点对s加密
那么,encryptPassword可实现如下,即使它还不是最好的方法:
std::stringencryptPassword(const std::string& password)
{
... // 检查length,如前
std::string encrypted; // default-construct encrypted
encrypted = password; // 赋值给encrypted
encrypt(encrypted);
return encrypted;
}
更可取的方法是用password初始化encrypted,从而跳过毫无意义并可能很昂贵的缺省构造:
std::stringencrypted(password); // 通过copy构造函数定义并初始化
这就是本条款的标题的真正含义。你不仅应该推迟一个变量的定义直到你不得不用它的最后一刻,而且应该试图推迟它的定义直到得到了它的初始化参数。通过这样的做法,你可以避免构造和析构无用对象,而且还可以避免不必要的缺省构造。
对于循环,如果一个变量仅仅在一个循环内使用,是循环外面定义它并在每次循环迭代时赋值给它更好,还是在循环内部定义这个变量更好?
//Approach A: 定义于循环外
Widget w;
for (int i = 0; i < n; ++i){
w = 取决于i的某个值;
...
}
// Approach B: 定义于循环内
for (int i = 0; i < n; ++i) {
Widget w(取决于i的某个值);
...
}
对于Widget的操作而言,就是下面这两个方法的成本:
· 方法A:1个构造函数+ 1个析构函数+ n个赋值。
· 方法B:n个构造函数+ n个析构函数。
对于那些赋值的成本低于一个构造函数/析构函数对的成本的类,方法A通常更高效,特别是在n变得很大的情况下。否则,方法B可能更好一些。此外,方法A与方法B相比,使得名字w 在一个较大的区域(包含循环的那个区域)内均可见,这可能会破坏程序的易理解性和可维护性。因此得出结论,除非你确信以下两点:(1)赋值比构造函数/析构函数对成本更低,(2)你正在涉及你代码中性能敏感的部分,否则你应该默认使用方法B。
· 尽可能延后变量定义式的出现。这样可以增加程序的清晰度并提高程序的性能。
条款27:尽量少做转型动作(1)
Minimize casting
强制转型破坏了类型系统。它会引起各种各样的麻烦,其中一些容易被察觉,另一些则格外地隐晦。强制转型在C,C#和Java中比在C++中更有必要,危险也更少。在C++语言中,强制转型是一个必须全神贯注的特性。
C风格(C-style)强制转型如下:
(T) expression // 将expression转型为T
函数风格(Function-style)强制转型使用这样的语法:
T(expression) // 将expression转型为T
这两种形式之间没有本质不同,纯粹就是一个把括号放在哪的问题。这两种形式被称为旧风格(old-style)的强制转型。
C++ 同时提供了四种新的强制转型形式(通常称为新风格的或C++风格的强制转型):
(因对强制转换不熟悉,且书上的解释自觉不够清晰,以下内容多摘自网络)
1)static_cast < type-id > ( expression )
该运算符把expression转换为type-id类型,但没有运行时类型检查来保证转换的安全性。编译器隐式执行任何类型转换都可由static_cast显示完成。它主要有如下几种用法:
①用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换:
进行上行转换(把派生类的指针或引用转换成基类表示)是安全的;
进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的。
②用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。
③把空指针转换成目标类型的空指针。
④把任何类型的表达式转换成void类型。
注意:static_cast不能转换掉expression的const、volatile、或者__unaligned属性。
2)dynamic_cast < type-id > ( expression )
该运算符把expression转换成type-id类型的对象。Type-id必须是类的指针、类的引用或者void*;如果type-id是类指针类型,那么expression也必须是一个指针,如果type-id是一个引用,那么expression也必须是一个引用。
dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。
在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的;
在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。
3)const_cast < type_id > ( expression )
该运算符用来修改类型的const或volatile属性。除了const或volatile修饰之外,type_id和expression的类型是一样的。
①常量指针被转化成非常量指针,并且仍然指向原来的对象;
②常量引用被转换成非常量引用,并且仍然指向原来的对象;
③常量对象被转换成非常量对象。
Volatile和const类似。
Volatile:同const、static一样,这是一个类型修饰符。一个使用volatile修饰的变量,比如volatileint i; 每次对该变量的直接引用,都会访问内存,而不是从寄存器中读取(如果其已经在寄存器中)。这样一来,volatile似乎没什么用处,反倒会使数据的读取相对变慢很多,如果没有volatile,编译器可能会优化你的程序,使得数据从寄存器中读取,从而加快程序的运行。但如果这个变量是同其它进程/线程共享的,就可能造成数据的不一致。多线程情况下,你可以使用互斥机制来保证对共享数据访问的原子性。但是,在单片机等嵌入式环境中,硬件经常不会有这种互斥机制的支持,这时某些共享的数据(比如端口)就可能会产生不一致的情况。而使用volatile就会使编译器不对代码进行优化,每次对该变量的访问都会从内存中读取。
4)reinterpret_cast < type_id > ( expression )
该操作符修改了操作数类型,但仅仅是重新解释了给出的对象的比特模型而没有进行二进制转换。例如:
int *n= new int;
double*d=reinterpret_cast<double*> (n);
在进行计算以后, d 包含无用值. 这是因为reinterpret_cast仅仅是复制n比特位到d, 没有进行必要的分析。
这个关键词在我们需要把类型映射回原有类型时用到它。我们映射到的类型仅仅是为了故弄玄虚和其他目的,这是所有映射中最危险的。因此,需要谨慎使用reinterpret_cast.
旧风格的强制转型依然合法,但是新的形式更可取。首先,在代码中它们更容易识别,简化了在代码中寻找类型系统被破坏的地方的过程。第二,更精确地指定每一个强制转型的目的,使得编译器诊断使用错误成为可能。例如,如果你试图将常量性去掉,除非使用新式转换的const_cast,否则无法通过编译。
唯一使用旧式转换的时机是当我要调用一个explicit构造函数传递一个对象给一个函数的时候,例如:
class Widget {
public:
explicit Widget(int size);
...
};
void doSomeWork(const Widget& w);
doSomeWork(Widget(15)); // 以int加上函数风格的转型动作创建Widget
doSomeWork(static_cast<Widget>(15)); //以int加上C++风格的转型动作创建Widget