Effective C++ 读书笔记
Effective C++ 读书笔记
1、让自己习惯C++
条款 01 :视C++为一个语言联邦
C++的四个层次:
- C:C++是在C语言的基础上发展而来的
- Object-Oriented C++:这是C++中不同于C的部分,这里主要指面向对象。
- Template C++:C++中的泛型编程。
- STL:这是一个标准模板库,它用模板实现了很多容器、迭代器和算法,使用STL往往事半功倍。
条款 02: 尽量const、enum、inline替换#define
const好处
- define直接常量替换,出现编译错误不易定位(不知道常量是哪个变量)
- define没有作用域,const有作用域提供了封装性
enum好处:
- 提供了封装性
- 编译器肯定不会分配额外内存空间(其实const也不会)
inline的好处:
define宏函数容易造成误用(下面有个例子)
cpp1
2
3
4
5
6
7//define误用举例
int a = 5, b = 0;
MAX(++a, b) //a++调用2次
MAX(++a, b+10) //a++调用一次
注意:
- 对于单纯的常量,最好以const对象或enums替换#define
- 对于形似函数的宏,最好改成内联函数
条款 03 :尽可能使用const
const修饰的变量不允许改变
注意指针常量与常量指针,stl中的迭代器类似指针(T* const point,指向的元素可以修改)
const成员函数
可以确认类中哪些成员函数可以修改数据成员
const对象只能调用const 对象成员函数,非const对象既可以调用普通成员函数也可以调用const成员函数(这是因为this指针可以转化为const this,但是const this 不能转化为非 const this)
一个函数是不是const是可以被重载的
更改了指针所指物的成员对象不算是const,但是如果只有指针属于对象,则成函数为bitwise const 不会发生编译器异议
用mutable关键字修饰的成员变量,将永远处于可变状态, 哪怕是在一个const函数中
如果const和非const成员功能类似,用非const版本调用const版本,避免代码复制;
以const修饰operator*的返回值类型可以阻止客户因“用户自定义类型而犯错”
cpp1
2if(a*b=c)
//本意是想要做一个比较操作,结果因为返回值不是const的话就可以修改了,直接变成赋值行为,也不会报错。cpp1
2
3
4
5
6
7
8
9
10
11
12
13class CTextBlock{
public:
const char& operator[](std::size_t position)const
{
...
return pText[position];
}
char& operator[](std::size_t position)
{
return const_cast<char&>(static_cast<const CTextBlock&>(*this)[position]);
}
char * pText;
int length;
条款 04 : 确定对象使用前已被初始化
- 有些情况下会初始化为0 ,有时候不会被初始化
- 内置类型,手工初始化
- 内置以外的类型,构造函数初始化
- 构造函数体内的是赋值,初始化列表中才是初始化
- 初始化顺序要和声明顺序一致
- 初始化的效率高于赋值
- 赋值是先定义变量,在定义的时候已经调用的变量的默认构造函数之后是用了赋值操作符;
- 初始化时直接调用了拷贝构造函数
- const、引用、基类传参(因为基类先于派生类初始化)、对象成员必须在初始化列表中
- 函数体内的static对象是local static对象,其他static对象是non-local static对象
- 定义在不同编译单元内的non-local static对象”初始化次序无明确
- static对象只有一份拷贝,且只初始化一次(类似于单例模式)使用local static对象,首次使用时初始化,返回其引用即可(local static声明周期是整个程序),以后再使用无需再次初始化。
- 总结:
- 手动初始化non-member对象
- 使用初始化列表初始化member对象。
- 消除初始化次序的不确定性。
关于编译单元:
在C++中,非局部静态对象(即全局或文件作用域的静态对象)的初始化次序在不同的编译单元(通常是不同的源文件)之间是未定义的。这意味着,如果你在一个编译单元中依赖于另一个编译单元中的全局静态对象的初始化结果,你的程序可能会遇到未定义行为,因为那些对象的初始化次序是不确定的。
为了避免这个问题,可以使用局部静态对象(即在函数内部声明的静态对象)。局部静态对象在它们首次被访问时才会被初始化,并且初始化是线程安全的(在C++11及更高版本中)。由于局部静态对象的初始化是在它们被首次访问的点上明确发生的,因此不存在跨编译单元的初始化次序问题。
2、 构造/析构/赋值运算
条款05 :了解C++默认编写并调用哪些函数
空类经过编译器处理后会有默认构造函数、复制构造函数、赋值操作符和析构函数。这些函数都是public且inline
- 默认构造函数,由它来调用基类和non-static成员变量的构造函数
- 析构函数是否是虚函数,继承基类,如果没基类,那么默认是non-virtual,析构函数会调用基类和non-static成员变量的析构函数。
- 复制构造函数和赋值操作符中,给成员变量初始化或赋值,会调用成员变量的赋值构造函数和赋值操作符。他们都是浅拷贝
- 赋值操作符,有些情况下编译器是不会合成的,例如
- 两个成员变量,一个是引用:初始化后不能更改,一个是常量:也是初始化后不能更改,因此不可以用赋值更改变量,此时编译器不会合成
- 基类的赋值操作是private的,派生类不会生成赋值运算符
条款 06 :若不想使用编译器自动生成的函数,就该明确拒绝
房子是个类,天下没有一样的房子,所以拷贝与赋值都不能使用,将其设置为私有(只声明不定义)就可阻止使用这两个函数
注意:普通调用会在编译阶段出错(private),友元和成员函数可以访问错误会发生在链接阶段(没有定义),错误出现越早越好,可以用继承来实现
1 | class Uncopyable{ |
其他类来继承就行了
这样继承的类中如果生产对应的拷贝与赋值构造函数,就会调用基类对应的函数,会发生编译错误
条款 07 :为多态基类声明为virtual析构函数
创建有层次的类时,将基类的析构函数声明为虚函数
原因:当基类指针(引用)指向子类对象时,如果析构对象通过delete 指针的方式,只会调用基类的析构函数,不会调用子类的析构函数。可能会造成内存泄漏
但是当一个类不做基类时,不要将析构函数弄成虚函数,因为调用过程中会多一步指针操作,同时对象也多了一个虚函数指针,
一个类不含虚函数,不适合做基类,STL中的容器没有虚析构函数,一个类中至少有个虚函数,析构函数才将弄为虚函数
一个类含有纯虚函数,抽象类不能被实例化
cpp1
2
3
4
5
6class AWOV
{
public:
virtual ~AWOV()=0;
};
AWOV::~AWOV(){}//这一步是必要的如果把这个当做是基类,会有问题,析构函数只有声明没有定义,析构函数从派生类到基类的调用时,会发生链接错误。因此需要定义(空定义)
条款 08 :别让异常逃离析构函数
析构函数可以抛出异常,但是不建议这么做;例如:
容器销毁会调用析构函数,如果抛出异常,剩下的元素没有被销毁,会造成内存泄漏。如果继续销毁,会存在两个异常,两个异常会导致不明确的行为
有时候又必须在析构函数中执行一些动作,这些动作可能会导致异常,如果调用这些动作不成功会抛出异常,使得异常传播。解决方法如下:
动作函数抛出错误,就终止程序,调用abort函数
cpp1
2
3
4
5
6
7
8
9
10
11
12~DBConn()//析构函数关闭连接
{
try{
db.close();
}
catch(……)
{
//记录下对close调用的失败
std::abort();//退出
}
}吞下这个异常,它会压制某些失败动作的重要信息。比较好的是重新设计接口,使得客户能对可能的异常做出反应。
cpp1
2
3
4
5
6
7
8
9
10
11~DBConn()//析构函数关闭连接
{
try{
db.close();
}
catch(……)
{
//记录下对close调用的失败
}
}
条款 09 : 绝不再构造和析构函数中调用virtual函数
人话版本:
- 对象的初始化状态
- 在构造函数执行期间,派生类对象的成员变量尚未完全初始化。如果此时通过基类构造函数调用virtual函数,并且该调用试图访问派生类的成员变量或方法,那么可能会访问到尚未初始化的数据,导致未定义行为。
- 类似地,在析构函数执行期间,派生类对象的成员变量可能已经开始被销毁,其状态已经是未定义的。此时调用virtual函数同样可能导致问题。
- 虚函数表的未正确设置
- 在C++中,虚函数通常是通过虚函数表(vtable)来实现的。在对象构造过程中,虚函数表可能还没有被正确设置以指向派生类的虚函数实现。因此,在构造函数中调用virtual函数可能会调用到错误的函数实现。
- 同理,在析构函数执行时,虚函数表可能已经开始被清理或修改,此时调用virtual函数同样可能遇到问题。
- C++语言的规范
- 从C++语言规范的角度来看,构造函数和析构函数中的virtual函数调用并不会“下降”到派生类。这意味着,即使在构造函数或析构函数中调用了virtual函数,实际上调用的也将是基类中的版本,而不是派生类中重写的版本。这通常与程序员的预期不符,可能导致难以调试的错误。
- 潜在的运行时错误
- 在构造和析构期间调用virtual函数可能会增加运行时错误的风险。例如,如果派生类的虚函数实现依赖于某些在构造函数或析构函数中尚未初始化或已被销毁的成员变量,那么这些实现可能会失败或产生不可预测的结果。
- 设计上的考虑
- 从设计角度来看,构造函数和析构函数的主要职责是初始化和清理对象的资源。它们不应该承担与对象业务逻辑相关的任务,这些任务应该由其他成员函数来处理。因此,将virtual函数调用放在构造函数或析构函数中可能违背了这一设计原则。
书中版本:
这类调用从不下降至子类(当前执行的构造函数与析构函数的那一层),此时无法呈现多态的性质。例如:
cpp1
2
3
4
5
6
7
8
9
10
11
12
13
14
15//父类
class Transaction{
public:
Transaction();
virtual void logTransaction()const//virtual function
{
//log the Transaction
std::cout<<"This is Transaction logTransaction"<<std::endl;
}
};
Transaction::Transaction()
{
logTransaction();//called in Ctor
}cpp1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17//子类
class BuyTransaction:public Transaction{
public:
virtual void logTransaction()const
{
std::cout<<"This is BuyTransaction logTransaction"<<std::endl;
}
};
class SellTransaction:public Transaction{
public:
virtual void logTransaction()const
{
std::cout<<"This is SellTransaction logTransaction"<<std::endl;
}
};当有个对象:BuyTransaction b 时,会输出父类的函数内容,这是因为基类先构造,在基类构造期间,不会下降到派生类去调用派生类的虚函数,所以调用的是基类的虚函数,此时不表现出多态的性质。
解决方法:将父类的那个函数设置成非虚函数,从derived class构造函数传递参数给base class构造函数
cpp1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Transaction{
public:
explicit Transaction(const std::string& parameter);
void logTransaction(const std::string& parameter)const//no-virtual function
{
//log the Transaction
std::cout<<"This is "<<parameter<<" logTransaction"<<std::endl;
}
};
Transaction::Transaction(const std::string& parameter)
{
logTransaction(parameter);//called in Ctor
}
class BuyTransaction:public Transaction{
public:
BuyTransaction()
:Transaction(CreatPamameter())
{
}
private:
static std::string CreatPamameter()
{
return "BuyTransaction";
}
};
class SellTransaction:public Transaction{
public:
SellTransaction()
:Transaction(CreatPamameter())
{
}
private:
static std::string CreatPamameter()
{
return "SellTransaction";
}
};
int main()
{
BuyTransaction b;
SellTransaction s;
return 0;
}当构造派生类对象时,先调用基类的构造函数,此时派生类还没有被构造出来,所以调用的是基类的虚函数。
而析构时,派生类已经析构掉了,所以基类析构时仍调用的是基类的虚函数。错!
实际上,无论派生类有没有被构造出来,还是已经析构了。在构造、析构函数中一定只会调用本类中的虚函数。 因为在函数进入构造、析构函数时,一定会把虚指针填充为当前类虚表的首地址
条款10 :令operator=返回一个reference to *this
- 为了实现连锁赋值,操作符必须返回一个reference指向操作符左侧的实参。其实,如果operator=不返回一个引用,返回一个临时对象,照样可以实现连锁赋值(但是这个临时对象会调用一个拷贝构造函数)
- 与之类似的有+=、-=等改变左侧操作符的运损,就当做是个协议,我们都去遵守吧
条款11 :在operator=中实现“自我赋值”
如果自己管理资源,可能会“在停止使用资源之前意外释放了它”
cpp1
2
3
4
5
6
7
8
9
10
11
12class Widget
{
public:
Widget& operator=(const Widget& rhs)
{
delete p;//如果p之前就已经释放掉了,再次释放会被报错
p=new int(ths.p);
return *this;
}
int *p;
};
123456789101112防止以上的方法就是“证同测试”,判断当前判断是不是赋值
cpp1
2
3
4
5
6
7
8
9
10
11
12
13class Widget
{
public:
Widget& operator=(const Widget& rhs)
{
if(this==&rhs)//证同测试
return *this;
delete p;
p=new int(rhs.p);
return *this;
}
int *p;
};还有一个方案是copy与swap技术,用来解决异常安全问题,条款29 详细说明
如果是引用传递
cpp1
2
3
4
5
6
7
8
9
10
11
12class Widget
{
public:
void swap(const Widget& rhs);//交换rhs和this
Widget& operator=(const Widget& rhs)
{
Widget tmp(rhs);//赋值一份数据
swap(tmp)//交换
return *this;//临时变量会自动销毁
}
int *p;
};如果是值传递,则不需要新建临时变量,直接使用函数参数即可
cpp1
2
3
4
5
6
7
8
9
10
11class Widget
{
public:
void swap(const Widget& rhs);//交换rhs和this
Widget& operator=(const Widget rhs)
{
swap(rhs)
return *this;
}
int *p;
};
条款 12 : 复制对象时勿忘其每一个部分
- 一旦给类添加变量,自己写的copying函数(拷贝与赋值构造函数)也要修改,因为编译器不会提醒你;
- 在派生类层次中,派生类中的构造函数没有初始化的基类部分是通过默认构造函数初始化的(没有就会报错)但是在赋值操作符中,不会调用基类的默认构造函数。因为赋值操作只是给对象赋值,不是初始化,因此不会调用基类的构造函数(重要)
- 赋值操作符与拷贝构造函数不能相互调用,因为拷贝构造函数是构造一个不存在的对象,而操作符是给一个存在的对象重新赋值。如果你发现拷贝构造和拷贝赋值的重复代码很多,应该去建立一个新的成员函数给两者使用,并且一般命名为init().
3、 资源管理
条款 13:以对象管理资源
在一个作用域内,在delete 之前就return了,会造成内存泄漏,所以delete管理内存远远不够
cpp1
2
3
4
5
6void fun()
{
Investment* pInv=CreateInvestment();
……//这里提前 return
delete pInv;//释放资源
}用对象控制对象,离开了作用域自然会调用析构函数析构,比如使用智能指针auto_ptr(唯一资源使用权,对它的拷贝动作为让旧指针变为nullptr)
- RAII:资源获取时机即是初始化时机(resource acquisition is initialization)。获取资源后立即放进对象内进行管理。
- 管理对象运用析构函数确保资源释放。管理对象是开辟在栈上面的,离开作用域系统会自动释放管理对象,自然会调用管理对象的析构函数。
- 还有一种指针是引用计数器型指针,会记录多少个对象在使用资源,计数器为0,就释放,如share_ptr
- auto_ptr和shared_ptr释放资源用的都是delete,而不是delete[],对于数组指针,shared_array来对应。类似的还有scope_array
请记住:
为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
两个常被使用的RAII classes 分别是tr1 :: shared_ptr和auto_ptr。前者通常是较佳选择,因为其copy行为比较直观。若选择auto_ptr,复制动作会使它(被复制物)指向null。
条款 14: 在资源管理类中小心coping行为
但是并不是所有资源都是开辟在堆上,有时候我们需要自己建立资源管理类
cpp1
2
3
4
5
6
7
8
9
10
11
12
13class Lock{
public:
explicit Lock(Mutex* mu):mutexPtr(mu)
{
lock(mutexPtr);
}
~Lock()
{
unlock(mutexPtr);
}
private:
Mutex* mutexPtr;
};这样客户对Lock的使用方法符合RAII方式:
cpp1
2
3
4
5
6
7Mutex m;//定义互斥器
……
{//建立区块来定义critical section
Lock(&m);
……//执行critical section 内的操作
}//在区块末尾,自动解除互斥器的锁当一个RAII对象被复制,会发生什么?有以下做法
禁止复制,将coping函数设置为私有,条款6
对管理资源使用引用计数法,复制的时候就加1 。mutexPrt变为类型从Mutex*变为shared即可
cpp1
2
3
4
5
6
7
8
9class Lock:private Uncopyable{
public:
explicit Lock(Mutex* mu):mutexPtr(mu,unlock)//以某个Mutex初始化,unlock作为删除其
{
lock(mutexPtr);
}
private:
shared_prt<Mutex> mutexPtr;
};注意的是在这个类中并没有自己编写析构函数。因为mutexPtr是类中的普通成员变量,编译器会自动生成析构函数类析构这样的变量。这个在条款5中有说明。
拷贝底部资源(深浅拷贝)
使用资源管理类的目的是保证不需要这个资源时能正确释放。如果这种资源可以任意复制,我们只需编写好适当的copying函数即可。确保拷贝时是深拷贝。
比如:C++中的string类,内部是指向heap的指针。当string复制时,底层的指针指向的内容都会多出一份拷贝。转移底层资源的拥有权。
有时候资源的拥有权只能给一个对象,这时候当资源复制时,就需要剥夺原RAII类对该资源的拥有权。像auto_ptr。在C++11新标准中的std::move便是这个功能。可以把一个左值转换为一个右值。
copying函数如果你不编写,编译器会帮你合成,其合成版本行为可参考条款5。要记住的是不论是自己编写还是编译器合成,都要符合自己资源管理类的需要。
请记住:
复制RALL对象必须一并复制它所管理的资源,所以资源的copying行为决定RALL对象的copying行为
普通而常见的RALL class copying行为是:抑制copying、实行引用计数等。
条款 15 :在资源管理类中提供对原始资源的访问
原始资源,没有经过封装的指针(可以这样理解)
cpp1
2
3
4
5
6//用智能指针来保存返回值
shared_prt<Investment> pInv=(createInvestment());
//有这样一个函数,显然是无法将只能指针对象的,这时就需要一个函数将管理的原始资源暴露出来
int dayHeld(const Investment* pi);
//shared_ptr和auto_ptr都提供一个get函数,用于执行这样的显示转换
dayHeld(pInv.get());为了使智能指针使用起来像普通指针一样,它们要重载指针取值(pointerdereferencing)操作符(operator->和operator*),它们允许转换至底部原始指针。
RAII class内的返回资源的函数和封装资源之间有矛盾。的确是这样,但这样不是什么灾难。RAII class不是为了封装资源,而是为确保资源释放。
请记住:
APIs往往要求访问原始资源(raw resources),所以每一个RAII class应该提供一个“取得其所管理之资源”的办法。
对原始资源的访问可能经由显式转换或隐式转换。一般而言显式转换比较安全,但隐式转换对客户比较方便。
条款 16 : 成对使用new和delete时要采取相同形式
- 如果使用new开辟内存,就使用delete释放。如果使用new[]开辟内存,就使用delete[]释放。
- 尽量不使用对数组做typedef动作。在C++的STL中有string、vector等templates(条款54),可以将数组需求降至几乎为零
条款 17 :以独立语句将newed对象置入智能指针
1 | //这样写是不行的,因为shared_ptr用普通指针构造的构造函数是explict的,不允许隐式转换 |
在使用智能指针时,应该用独立的语句把新创建的对象指针放入智能指针,否则可能会造成内存泄露
c++1
2
3
4
5
6
7
8//对于这个的传参
int processWidget(shared_ptr<Widget> pw, int priority);
在调用processWidget之前有三件事:
1、执行priority()函数
2、执行new Widget
3、执行shared_ptr构造函数C++编译器会以什么样的次序来完成这些事情呢?弹性很大。在Java和C#中,总是以特定的次序来完成这样函数参数的计算,但在C++中却不一定。唯一可以确定的是new Widget在shared_ptr之前调用。但是函数priority排在第几却不一定。假设排在第二,那么顺序就是1、执行new Widget。2、执行函数priority()。3执行shared_ptr构造函数。
如果对函数priority()调用出现异常,那么new Widget返回的指针还没来得及放入shared_ptr中。这样会造成内存泄露。
因此可以分开写,先创建,然后在传参
cpp1
2shared_prt<Widget> pw(new Widget);
processWidget(pw,priority());
请记住:
以独立语句将 newed 对象存储于(置入)智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄漏。
4、设计与声明
条款18:让接口容易被正确使用,不容易被误用
1、保证参数一致性:
1 | void print_date(int year, int month, int day) |
在这样一个打印时间的函数接口中,我们按照年月日的顺序输出,但是1式却输出年日月。错误的参数传递顺序造成了接口的误用。
解决办法:
1 | class day{...}; |
当然,传递某个有返回值的函数也是可以解决的,但这种方法看起来很奇怪。
2、保证接口行为一致性:
内置数据类型(ints, double…)可以进行加减乘除的操作,STL中不同容器也有相同函数(比如size,都是返回其有多少对象),所以,尽量保证用户自定义接口的行为一致性。
3、如果一个接口必须有什么操作,那么在它外面套一个新类型:
比如:
1 | employee* createmp();//其创建的堆对象要求用户必须删除 |
如果用户忘记使用资源管理类,就有错误使用这个接口的可能,所以必须先下手为强,直接将 createmp() 返回一个资源管理对象,比如智能指针share_ptr 等等:
1 | tr1::share_ptr<employee> createmp(); |
如此就避免了误用的可能性。
4、有些接口可以定制删除器,就像 STL 容器可以自定义排序,比较函数一样
1 | tr1::share_ptr<employee> p(0, my_delete());//error! 0 不是指针 |
第一个参数是被管理的指针,第二个是自定义删除器。
- 好的接口容易被正确使用,不容易被误用。
- 促进正确使用“的办法包括接口一致性,以及于内置类型兼容。
- 阻止误用“方法包括建立新类型、限制类型上的操作、束缚对象值,以及消除客户的资源管理责任。
- shared_ptr支持特定的删除器。可以防范cross-DLL problem,可以被用来自动解除互斥锁(就是在释放资源的时候解锁)。
- shared_ptr一个特别好的性质是:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在客户的错误:Corss-DLL Problem。这个问题发生于:对象在一个动态链接库DLL中被new创建,却在另一个DLL内被delete销毁。在许多平台上,这一类跨DLL之new/delete成对使用会导致运行期错误。shared_ptr没有这个问题,因为它的删除器来自其所诞生的那个DLL的delete。
条款19 :设计class犹如设计type
要注意解决以下问题:
- 新type的对象应该如何被创建和销毁?
- 对象初始化和对象赋值该有什么样的区别? 条款4
- 新type的对象如果被pass by value,意味着什么
- 什么是新type的合法值?
- 新type需要配合某个继承图系(inheritance graph)吗? (条款34和条款36)
- 新type需要什么样的转换?
- 什么样的操作符和函数对此新type而言是合理的?
- 什么样的函数应该被驳回?
- 谁该取用新type的成员?
- 什么是新type的“未声明接口”(undeclared interface)?
- 你的新type有多么一般化?
- 你真的需要一个新type吗?
条款20: 宁以pass-by-reference-to-const替换pass-by-value(本条第六点要常看)
- 在默认情况下,C++函数传递参数是继承C的方式,是值传递(pass by value)。这样传递的都是实际实参的副本,这个副本是通过调用复制构造函数来创建的。有时候创建副本代价非常昂贵
- 以pass by reference-to-const方式传递,可以回避所有构造函数和析构函数。这种方式传递,没有新对象创建,所以自然没有构造和析构函数的调用参数中,以const修饰是比较重要的,原先的pass by value,原先的值自然不会被修改。现在以pass by reference方式传递,函数validateStudent内使用的对象和传进来的同同一个对象,为了防止在函数内修改,加上const限制。
- 以pass by reference方式传递,还可以避免对象切割(slicing)问题。一个派生类(derived class)对象以pass by value方式传递,当被视为一个基类对象(base class)时,基类对象的copy构造函数会被调用,此时派生类部分全部被切割掉了,仅仅留下一个base class部分(因为传参的时候是base类创建的副本对象)。
- 对于内置类型,pass by value往往比pass by reference更高效((引用本质是指针)。所以在使用STL函数和迭代器时,习惯上都被设计出pass by value
- 对象小并不意味着copy构造函数代价小,许多对象(包括STL容器),内涵的成员只不过是一两个指针,但是复制这种对象时,要复制指针指向的每一样东西,这个代价很可能十分昂贵。
- 一般情况下,可以假设内置类型和STL迭代器和函数对象以pass by value是代价不昂贵。其他时候最好以pass by reference to const替换掉pass by value。
条款21: 必须返回对象时,别妄想返回其reference
如下这种会出现错误,因为引用只是对象的别名,返回的是局部Rational对象的别名,但是离开函数后该对象就被析构了,返回的是一个无用值,所以要返回一个值
1 | inline const Rational& operator*(const Rational& lhs, const Rational& rhs) |
在返回一个reference和返回一个object之间抉择时,挑出行为正确的那个。让编译器厂商为你尽可能降低成本吧!
条款22:将成员变量声明为private
- 封装。如果通过函数访问成员变量,日后可以用某个计算替换这个变量,这时class的客户却不知道内部实现已经变化。
- 将成员变量声明为private。这可以赋予客户访问数据的一致性、可细微划分访问控制、允许约束条件获得保证,并提供class作者以充分弹性实现。
- protected并不比public更具有封装性。
条款23:宁以non-member、non-friend替换member函数
非成员函数,非友元函数,成员函数
释义:如果一个成员函数调用了其他的成员函数,那么就要用一个非成员函数替换这个成员函数。
根据条款22,对类变量的操作只能通过类成员函数实现(因为它是私有变量),那么如果一个成员函数内部实现是调用其他的成员函数,则一个非成员函数也可以做到这样的效果:
1 | class preson |
func3() 和 use_all() 的效果是一样的,但这时候我们倾向于选择 use_all 函数,因为func3()作为一个成员函数,其本身也是个可以访问私有变量的函数。use_all() 函数其本身不可以访问私有变量。所以 use_all() 比 func3() 更有封装性。(能够访问私有变量的函数越少越好)
在了解这点之后,我们做一些更深层次的探讨:
我们称 use_func()(func3()的非成员函数版本)为便利函数。假设一个类有多个诸如 func1() 的函数,根据排列组合,也就有很多便利函数。为了让这些便利函数和它的类看上去更像一个整体,我们把便利函数和类放在一个 namespace 中。于是,我们可以更为轻松地拓展这些便利函数——多做一些排列组合。
若一个成员函数调用其他成员函数,那么这个成员函数的非成员函数版本比之拥有更多的封装性,和机能扩充性。
总结:
- 用non-member、non-friend函数替换member函数,这样可以增加封装性、包裹弹性和机能扩充性,因为不能访问私有变量。
- namespace可以跨越多个源码文件,class不能,将所有的便利函数放在多个头文件但隶属于同一个命名空间,意味着客户可以轻松扩展这一组遍历函数。他们需要做的是添加更多的非成员函数和非友元函数到这个命名空间内
条款24:若所有参数皆需要类型转换,请为此采用non-member函数
通常情况下,class不应该支持隐式类型转换
也有例外,比如建立一个分数管理器,允许隐式类型转换
cpp1
2
3
4
5class Rational{
public:
Rational(int numerator=0, int denominator=1);//非explicit,允许隐式转换
……
};当然,若作为成员函数,this指针为隐形的参数,只需要一个变量参数传进去
cpp1
2
3
4
5
6class Rational{
public:
……
const Rational operator*(const Rational& rhs);
……
};进行混合运算时
cpp1
2result=oneHalf*2;//正确,相当于oneHalf.operator*(2);
result=2*oneHalf;//错误,相当于2.operator*(oneHalf);这是错误的,2是this指向的对象,必须是该类本身的类型。这是因为
- 只有参数列于参数表,才是隐式类型的参与者
- 2不是该类型,不能调用成员函数operator *;
因此可以定义为一个非成员函数,可以进行隐式转换的
cpp1
const Rational operator*(const Rational& lhs, const Rational& rhs);
总结:如果需要为某个函数的所有参数(包括this指针所指向的隐喻参数)进行类型转换,这个函数必须是个non-member函数
另一说法:如果所有参数(运算符左边或者右边的参数)都需要类型转换,用 non-member 函数。
条款25:考虑写出一个不抛出异常的swap函数
周所周知,swap 可以交换两个数的值,标准库的 swap 函数是通过拷贝完成这种运算的。想想,如果是交换两个类对象的值,如果类中变量的个数很少,那么 swap 是有一定效率的,但如果变量个数很多呢?
你一定联想到了之前提过的,引用传递替换值传递。没错,交换两个类对象的地址就可以很有效率地完成大量变量的 swap 操作。不幸的是,标准库的 swap 并无交换对象地址的行为,所以我们需要自己写 swap 函数。
1 | class person{...}; |
这个函数无法通过编译,因为类变量是 private,无法通过对象访问。所以要把它变成成员函数。
1 | class person |
如果你觉得 p1.my_swap(p2) 的调用形式太low了,你可以设计一个non-member 函数(如果是在同一个命名空间那就再好不过了),实现swap(p1, p2),这里不做演示。你还可以特化 std 里的 swap 函数:
1 | namespace std |
值得注意的是,如果你设计的是类模板,而尝试对swap特化,那么会在 std 里发生重载,这是不允许的,因为用户可以特化 std 的模板,但不可以添加新的东西到 std 里。
还有一点:在上面工作全部完成后,如果想使用 swap ,请确定包含一个 using 声明式,一边让 std::swap 可见,然后直接使用 swap。
1 | template<class T> |
其中过程:
如果T在其命名空间有专属的 swap,则调用,否则调用 std 的swap。
如果在 std 有特化的 swap,则调用,否则调用一般的 swap。(也即是拷贝)
这一点虽然看着很奇怪……
总结
- 如果std::swap不高效时,提供一个swap成员函数,并且确定这个函数不抛出异常。
- 如果提供一个member-swap,也应该提供一个non-member swap来调用前者。对于class(非class template),要特化std::swap。
- 调用swap时,针对std::swap使用using形式,然后调用swap并且不带任何命名空间资格修饰。
- 为“用户定义类型”进行std template全特化时,不要试图在std内加入某些对std而言是全新的东西。
5、实现
条款26:变量尽可能在使用时定义
提前定义变量,有可能导致变量并没有使用(如中间抛异常了),而平白多了一个构造和析构成本。
但是循环怎么办?
1 | // 方式A |
做法A:1个构造 + 1个析构 + n个赋值操作
做法B:n个构造函数 + n个析构函数
打破本条款选择A的依据:
(1)你知道赋值成本比“构造+析构”成本低。
(2)你正在处理代码中效率高度敏感的部分。
否则你应该使用做法B,维持变量尽可能在使用时定义的原则。
请记住
尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
条款27:尽量少做转型动作
C语言风格类型转换(老式)
1 | (T)expression // 显示强转 |
C++新式转换,提倡统一都用新式风格,职能分类,更安全,更清晰。
- const_cast(expression) 用于将对象的常量性移除。也是唯一有此能力的C+±style转型操作符。
- dynamic_cast(expression) 一般用于安全向下转型,如基类到派生类。要谨慎,可能效率低下。
- static_cast(expression) 用来强制隐式转换,一般用于相关联类型转换,没有类型检测。如将int转double,派生类转基类(安全),基类转派生类(不安全)等。
- reinterpret_cast(expression) 一般用于不相干类型转换,没有限制。如int* 转int,int转函数指针等。常用于转换函数指针,即可以将一种类型的函数指针转换为另一种类型的函数指针。
转型破环了类型系统。那可能导致任何种类的麻烦,有些容易识别,有些非常隐晦。所以尽量少做转型操作。
关于传递给const
引用临时对象的问题
我们可以安全地将一个临时对象传递给一个接受const
引用的函数。在C++中,临时对象在函数调用的整个持续时间内都是有效的,因此可以安全地传递给const
引用参数。这样做的好处包括:
- 避免不必要的拷贝:通过传递引用而不是值,可以避免对临时对象的拷贝,从而提高效率。
- 保持类型安全:使用
const
引用可以确保在函数内部不会修改传递的对象。
请记住
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型操作放进客户代码中。
- 宁可使用C+±style转型,不要使用旧式转型。前者很容易识别出来,而且也比较有着分门别类的职掌。
条款28: 尽量避免返回handles指向对象内部成分
handers是指:对象内的子对象的引用、指针或迭代器。返回子对象的引用、指针、迭代器会降低封装性,外部能越级访问深层级的对象并修改属性。
这并不意味着你绝对不可以让成员函数返回handle。有时候你必须那么做。例如operator[]就允许你获取strings和vectors的元素。尽管如此,这样的函数毕竟是例外,不是常态。
请记住
避免返回handles(包括引用、指针、迭代器)指向对象内部。遵守这条条款可增加封装性,帮助const成员函数的行为像个const,并将发生”虚吊号码牌“的可能性降至最低。
条款29. 为”异常安全”而努力是值得的
我们要时刻要求自己写的函数都是异常安全函数。
异常安全函数有两个条件:
- 不泄漏任何资源。
- 不允许数据败坏。
同时,异常安全函数内分了3个级别保证,你至少满足其中之一。
- 基本承诺:如果抛出异常,程序内的任何事物仍然保持在有效状态下。
- 强烈保证:如果抛出异常,程序状态不改变。如果函数成功,就是完全成功,如果函数失败,程序会回到调用函数之前的状态。
- 不抛异常保证:承诺绝不抛出异常,所有操作都是作用于内置类型身上。
一般而言,我们都应该尽量做到强烈保证这个级别。而最高级别很多时候很难做到,任何使用动态内存的东西如果内存不足都有可能抛出异常。
下面是为编写异常安全函数而努力的示例:
1 | class PrettyMenu { |
上面这个常规思路的实现,如果new Image抛异常,lock资源泄漏,bgImage,imageChanges数据也招到破坏。不满足异常安全函数条件任何一个。下面我们来看怎么解决这两个问题:
1 | class PrettyMenu { |
做到这里还只能说满足基本承诺,如果Image构造函数抛异常(这里抛异常由编译器内部实现),有可能破环外部引用的imgSrc数据源(todo:这里有点牵强~感觉做到这一步已经是强烈保证了!)。
还记得我们前面写过一个不抛异常的swap么?就可以用在这里,我们让改变背景图片的操作先在副本对象中操作,都正确操作完后,在用swap交换数据,这样就保证了即使失败了也不会影响原有数据状态。
1 | struct PMImpl { |
强烈保证并非时刻都显得实际,也要衡量空间、效率成本。当强烈保证不切实际时,你就必须保证提供基本保证。
请记住
- 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构破坏。这样的函数有3中可能的保证:基本型、强烈型、不抛异常型。
- 强烈保证往往能够以swap来实现出来,但强烈保证并非对所有函数都可实现或具备现实意义。
- 函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的异常安全保证中的最低者。
条款30:透彻了解inline的里里外外
inline行为发生在编译期间,编译器是否要进行inline,不是取决于函数带不带inline,有时带了inline也不一定会inline(virtual函数,运行时才知道调用哪个),没带也可能inline(实现在头文件中)。
请记住
- 将大多数inline限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为函数模板出现在头文件中定义,就将它们声明为inline。
条款31: 将文件间的编译依赖关系降至最低
一、定义与目的
- Handle class(句柄类):
- 句柄类是一种设计模式,用于将类的接口和实现分离。
- 它通常包含一个指向实现类的指针或引用,客户通过句柄类调用接口,而不需要直接知道实现类的细节。
- 目的是减少文件间的编译依存关系,当实现类发生变化时,客户代码不需要重新编译。
- Interface class(接口类):
- 接口类是一种抽象类,通常包含纯虚函数(在Java等语言中称为接口)。
- 它定义了类必须实现的接口,而不包含具体的实现。
- 目的是提供一种规范或契约,确保不同的类能够以一致的方式被使用。
二、实现方式
- Handle class:
- 句柄类通常包含一个指向实现类的智能指针(如C++中的
std::shared_ptr
或std::unique_ptr
)。 - 句柄类的方法通过调用实现类的方法来实现功能。
- 实现类可以单独编译和链接,减少了客户代码对实现类细节的依赖。
- 句柄类通常包含一个指向实现类的智能指针(如C++中的
- Interface class:
- 接口类通常包含纯虚函数,这些函数在接口类中声明但没有实现。
- 具体类通过继承接口类并实现其纯虚函数来提供具体的功能。
- 客户代码通常通过接口类的指针或引用来操作具体类的对象,从而实现多态性。
三、使用场景
- Handle class:
- 适用于需要将接口和实现分离的场景,以减少编译依赖和提高代码的可维护性。
- 例如,在大型项目中,将类的接口和实现分离可以减少编译时间和避免不必要的重新编译。
- Interface class:
- 适用于需要定义一组相关操作的规范或契约的场景。
- 例如,在面向对象的框架中,接口类用于定义组件之间的交互规范。
直接看示例代码:
1 | // 相关头文件引入 |
上面Person定义文件和其包含的文件之间形成了一种编译依赖关系。如果这些头文件中任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个包含Person class的文件就得重新编译。这样的连串编译依赖关系会对许多项目造成难以形容的灾难。
解决这个问题的本质是让类的接口与实现分离(加快编译速度)。通常有两种做法:
第一种拆分两个类,一个用于声明,一个用于实现。
1 |
|
将实现分开后,即使修改了实现部分逻辑,对包含了Person接口类的其它类也没有影响,不需要重新编译。
第二种是用接口类。
这种类的目的是详细一一描述派生类的接口,因此它通常没有成员变量,也没有构造函数,只有一个virtual析构函数以及一组纯虚函数声明。
1 | // 接口定义,外部使用通过基类的create接口即可 |
请记住
- 支持编译依赖最小化的一般构想是:依赖声明式的头文件,不要依赖定义式的头文件。(声明与定义拆两个类,外部只include声明的类头文件) 基于此构想有两种手段:接口与实现拆分两个类和接口类。
- 程序库头文件应该以“完全且仅有声明式的形式存在。就是include的类都是声明式的类,其真实实现在另一个类中。
6.继承与面向对象设计
条款32. 确定你的public继承塑模出is-a关系
公有继承:继承过来的基类成员访问属性不变。
保护继承:继承过来的基类中的私有成员访问属性不变,公有成员和保护成员变为保护成员。
私有继承:继承过来的基类中的私有成员属性不变,公有成员和保护成员变为私有成员。
不管是哪种继承方式,派生类中成员可以访问基类的公有成员和保护成员,无法访问私有成员。而继承方式影响的是派生类继承成员的访问属性。
请记住
public继承:适用于base classes身上的每一件事情一定也适用与derived classes身上,因为每一个derived class对象也都是一个base class对象。
条款33. 避免遮掩继承而来的名称
派生类中函数会遮掩基类中的同名函数。从名称查找来看,像是基类中对应的同名函数没被继承过来一样。简单来说就是作用域问题,派生类覆盖基类。
1 | class Base { |
如果不想被派生类同名函数把基类中所有其它重载函数都遮掩了,可以使用using声明,也可以使用作用域说明符。(如在函数前加上Base:: 就知道调用的是基类的)
1 | // 使用using后,上面两处报错的都可以找到Base::mf1/Base::mf3了。 |
如果不想要外边访问基类中任何成员,可以用私有继承实现(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函数前后做一些额外操作空间。
1 | class GameCharacter { |
第二种,把这个虚函数提到类外边以一个普通函数存在,然后类的构造函数接收一个函数指针指向这个函数。把实现从类成员中剥离出去。— Strategy设计模式
为了更清晰地解释这段话,我们可以将其拆分为以下几个要点:
- 虚函数提到类外边:
在经典的面向对象编程中,虚函数是定义在类内部的,用于实现多态性。但在这里,提到的是将这些虚函数(或更准确地说是它们的功能)实现为类外部的普通函数。 - 函数指针:
函数指针是指向函数的指针,它允许你将函数作为参数传递给其他函数,或者将函数作为对象成员(尽管这里说的是构造函数接收,但实际上是类内部可能持有这个指针以调用函数)。 - 类的构造函数接收函数指针:
类的构造函数被设计为接收一个函数指针作为参数。这意味着在创建类的对象时,你可以指定一个具体的函数来实现某个特定的行为。 - 实现从类成员中剥离出去:
通过将行为(原本可能是类的虚函数)实现为外部函数,并通过函数指针与类关联,这些行为就不再是类不可分割的一部分。这样做的好处是增加了代码的灵活性和可重用性。
1 | class GameCharacter; |
这样的好处是:
- 同一个类之下不同的对象可以有不同的defaultHealthCalc实现。
- 某个类对象的defaultHealthCalc可在运行期变更。
请记住
- virtual 函数的替代方案包括NVI手法和Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。
- 将机能从成员函数移到class外部函数,带来的一个缺点是,这个非成员函数无法访问class的非公有成员。
- tr1::function对象的行为就像一般函数指针。比函数指针能多接纳一些特别的函数。
条款36. 绝不重新定义继承而来的非虚函数
当做一个约定就行
请记住
- 绝对不要重新定义继承而来的non-virtual函数。
条款37. 绝不重新定义继承而来的缺省参数值
1 | class Shape { |
上面代码我们都知道最后的pr->draw调用的是Rectangle里覆写后的draw,这很正常没什么问题。
诡异的是缺省的参数却是用的Red,而不是自己的Green。
导致这个结果的原因是编译器优化的手段,缺省参数是静态绑定的(运行之前确定),而virtual函数是动态绑定的(运行时确定)。所有上面pr->draw的调用就出现接口是用的派生类的,而缺省参数用的基类的。
这种表现会给阅读代码的人带来歧义,所以:
请记住
- 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定的,而virtual函数是你唯一应该覆写的东西,却是动态绑定。
条款38. 类的子对象
区分类的继承和复合,B继承A,我们可以说B是A,而B中包含A子对象(复合),我们一般说B中有A,而不能说B是A了。
请记住
- 子对象的意义和public继承完全不同。
条款39. 明智而审慎地使用private继承
私有继承:继承过来的基类中的所有成员在子类中都是私有成员。
Private继承意味 is-implemented-in-terms of(根据某物实现出)。它通常比复合
(composition)的级别低。但是当derived class需要访问protected base class 的成员,或需要重新定义继承而来的virtual函数时,这么设计是合理的。
和复合(composition)不同,private继承可以造成empty base最优化。这对致
力于“对象尺寸最小化”的程序库开发者而言,可能很重要。
条款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的两相组合。
7.模板与泛型编程
条款41. 了解隐式接口和编译期多态
隐式接口:函数模板,类型不清楚,对我们来说接口是隐藏的。
显示接口:我们常规的头文件接口声明就是显示接口,明确了返回值,参数。
编译期多态:编译时实例化模板确定哪个重载函数被调用。
运行期多态:运行时哪一个virtual函数该被绑定。
请记住
- class和template都支持接口和多态。
- 对class而言接口是显示的。多态则是通过virtual函数发生于运行期。
- 对template而言,接口是隐式的。多态则通过template实例化和函数重载解析,发生于编译器。
条款42. 了解typename的双重意义
模版声明有两种形式:
- typename
- class
这里声明模版参数时,它们的意义完全相同。
不过对于typename在模版中除了声明模版参数外还有几处特别的用处要注意!
1 | template<typename C> |
这里有个新名词要了解,嵌套从属类型:即属于模版类型C下的类型,形式:C::xxx
。
**上面对应的就是C::const_iterator,这里是有歧义的,**C::const_iterator是一个类型了还是一个变量了,如果作为类型上面就是定义一个指针x,如果作为变量就是乘x。对于这种嵌套从属类型,编译器一般默认当变量处理。如果要当类型处理就必须在其前面加关键字typename
。
1 | typename C::const_iterator* x; // 这样就显示告诉编译器,C::const_iterator是一个自定义类型 |
另外对于嵌套从属类型前面加typename,有两处特例不能加。即不能出现在基类和成员初始化列表的嵌套从属类型里(除此之外都要加)。
1 | template<typename T> |
请记住
- 声明template参数时,前缀关键字class和typename可互换,意义一样。
- 请使用关键字typename标识嵌套从属类型,但不得在基类或成员初始化列表内使用。
条款43. 注意处理模版化基类内的名称
1 | template<typename T> |
像上面的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被实例化多次时可能发生的重复。
1 | template<typename T, std::size_t n> // 这里T称为模版的类型参数,n是非类型参数 |
上面这段模版封装,多次实例化,其中invert也会实例多份,虽然它们二进制实现一样。这就是隐晦的重复代码。
1 | template<typename T> |
把重复逻辑移到基类中,所有模版类共有,这样就减少了代码膨胀了。
本条款想表达的是使用template时要注意多次实例化后可能带来的代码重复,要尽量避免这种重复代码。
请记住
- template生成多个class和多个函数,所以任何template代码都不该与某个造成膨胀的template参数产生相依关系。
- 因非类型模版参数而造成的代码膨胀,往往可消除,做法是以函数参数或class成员变量替换template参数。
- 因类型参数而造成的代码膨胀,往往可以降低,做法是让带有完全相同二进制实现的代码共享,如放基类中。
条款45. 运用成员函数模版接受所有兼容类型
在C++中,成员函数模板允许我们编写可以接受多种类型参数的成员函数,而不需要为每种类型都显式地重载函数。这就是“运用成员函数模板接受所有兼容类型”这句话的含义。
简单来说,当你有一个类或结构体,并且你希望它的某个成员函数能够接受不同类型的参数(只要这些类型是兼容的,即可以进行相应的操作),你就可以使用成员函数模板来实现这一点。这样做的好处是代码更加简洁、易于维护,并且提高了代码的复用性。
1 |
|
还想要表达的是我们封装的模版所有操作行为要和普通类保持一致。即隐式行为要一致。如不同类型可隐式相互转换。
1 | template<typename T> |
不过注意泛化的成员函数(即成员函数模版)并不会影响编译器自动生成类默认函数规则。所以如果你要完全自定义类行为,默认产生的函数除了泛化版本,对应的正常化版本也要声明。
请记住
- 请使用成员函数模版生成可接受所有兼容类型的函数。
- 如果你声明成员函数模版用于泛化copy构造函数或赋值操作符,你还是需要声明对应正常的copy构造函数和赋值操作符函数。
条款46. 需要类型转换时请为模版定义非成员函数
对应条款24,这里只是模版实现。规则一致,但它们写法上有所区别了。
1 | template<typename T> |
上面只是把24条款示例改为模版实现,然而模版版本是编译不过的,因为编译器并不知道2要转换为什么。编译器推断不了模版的隐式转换。
对于模版我们只能通过friend和inline特性来实现非成员函数的定义。
1 | template<typename T> |
这样就可以编译,连接通过了。
请记住
- 当我们编写一个class template,而它所提供的函数要支持隐式转换时,请将这些函数定义为class template内部的friend函数。
条款47. 请使用traits class表现类型信息
想要了解STL容器可以转到C++ | STL | 侯捷 | 学习笔记_c++ stl-CSDN博客
请记住
- Traits class 使得类型相关信息在编译器可用。它们以template和template特化完成实现。
- 整合重载技术后,traits class有可能在编译期对类型执行if…else测试。(重载是编译期确定,if是运行期确定)
例子:
以 iterator_traits 为例介绍如何实现和使用 traits classes。STL 提供了很多的容器、迭代器和算法,其中的 advance 便是一个通用的算法,可以让一个迭代器移动给定距离:
1 | template<typename IterT, typename DistT> |
回到 advance 上,它的实现取决于 Iter 类型:
1 | template<typename IterT, typename DistT> |
接下来就是怎么判断 Iter 的类型是否是 random access 迭代器了,也就是需要知道它的类型。这真是需要使用到 Traits classes 的地方。
实现 Traits classes:
用户自定义类型:
1 | template<typename IterT> |
指针类型:
指针本身就是可以支持随机访问(random access)的,所以我们对指针类型提供一个偏特化版本即可:
1 | template<typename IterT> // template偏特化 |
advance 实现:
不好的实现方式:
1 | template<typename IterT, typename DistT> |
IterT
和 iterator_traits<IterT >::iterator_category
都是可以在编译期间确定的,而 if 判断却要在运行期间核定,这样不仅浪费时间,也会导致代码膨胀。
建议做法是建立一组重载函数(doAdvance),接受不同的类型,原函数(advance)调用这些重载函数。
1 | // 原函数 |
条款48. 认识template元编程
47条款的示例就是使用的模版元编程技术,它是一种把运行期的代码转移到编译期完成的技术。这种技术可能永远不会成为主流,但是如果你是一个程序库开发员,那这种技术就是家常便饭了。
通过模版或重载技术,把如if这种运行期的判断转换为编译期重载函数自动匹配。
它有两个特点:
- 它让某些事情更容易。如果没有它,那些事情将是困难的,甚至不可能的。
- 由于它将工作从运行期转移到编译期。这可更早发现错误,而且更高效、较小的可执行文件、较短的运行期、较少的内存需求。不过它会使编译时间变长。
请记住
- 模版元编程可将工作由运行期转移到编译期,因而得以实现早期错误发现和更高的执行效率。
- 模版元编程可被用来生成客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。
8.定制 new 和 delete
了解new和delete相关知识可转入侯捷 | C++ | 内存管理 | 学习笔记(一): 第一章节 primitives-CSDN博客,想要了解内存池相关内容可转入侯捷内存管理第二章节或手写SGI STL,nginx内存池项目博客。
条款49. 了解new-handler的行为
new-handler就是当new抛异常之前,它会先调用一个客户指定的错误处理函数。通过set_new_handler
标准库函数指定。
1 | // 当new无法分配足够内存时,被调用 |
上面异常处理是全局的,但有时候你可能需要为不同类处理不同异常。
1 | class X { |
C++并不支持class的专属new-hander,但也可以通过其它形式自己实现。
1 | // RAII对象,保证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,请确定不要无意识地(非故意)遮掩了它们的正常版本。
9.杂项讨论
条款53. 不要忽略编译器的警告
记住后期很多无休止调试就是由于你前期没有重视编译警告引起的。尽管一般认为,写出一个在最高警告级别下也无任何警告信息的程序是理想的,然而如果你对某些警告信息有深刻理解,你倒是可以选择忽略它。不管怎样说,在你打发某个警告信息之前,请确定你了解它意图说出的精确意义。这很重要!
请记住
- 严肃对待编译器发出的警告信息。努力在你的编译器的最高警告级别下争取无任何警告的荣誉。
- 不要过度依赖编译器的报警能力,因为不同的编译器对待事情的态度并不相同。一旦移植到另一个编译器上,你原本依赖的警告信息有可能消失。
条款54. 让自己熟悉包括 TR1 在内的标准程序库
- C++ 标准程序库主要由 STL,iostream,locales 组成,并包含 C99 标准程序库。
- TR1 组件都在
std::tr1::
命名空间下,以下是组件实例:
- 智能指针。
tr1::function
,常用于实现回调函数。tr1::bind
,能够做 STL 绑定器 bind1st 和 bind2nd 所做的每一件事,而又更多。- Hash tables,用来实现 sets,multisets,maps 和 multi-maps。
- 正则表达式。
- Tuples 变量组,这是标准程序库 pair 的升级,pair 只能持有两个对象,而
tr1::tuple
可持有任意个数对象。 tr1::array
,本质是个 STL 化的数组,即一个支持成员函数 begin 和 end 的数组。不过它大小固定,并不使用动态内存。tr1::mem_fn
,这是一个语句上构造与成员函数指针(member function pointers)一致的东西。同样容纳并扩充了 C++98 的mem_fun
和mem_fun_ref
的能力。tr1::reference_wrapper
,一个让引用的行为更像对象的设施。- 随机数生成工具,它大大超越了 rand。
- 数学特殊函数,包括 Laguerre 多项式、Bessel 函数、完全椭圆积分,以及更多数学函数。
- C99 兼容扩充,这是一大堆函数和模版用来将许多新的 C99 程序库特性带入 C++。
- Type traits,一组 traits classes(条款 47),用以提供类型的编译期信息。
tr1::result_of
,这是一个用来推导函数调用的返回值类型的模版。
这些实现一般很多实现在boost库中都有!
请记住
- C++标准程序库的主要功能由STL、iostreams、locales组成。并包含C99标准程序库。
- TR1添加了智能指针、一般化函数指针、hash-based容器、正则表达式以及另外10个组件的支持。
- TR1自身只是一份规范。为获得TR1提供的好处,你需要一份实现。一个好的实现来源是Boost。
条款55. 让自己熟悉 Boost
- Boost 是一个社群,也是一个网站。致力于免费、源码开放、同僚复审的 C++ 程序库开发。
- Boost 提供许多 TR1 组件的实现品,以及其它许多程序库。