effective C++ 第三版 第一章

本文最后更新于:2021年10月27日 凌晨

守则01:把C++看做一个语言的集合,而不是单一的语言

“C++ is a federation of languages”

早期的C++只是叫”C with classes”,但发展到今天已经成为一个多重泛型编程语言(Multi-paradigm programming language),它具有4种“子语言”:

  • C
  • 面向对象的C++
  • 模板C++
  • STL

高效的C++编程守则取决于你所使用的“子语言”

例如**:**

  • 在C中,一般使用值传递 (Pass by value)
  • 在**面向对象的C++和模板C++**中,使用常量引用传递 (Pass by const reference)更加高效
  • 对于STL,因为迭代器是基于指针构造而成,直接使用值传递即可

守则02:尽量使用const, enum, inline, 减少宏变量#define的使用

或者说,尽量多用编译器,少用预处理器

“Prefer the compiler to the preprocessor”

C++提供的编译预处理功能主要有以下三种:
  ① 宏定义
  ② 文件包含
  ③条件编译

1
#define A 1.653   

在上面这个语句中,字符串’A’是不会被编译器看到的,而编译器看到的是’1.653’,这就会导致在调试过程中,编译器的错误信息只显示’1.653’而不是’A’,让你不知从何下手

解决方法:定义全局常量

1
const double A = 1.653;

使用全局常量还有一个好处:预处理器只会把每次对’A’的引用变成’1.653’而不管其是否已经存在,这就导致多次引用’A’会造成多个重复对象出现在目标代码中(Object code),造成资源浪费。

当定义或声明全局变量时,常数指针和类的常数需要另加考虑

  • 对于指针

对于指针要把指针本身和它指向的数据都定义为const,例如

1
const char* const myWord = "Hello";

(指向常量的常量指针)

在C++中可以更方便地使用std::string这样基于char*类型的推广,例如

1
const std::string myWord("Hello");
  • 对于类的常数

声明为类的私有静态成员,这样既保证变量只能被这个类的对象接触到,又不会生成多个拷贝

1
2
3
4
class Player{
private:
static const int numPlayer = 5;
........

注意,因为此处是类的成员声明范围内,所以上面只是变量的声明和初始化,而并非定义,因此如果想获取变量的地址,需要在别处另加定义。这个定义不能有任何赋值语句,因为在类内已经规定为const:

1
const int Player::numPlayer;

额外的补充:类内const推荐用static

细节可见(4)

  • static的const对象赋初值与否编译器都能通过

    image-20211026232229655

  • 非static的const对象不允许赋初值

    赋值会报错

    error: non-static const member ‘const int A::NUM_TURNS’, can’t use default assignment operator

    不赋值且无构造函数也会报错

    error: non-static const member ‘const int A::NUM_TURNS’, can’t use default assignment operator

    image-20211026232615673

对于#define的宏函数,尽量使用inline修饰的函数来代替#define

C中经常会用形如#define max(a,b) ((a) > (b) ? (a) : (b))这样的宏而不是定义成函数,一方面为了代码编写简洁而另一方面,又不增加函数调用的开销。

为什么要加()呢,因为怕运算符优先级问题引起歧义,这在C中已是路人皆知的技巧。

但实际上这种手法捉襟见肘,缺陷很多,比如:

1
2
3
int a = 5, b = 0;
max(++a, b); //a的值会增加2次
max(++a, b+10); //a的值只增加了1次

虽然有点故意刁难的意思。

C++的inline完全规避了这种缺陷,可以改为inline函数:

1
2
template<typename T>
inline const T& max(const T& a, const T& b){ return a > b ? a : b; }

inline还有一个好处就是现代的编译器都比程序员聪明,你显式声明inline实际上最终不一定是inline,而有一些即使不声明inline也会被编译器优化成inline,这是C++的一大性能优化。

守则03: 尽可能使用const关键字

“Use const whenever possible”

  • 指针与const:

记忆法: const在星号左边修饰数据,const在星号右边修饰指针

以及如下两个语句的功能是相同的,不需要对此产生困惑:

1
2
const char* pw;             //都表示指向常量char的指针
char const* pw;
  • 迭代器与const

迭代器在功能上相当于指向某类型T的指针 T*

区分 const iterator 和 const_iterator

因此,如果想定义某迭代器指向一个常数,使用const iterator是不可以的,这样只相当于定义一个迭代器为一个常量(T* const),例如:(相当于指向常量的指针

1
2
3
const std::vector<int>::iterator it = v.begin(); //注意,此声明只表示迭代器本身是常量     
*it = 10; //编译通过,迭代器是常量,数据可以被修改
++it; //编译失败!因为const迭代器不允许改变!

解决方法,使用const_iterator:(相当于指针本身为常量

1
2
3
std::vector<int>::const_iterator it = v.begin();  //使用了const_iterator类型
*it = 10; //编译失败,数据不允许被改变!
++it;
  • 尽量使用const可以帮助调试
  • 类的成员函数与const
  • 成员函数的常量性(Constness)
  • 在定义常量与非常量成员函数时,避免代码重复

既然两个版本的成员函数都要有,为什么又要避免重复?

其实在这里指的是函数的实现要避免重复。试想某函数既要检查边界范围,又要记录读取历史,还要检查数据完整性,这样的代码复制一遍,既不显得美观,又增加了代码维护的难度和编译时间。因此,我们可以使用非常量的函数来调用常量函数。(绝对不能是常量调用非常量版本)

1
2
3
4
5
6
7
const char& operator[](std::size_t pos) const{....}
char& operator[](std::size_t pos){
return
const_cast<char&>( //const_cast去掉const关键字,并转换为char&
static_cast<const Text&>(*this) //给当前变量加上const关键字,才可以调用const操作符
[position]);
}

为了避免无限递归调用当前非常量的操作符,我们需要将(*this)转换为const Text&类型才能保证安全调用const的操作符,最后去掉const关键字(const_cast)再将其返回,巧妙避免了代码的大段复制。

守则04: 在使用前保证对象是初始化的

  • 自有类型(built-in type)的初始化

C++的自有类型继承于C,因此不能保证此类型的变量在定义时被初始化。使用未初始化的数据可能会导致程序不正常运作,因此在定义变量的时候,需要对其进行初始化。

  • 类的初始化

对于用户自定义的类,我们需要构造函数(constructor)来完成此类的初始化

C++规定,在进入构造函数之前,如果用户没有规定初始化过程,C++将自动调用各成员对应类型的默认构造函数。

这样一来,此构造函数就相当于先调用了C++的默认构造函数,又做了一次赋值操作覆盖掉了先前的结果,造成了浪费。

解决方法:使用**初始化列表(initialization list)**,C++就不必额外调用默认构造函数了。

  • 某些初始化是语法必要的

    例如在定义引用(reference)和常量(const)时,不将其初始化会导致编译器报错

    1
    2
    3
    4
    5
    6
    const int a;                //报错,需要初始化!
    int& b; //报错,需要初始化!
    //现在对其进行初始化:
    const int a = 3; //编译通过
    int c = 3;
    int& b = c; //编译通过!
  • 数据初始化的顺序

在继承关系中,基类(base class)总是先被初始化

在同一类中,成员数据的==初始化顺序与其声明顺序是一致的==,而不是初始化列表的顺序。因此,为了代码一致性,要保证初始化列表的顺序与成员数据声明的顺序是一样的。

  • 初始化非本地静态对象

编译单元(translation unit): 可以让编译器生成代码的基本单元,一般一个源代码文件就是一个编译单元。

非本地静态对象(non-local static object): 静态对象可以是在全局范围定义的变量,在名空间范围定义的变量,函数范围内定义为static的变量,类的范围内定义为static的变量,而除了函数中的静态对象是本地的,其他都是非本地的。

此外注意,静态对象存在于程序的开始到结束,所以它不是基于堆(heap)或者栈(stack)的。初始化的静态对象存在于.data中,未初始化的则存在于.bss中。

回到问题,现有以下服务器代码:

1
2
class Server{...};     
extern Server server; //在全局范围声明外部对象server,供外部使用

又有某客户端:

1
2
3
4
5
6
class Client{...};
Client::Client(...){
number = server.number;
}

Client client; //在全局范围定义client对象,自动调用了Client类的构造函数

以上问题在于,定义对象client自动调用了Client类的构造函数,此时需要读取对象server的数据,但全局变量的不可控性让我们不能保证对象server在此时被读取时是初始化的。试想如果还有对象client1, client2等等不同的用户读写,我们不能保证当前server的数据是我们想要的。

解决方法: 将全局变量变为本地静态变量

使用一个函数,只用来定义一个本地静态变量并返回它的引用。==因为C++规定在本地范围(函数范围)内定义某静态对象时,当此函数被调用,该静态变量一定会被初始化。==(singleton模式)

1
2
3
4
5
6
class Server{...};

Server& server(){ //将直接的声明改为一个函数
static Server server;
return server;
}
1
2
3
4
5
6
7
8
9
10
class Client{...};

Client::client(){ //客户端构造函数通过函数访问服务器数据
number = server().number;
}

Client& client(){ //同样将客户端的声明改为一个函数
static Client client;
return client;
}

第一章总结

  1. 视 C++ 为一个语言联邦(C、Object-Oriented C++、Template C++、STL)
  2. 宁可以编译器替换预处理器(尽量以 constenuminline 替换 #define
  3. 尽可能使用 const
  4. 确定对象被使用前已先被初始化(构造时赋值(copy 构造函数)比 default 构造后赋值(copy assignment)效率高)