Effective C++笔记 📒
让自己习惯C++
条款01: 视C++为一个语言联邦
C++主要的4个次语言
- C Blocks; statements; preprocessor; built-in data types; arrays; pointers; ...
- Object-Oriented C++ Classes; encapsulation(封装); inheritance; polymorphism virtual; ...
- Template C++ Generic programming
- STL Containers; iterators; algorithms; function objects; ...
每个层次应该有自己的最佳实践。例如对于 C 层次,传入函数最佳的实践应该是传入值(pass-by-value),而不是指针(pass-by-reference),而对于 C with classes 层次,则以传递引用为最佳的实践。
条款02:
尽量以const, enum, inline 替代
#define
宏常量用全局的const或者enum来替换
当你在一个类内声明某变量,但你的编译器不允许在声明时赋值初始化,同时接下来的某个语句却需要用到这个变量的具体数值,例如:
1 | |
此时编译器便会报错,需要在编译期间noPlayer有具体数值,这个时候就需要使用如下技巧:
1 | |
这样编译器就能顺利通过,因为enum可以被当成int类型来使用
但注意enum类型在内存中没有实体,无法取得enum类型的地址,因此这个方法更相当于取一个本地的#define数值
宏函数用inline修饰的函数来替换
1 | |
条款03:
尽可能使用const
1 | |
1 | |
1 | |
令函数返回常量值往往可以降低客户错误而造成的意外,而又不至于放弃安全性和高效性。
1 | |
给成员函数使用const关键字是非常重要的,它可以让接口更加直观,直接告诉用户这个函数是不是只读(Read
only),会不会改变某变量。更重要的是,用const修饰的对象只能调用const修饰的成员函数,因为不被const修饰的成员函数可能会修改其他成员数据,打破const关键字的限制。因此,需要同时声明有const和没有const的成员函数,例如:
1
2
3
4const char& operator[](size_t pos) const;
// 函数前const:普通函数或成员函数(非静态成员函数)前均可加const 修饰,表示函数的返回值为const,不可修改。
// 函数后加const:只有类的非静态成员函数后可以加 const 修饰,表示该类的 this 指针为 const 类型,不能改变类的成员变量的值,即成员变量为read only
char& operator[](size_t pos);
下面代码中length()函数要做某些错误检测,因此可能会修改成员数据。即使真正的功能核心只是返回字符长度,编译器依然认为你可能会修改某些成员数据而报错。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class Text{
public:
std::sizt_t length() const;
private:
char* pText;
std::size_t length;
bool lengthValid;
....
};
std::size_t Text::length() const {
if(!lengthValid){ //做某些错误检测
length = std::strlen(pText); //error! 在 const 成员函数中
lengthValid = true; //不能赋值给textLength
} //和lengthIsValid
return length;
}
逻辑常量性(Logical
constness),即允许某些数据被修改,只要这些改动不会反映在外,例如,以上问题可以用mutable关键字来解决。
引入
mutable之后,C++ 可以有逻辑层面的const,也就是对一个常量实例来说,从外部观察,它是常量而不可修改;但是内部可以有非常量的状态。
mutable只能用来修饰类的数据成员;而被mutable修饰的数据成员,可以在const成员函数中修改。
1 | |
在定义常量与非常量成员函数时,避免代码重复
代码复制一遍,既不显得美观,又增加了代码维护的难度和编译时间。因此,我们可以使用非常量的函数来调用常量函数。(如果使用相反的方法,用 const 函数来调用 non-const 函数,就可能会有未知结果,因为这样相当于 non-const 函数接触到了 const 对象的数据,就可能导致常量数据被改变。)
1 | |
为了避免无限递归调用当前非常量的操作符,我们需要将(*this)转换为const Text&类型才能保证安全调用const的操作符,最后去掉const关键字再将其返回,巧妙避免了代码的大段复制。
条款04: 确定对象被使用前已被初始化
注意 assignment 和 initialization 的区别
对象的成员变量的初始化动作发生在进入构造函数本体之前。
1 | |
所以最好使用 member initialization list 替换赋值动作,结果相同但通常效率更高:
1 | |
构造函数是可以被重载(overload)的,还需要一个没有参数输入的默认构造函数,可以定义:
1 | |
在定义引用(reference)和常量(const)时,不将其初始化会导致编译器报错
1 | |
C+成员初始化次序:
base classes 早于其derived classes 被初始化;
class 的成员变量总是以其声明次序被初始化;
1
2
3
4
5
6
7
8
9
10
11class myClass{
private:
int a; // 真正顺序
int b;
int c;
public:
myClass(int _a, int _b, int _c);
};
//注意,即使初始化列表是c->a->b的顺序,真正的初始化顺序还是按照a->b->c
myClass::myClass(int _a, int _b, int _c): c(_c), a(_a), b(_b) {}
static: 寿命从被构造出来直到程序结束为止;
local static: 函数内生命的 static 对象;
non-local static: 其余 static 对象。
程序结束时 static 对象会被自动销毁,也就是他们的析构函数会在main()结束时被自动调用。
编译单元(translation unit): 可以让编译器生成代码的基本单元,一般一个源代码文件就是一个编译单元。
非本地静态对象(non-local static object): 静态对象可以是在全局范围定义的变量,在名空间范围定义的变量,函数范围内定义为 static 的变量,类的范围内定义为 static 的变量,而除了函数中的静态对象是本地的,其他都是非本地的。
C++对定义于不同编译单元内的 non-local static 对象的初始化次序无明确定义。
此外注意,静态对象存在于程序的开始到结束,所以它不是基于堆(heap)或者栈(stack)的。初始化的静态对象存在于.data中,未初始化的则存在于.bss中。
现在有一种特殊情况,尤其是在大型项目中比较普遍:在两个编译单元中,分别包含至少一个非本地静态对象,当这些对象发生互动时,它们的初始化顺序是不确定的,所以直接使用这些变量,就会给程序的运行带来风险。那如何初始化非本地静态对象?
解决方法: 将非本地的静态变量变为本地静态变量
使用一个函数,只用来定义一个本地静态变量并返回它的引用。因为C++规定在本地范围(函数范围)内定义某静态对象时,当此函数被调用,该静态变量一定会被初始化。
1 | |
1 | |
构造/析构/赋值运算
条款05: 了解C++默默编写并调用哪些函数
C++会为类生成默认的关键函数,但是这些函数只有在被需要调用的时候才会生成,因此这4种功能不是保证都存在的。
1 | |
对于赋值运算符,只有当代码合法而且有意义时,编译器才会自动生成,否则拒绝编译。解决方法是自己定义赋值运算符。
条款06: 明确告诉编译器你不需要的某些自动生成的函数
所有编译器生成的函数都是public。通过将不需要的函数声明为 private 可以阻止编译器自动生成。
1 | |
更好的解决方案:当一个父类将拷贝函数声明为私有时,编译器会拒绝为它的子类生成拷贝函数。因此我们可以专门使用一个父类,在其中声明拷贝操作为私有,并让我们的类继承自它。
1 | |
条款07: 为多态基类声明 virtual 析构函数
析构函数(destructor)用来释放对象所占用的资源。当对象的使用周期结束后,例如当某对象的范围(scope)结束时,或者是动态分配的对象被 delete 关键字解除资源时,对象的析构函数会被自动调用,对象所占用的资源就会被释放。以及像第五章讲过的,假如在你的类中不声明析构函数,编译器也会为你自动生成一个。
多态(polymorphism)则是C++面向对象的基本思想之一,即抽象(abstraction),封装(encapsulation),继承(inheritance),多态(polymorphism)。如果我们希望仅仅通过基类指针就能操作它所有的子类对象,那这就是多态。
当你通过基类指针使用子类,使用完毕后却只从基类删除。同时这个基类的析构函数并不是虚函数(virtual),也就是不允许子类有自己版本的析构函数,这样就只能删除子类中基类的部分,而子类衍生出来的变量和函数所占用的资源并没有被释放,这就造成了这个对象只被释放了一部分资源的现象,依然会导致内存泄漏。
1 | |
解决方法:给基类一个虚的析构函数,这样子类就允许拥有自己的析构函数,就能保证被占用的所有资源都会被释放。
1 | |
虚函数是用来在运行时(runtime),自动把编译时未知的对象,比如用户输入的对象,和它所对应的函数绑定起来并调用。当一个类包含虚函数时,编译器会给这个类添加一个隐藏变量,即虚函数表指针(virtual table pointer),用来指向一个包含函数指针的数组,即虚函数表(virtual table)。当一个虚函数被调用时,具体调用哪个函数就可以从这个表里找了。
如果 class 不含 virtual 函数,通常表示它并不意图被用作一个 baseclass,此时令其析构函数为 virtual往往是个馊主意(会增加其对象大小)。因此只有当 class内含至少一个 virtual 函数,才为它声明 virtual 析构函数。
对于抽象类(abstract class),抽象类是包含至少一个纯虚函数的类(pure virtual function),而且它们不能被实例化,只能通过指针来操作,是纯粹被用来当做多态的基类的。
相比具体类(concrete class),虽然它们都可以通过父类指针来操作子类,但抽象类有更高一层的抽象,从设计的角度上能更好概括某些类的共同特性。
多态的基类需要有虚析构函数,抽象类又需要有纯虚函数,那么在抽象类中就要把析构函数声明为纯虚函数:
1 | |
同时注意,当在继承层级中某一类的析构函数被调用时,它下一级类的析构函数会被随后调用,最后一直到基类的析构函数,因此作为析构函数调用的终点,要保证有一个定义,否则链接器会报错。
1 | |
析构函数的运作方式是:最深层派生(most derived)的那个 class 起析构函数最先被调用,然后是其每一个 base class 的析构函数被调用。
*某些类不是被用来当做基类的,比如std::string和STL,或者某些不是用来实现多态的基类,比如上一章的Uncopyable,就不需要虚的析构函数。
条款08: 阻止异常离开析构函数
一个例子:
1 | |
为确保客户不忘在DBConnection对象上调用close(),可以创建一个用来管理DBConnection资源的class,并在其析构函数中调用close。
1 | |
但是上述场景有一个问题,若对象dbc的析构函数调用close()时抛出异常该怎么办?
主动关闭 使用
std::abort()主动关闭程序,而非任由程序崩溃。1
2
3
4
5
6
7
8DBConn::~DBConn(){
try{
db.close();
}catch(...){
//记录访问历史
std::abort();
}
}重新设计借口 把调用 close 的责任从 DBConn 析构函数移到 DBConn 客户手上。但在 DBConn 中仍有“双保险”调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class DBConn{
public:
...
~DBConn();
void close(); //[1]当要关闭连接时,手动调用此函数
private:
DBConnection db;
bool closed = false; //显示连接是否被手动关闭
};
void DBConn::close(){ //[1]
db.close();
closed = true;
}
DBConn::~DBcon(){
if(!closed)
try{
db.close();
}catch(...){
//记录访问历史
//消化异常或者主动关闭
}
}
条款09: 绝不在构造和析构过程中调用 virtual 函数
假设有一个 class 继承体系,用来记录股票交易:
1 | |
此时定义一个对象:
1 | |
此时bass class 构造函数先被调用,但 base class 构造期间 virtual 函数不会下降到derived class 阶层。derived class 对象的 base class 构造期间,对象的类型是 base class 而不是 derived class(可以看作在base class 构造期间,virtual 函数不是 virtual 函数)
如果一定想要对象在初始化的时候完成某些任务。那就需要在父类Transaction中,把虚函数logTransaction去掉virtual关键字,变成普通的函数,然后在子类构造过程中,把某些信息传递到父类的构造函数中。
1 | |
现在就可以在子类对象中如下定义构造函数了,这样就能把子类的信息传递到父类中,让父类构造函数去完成子类构造函数想做的事:
1
2
3
4
5
6
7class BuyTransaction : public Transaction{
public:
BuyTransaction(...) : Transaction(createLog(...)) { ...}
...
private:
static std::string createLog(...);
};
这里
createLog()就是一个辅助函数(helper function),用来将某函数的一部分功能封装成另一个小函数,减少代码的复杂性,使代码更加可读。此外,因为这是一个子类的私有成员,父类构造函数被调用时不能保证它被初始化,所以使用static关键字可以避免意外使用了未初始化的成员数据。
条款10: 令operator=返回一个referenc to *this
C++的赋值符号=(assignment
operator)具有链式赋值(Chained assignment)特性:
1 | |
对于自定义类,应该遵守如下两个规则:
- 返回类型是reference;
- 返回
*this给左变量。
1 | |
以上适用于所有赋值相关运算,如:
1 | |
条款11:
在operator=中处理「自我赋值」
自我赋值(self-assignment):
1 | |
在继承体系中,即使两个对象被声明为不同类型,但仍可能发生该情况:
1 | |
如果使用条款13和条款14运用对象来管理资源则或许是“自我赋值安全的”(self-assignment-safe)。
但如果手动管理资源则可能出现问题。假设用一个 class 保存一个指针指向一块动态分配的位图(bitmap):
1 | |
如若传入的参数rhs为自身,会导致 delete
语句把*this自己的资源释放掉,同时也释放掉了rhs的资源,最后返回的*this包含了一个损坏的数据,你不能访问不能修改,甚至不能通过
delete
来为其释放资源,等于说这段空间就凭空消失了,所以这段代码不是自赋值安全的。
方案1:检查传入的参数rhs是否为*this
但仍不是异常安全的(exception-safe)
1 | |
方案2:重新排列语句 既对自赋值安全,对异常也是安全的。如果现在 new 的这行抛出了异常,指针 pb 也不会被提前删除。
1 | |
方案3:先拷贝再调换( copy and swap 技术)
1 | |
另一种形式,巧妙利用了C++传值(pass-by-value)会自动生成一份本地拷贝的特性:
1
2
3
4Widget& Widget::operator=(Widget rhs){ // 此时 rhs 是被传对象的副本
swap(rhs);
return *this;
}
条款12: 对复制对象的所有成员变量都拷贝
C++有两种拷贝函数(copying function):拷贝构造函数(copy constructor)和拷贝赋值操作符(copy assignment operator)。如果在自己定义的类中不声明这些拷贝函数,编译器会自动生成。如果我们声明了自己的拷贝函数,程序将会执行我们自己的拷贝函数。
1 | |
当上述代码新增了一个数据成员时,如果没改变拷贝函数,则只能得到一个部分拷贝(partial copy)的对象。同样的情况也发生在继承体系中:
1 | |
上面的代码并没有拷贝基类部分的数据成员。基类的数据成员被设定为默认值。
base class的数据复制过程必须小心(数据往往是private,无法直接访问)。所以应该直接让derived class的拷贝函数调用相应的baseclass函数。
1 | |
C++的这两种拷贝函数有相似的功能和代码,那么我们能不能避免代码重复,让其中一个拷贝函数调用另一个呢?答案是不能。
使用拷贝赋值操作符调用拷贝构造函数,或者使用拷贝构造函数调用拷贝赋值操作符,都是没有意义的。拷贝赋值操作符适用于已经构造好的对象,而拷贝构造函数适用于还没有构造好的对象,所以这种做法在语义上是错误的。
消除代码重复需要做的是:建立一个新的成员函数给两者调用。
这样的函数往往是 private 且常被命名为
init。
资源管理
条款13: 以对象管理资源
在C++中,资源多数是指动态分配的内存。如果你只用 new 来分配内存却不在使用完后 delete 掉,将会导致内存泄漏。
内存泄漏:程序在堆中申请的动态内存,在程序使用完成时没有得到及时的释放。当这些变量的生命周期已结束时,该变量在堆中所占用的内存未能得到释放,从而就导致了堆中可使用的内存越来越少,最终可能产生系统运行较慢或者系统因内存不足而崩溃的问题。
假设我们在为不同类型的投资写一个库:
1 | |
返回一个指针就说明我们要负责在用毕后及时释放资源:
1 | |
存在的问题:若中间部分存在并触发了return语句,则delete会被跳过;若delete语句在某个循环中,若触发了break或goto语句,delete也不会被执行;若中间代码抛出异常,该指针也不会被删除......
解决方案1(已被弃用):利用C++的对象析构函数自动调用机制,把资源封装在对象里面,这样当对象完成了它的使用周期,资源就会保证被释放。使用标准库的模板智能指针auto_ptr,它的析构函数会自动调用
delete 来释放它所指向的对象。
1 | |
RAII(Resource Acquisition Is Initialization)
获得资源后要立即传递给资源管理对象用来初始化。
资源管理类要利用析构函数来释放资源。当对象超出了它的作用域(scope)时,它的析构函数会自动调用,所以用析构函数来释放资源是保证安全的。
因为auto_ptr会在使用周期后自动删除资源,所以不要使用多个auto_ptr指向同一个对象,否则同一个对象会被释放两次,是一个非法操作。为了防止这种操作,标准库给auto_ptr定义了一个奇怪的特性:拷贝旧指针到一个新指针,旧指针会被设为NULL:
1 | |
解决方案2:使用引用计数的智能指针(Reference-Counting
Smart Pointer,
RCSP),它在运行时会统计有多少对象指向当前的资源,然后当没有任何对象指向当前资源时便会自动释放。C++标准库shared_ptr为例:
1 | |
shared_ptr 可以在 STL
容器中使用,成为一个更好的选择,因为shared_ptr没有auto_ptr自动把原对象设为NULL的拷贝特性,也因为当使用容器的算法功能生成本地拷贝时,此时有两个对象指向了这个资源。即使拷贝的析构函数被调用了,原有的对象依然在指向这个资源,该资源便不会被提前释放。
使用智能指针只是用对象管理资源的方法之一,而且也存在着局限性。例如我们不能使用标准库的智能指针来指向一个动态分配的数组,因为析构函数调用的是delete而不是delete[],虽然这样做依然可以通过编译。
1 | |
条款14: 小心考虑资源管理类的拷贝行为
上一条款提到了如何用智能指针管理基于堆(heap)的资源,但对于堆以外的资源(如 mutex 锁)智能指针不再那么好用了,因此需要写自己的资源管理类。
假设使用 C API 函数处理类型为Mutex的互斥器对象(mutex
objects),有两个函数可用:
1 | |
同时我们有一个符合 RAII 规范的类来管理这些锁,RAII 即获取资源在对象构造过程中,释放资源在对象析构过程中:
1 | |
例如访问临界区(critical section), 临界区即线程必须互斥地访问某些资源,这些资源必须只能由最多一个线程访问,我们就需要以 RAII 的方式来进行操作:
1 | |
在创建自己的 RAII 资源管理类时,我们必须要思考需要如何规定这个类的拷贝行为。
解决方案1:禁止拷贝 有些对象的拷贝是没有意义的,就比如栗子中的这个锁,没有人会给同一个资源上两个锁,对于这样的类,我们就干脆禁止掉拷贝。(见条款06)
1 | |
解决方案2:给资源引用计数:
可以替代裸指针把shared_ptr作为 RAII
对象的数据成员来实现这个功能,将 mutexPtr
的类型从Mutex*变成shared_ptr<Mutex>。shared_ptr提供了一个特殊的可定义函数,删除器(deleter),即在引用计数为零时调用的函数,是shared_ptr构造函数的一个附加参数。这个函数在auto_ptr中是不存在的,因此它不能有自定义的删除行为,只能删除掉它包括的指针。
1 | |
这里没有定义析构函数,因为类的析构函数会调用它的非静态数据成员的析构函数(条款05)。这个例子中,Lock类的析构函数会调用它的成员mutexPtr的析构函数,而在当mutexPtr的引用计数为零时,它的析构函数则会调用删除器,即我们绑定的unlock函数。
常用的 RAII 类的拷贝行为有禁止拷贝,使用引用计数,拷贝资源,转移所有权,但也可以用其他做法来符合特殊需要。
条款15: 在资源管理类中提供对原始资源的访问(还没完全没看懂)
假设在为不同类型的投资写函数库:
1 | |
此时编译器会报错,因为函数需要裸指针类型的参数,而你传入的是智能指针类型。你需要做的也很简单,把智能指针转换为裸指针,使用隐式转换或者显式转换。
shared_ptr和auto_ptr都有一个成员函数get(),用来执行显式转换,返回智能指针对象所包含的裸指针:
1 | |
shared_ptr和auto_ptr也重载了指针的解引用运算符,即->和*,这意味着我们可以通过它们来实现隐式转换:
1 | |
定义自己的 RAII 资源管理类:
1 | |
如果我们要使用某些 C API
只能使用FontHandle类型,我们就需要把Font类型显式转换为FontHandle类型,因此我们定义一个显式转换的函数get():
1 | |
但是这样显式转换的缺点就是每次使用都要调用get()函数,比较麻烦:
1 | |
另外一个缺点就是,我们既然写了 RAII 资源管理类,为什么还要每次只使用它的原始资源,这不是跟我们希望避免资源泄漏的初衷背道而驰吗?我们下面来看看隐式转换:
1 | |
这样调用 API 就会简单很多:
1 | |
但是隐式转换也有缺点,某些类型错误就不会被编译器探测到了:
1 | |
我们希望把一个Font对象拷贝进另一个Font对象,但倘若输入的Font变成了FontHandle,这样就把我们的资源管理对象变成了原始资源对象,编译器也不会报错。
结果就是,程序有一个FontHandle对象被f1封装和管理,但这个FontHandle通过上面的操作被拷贝进了f2,这样f1和f2同时控制着一个FontHandle。如果f1被释放,这个FontHandle也将被释放,就会导致f2的字体被损坏。
条款16:
new和delete要对应使用
1 | |
如若未对应起来使用,结果皆为未定义。
在typedef类型中需要另加注意,最好在代码注释中标出要使用什么版本的delete(最好别用 typedef 定义数组):
1 | |
条款17: 使用单独的语句创建智能指针对象
假设我们有如下函数:
1 | |
现在用如下的语句调用这些函数:
1 | |
这句调用必然会导致编译器报错,因为除非定义过隐式转换的运算符,裸指针对象不能被隐式转换为智能指针对象,下面才是语法正确的调用方式:
1 | |
即使你在这里使用了智能指针,内存泄漏依然有发生的可能。
解析该函数的参数分为三步:
- 调用priority()函数
- 执行 new 语句
- 调用智能指针构造函数
不像 Java 或者 C# 的编译器只会以固定的顺序解析参数,C++ 编译器多种多样,而且根据优化选项的不同,编译器可能会改变这三步的执行顺序,以此利用指令流水线停顿的周期(pipeline stall),获得更高效率的代码。假设某编译器生成的机器代码实际执行顺序如下:
- 执行 new 语句
- 调用
priority()函数 - 调用智能指针函数
如果priority()函数抛出了异常呢?那么从 new
语句动态分配的资源在到达智能指针构造函数之前就会泄露了。解决方法也很简单,使用一个单独的语句来创建智能指针对象:
1 | |
编译器是逐语句编译的,通过使用一个单独的语句来构造智能指针对象,编译器就不会随意改动解析顺序,保证了生成的机器代码顺序是异常安全的,以及这样的代码写出来也更加美观。
「用一个单独的语句把裸指针储存到智能指针中。否则资源泄漏可能就会这么意想不到地发生了。」
设计与声明
软件设计就是让软件做你想做的事,软件设计一定需要接口(interface)设计,最后用C++实现。接口就是你提供给用户使用你代码的途径。C++到处都充满了接口的概念,比如函数接口,类接口,模板接口。理想条件下,如果我们用错了接口,编译器会报错,而如果编译器没有报错,那么我们用的接口就是对的。
条款18: 使接口容易被正确使用,不易被误用
如何预防错误次序传递参数?可以定义新的类型,使用简单的包装类(wrapper class),让编译器对错误的类型报错:
1 | |
保证了格式的正确,下一步就是要对取值做出规范,例如月份只能有1到12。使用 enum 可以满足功能上的要求,但 enum 不是类型安全的(type safe),因为第2章展示过 enum 可以被用来当作 int 类型使用。我们需要定义包含所有月份的集合:
1 | |
这种方法虽然略显繁琐,但保证了数据的正确性,并且在提升网页脚本安全性的实践中,这是一种常用的防止恶意用户注入代码的思路。
要把接口设计得一致。例如每一个 STL
容器类都有一个成员函数size()来返回容器当前包含的对象数量。一致还指行为上的一致性,也就是说要把功能做得与原始类型或是其它标准类型的逻辑一致。
"When in doubt, do as the ints do."
任何要求用户记住东西的接口都更容易造成误用。例如动态分配了一个资源,要求用户以某种特定的方式释放资源。例如条款13导入了一个 factory
函数,它返回一个指针指向Investment继承体系内的一个动态分配对象:
1 | |
为了防止资源泄漏,这个动态分配的资源必须在使用完后删除,但要求用户这样做可能会产生两种情景:
- 用户忘记了删除
- 多次删除同一个指针
解决方法是使用智能指针自动管理资源,但如果用户忘记把这个函数的返回值封装在智能指针内呢?所以我们最好让这个函数直接返回一个智能指针对象:
1 | |
同时std::shared_ptr还能阻止用户犯下资源泄漏的错误,因为允许智能指针被建立起来时指定一个资源释放函数(deleter)绑定于智能指针身上。
假如我们规定,如果用户从这个工厂函数得到了一个Investment*对象,在释放时要用另一个getRidOfInvestment()函数来释放资源,而不是单独使用delete,这就可能会导致用户忘记而使用了错误的释放机制。要防止这种错误,我们把getRidOfInvestment()绑定到shared_ptr的删除器,这样shared_ptr就会在使用完成后自动帮用户调用释放函数。
1 | |
绑定删除器还有另一大好处,就是避免了DLL交叉问题(cross-DLL
problem)。这个问题是当一个对象从一个 DLL 中生成,在另一个 DLL
中释放时,在许多平台上就会导致运行时的问题,因为不同 DLL
的new和delete可能会被链接到不同代码。shared_ptr的删除器则是固定绑定在创建它的
DLL
中,这就例如,我们有Stock类继承自Investment:
1 | |
工厂函数返回的Stock类型智能指针就能在各个 DLL
中传递,智能指针会在构造时就固定好当引用计数为零时调用哪一个 DLL
的删除器,因此不必担心 DLL 交叉问题。
条款19: 设计类如同设计类型
好的类型拥有自然的语法,直观的语义和高效率的实现。如何高效地设计一个类呢? 以下的问题在几乎所有的类型设计中都会遇到,以及考虑这些问题会如何影响到你的设计:
- 新类型的对象要如何创建和销毁?
"How should objects of your new type be created and destroyed?"
这决定了要如何写构造函数和析构函数,包括要使用什么内存分配和释放函数,即 new 还是 new[],delete 还是 delete[]。(条款16)
- 对象初始化要如何区别于赋值?
"How should object initialisation differ from object assignment?"
这决定了你如何写,如何区别构造函数和赋值运算符,以及不要把初始化与赋值混淆,因为它们的语义不同,构造函数适用于未创建的对象,赋值适用于已创建的对象,这也是为什么我们要在构造函数中使用初始化列表而不使用赋值的原因。(条款04,条款12)
- 新类型的对象传值有什么意义?
"What does it mean for objects of your new type to be passed by value?"
要记住拷贝构造函数决定了你的类型是如何被传值的,因为传值会生成本地的拷贝。
- 新类型的合法数值有什么限制?
"What are the restrictions on legal values for your new type?"
通常情况下,并不是成员的任何数值组合都是合法的。要让数据成员合法,我们需要根据合法的组合,在成员函数中对数值进行检测,尤其是构造函数,赋值运算符和setter。这也会影响到使用它的函数会抛出什么异常。
- 新类型属于某个继承层次吗?
"Does your new type fit into an inheritance graph?"
如果你的新类型继承自某个已有的类,你的设计将被这些父类影响到,尤其是父类的某些函数是不是虚函数。如果你的新类型要作为一个父类,你将要决定把哪些函数声明为虚函数,尤其要注意析构函数。(条款07)
- 新类型允许什么样的转换?
"What kind of type conversions are allowed for your new type?"
新类型的对象将会在程序的海洋中与其它各种各样的类型并用,这时你就要决定是否允许类型的转换。如果你希望把T1隐式转换为T2,你可以在T1中定义一个转换函数,例如operator
T2,或者在T2中定义一个兼容T1的不加explicit修饰的构造函数。
如果希望使用显式转换,你要定义执行显示转换的函数。(条款15)
- 什么运算符和函数对于你的新类型是有意义的?
"What operators and functions make sense for the new type?"
这决定了你要声明哪些函数,包括成员函数,非成员函数,友元函数等。
- 你要禁止哪些标准函数?
"What standard functions should be disallowed?"
如果不希望使用编译器会自动生成的标准函数,把它们声明为私有。(条款16)
- 谁可以接触到成员?
"Who should have access to the members of your new type?"
这影响到哪些成员是公有的,哪些是保护的,哪些是私有的。这也能帮你决定哪些类和函数是友元的,以及要不要使用嵌套类(nested class)。
- 新类型的"隐藏接口"是什么?
"What is the 'undeclared interface' of your new type?"
新类型对于性能,异常安全性,资源管理(例如锁和内存)有什么保障? 哪些问题是自动解决不需要用户操心的? 要实现这些保障,自然会对这个类的实现产生限制,例如要使用智能指针而不要使用裸指针。
- 新类型有多通用?
"How general is your new type?"
如果想让你的新类型通用于许多类型,定义一个类模板(class template),而不是单个新类型。
- 新类型真的是你需要的吗?
"Is a new type what you really want?"
如果你想定义一个子类只是为了给基类增加某些新功能,定义一些非成员的函数或者函数模板更加划算。
条款20: 宁以 pass-by-reference-to-const 代替 pass-by-value
尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题(slicing problem)。
Pass-by-value 使得对象内部总体成本(构造 + 析构)增加。
1
bool validateStudent(const Student &s); // const 使得 s 不会被改变Sclicing Problem:
当一个 derived class 对象以 by-value 方式传递并被视为一个 base class 对象,base class 的 copy 构造函数会被调用,导致 derived class 的特化性质全部被切割。
以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象。对它们而言,pass-by-value往往效率更高。
因为习惯上被设计为 passed by value
条款21: 必须返回对象时,别妄想返回其 reference
绝不要返回 pointer 或 reference 指向一个 local stack 对象,或返回 reference 指向一个 heap-allocated 对象,或返回 pointer 或reference 指向一个 local static 对象而有可能同时需要多个这样的对象。条款04已经为“在单线程环境中合理返回 reference 指向一个 local static 对象”提供了一份设计实例(单例)。
一个必须返回新对象的函数正确写法:返回新对象。
1 | |
条款22: 将成员变量声明为 private
切记将成员变量声明为 private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性。
语法一致性(条款18):如果成员变量不是public,那么只能通过成员函数访问。
protected 并不比 public 更具封装性。
一样缺乏封装性。
其实只有两种访问权限:private(封装)和其他(不提供封装)
条款23: 宁以non-member、non-friend 替换 member 函数
- 宁可拿 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性(packaging flexibility)和机能扩充性。
1 | |
用户可能想一整个执行所有这些动作,那么 WebBrowser 提供这样一个函数:
1 | |
更好的方式是让clearBrowser()成为一个 non-member
函数并且位于WebBrowser所在的同一个 namespace 内:
1 | |
namespace 与 classes 不同,前者可以跨越多个源码文件而后者不能。
条款24: 若所有参数皆需类型转换,请为此采用 non-member 函数
如果你需要为某个函数的所有参数(包括被
this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。例如允许整数隐形转换成有理数并支持混合式算术运算: 让
operator *成为一个non-member函数,允许编译器在每一个实参身上执行隐式类型转换。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Rational {
public:
Rational(int numerator = 0, // 刻意不为explicit
int denominator = 1); // 允许 int-to-Rational隐式转换
int numerator() const;
int denominator() const;
...
}
const Rational operator * (const Rational &lhs, const Rational &rhs) {
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; // 通过编译
result = 2 * oneFourth; // 通过编译member函数的反面是non-member函数而非 friend 函数。 (能避免 friend函数就避免)
条款25: 考虑写出一个不抛异常的 swap 函数
C++只允许对class templates偏特化,在function templates 身上偏特化是行不通的。
- 当
std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。 - 如果你提供一个member
swap,也该提供一个non-memberswap用来调用前者。对于classes(而非templates),也请特化std::swap。 - 调用
swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。 - 为“用户定义类型”进行
stdtemplates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
假设我们有一个用户定义的类
Widget,其中包含一些资源管理逻辑。为了提高效率,我们希望提供一个自定义的
swap 函数,这个函数不抛异常。
提供一个
swap成员函数 我们首先为Widget类提供一个成员函数swap,并确保它是noexcept的。1
2
3
4
5
6
7
8
9
10
11
12class Widget {
public:
void swap(Widget& other) noexcept {
using std::swap;
swap(this->data, other.data);
swap(this->ptr, other.ptr);
}
private:
int data;
std::unique_ptr<int> ptr;
};在这个例子中,
Widget类包含一个整数和一个智能指针。我们在swap成员函数中交换这两个成员变量,并使用noexcept来保证函数不会抛出异常。提供一个 non-member
swap函数接下来,我们提供一个全局的
swap函数,它调用Widget的成员函数swap。1
2
3void swap(Widget& a, Widget& b) noexcept {
a.swap(b);
}这个 non-member
swap函数也使用noexcept,并直接调用Widget的成员函数swap。对
std::swap进行特化对于
Widget类,我们还可以对std::swap进行特化,以确保在标准库调用std::swap时使用我们定义的高效swap函数。1
2
3
4
5
6namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) noexcept {
a.swap(b);
}
}这样,当在某些情况下
std::swap被调用时,比如在标准算法中使用时,std::swap将会使用我们提供的Widget的swap实现。在函数中使用
swap在需要交换两个
Widget对象的函数中,我们可以按照以下方式使用swap:1
2
3
4
5void someFunction() {
Widget w1, w2;
using std::swap;
swap(w1, w2);
}
在这个例子中,using std::swap; 声明允许我们在调用
swap 时不带任何命名空间修饰符。如果 Widget
类的 swap 函数存在,那么它将会被优先调用;否则,将使用
std::swap。
实现
条款26: 尽可能延后变量定义式的出现时间
尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
在构造对象时跳过 default 构造过程(条款04)
1
2
3
4std::string encrypted;
encrypted = password;
// 👇
std::string encrypted(password);如果 classes 的赋值成本低于一组构造+析构成本,A 更高效,尤其 n 很大的时候。否则 B 或许更好。此外 A 造成的作用域比 B 更大。
1
2
3
4
5
6
7
8
9
10// A
Widget w;
for (int i = 0; i < n; ++i) {
w = 取决于 i 的某个值;
}
// B
for (int i = 0; i < n; ++i) {
Widget w(取决于 i 的某个值);
}
条款27: 尽量少做转型动作
转型语法,因为通常有三种不同的形式,可写出相同的转型动作。 C 风格的转型动作看起来像这样:
1 | |
函数风格的转型动作看起来像这样:
1 | |
两种形式并无差别,纯粹只是小括号的摆放位置不同而已。
C++还提供四种新式转型(常常被称为new-style 或 C++-style casts):
1 | |
const_cast通常被用来将对象的常量性转除(cast away the constness)。它也是唯一有此能力的C++-style转型操作符。dynamic_cast主要用来执行“安全向下转型”(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。- reinterpret_cast意图执行低级转型,实际动作(及结果)可能取决于编译器,这也就表示它不可移植。例如将一个 pointer to int 转型为一个 int。这一类转型在低级代码以外很少见。本书只使用一次,那是在讨论如何针对原始内存(raw memory)写出一个调试用的分配器(debugging allocator)时,见条款50。
static_cast用来强迫隐式转换(implicit conversions),例如将non-const对象转为 const 对象(就像条款03所为),或将 int 转为 double 等等。它也可以用来执行上述多种转换的反向转换,例如将 void* 指针转为 typed 指针,将pointer-to-base 转为 pointer-to-derived。但它无法将 const 转为 non-const ——这个只有const_cast才办得到。- 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_casts。
- 如果有个设计需要转型动作,试着发展无需转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
- 宁可使用 C++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
条款28: 避免返回 handles 指向对象内部成分
避免返回 handles(包括references、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个const,并将发生(dangling handles)的可能性降至最低。
这些“handles”原本指向有效的内存区域或资源,但是由于某些操作(如对象的销毁、内存释放等),这些“handles”现在指向了无效的内存区域或已经释放的资源。因此,当你使用这些悬空的“handles”时,会导致未定义行为,这可能导致程序崩溃、数据损坏或安全漏洞。
1
2
3
4// Dangling Pointer
int* ptr = new int(5);
delete ptr;
*ptr = 10; // 悬空指针的未定义行为
1 | |
这样的设计可通过编译,但却是错误的。实际上它是自我矛盾的。一方面 upperLeft 和 lowerRight 被声明为 const 成员函数,因为它们的目的只是为了提供客户一个得知 Rectangle 相关坐标点的方法,而不是让客户修改Rectangle(见条款03)。另一方面两个函数却都返回 references 指向 private 内部数据,调用者于是可通过这些 references 更改内部数据!
条款29: 为“异常安全”而努力是值得的
当异常被抛出时,带有异常安全性的函数会:
- 不泄漏任何资源。
- 不允许数据败坏(new失败时,指针指向一个被删除的对象)。
异常安全函数(Exception-safe functions)提供以下三个保证之一:
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有的 class 约束条件都继续获得满足)。
- 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会恢复到“调用函数之前”的状态。
- 不抛掷(nothrow)保证,承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如ints,指针等等)身上的所有操作都提供 nothrow 保证。这是异常安全码中一个必不可少的关键基础材料。
异常安全码(Exception-safe code)必须提供上述三种保证之一。
异常安全函数(Exception-safefunctions)即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
“强烈保证”往往能够以 copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
Copy and Swap策略的核心思想是通过复制和交换来实现赋值操作。具体来说,赋值操作符的实现分为三个步骤:
- 复制:通过调用复制构造函数创建一个临时对象,这个对象是目标对象的副本。
- 交换:使用
std::swap函数交换当前对象和临时对象的内部资源。 - 销毁:由于临时对象是局部变量,在赋值操作符函数退出时会自动销毁,原来的资源也随之释放。
若有任何修改动作抛出异常,原对象仍保持未改变状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22struct PMImpl { // PMImpl = "PrettyMenu
std::tr1::shared_ptr<Image> bgImage; // Impl."; see below for
int imageChanges; // why it's a struct
};
class PrettyMenu {
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
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
pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); // modify the copy
++pNew->imageChanges;
swap(pImpl, pNew); // swap the new data into place
}函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
条款30: 透彻了解 inlining 的里里外外
inline 函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。这样做可能增加你的目标码(object code)大小。
inline 函数通常一定被置于头文件内,因为大多数建置环境(build environments)在编译过程中进行inlining,而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样子。某些建置环境可以在连接期完成inlining,少量建置环境如基于NETCLI(Common Language Infrastructure;公共语言基础设施)的托管环境(managed environments)竟可在运行期完成inlining。然而这样的环境毕竟是例外,不是通例。Inlining在大多数C++程序中是编译期行为。
将大多数 inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binaryupgradability)更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
不要只因为functiontemplates出现在头文件,就将它们声明为inline。
条款31: 将文件间的编译依存关系降至最低\(^*\)
假设你对C++程序的某个 class 实现文件做了轻微修改,而且只修改 private 成分,然后重新编译这个程序,你会发现整个程序都被重新编译和链接了。问题在于C++并没有把“将接口从实现中分离”这件事做得很好。class的定义不只详细叙述了 class 接口,还包括十足的实现细节。例如:
1 | |
这里的 Person
类无法通过编译,因为编译器没有得到其实现代码中所用到string,Date,Address的定义式。这样的定义式通常由
#include 提供,所以 Person
定义文件的上方可能存在这样的东西:
1 | |
这么一来便在 Person 定义文件和其包含文件之间形成了编译依存关系。如果这些头文件中有任何一个被改变,或这些头文件依赖的其他头文件有任何改变,任何使用 Person 类的文件必须重新编译。这样的串联编译依存关系会对许多项目造成难以形容的灾难。
C++为什么坚持将 class 的实现细节置于 class
定义式中?前置声明无法实现的原因在于,一是你可以通过
#include
将标准库声明导入代码中;二是编译器必须在编译期间知道对象大小。考虑这个:
1 | |
当编译器看到x,它知道必须分配多少内存,因为编译器知道 int 有多大。当编译器看到 p 的定义时,它必须知道必须分配足够的空间以放置一个Person。它获取 Person 对象大小的唯一办法就是询问 class 定义式。
此问题在 Java 中不存在,因为定义对象时,编译器只分配一个指针指向该对象。也就是说它们将上述代码看成这样:
1 | |
这当然也是合法的C++代码。针对 Person 我们也可以这样做:把 Person
分割为两个class,一个只提供接口,另一个负责实现该接口。负责实现接口类的类名为PersonImpl,Person将定义如下:
1 | |
在这样的设计下,Person 的客户就可以完全与 Date,Address 和 Person 的实现细节分离了。那些 class 的任何实现修改都不需要 Person 客户端重新编译。这真正是“接口与实现分离”。
这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件的声明相依。其他每一件事都源自于这个设计策略:
如果使用对象引用或指针可以完成任务,就不要使用对象本身。 你可以只靠一个类型声明就定义出指向该类型的引用或指针;但如果定义某类型的对象,就需要用到该类型的定义式。
如果能够,尽量以 class 的声明替换 class 定义。 注意,当你声明一个函数而它用到某个 class 时,你并不需要该 class 的定义;即使函数以by value方式传参或返回值:
1
2
3class Date;
Date tody(); // ok
void clearAppointments(Date d); // ok为声明和定义提供不同的头文件。 为了促进严守上述准则,需要两个头文件,一个用于声明,一个用于定义。客户总是
#include一个声明文件而非前置声明若干函数。
像 Person 这样的 pimpl idiom 的 class,往往被称为 handle class。这样的 class 可以将它们的所有函数转交给相应的实现类并由后者完成实际工作。例如下面是 Person 两个成员函数的实现:
1 | |
另一个实现 handle class 的办法是,令 Person 成为抽象基类,称为Interface class。这种 class 的目的是详细描述派生类的接口,因此它通常没有成员变量,也没有构造函数,只有一个虚析构函数以及一组纯虚函数,用来叙述整个接口。
Interface class 类似 Java 的 Interface,但 C++ 的 interface class 并不需要负担 Java 的 interface 所需负担的责任。举个例子,Java不允许在 interface 内实现成员变量或成员函数,但C++不禁止这两样东西。
一个针对 Person 的 Interface class 或许看起来像这样:
1 | |
这个 class 的用户必须以 Person 的指针或引用来写程序,因为不可能为 Person 创建实例。就像 Handle class 的客户一样,除非 Interface 的接口被修改,否则其客户不需要重新编译。
Interface class 的客户必须有办法为这种 class 创建新对象。它们通常调用工厂函数或 virtual 构造函数。它们返回指针指向动态分配所得的对象,而该对象支持Interface class的接口。这样的函数又往往在Interface class中被声明为static:
1 | |
客户会这样使用它们:
1 | |
支持 Interface class 接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。一切都在 virtual 构造函数实现文件内发生。假设Interface class Person有个具象的派生类RealPerson,后者提供继承而来的 virtual 函数实现:
1 | |
有了 RealPerson 之后,写出 Person::create
就不稀奇了:
1 | |
一个更现实的 Person::create
实现会创建不同的派生类对象,取决于参数值、读取自文件或数据库的数据、环境变量等。
Handle class 和 Interface class 解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。
支持编译依存最小化的一般构想是:依赖声明,不要依赖定义。基于此构想的两个手段是Handle class 和 Interface class。
程序库头文件应该以“完全且仅有声明”的形式存在。这种做法不论是否涉及template 都适用。
继承与面向对象设计
条款32: 确定你的 public 继承塑模出 is-a 关系
“public继承”意味 is-a。适用于base classes身上的每一件事情一定也适用于derived classes 身上,因为每一个derived class 对象也都是一个base class对象。
is-a 并非说唯一存在于 classes 之间的关系。另两个常见的是 has-a (条款38)和 is implemented-in-terms-of(条款39)
条款33: 避免遮掩继承而来的名称
derived classes内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
graph LR A[local scope] --> B[Derived] --> C[Base] --> D[namespace of Base] --> E[global scope]1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class 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();
...
};基类内所有名为
mf1和mf3的函数都被派生类内的mf1和mf3遮蔽掉了。从名字查找来看,Base::mf1和Base::mf3不再被Derived继承!1
2
3
4
5
6
7
8Derived d;
int x;
...
d.mf1(); // Derived::mf1
d.mf1(x); // ❌ error: no Derived::mf1(int)
d.mf2(); // Base::mf2
d.mf3(); // Derived::mf3
d.mf3(x); // ❌ error: no Derived::mf3(int)可以适用
using声明override C++对继承而来的名字的缺省遮掩行为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class 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:
using Base::mf1; // 让 Base class 内名为 mf1 和 mf3 的所有东西
using Base::mf3; // 在 Derived 作用域都可见 (并且 public )
virtual void mf1();
void mf3();
void mf4();
...
};
d.mf1(); // Derived::mf1
d.mf1(x); // Base::mf1(int)
d.mf2(); // Base::mf2
d.mf3(); // Derived::mf3
d.mf3(x); // Base::mf3(int)为了让被遮掩的名称再见天日,可使用using 声明式或转交函数(forwarding functions)。
这意味着如果你继承基类并加上重载函数,而你又希望重新定义或重写其中的一部分,那么你必须为那些原本会被遮蔽的每个名字引入一个
using声明。有时候你并不想要继承基类的所有函数,但在public继承下,这绝不可能发生,因为它违反了public继承下的is-a关系。然而在private继承下它却可能是有意义的。例如假设Derived以private继承Base,而Derived唯一想继承mf1的无参数版本。
using声明在这里派不上用场,因为using声明会导致继承来的名称在派生类中都可见。我们需要不同的技术,即一个简单的转发函数(forwarding function):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
... // 与此前相同
};
class Derived : private Base { // **private**
public:
virtual void mf1() { // forwarding function
Base::mf1(); // 暗自成为 inline (条款30)
}
void mf3();
void mf4();
...
};
...
Derived d;
int x;
d.mf1(); // Derived::mf1()
d.mf1(x); // ❌ error: Base::mf1(int)被遮掩
条款34: 区分接口继承和实现继承
1 | |
接口继承和实现继承不同。在 public 继承之下,derivedclasses总是继承base class的接口。
pure virtual 函数只具体指定接口继承。
简朴的(非纯)impure virtual函数具体指定接口继承及缺省实现继承。
non-virtual函数具体指定接口继承以及强制性实现继承。
Shape的non-virtual函数objectID1
2
3
4
5class Shape {
public:
int objectID() const;
...
};如果成员函数是个non-virtual函数,意味着它并不打算在派生类中有不同的行为。无论派生类表现得多么特异化,它的行为都不可以改变。就其自身而言:
- 声明non-virtual函数的目的是为了令派生类继承函数的接口以及一份强制性实现。
可以把
Shape::objectID()的声明想做是:每个Shape对象都有一个用来生成对象识别码的函数;此识别码总是采用相同的计算方法,该方法由Shape::objectID()的定义决定,任何派生类都不应该尝试改变其行为。非虚函数代表的意义是不变性凌驾于特异性,因此它绝不该在派生类中被重定义。
Airplane::fly为纯虚函数,只提供其飞行接口。其默认行为也出现在Airplane类中,但此次以独立函数defaultFly的形式表现。若想使用默认实现,可在其fly函数中对defaultFly做一个inline调用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class ModelA : public Airplane {
public:
virtual void fly(const Airport& destination)
{ defaultFly(destination); }
...
};
class ModelB : public Airplane {
virtual void fly(const Airport& destination)
{ defaultFly(destination); }
...
};
class ModelC : public Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination) {
// 将C型飞机飞至指定目的地
// note: 不可能意外继承不正确的fly实现代码
// 因为Airplane的纯虚函数迫使ModelC
// 必须提供自己的fly版本
}可以利用纯虚函数必须在派生类中重新声明,但它们也可以拥有自己的实现这一事实。下面便是
Airplane继承体系如何给纯虚函数一份定义:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class Airplane {
public:
virtual void fly(const Airplane& destination) = 0;
...
};
void Airplane::fly(const Airport& destination) {...}
class ModelA : public Airplane {
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination); }
...
};
class ModelB : public Airplane {
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination); }
...
};
class ModelC : public Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination) {
// 将C型飞机飞至指定目的地
}纯虚函数
Airplane::fly替换了独立函数Airplane::defaultFly。如果合并fly和defaultFly,就丧失了让两个函数享有不同保护级别的机会:习惯上被设定为protected的函数(defaultFly)如今成了public(fly)。
条款35:考虑virtual函数以外的其他选择
假设你正在写一个游戏软件,你打算为游戏内的人物设计一个继承体系。你提供一个成员函数
healthValue,它会返回一个整数,表示人物的健康程度。由于不同的人物可能以不同的方式计算它们的健康值指数,将healthValue声明为virtual似乎是再好不过的做法:
1 | |
但从某个角度都说却反成为了它的弱点。由于这个设计如此清晰,你可能因此没有认真考虑其他替代方案。为了帮助你跳脱面向对象设计的常规思路,让我们思考其他一些解法:
由 Non-virtual Interface 手法实现 Template Method 模式
我们从一个有趣的思想流派开始,这个流派主张virtual函数应该几乎总是private。这个流派的拥护者建议,较好的设计是保留healthValue为public成员函数,但让它成为non-virtual,并调用一个private
virtual函数进行实际工作:
1 | |
这一基本设计,也就是“令客户通过 public non-virtual 成员函数间接调用
private virtual 函数”,成为non-virtual
interface(NVI)手法,它是 Template Method
设计模式(与C++
template并无关联)的一个独特表现形式。我把这个non-virtual函数(healthValue)称为virtual函数的包装器(wrapper)。
NVI手法的一个优点可以在一个virtual函数被调用之前设定好适当场景,并在结束之后清理场景。如果你直接让客户调用virtual函数,就没有任何好办法可以做这些事。
在NVI手法下其实没必要让virtual函数一定得是private。某些class继承体系要求派生类在虚函数的实现内必须调用其基类的实现,而为了这样的调用合法,virtual函数必须是protected。有些时候virtual函数甚至一定得是public,例如多态性质的析构函数,这么一来就不能实施NVI手法了。
由 Function Pointers 实现 Strategy 模式
另一个设计主张“任务健康指数的计算与任务类型无关”,这样的计算完全不需要“人物”这个成分。例如我们可能会要求每个人物的构造函数接收一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算:
1 | |
这个做法是常见的 Strategy
设计模式的简单应用。拿它和基于GameCharacter继承体系内的
virtual 函数做法比较,它提供某些弹性:
同一人物类型的不同实体可以有不同的健康计算函数,例如:
1
2
3
4
5
6
7
8
9
10
11class EvilBadGuy : public GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf) {...}
...
};
int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);
EvilBagGuy ebg1(loseHealthQuickly);
EvilBagGuy ebg2(loseHealthSlowly);某已知人物的健康指数计算函数可在运行期变更。例如
GameCharacter可提供一个成员函数setHealthCalculator,用来替换当前的健康指数计算函数。此时健康指数计算函数不再是
GameCharacter继承体系内的成员函数。如果人物的健康可单纯根据该任务public接口的来的信息加以计算,如果需要non-public信息进行精确计算,就有问题了。一般而言,解决此问题的办法就是弱化class的封装。例如class可声明那个non-member函数为friend,或是为其实现的某一部分提供public访问函数。运用函数指针替换virtual函数,其优点是否足以弥补缺点,是你必须根据每个设计情况的不同而抉择的。
由 std::function
完成 Strategy 模式
如果我们不再使用函数指针,而是改用一个类型为
std::function
的对象,健康指数计算就可以使任何可调用对象,只要其签名兼容需求。以下将刚才的设计改为使用
std::function:
1 | |
和前一个设计比较,这个设计几乎相同。唯一不同的是如今GameCharacter持有一个
std::function
对象,这个可以让客户在指定健康计算函数上拥有更惊人的弹性:
1 | |
典型的 Strategy 模式
典型的Strategy做法将健康函数做成一个分离的继承体系中的virtual成员函数。
1 | |
这个解法的吸引力在于,熟悉标准Strategy模式的人很容易辨认,而且它还提供将一个既有的健康算法纳入使用的可能性——只要为HealthCalcFunc继承体系添加一个派生类即可。
- virtual函数的替代方案包括NVI手法及Strategy设计模式的多种形式。
- 将功能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问class的non-public成员。
std::function对象的行为就像一般函数指针。这样的对象可接纳与目标签名兼容的可调用对象。
条款36: 绝不重新定义继承而来的 non-virtual 函数
- 绝对不要重新定义继承而来的non-virtual函数。
1 | |
面对一个类型为D的对象:
1 | |
如果有以下行为:
1 | |
异于以下行为:
1 | |
如果mf是个 non-virtual 函数而D定义有自己的
mf 版本,那就会发生上述情况:
1 | |
造成此行为的原因是,non-virtual函数都是静态绑定。由于pb被声明为B指针,通过pb调用的non-virtual函数永远是B的版本。
另一方面,virtual函数却是动态绑定(见条款37)。如果mf是个virtual函数,不论是pB还是pD调用mf都会导致调用D::mf,因为真正指的都是一个类型为D的对象。
条款32说过,public继承意味着is-a的关系。条款34描述为什么在class内声明一个non-virtual函数会为该class建立一个不变性,凌驾其特异性。将这两个观点施行于两个class
B和D和non-virtual成员函数mf上,那么:
- 适用于B对象的每一件事,也适用于
D对象,因为每个D对象都是一个B对象。 - B的派生类一定会继承
mf的接口和实现,因为mf是B的non-virtual函数。
现在,如果D重新定义mf,你的设计便会出现矛盾。任何情况下都不该重新定义一个继承而来的non-virtual函数。
条款37: 绝不重新定义继承而来的缺省参数值
- 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual 函数——你唯一应该覆写的东西——却是动态绑定。
1 | |
条款38: 通过复合建模出 has-a 或“根据某物实现出”
- 复合的意义和public继承完全不同。
- 在应用域,复合意味着has-a。在实现域,复合意味着is-implemented-in-terms-of。
条款39: 明智而谨慎地使用 private 继承
我们复现条款32的例子,并以private继承替换public继承:
1 | |
private继承意味着implemented-in-terms-of(根据某物实现出)。如果你让D以private继承B,你的用意是为了采用B内已经完备的某些特性,不是因为B和D存在任何观念上的关系。
条款38指出复合的意义也是is-implemented-in-terms-of,应当尽可能使用复合,必要时才使用private继承。主要是当protected成员或virtual函数牵扯进来时,必要时才使用private继承。
我们让Widget class记录每个成员函数的被调用次数。运行期间我们将周期性检查那份信息。为了这项工作,我们需要设定某种计时器。
我们创建一个Timer对象,可调整为以我们需要的任何频率检查信息:
1 | |
为了让Widget重新定义Timer内的virtual函数,Widget必须继承自Timer。我们必须以private形式继承Timer:
1 | |
Timer的public onTick函数在Widget内变成private,使得客户不会调用它。
这是个好设计,但是private继承并非绝对必要,我们可以使用嵌套类:
1 | |
你或许会想设计Widget使它得以拥有派生类,但你可能会想阻止派生类重定义onTick。如果以嵌套类的形式实现,派生类将无法取用WidgetTimer,因此无法继承或重定义它的虚函数。
有一种激进的情况涉及空间最优化,可能会促使你选择private继承而不是继承加复合。这个情况只适适用于你所处理的class不带任何数据时,这种空类对象不使用任何空间,然而C++中凡是独立的对象都必须有非零大小,所以如果你这样做:
1 | |
你会发现HoldsAnInt的内存大小大于int的内存大小。而空类只有在独立存在的情况下有非零大小,当我们使用继承,而不是内含:
1 | |
此时HoldsAnInt和int的内存大小相等。这就是所谓的空基类优化(EBO, empty base optimization)。
SEO
C++ 中的空基类优化(Empty Base Optimization,简称 EBO)是一种编译器优化技术,旨在减少因继承空类(即没有非静态成员变量的类)所带来的额外内存开销。通常在 C++ 中,每个对象至少会占用一个字节的内存,以确保它具有唯一的地址。这意味着即使一个空类本身不包含任何数据成员,其对象在内存中也会占用至少一个字节。
然而,当一个类通过多重继承继承多个空基类时,标准 C++ 要求每个基类子对象必须有一个唯一的地址。为了避免这种情况下产生不必要的内存开销,编译器可以通过空基类优化来节省内存。
EBO 的实现原理
EBO 的基本原理是在多重继承的情况下,允许编译器将空基类子对象与派生类或其他非空基类子对象共享同一个地址。这意味着在没有其他数据成员或非空基类的情况下,派生类对象的大小可以是 0。这种优化在继承多个空基类时尤其有用。
1 | |
在上面的例子中,EmptyBase 是一个空类,而
Derived 类继承了 EmptyBase 并包含一个
int 型数据成员。通过 EBO,编译器可以将
EmptyBase 的子对象与 Derived 的
int 成员共享内存位置,从而使 Derived
对象的大小等于 int 的大小(通常为 4 或 8
字节,取决于系统架构)。
EBO 的适用场景
EBO 主要在以下情况下发挥作用:
- 多重继承:当一个类继承多个空基类时,EBO 能够显著减少对象的内存开销。
- 模板编程:在模板元编程中,空类常用于类型标签、策略类或元数据传递,EBO 可以优化这些场景下的内存使用。
限制
尽管 EBO 是一种有效的优化技术,但它并不是在所有情况下都可以应用。特别是当空基类是虚基类或多次继承自相同的空基类时,EBO 可能无法有效应用。此外,不同编译器对 EBO 的支持程度可能有所不同,但大多数现代 C++ 编译器都能够有效地利用这一优化技术。
总结
空基类优化(EBO)是 C++ 编译器的一种优化技术,旨在减少继承空类时不必要的内存开销。通过将空基类子对象与派生类其他成员共享内存地址,EBO 能够显著优化对象的内存使用,特别是在多重继承和模板元编程中。
- private继承意味着is-implemented-in-terms-of。它通常比复合级别低。但是当派生类需要访问protected基类的成员,或需要重新定义继承而来的virtual函数时,这么设计是合理的。
- 和复合不同,private继承可以造成空基类优化。这对致力于对象大小最小化的程序库开发者而言,可能很重要。
模板与泛型编程
定制new和delete
杂项讨论
其他
移动语义是C++11 中新增的一个语言特性,它允许将一个对象的所有权从一个对象转移到另一个对象,而不需要进行数据的拷贝。 这种转移可以在对象生命周期的任意时刻进行,可以说是一种轻量级的复制操作。在实际场景中,右值引用和 std::move 被广泛用于在 STL 和自定义类中实现移动语义,避免拷贝,从而提升程序性能。
std::vector的push_back和emplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。
std::string_view 是一个非拥有类,它封装了一个指向常量字符数组的指针和长度信息。 它提供了一种有效的方式来引用和操作字符串,而无需像 std::string 那样进行内存分配和复制。