前言
此书由Scott Meyers所著,侯捷所译,乃经典中的经典,十分值得多刷。此文旨在记录书籍重点内容,并附上个人调试与理解
原书中含有较多译者未予翻译的英文词条,这里给出大致参照
英文词条 | 中文翻译 | 英文词条 | 中文翻译 |
---|---|---|---|
const | 常量 | non-const | 非常量 |
static | 静态 | non-static | 非静态 |
virtual | 虚的 | non-virtual | 非虚的 |
heap | 堆区 | stack | 栈区 |
by value | (以)值(的方式) | by reference | (以)引用(的方式) |
base class | 基类(父类) | derived class | 派生类(子类) |
一.让自己习惯C++
条款01:视C++为一个语言联邦
View C++ as a federation of languages
在C++诞生初期,它的名称为C with Classes,可以视作它只是C加上一些面向对象的特性。但随着C++的逐渐发展,已经逐渐演变为一个同时支持过程形式、面向对象形式、函数形式、泛型形式、元编程形式的语言。总的来说我们应将C++视作一个语言联邦,分为以下四种
- C:内置数据类型,数组,指针,语句...等等统统来自C语言
- Object-Oriented C++:最初的C with Classes,包含了类,封装,继承,动态绑定...等等面向对象的设计
- Template C++:模板,泛型编程
- STL:一个template程序库,涵盖了容器,迭代器,算法与函数对象
条款02:尽量以const,enum,inline替换#define
Prefer consts, enums, and inlines to #define
传统艺能,在C风格的数据结构训练中,我们常用以下几种宏定义
#define ERROR -1
#define FALSE 0
#define OK 1
由于各种不可抗力,可能导致ERROR,FALSE等在编译器开始处理源码之前就被从预处理器上移除了,并没有进入到记号表中,使后续的代码错误信息可能指出错误来自于“-1”或是“0”而非ERROR或是FALSE。哦!这糟糕的代码将让您一头雾水!
正如条款所说的,尽量以const替换#define
const int Error = -1; //全大写风格一般用于宏
另一种情况,由于编译器认为必须在编译期间便知道数组的大小,故在构建中传入变量或是non-static的常量均是不合法的,下面以一个类成员作示范
#define ARRAYSIZE 10
class GamePlayer {
private:
int scores[ARRAYSIZE]; //传统做法
static const int ArraySize = 10; //ArraySize常量声明式,编译器进行特殊处理
int scoresBetter[ArraySize]; //优化做法
};
//const int GamePlayer::ArraySize; //部分编译器可能仍需要你写出这行定义式
//无需也不能够赋初值,原因:为const常量
上述例子中对ArraySize的初始化被称为 “in-class初值设定”。众所周知,若要在类内写一个静态成员变量,需要在类内声明,然后再在类外实现,而对于整数类型(例如int,char,bool)来说,编译器可以对其做出特殊处理,也就是“in-class初值设定”。但若对于float亦或是double类型的变量,就需要走老路子了
class CostEstimate {
private:
static const double FudgeFactor; //该类一般写在头文件中 CostEstimate.h
};
const double CostEstimate::FudgeFactor = 1.23; //该定义式写在实现文件中 CostEstimate.cpp
需要注意的是,由于“老路子”中的静态成员常量并不是在编译器期间便初始化值的,故也无法被当作“大小”而去参与构建数组。
书中还提到了一种称为“the enum hack”的补偿做法,这是一种比较神奇的做法,它与const声明的常量不同,它是无法被取地址的,也就不会导致非必要的内存分配
class GamePlayer {
private:
enum { ArraySize = 10 };
int scores[ArraySize];
};
关于常量就讲到这么多,对于函数而言,使用inline替换 #define显然要方便且安全的多
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b)) //每个变量都必须需要加上括号
template<class T>
inline void callWithMax(const T& a, const T& b) {
f(a > b ? a : b);
}
总结
对于单纯常量,尽量以const对象或enum替换#define
对于形似函数的宏,最好改用inline函数替换#define
条款03:尽可能使用const
Use const whenever possible
指针
先分清常量指针与指针常量
const int* a1 = 10; //non-const pointer, const data
int const *a2 = 10; //non-const pointer, const data
int* const a3 = 10; //const pointer, non-const data
const int* const a4 = 10 //const pointer, const data
迭代器
熟悉STL的人应该都了解过迭代器,初看可能会觉得其规则是反过来的,其实不然,细品
std::vector<int> vec;
const std::vector<int>::iterator iter = vec.begin(); //相当于指针常量,指向不能改,所指物能改
std::vector<int>::const_iterator citer = vec.begin(); //相当于常量指针,指向能改,所指物不能改
返回值
许多时候我们并不想要让我们函数的返回值(返回一个引用的时候)被人所修改,这个时候我们可以用const去修饰返回值
const std::string& GetSentence() { return m_str; }
//假设该函数为成员函数,且类内有一个std::string类型的成员m_str
当然了,若是以by value的形式返回,本身也就修改不了的,加不加常量修饰都无所谓了,下文会再次提到
函数参数
较为简单,当我们的程序中不会或我们本意为不想修改函数参数的时候,将其标记为const,即该参数为只读,非可写
void SetSentence(const std::string& str) { m_str = str; }
//假设该函数为成员函数,且类内有一个std::string类型的成员m_str
常函数
书本中的例子
class TextBook {
public: //为了简洁省去构造,析构等函数
const char& operator[](std::size_t position) const { return text[position]; }
char& operator[](std::size_t position) { return text[position]; }
private:
std::string text = "Hello";
};
很简单的例子,当不同性质的对象调用重载运算符时,会调用到相应的版本
const TextBook tb1;
std::cout << tb1[1] << std::endl; //调用到常函数版本的重载,若为非常量则同理,不赘述
有两个细节
第一个是常函数版本的重载还将返回值修饰为const。若取消该修饰
char& operator[](std::size_t position) const { return text[position]; }
这么写,编译器是会给你指出错误的。在你规定了一个函数为常函数后,我们在函数中操作的成员变量都相当于“被自动加上了const属性”,而我们返回的该成员变量的引用却没有const修饰,这是显然不合法的。总结的来说,一个常函数返回的是类成员(值类型)的引用时,必须给返回值加上const修饰
第二个是两个重载的返回类型皆为reference to char,而非单纯的char。
在C++中,若函数的返回类型是一个内置类型,那么改动函数返回值是绝对非法的。具体可以参考我另外一篇博客——C++11中的右值引用
int GetResult() { return 10; }
GetResult() = 100; //企图更改一个右值,是非法的
再说了,即使这么做是合法的,若以by value的形式返回,返回的将是text[position]
的一个副本,而不是它本身,那么如果我们后续需要对其值做出改动(重新赋值之类的),那也就会全部应用到这个副本上,这明显不是我们想要的。别说non-const operator[ ]了,const operator[ ]的返回值本身就被const所修饰,那还改动个锤子噢
这篇条款中,作者还提到了两个概念,这里附上我个人的理解
- bitwise constness,中文翻译过来是“位元上的常量性”,我认为可以理解成是“编译器认为的常量”。此也正是C++对常量性的定义,因此常函数不可以更改对象内任何non-static成员变量
- logical constness,中文翻译过来是“逻辑上的常量性”,但我认为“常人理解上的常量”这个概念相来说会更清晰一点
举个例子以便更好的理解这两个概念,假设我们有一个类
class MyArray {
private:
std::vector<int> vec; //核心成员,储存了数据
int FuncCalledTimes; //非核心成员,用于计数
public: //为了简洁省去构造,析构等函数
int GetItem(int index) const {
FuncCalledTimes++;
return vec[index];
}
};
我们的目的是通过计数,来记录外界一共调用了多少次GetItem
。在我们的理解中,我们希望接口只修改非核心成员FuncCalledTimes
,而对核心成员vec
不被修改。此时我们将GetItem
具备的这种特性成为logical constness。但是很不幸的是,这段代码是无法通过编译的,编译器并不理会我们人类的意图,它秉承着bitwise constness,将我们的代码标出了错误:表达式必须是可修改的左值。
mutable
关键字便是用来解决这个冲突的。这样我们既保留了logical constness也保证了能够编译通过。一句话,mutable
释放掉了non-static成员变量的bitwise constness约束
mutable int FuncCalledTimes;
这个时候总会不折不挠的人不想使用该关键字而想尝试一些奇特的类型转换操作。你喜欢就行,想怎么转就怎么转,但不推荐
int GetItem(int index = 0) const {
const_cast<int&>(FuncCalledTimes)++;
//const_cast<MyArray&>(*this).FuncCalledTimes++;
//const_cast<MyArray*>(this)->FuncCalledTimes++;
return vec[index];
}
还有一点需要注意的是,如果我们的类成员是指针类型,那么bitwise constness只能确保该指针的指向是const的,而其指向物其实是可以改变的
class MyIntArr {
public: //为了简洁省去构造,析构等函数
int* arr;
void Change(int index) const { arr[index] = 10; }
int& Get(int index) const { return arr[index]; }
};
const MyIntArr mia;
mia.arr[10] = 100;
mia.Change();
mia.Get(5) = 500;
即使mia
是被规定为const的,但仍可能出现上述我们并不想出现的结果,而且,以上调用都是合法的。为了解决这些问题,我们可以约束返回值或该指针,施加const属性以达到我们的目的
在const和non-const成员函数中避免重复
假设TextBook
类中的operator[ ]需要增添一些新功能,那么就需要在两个重载函数中写出一份相同的代码,这个时候由于代码的重复以及随之而来的编译时间,维护等问题,我们必须对代码进行优化。或许你认为可以将这些新功能写到第三方函数,然后再在两个重载函数中调用它,但这并不是最优解。
class TextBook {
public: //为了简洁省去构造,析构等函数
const char& operator[](std::size_t position) const {
//...一些操作
return text[position];
}
char& operator[](std::size_t position) {
//...一些和上方一样操作
return text[position];
}
private:
std::string text = "Hello";
};
我们想要的是能够在普通版本的operator[ ]中去调用const版本的operator[ ],代码更改如下
//其他代码保持不变
char& operator[](std::size_t position) {
return const_cast<char&>
(static_cast<const TextBook&>(*this)
[position]);
}
第一步先利用static_cast
给*this
添加const属性,使其调用到常函数版本的operator[ ],然后再利用const_cast
移除const属性,最后将其以by-reference的形式返回
TextBook tb;
tb[0] = 'j'; //运用const operator[]实现出non-const版本
需要注意的是将一个non-const的成员转换为const成员是安全的,反之不安全,这也就是为什么我们利用普通版本的operator[ ]去调用常函数版本的operaotr[ ]
总结
为了避免日后可能不小心犯错,应尽量将那些不会被改动到的东西声明为const
编译器强制执行的bitwise constness可能并不与我们的需求相符,我们应按需求遵循logical constness
当const和non-const成员函数有着实质等价的实现时,我们可以令non-const版本调用const版本以避免代码重复
条款04:确定对象被使用前已先被初始化
Make sure that objects are initialized before they‘re used
首先重点是分清楚初始化与赋值。假设有一个类,他的构造函数如果是这么写的
//以下操作为赋值
MyClass(const std::string& _name, const int& _age) {
this->name = _name;
this->age = _age;
}
//调用到name的拷贝赋值函数
对于name
,系统会在MyClass
构造函数进入之前,先对name
进行一次默认构造,然后再轮到我们将_name
的赋值给它,也就是相当于默认构造函数所作的操作是浪费的
对于内置类型age
,由于其不具备任何构造函数,那么也就是说明在进入MyClass
构造函数时,其值是不确定的。你可以试试直接在构造函数中std::cout
它,看看会发生什么事情
所以初始化列表出现了(initialization list)
//以下操作为初始化
MyClass(const std::string& _name, const int& _age) : name(_name), age(_age) {}
//调用name的拷贝构造函数
也可以这么写,相当于调用name
的默认构造函数,对于内置类型age
来说,此做法相当于将其置为0。不管怎么说,我们最好在初始化列表中列出所有成员变量
MyClass() : name(), age() {}
还有一点需要注意的是,成员变量的初始化次序,并不是按照初始化列表中的声明顺序排列的,其是以成员变量在类中的生民次序被初始化。举一个例子,以上初始化列表,我先写name
,再写age
,而在类中我把它颠倒过来
class MyClass {
int age;
std::string name;
};
那么初始化的顺序将会是:先age
,再name
“不同编译单元内定义之non-local static对象”的初始化次序
这是一个难点,首先应该分清静态对象的类型。函数内的static对象称为local static对象,其他的static对象成为non-local static对象。其次,编译单元是指产出单一目标文件的那些源码,比如一个cpp文件再加上其所include的头文件。
问题来了,C++没有明确定义不同编译单元内的non-local static对象的初始化次序。那么也就是说,假设我在创建一个对象Entity
的时候,若其构造函数会调用到另外一个编译单元中的non-local static对象,叫做AnoEntity
,且其恰好还未被初始化,那么程序将会出现不确定的结果。因此,如何保证我创建这个Entity
的时候,AnoEntity
已先被初始化?
幸运的是,对于local static对象,C++会在其所在的那个函数被调用期间将该对象初始化。那么我们需要做的就是,把non-static static对象搬到一个其“专属”的函数中,然后将其返回。是不是觉得这种做法十分眼熟,在另一篇讲单例的博客中用到的就是这种方法
MyClass& Myc_LS() {
static MyClass Myc; //local static对象
return Myc;
}
以上做法还有一个精妙之处:如果我们从未调用过Myc_LS
,那么也一定不会引发Myc
的构造和析构的成本(只有在首次遇到local static对象的定义式时才会对其初始化),这是一个non-local static对象所不具备的优势
二.构造/析构/赋值运算
条款05:了解C++默默编写并调用哪些函数
Know what fumcctions C++ silently writes and calls
挺简单的内容。如果你没写,且唯当这些函数需要被调用的时候,编译器会按需给你创建出这些东西:拷贝构造函数,拷贝赋值函数和析构函数。且若你没有声明任何构造函数,它也会给你创建一个默认构造函数。当然了,因为这本书已经有一些年代了,而后续C++ 11中又加入了移动语义,也就是说编译器还会自动给帮你生成:移动构造函数,移动赋值函数
几点需要注意的
- 当这些函数被需要,它们才会被编译器创建出来
- 如果你写了一个即以上的有参构造函数,那么编译器是再不会帮你生成默认构造函数的
- 编译器写的拷贝函数都是浅拷贝
- 编译器写的析构函数是non-virtual的,除非它的基类(前提是它有)有一个我们自己写出的虚析构函数
- 若一个基类中将拷贝赋值函数声明为private,那么编译器不会为其派生类创建拷贝赋值函数
那么问题来了,如果我们的name
是一个reference to string,age
是一个const
class MyClass {
public:
MyClass(std::string& _name, const int& _age) : age(_age), name(_name) {}
private:
std::string& name;
const int age;
};
std::string newEntity("Kitty");
std::string oldEntity("Betty");
MyClass newOne(newEntity, 10);
MyClass oldOne(oldEntity, 20);
newOne = oldOne;
那么这个时候,假设编译器为我们自动生成了一个拷贝赋值函数。对于name
来说,编译器是应该更改类成员name
的指向呢,还是应该修改newEntity
的值呢。前者肯定是非法的,引用本质上是一个指针常量,即不可修改指针的指向,而对于后者而言,若有其他引用或指针绑定到newEntity
上,那一经历拷贝复制,其他对象也必然会收到影响。而对于age
来说,修改一个const修饰的对象肯定是非法的
编译器会怎么做?它会报错,让你自己去权衡,去实现operator=
题外话,新手可能会搞混下面两种情况,认为括号内不写东西其实就是无参构造函数,其实不是
MyClass myc; //无参构造函数
MyClass myc(); //声明一个返回值为MyClass,且不含有参数的函数,名字叫做myc
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
Explicitly disallow the use of compiler-generated functions you do not want
这篇文章所讲述的东西是C++旧标准的玩意,现如今基本用不上,所以讲点新时代的做法
default
上文中提到过,若我们显示声明了一个有参构造函数,那么编译器将不会自动帮我们生成默认构造函数。如果我们这个时候还想要,那么可以
class MyClass {
public: //为了简洁省去了类成员
MyClass() = default;
MyClass(std::string& _name, const int& _age) : age(_age), name(_name) {}
};
当然了,default
只能用在那些编译器会自动帮我们生成的函数,我们将其称作“特殊成员函数”。再举个例子,空析构函数与默认析构函数是不同的,这也就是直接书写空花括号和使用default
的区别。再者,使用default
会使代码变得更简洁,易读
delete
看过单例博客的读者应该很清楚,delete
除了用在释放已动态分配的内存外,还可以用于禁用成员函数的使用(不单单是特殊成员函数)。而被delete
掉的函数,我们称之为explicitly deleted function
class MyClass {
public: //为了简洁省去了类成员
MyClass(double) = delete; //禁用double的隐式转换
MyClass(const MyClass&) = delete; //禁止拷贝构造
};
需要注意的是,删除的函数是隐式内联的,也就是说以下操作是非法的
class MyClass {
public: //为了简洁省去了类成员
MyClass(const MyClass&);
};
MyClass::MyClass(const MyClass&) = delete;
//编译器报错:删除的函数定义必须是函数的第一个声明
总结:
使用
default
手动让编译器生成默认特殊成员函数使用
delete
删除特殊成员函数可以防止编译器生成我们不想要的特殊成员函数使用
delete
删除正常成员函数可以有效防止有问题的类型转换导致意料之外的结果
条款07:为多态基类声明virtual析构函数
Declare destructors virtual in polymorphic base classes
提到多态,总是会联想到virtual
,标准的多态实现条件为
- 有继承关系
- 有方法的重写
- 有父类指针指向子类对象
个人认为有2,3点就足够了,毕竟都使父类指针子类对象了,那肯定就包含了继承关系
既然是多态,按照常识一般都得把多态基类的析构函数声明为virtual的。如果是non-virtual的析构函数,那么当一个derived class对象经由一个base class指针删除时,会发生诡异的“局部销毁现象”,也就是说对象的derived成分并没有被销毁
virtual的析构函数成为虚析构函数,那么纯虚析构函数有什么作用呢?纯虚函数会使一个类变为抽象类,也就是不能被实例化。那么当我们希望一个类是抽象的,但是又没有合适的纯虚函数的时候,就可以为类声明一个纯虚析构函数。这样子既满足了对抽象类的需求,还解决了可能导致“局部销毁”的问题
class MyBaseClass {
public:
virtual ~MyBaseClass() = 0;
};
MyBaseClass::~MyBaseClass() {} //为其提供一份定义
当对象被销毁时,首先会先调用派生类的析构函数,然后往上逐步调用基类的析构函数。举个例子,编译器会在MyDerivedClass
的析构函数中创建一个对~MyBaseClass()
的调用,那么如果我们没有对MyBaseClass
给出定义的话,运行时链接器会报错
- 对于成员函数,纯虚函数不需要给出实现(没有意义)
- 对于纯虚析构函数,需要为其提供定义或实现(会被调用)
总结
一个类设计为base class并不一定是为了多态用途,两者是有区别的
虚析构函数总与多态联系在一起
不要去继承一个带有non-virtual析构函数的类,例如STL里头的
std::vector
,std::list
等等,当然还有std::string
如果一个基类的设计并不是为了多态用途,那么就不应该为其声明虚析构函数
条款08:别让异常逃离析构函数
Prevent exceptions from leaving destructors
只要析构函数抛出未经处理的异常,那么销毁该对象时程序都会报错停止,例如
class MyClass {
public:
~MyClass() { throw 10; } //抛出了未经处理的异常
};
那么我们就需要在析构函数中把这个异常处理掉,免得程序被迫终止
处理异常有两种方法,一种是调用std::abort()
手动停止程序,另一种是制作一些报错记录,然后将异常吞下,程序继续执行
~MyClass() {
try { throw 10; }
catch(...) {
//std::abort(); //第一种,手动停止
std::cout << "异常抛出" << std::endl; //报错记录,然后程序继续运行
}
}
原书中给出了例子,假定我们使用一个类负责数据库连接
class DBConnection {
public:
void Close(); //关闭联机,若关闭失败则抛出异常
};
class DBConn {
public:
void Close() {
db.Close();
closed = true;
}
~DBConn() {
if (!closed) {
try { db.Close(); }
catch(...) { std::cout << "异常" << std::endl; }
}
}
private:
DBConnection db;
bool closed;
};
DBConn
是一个管理DBConncetion
对象的类,它把关闭联机的操作转移到用户手中,同时又在其析构函数中检查了一次,双重保险。可以看到我们将一个可能抛出异常的函数交给了成员函数去执行,目的是为了能让用户能对导致抛出异常的bug进行修复。下图为用户手动调用管理类的Close()
时抛出异常的情况
如果当用户忘记手动调用,且在析构函数中发生了异常时,这时候双重保险起作用,异常会在记录后被吞下——程序在忽略了一个错误后仍继续执行
总结
析构函数抛出未经处理的异常,不彳亍,程序会直接终止;其他地方抛出未经处理的异常,彳亍,去Debug
如果析构函数调用的函数可能抛出异常,则应捕获它,然后选择
std::abort()
或吞下异常如果用户需要对某个操作函数运行期间抛出的异常做出一定的反应,那么管理类应该提供一个成员函数(例如类
DBConn
中的Close()
)去执行该操作