Effective C++ 阅读笔记(一)
Introduction
explicit关键字
在构造函数前加上explicit
关键字可以避免编译器做期望之外的类型转换,一般来说这是很好的特性,正常情况下都应该使用它。
关于 c++ 中的explicit
关键字:用于修饰只有一个参数的类构造函数,表明该构造函数是显式的,而默认情况下类构造函数是隐式的,相当于被implicit
修饰。这个构造函数会在拷贝构造和拷贝赋值的时候产生作用,例如下面的这两种写法:
short a = 10;
int b = a;
这种常见的写法利用到的就是int
允许隐式类型转换。
以下面为例,比较explicit
对类构造函数使用的影响:
class A {
public:
explicit A(int x): a(x) {}
private:
int a;
};
class B {
public:
B(int x): b(x) {}
private:
int b;
};
A a1(10); // 通过,显式调用类构造函数
A a2 = 10; // 不通过,因为编译器试图隐式地将10转化为A
a1 = 10; // 不通过,编译器首先试图隐式地将10转化为A,失败后试图调用A的参数为int的拷贝赋值函数,发现未定义
B b1(10); // 通过,显式调用类构造函数
B b2 = 10; // 通过,10被隐式转化为B
b1 = 10; // 通过,首先10被隐式转化为B,接着调用B的拷贝赋值函数
以上现象发生的原因其实并不那么浅显,仔细推究可以发现几个疑问点:
A a1(10)
和A a2 = 10
两种写法非常相近,因为本质上都是调用类的拷贝构造函数,但编译器认为A a2 = 10
涉及到将10
从int
转化为A
的隐式的转换,因此禁止这种写法。a1 = 10
和b1 = 10
这种形式的赋值实际上有两种实现路径,一是直接调用类的参数为int
的拷贝赋值函数,二是先将10
隐式转化为A / B
,在调用类的参数为A / B
的拷贝赋值函数。对于
a1 = 10
来说,两条路径都不通,从路径一来说,A
没有定义参数为int
的拷贝赋值函数(你可以通过定义这个函数来使得该操作通过编译器检查);从路径二来说,将10
隐式转化为A
违背了explicit
关键字的限制。对于
b1 = 10
来说,同样会优先使用路径一,但由于同样没有定义参数为int
的拷贝赋值函数,会通过路径二的方式进行实现。
顺着这个思路可以继续修改B
的拷贝赋值函数,判断编译器是如何选择路径的:
class B {
public:
explicit B(int x): b(x) {}
B& operator=(int x) {
b = x;
std::cout << "int" << std::endl;
return *this;
}
B& operator=(const B& rhs) {
this->b = rhs.b;
std::cout << "B" << std::endl;
return *this;
}
private:
int b;
};
B b1;
b1 = 10;
可以通过控制explicit
关键字(是否禁止隐式转换),以及控制是否定义B& operator=(int x)
(是否只能通过B
进行拷贝赋值)观察结果。
- 使用
explicit
关键字,没有定义B& operator=(int x)
,此时b1 = 10
无法通过编译。 - 使用
explicit
关键字,定义B& operator=(int x)
,可以通过编译并输出int
,说明10
没有经过隐式转换,直接作为参数传给拷贝赋值函数。 - 不使用
explicit
关键字,没有定义B& operator=(int x)
,可以通过编译并输出B
,说明10
经过隐式转换为B
后,再作为参数传给拷贝赋值函数。 - 不使用
explicit
关键字,定义B& operator=(int x)
,可以通过编译并输出int
,说明编译器优先使用现成的拷贝赋值函数,其次才会考虑隐式类型转换。
Accustoming Yourself to C++
Item 1: View C++ as a federation of languages
最初 c++ 仅仅在 c 的基础上引入对象概念,也就是所谓的 “C with class”。但随着时间发展有了更多内容,现在的 c++ 涵盖的范围很广。笼统地说我们可以把 c++ 视作四种子语言的混合:
- C 语言:代码块、声明、预处理器(预编译)、内置数据类型、数组、指针等基础概念。
- 面向对象 C++:封装、继承、多态的面向对象设计。
- 模板 C++:一种通用的设计模式,包括模板元编程 (TMP),虽然 TMP 还不是目前主流的编程思想。
- STL:STL 的编写方式很独特,是一种容器、迭代器、算法、函数对象的混合,这并不是唯一的库编写方法,但是在使用 STL 时必须理解编写者的思路才不容易出错。
在使用C++时,需要分辨当前使用的是哪种”子语言”,甚至需要随时在”子语言”之间切换。
Item 2: Prefer consts, enums, and inlines to #defines.
这里本质上说的是,应该把工作尽量留给编译器,而不是预处理器。
用常量代替 #define
第一个例子说明的原则是,用常量代替 #define,这样做的好处如下:
#define ASPECT_RATIO 1.653
const double AspectRatio = 1.653;
- 宏会在预处理阶段直接替换,如果程序抛异常,只可能抛出 1.653 这个摸不着头脑的数字,那么调试者就要花更多的时间来定位问题。相比较而言,使用定义常量抛出的 AspectRatio 会被记录在符号表里,在错误定位时更加简单。
- 使用宏定义的结果可能会导致更大的目标代码,因为这个 1.653 在目标代码中可能会有多个副本,而使用常量定义,这个值永远只有一个副本。
- 常量变量有作用域的概念,而在宏眼里是没有定义域这个概念的。这在使用面向对象的编程方法时尤其重要,因为没有定义域就没有
private
,没有private
时,封装也就无从谈起。
此处还介绍了一个约定俗成,比如下面定义的这个 class:
class GamePlayer {
private:
static const int NumTurns = 5; // constant declaration
int scores[NumTurns]; // use of constant
...
};
其中,static const int NumTurns = 5;
是一个静态常量的声明。通常在 c++ 中,你使用的任何东西都要有定义,唯一的例外是像这种 class 中静态的内置数据类型(比如 interger, char, bool),只要你没有对这些元素取地址的需求,你就不需要额外定义它们。如果你的编译器还是让你给出定义,你的定义可以写成const int GamePlayer::NumTurns;
这样,因为你的初始值在声明里给出了,无初始值的定义是允许的。
然而上面的写法在一些老旧的编译器中是无法通过的,而且在 class 内部进行初始化的写法也仅限于内置数据类型,仅限于初始化为常量。在这种情况下,你就只能在定义时进行初始化了。
在定义时初始化可以应付大多数情况,但还是有例外,就是当你在编译阶段就需要使用到常量值的场景,典型情况是使用常量作为数组长度,因为编译器总是坚持要在编译阶段就知道数组长度。比如上面代码中 scores 数组的定义。
解决这个场景的常用方法是 “the enum hack”,用枚举类型代替 int。比如你可以像这样写:
class GamePlayer {
private:
enum { NumTurns = 5 }; // “the enum hack” — makes NumTurns a symbolic name for 5
int scores[NumTurns]; // fine
...
};
关于 “the enum hack” 需要知道以下几点:
- 它在功能上其实更接近于 #define,因为 enum 无法取地址也无法引用,当你不希望这些发生时,enum 是一个很好的选择。
- 一般来说,编译器不会为内置数据类型的常量留出内存空间,但是如果编译器很糟糕,这还是有可能的,用 enum 可以彻底避免这样的内存分配。
- 需要了解 “the enum hack” 的一个很实在的原因是,这种写法很普遍,你需要在看到时立刻认出它。另外这也是模板元编程的基本技巧。
用内联模板代替 #define
另一个例子介绍的原则是,如果需要用形式和函数类似的宏,那么更好的方法是用内联模板来替代它们。
例如以下两种写法:
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
f(a > b ? a : b);
}
如果真的把这个宏当成函数来使用,可能会产生极其糟糕和难以察觉的后果,比如说:
int a = 5, b = 0;
CALL_WITH_MAX(++a, b); // a is incremented twice
CALL_WITH_MAX(++a, b+10); // a is incremented once
而模板的好处则非常容易列举:
- 不需要给参数再加额外的括号。
- 不需要担心计算发生了很多次。
- 最重要的,模板函数是一个真的函数,遵守了作用域和访问权限的约束,也自然能符合 class 中私有成员函数的定义。一般来说,没有宏能真正做到这一点。
综上,当可以使用常量和模板来替代时,#define 应该用的越少越好。当然,其他的预处理宏还是必要的,比如 #include,比如 #ifdef / #ifndef,这些语句在编译控制阶段的作用很重要。所以预处理阶段不能被完全取代,只是需要多给它放放假(。
Item 3: Use const whenever possible.
const 关键字让你能够告知编译器和其他程序员,这是一个不应该被修改的对象。这里给出的建议是,只要能用 const 的地方就一律使用。
在指针上使用 const
在使用const
修饰指针时,可能会有两种语义,一是修饰指针本身,二是修改指针指向的值,因此会有以下四种情况:
char greeting[] = "Hello";
char *p = greeting; // non-const pointer, non-const data
const char *p = greeting; // non-const pointer, const data
char * const p = greeting; // const pointer, non-const data
const char * const p = greeting; // const pointer, const data
这里有一个简单的记忆方式:看const
出现在*
的左边还是右边,左边则修饰指向值,右边则修饰指针本身。掌握这个原则之后,可以判断出来const Widget *pw
和Widget const *pw
两种写法是完全等价的。
和指针性质非常类似的是 STL 迭代器,我们可以直接用const
修饰迭代器本身,但如果希望把迭代器指向的值视为常量,那么需要使用的是const_iterator
:
std::vector<int> vec;
...
const std::vector<int>::iterator iter = // iter acts like a T* const
vec.begin();
*iter = 10; // OK, changes what iter points to
++iter; // error! iter is const
std::vector<int>::const_iterator cIter = // cIter acts like a const T* vec.begin();
*cIter = 10; // error! *cIter is const
++cIter; // fine, changes cIter
在函数返回值上使用 const
这不是一个通用的准则,但有时会有奇效,例如重载乘法时会有以下的写法:
class Rational { ... };
const Rational operator*(const Rational& lhs, const Rational& rhs);
返回一个const Rational
的好处是,如果你在 if 判断中错把==
写成了=
,编译器可能能够帮你发现这个错误,比如:
if (a * b = c) ...
,由于a * b
不能被赋值修改,自然就无法通过编译了。
在成员函数上使用 const
例如下面这个 class:
class TextBlock {
public:
...
const char& operator[](std::size_t position) const // operator[] for
{ return text[position]; } // const objects
char& operator[](std::size_t position) // operator[] for
{ return text[position]; } // non-const objects
private:
std::string text;
};
成员函数最后的const
用于指示这个函数不能对成员变量造成修改。被const
修饰的成员函数相当于作出了一个承诺,这个承诺使它无法调用其他未被const
修饰的成员函数。
具体地说上面的这个例子,它重载了[]符号,重载的两种形式分别对应了当调用者是const TextBlock
和TextBlock
两种,它们的返回引用形式也不同:
TextBlock tb("Hello");
std::cout << tb[0]; // calls non-const
tb[0] = ’x’; // fine — writing a non-const TextBlock
const TextBlock ctb("World");
std::cout << ctb[0]; // calls const TextBlock::operator[]
ctb[0] = ’x’; // error! — writing a const TextBlock
可以发现这种重复的实现带来了代码冗余,尤其是如果[]
操作符的功能被进一步扩展,比如增加了边界检查、日志等等功能之后,两个函数里可能都要写一遍这些功能。一种方法是额外增加一个 private 方法,然后两种形式的操作符都去调用这个 private 方法,但这仍然是一种冗余。一个进一步避免冗余的方法是,实现其中一个,然后让另一个去调用它。刚才说到const
成员函数不能调用非const
成员函数,而非const
成员函数则没有任何限制,所以一定是实现const
成员函数,让非const
成员函数调用它,比如像下面这样:
class TextBlock {
public:
...
const char& operator[](std::size_t position) const // same as before
{
...
return text[position];
}
char& operator[](std::size_t position) // now just calls const op[]
{
return const_cast<char&>( // cast away const on op[]’s return type;
static_cast<const TextBlock&>(*this) // add const to *this’s type;
[position] // call const version of op[]
);
}
...
};
这里涉及了两次cast
转换,实际上是先加上一个const
再去掉它:先把TextBlock&
变成const TextBlock&
,调用const
成员函数,再把返回值从const char&
转换为char &
。具体来说,加上const
是一个比较宽松(或者说安全)的操作,static_cast
就可以做到,而去掉const
是一个比较严格的操作,必须使用const_cast
才能做到。
这个实现或许也不是很优美,甚至有点奇怪,因为cast
最好也不要滥用。但是这种减少代码冗余的思想是值得学习的:我们可以通过在非const
成员函数里调用const
成员函数,并使用const_cast
来对const
对象进行转换。
const 成员函数的两种等级
这里提出了两种 const 等级:
- bitwise-constness:认为 const 成员函数不应当修改任何类内的成员变量,也就是从数据成员的角度讲,const 函数被执行前后不应该发生任何变化。
- logical-constness:认为只要从外部,或者说从用户的角度来讲,const 成员函数没有导致类的状态发生变化即可。比如一些仅用作缓存功能的 private 成员变量可以发生变化,只要用户无法感知到即符合要求。
从编译器的角度来说,只有bitwise-constness能够通过编译。但借助mutable
关键字也可以实现logical-constness,被mutable
修饰的成员变量是可以在 const 成员函数内被修改的,修改这些变量也不会导致编译不通过的问题。
class CTextBlock {
public:
...
std::size_t length() const;
private:
char *pText;
mutable std::size_t textLength; // these data members may
mutable bool lengthIsValid; // always be modified, even in
}; // const member functions
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText); // now fine
lengthIsValid = true; // also fine
}
return textLength;
}
补充:现在似乎并不那么推崇在任何情况下都尽可能多地使用const
了,主要原因是const
和许多其他的语言特性一样,具有一种传染性,比如将一个成员函数修饰成const
后,所以被它调用的函数也要做修改,这可能会增加工程实现的复杂度。
Item 4: Make sure that objects are initialized before they’re used.
C++ 的对象初始化规则非常复杂,以至于很难掌握,最好的方法在每次使用对象之前都确保它被初始化了。这当然会造成一些性能上的开销,但是这个开销比起未初始化造成的风险来说是微不足道的。
初始化和赋值的区别
以一个 class 为例:
class ABEntry { // ABEntry = “Address Book Entry”
public:
ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones);
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
};
它的构造函数经常可以见到两种写法:
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
{
theName = name; // these are all assignments,
theAddress = address; // not initializations
thePhones = phones;
numTimesConsulted = 0;
}
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address), // these are now all initializations
thePhones(phones),
numTimesConsulted(0)
{} // the ctor body is now empty
虽然它们最终的效果几乎一样,但是实现的过程是完全不同的。第一种写法实际上是默认初始化 + 拷贝赋值的组合,而第二种写法则是直接调用拷贝构造函数进行初始化。对于非内置的数据类型来说,这可能会造成很大的性能差异。但是对于内置的数据类型,比如这里的 numTimesConsulted 来说,初始化和赋值在性能上是没有差距的,但是为了一致性的考虑最好还是应该采用第二种写法。
如上第二种形式的写法叫做 initialization list。对于const
或者引用对象,必须使用 initialization list进行初始化,比如 class 中有一个const int
,那么就必须以第二种写法进行初始化,编译器不允许使用第一种写法,因为会被认为对const int
进行赋值修改。对于其他对象,如果非内置数据类型没有出现在 initialization list 中,编译器就会自动对这些对象进行默认的初始化;如果内置数据类型没有出现在 initialization list 中,这种行为似乎未定义的,同样可能导致未定义的糟糕后果,所以应当保证每个成员对象都出现在了initialization list中。
class 的初始化顺序
class 内进行初始化的顺序与 initialization list 的顺序无关,只和 class 中被定义的顺序有关,比如:
class ABEntry {
public:
ABEntry(): d(a + b + c), a(10), b(5), c(2) {}
private:
int a, b, c, d;
};
经过初始化后变量d
的值为 17。当然这个特性很容易造成混淆误解,所以为了代码的可读性也为了避免未定义行为,请按照定义的顺序构造 initialization list。
外部对象的初始化问题
对于本地对象来说,在使用对象之前,总是能保证对象已进行过初始化,但对于外部对象来说就不是这样,比如定义了一个文件系统,从外部获取到这个系统的实例:
class FileSystem { // from your library’s header file
public:
...
std::size_t numDisks() const; // one of many member functions ...
};
extern FileSystem tfs; // declare object for clients to use// (“tfs” = “the file system” ); definition is in some .cpp file in your library
现在我们需要使用到这个实例的方法来进行另一个初始化:
class Directory { // created by library client
public:
Directory( params ); ...
};
Directory::Directory( params )
{
...
std::size_t disks = tfs.numDisks(); // use the tfs object ...
}
Directory tempDir( params ); // directory for temporary files
这时候的问题在于,tfs
必须在tempDir
被初始化之前初始化,但tfs
对于一个外部对象来说,这一点是不被保证的。
这里可以引入单例模式解决这个问题,由于FileSystem
只需要一个实例,因此可以用下面这种方法把一个外部对象转化为本地静态对象:
class FileSystem { ... }; // as before
FileSystem& tfs() // this replaces the tfs object; it could be static in the FileSystem class
{
static FileSystem fs; // define and initialize a local static object
return fs; // return a reference to it
}
由于 c++ 保证了内部静态对象会在首次遇到其定义时被初始化,所以这样转换后初始化的依赖问题就被解决了。