个人博客地址: https://cxx001.gitee.io
本文阅读说明
孔子云:“取乎其上,得乎其中;取乎其中,得乎其下;取乎其下,则无所得矣”。
对于读书求知而言,这句古训教我们去读好书,最好是好书中的上品----经典书。《Effective C++》就是这类经典书,值得反复去读,每次都能有不一样的收获。
有人说C++程序员可以分为两类,读过Effective C++的和没读过的。近段再次精读记录下全书纲领,每小节最后都有请记住
精炼总结,便于日后据此回顾。
本书电子版
一、让自己习惯C++
1. 视C++为一个语言联邦
C++可以看成是相关语言组成的一个集合而非单一语言,在其某个次语言中,各种守则与示例都倾向于简单、直观易懂、并且容易记住。然而当你从一个次语言移往另一个次语言,守则可能改变。每个次语言都有自己的规约。
为了深刻理解C++,你必须认识其主要的次语言。幸运的是总共只有4个:
- C
- Object C++ 也就是带类的C
- Template C++ 这是C++的范型编程
- STL 是个template程序库
请记住
C++ 高效编程守则视状况而变化,取决于你使用C++ 的哪一部分。
2. 尽量用 const, enum, inline 替换 #define
define只是在预处理器中做简单的字符替换,编译器根本就不知道define定义的宏符号。
使用define存在一些问题:
- 没有作用域,只能全局的。
- 常量宏定义如果出错,编译器报错只知道常量值,根本就不知道宏名称,排错时容易让人迷惑。
- 带参数的宏使用时要特别小心运算优先级,受参数内容影响,特别容易错误。
- 没有类型检查。
所以对于定义常量一般我们用const替换,有两种特殊情况值得说说。
第一是定义常量指针,由于常量定义通常在头文件内(以便被不同的源码引入),因此有必要将指针和指针所指的值都声明为const,也就是常量指针常量。
const char * const authorName = "Scott Meyers";
这种定义字符串常量,我们一般用string对象更合宜。
const std::string authorName = "Scott Meyers";
第二是类里的常量定义。为了确保此常量至多只有一份,你必须让它成为一个static成员:
class GamePlayer {
private:
static const int NumTurns = 5; // 如果编译器认为这只是声明,那么要把这个定义写到cpp中
int scores[NumTurns]; // 这种编译器可能不允许用static常量,那么我们可以用enum替换
}
// 使用enum来声明数组长度值(数组在编译时编译器必须知道它的长度)
class GamePlayer {
private:
enum {NumTurns = 5};
int scores[Numturns]; // 这就没问题了
}
下面我们再看define定义带参数的函数式宏容易错误的例子:
// a和b的较大值调用f
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b));
int a = 5, b = 0;
CALL_WITH_MAX(++a, b); // a被累加两次
CALL_WITH_MAX(++a, B+10); // a被累加一次
// 在这里,调用f之前,a的累加次数竟然取决于它被拿来和谁比较~
我们用inline来替换的话,效率上不会比define差,都是在调用处直接展开,没有函数调用的开销。也不需要考虑宏参数都加括号和参数被计算多次等问题。
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
f(a > b ? a : b);
}
请记住
- 对于单纯常量,最好以const 对象或 enum替换define
- 对于形似函数的宏,最好改用inline函数替换define
3. 尽可能使用const
-
常量指针、指针常量、常量指针常量
const char* p = "greeting"; // 常量指针,数据不能修改 char* const p = "greating"; // 指针常量,指针不能修改 const char* const p = "greating" // 常量指针常量,指针和数据都不能修改
-
stl迭代器常量
迭代器的作用就像个 T* 指针。 const修饰迭代器,其实类似T* const,它是迭代器不能修改,但所指的值可以修改。如果要保证值也不能修改,那就只能用 const_iterator 迭代器了。
std::vector<int> vec; ... const std::vector<int>::iterator iter = vec.begin(); *iter = 10; // 没问题,改变iter所指的值 ++iter; // 错误! iter指针是常量 std::vector<int>::const_iterator citer = vec.begin(); *citer = 10; // 错误! *citer是常量 ++citer; // citer指针可以改变,没问题
-
const成员函数
const成员函数:在其函数体内不能修改该对象的数据成员,这里本质const修饰的是函数隐式参数this。
const对象只能调用const成员函数,本质也一样,因为修饰的是this。(const成员函数可以被非const对象调用)
如果在const函数中确实想修改某些数据成员,那么可以把这些数据成员声明时用
mutable
修改,表示可变的。补充:
函数重载:
- 函数的参数不同(个数、顺序),与返回值无关
- 常量性不同,也可以重载 (常量成员函数与非常量成员函数也能重载)
类型转换:
// 除const修饰的对象类型转换用 static_cast double somevalue = 3.14; void* p = &somevalue; double* pd = static_cast<double*>(p); // 将 void*类型指针转换为double*类型 // 去除const对象的常量属性用 const_cast const char* pc; char* p = const_cast<char*>(pc); // 将常量指针pc转换为普通指针
请记住
- 将某些东西声明为 const 可帮助编译器识别错误用法。const 可被用于任何作用域内的对象、函数参数、函数返回值、成员函数体。
- 编译器强制实施 “const修饰对象不能被修改” ,但你编写程序时应该使用 ”概念上的常量性“。
- 当 const 和 non-const 成员函数有着一样的实现时,可以用non-const 版本调用const 版本可以避免代码重复。
4. 确定对象被使用前已先被初始化
读取未初始化的值会导致不明确的行为。在某些平台上,仅仅只是读取未初始化的值,就可能让你的程序终止运行。更可能的情况是读入一些"半随机"值,污染了正在进行读取动作的那个对象,最终导致不可预测的程序行为,以及许多令人不愉快的调试过程。
所有我们定下一个规则,永远在使用对象之前将它初始化。这很简单,但是对于自定义类型,初始化责任就落在构造函数身上了,这里重要的是别混淆了赋值和初始化。
在构造函数里给成员数据赋值不是初始化,而是赋值操作。程序会先调用默认构造函数初始化,然后再调用自身构造函数赋值。这里要避开赋值操作就得用成员初始化列表的形式,这样在调用默认构造函数时直接使用成员初始化列表的值初始化,这样效率也更高。
自定义类型数据初始化顺序和它声明时一致,和构造函数里赋值顺序无关,所以为了不让阅读者歧义,我们约定构造函数里初始化数据时顺序和声明时保持一致。
当我们已经做到在对象创建时都初始化它,那么就只有一件事情需要操心了,那就是注意不同编译单元内定义的全局对象的初始化顺序(不同编译单元指不同的cpp源文件中)。
class FileSystem {
public:
...
std::size_t numDisks() const;
...
};
extern FileSystem tfs; // 给其它类使用的全局对象
class Directory {
public:
Directory(params);
...
};
Directory::Directory(params)
{
...
std::size_t disks = tfs.numDisks(); // 使用其它编译单元的对象
...
}
// 创建一个Directory对象
Directory tempDir(params);
这样上面两个编译单元的对象谁先初始化,这个编译器也不知道。
对于不同编译单元的全局对象初始化顺序问题,要避免我们得把全局对象包装到函数块里。
class FileSystem {...};
FileSystem& tfs()
{
static FileSystem fs;
return fs;
}
///
class Directory {...};
Directory::Directory(params)
{
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir()
{
static Directory td;
return td;
}
将全局对象封装到函数里,把直接访问全局对象改为调用函数的形式,这样就不用关心跨编译单元对象的初始化顺序问题了。
请记住
- 为内置类型对象进行手工初始化,因为c++不保证初始化它们。
- 构造函数最好使用成员初始化列表,而不要在构造函数本体内使用赋值操作。初始化列表的成员变量,其顺序应该与它们声明顺序一致。
- 为避免"跨编译单元的初始化顺序"问题,请用函数包装替换全局对象。
二、构造/析构/赋值运算
5. 了解C++默默编写并调用哪些函数
当编写了一个空类,C++编译器会自动(如果它们被使用的话)给这个空类生成一个默认构造函数、一个拷贝构造函数、一个赋值操作符重载函数和一个析构函数。
// 如果你写下这么个空类
class Empty {};
// 这就好像你写下这样的代码
class Empty {
Empty() {...}
Empty(const Empty& rhs) {...}
~Empty() {...}
Empty& operator=(const Empty& rhs) {...}
}
注意上面4个函数:
- 只有它们被需要(被调用),它们才会真正被编译器创建出来。
- 只要被我重定义了的,编译器就不会再自动生成它们。注意:如果我重定义了任何构造函数则默认构造函数就不会自动创建了,而如果只重定义了默认构造函数,则上面剩余函数编译器还是会自动生成。
上面自动生成的拷贝构造函数和赋值操作符函数其内部实现如出一辙,但是有下面3种特殊情况,编译器会拒绝生成赋值操作符函数。
- 带有
&
引用的成员。 - 带有
const
的成员。 - 某个基类将赋值操作符函数声明为private,那么其派生类编译器也会拒绝生成赋值操作符函数。
1、2情况示例:
template<class T>
class NameObject {
public:
NameObject(std::string& name, const T& value);
private:
std::string& nameValue;
const T objectValue;
}
// 考虑下面会发生什么事:
std::string newDog("Persephone");
std::string oldDog("Satch");
NameObject<int> p(newDog, 2);
NameObject<int> s(oldDog, 36);
p = s; // 这是不允许的,1. 引用只能指向引用的对象不能修改 2. const修饰成员不能被修改
请记住
- 编译器可以暗自为class创建default构造函数、copy构造函数、copy assignment操作符,析构函数。
6. 若不想使用编译器自动生成的函数,就该明确拒绝
如果某个场景需要某个类对象是唯一独一份的,不想外部拷贝这个对象。
直接想到的是限制它的拷贝构造函数和赋值操作符函数,即不让外部调用它们。
那第一种方案就是重定义它们并声明为private
属性(注意不要实现它们,目标是防止自己内部或friend函数调用)。
class HomeForSale {
public:
...
private:
// 只声明不实现,目标是防止自己内部或friend函数调用
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&);
}
第二种方式是放基类里去声明它们为private
属性,然后只需要继承它,那么派生类默认生成的拷贝构造函数和赋值操作符函数会去调用基类里对应的函数。从而达到同样效果(注意多重继承问题)。
class Uncopyable
{
protected:
Uncopyable() {};
~Uncopyable() {};
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};
#include "Uncopyable.h"
class HomeForSale : private Uncopyable
{};
请记住
为阻止编译器自动生成函数机制,可将相应的成员函数声明为
private
并且不予实现。或者继承像上面Uncopyable
这样的基类也是一种做法。
7. 为多态基类声明virtual析构函数
在多态场景中,基类指针指向派生类对象,delete
基类指针,基类的析构函数要加virtual
,不然只会销毁派生类中基类部分而自身内的成员没被销毁,于是造成一个诡异的局部销毁对象,导致内存泄漏、败坏数据结构。
如果一个类不含virtual
函数,通常表示它并不意图被用做一个基类使用。当类不企图被当作基类,令其析构函数为virtual
往往是个馊主意(平白多一个虚表指针空间)。记住虚函数一般用于多态场景的基类中。
许多人的心得是:只有当类内至少含有一个virtual
函数,才为它声明virtual
析构函数。
请记住
- 带多态性质的基类应该声明一个
virtual
析构函数。如何类带有任何virtual
函数,它就应该拥有一个virtual
析构函数。- 类的设计目的如果不是作为基类使用,或不是为了具备多态性,就不该声明
virtual
析构函数。
8. 别让异常逃离析构函数
析构函数抛出异常可能会带来不明确的行为。因此我们如果一定要在析构函数内执行可能失败的操作,就必须要用try...catch
截取异常,不让异常从析构函数抛出去导致不明确行为发生。
截取异常正确做法一般有2种情况:
- 直接吞下异常或者结束程序。
DBConn::~DBConn()
{
try {
db.close();
} catch (...) {
// std::abort(); //自己选择要么记录错误什么也不做,要么让程序终止
}
}
- 可能失败的操作提供对外接口,使外部有机会对可能失败的情况做出反应。
DBConn::~DBConn()
{
public:
void close() // 给外部一个处理有可能错误的机会
{
db.close();
closed = true;
}
~DBConn()
{
if (!closed) {
try {
db.close();
} catch (...) {
// std::abort(); //自己选择要么记录错误什么也不做,要么让程序终止
}
}
}
private:
DBConnection db;
bool closed;
}
请记住
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吐下它们或者结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(而非在析构函数中)执行该操作。
9. 绝不在构造和析构过程中调用virtual函数
这条规则是这么一个场景:在多态的基类构造或析构函数中调用重写的virtual
函数,那么在派生类对象构造或析构时,调用的函数是基类的,并不是派生类重写后的。
因为在派生类构造时,先调基类的构造,此时派生类对象还不存在,并没有多态属性。析构也是同理,先把派生类释放了,再析构基类时,派生类对象也不存在了。
所以基类构造/析构永远不要调用virtual
函数,可行的方案是把virtual
去掉,派生类通过传参调用。
请记住
- 在构造和析构期间不要调用
virtual
函数,因为这类调用从不下降至派生类。
10. 令operator= 返回一个 reference to *this
赋值操作符必须返回一个reference指向操作符的左侧实参。这是一个约定协议,并无强制性。如果不遵循代码也不会报错。然而所有内置类型和标志程序库提供的类型如string``vector``trl::shared_ptr
等都遵循这个约定。因此除非你有一个标新立异的好理由,不然还是随众吧。
class Widget {
public:
...
Widget& operator+=(const Widget& rhs) // 赋值相关的运行也适用这个约定+=,-=,*=等
{
...
return *this;
}
Widget& operator=(int rhs)
{
...
return *this;
}
...
}
请记住
令赋值操作符返回一个reference to *this。
11. 在operator= 中处理"自我赋值"
重载赋值操作符时,要注意自我赋值的处理。像下面这样自我赋值时就会导致返回的指针指向一个已被删除的对象:
// Widget w; w = w;
class Widget {
...
private:
BitMap* pb;
}
Widget& Widget::operator=(const Widget& rhs)
{
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
正确的写法有3种方式:
- 在开始处验证自我赋值
Widget& Widget::operator=(const Widget& rhs)
{
if(this == rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
- 记录旧对象,改变调用顺序
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* pOrig = pb;
pb = new Bitmap(*rhs.pb);
delete pOrig;
return *this;
}
- 创建副本做数据交换
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); // 为rhs数据创建副本
swap(temp); // 将*this数据和副本的数据做交换
return *this;
}
请记住
- 确保当对象自我赋值时
operator=
有良好行为。其中技术包括比较来源对象和目标对象的地址、精心周到的语句顺序、以及copy-and-swap。- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
12. 复制对象时勿忘其每一个成分
这里主要注意复制派生类对象时(拷贝构造函数和赋值操作符函数),要主动调用其基类对应的复制函数。
先看下面不规范示例:
class PriorityCustomer: public Customer {
public:
...
PriorityCustomer(cosnt PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
...
private:
int priority;
}
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: priority(rhs.priority)
{
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
priority = rhs.priority;
return *this;
}
上面这种只复制了派生类中的成员,由于没有指定实参给基类的拷贝构造函数,则会调用默认不带参的构造函数来构造基类成员。
正确写法:
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // 调用基类构造函数
priority(rhs.priority)
{
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
Customer::operator=(rhs); // 调用基类赋值操作
priority = rhs.priority;
return *this;
}
如果上面两个复制函数重复代码很多,也可以考虑把重复部分封装到一个如:init的私有函数中,给两者调用。
请记住
- 复制函数(拷贝构造函数和赋值操作符函数)应该确保复制对象内的所有成员变量及所有基类成分。
- 不要尝试以某个复制函数实现另一个复制函数。应该将共同部分放进第三个函数中,并由两个复制函数共同调用。
三、资源管理
13. 以对象管理资源
为了防止资源泄漏,我们管理资源的释放一般不人为维护,而是**通过对象构造时引用资源,析构时释放资源来管理。**这样管理资源的对象我们称之为RAII
(Resource Acquisition Is Initialization)对象。如:智能指针就是这样的思路。
请记住
- 为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
- 两个常被使用的RAII对象分别是shared_ptr和auto_ptr。前者通常是较佳选择,因为其copy行为比较直观。若选auto_prt,复制动作会使被复制对象变为null。
14. 在资源管理类中小心coping行为
请记住
- 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。(参考不同类型智能指针行为)
- 普遍而常见的RAII对象的复制行为是:转移资源、抑制copying、施行引用计数法。(同样可参考不同类型智能指针实现)
15. 在资源管理类中提供对原始资源的访问
对于RAII类我们一般要提供对原始资源的访问。有两种方式:显示转换和隐式转换。
显示转换(提供一个接口get获取):
// RAII类Font
class Font {
public:
explicit Font(FontHandle fh) : f(fh) {}
~Font() {releaseFont(f);}
FontHander get() const {return f;} // 显示转换函数
private:
FontHandle f; // 原始资源
}
// 使用
...
void changeFontSize(FontHnadle f, int newSize);
Font f(getFont());
int newFontSize;
...
changeFontSize(f.get(), newFontSize); // 显示获取f的原始资源
隐式转换:
class Font {
public:
...
operator FontHandler() const // 隐式转换函数
{return f;}
...
}
// 使用
Font f(getFont());
int newFontSize;
...
changeFontSize(f, newFontSize); // 将Font隐式转换为FontHandler
请记住
- RAII往往要求访问原始资源,所以每一个RAII类应该提供一个“取得其所管理之资源"的办法。
- 对原始资源的访问可能经由显示转换或隐式转换。一般而言显示转换比较安全,但隐式转换对客户比较方便。
16. 成对使用new和delete时要采取相同形式
如果new对象,对应delelte对象,如果new数组,对应delete数组(加[])。这点很容易,不过注意一种情况,对数组使用了typedef重命名后,就比较隐蔽了。所以约定尽量不要对数组形式做typedef动作。这很容易达成,因为C++标准库含有string,vector等template,可将数组的需求降至几乎为零。
请记住
如果你在new表达式中使用[],必须在相应的delete表达式中也使用[]。如果你在new表达式中不使用[],一定不要在相应的delete表达式中使用[]。
17. 以独立语句将new对象置入智能指针
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());
编译器在调用processWidget
之前,必须首先核算即将被传递的各个实参。上面第二实参只是一个单纯的对priority
函数的调用,但第一实参std::tr1::shared_ptr<Widget>(new Widget)
由两部分组成:
- 执行
new Widget
表达式 - 调用
shared_ptr构造函数
于是在调用processWidget
之前,编译器必须创建代码,做以下三件事:
- 调用
priority
- 执行
new Widget
表达式 - 调用
shared_ptr构造函数
c++编译器以什么样的次序完成这些事情呢?弹性很大。不过可以确定的是new Widget
一定执行在share_ptr构造函数
之前,因为这个表达式的结果还要被传递作为shard_ptr构造函数
的一个实参,但对priority函数
的调用则可以排在第一或第二或第三执行。如果编译器选择第二顺位执行它,最终获得这样的操作序列:
- 执行
new Widget
表达式 - 调用
priority
- 调用
shared_ptr构造函数
问题来了,如果2执行失败,抛异常了,那么1 new的内存还没来得及置入智能指针中,就导致内存泄漏了。
解决上面问题办法很简单,就是分离语句:
std::tr1::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());
以上之所以行得通,因为编译器对于跨语句的各项操作没有重新排序的自由(只有在语句内它拥有那个自由度)。
请记住
以独立语句将new对象置入智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄漏。
四、 设计与声明
18. 让接口容易被正确使用,不易被误用
**理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码就不该通过编译;如果代码通过了编译,它的行为就该是客户所想要的。**尽量不要在运行期才发现问题。
示例一 日期的class设计构造函数
class Date {
public:
Date(int month, int day, int year);
...
}
上面接口设计很容易让客户至少犯下两个错误。
- 他们也许会以错误的次序传递参数。
- 他们可能传递一个无效的月份或者天数。
正确的设计是:
// 限制类型
struct Day {
explicit Day(int d) : val(d) {}
int val;
}
struct Month {
explicit Month(int m) : val(m) {}
int val;
}
struct Year {
explicit Year(int y) : val(y) {}
int val;
}
class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...
};
// 使用
Date d(Month(12), Day(1), Year(2022));
// 进一步限制参数的值有效性设计,如:
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::Dec(), Day(1), year(2022));
示例二:通用行为接口一致性,与标准、内置类型尽量保持一致。STL容器的接口就十分一致,这使得它们非常容易被使用。例如每个STL容器都有一个名为size的成员函数,它会告诉调用者目前容器内有多少对象。
示例三:接口返回指针应该返回智能指针,不应该对外返回原始指针,客户还要关系指针释放操作。
std::tr1::shared_ptr<Investment> createInvestment();
shared_ptr智能指针还一个特性,就是在创建时还可以指定自定义析构行为。
// 参数1 是原始指针 参数2 引用计数为0时执行函数deleteFunc
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), deleteFunc);
接口返回智能指针还消除另一个潜在的客户错误,就是跨dll传递问题。这个问题发生于对象在动态库中被new创建,却在另一个动态库内被delete销毁。在许多平台上,这类问题会导致运行期错误。而使用shared_ptr就没有这个问题,因为当引用计数为0时会追踪调用原始那个动态库上的销毁。
请记住
- 好的接口很容易被正确使用,不容易被误用。你应该在你的所在接口中努力达成这些性质。
- ”促进正确使用“ 的办法包括接口的一致性,以及与内置类型的行为兼容。
- ”阻止误用“ 的办法包括建立新类型、限制类型上的操作(private)、束缚对象值,以及消除客户的资源管理责任。
- tr1::share_ptr支持定制型删除器。这可防范dll问题,可被用来自动解除互斥锁(RAII类设计管理锁)等等。
19. 设计class犹如设计type
类本质是自定义的类型,要像设计内置类型一样设计类。重载函数和操作符、控制内存的分配和归还、定义对象的初始化和终结…全都在你手上。
设计优秀的类是一项艰巨的工作,甚至类的成员函数效率都有可能受到它们“如何被声明”的影响。那么如何设计高效的类呢?下面提出了一些问题,你的回答往往导致你的设计规范:
- **新type的对象应该如何被创建和销毁?**这会影响到你的构造函数和析构函数以及内存分配函数和释放函数(operator new, operator [], operator delete和operator delete[]) 的设计。(见第8章)
- **对象的初始化和对象的赋值该有什么样的差别?**这个答案决定你的构造函数和赋值操作符的行为。(见条款4)
- **新type的对象如果以值传递,意味着什么?**记住copy构造函数决定了你以值传递的行为。
- **什么是新type的合法值?**对class外部传入的参数一定要做错误检测工作。
- **你的新type要考虑继承或被继承影响么?**如果你继承已有的classes,你就受到那些classes的设计的束缚,特别是受到它们的函数是
virtual
或non-virtual
的影响。如果你允许其它classes继承你的class,那会影响你所声明的函数,尤其是析构函数是否加virtual
。(见条款7、34、36) - **你的新type需要什么样的转换?**你是否希望你的type和其它types之间可以转换,是隐式转换或显示转换。或者不允许隐式转换,只允许
explicit
的构造函数存在,就得写出专门负责执行转换的函数。(条款15有显示/隐式转换函数范例) - **什么样的操作符和函数对新type是合理的?**这个问题答案决定你将为你的class声明哪些函数。其中哪些该是成员函数,哪些不该。(见条款23,24,46)
- **什么样的标准函数应该驳回?**这些正是你必须声明为private的,而且不去实现。(见条款6)
- **谁该取用新type的成员?**这个提问可以帮助你决定哪些成员为public,哪些为protected,哪些为private。它也帮助你决定哪些classes或functions应该是friends,以及将它们嵌套于另一个之内是否合理。
- **新type的内部约束?**它对效率、异常安全性(见条款29)以及资源使用(如多任务锁和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。
- **你的新type有多么一般化?**或许你其实并非定义一个新type,而是定义一整个types家族。果真如此你就不该定义一个新class,而是应该定义一个新的class template。
- **你真的需要一个新type吗?**如果只是定义新的派生类以便为既有的class添加机能,那么说不定单纯定义一个或多个非成员函数或templates,更能够达到目标。
这些问题不容易回答,所以定义出高效的classes是一种挑战。然而如果能够设计出至少像C++内置类型一样好的用户自定义classes,一切汗水便都值得。
请记住
Class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过上面所有讨论主题。
20. 传引用替代传值(函数参数)
函数参数都是以实际实参的副本为初值,而调用端所获得的亦是函数返回值的一个副本。这些副本是由对象的copy构造函数产出。
值传递会调用对象的copy构造函数,使用完又调用析构函数。如果比较复杂对象,还有嵌套子对象,这样值传参是昂贵费时的。通过引用传递,底层实际是传递的对象指针,这样对象的copy构造函数和析构函数都不会调用。
请记住
尽量以引用传递替换值传递。前者通常比较高效,并可避免切割问题(派生对象给基类)。
以上规则并不适用与内置类型,以及STL的迭代器和函数对象。对它们而言,值传递往往比较适当。
21. 必须返回对象时,别返回引用(函数返回值)
请记住
绝不要返回指针或引用指向一个栈区对象(函数返回后销毁了),或返回引用指向一个堆区对象(增加内存泄漏风险),或返回指针或引用指向一个static对象而由可能同时需要多个这样的对象(条款4已经为“在单线程环境中合理返回引用指向一个static对象”提供了一份设计示例)。
22. 将成员变量声明为private
请记住
- 切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
- protected并不比public更具封装性。从封装角度讲,其实只有两种访问权限:private(提供封装)和其它(不提供封装)。
23. 宁以non-member、non-friend替换member函数
首先要知道,class里member函数越多,意味着能访问private成员变量的函数越多,封装性越低。如果要你在一个member函数和一个non-member,non-friend函数之间做抉择,而且两者都能实现相同的机能,那么,导致较大封装性的是后者,因为它并不增加能够访问class内的private成分的函数数量。
这个条款多见应用场景是工具函数的封装,头文件还可以根据功能分类出多个并加上namespace保护。头文件按功能分类成多个,使用时才可以按需只导入自己需要的。
请记住
- 宁可拿non-member、non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩展性。
24. 若所有参数皆需类型转换,请为此采用non-member函数
这点书中示例感觉有点牵强,就举例了一个operator*
函数为了满足交换律,所以把operator*
封装到了class之外。就得出此条款感觉前后并没有必然联系~ 相关条款46。
todo: 待后续再读解惑~
请记住
- 如果你需要为某个函数的所有参数进行类型转换,那么这个函数必须是个non-member。
25. 考虑写出一个不抛异常的swap函数
怎么写一个高效且不抛异常的swap?一般而言这两点是一起的,因为高效率的swap几乎总是基于对内置类型的操作,而内置类型上的操作绝不会抛出异常。
首先看看std标准库提供的swap算法,大概下面这样:
namespace std {
template<typename T>
void swap(T& a, T&b)
{
T temp(a);
a = b;
b = temp;
}
}
只要类型T支持copy构造函数和copy操作符就行。它并不高效,涉及了3个对象的复制。所以针对自定义类我们常常要定制swap,看看下面示例:
class WidgetImpl {
public:
...
private:
int a, b, c;
std::vector<double> v;
...
}
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
{
...
*pImpl = *(rhs.pImpl);
...
}
...
private:
WidgetImpl* pImpl;
}
观察Widget对象置换,其实只要置换pImpl指针就行了。
// Widget内部增加swap定制接口
class Widget {
public:
...
void swap(Widget& other)
{
using std::swap;
swap(pImpl, other.pImpl);
}
...
}
// 同时为std::swap添加这个定制版本。STL容器也是这么干的,同时提供了public swap成员函数和std::swap定制版本。
namespace std {
template<> // 声明下面Widget是定制版本
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b);
}
}
还一种情况,如果上面Widget和WidgetImpl都是模板类而非类的话。那就不能std里添加定制swap了(编译不通过),需要用non-member函数替代。
namespace WidgetStuff { // 加命名空间是个好习惯
...
template<typename T>
class WidgetImpl {...}
// 模板类,内含swap成员函数
template<typename T>
class Widget {...};
...
// non-member函数的swap
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
请记住
- 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
- 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请提供定制std::swap。
- 调用swap时应针对std::swap使用using声明,然后调用swap并且不带任何"命名空间修饰"。
- 为自定义类型进行std::swap定制是好的,但是往std中新增templates是不允许的。
五、实现
26. 变量尽可能在使用时定义
提前定义变量,有可能导致变量并没有使用(如中间抛异常了),而平白多了一个构造和析构成本。
但是循环怎么办?
// 方式A
Widget w;
for (int i = 0; i < n; ++i) {
w = xxx;
...
}
// 方式B
for (int i = 0; i < n; ++i) {
Widget w = xxx;
...
}
做法A:1个构造 + 1个析构 + n个赋值操作
做法B:n个构造函数 + n个析构函数
打破本条款选择A的依据:
(1)你知道赋值成本比“构造+析构"成本低。
(2)你正在处理代码中效率高度敏感的部分。
否则你应该使用做法B,维持变量尽可能在使用时定义的原则。
请记住
尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
27. 尽量少做转型动作
C语言风格类型转换(老式)
(T)expression // 显示强转
T(expression) // 构造隐式转换
C++新式转换,提倡统一都用新式风格,职能分类,更安全,更清晰。
-
const_cast(expression) 用于将对象的常量性移除。也是唯一有此能力的C+±style转型操作符。
-
dynamic_cast(expression) 一般用于安全向下转型,如基类到派生类。要谨慎,可能效率低下。
-
static_cast(expression) 用来强制隐式转换,一般用于相关联类型转换,没有类型检测。如将int转double,派生类转基类(安全),基类转派生类(不安全)等。
-
reinterpret_cast(expression) 一般用于不相干类型转换,没有限制。如int* 转int,int转函数指针等。常用于转换函数指针,即可以将一种类型的函数指针转换为另一种类型的函数指针。
转型破环了类型系统。那可能导致任何种类的麻烦,有些容易识别,有些非常隐晦。所以尽量少做转型操作。
请记住
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型操作放进客户代码中。
- 宁可使用C+±style转型,不要使用旧式转型。前者很容易识别出来,而且也比较有着分门别类的职掌。
28. 尽量避免返回handles指向对象内部成分
handers是指:对象内的子对象的引用、指针或迭代器。返回子对象的引用、指针、迭代器会降低封装性,外部能越级访问深层级的对象并修改属性。
这并不意味着你绝对不可以让成员函数返回handle。有时候你必须那么做。例如operator[]就允许你获取strings和vectors的元素。尽管如此,这样的函数毕竟是例外,不是常态。
请记住
避免返回handles(包括引用、指针、迭代器)指向对象内部。遵守这条条款可增加封装性,帮助const成员函数的行为像个const,并将发生”虚吊号码牌“的可能性降至最低。
29. 为”异常安全"而努力是值得的
我们要时刻要求自己写的函数都是异常安全函数。
异常安全函数有两个条件:
- 不泄漏任何资源。
- 不允许数据败坏。
同时,异常安全函数业内分了3个级别保证,你至少满足其中之一。
- 基本承诺:如果抛出异常,程序内的任何事物仍然保持在有效状态下。
- 强烈保证:如果抛出异常,程序状态不改变。如果函数成功,就是完全成功,如果函数失败,程序会回到调用函数之前的状态。
- 不抛异常保证:承诺绝不抛出异常,所有操作都是作用于内置类型身上。
一般而言,我们都应该尽量做到强烈保证这个级别。而最高级别很多时候很难做到,任何使用动态内存的东西如果内存不足都有可能抛出异常。
下面是为编写异常安全函数而努力的示例:
class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); // 改变背景图片
...
private:
Mutex mutex; // 多线程环境,互斥锁
Image* bgImage; // 当前背景图片
int imageChanges; // 背景图片改变次数
}
// 这是我们最常规思路的实现
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}
上面这个常规思路的实现,如果new Image抛异常,lock资源泄漏,bgImage,imageChanges数据也招到破坏。不满足异常安全函数条件任何一个。下面我们来看怎么解决这两个问题:
class PrettyMenu {
...
std::tr1::shared_ptr<Image> bgImage; // 智能指针
...
}
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex); // 封装锁,见条款14
bgImage.reset(new Image(imgSrc));
++imageChanges;
}
做到这里还只能说满足基本承诺,如果Image构造函数抛异常(这里抛异常由编译器内部实现),有可能破环外部引用的imgSrc数据源(todo:这里有点牵强~感觉做到这一步已经是强烈保证了!)。
还记得我们前面写过一个不抛异常的swap么?就可以用在这里,我们让改变背景图片的操作先在副本对象中操作,都正确操作完后,在用swap交换数据,这样就保证了即使失败了也不会影响原有数据状态。
struct PMImpl {
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
}
class PrettyMenu {
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
}
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap;
Lock ml(&mutex);
std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl)); // 副本对象
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pImpl, pNew); // 交互数据
}
强烈保证并非时刻都显得实际,也要衡量空间、效率成本。当强烈保证不切实际时,你就必须保证提供基本保证。
请记住
- 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构破坏。这样的函数有3中可能的保证:基本型、强烈型、不抛异常型。
- 强烈保证往往能够以swap来实现出来,但强烈保证并非对所有函数都可实现或具备现实意义。
- 函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的异常安全保证中的最低者。
30. 透彻了解inline的里里外外
inline行为发生在编译期间,编译器是否要进行inline,不是取决于函数带不带inline,有时带了inline也不一定会inline(virtual函数,运行时才知道调用哪个),没带也可能inline(实现在头文件中)。
请记住
- 将大多数inline限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为函数模板出现在头文件中定义,就将它们声明为inline。
31. 将文件间的编译依赖关系降至最低
直接看示例代码:
// 相关头文件引入
#include <string>
#include "date.h"
#include "address.h"
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::string theName;
Date theBirthDate;
Address theAddress;
}
上面Person定义文件和其包含的文件之间形成了一种编译依赖关系。如果这些头文件中任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个包含Person class的文件就得重新编译。这样的连串编译依赖关系会对许多项目造成难以形容的灾难。
解决这个问题的本质是让类的接口与实现分离(加快编译速度)。通常有两种做法:
第一种拆分两个类,一个用于声明,一个用于实现。
#include <string>
#include <memory>
// class 只是声明这个类,没有定义信息,可以使用类的引用和指针(大小固定),不能有定义。减少编译依赖手段。
// include 则是把整个类导入,包含了定义信息,也注入了依赖相关,增加了编译时间。
class PersonImpl;
class Date;
class Address;
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::tr1::shared_ptr<PersonImpl> pImpl; // 指向实现类
}
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{}
std::string Person::name() const
{
return pImpl->name(); // simunps的fileManger封装就是类似这种做法
}
将实现分开后,即使修改了实现部分逻辑,对包含了Person接口类的其它类也没有影响,不需要重新编译。
第二种是用接口类。
这种类的目的是详细一一描述派生类的接口,因此它通常没有成员变量,也没有构造函数,只有一个virtual析构函数以及一组纯虚函数声明。
// 接口定义,外部使用通过基类的create接口即可
class Person {
public:
virtual ~Person();
static std::tr1::shared_ptr<Person> create(
const std::string& name,
const Date& birthday,
const Address& addr);
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
}
std::tr1::shared_ptr<Person> Person::create(
const std::string& name,
const Date& birthday,
const Address& addr)
{
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
// 实现定义
class RealPerson : Person {
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr)
{}
virtual ~RealPerson() {}
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::string theName;
Date theBirthDate;
Address theAddress;
}
请记住
- 支持编译依赖最小化的一般构想是:依赖声明式的头文件,不要依赖定义式的头文件。(声明与定义拆两个类,外部只include声明的类头文件) 基于此构想有两种手段:接口与实现拆分两个类和接口类。
- 程序库头文件应该以“完全且仅有声明式的形式存在。就是include的类都是声明式的类,其真实实现在另一个类中。
六、继承与面向对象设计
32. 公有继承
公有继承:继承过来的基类成员访问属性不变。
保护继承:继承过来的基类中的私有成员访问属性不变,公有成员和保护成员变为保护成员。
私有继承:继承过来的基类中的私有成员属性不变,公有成员和保护成员变为私有成员。
不管是哪种继承方式,派生类中成员可以访问基类的公有成员和保护成员,无法访问私有成员。而继承方式影响的是派生类继承成员的访问属性。
请记住
public继承:适用于base classes身上的每一件事情一定也适用与derived classes身上,因为每一个derived class对象也都是一个base class对象。
33. 避免遮掩继承而来的名称
派生类中函数会遮掩基类中的同名函数。从名称查找来看,像是基类中对应的同名函数没被继承过来一样。简单来说就是作用域问题,派生类覆盖基类。
class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
...
}
class Derived: public Base {
public:
virtual void mf1();
void mf3();
void mf4();
...
}
Derived d;
int x;
...
d.mf1(); // 没问题,调用Derived::mf1
d.mf1(x); // 报错!Derived::mf1遮掩了同名的Base::mf1
d.mf2(); // 没问题,调用Base::mf2
d.mf3(); // 没问题,调用Derived::mf3
d.mf3(x); // 报错,Derived::mf3遮掩了Base::mf3
如果不想被派生类同名函数把基类中所有其它重载函数都遮掩了,可以使用using声明。
// 使用using后,上面两处报错的都可以找到Base::mf1/Base::mf3了。
class Derived: public Base {
public:
using Base::mf1; //Base class中名为mf1和mf3的所有东西,
using Base::mf3; //在Derived中都可见
virtual void mf1();
void mf3();
void mf4();
...
}
// 转交函数,一般用于私有继承中
class Derived: private Base {
public:
virtual void mf1() // 转交函数
{
Base::mf1(); // 派生类函数调用基类对应函数
}
}
如果不想要外边访问基类中任何成员,可以用私有继承实现(private)。
请记住
- derived class 内的名称会遮掩base class 内的名称。在public继承下从来没有人希望如此。所以这点要特别注意(使用using)。
- 为了让被遮掩的名称再见天日,可使用using声明式或转交函数。
34. 区分接口继承和实现继承
业内默认约定基类中的成员函数用途:
- 纯虚函数:derived class只想继承其声明,实现由derived class自己实现。
- 虚函数:derived class希望同时继承函数的接口和实现,但又希望能够覆写它们所继承的实现。
- 普通函数:derived class只想继承函数的接口和实现,并且不允许我自己再覆写。
依据上面约定你应该就知道成员函数属性应该怎么声明了。
请记住
- 接口继承和实现继承不同。在public继承之下,derived class总是继承base class的接口。
- 纯虚函数只具体指定接口继承。
- 虚函数具体指定接口继承及默认的实现继承。
- 普通函数具体指定接口继承以及强制性实现继承。
35. 考虑virtual函数以外的其他选择
条款34刚说了,在我们希望同时继承函数的接口和实现,但又希望能够覆写它们所继承的实现时用virtual函数。
而这里是这种场景的一些其它流派主张思想。
第一种,Non-Virtual Interface(NVI),主张virtual函数应该几乎总是private。这个较好的设计是用一个non-vitual函数去调用一个private virtual函数。这样我们就提供了在调用private virtual函数前后做一些额外操作空间。
class GameCharacter {
public:
// 这里inline只是为了演示示例
int healthValue() const // 普通成员函数派生类不要重新定义它,见条款36
{
... // 做一些事前工作
int retVal = doHealthValue();
... // 做一些事后工作
return retVal;
}
...
private:
virtual int doHealthValue() const // 虚函数,派生类可重写
{
...
}
}
第二种,把这个虚函数提到类外边以一个普通函数存在,然后类的构造函数接收一个函数指针指向这个函数。把实现从类成员中剥离出去。— Strategy设计模式
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef int (*HealthCalcFunc)(const GameCharacter&); // 函数指针形式
// 另一种使用tr1::function更灵活,它是一个类模板,其成员变量是一个函数指针。
// 函数指针只支持指向外部普通函数,而function对象还支持类成员函数可以(结合bind,绑定this)。
// typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf)
{}
int healthValue() const
{
return healthFunc(*this);
}
...
private:
HealthCalcFunc healthFunc;
}
这样的好处是:
- 同一个类之下不同的对象可以有不同的defaultHealthCalc实现。
- 某个类对象的defaultHealthCalc可在运行期变更。
todo: 本条款具体使用场景还是没有深刻理解其好处,待后续遇到再回顾~
请记住
- virtual 函数的替代方案包括NVI手法和Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。
- 将机能从成员函数移到class外部函数,带来的一个缺点是,这个非成员函数无法访问class的非公有成员。
- tr1::function对象的行为就像一般函数指针。比函数指针能多接纳一些特别的函数。
36. 绝不重新定义继承而来的非虚函数
好习惯约定!
请记住
- 绝对不要重新定义继承而来的non-virtual函数。
37. 绝不重新定义继承而来的缺省参数值
class Shape {
public:
enum ShapeColor {Red, Green, Blue};
virtual void draw(ShapeColor color = Red) const;
}
class Rectangle:public Shape {
public:
virtual void draw(ShapeColor color = Green) const;
}
Shape* pr = new Rectangle;
pr->draw();
上面代码我们都知道最后的pr->draw调用的是Rectangle里覆写后的draw,这很正常没什么问题。
诡异的是缺省的参数却是用的Red,而不是自己的Green。
导致这个结果的原因是编译器优化的手段,缺省参数是静态绑定的(运行之前确定),而virtual函数是动态绑定的(运行时确定)。所有上面pr->draw的调用就出现接口是用的派生类的,而缺省参数用的基类的。
这种表现会给阅读代码的人带来歧义,所以:
请记住
- 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定的,而virtual函数是你唯一应该覆写的东西,却是动态绑定。
38. 类的子对象
区分类的继承,B继承A,我们可以说B是A,而B中包含A子对象,我们一般说B中有A,而不能说B是A了。
请记住
- 子对象的意义和public继承完全不同。
39. 明智而审慎地使用private继承
这条目前认为没什么记录的,就是前面讲的私有继承。
40. 明智而审慎地使用多重继承
多重继承,两个常见问题:
- C继承A和B,如果A、B里有相同的成员,那么C直接调用这些成员就会有歧义,不知道调用A的还是B的。所以正确调用要明确指明,C.A::xxxFunc();
- 多层继承中,B、C继承A,D继承B和C,那么常规D中有两份A,如果不想要两份就得用virtual(虚基类里会增加一个指针大小),虚继承。
多重继承我的建议是能避免就尽量避免。不能避免你就要清楚它带来的问题和内部实现成本消耗细节。
请记住
- 多重继承比单一继承复杂。它可能导致新的歧义性,以及对virtual继承的需要。
- virtual继承会增加大小、速度、初始化及赋值复杂度等成本。如果virtual base classe不带任何数据,将是最具实用价值的情况。
- 多重继承的确有正当用途。其中一个场景涉及public继承某个Interface class和private继承某个协助实现的class的两相组合。
七、模板与泛型编程
41. 了解隐式接口和编译期多态
隐式接口:函数模板,类型不清楚,对我们来说接口是隐藏的。
显示接口:我们常规的头文件接口声明就是显示接口,明确了返回值,参数。
编译期多态:编译时实例化模板确定哪个重载函数被调用。
运行期多态:运行时哪一个virtual函数该被绑定。
请记住
- class和template都支持接口和多态。
- 对class而言接口是显示的。多态则是通过virtual函数发生于运行期。
- 对template而言,接口是隐式的。多态则通过template实例化和函数重载解析,发生于编译器。
42. 了解typename的双重意义
模版声明有两种形式:
- template
- template
这里声明模版参数时,它们的意义完全相同。
不过对于typename在模版中除了声明模版参数外还有几处特别的用处要注意!
template<typename C>
void print2nd(const C& container)
{
C::const_iterator* x;
...
}
这里有个新名词要了解,嵌套从属类型:即属于模版类型C下的类型,形式:C::xxx
。
上面对应的就是C::const_iterator,这里是有歧义的,C::const_iterator是一个类型了还是一个变量了,如果作为类型上面就是定义一个指针x,如果作为变量就是乘x。对于这种嵌套从属类型,编译器一般默认当变量处理。如果要当类型处理就必须在其前面加关键字typename
。
typename C::const_iterator* x; // 这样就显示告诉编译器,C::const_iterator是一个自定义类型
另外对于嵌套从属类型前面加typename,有两处特例不能加。即不能出现在基类和成员初始化列表的嵌套从属类型里(除此之外都要加)。
template<typename T>
class Derived : public Base<T>::Nested // 不能加typename
{
public:
explicit Derived(int x) : Base<T>::Nested(x) // 不能加typename
{
typename Base<T>::Nested temp; // 这里要加
}
}
请记住
- 声明template参数时,前缀关键字class和typename可互换,意义一样。
- 请使用关键字typename标识嵌套从属类型,但不得在基类或成员初始化列表内使用。
43. 注意处理模版化基类内的名称
template<typename T>
class LoggingMsgSender : public MsgSender<T> // 模版化基类
{
public:
...
void sendClearMsg(const MsgInfo& info)
{
...
sendClear(info); // 如果这个接口属于基类的,这里也不认识,因为基类是什么这时编译器不知道
...
}
}
像上面的sendClear接口模版化基类里是否存在,编译器是不确定的,所以这种编译会报错。有下面3种方式解决这种问题,就是明确告诉编译器假设它存在。
- 通过
this->sendClear(info);
调用,假设sendClear在this中。 - 调用前加using声明
using MsgSender<T>::sendClear;
,明确告诉编译器sendClear在模版基类中。 - 调用时明白指明,
MsgSender<T>::sendClear(info);
请记住
- 可在派生类模版内通过
this->
指明基类模版的成员名称(1),或者由一个明白写出的属于基类的修饰符完成(2, 3)。
44. 将与参数无关的代码抽离template
**template是一个节省时间和避免代码重复的一个奇方妙法。**不再需要键入20个类似的class而每一个带有15个成员函数,你只需键入一个class template,留给编译器去实例化那20个你需要的相关class和300个函数。(它们只有在被使用时才会实例化)
template虽然给我们提供了方便,但是注意如果使用不当,很容易导致代码膨胀(执行文件变大)。其结果有可能源码看起来合身而整齐,但目标码却不是那么回事。在template代码中,重复是隐藏的,所以你必须训练自己去感受当template被实例化多次时可能发生的重复。
template<typename T, std::size_t n> // 这里T称为模版的类型参数,n是非类型参数
class SquareMatrix {
public:
...
void invert();
}
// 实例化
SquareMatrix<double, 5> sm1;
sml.invert();
SquareMatrix<double, 10> sm2;
sm2.invert();
上面这段模版封装,多次实例化,其中invert也会实例多份,虽然它们二进制实现一样。这就是隐晦的重复代码。
template<typename T>
class SquareMatrixbase {
protected:
...
void invert(std::size_t matrixSize);
...
}
template<typename T, std::size_t n>
class SqureMatrix : public SquareMatrixbase<T> {
private:
using SquareMatrixBase<T>::invert;
...
public:
...
void invert() {
this->invert(n);
}
}
把重复逻辑移到基类中,所有模版类共有,这样就减少了代码膨胀了。
本条款想表达的是使用template时要注意多次实例化后可能带来的代码重复,要尽量避免这种重复代码。这就是我的理解。
TODO: 翻译的请记住条款描述得有点抽象,没深刻理解~待日后回顾重新理解!
请记住
- template生成多个class和多个函数,所以任何template代码都不该与某个造成膨胀的template参数产生相依关系。
- 因非类型模版参数而造成的代码膨胀,往往可消除,做法是以函数参数或class成员变量替换template参数。
- 因类型参数而造成的代码膨胀,往往可以降低,做法是让带有完全相同二进制实现的代码共享,如放基类中。
45. 使用成员函数模版接受所有兼容类型
本条款想要表达的是我们封装的模版所有操作行为要和普通类保持一致。即隐式行为要一致。如不同类型可隐式相互转换。
template<typename T>
class SmartPrt {
public:
SmartPrt(const SmartPrt& other); //正常的copy构造函数,取消编译器自动生成
template<typename U> // 泛化的copy构造函数(成员函数模版),接受不同类型对象转换
SmartPrt(const SmartPrt<U>& other) : heldPtr(other.get())
{
...
}
T* get() const {return heldPtr;};
...
private:
T* heldPtr;
}
不过注意泛化的成员函数(即成员函数模版)并不会影响编译器自动生成类默认函数规则。所以如果你要完全自定义类行为,默认产生的函数除了泛化版本,对应的正常化版本也要声明。
请记住
- 请使用成员函数模版生成可接受所有兼容类型的函数。
- 如果你声明成员函数模版用于泛化copy构造函数或赋值操作符,你还是需要声明对应正常的copy构造函数和赋值操作符函数。
46. 需要类型转换时请为模版定义非成员函数
对应条款24,这里只是模版实现。规则一致,但它们写法上有所区别了。
template<typename T>
class Rational {
public:
Rational(const T& numerator = 0,
const T& denominator = 1);
const T numerator() const;
const T denominator() const;
...
}
// 需要隐式转换的接口定义为非成员函数
template<typename T>
const Rational<T> operator* (const Rational<T>& lhs,
const Rational<T>& rhs)
{...};
// 使用
Rational<int> oneHalf(1, 2);
Rational<int> result = oneHalf * 2; // 这里会编译错误,2不能隐式转换
上面只是把24条款示例改为模版实现,然而模版版本是编译不过的,因为编译器并不知道2要转换为什么。编译器推断不了模版的隐式转换。
对于模版我们只能通过friend和inline特性来实现非成员函数的定义。
template<typename T>
class Rational {
public:
...
// 这里Rational是Rational<T>的简写形式,在类模版内部可以简写。
friend const Rational operator*(const Rational& lhs,
const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
}
这样就可以编译,连接通过了。
请记住
- 当我们编写一个class template,而它所提供的函数要支持隐式转换时,请将这些函数定义为class template内部的friend函数。
47. 请使用traits class表现类型信息
TODO: 本条款主要是标准库中大量使用(如stl迭代器),目前还不太理解其深层含义,待日后再一次回顾理解~
请记住
- Traits class 使得类型相关信息在编译器可用。它们以template和template特化完成实现。
- 整合重载技术后,traits class有可能在编译期对类型执行if…else测试。(重载是编译期确定,if是运行期确定)
48. 认识template元编程
47条款的示例就是使用的模版元编程技术,它是一种把运行期的代码转移到编译期完成的技术。这种技术可能永远不会成为主流,但是如果你是一个程序库开发员,那这种技术就是家常便饭了。
通过模版或重载技术,把如if这种运行期的判断转换为编译期重载函数自动匹配。
它有两个特点:
- 它让某些事情更容易。如果没有它,那些事情将是困难的,甚至不可能的。
- 由于它将工作从运行期转移到编译期。这可更早发现错误,而且更高效、较小的可执行文件、较短的运行期、较少的内存需求。不过它会使编译时间变长。
请记住
- 模版元编程可将工作由运行期转移到编译期,因而得以实现早期错误发现和更高的执行效率。
- 模版元编程可被用来生成客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。
八、定制new和delete
49. 了解new-handler的行为
new-handler就是当new抛异常之前,它会先调用一个客户指定的错误处理函数。通过set_new_handler
标准库函数指定。
// 当new无法分配足够内存时,被调用
void outOfMem()
{
std::cerr << "Unable to satisfy request for memory\n";
std::abort();
}
int main()
{
std::set_new_handler(outOfMem);
int* pBigDataArray = new int[100000000L];
...
}
上面异常处理是全局的,但有时候你可能需要为不同类处理不同异常。
class X {
public:
static void outOfMem();
...
}
class Y {
public:
static void outOfMem();
...
}
X* p1 = new X; // 如果X错误,你希望调用X的错误函数
Y* p2 = new Y; // 如果Y错误,你希望调用Y的错误函数
C++并不支持class的专属new-hander,但也可以通过其它形式自己实现。
// RAII对象,保证new_handler还原
class NewHandlerHolder {
public:
explicit NewHandlerHolder(std::new_handler nh) : handler(nh) {}
~NewHnadlerHolder()
{
std::set_new_handler(handler);
}
private:
std::new_handler handler;
// 阻止copiying
NewHandlerHolder(const NewHandlerHolder&);
NewHandlerHolder& operator=(const NewHandlerHolder&);
}
// 声明
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_hnadler(std::new_handler p) throw()
{
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}
template<typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size)
throw(std::bad_alloc)
{
// new如果失败,则先会调用currentHandler,然后set_new_handler会返回上一次的handler。
// NewHandlerHolder这个RAII对象则在析构时会把上面返回的上一次new_handler设置回去。
NewHandlerHolder h(std::set_new_handler(currentHandler));
return ::operator new(size);
}
template<typename T>
std::new_handler NewHandlerSupport<T>::currentHandler = 0;
// 使用,只要继承封装的NewHandlerSupport<T>,就能够实现针对类自己的new_handler了。
class Widget : public NewHandlerSupport<Widget> {
...
}
Widget::set_new_handler(xxxx); // xxxx是new失败执行的回调函数
Widget* w = new Widget; // 如果失败,先会调用xxxx,然后会还原new_handler回调函数。
请记住
- set_new_handler允许客户指定一个函数,在内存分配无法获得满足时被调用。
- 让new不抛异常是一个颇为局限的工具,因为它只是保证了内存分配时不抛异常,后续调用构造函数还是可能抛出异常。=> new做了两件事:1. 分配内存 2. 调用类的构造函数。
50. 了解new和delete的合理替换时机
什么时候我们需要替换编译器提供的new或delete呢?下面是三个最常见的理由:
- **用来检测运用上的错误。**如new的一段内存,delete时失败了导致内存泄漏。又或多次delete导致不确定行为。
- **为了提升性能。**编译器默认提供的new/delete是大众的,均衡的,不针对特定场景特定优化。如需要大量申请/释放内存场景(碎片),我们习知的有内存池技术。
- **为了收集使用上的统计数据。**统计任何时刻内存分配情况等。
但是要自定义一个合适的new/delete并非易事,如内存对齐(对齐指令执行效率最高),可移植性、线程安全…等等细节。所以我的建议是在你确定要自定义new/delete之前,请先确定你程序瓶颈是否真的由默认new/delete引起,而且现在也有商业产品可以替代编译器自带的内存管理器。或者也有一些开源的产品可以使用,如Boost的Pool就是对于常见的分配大量小型对象很有帮助。
请记住
- 有许多理由需要写个自定义的new和delete,包括改善性能、对堆区运用错误进行调试、收集堆区使用信息。
51. 编写new和delete时需固守常规
上面条款说了什么时候需要自定义new/delete,本节则告诉你写自定义new/delete需要遵守的一般规范。
请记住
- operator new 1. 应该内含一个无限循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用new-handler。2. 它也应该有能力处理0字节申请。3. Class的专属版本则还应该处理“比正确大小更大的申请”(被继承后, new 派生对象,这时可以走编译器默认new操作)。
- operator delete应该在收到null指针时不做任何事情。Class专属版本还应该处理“比正确大小更大的申请”(同上)。
52. new与delete成对出现
请记住
- 当你写一个operator new, 请确定也写出了对应的operator delete。如果没有这样做,你的程序可能会发生隐晦而时断时续的内存泄漏。
- 当你声明new和delete,请确定不要无意识地(非故意)遮掩了它们的正常版本。
九、杂项讨论
53. 不要轻忽编译器的警告
记住后期很多无休止调试就是由于你前期没有重视编译警告引起的。尽管一般认为,写出一个在最高警告级别下也无任何警告信息的程序是理想的,然而如果你对某些警告信息有深刻理解,你倒是可以选择忽略它。不管怎样说,在你打发某个警告信息之前,请确定你了解它意图说出的精确意义。这很重要!
请记住
- 严肃对待编译器发出的警告信息。努力在你的编译器的最高警告级别下争取无任何警告的荣誉。
- 不要过度依赖编译器的报警能力,因为不同的编译器对待事情的态度并不相同。一旦移植到另一个编译器上,你原本依赖的警告信息有可能消失。
54. 熟悉TR1在内的标准程序库
TR1是C++标准程序库第一次扩充,包含14个新组件,统统都放在std::tr1命名空间下。
- 智能指针tr1::shared_ptr和tr1::weak_ptr。
- tr1::function,可表示任何函数,是一个模板。在条款35中有使用。
- tr1::bind,同样35示范中有它用法。
- hash table,用来实现set, multiset, map和multi-map容器的hash版本。
- 正则表达式。
- tr1::tuple,标准库中的pair template的新一代制品,可持有任意个数的对象(pair只能持有两个对象)。
- tr1::array,是一个STL化的数组。
- tr1::mem_fn,生成指向成员的指针的包装对象。
- tr1::reference_wrapper, 一个让引用的行为更像对象的设施。
- 随机数生成工具。
- 数学特殊函数。
- C99兼容扩充。
- Type traits,见条款47。
- tr1::result_of,这是一个模板,用来推导函数调用的返回类型。
这些实现一般很多实现在boost库中都有!
请记住
- C++标准程序库的主要功能由STL、iostreams、locales组成。并包含C99标准程序库。
- TR1添加了智能指针、一般化函数指针、hash-based容器、正则表达式以及另外10个组件的支持。
- TR1自身只是一份规范。为获得TR1提供的好处,你需要一份实现。一个好的实现来源是Boost。
55. 让自己熟悉Boost
你正在寻找一个高质量、源码开放、平台独立、编译器独立的程序库吗?看看Boost吧。有兴趣加入一个由雄心勃勃充满才干的C++开发人员组成的社群,致力发展当前最高技术水平的程序库吗?看看Boost吧!想要一瞥未来的C++可能长相吗?看看Boost吧!官网地址
请记住
- Boost是一个社群,也是一个网站。致力于免费、源码开放、同僚复审的C++程序库开发。Boost在C++标准化过程中扮演深具影响力的角色。
- Boost提供了许多TR1组件的实现,以及其他许多程序库。
十、本书之外
《Effective C++》一书覆盖我认为对于以编程为业的C++程序员最重要的一般性准则。如果你有兴趣更强化各种高效做法,我推荐你再试试另外两本书:《More Effective C++》和《Effective STL》。
《More Effective C++》覆盖了另一些编程准则,以及对于性能和异常的广泛讨论。它也描述了重要的C++编程技术如智能指针、引用计数和代理对象等等。
《Effective STL》是一本和《Effective C++》一样的准则导向书籍,专注于对STL的高效运用。