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涉及到将10int转化为A的隐式的转换,因此禁止这种写法。

  • a1 = 10b1 = 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. 宏会在预处理阶段直接替换,如果程序抛异常,只可能抛出 1.653 这个摸不着头脑的数字,那么调试者就要花更多的时间来定位问题。相比较而言,使用定义常量抛出的 AspectRatio 会被记录在符号表里,在错误定位时更加简单。
  2. 使用宏定义的结果可能会导致更大的目标代码,因为这个 1.653 在目标代码中可能会有多个副本,而使用常量定义,这个值永远只有一个副本。
  3. 常量变量有作用域的概念,而在宏眼里是没有定义域这个概念的。这在使用面向对象的编程方法时尤其重要,因为没有定义域就没有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” 需要知道以下几点:

  1. 它在功能上其实更接近于 #define,因为 enum 无法取地址也无法引用,当你不希望这些发生时,enum 是一个很好的选择。
  2. 一般来说,编译器不会为内置数据类型的常量留出内存空间,但是如果编译器很糟糕,这还是有可能的,用 enum 可以彻底避免这样的内存分配。
  3. 需要了解 “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

而模板的好处则非常容易列举:

  1. 不需要给参数再加额外的括号。
  2. 不需要担心计算发生了很多次。
  3. 最重要的,模板函数是一个真的函数,遵守了作用域和访问权限的约束,也自然能符合 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 *pwWidget 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 TextBlockTextBlock两种,它们的返回引用形式也不同:

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++ 保证了内部静态对象会在首次遇到其定义时被初始化,所以这样转换后初始化的依赖问题就被解决了。