Item1: View Cpp as a federation of languages
C++可以看成是下面四种不同语言的组合:
- C 语言
- 面向对象的 C++:主要针对封装、多态、继承、虚函数等等。
- 模板 C++:主要针对泛型编程。
- The STL(标准模板库):最常用且特别的模板库。
每种对应不同的策略,需要根据情况选择对应的策略。
Note
Rules for effective C++ programming vary, depending on the part of C++ you are using.
Item2: Prefer consts, enums and inlines to #define
#define 在 preprocess 阶段完成,而前面三个关键字都是在 compile 阶段完成的。
符号表:symbol table。这个概念和编译的关系是?符号表似乎就是一个存了所有变量名字的大表吧。
#define 出来的东西本质上不是变量,不会进到符号表里面,这给我们 debug 带来困难。
代码体积更小(不会有多余的 copy),含义更加明确。
两类特殊情况:
- Constant pointer:需要指针值和指针指向的指都被定义成常量,这样这个常量才算真正的“常量”。
- Class-specific constants:限制一个常量的作用域在这个类内部,则这个常量是这个类的成员。并且,为了使得这个常量只有一个 copy,还要声明其为
static类型。
| |
上面是对 NumTurns 这个常量的 declaration,而不是 definition。这两个有什么区别吗?
“Usually, C++ requires that you provide a definition for anything you use, but class-specific constants that are static and of integral type (e.g., integers, chars, bools) are an exception.” (Dulimov, p. 14)
The enum hack:
“The only exception is when you need the value of a class constant during compilation of the class, such as in the declaration of the array GamePlayer:: scores above (where compilers insist on knowing the size of the array during compilation). Then the accepted way to compensate for compilers that (incorrectly) forbid the in-class specification of initial values for static integral class constants is to use what is affectionately (and non-pejoratively) known as “the enum hack.”” (Dulimov, p. 15)
| |
不要用 #define,而是用 inline const 和 enum,因为 #define 只是简单的文本替换,在预处理阶段完成。
- For simple constants, prefer
constobjects orenumto#define - For function-like macros, prefer
inlinefunctions to#define
Item3: Use const whenever possible
“The wonderful thing about const is that it allows you to specify a semantic constraint — a particular object should not be modified and compilers will enforce that constraint.” (Dulimov, p. 17)
同时告诉编译器和其他的程序员:这个变量是 const,它不应该被修改。
一个问题:我记得之前说 char* 表示的字符串就是常量字符串,但是这里为什么提到
| |
的时候,作者的描述让我觉得这个字符串是可以修改的?
对上面问题的解释:
- 不是所有的
char*都指向常量字符串。 - 只有
char* p = "字符串字面量";时,对应的字符串才是只读的、无法修改的。 - 而我们书上这种
char* p = greeting;是可以修改的字符串。因为greeting是字符数组,存在栈空间上,可以随便修改。 - 而字符串字面量放在
.rodata段,在进程内存布局里面,属于代码段,和.text一样,都是在运行过程中绝对不允许被修改的。
【补充】进程虚拟地址空间布局:
| 段名称 | 存储内容 | 读写权限 | 生命周期 |
|---|---|---|---|
| 栈 (Stack) | 局部变量、函数帧 | 读写 | 函数调用结束即释放 |
| 堆 (Heap) | 动态分配内存 | 读写 | 程序员手动控制(或进程结束) |
| BSS | 未初始化全局/静态变量 | 读写 | 程序整个运行期 |
| 数据段 (Data) | 已初始化全局/静态变量 | 读写 | 程序整个运行期 |
| 代码段 (Text) | 机器指令、常量 | 只读 | 程序整个运行期 |
注意到,当 const 关键字在 * 的左边的时候,不论 const 在类型名的左边还是右边,都表示“指向的对象是常量”,两种表示方法完全等价:
| |
STL iterator 中的 const
STL 的 iterator,其实就类似一个指针。定义 const 类型的迭代器,表示这个迭代器的指针值本身无法被修改。
“Declaring an iterator const is like declaring a pointer const (i.e., declaring a T* const pointer): the iterator isn’t allowed to point to something different, but the thing it points to may be modified.” (Dulimov, p. 18)
如果想要迭代器指向的对象是常量,那么我们需要 const_iterator
来看下面的例子:
| |
函数声明中的 const
函数声明中,const 的用法:这是 const 最强大的一类用法
- 修饰 return value,表示返回值必须是一个无法修改的常量
- 修饰参数
- 修饰成员函数(指代整个成员函数本身)
const 修饰 function return value
| |
这里定义了 Rational 类的乘法结果为 const 类型。为什么这么定义?因为如果不将返回值定义为 const 类型,那么就会出现如下操作:
| |
这很奇怪,不是吗?虽然正常人不会这么写代码,但是,如果我们有时候出现一个笔误,比如将 == 写成了 =,那么就会出现上面的问题:
| |
优秀的用户自定义类型,不应该与内置类型产生不必要的不兼容问题。也就是说,内置类型不支持的非法操作,我们的自定义类型也不应该支持。
“One of the hallmarks of good user-defined types is that they avoid gratuitous incompatibilities with the built-ins (see also Item 18), and allowing assignments to the product of two numbers seems pretty gratuitous to me.” (Dulimov, p. 19)
函数参数里面,能定义成 const 类型的,尽量定义成 const 类型。
“Unless you need to be able to modify a parameter or local object, be sure to declare it const. It costs you only the effort to type six characters, and it can save you from annoying errors such as the “I meant to type
==but I accidently typed=” mistake we just saw.” (Dulimov, p. 19)
const 修饰成员函数,表示该成员函数不会修改类中的非静态成员变量
下面重点讲讲 const 成员函数。
const 修饰成员函数,表示这个成员函数对所有的成员变量都是只读的:能读取所有的成员变量,但不能修改任何成员变量。
为什么要这么设计:
const让成员函数表示只读,这让这个类对外暴露的接口类型更加清晰。- 允许
const对象调用这个函数。
下面这段话是说,仅在常量性上存在差异的函数可以构成重载。也就是说,成员函数的后面加不加 const,就完全是两个不同的成员函数。
“Many people overlook the fact that member functions differing only in their constness can be overloaded, but this is an important feature of C++.” (Dulimov, p. 19)
可以看下面的例子:
| |
这样,如果你定义了一个 const Textbook 类型的量,它在使用 [] 运算符的时候,就只会调用 const 成员函数,而不是另一个成员函数。从而,同时保证了常量的安全性和可读性。
修改返回值为内置类型的
That’s because it’s never legal to modify the return value of a function that returns a built-in type.
成员函数被定义为 const 的两种含义:
“Let’s take a brief time-out for philosophy. What does it mean for a member function to be const? There are two prevailing notions: bitwise constness (also known as physical constness) and logical constness.” (Dulimov, p. 21)
The bitwise const camp believes that a member function is const if and only if it doesn’t modify any of the object’s data members (excluding those that are static), i.e., if it doesn’t modify any of the bits inside the object.
常量成员函数不能修改类的非静态成员变量,但是可以修改静态成员变量。
In fact, bitwise constness is C++’s definition of constness, and a const member function isn’t allowed to modify any of the non-static data members of the object on which it is invoked.
但是,bitwise constant 不代表 logical constant,因为有可能你的成员变量是一个指针,指针值没有变,但是这个指针指向的对象内容变了。这样,还是能编译通过,但会出现问题。
可以看下面的例子:
| |
由于 operator[] 没有规定返回值类型为 const char&,所以我们实际可以取得一个原字符串的引用,并对其做修改:
| |
这样,我们就修改了一个 const 常量调用 const 函数的返回值,这是非常危险的行为!
我感觉,其实这里的问题出在,我们没有对 operator[] 的返回值也做 const 处理。const 修饰成员函数,只保证了自身的成员变量不被修改,而和返回值是否为常量类型没有关系。
真正的问题是在我们使用了 char* 类型作为成员。
const 只保证 pText 这个指针本身的值不变(地址不变),但 pText 指向的堆内存不在对象的直接控制范围内,所以 const 管不到。
所以,除了 bitwise constness 之外,我们还需要 logical constness!
Logical constant 指的是,我们会对某些成员做一些修改,但这些修改不会影响用户的使用,同时也对用户是透明的。但是,c++默认使用 bitwise const,怎么让它兼容 logical const 呢?可以使用 mutable 关键字。
逻辑上,不改变对象的对外表现,同时修改一些内部细节。
经典例子:字符串长度
| |
Avoiding Duplication in const and Non-const Member Functions
两处重载函数的代码逻辑完全重复,只有返回值不同。这时候,如果一处改了,另一处没改,就会出 bug。
| |
天然的想法:让非 const 版本调用 const 版本。那么做一步类型转换就行。
| |
这里两步类型转换在做什么?注意到:*this 的类型是 TextBlock&(非 const,因为上面没有调用 const 成员函数)
步骤 1:static_cast<const TextBlock&>(*this)
- 把
*this从TextBlock&转成const TextBlock& - 这样调用
operator[]时匹配到的是const版本 const版本返回const char&
步骤 2:const_cast<char&>(...)
- 把
const char&转回char& - 这样非
const版本最终返回的是非const引用,调用方可以修改。
简单来说,就是让非 const 版本的成员函数,其 *this 经过类型转换之后,能够调用 const 版本的成员函数,再类型转换回非 const 类型的返回值。
注意:不能反过来操作,即不能让 const 成员函数调用非 const 成员函数,因为这是不被允许的行为。
一句话
static_cast:编译器本来就允许的事,你只是显式说出来const_cast:编译器故意阻止的事,你强行绕过去
所以说,const_cast 是个危险的操作,我们应当尽量避免。那么为什么这里可以用 const_cast 呢?
是因为我们传入的值本来就不是 const 类型,我们为了能复用 const 成员函数的代码,强制转换成了 const 类型,而后面做的事情则是转换回去,所以没问题。
Note
- Declaring something
consthelps compilers detect usage errros.constcan be applied to objects at any scope, to function parameters and return types, and the member functions as a whole. - Compilers enforce bitwise constness, but you should program using logical constness.
- When const and non-const member functions have essentially identical implementations, code duplication can be avoided by having the non-const version call the const version.
Item4: Make sure that objects are initialized before they’re used
插入一个小知识点:声明和定义的区别
- 声明 declaration:只告诉编译器该变量存在,但是不为其分配内存。
- 声明 + 定义 definition:既完成声明的工作,又为其分配了内存。
所以,int x; 是“声明 + 定义”,只是没有初始化 x 的值。
Initializing non-member objects of built-in types
回到本节的内容。什么时候,一个变量一定保证会被初始化?这个规则十分复杂,没有一个定论。
所以,我们最好在使用一个变量之前,确保已经手动初始化过这个变量的值。
- 对于 built-in types,我们必须手动初始化;
- 而其他的类型,初始化的责任则都归于构造函数。
Make sure that all constructors initialize everything in the object.
初始化(initialization)和赋值(assignment):
- 初始化是在变量创建的那一刻就给它一个值。
- 赋值是在变量已经存在之后,再改变它的值。
对于 built-in types 这样的简单类型,两者其实区别不大;但对于复杂的 class 来说,初始化调用的是 constructor,而赋值调用的是赋值运算符,即 operator=。
Initializing by constructor
构造函数中,成员变量的初始化和赋值:
| |
直接使用 member initialization list 初始化成员变量会更快,因为省去了一次默认构造的开销。
“For most types, a single call to a copy constructor is more efficient sometimes much more efficient — than a call to the default constructor followed by a call to the copy assignment operator.” (Dulimov, p. 28)
当然,如果在 member init list 里面不输入值,那么就可以直接调用默认构造函数。
不能被赋值的 built-in types 一定要初始化,如 const 类型和 references。
如果一个类有多个构造函数,那么写 member init list 可能是一件重复、麻烦、无聊的事情。这时候,我们可以考虑写一个 pseudo-initialization 的函数,将那些 built-in types 的初始化全部在这个函数中用赋值来完成,然后每个 constructor 只需要调用这个函数即可。
不过,在大多数时候,member init list 总是优于用赋值来完成的伪初始化。
初始化的顺序:
- 基类总是比派生类更早初始化。
- 类内的成员变量是按照它们声明的顺序初始化的,不论它们在 member init list 里面是怎么排列的。(最好还是保证 member init list 里的顺序和声明顺序一致,这是一个 good coding habit)
“This is true even if they are listed in a different order on the member initialization list (something that’s unfortunately legal).” (Dulimov, p. 29)
Initialization of non-local static objects defined in different translation units
首先来看静态对象的定义:
“A static object is one that exists from the time it’s constructed until the end of the program.” (Dulimov, p. 30)
所以,static object 既不在 stack 上,也不在 heap 上,它在我们之前提过“进程虚拟地址空间布局”里面的 data 段(常量初始化)或者 bss 段(零初始化)。(global object 的存储位置、生命周期的判定都和 static object 相同,两者只是作用域/可见性不同)
可以看下面四种情况:
| |
零初始化之后,再赋值,数据还是在 bss 段,不会跑到 data 段去。
然后介绍一下 Translation Unit(翻译单元):
- 一个
.cpp文件加上它#include的所有头文件,展开后的整体。
| |
编译时,每个 .cpp 文件独立编译为一个 translation unit,各自不知道其他人的存在:
main.cpp + 所有 #include --> TU 1 --> main.o
foo.cpp + 所有 #include --> TU 2 --> foo.o
bar.cpp + 所有 #include --> TU 3 --> bar.o最后链接器把所有 .o 文件拼在一起,生成可执行文件。
Non-local static object:
“nonlocal static object (i.e., an object that’s global, at namespace scope, or static in a class or at file scope).” (Dulimov, p. 30)
补充知识:怎么理解 static 关键字?这其实比较混乱,可以从三个视角理解:
- 全局
static:让变量脱离其他 TU 的可见性 - 局部
static:让变量脱离函数栈的生命周期 - 类
static:让变量脱离具体对象的归属
Non-local static object 跨 TU 的初始化顺序是不可见的。
And the actual problem is this: if initialization of a non-local static object in one translation unit uses a non-local static object in a different translation unit, the object it uses could be uninitialized, because the relative order of initialization of nonlocal static objects defined in different translation units is undefined.
C++只保证在同一个 TU 内,static 对象按定义顺序初始化。但对于不同翻译单元中定义的 non-local static object,它们的初始化顺序是未定义的。
理解 static 的含义:指的是变量的“生命周期”。这里的 non-local static object,指的就是非局部且生命周期贯穿整个程序的对象。
C++中变量的生命周期
C++ 中变量的生命周期主要有三种:
| Storage duration | 含义 | 对应代码 |
|---|---|---|
| automatic | 进入块时创建,离开块时销毁 | 局部变量、函数参数 |
| dynamic | new 创建,delete 销毁 | 堆上对象 |
| static | 程序启动时创建,程序结束时销毁 | 全局变量、static 局部变量、static 成员 |
来看下面这个例子:
文件 A:FileSystem.h
| |
文件 B:Directory.h
| |
问题在于:tempDir 的构造函数依赖于 theFileSystem 已经被初始化。但如果这两个对象分别在不同的 .cpp 文件中定义,编译器不保证 theFileSystem 先于 tempDir 初始化。
如果 tempDir 先被初始化,它的构造函数就会使用一个尚未构造好的 theFileSystem —— 这是未定义行为。
那么怎么解决这个问题呢?使用 Meyer’s Singleton,具体来说是 Construct On First Use。
| |
核心思路:把 non-local static object 放进一个函数里,变成 local static object,然后返回它的引用。调用方不再直接引用那个全局/类静态变量,而是调用函数。
Note
To avoid using objects before they’re initialized, then, you need to do only three things.
- Manually initialize objects of built-in type, because C++ only sometimes initializes them itself.
- In a constructor, prefer use of the member initialization list to assignment inside the body of the constructor. List data members in the initialization list in the same order they’re declared in the class.
- Avoid initialization order problems across translation units by replacing non-local static objects with local static objects.