C++ 是我最喜欢的一门语言,C++ programer 一直为其拥有的非同寻常的能力范围和表现力而自豪(当然如果能正确使用的话)。正如 Scott Meyers, 本书作者所言:学习一种编程语言的基础是一回事;学习如何用那种语言设计和实现高效率的程序完全是另外一回事。断断续续学习了两年的 C++ programing language, 我的追求从学会语言基础,逐渐变为追求更加高效,并同时具备高可扩展性和可维护性的编程。
Effetive C++ 是一本主要面向 C++ 03 标准及之前的 C++ 编程过程中的一些编程 guidelines 的,自 C++ 11 以来,C++ 不断推陈出新,关于一些场景作者提出的解决方案也许有了更好的替代方案。但作为一切的基础,以及一系列书目的开山之作(more Efective C++, Effective Modern C++),此书仍然被许多人认为是 CPP programer 进阶的必看书目。此读书笔记仅供我自己查阅,提供简单的总结与感想,以从作者引人入胜但对于已经认真体会的读者而言无益的行文中走出 (TLDR)。并对书中的一些问题尝试使用 C++ 11 后的新特性解决。
我不想成为一个语言学家,阅读之后还需要更多的编程练习来巩固所学习的知识。
# 一些基础的杂项
# item1 将 C++ 视为 federation of languages (语言联合体)
将 C++ 从一门编程语言看作四门互相联系但有各自主体思想的子语言的结合体:
- C: 包括 built-in 数据结构,流程控制语句等,提供语法基础。
- Object-Oriented C++:C++ 的面向对象部分,主要设计封装继承多态这三个方面
- Template C++:泛型编程部分,对多数程序员少用但及其强大的 C++ 语言部分,提供包括 TMP (模板元编程) 这样的黑魔法。
- STL:containers,iterators,algorithms,functions objects... 方便地使用大佬们提供的编程工具,优化编程体验,不用像 C 那样 array simulate everything 了。
每一种子语言都有一套自己常用的编程理念,在不同的子语言下编程时,可能会要从不同的规则中进行转换(比如传值的方式)。
# item2 用 consts, enums, 和 inlines 取代 #defines
#defines 无法提供包括作用域控制,类型检查等功能,因此能避免就避免。一般只在控制编译逻辑的时候配合 #ifdef/#ifndef 使用,比如根据某些宏修改一些 objects 的定义,而不要用在编程的逻辑中。
- 想使用 #defines 定义常量时 比如
#define PI 3.1415926
,使用 const 替代,以获取更好的编译器报错体验与类型检查 - 想使用 #defines 定义类属常量时,比如在类中定义
static const int a = 5;
这里涉及到了类属常量的初始化问题,简要来说,一些编译器禁止 static integral class constants(静态整型族类属常量)的 in-class specification 而不得不在类外使用const int MyClass::a = 5;
这样的初始化方式。(ps: 都是很老的编译器了,在作者那个年代都算过时的了,gcc4.0.4 都可以在类中定义整型族常量)。对于静态非整型族类属常量,比如static const string s = "hello"
,或者static const string s = nullptr
这样的语句是不允许出现在类的定义中的。你必须要在类外初始化。for example:
class MyClass { | |
public: | |
static const int cst = 5; // allowed | |
static const string s; // static const string s = "hello"? not allowed | |
static const int* p ; // static const int* p = nullter? not allowed | |
}; | |
const string MyClass::s = "hello"; | |
const int* MyClass::p = nullptr; |
这个过程体现了 C++ 令人难以忍受的缺陷,对于很多东西,它具有很多的特例,并且你很难理解为什么,没有什么规则是通用的。一个好消息是,C++ 17 后做出了补救 (又有新东西要记了):
according to c++ - How can you define a static data member of type const std::string? - Stack Overflow
class MyClass { | |
public: | |
static constexpr int cst = 5; | |
static constexpr std::string_view STRING = "some useful string constant"; | |
static constexpr int* p = nullptr; | |
}; |
- 想使用 #defines 定义函数时,使用 inline 的模版函数替代,以获取函数参数具有的参数类型检查功能,并且拥有不输于 defines 的性能。还有,在使用 defines 定义函数时,你不得不给参数加括号 😃
对了 忘了提 enums 了,这里作者使用了一个叫做 the enum hack 的技术,主要来源于一个 enumerated type(枚举类型)的值可以用在一个需要 ints 的地方。for example:
class GamePlayer { | |
private: | |
enum { NumTurns = 5 }; // "the enum hack" - makes | |
// NumTurns a symbolic name for 5 | |
int scores[NumTurns]; // fine | |
... | |
}; |
使用这项技术,你可以禁止对 NumTurns 的取地址行为,第二个理由是,大量的代码在使用它(作为模版元编程的基本技术之一),所以最好还是认识一下。
by the way, 当你声明一个常量指针的时候,最好别忘了把这个指针也设为 const。
# item3 只要可能就用 const
use const as long as possible, 经常看到的一句话了,但是看了这节后刷新了我的认知,这节内容主要分为三点:
- const 关键字提供了语义上的表达,有助于编译器发现错误。const 用途广泛,可用于对象,函数参数,返回类型,成员函数等。(这点为大部分程序员对此节标题的唯一理解)
- 编译器坚持的是 bitwise constness (二进制位常量性), 但程序员应当使用 conceptual constness (概念上的常量性) 来编程。
- 当 const 和 non-const 成员函数本质上具有相同的实现的时候,使用 non-const 版本调用 const 版本可以避免代码重复。
关于第二点的理解:
二进制位常量性是指当你声明一个变量为 const 的时候,编译器只需要检查它在内存中存储的二进制位有没有被更改就行了,因为这样实现十分方便,比如当你声明一个指针为 const 时候,你不能改变指针的值,但是你可以改变指针指向的对象的值。但是当你声明一个类为 const 的时候,只能调用 const 成员函数,并且不能改变其任何成员变量,这实在是太苛刻了。
for example: 一个可以存储文本块长度的类:
class CTextBlock { | |
public: | |
... | |
std::size_t length() const; | |
private: | |
char *pText; | |
std::size_t textLength; // last calculated length of textblock | |
bool lengthIsValid; // whether length is currently valid | |
}; | |
std::size_t CTextBlock::length() const | |
{ | |
if (!lengthIsValid) { | |
textLength = std::strlen(pText); // error! can't assign to textLength | |
lengthIsValid = true; // and lengthIsValid in a const | |
} // member function | |
return textLength; | |
} |
对于 length () 成员函数,由于语义的需要必须定义为 const。但不想每次都调用 strlen 计算文本长度,这会带来许多开销,于是便用上述方法存储字符串长度。但是这违反了二进制常量性,毕竟改变了成员变量。
因此引入了逻辑常量性的概念,这一理念认为,** 一个 const 成员函数可以改变对象中的一些 bits, 但是只能用客户无法察觉的方法。** 这种理念的实现是通过 mutable 关键字实现的,比如将上面的 textLength 和 lengthIsValid 变量用 mutable 修饰即可。
对于第三点的理解:
一般来说,类中重载的 const 成员函数和非 const 成员函数的逻辑都是差不多的,这带来了额外的编译时间,维护成本以及代码膨胀,这对于一些程序员来说是不可忍受的。因此可以使用这样一个 trick: 通过强制转型,让 non-const 成员函数调用 const 版本。for example:
class TextBlock { | |
public: | |
const char& operator[](std::size_t position) const // same as before | |
{ | |
return text[position]; | |
} | |
char& operator[](std::size_t position) // now just calls const op[] | |
{ | |
return | |
const_cast<char&>( // cast away const on | |
// op[]'s return type; | |
static_cast<const TextBlock&>(*this) // add const to *this's type; | |
[position] // call const version of op[] | |
); | |
} | |
}; |
这样的代码通过不了选美比赛 😄 ,但是它有效。
注意,反过来让 const 版本调用 non-const 版本不可取,因为违反了 const 的语义,你将承受改变成员变量的风险。
# item 4 确保对象在使用前被初始化
对于 C++ 的对象,当你不初始化它们时,有些时候它们会自动初始化,有时候不会,因此为了避免 ub,再声明的同时最好也初始化它们。
要点:
手动初始化 built-in type 的 objects,因为 C++ 只在某些时候才会自己初始化它们
在 constructor 中,用 member initialization list 代替函数体中的 assignment。initialization list 中 data members 的排列顺序要与它们在 class(类)中被声明的顺序相同。因为在构造函数赋值之前,成员变量已经被默认初始化好了,这样做能提升性能。除此之外的是,有些时候,初始化列表式可选项,有时候是必选项,因此为了方便记忆,一律使用初始化列表即可。
通过用 local static objects(局部静态对象)代替 non-local static objects(非局部静态对象)来避免跨 translation units(编译单元)的 初始化顺序问题。
即将在全局中定义一个 static FileSystem fs;
替换为提供一个函数,有点像单例模式:
FileSystem& tfs() // this replaces the tfs object; it could be | |
{ // static in the FileSystem class | |
static FileSystem fs; // define and initialize a local static object | |
return fs; // return a reference to it | |
} |
# 构造、析构与赋值
# item5 了解 C++ 为你默认编写并调用了哪些函数
- 编译器可以隐式生成一个 class(类)的 default constructor(缺省构造函数),copy constructor(拷贝构造函数),copy assignment operator(拷贝赋值运算符)和 destructor(析构函数)。C++11 后还增加了移动构造和移动赋值。
对于以下场景:深拷贝,类中有 const 或引用成员变量,以及析构函数有 virtual 需求的时候,默认生成的函数可能不会符合要求。
# item6 若不想使用编译器生成函数,就明确拒绝
在 C++ 11 之前,作者是通过将相应的成员函数声明为 private 实现的,或者继承自一个使用这种方法实现的 Uncopyable 的基类。
C++ 11 之后,使用 = delete
即可
# item7 为多态基类声明虚析构函数
为了防止内存泄露,必须将多态基类的析构函数声明为虚函数
除此之外还要注意两点:
- 普通的基类无需也不应该有虚析构函数,因为虚函数无论在时间还是空间上都会有代价
- 如果一个类型没有被设计成多态基类,又有被误继承的风险,可以使用 C++ 11 中的
final
关键字,这样禁止派生可以防止误继承造成上述问题。
# item8 防止异常逃离析构函数
两个要点:
- destructor(析构函数)应该永不引发 exceptions(异常)。如果 destructor(析构函数)调用了可能抛出异常的函数,destructor(析构函数)应该捕捉所有异常,然后抑制它们或者终止程序。这里的抑制指的是在 try catch 语句中捕获并处理。
- 如果 class(类)客户需要能对一个操作抛出的 exceptions(异常)做出回应,则那个 class(类)应该提供一个常规的函数(也就是说,non-destructor(非析构函数))来完成这个操作。
对于第二点,一个常见例子是使用各种 db 的连接池的场景,通常用户需要使用 db.close () 显式释放资源,对于拥有 RAII 机制的 C++ 来说,为什么还需要这样做呢。因为通过 db.close () 这样的普通成员函数,用户可以通过 try catch 语句自行对这个异常做出回应。在析构函数中,同样可以继续使用 RAII 机制,记录用户有无手动释放,没有则自行调用 db.close ()
# item9 避免在构造函数或者析构函数中调用虚函数
简单来说,在构造和析构的时候,对象的类型是不确定的,因此想要调用的虚函数可能不会如你所愿。可以这样理解,再析构和构造函数中虚函数表的构造和析构的时机是不确定的,因此调用虚函数会存在问题。
这点看似简单,却十分容易踩坑,考虑以下场景,为了避免重复代码,我们可能将不同的变量初始化放到一个 init () 函数之中,即使这个函数不是虚函数,但如果其中调用了虚函数,还是违背了这一原则,并且以难以察觉的方式。
# item10 在赋值运算符中返回一个 reference to *this
简单来说 这样做是为了支持链式赋值并让自己的接口和内置类型的接口尽可能相似。因此,请将赋值操作符的返回类型设为 ObjectClass& 并返回 * this。
# item11 在赋值运算符中处理自赋值
当给一个对象赋值时,一般来说,这个对象需要释放现有资源,然后通过赋值获取新资源,这个逻辑在处理自我赋值的时候会失效,因为释放的资源可能永远找不到了。
解决这个问题的一个方案是,通过特判处理自赋值情况,很简单也常见的一种思路如下:
SomeClass& SomeClass::operator=(const SomeClass& rhs) { | |
if (this == &rhs) return *this; | |
delete ptr; | |
ptr = new DataBlock(*rhs.ptr); // 如果此处抛出异常,ptr 将指向一块已经被删除的内存。 | |
return *this; | |
} |
首先这种方案的一个问题是:当释放已有资源后,获取新资源的过程可能发生异常,此时指针会指向被释放的资源,导致后续程序出错。
解决的思路也很简单,即先获取新的资源,然后再释放原有的资源:
SomeClass& SomeClass::operator=(const SomeClass& rhs) { | |
DataBlock* pOrg = ptr; | |
ptr = new DataBlock(*rhs.ptr); // 如果此处抛出异常,ptr 仍然指向之前的内存。 | |
delete pOrg; | |
return *this; | |
} |
同样,这种方案也不用特判是否是自赋值语句了,还有一点好处是,通过减少 if 语句,可能会对指令流水线的工作有益。
还有一种方案是使用 copy-and-swap 方法,不过本人感觉性能可能会稍低,因此不作讨论。
# item12 拷贝一个对象的所有组成成分
即时刻注意以下三点:
- 为类增加成员时,别忘了更改拷贝相关的函数中的逻辑
- 在继承的场景下,子类需要并且必须通过调用父类的拷贝构造或拷贝赋值来进行父类成员的拷贝。
- 拷贝构造和拷贝赋值不能互相调用,如果想减少代码重复,就将通用功能放入一个第三方的函数中。
# 资源管理
# item13 使用对象管理资源
经典的 RAII 思想,即利用析构函数退出作用域自动调用的特点处理资源的释放。
C++11 后有了智能指针,因此 RAII 的实践已经很简单了。没想到那时候的智能指针已经存在于 tr1 库里了😄
# item14 谨慎考虑资源管理类的拷贝行为
即需要考虑在不同情况下是否要禁止或以其他方式控制对资源的拷贝。
C++11 后有了 shared_ptr 和 unique_ptr 后,应该能应付各种场景了。
# item15 在资源管理类中准备访问裸资源(raw resources)
在智能指针中,可以发现,它们提供了 get () 方法获取保管的资源。为什么?
因为很多 API 的参数中,是和资源本身打交道的,因此一个 RAII 资源管理类需要提供访问裸资源的接口。
一个疑问是,这样是否违反了封装性?答案是不会,因为 RAII 只是为了保证资源释放这个行为的发生,封装不是其存在的目的。同时,如果你提供隐式转换,可能会导致一些预期之外的错误,因此资源管理类仅提供显示转换是最合理的。
# item16 使用相同形式的 new 和 delete
即 new 和 delete,new [] 和 delete [] 搭配使用
# item17 在一个独立的语句中将 new 出来的对象存入智能指针
此条是为了防止非常微妙的内存泄漏,这种 bug 发生概率很小,但一旦出现很难被解决。
对于这样的两个函数:
int priority(); | |
void processWidget(share_ptr<Widget> sp, int priority); |
一个简洁的调用方法是:
processWidget(share_ptr<widget>(new Widget()), priority()); |
但这个语句的一大特点是,可能变为这样的执行顺序:
执行 "new Widget"。
调用 priority。
调用 shared_ptr 的构造函数。
当在第二点中发生了异常,第一点的 new Widget () 构造的类可能就会发生内存泄漏。
避免类似问题的方法很简单:用一个单独的语句创建 Widget 并将它存入一个智能指针,然后将这个智能指针传递给 processWidget
shared_ptr<Widget> pw(new Widget()); // store newed object | |
// in a smart pointer in a | |
// standalone statement | |
processWidget(pw, priority()); // this call won't leak |
这样做是因为编译器在不同的语句之间重新安排操作顺序的活动余地比在一个语句之内要小得多
# 设计与声明
# item18 让接口容易被正确使用
本条款讨论如何帮助你的客户在使用你的接口时避免他们犯错误。
在设计接口时,我们常常会错误地假设,接口的调用者拥有某些必要的知识来规避一些常识性的错误。但事实上,接口的调用者并不总是像正在设计接口的我们一样 “聪明” 或者知道接口实现的” 内幕信息 “,结果就是,我们错误的假设使接口表现得不稳定。这些不稳定因素可能是由于调用者缺乏某些先验知识,也有可能仅仅是代码上的粗心错误。接口的调用者可能是别人,也可能是未来的你。所以一个合理的接口,应该尽可能的从语法层面并在编译之时运行之前,帮助接口的调用者规避可能的风险。
- 使用外覆类型(wrapper)提醒调用者传参错误检查,将参数的附加条件限制在类型本身
当调用者试图传入数字 “13” 来表达一个 “月份” 的时候,你可以在函数内部做运行期的检查,然后提出报警或一个异常,但这样的做法更像是一种责任转嫁 —— 调用者只有在尝试过后才发现自己手残把 “12” 写成了 “13”。如果在设计参数类型时就把 “月份” 这一类型抽象出来,比如使用 enum class(强枚举类型),就能帮助客户在编译时期就发现问题,把参数的附加条件限制在类型本身,可以让接口更易用。
- 从语法层面限制调用者不能做的事
接口的调用者往往无意甚至没有意识到自己犯了个错误,所以接口的设计者必须在语法层面做出限制。一个比较常见的限制是加上 const
,比如在 operate*
的返回类型上加上 const
修饰,可以防止无意错误的赋值 if (a * b = c)
。
- 接口应表现出与内置类型的一致性
让自己的类型和内置类型的一致性,比如自定义容器的接口在命名上和 STL 应具备一致性,可以有效防止调用者犯错误。或者你有两个对象相乘的需求,那么你最好重载 operator*
而并非设计名为”multiply” 的成员函数。
- 从语法层面限制调用者必须做的事
别让接口的调用者总是记得做某些事情,接口的设计者应在假定他们总是忘记这些条条框框的前提下设计接口。比如用智能指针代替原生指针就是为调用者着想的好例子。如果一个核心方法需要在使用前后设置和恢复环境(比如获取锁和归还锁),更好的做法是将设置和恢复环境设置成纯虚函数并要求调用者继承该抽象类,强制他们去实现。在核心方法前后对设置和恢复环境的调用,则应由接口设计者操心。
当方法的调用者(我们的客户)责任越少,他们可能犯的错误也就越少。
# item19 将 class 的设计当成设计一个 type
本条款提醒我们设计 class 需要考虑的问题:
- 对象该如何创建销毁:包括构造函数、析构函数以及 new 和 delete 操作符的重构需求。
- 对象的初始化与赋值行为应有何区别。
- 对象被拷贝时应考虑的行为:拷贝构造函数。
- 对象的合法值是什么?在成员函数内部对参数做合法性检查。
- 新的类型是否应该复合某个继承体系,这就包含虚函数的覆盖问题。
- 新类型和已有类型之间的隐式转换问题,这意味着类型转换函数和非 explicit 函数之间的取舍。
- 新类型是否需要重载操作符。
- 什么样的接口应当暴露在外,而什么样的接口应当封装在内(public 和 private)
- 新类型的效率、资源获取归还、线程安全性和异常安全性如何保证。
- 这个类是否具备 template 的潜质,如果有的话,就应改为模板类。
# item20 尽量使用 pass-by-reference-to-const 替换 pass-by-value
值传参的问题有:
- 按值传参涉及大量参数的复制,这些副本大多是没有必要的。
- 如果拷贝构造函数设计的是深拷贝而非浅拷贝,那么拷贝的成本将远远大于拷贝某几个指针。
- 对于多态而言,将父类设计成按值传参,如果传入的是子类对象,仅会对子类对象的父类部分进行拷贝,即部分拷贝,而所有属于子类的特性将被丢弃,造成不可预知的错误,同时虚函数也不会被调用。
- 小的类型并不意味着按值传参的成本就会小。首先,类型的大小与编译器的类型和版本有很大关系,某些类型在特定编译器上编译结果会比其他编译器大得多。小的类型也无法保证在日后代码复用和重构之后,其类型始终很小。
尽管如此,面对内置类型和 STL 的迭代器与函数对象,我们通常还是会选择按值传参的方式设计接口。因为,对于内置类型和迭代器,它们往往很小,值传递开销低,有时甚至胜过引用。至于函数对象,值传递主要解决多线程中的同步问题。
# item21 必须返回对象时,切忌返回 reference
主要是一些程序员妄想减少拷贝的开销,而试图将函数的返回值设为引用,而导致对象析构后还试图调用引发错误。
C++ 11 之后,通过 std::move () 和移动构造函数即可解决这个问题。
# item22 将成员变量声明为 private
结论:** 请对 class 内所有成员变量声明为 private
, private
意味着对变量的封装。** 作者提供了更有价值的信息在于不同的属性控制 —— public
, private
和 protected
—— 代表的设计思想。
简单的来说,把所有成员变量声明为 private 的好处有两点。首先,所有的变量都是 private 了,那么所有的 public 和 protected 成员都是函数了,用户在使用的时候也就无需区分,这就是语法一致性;其次,对变量的封装意味着,可以尽量减小因类型内部改变造成的类外外代码的必要改动。增加可维护性。
作者还提出了一个观点,对 private
来说 ** public
和 protected
属性在一定程度上是等价的 **。一个自定义类型被设计出来就是供客户使用的,那么客户的使用方法无非是两种 —— 用这个类创建对象或者继承这个类以设计新的类 —— 以下简称为第一类客户和第二类客户。那么从封装的角度来说,一个 public
的成员说明了类的作者决定对类的第一种客户不封装此成员,而一个 protected
的成员说明了类的作者对类的第二种客户不封装此成员。也就是说,当我们把类的两种客户一视同仁了以后, public
、 protected
和 private
三者反应的即类设计者对类成员封装特性的不同思路 —— 对成员封装还是不封装,如果不封装是对第一类客户不封装还是对第二类客户不封装。
有意思的是 Java 就删除了 protected 继承,因为这个功能太鸡肋了吧,至少在 C++ 中我还没有见到过使用 protected 的例子。
# item23 用非成员非友元函数取代成员函数
用非成员非友元函数取代成员函数,可以提高封装性与可扩展性
比如对一个浏览器清除 cookie,cache 和 history 的函数,到底是应该选择 choice1: WebBrowzer.clearEverything()
这样一个接口,还是应该选择 choice2: clearBrowser(WebBrowser &wb)
呢,注意 WebBrowzer 类已经提供了基本的清除 cookie,cache 和 history 的接口,如下所示:
class WebBrowser { | |
public: | |
... | |
void clearCache(); | |
void clearHistory(); | |
void removeCookies(); | |
... | |
}; |
为了方便用户,提供两种清除数据接口的选择如下:
class WebBrowser { //choice 1: 成员函数 | |
public: | |
... | |
void clearEverything(); // calls clearCache, clearHistory, | |
// and removeCookies | |
... | |
}; | |
void clearBrowser(WebBrowser& wb) //choice 2: 非成员非友元函数 | |
{ | |
wb.clearCache(); | |
wb.clearHistory(); | |
wb.removeCookies(); | |
} |
如何提高封装性:
可以使用这样一个标准来衡量类的封装性,即可以直接访问类的 private 部分的函数的数量,这个数量越多,类的封装性就越差。从这点来看,应该选择使用 choice2。
如何提高可扩展性:
个人认为,这一点更具有说服力。假设将类的成员函数分为两类:直接访问 private 部分的成员函数,如 clearCache() clearHistory()
等与间接访问 private 部分的成员函数,如这里的 clearEverything()
。很明显,第二种成员函数的设计目标是为了方便程序员的操作,因为即使没有第二种函数,程序员也可以通过调用第一种函数来达到目的。
既然如此,为何不更方便一点呢,要实现这一点,需要配合 C++ 的 namespace 功能来实现。
假设我们采用 choice1, 即使用更多的成员函数,类的定义会变得冗长,并且当我们更改某些 “方便” 成员函数的时候,使用这个类定义的所有编译单元都必须重新编译。解决这个问题的一个例子是 C++ 的 std 命名空间,比如 vector,string 等 std 命名空间提供的功能,分散在不同的源文件和头文件中,当你需要使用 vector 时,你无需 include string 相关的头文件。可以说,string 相关代码对你当前的代码没有影响。
假设 webBrowser 类得到了扩展,需要提供书签,打印,清除数据这三个功能,完全可以使用一个 namespace WebBrowserStuff
并在不同的三个头文件和源文件中分别定义这三个功能相关的 "方便" 函数。假设某个源文件只需要类的打印功能,它不需要 include 其它的头文件,除了打印相关的其它头文件与源文件的更改也不会影响这个源文件的编译。
简单来说: 我们通过将一个类的相关功能函数拆的四分五裂,达到了更高的灵活性与可扩展性。
# item24 当类型转换应该用于所有参数时,声明为非成员函数
一个简单的例子即可说明:
考虑一个可以接受单个 int 类型构造的 Rational 类,如果在类中重载 operator* ,可以与另一个 Rational 类使用 * 运算符做乘法。这样的做法允许使用 rational * 2
而不允许使用 2 * rational
这样的语句,这往往不是程序员所期望的。即如果一个操作符是成员函数,那么它的第一个操作数 (调用对象) 不会发生隐式类型转换。
因此如果想允许像从 2 到 rational 对象这样的隐式类型转换,应该将 operator * 的重载放在类外。
by the way, 如果想要禁止对某些类型的隐式类型转换,可以使用 C++ 11 之后的 explicit
关键字
# item25 : 考虑支持不抛异常的 swap
此节内容非常精彩,深深体现了 C++ 语言的魅力,或者在反 C++ 程序员眼中无法忍受的特点:为了极致的性能提升而大大增加的程序复杂性与给程序员带来的思想负担,即使这个性能提升有时并不明显。一个简单的 swap 函数,竟然涉及了这么多的知识:模板类、完全特化与非完全特化、std 命名空间的扩充限制、命名空间与目标函数搜索规则、注重异常安全的编程范式...
swap 是一个重要的函数,在本书中,它就作为异常安全编程 (exception-safe) 的基础 (item 29) 和一种实现自赋值的通用机制 (item 11) 被提及。
作为一切的基础,先看看 std::swap 是如何实现的:
namespace std { | |
template<typename T> // 与常见的实现相同 | |
void swap(T& a, T& b) | |
{ | |
T temp(a); | |
a = b; | |
b = temp; | |
} | |
} |
可以看到一点:只要我们的类型支持拷贝,std::swap 就能完成它的工作。
缺点是:它太慢了,特别是对于成员变量含有指针的函数,它带来了三次拷贝,并且是不必要的。
考虑这样一个类 Widget(体现了 pointer to implemention 设计思想的一组类),即它的主要成员是一个 WidgetImpl, 它长这样:
class WidgetImpl { // class for Widget data; | |
public: // details are unimportant | |
... | |
private: | |
int a, b, c; // possibly lots of data — | |
std::vector<double> v; // expensive to copy! | |
... | |
}; | |
class Widget { // class using the pimpl idiom | |
public: | |
Widget(const Widget& rhs); | |
Widget& operator=(const Widget& rhs) // to copy a Widget, copy its | |
{ // WidgetImpl object. For | |
... // details on implementing | |
*pImpl = *(rhs.pImpl); // operator= in general, | |
... // see Items 10, 11, and 12. | |
} | |
... | |
private: | |
WidgetImpl *pImpl; // ptr to object with this | |
}; |
考虑使用 std::swap 的开销:它不仅要拷贝三个 Widgets,而且还有三个 WidgetImpl 对象,而实际上只需要交换它们的指针就可以了
一个自然的想法是,我们提供 std::swap 的一个特化版本,如下:
namespace std { | |
template<> | |
void swap<Widget>(Widget& a, // 对 std::swap 特化 虽然还不能编译 | |
Widget& b) | |
{ | |
swap(a.pImpl, b.pImpl); // 只用交换指针就行 | |
} | |
} |
不能通过编译的原因是,访问了 Widget 类中 private 部分的指针。解决这一问题也很简单:要么声明这个特化为 Widget 类的友元,要么让 Widget 提供一个成员函数 swap 作为接口,并让这个特化去调用它。根据 STL 中容器的选择:这里让 Widget 声明一个名为 swap 的 public 成员函数去做实际的交换,然后特化 std::swap 去调用那个成员函数:
class Widget { | |
public: | |
... | |
void swap(Widget& other) | |
{ | |
using std::swap; | |
swap(pImpl, other.pImpl); | |
} | |
... | |
}; | |
namespace std { | |
template<> // revised specialization of | |
void swap<Widget>(Widget& a, // std::swap | |
Widget& b) | |
{ | |
a.swap(b); // to swap Widgets, call their | |
} // swap member function | |
} |
到此问题告一段落,除非我们让问题变得更复杂一些:假设 Widget 和 WidgetImpl 是类模板,而不是类呢,比如说我们参数化存储在 WidgetImpl 中的数据类型:
template<typename T> | |
class WidgetImpl { ... }; | |
template<typename T> | |
class Widget { ... }; |
可以很自然的在刚才的基础上写出这样一段代码:
namespace std { | |
template<typename T> | |
void swap<Widget<T> >(Widget<T>& a, // error! illegal code! | |
Widget<T>& b) | |
{ a.swap(b); } | |
} |
但是,它通过不了编译。理由是:这是一个试图对函数进行部分特化 (partial specialization) 的代码,而与允许对类进行部分特化相反的是,C++ 不允许对函数进行部分特化。这是由于函数重载完全可以达到函数部分特化的目的,所以 C++ 索性禁止了函数部分特化。正确使用重载来达到目的的代码是:
namespace std { | |
template<typename T> // an overloading of std::swap | |
void swap(Widget<T>& a, // (note the lack of "<...>" after | |
Widget<T>& b) // "swap"), but see below for | |
{ a.swap(b); } // why this isn't valid code | |
} |
在通常情况下,上面的代码已经足够达成目的了,但是很遗憾,这又触发了 C++ 的另一条红线,因为我们试图为 C++ 的 std 命名空间添加新成员,而这一做法是 ub 的。根据 Extending the namespace std - cppreference.comC++ 禁止对 std 命名空间进行扩充,除非你试图为一个用户定义类型而对 std 空间里的原有成员添加一个完全特化。一个解释是:用户在扩充 std 命名空间后,如果下一个版本的 C++ 添加了一个与用户命名相同的新成员,就会发生命名冲突,从而导致用户之前的代码不再可用。
作者使用的方法是,将上面的代码原封不动地从 std 中挪到 Widget 类所在的命名空间:
namespace WidgetStuff { | |
... // templatized WidgetImpl, etc. | |
template<typename T> // as before, including the swap | |
class Widget { ... }; // member function | |
... | |
template<typename T> // non-member swap function; | |
void swap(Widget<T>& a, // not part of the std namespace | |
Widget<T>& b) | |
{ | |
a.swap(b); | |
} | |
} |
现在,从 client 视角来看我们的程序,
假设你写了一个函数模板来交换两个对象的值:
template<typename T> | |
void doSomething(T& obj1, T& obj2) | |
{ | |
... | |
swap(obj1, obj2); | |
... | |
} |
哪一个 swap 应该被调用呢?std 中的通用版本,你知道它必定存在;std 中的通用版本的特化,可能存在,也可能不存在;T 专用版本,可能存在,也可能不存在,可能在一个 namespace 中,也可能不在一个 namespace 中(但是肯定不在 std 中)。究竟该调用哪一个呢?如果 T 专用版本存在,你希望调用它,如果它不存在,就回过头来调用 std 中的通用版本。如下这样就可以符合你的希望:
template<typename T> | |
void doSomething(T& obj1, T& obj2) | |
{ | |
using std::swap; // make std::swap available in this function | |
... | |
swap(obj1, obj2); // call the best swap for objects of type T | |
... | |
} |
当编译器看到这个 swap 调用,他会寻找正确的 swap 版本来调用。C++ 的名字查找规则 (参见 Argument-dependent lookup - cppreference.com) 确保能找到在全局 namespace 或者与 T 同一个 namespace 中的 T 专用的 swap。(例如,如果 T 是 namespace WidgetStuff 中的 Widget,编译器会利用参数依赖查找(argument-dependent lookup)找到 WidgetStuff 中的 swap。)如果 T 专用 swap 不存在,编译器将使用 std 中的 swap,这归功于此函数中的 using declaration 使 std::swap 在此可见。尽管如此,相对于通用模板,编译器还是更喜欢 T 专用的 std::swap 的特化,所以如果 std::swap 对 T 进行了特化,则特化的版本会被使用。
本节总结:
- 如果 std::swap 对于你的类型来说是低效的,请提供一个 swap 成员函数。并确保你的 swap 不会抛出异常。
- 如果你提供一个成员 swap,请同时提供一个调用成员 swap 的非成员 swap。对于类(非模板),还要特化 std::swap。
- 调用 swap 时,请为 std::swap 使用一个 using declaration,然后在调用 swap 时不使用任何 namespace 限定条件。
- 为用户定义类型完全地特化 std 模板没有什么问题,但是绝不要试图往 std 中加入任何全新的东西。
也许还遗留了一个问题,如本节标题所言:绝不要让 swap 的成员版本抛出异常,这是因为 swap 的非常重要的应用之一是为类(以及类模板)提供强大的异常安全(exception-safety)保证 (参见 item 29)。swap 的缺省版本基于拷贝构造和拷贝赋值,如果这两个函数仅涉及内建类型,那么一切 ok,因为可以默认对内建类型的操作绝不会抛出异常。否则的话,可能涉及用户类型的拷贝,而我们为了高效的交换,此时应当提供一个更高效的 swap 版本,使用指针类型的交换来避免缺省拷贝函数的这一缺点,这时又正好解决了可能抛出异常的问题。
# 编程实践
# item26 只要有可能就推迟变量定义
优点:
- 增加程序可读性
- 避免变量的定义和真正使用离得太远,使得中间出现 return 或抛出异常等情况而导致变量白白构造和析构。
# item27 将强制转型转到最少
如果强制转型使用太多,很可能代码的设计就有问题。
- 避免强制转型的滥用,特别是在性能敏感的代码中应用 dynamic_casts,如果一个设计需要强制转型,设法开发一个没有强制转型的侯选方案。
- 如果必须要强制转型,设法将它隐藏在一个函数中。客户可以用调用那个函数来代替在他们自己的代码中加入强制转型。
- 尽量用 C++ 风格的强制转型 (四种 cast) 替换 C 风格的强制转型。它们更容易被注意到,而且他们做的事情也更加明确。
# item28 避免返回对象内部构件的句柄(引用,指针,或迭代器)
由于编译器的二进制常量性 (item3),将成员函数声明为 const 有时仍然会破坏封装性,比如返回内部私有成员的指针或引用等,如果不得不这样做,应该同时将成员函数的返回值设为 const。
# item29 争取异常安全(exception-safe)的代码
根据作者的定义,一个异常安全函数抛出异常时,应当:
- 没有资源泄露
- 不允许数据结构恶化,即对相关的变量使用不应该发生未定义或错误的行为。
for example:
void PrettyMenu::changeBackground(std::istream& imgSrc) | |
{ | |
lock(&mutex); // acquire mutex (as in Item 14) | |
delete bgImage; // get rid of old background | |
++imageChanges; // update image change count | |
bgImage = new Image(imgSrc); // install new background | |
unlock(&mutex); // release mutex | |
} |
这是一个对一个菜单对象背景图片更换的函数,当 new 操作符抛出异常时,mutex 对象并没有被释放,并且 bgImage 指针指向一个被删除的对象,分别违反了第一条和第二条原则,因此这个函数不是异常安全的。
达到第一条原则有一个十分简单且通用的方法,就是利用 C++ 的 RAII 机制 (item 13):
void PrettyMenu::changeBackground(std::istream& imgSrc) | |
{ | |
Lock ml(&mutex); // 异常抛出时会触发 Lock 类的析构 | |
delete bgImage; | |
++imageChanges; | |
bgImage = new Image(imgSrc); | |
} |
接着处理数据结构恶化问题,作者认为,异常安全对这一问题需要提供以下三种不同级别的保证之一:
- 函数提供基本保证(the basic guarantee),允诺如果一个异常被抛出,程序中剩下的每一件东西都处于合法状态。没有对象或数据结构被破坏,而且所有的对象都处于内部调和状态(所有的类不变量都被满足)。即:抛出异常后,程序的一部分状态可能被改变,但仍处于合法状态,不至于影响程序继续运行,或者程序员有办法捕捉这些改变并做出应对。
- 函数提供强力保证(the strong guarantee),即如果一个异常被抛出,程序的状态就像它们从没有被调用过一样。
- 函数提供不抛出保证(the nothrow guarantee),允诺决不抛出异常,对内建类型的操作一般都是 nothrow 的,这是异常安全代码中必不可少的基础构件。
** 对于 programmer 来说,他们提供的函数应当尽量达到最高级别的保证。** 一般来说,程序员提供的函数只要不包含内建类型,基本就只能提供强力保证了。
接下来是一个提供强力保证的通用策略:copy-and-swap,这常常配合 pimpl 原则 (pointer to implementation) 使用,它的步骤一般是这样的:
- 将每一个对象中的全部数据从 “真正的” 对象中放入到一个单独的实现对象中,然后将一个指向实现对象的指针交给真正对象。(pimpl)
- 做出一个你要改变的对象的拷贝,然后在这个拷贝上做出全部所需的改变。如果改变过程中的某些操作抛出了异常,最初的对象保持不变。在所有的改变完全成功之后,将被改变的对象和最初的对象在一个不会抛出异常的操作中进行交换。(copy-and-swap)
对于 PrettyMenu 的 changeBackground 函数来说,可以这样写:
void PrettyMenu::changeBackground(std::istream& imgSrc) | |
{ | |
using std::swap; // see Item 25 | |
Lock ml(&mutex); // acquire the mutex | |
std::tr1::shared_ptr<PMImpl> //copy obj. data,PMImpl 是一个包含所有数据的工具类。 | |
pNew(new PMImpl(*pImpl)); | |
pNew->bgImage.reset(new Image(imgSrc)); // modify the copy | |
++pNew->imageChanges; | |
swap(pImpl, pNew); // swap the new | |
// data into place | |
} |
# item30 理解 inline 化的介入和排除
- 正确理解 inline: inline 只是对于编译器的请求,最终函数是否 inline 取决于编译器。
- 正确使用 inline:
- 正确评估 inline 的效果:inline 一般只适用于小的,频繁调用的函数上,这种函数编译后的机器指令最好小于一个函数调用产生的机器指令。一个较大的函数 inline 化会带来代码膨胀,导致附加的分页调度,减少指令缓存命中率,以及随之而来的性能损失。
- 对于头文件中函数的 inline 慎重,因为 inline 函数展开的特性,与头文件与实现相分离的模式相比,使用 inline 函数的 clients 必须在 inline 函数更改后进行重新编译。
# item 31 最小化文件之间的编译依赖
为了尽量减小更改一个实现所引起的链式反应,需要掌握最小化编译依赖的精髓:只要能实现,就让你的头文件独立自主,如果不能,就依赖其它文件中的声明,而不是定义。其它每一件事都从这个简单的设计策略产生。所以:
当对象的引用和指针可以做到时就避免使用对象。仅需一个类型的声明,你就可以定义到这个类型的引用或指针。而定义一个类型的对象必须要存在这个类型的定义。因为指针和引用的大小是固定的,而对象的大小不固定,在编译时需要知道对象具体大小的场景时不适用。
用对类声明的依赖替代对类定义的依赖。声明一个使用一个类的函数时绝对不需要有这个类的定义,即使这个函数通过传值方式传递或返回这个类
比如
class Date; // class declaration
Date today(); // fine — no definition
void clearAppointments(Date d); // of Date is needed
为声明和定义分别提供头文件。为了便于坚持上面的指导方针,头文件需要成对出现:一个用于声明,另一个用于定义。当然,这些文件必须保持一致。如果一个声明在一个地方被改变了,它必须在两处都被改变。得出的结果是:库的客户应该总是 #include 一个声明文件,而不是自己前向声明某些东西,而库的作者应该提供两个头文件。例如,想要声明 today 和 clearAppointments 的 Date 的客户不应该像前面展示的那样手动前向声明 Date。更合适的是,它应该 #include 适当的用于声明的头文件:
#include "datefwd.h" // header file declaring (but not
// defining) class Date
Date today(); // as before
void clearAppointments(Date d);
基于以上想法的两个方法是 Handle 类和 Interface 类,Handle 类和 Interface 类从实现中分离出接口。
Handle 类采用 pimpl 思想,将所有的操作都交给内部指针指向的对象来做,缺点是可能需要提供更多的文件。
Interface 类利用纯虚函数的思想,类似 Java Interface 的使用,需要提供方法来制作具体的实体类。
# 面向对象设计
# item 32 确保 public inheritance 模拟 "is-a"
不同于 private 继承的一点是,public 继承体系中,子类将拥有父类提供的所有 public 接口。即 public inheritance 意味着 "is-a"。适用于 base classes 的每一件事也适用于 derived classes,因为每一个 derived class object 都是一个 base class object。
其实这是设计模式六大原则中 “里氏替换原则” 的 C++ 实践,里氏替换原则指出:应用程序中任何父类对象出现的地方,我们都可以用其子类的对象来替换。一个经典的反例是一个 public 继承自长方形类的正方形类,正方形类不能像长方形那样随意地改变长度与宽度,即 setLength () 和 setWidth () 接口在正方形类的使用中会出现麻烦,因此正方形类不应该 public 继承自长方形类。
# item33 避免覆盖 “通过继承得到的名称”
在子类中,只要声明了一个与父类同名的函数,比如 foo()
,那么父类中所有的同名函数都会变得不可见,不管是 foo()
还是 foo(int)
还是 foo() const
, 作用域级别的遮盖是和参数类型以及是否虚函数无关的,这可能有些违反直觉,作者的解释是这是为了防止 programmer 在从某个库或者某个框架中创建一个派生类时,在不知情的情况下从遥远的某个 base classes 中继承了同名的其它函数 (over loads) 的情况。不幸的是,一般情况下 programmer 是需要继承这些 overloads 的。在这种情况下如何绕过 C++ 对 “通过继承得到的名字” 的缺省的覆盖机制呢。
一种方法是使用 using declarations, 这将使父类的所有同名函数可见:
class Derived: public Base { | |
public: | |
using Base::mf1; // make all things in Base named mf1 and mf3 | |
using Base::mf3; // visible (and public) in Derived's scope | |
virtual void mf1(); | |
void mf3(); | |
void mf4(); | |
... | |
}; |
还有一种情况是,只想让 base classes 的某个同名函数可见(这是不应该发生在 public 继承中的,否则将违反 item32),这种时候应当使用一个简单的 forwarding function:
class Derived: private Base { | |
public: | |
virtual void mf1() // forwarding function; implicitly | |
{ Base::mf1(); } // inline (see Item 30) | |
... | |
}; | |
... | |
Derived d; | |
int x; | |
d.mf1(); // fine, calls Derived::mf1 | |
d.mf1(x); // error! Base::mf1() is hidden |
总结:
- derived classes 中的名字会覆盖 base classes 中的名字,在 public 继承中 中,这不应当发生。
- 为了使隐藏的 name 重新可见,使用 using declarations 或者 forwarding functions(转调函数)。
# item 34 区分 inheritance of interface(接口继承)和 inheritance of implementation(实现继承)
继承的情况可以分为三种:
- 继承了一个纯虚函数(Pure virtual functions)
- 继承了一个简单虚拟函数(Simple virtual functions)
- 继承了一个非虚拟函数 (Non-virtual functions)
什么时候该选哪种方式呢?作者提供了这三种继承方式的语义,以供选择的时候参考
- 纯虚函数指定仅有接口被继承
- 简单虚拟函数指定 接口继承加上 缺省实现继承
- 非虚拟函数指定接口继承加上强制实现继承,参见 item32 对于非虚拟函数一般来说不能重写它。
# item35 考虑可选的虚函数的替代方法
虚函数简单易懂又功能强大,无脑的使用虚函数可以解决大部分问题,但是在一些特殊场景中,以下几个方法可能会更好。
non-virtual interface (NVI) idiom(非虚拟接口惯用法),这个方法主张将虚函数声明为私有,然后通过非虚拟的 public 接口调用它。这个方法适合继承体系的各个方法包含相同的处理逻辑的时候,将不同的部分放在虚函数中,让虚函数专注于实现各个类不同的部分,然后在调用它的 public 接口中做一些通用的控制工作,for example:
class GameCharacter {
public:
int healthValue() const // derived classes do not redefine
{ // this - see Item 36
... // do "before" stuff - see below
int retVal = doHealthValue(); // do the real work
... // do "after" stuff - see below
return retVal;
}
...
private:
virtual int doHealthValue() const // derived classes may redefine this
{
... // default algorithm for calculating
} // character's health
};
在调用虚拟的 doHeadthValue 方法之前和之后可以做一些处理。
使用作为成员变量的函数指针,一个策略模式的简单实现。
使用 std::function 对象,这样做的优点是 function 对象可以被多个不同类调用,更方便。
# item36 绝不要重定义一个 inherited non-virtual function(通过继承得到的非虚拟函数)
可以参照 item32 与里式替换法则,在能使用父类的地方一定能够使用子类,而不会破坏程序的行为。同时,如果你的函数有多态调用的需求,一定记得把它设为虚函数,否则基类指针指向子类对象的时候是不会调用到子类重载过的函数的,很可能会出错。
# item37 绝不要重定义一个函数的 inherited default parameter value(通过继承得到的缺省参数值)
原因是缺省参数是静态绑定的,但是对于虚函数是动态绑定的,因此通过父类指针调用子类的虚函数的时候,会发生实现按照子类,但缺省参数按照父类的情况。同时同 item36,除了虚函数外,也希望子类的表现与父类相同。
# item38 通过 composition 模拟 "has-a" 或 "is-implemented-in-terms-of"(是根据…… 实现的)
使用复合代替继承是一个经典的设计思想,能提高程序运行的性能。假设 A 类拥有一个 B 类的成员变量,语义是 A 类是根据 B 类实现的,这很容易理解。
# item39 谨慎使用 private inheritance(私有继承)
我们知道,public 继承代表着 "is a" 关系,private 继承代表着 has a 或者说 "是根据... 实现的" 关系。因为 private 继承不会拥有与父类相同的接口,但是可以复用父类的所有接口与成员变量来对外提供新功能。
但是根据 item38,复合也拥有与私有继承同样的语义,那么如何在这两者中进行选择呢?结论是这样的:== 只要你能就用 composition(复合),只有在绝对必要的时候才用 private inheritance(私有继承)。== 这里绝对必要的情况主要是当 protected members 和虚函数参与进来的时候。比如说,当你要处理的两个 classes(类)不具有 is-a(是一个)的关系,而且其中的一个还需要访问另一个的 protected members(保护成员)或需要重定义一个或更多个它的 虚函数。甚至在这种情况下,我们也看到 public inheritance 和 containment 的混合使用通常也能产生你想要的行为,虽然有更大的设计复杂度。
两个原因:
- private 继承的子类如果还可能被新的类继承的时候,可能需要禁止当前类的虚函数被重载,作者使用了复杂的方法,但 C++ 11 后使用 final 关键字便可解决此问题,故此原因可忽略
- item31 提到的最小化编译依赖的问题,复合可以只需要对象的声明,而继承必须看到对象的定义。
# item40 谨慎使用多继承
能避免使用多继承就避免使用多继承,目前还没有看到过必须使用多继承的例子,java 删除了多继承貌似也没什么问题。
总结:
- 多继承比 single inheritance 单继承更复杂。它能导致新的歧义问题(父类中拥有同名 members) 和对 virtual inheritance(虚继承)的需要。
- virtual inheritance(虚继承)增加了 size 和 speed 成本,以及 initialization(初始化)和 assignment(赋值)的复杂度。当 virtual base classes(虚拟基类)没有数据时它是最适用的。
- 多继承有合理的用途。比如一种方案涉及组合从一个 Interface class(接口类)的公有继承和从一个有助于实现的 类的 私有继承。
# 模板
这一章的内容值得反复阅读,行文环环相扣,实在是难以再浓缩了。
# item41 理解 implicit interfaces(隐式接口)和 compile-time polymorphism(编译期多态)
类和模版的一个共同特点是它们都支持接口和多态,区别是类支持显式接口和运行时多态(通过虚函数实现),而模版支持隐式接口和编译期多态。
运行时多态与编译器多态很好理解。显式接口的意思是,一个类提供了哪些接口,在它的类定义之中就能全部找到,所以是显式的。而对于模板来说,参数化的类需要提供的接口是基于合法表达式的,比如一个表达式 if (templateClass.size() > 10)
, 我们可以推测出这个参数化的类必须提供 templateClass 接口,所以说是隐式的。
# item42 理解 typename 的两个含义
第一个含义,用于模版声明中:
template<class T> class Widget; // uses "class" | |
template<typename T> class Widget; // uses "typename" |
这里的 typename
和 class
在语法层面上没有什么不同,都用在声明一个模板类型参数的时候。但是,在惯例上,一般会使用 typename
,表示类型 T 不仅可以是一个类类型,也可以是其它类型,仅当模板类型参数仅接受用户定义类型的时候使用 class。
第二个含义,用 typename
去标识 nested dependent type names(嵌套依赖类型名)
这里设计两个概念:嵌套和依赖,for example,这个代码还不能通过编译,只是举例:
template<typename C> // print 2nd element in | |
void print2nd(const C& container) // container; | |
{ // this is not valid C++! | |
if (container.size() >= 2) { | |
C::const_iterator iter(container.begin()); // get iterator to 1st element | |
++iter; // move iter to 2nd element | |
int value = *iter; // copy that element to an int | |
std::cout << value; // print the int | |
} | |
} |
这个函数中有两个局部变量, iter
和 value
。 iter
的类型是 C::const_iterator
,一个依赖于模板参数 C 的类型。一个 模板中的依赖于一个 模板参数的名字被称为 dependent names(依赖名字)。当一个 dependent names(依赖名字)嵌套在一个类的内部时,我称它为 nested dependent name(嵌套依赖名字)。C::const_iterator 是一个 nested dependent name(嵌套依赖名字)。实际上,它是一个 nested dependent type name(嵌套依赖类型名),也就是说,一个涉及到一个 type(类型)的 nested dependent name(嵌套依赖名字)。
print2nd 中的另一个 局部变量 value
具有 int 类型。int 是一个不依赖于任何模板参数的名字。这样的名字被称为 non-dependent names(非依赖名字)。
上面的这个代码错在 C::const_iterator iter(container.begin());
这一行,在这里,我们之所以觉得能够声明 iter
这个变量,是因为我们默认 C::const_iterator 是一个类型,也就是说 iter
的声明仅在 C::const_iterator
是一个类型时才有意义,但是在编译器看来这是不对的,编译器会认为 C::const_iterator 可能是 C 中的一个静态数据成员,因为编译器会考虑所有可能的输入,** 因此在 C 还不是已知的时候,它会假定嵌套依赖名字不是一个 type (类型)。** 因此为了声明 iter
变量,我们必须告诉编译器 C::const_iterator
是一个类型,我们将 typename
放在这个声明的前面来做到这一点。
template<typename C> // this is valid C++ | |
void print2nd(const C& container) | |
{ | |
if (container.size() >= 2) { | |
typename C::const_iterator iter(container.begin()); | |
... | |
} | |
} |
这就是 typename
的第二个含义, typename
前置于嵌套依赖类型名。但这一规则也有两个例外:
typename
不必前置于在一个 list of base classes(基类列表)中的或者在一个 member initialization list(成员初始化列表)中作为一个 base classes identifier(基类标识符)的 nested dependent type name(嵌套依赖类型名)。很拗口,但例子很简单:
template<typename T> | |
class Derived: public Base<T>::Nested { // 基类列表中 typename not | |
public: // allowed | |
explicit Derived(int x) | |
: Base<T>::Nested(x) // 初始化列表中的基类标识符 | |
{ // : typename not allowed | |
typename Base<T>::Nested temp; // use of nested dependent type | |
... // name not in a base class list or | |
} // as a base class identifier in a | |
... // mem. init. list: typename required | |
}; |
这里还有一个知识点,涉及 typedef
与 typename
,同时还需要一点点模板元编程的知识。
假设我们在写一个取得一个 iterator 的 函数模板,而且我们要做一个 iterator 指向的对象的局部拷贝 temp,我们可以这样做:
template<typename IterT> | |
void workWithIterator(IterT iter) | |
{ | |
typename std::iterator_traits<IterT>::value_type temp(*iter); | |
... | |
} |
这里 typename std::iterator_traits<IterT>::value_type
实在太长了,因此可以使用这种语法简化它:
template<typename IterT> | |
void workWithIterator(IterT iter) | |
{ | |
typedef typename std::iterator_traits<IterT>::value_type value_type; | |
value_type temp(*iter); | |
... | |
} |
有点怪,但是这是合理的,可以很快的习惯这种语法,因为输一长串 typename + 类型太麻烦了
# item 43 了解如何访问 templatized base classes(模板化基类)中的名字
在模板类的继承体系中,子类不能像 Object-oriented C++ 那样直接使用父类的方法,这是模板具有特化的特性引起的问题,即针对某个类型进行的特化,这个特化后的类与其他通过相同模板的类可能拥有不同的方法,因此 C++ 拒绝模版派生类对模版基类方法的直接使用,因此,程序员需要向编译器保证任何后继的 base class template(基类模板)的 specializations(特化)都将支持 general template(通用模板)提供的 interface(接口),如果保证被证实不成立,真相将在后继的编译过程中暴露,编译器会报错。
保证的方法有:
- 经由 "this->" 前缀
- 经由 using declarations
- 经由一个 explicit base class qualification(显式基类限定)引用 base class templates(基类模板)中的名字。
比如想要使用基类模板的 sendClear(...)
函数,直接使用 sendClear()
不行,而下面这些语法才是正确的:
template<typename Company> | |
class LoggingMsgSender: public MsgSender<Company> { | |
public: | |
... | |
void sendClearMsg(const MsgInfo& info) | |
{ | |
write "before sending" info to the log; | |
this->sendClear(info); // okay, assumes that | |
// sendClear will be inherited | |
write "after sending" info to the log; | |
} | |
... | |
}; |
template<typename Company> | |
class LoggingMsgSender: public MsgSender<Company> { | |
public: | |
using MsgSender<Company>::sendClear; // tell compilers to assume | |
... // that sendClear is in the | |
// base class | |
void sendClearMsg(const MsgInfo& info) | |
{ | |
... | |
sendClear(info); // okay, assumes that | |
... // sendClear will be inherited | |
} | |
... | |
}; |
template<typename Company> | |
class LoggingMsgSender: public MsgSender<Company> { | |
public: | |
... | |
void sendClearMsg(const MsgInfo& info) | |
{ | |
... | |
MsgSender<Company>::sendClear(info); // okay, assumes that | |
... // sendClear will be | |
} // inherited | |
... | |
}; |
# item44 从模板中分离出参数无关的代码
考虑以下求矩阵得 invert 的代码:
template<typename T, // template for n x n matrices of | |
std::size_t n> // objects of type T; see below for info | |
class SquareMatrix { // on the size_t parameter | |
public: | |
... | |
void invert(); // invert the matrix in place | |
}; | |
SquareMatrix<double, 5> sm1; | |
... | |
sm1.invert(); // call SquareMatrix<double, 5>::invert | |
SquareMatrix<double, 10> sm2; | |
... | |
sm2.invert(); // call SquareMatrix<double, 10>::invert |
这里将有两个 invert 函数被实例化。这两个函数不是相同的,因为一个作用于 5 x 5 矩阵,而另一个作用于 10 x 10 矩阵,但是除了常数 5 和 10 以外,这两个函数是相同的。很明显,这里发生了代码膨胀。
因此正确的做法是提供一个模板父类,派生自这个父类的所有具有相同 T 类型的 SquareMatrix 子类都共享同一个 invert 函数。
# item45 用成员函数模板接受所有兼容类型
本原则的场景是:考虑一个 A 类型与 B 类型,其中 B 类型是 A 类型的派生类,那么一个 B 类型的对象或指针可以隐式的转换为 A 类型的指针或对象。但是,对于智能指针或类似功能的其他类,一个 share_ptr<B> 类型对象却无法隐式转换为 share_ptr<A > 类型,因为同一个模板的不同实例化之间没有继承关系,虽然这样的转换符合直觉并且大多数情况下很有用。因此在用户定义类型中模仿这样的转换是有意义的,并且需要一些技巧。
正确的做法是使用一个模板构造函数:
template<typename T> | |
class SmartPtr { | |
public: | |
template<typename U> | |
SmartPtr(const SmartPtr<U>& other) // initialize this held ptr | |
: heldPtr(other.get()) { ... } // with other's held ptr | |
T* get() const { return heldPtr; } | |
... | |
private: // built-in pointer held | |
T *heldPtr; // by the SmartPtr | |
}; |
他接受任意类型的其他智能指针,并且仅当 U 类型能隐式转换为 T 类型的时候,代码能通过编译,这正是我们想要的。除此之外,赋值操作也应该达到这个效果,因此我们可以写出下面的代码:
template<class T> class shared_ptr { | |
public: | |
template<class Y> // construct from | |
explicit shared_ptr(Y * p); // any compatible | |
template<class Y> // built-in pointer, | |
shared_ptr(shared_ptr<Y> const& r); // shared_ptr, | |
template<class Y> // weak_ptr, or | |
explicit shared_ptr(weak_ptr<Y> const& r); // auto_ptr | |
template<class Y> | |
explicit shared_ptr(auto_ptr<Y>& r); | |
template<class Y> // assign from | |
shared_ptr& operator=(shared_ptr<Y> const& r); // any compatible | |
template<class Y> // shared_ptr or | |
shared_ptr& operator=(auto_ptr<Y>& r); // auto_ptr | |
... | |
}; |
需要注意的是,成员模板并不改变语言规则,而且规则规定一个拷贝构造函数时必需的而我们没有提供,编译器会自动生成一个,所以一个模板构造函数不会阻止编译器生成非模板的构造函数。因此为了全面支配拷贝构造,我们必须既声明一个模板拷贝构造函数,又声明一个常规的,非模板的拷贝构造函数,这同样适用于赋值,正确的实践应当像这样:
template<class T> class shared_ptr { | |
public: | |
shared_ptr(shared_ptr const& r); // copy constructor | |
template<class Y> // generalized | |
shared_ptr(shared_ptr<Y> const& r); // copy constructor | |
shared_ptr& operator=(shared_ptr const& r); // copy assignment | |
template<class Y> // generalized | |
shared_ptr& operator=(shared_ptr<Y> const& r); // copy assignment | |
... | |
}; |
# item46 需要类型转换时在模板内定义非成员函数
此节针对 item24 中的示例增加了扩展讨论 (模板化 Rational 和 operator*)。item 24 的结论告诉我们,非成员函数适合应用到所有参数都需要进行隐式类型转换的场景之中。因此在模板中也使用相同的方法是很自然的:
template<typename T> | |
class Rational { | |
public: | |
Rational(const T& numerator = 0, // see Item 20 for why params | |
const T& denominator = 1); // are now passed by reference | |
const T numerator() const; // see Item 28 for why return | |
const T denominator() const; // values are still passed by value, | |
... // Item 3 for why they're const | |
}; | |
template<typename T> | |
const Rational<T> operator*(const Rational<T>& lhs, | |
const Rational<T>& rhs) | |
{ ... } |
正如 item24 中提到的,我们想要支持 mixed-mode arithmetic(混合模式运算),所以我们要让下面这些代码能够编译:
Rational<int> oneHalf(1, 2); // this example is from Item 24, | |
// except Rational is now a template | |
Rational<int> result = oneHalf * 2; // error! won't compile |
编译失败的原因是:模板实参推导的过程中,从不考虑隐式类型转换因此面对一个 Rational<int> 类型的参数 oneHalf
和 int 类型的参数 2
, 编译器不会将 2
隐式转换为 Rational<int> 类型,也就导致了 operator * 模板推导失败,编译器找不到能用的实现。
也就是说,如果我们有了一个实例化的函数,对它的调用过程中参数可以发生隐式类型转换,但既然模板推导过程失败了,我们也就没有了实例化的函数,这就是编译失败的原因。
这里用到的解决方案是让 class Rational<T> 为 Rational<T> 声明作为一个友元函数的 operator*。class 类模板不依靠 模板实参推演(这个过程仅适用于 function templates(函数模板)),所以 T 在 class Rational<T> 被实例化时总是已知的:
template<typename T> | |
class Rational { | |
public: | |
... | |
friend // declare operator* | |
const Rational operator*(const Rational& lhs, // function (see | |
const Rational& rhs); // below for details) | |
}; | |
template<typename T> // define operator* | |
const Rational<T> operator*(const Rational<T>& lhs, // functions | |
const Rational<T>& rhs) | |
{ ... } |
现在对 operator * 的混合模式调用可以编译了,因为当 object oneHalf 被声明为 Rational<int> 类型时,class Rational<int> 被实例化,而作为这一过程的一部分,取得 Rational<int > 参数的友元函数 operator* 被自动声明。作为已声明 函数(并非一个 函数模板),在调用它的时候编译器可以使用隐式转换函(譬如 Rational 的非显式构造函数),而这就是它们如何使得混合模式调用成功的。
by the way,有一个小知识点是:在一个 类模板内部,模板的名字可以被用做 模板和它的参数的缩写,所以,在 Rational<T> 内部,我们可以只写 Rational 代替 Rational<T>。
但是,上面的代码虽然能够编译,但是还不能链接,因为 operator* 还没有提供实现。我们打算让 class 之外的 operator* 模板提供这个定义,但是这种方法不能工作 (why)。让它能工作的最简单的方法或许就是将 operator* 的本体合并到它的 定义中:
template<typename T> | |
class Rational { | |
public: | |
... | |
friend const Rational operator*(const Rational& lhs, const Rational& rhs) | |
{ | |
return Rational(lhs.numerator() * rhs.numerator(), // same impl | |
lhs.denominator() * rhs.denominator()); // as in | |
} // Item 24 | |
}; |
# item47 为类型信息使用 traits classes(特征类)
当我们的函数想为某些特定类型提供更高效的实现,并且不想在运行期花费额外开销进行类型判断的时候,本节的技巧十分有用。本节的一些做法在 C++ 11 后有了更简洁的做法,不过了解一下还是很有必要的,替代方法在节末也有涉及。
以 STL 提供的 advance 函数为例,advance 将一个指定的 iterator 移动一个指定的距离:
template<typename IterT, typename DistT> // move iter d units | |
void advance(IterT& iter, DistT d); // forward; if d < 0, | |
// move iter backward |
在概念上,advance 仅仅是在做 iter += d
, 但是这样实现是错误的,因为只有随机访问迭代器支持 +=
操作。其它迭代器不得不反复利用 ++
或 --
d 次来实现 advance。
STL 的 iterator 不同种类的简单回顾:
名称 | 特点 | 代表 |
---|---|---|
input iterators (输入迭代器) | 只能向前移动,每次移动一步,只读 | 输入文件的读指针 |
output iterators (输出迭代器) | 只能向前移动,每次移动一步,只写 | 输出文件的写指针 |
forward iterators (前向迭代器) | 只能向前移动,每次移动一步 | 单向链表容器的迭代器 |
bidirectional iterators (双向迭代器) | 加上了和前向迭代器一样的向后移动能力 | set,map 的迭代器 |
random access iterators (随机访问迭代器) | 可以在常量时间里向前或向后跳转任意距离 | vector,string 的迭代器 |
对于五种迭代器种类,C++ 都有一个用于识别它的 "tag struct" 结构体在标准库中:
struct input_iterator_tag {}; | |
struct output_iterator_tag {}; | |
struct forward_iterator_tag: public input_iterator_tag {}; | |
struct bidirectional_iterator_tag: public forward_iterator_tag {}; | |
struct random_access_iterator_tag: public bidirectional_iterator_tag {}; |
这些结构体之间的继承关系体现了 "is a" 关系
返回到 advance,对于不同的 iterator, 实现 advance 的一个方法是使用反复增加或减少 iterator 的循环,这个方法时间复杂度是 O (n),但是随机访问迭代器支持常量时间的移动,所以当它出现的时候我们最好能利用这种能力。我们真正想做的大致可以这样描述:
template<typename IterT, typename DistT> | |
void advance(IterT& iter, DistT d) | |
{ | |
if (iter is a random access iterator) { | |
iter += d; // use iterator arithmetic | |
} // for random access iters | |
else { | |
if (d >= 0) { while (d--) ++iter; } // use iterative calls to | |
else { while (d++) --iter; } // ++ or -- for other | |
} // iterator categories | |
} |
** 现在的关键是,我们如何得到关于一个类型的某些信息。这就是 traits 能做到的:它们允许你在编译过程中得到过于一个类型的信息。**traits 不是 C++ 中的一个关键字或预定义结构;它们是一项技术和 C++ 程序员遵守的惯例。建立这项技术的要求之一是它在 内建类型上必须和在 user-defined types(用户定义类型)上一样有效,因此将信息嵌入到类型内部是不可以的,因为无法将信息嵌入一个指针内部。那么,一个类型的 traits 信息,必须在类型外部。标准的方法是将它放到 模以及这个模板的一个或更多的特化中。对于 iterators,标准库中模板被称为 iterator_traits:
template<typename IterT> // template for information about | |
struct iterator_traits; // iterator types |
iterator_traits 的工作方法是对于每一个 IterT 类型,在 结构体 iterator_traits<IterT> 中声明一个名为 iterator_category 的 typedef。这个 typedef 被看成是 IterT 的 iterator category(迭代器种类)。
iterator_traits 通过两部分实现这一点。首先,它强制要求任何 user-defined iterator(用户定义迭代器)类型必须包含一个名为 iterator_category 的嵌套 typedef 用以识别适合的 tag struct(标签结构体)。例如,deque 的 iterators(迭代器)是随机访问的,所以一个 deque iterators 的 class 看起来就像这样:
template < ... > // template params elided | |
class deque { | |
public: | |
class iterator { | |
public: | |
typedef random_access_iterator_tag iterator_category; | |
... | |
}; | |
... | |
}; |
对于一个 list (双向链表),则是这样:
template < ... > | |
class list { | |
public: | |
class iterator { | |
public: | |
typedef bidirectional_iterator_tag iterator_category; | |
... | |
}; | |
... | |
}; |
而 iterator_traits 仅仅是简单地模仿了 iterator class 的嵌套 typedef:
// the iterator_category for type IterT is whatever IterT says it is; | |
// see Item 42 for info on the use of "typedef typename" | |
template<typename IterT> | |
struct iterator_traits { | |
typedef typename IterT::iterator_category iterator_category; | |
... | |
}; |
同时为了支持指针这样无法带有嵌套 typedef 的东西,iterator_traites 为其提供了一个部分模板特化:
template<typename IterT> // partial template specialization | |
struct iterator_traits<IterT*> // for built-in pointer types | |
{ | |
typedef random_access_iterator_tag iterator_category; | |
... | |
}; |
到此为止,可以总结出如何设计和实现一个 traits class:
- 识别你想让它可用的关于类型的一些信息(例如,对于 iterators(迭代器)来说,就是它们的 iterator category(迭代器种类))。
- 选择一个名字标识这个信息(例如,iterator_category)。
- 提供一个模板和一系列特化(例如,iterator_traits),它们包含你要支持的类型的信息。
有了 iterator_traits,就可以改善 advance 的伪代码:
template<typename IterT, typename DistT> | |
void advance(IterT& iter, DistT d) | |
{ | |
if (typeid(typename std::iterator_traits<IterT>::iterator_category) == | |
typeid(std::random_access_iterator_tag)) | |
... | |
} // 这个代码可能涉及编译问题 见原书 item48 |
现在的问题是:IterT 的类型在编译期间是已知的,所以 iterator_traits<IterT>::iterator_category 可以在编译期间被确定。但是 if 语句还是要到运行时才能被求值。为什么要到运行时才做我们在编译期间就能做的事情呢?它浪费了时间。
解决方法是,重载,重载的最佳匹配是编译期间完成的,这个行为也有点像 if 语句选择分支的过程。为了让 advance 拥有我们想要的行为方式,我们必须要做的全部就是创建一个包含 advance 的 “内容” 的重载函数的多个版本:
template<typename IterT, typename DistT> // use this impl for | |
void doAdvance(IterT& iter, DistT d, // random access | |
std::random_access_iterator_tag) // iterators | |
{ | |
iter += d; | |
} | |
template<typename IterT, typename DistT> // use this impl for | |
void doAdvance(IterT& iter, DistT d, // bidirectional | |
std::bidirectional_iterator_tag) // iterators | |
{ | |
if (d >= 0) { while (d--) ++iter; } | |
else { while (d++) --iter; } | |
} | |
template<typename IterT, typename DistT> // use this impl for | |
void doAdvance(IterT& iter, DistT d, // input iterators | |
std::input_iterator_tag) | |
{ | |
if (d < 0 ) { | |
throw std::out_of_range("Negative distance"); // see below | |
} | |
while (d--) ++iter; | |
} |
给出针对 doAdvance 的各种重载,advance 需要做的全部就是调用它们,传递一个适当的 iterator category 类型的额外 object 以便编译器利用重载匹配正确的实现:
template<typename IterT, typename DistT> | |
void advance(IterT& iter, DistT d) | |
{ | |
doAdvance( // call the version | |
iter, d, // of doAdvance | |
typename // that is | |
std::iterator_traits<IterT>::iterator_category() // appropriate for | |
); // iter's iterator | |
} |
ps: C++17 后提供的 constexpr if 和 <type_traits> 库是重载的一个替代方法,可以使上述代码更简单易读:
#include <type_traits> | |
template<typename IterT, typename DistT> | |
void advance(IterT& iter, DistT d) { | |
constexpr if (std::is_same_v<typename std::iterator_traits<IterT>::iterator_category, std::random_access_iterator_tag>) { | |
doAdvance(iter, d, std::random_access_iterator_tag{}); | |
} else constexpr if (std::is_same_v<typename std::iterator_traits<IterT>::iterator_category, std::bidirectional_iterator_tag>) { | |
doAdvance(iter, d, std::bidirectional_iterator_tag{}); | |
} else { | |
doAdvance(iter, d, std::input_iterator_tag{}); | |
} | |
} |
# item48 感受模板元编程 (TMP)
本节提供的例子是 TMP 界的 "hello world" 程序:用模板元编程计算阶乘:
template<unsigned n> // general case: the value of | |
struct Factorial { // Factorial<n> is n times the value | |
// of Factorial<n-1> | |
enum { value = n * Factorial<n-1>::value }; | |
}; | |
template<> // special case: the value of | |
struct Factorial<0> { // Factorial<0> is 1 | |
enum { value = 1 }; | |
}; |
然后可以这样使用:
int main() | |
{ | |
std::cout << Factorial<5>::value; // prints 120 | |
std::cout << Factorial<10>::value; // prints 3628800 | |
} |
很基础,但是能体现 TMP 的最大优势:将运行期的计算放到编译器计算,但是可能还不足够显示模板元编程的巨大威力。模板元编程是一门图灵完备的语言,它能做到很多事,它带来的效率提升令人惊叹...
C++11 后,模板元编程正式获得了标准库支持,遗憾的是本书完成之前还没有引入,模板元编程现在有了许多通用的范式和惯例,当需要的时候,可以深入去了解它们。
C++11 的 <type_traits> 库提供了一些非常有用的模板元编程支持,包括 (gpt 生成):
- 型别特征 (Type Traits):
std::is_same<T, U>
: 检查两个类型是否相同。std::is_integral<T>
: 检查类型是否为整型。std::is_floating_point<T>
: 检查类型是否为浮点型。std::is_pointer<T>
: 检查类型是否为指针。- 还有很多其他有用的型别特征,如
is_array
、is_class
、is_enum
等。
- 类型转换:
std::conditional<B, T, F>
: 根据布尔值B
选择类型T
或F
。std::enable_if<B, T>
: 根据布尔值B
启用或禁用类型T
。std::decay<T>
: 获取类型T
的 "衰减"(decay) 形式。
- 数值计算:
std::integral_constant<T, v>
: 表示一个编译时常量值。std::tuple<Types...>
: 异构容器,可用于编译时计算。std::pair<T, U>
: 二元组,可用于编译时计算。
- 函数操作:
std::invoke<F, Args...>
: 调用可调用对象F
并传递参数Args
。std::result_of<F(Args...)>
: 获取调用F(Args...)
的结果类型。
- 算法:
std::make_index_sequence<N>
: 生成一个包含0
到N-1
的整数序列。std::index_sequence_for<T1, T2, ..., TN>
: 根据给定类型生成整数序列。
从这些函数的作用可见 TMP 的强大效用,足以在需要的时候担当重任。
# 内存管理
# item49 了解 new-handler 的行为
对是否处理内存分配失败这件事有争议,一种说法是直接让程序 crash 即可,毕竟内存不足已经是十分严重的系统问题,并且对于内存分配失败程序员大多数时候总是无能为力。但是,任何技术都有适合的场景,并且本节涉及的一些技巧还是十分精妙,有助于学习。
这一节主要介绍了对 new 行为分配内存失败的一种处理方案:new-handler,程序员可以使用 <new> 标准库中的 set_new_handler 函数设置一个 new-handler,在内存分配失败时会调用设置的 new-handler,具体来说它们的声明是这样的:
namespace std { | |
typedef void(*new_handler)(); //new_handler 是一个函数指针类型 | |
new_handler set_new_handler(new_handler p) throw(); // 设置 global new_handler 为 p,并返回旧的 new_handler | |
} |
一个使用的例子是:
void outOfMem() { | |
std::cerr <<"Unable to satisfy request for memory\n"; | |
std::abort(); | |
} | |
int main() { | |
std::set_new_handler(outOfMem); | |
int * pBigDataArray = new int[1000000000L]; | |
... | |
} |
当 operator new 无法满足内存申请时,它会不断调用 new-handler,直到找到足够内存 (见 item 51),从这个描述可以总结出一个好的 new-handler 必须做以下事情之一:
- 让更多内存被使用
- 安装另一个 new-handler(让有能力的人来)
- 卸载 new-handler(将 new-handler 设置为 null,没有安装任何 new-handler 时,operator new 会在内存分配失败时抛出异常
- 抛出 bad_alloc 异常
- 不返回,调用 abort 或 exit
很多时候我们希望以不同的方式处理内存分配失败情况,特别是希望视分配物属于哪个 class 而定,我们想要达到这样的效果:
class X {
public:
static void outOfMemory();
...
}
class Y {
public:
static void outOfMemory();
}
X *p1 = new X; //失败时调用X::outOfMemory
Y *p2 = new Y; //失败时调用Y::outOfMemory
遗憾的是 C++ 不支持为类提供专属的 new-handlers, 我们不得不自己实现这种行为。只需每个 class 提供自己的 set_new_handler 和 operator new 即可,其中 set_new_handler 使客户得以指定 class 专属的 new-handler, 而 operator new 则确保在分配 class 对象内存的过程中用类专属的 new-handler 替换 global new-handler。
假设我们现在想为 Widget 类提供一个专属 new-handler
class Widget { | |
public: | |
static std::new_handler set_new-handler(std::new_handler p)throw(); | |
static void* operator new(std::size_t size) throw(std::bad_alloc); | |
private: | |
static std::new_handler currentHandler; | |
}; | |
std::new_handler Widget::currentHandler = nullptr; | |
std::new_handler Widget::set_new_handler(std::new_handler p)throw() { // 和标准版没什么区别 | |
std::new_handler oldHandler = currentHandler; | |
currentHandler = p; | |
return oldHandler; | |
} |
最重要的是 Widget 重载的 opertor new, 它需要做到以下事情:
- 调用标准 set_new_handler, 将 Widget 的 new-handler 安装为 global new-handler
- 调用 global operator new 执行实际的内存分配,如果分配失败,会调用 widget 的 new-handler,如果最终仍无法分配,会抛出 bad_alloc 异常,并且必须恢复原本的 global new-handler。为了确保原来的 new-handler 总是能够被重新安装回去,将用到 RAII 机制
- 如果 global operator new 成功分配,Widget 的 operator new 会返回一个指向分配的内存的指针,并且要自动恢复调用前的 global new-handler。
代码,运用到了 RAII 机制:
class NewHandlerHolder { // 资源管理类 | |
public: | |
explicit NewHandlerHolder(std::new_handler nh) : handler(nh) {} | |
~NewHandlerHolder() {std::set_new_handler(handler);} // 析构时恢复 global new_handler | |
NewHandlerHolder(const NewHandlerHolder &) = delete; // 禁止拷贝和赋值 | |
NewHandlerHolder& operator=(const NewHandlerHolder &) = delete; | |
private: | |
std::new_handler handler; | |
} | |
// Widget 的 operator new 也很简单 | |
void* Widget::operator new(std::size_t size) throw(std::bad_alloc) { | |
NewHandlerHolder h(std::set_new_handler(currentHandler)); | |
return ::operator new(size); // 成功返回或抛出异常时会自动恢复之前的 new-handler | |
} |
现在 Widget 的客户可以这样方便地为其指定 new-handler:
void outOfMem(); | |
Widget::set_new_handler(outOfMem); | |
Widget* pw1 = new Widget; // 失败是调用 outOfMem | |
std::string* ps = new std::string; // 失败时调用 global new-handler | |
Widget::set_new_handler(0); | |
Widget* pw2 = new Widget; // 失败时抛出异常 |
实现这一方案的代码并不因 class 的不同而不同,因此考虑如何加上复用是一个自然的想法,一个简单的做法是提供一个 base class 来允许派生类继承这一能力。由于每个类都需要获得一个不同的静态 currentHandler 对象,需要用到模板。
template<typename T> | |
class NewHandlerSupport { | |
public: | |
static std::new_handler set_new-handler(std::new_handler p)throw(); | |
static void* operator new(std;:size_t size)throw(std::bad_alloc); | |
... | |
private: | |
static std::new_handler currentHandler; | |
}; | |
template<typename T> | |
std::new_handler | |
NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw() { | |
std::new_handler oldHandler = currentHandler; | |
currentHandler = p; | |
return oldHandler; | |
} | |
templace<typename T> | |
void* NewHandlerSupport<T>::operator new(std::size_t size) throw(std::bad_alloc) { | |
NewHandlerHolder h(std::set_new_handler(currentHandler)); | |
return ::operator new(size); | |
} | |
template<typename T> | |
std::new_handler NewHandlerSupport<T>::currentHandler = 0; | |
// 现在 widget 类只需要 | |
class Widget:public NewHandlerSupport<Widget> { | |
... | |
}; |
# item50 领会何时替换 new 和 delete 才有意义
在以下情况可以替换缺省的 new 和 delete:
- 为了检测运用错误
- 为了收集动态分配内存的使用统计信息
- 为了增加分配和归还的速度
- 为了降低缺省内存管理器带来的额外空间开销
- 为了弥补缺省分配器中的非最佳齐位 (比如 x86 架构对 8 byte 齐位的 double 类型最快,所以重载 opertor new 让分配地址都是 8 byte 齐位)
- 为了将相关对象集中
- 为了获得非传统的行为
内存分配很困难,坑也很多,如需要重写最好还是引入或者参考开源库。
# item51 编写 new 和 delete 时要遵守惯例
首先看看 operator new 大概做了什么:
void * operator new(std::size_t size) throw(std::bad_alloc) | |
{ // your operator new might | |
using namespace std; // take additional params | |
if (size == 0) { // handle 0-byte requests | |
size = 1; // by treating them as | |
} // 1-byte requests | |
while (true) { | |
_attempt to allocate size bytes;_ | |
if (_the allocation was successful_) | |
return (_a pointer to the memory_); | |
// allocation was unsuccessful; find out what the | |
// current new-handling function is (see below) | |
new_handler globalHandler = set_new_handler(0); | |
set_new_handler(globalHandler); | |
if (globalHandler) (*globalHandler)(); | |
else throw std::bad_alloc(); | |
} | |
} |
它处理了零字节(C++ 要求即使请求零字节,operator new 也要返回一个合理的指针),也包含了一个无限循环,跳出循环的唯一出路是内存被成功分配或 new-handling function 做了 item49 中描述的事情之一:使得更多的内存可用,安装一个不同的 new-handler,卸载 new-handler,抛出一个 bad_alloc,或不再返回。我们自己重写的 operator new 也需要遵循这些规则。
有一个容易被忽略的点是 operator new 成员函数会被派生类继承,对于 Class X 来说 它的 operator new 成员函数的 size 一般是与 sizeof (X) 适配的,绝不会更大或者更小。然而,由于继承,就有可能一个基类中的 operator new 被调用来为一个派生类分配内存。如果基类 的 operator new 不是被设计成应付这种情况的(很有可能是基类专属的操作)。它处理这种局面的最佳方法就是把这个请求调用甩给 standard operator new:
void * Base::operator new(std::size_t size) throw(std::bad_alloc) | |
{ | |
if (size != sizeof(Base)) // if size is "wrong," | |
return ::operator new(size); // have standard operator | |
// new handle the request | |
... // otherwise handle | |
// the request here | |
} |
对于 operator delete,事情就更简单了,唯一需要注意的就是 C++ 保证删除空指针总是安全的,所以你需要遵循这个保证。下面是一个 standard operator delete 的伪代码:
void operator delete(void *rawMemory) throw() | |
{ | |
if (rawMemory == 0) return; // do nothing if the null | |
// pointer is being deleted | |
_deallocate the memory pointed to by rawMemory;_ | |
} |
类似地,对于错误大小的 delete 请求,也可以委托给 stardard operator delete 来做。
# item52 如果编写了 placement new,就要编写 placement delete
两个要点:
在编写一个 operator new 的 placement 版本时,确保同时编写 operator delete 的相应的 placement 版本。否则,你的程序可能会发生微妙的,断续的 memory leaks 内存泄漏。
原因是,考虑下面这种代码:
Widget *pw = new Widget;
这行代码会发生两件事,一是调用 operator new 为 Widget 分配内存,然后调用 Widget 的默认构造函数,假设第二步调用抛出了一个异常,第一步的内存分配必须被撤销,否则就是一个内存泄漏。由于 clients 无法获取第一步分配的地址,因此撤销操作必须由 C++ runtime system 来完成。
那么 C++ 会调用哪个 delete 函数来撤销呢,它会调用与这个 new 操作参数一致的 operator delete 来撤销,如果没有提供,它什么也不会做,那么就将发生内存泄漏。
比如说:
class Widget {
public:
...
static void* operator new(std::size_t size, std::ostream& logStream)
throw(std::bad_alloc); // 这里提供了一个 placement new
static void operator delete(void *pMemory) throw();
static void operator delete(void *pMemory, std::ostream& logStream)
throw(); // 必须提供一个与 placement new 对应的 delete 函数
...
};
当你声明 new 和 delete 的 placement 版本时,确保不会无意中覆盖这些函数的常规版本。
item 33 讨论了继承体系中 name 被覆盖的场景和需要考虑的细节,new 和 delete 的编写也不例外。
C++ 在全局提供如下形式的 operator new:
void* operator new(std::size_t) throw(std::bad_alloc); // normal new
void* operator new(std::size_t, void*) throw(); // placement new
void* operator new(std::size_t, // nothrow new —
const std::nothrow_t&) throw(); // see [Item 49]
因此如果你在一个 class 中声明了任何 operator news,都将覆盖所有这些标准形式。除非你有意防止 class 的客户使用这些形式,否则,除了你创建的任何自定义 new 形式以外,还要确保它们都可以使用。