精读 Effective C++

引言

深入 C++ 的诸多设计细节,了解实际场景的最佳实践,以面向对象的方式重新认识 C++。

将 C++ 视作一系列的语言

Item 1: View C++ as a federation of languages

最初,C++ 只是 C 语言加上一些面向对象的特性,所以 C++ 的原名是 “C with Classes”。 现在的 C++ 已经逐渐成熟,成为一门 多范式的程序设计语言(multiparadigm programming language)。同时支持过程式、面向对象、函数式、泛型编程,以及元编程。

C++ 的灵活使得它在很多问题上并没有统一的规则,而是取决于具体的程序设计范式和当前架构的设计意图。这样的情况下,我们最好把 C++ 看做是一系列的编程语言,而非一种特定的编程语言。

C++ 有四种主要的子语言:

  • C:C++ 是基于 C 设计的,你可以只使用 C++ 中 C 的那部分语法。此时你会发现你的程序反映的完全是C的特征:没有模板、没有异常、没有重载。
  • Object-Oriented C++:面向对象程序设计也是 C++ 的设计初衷:构造与析构、封装与继承、多态、动态绑定的虚函数。
  • Template C++:这是 C++ 的泛型编程部分,多数程序员很少涉及,但模板在很多情况下仍然很方便。另外 模板元编程(template metaprogramming)也是一个新兴的程序设计范式,虽然有点非主流。
  • STL:这是一个特殊的模板库,它的容器、迭代器和算法优雅地结合在一起,只是在使用时你需要遵循它的程序设计惯例。当然你也可以基于其他想法来构建模板库。

总之 C++ 并非单一的一门语言,它有很多不同的规则集。因而C++可以被视为四种主要子语言的集合,每个子语言都有自己的程序设计惯例。

C++ 程序设计的惯例并非一成不变,而是取决于你使用C++语言的哪一部分。例如, 在基于C语言的程序设计中,基本类型传参时传值比传引用更有效率。 然而当你接触Object-Oriented C++时会发现,传常量指针是更好的选择。 但是你如果又碰到了STL,其中的迭代器和函数对象都是基于C语言的指针而设计的, 这时又回到了原来的规则:传值比传引用更好。

避免使用 define

Item 2: Prefer consts, enums, and inlines to #defines

尽量使用常量、枚举和内联函数,代替 #define。我们知道 #define 定义的宏会在编译时进行替换,属于模块化程序设计的概念。 宏是全局的,面向对象程序设计中破坏了封装。因此在 C++ 中尽量避免它!

接着我们具体来看 #define 造成的问题。

不易理解

众所周知,由于预处理器会直接替换的原因,宏定义最好用括号括起来。#define函数将会产生出乎意料的结果:

#define MAX(a, b) a > b ? a : b
MAX(i++, j)

i 自加次数将取决于 j 的大小,然而调用者并不知情。宏的行为不易理解,本质上是因为宏并非 C++ 语言的一部分,它只是源代码的预处理手段。

不利于调试

宏替换发生在编译时,语法检查之前。因此相关的编译错误中不会出现宏名称,我们不知道是哪个宏出了问题。例如:

#define PERSON alice
PERSON = bob;

如果 alice 未定义,PERSON=bob; 便会出错:use of undeclared identifier ‘alice’。 然而我们可能不知道 alice 是什么东西,PERSON 才是我们定义的“变量”。

宏替换是在预处理过程中进行的,原则上讲编译器不知道宏的概念。然而,在现代的编译器中(例如Apple LLVM version 6.0), 编译器会记录宏替换信息,在编译错误中给出宏的名称:

test.cpp:8:5: error: use of undeclared identifier 'alice'
PERSON = bob;
^
test.cpp:4:16: note: expanded from macro 'PERSON'
#define PERSON alice;
^

于是,Meyers 提到的这个问题已经不存在了。然而作者的本意在于:尽量使用编译器,而不是预处理器。 因为 #define 并不是 C++ 语言的一部分。

enumconst 更好用

既然 #define 不能封装在一个类中,我们可以用 static const 来定义一个常量,并把它的作用于局限在当前类:

class C{
static const int NUM = 3;
int a[NUM];
};

通常 C++ 要求所有的声明都给出定义,然而数值类型(char, int, long)的静态常量可以只给声明。这里的 NUM 就是一个例子。 然而,如果你想取 NUM 的地址,则会得到编译错误:

Undefined symbols for architecture x86_64:
"C::NUM", referenced from:
_main in a-88bbac.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

因此如果你要取地址,那么就给出它的定义:

class C{
static const int NUM = 3;
int a[NUM];
};
const int C::NUM;

因为声明 NUM 时已经给定了初始值,定义时不允许再次给初始值。 如果使用 enum,事情会简单很多:

class C{
enum { NUM = 3 };
int a[NUM];
};

尽量使用常量

Item 3: Use const whenever possible

尽量使用常量。不需多说,这是 防卫型(defensive)程序设计的原则, 尽量使用常量限定符,从而防止客户错误地使用你的代码。

常量的声明

总结一下各种指针的声明方式吧:

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 放在类型左边,有人把 const 放在 * 左边,都是可以的:

void f1(const Widget *pw);   // f1 takes a pointer to a constant Widget object
void f2(Widget const *pw); // 等效

STL 的 iterator 也是类似的,如果你希望指针本身是常量,可以声明 const iterator; 如果你希望指针指向的对象是常量,请使用 const_iterator

std::vector<int> vec;

// iter acts like a T* const
const std::vector<int>::iterator iter = vec.begin();
*iter = 10; // OK, changes what iter points to
++iter; // error! iter is const

//cIter acts like a const T*
std::vector<int>::const_iterator cIter = vec.begin();
*cIter = 10; // error! *cIter is const
++cIter; // fine, changes cIter

返回值声明为常量可以防止你的代码被错误地使用,例如实数相加的方法:

const Rational operator*(const Rational& lhs, const Rational& rhs);

当用户错误地使用 = 时:

Rational a, b, c;
if (a * b = c){
...
}

编译器便会给出错误:不可赋值给常量。

常量成员方法

声明常量成员函数是为了确定哪些方法可以通过常量对象来访问,另外一方面让接口更加易懂: 很容易知道哪些方法会改变对象,哪些不会。

成员方法添加常量限定符属于函数重载。常量对象只能调用常量方法, 非常量对象优先调用非常量方法,如不存在会调用同名常量方法。 常量成员函数也可以在类声明外定义,但声明和定义都需要指定 const 关键字。 例如:

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;
};

TextBlock tb("Hello");
const TextBlock ctb("World");
tb[0] = 'x'; // fine — writing a non-const TextBlock
ctb[0] = 'x'; // error! — writing a const TextBlock

比特常量和逻辑常量

比特常量(bitwise constness):如果一个方法不改变对象的任何非静态变量,那么该方法是常量方法。 比特常量是 C++ 定义常量的方式,然而一个满足比特常量的方法,却不见得表现得像个常量, 尤其是数据成员是指针时:

class TextBlock{
char* text;
public:
char& operator[](int pos) const{
return text[pos];
}
};

const TextBlock tb;
char *p = &tb[1];
*p = 'a';

因为 char* text 并未发生改变,所以编译器认为我们的操作都是合法的。 然而我们定义了一个常量对象 tb,只调用它的常量方法,却能够修改tb的数据。 对数据的操作甚至可以放在 operator[]() 方法里面。

这一点不合理之处引发了 逻辑常量(logical constness)的讨论:常量方法可以修改数据成员, 只要客户检测不到变化就可以。可是常量方法修改数据成员 C++ 编译器不会同意的!这时我们需要 mutable 限定符:

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
};

std::size_t CTextBlock::length() const{
if (!lengthIsValid) {
textLength = std::strlen(pText);
lengthIsValid = true;
}
return textLength;
}

避免常量/非常量方法的重复

通常我们需要定义成对的常量和普通方法,只是返回值的修改权限不同。 当然我们不希望重新编写方法的逻辑。最先想到的方法是常量方法调用普通方法,然而这是 C++ 语法不允许的。 于是我们只能用普通方法调用常量方法,并做相应的类型转换:

const char& operator[](size_t pos) const{
...
}

char& operator[](size_t pos){
return const_cast<char&>(
static_cast<const TextBlock&>(*this)
[pos]
);
}
  1. *this 的类型是 TextBlock,先把它强制隐式转换为 const TextBlock,这样我们才能调用那个常量方法
  2. 调用 operator[](size_t) const,得到的返回值类型为 const char&
  3. 把返回值去掉 const 属性,得到类型为 char& 的返回值