说明:本文面向有经验的 C++ 程序员,不适合初学者。
此为《你可能不知道的 C++》的第一部分,讨论 C & C++,编译单元,及对象。
C++ has indeed become too "expert friendly".
C++ 着实已经变得太“面向专家”了。
Bjarne Stroustrup, The Problem with Programming, Technology Review, Nov 2006.
C++ & C
C++ 是 C 的超集,但是 C++ 中的子集 C 跟原始的 C 还是有点不一样。
结构 & 联合
C 的结构(struct)不是一种类型,使用时得带着关键字
struct
,一般用typedef
来避免这种不便。C++ 的结构几乎等价于类,只是缺省的访问权限为
public
而非private
。C++ 的联合(union)可以有成员函数,甚至可以有构造和析构函数。
不带参数的函数
对 C 来说,一个不带参数的函数意味着可以接受任意参数。所以void f()
就相当于void f(...)
,而下面三个函数指针类型中:
typedef void (*foox)();
typedef void (*foo1)(int);
typedef void (*foo2)(void);
foo1
和foo2
可以隐式地转型为foox
,就好比可以从int*
或char*
隐式地转型成void*
。
要想让一个 C 函数真正没有参数,得用void
:
void foo(void);
对 C++ 来说,一个不带参数的函数就是指不接受参数。往参数列表里放void
是多余的。
提升void*
详见:Brian W. Kernighan & Rob Pike, The Practice Of Programming, 2.6
C 会自动提升(promote)void*
:
int* pi = malloc(sizeof(int));
函数malloc
返回void*
,赋值给int*
时不需要显式转型。而 C++ 必须显式转型:
int* pi = static_cast<int*>(malloc(sizeof(int)));
CONSTS
C++ 允许 consts 用在常量表达式中:
const int MAX = 4;
int a[MAX + 1];
switch (i) {
case MAX:
...
而 C 则必须使用宏:
#define MAX 4
引一段《C++ 的设计和演化》的原文:
(Bjarne Stroustrup, The Design and Evolution Of C++, 3.8)
In C, consts may not be used in constant expressions. This makes consts far less useful in C than in C++ and leaves C dependent on the preprocessror while C++ programmers can use properly typed and scoped consts.
C 的 consts(特指用 const 关键字修饰的常量)不可以用在常量表达式中。这让 C 的 consts 远不如 C++ 的有用,也让 C 依赖于预处理器,而 C++ 程序员则可以使用有适当类型和作用域的 consts。
前置声明
C 代码块中,所有声明必须出现在任何程序语句之前,比如函数定义时,先声明所有局部变量:
void foo() {
int ival, *p;
/* … */
}
而 C++ 的声明,诸如int ival;
,其自身就是一个程序语句,也因此可以出现在程序文本中的任何位置。
编译单元
C/C++ 中的一个源文件(.c, .cpp, .cc
)就是一个编译单元(compilation unit)。
头文件(.h, .hpp
)不是编译单元,是不能单独编译的。
源文件经过预处理,先搞定下面这些东西:
宏:包括用户定义的,和预定义的(
__cplusplus
,__FILE__
, ...)包含语句:源文件中的
include
语句全部展开条件编译:
#if
,#else
,#ifudef
, ...#error
,#warning
, ...
预处理过的源文件,经过编译,生成对象文件(.o, .obj
)。对象文件经过链接或打包,生成可执行文件或程序库。虽然这里的步骤不太严格,但是大抵就是这样。
如果你对预处理的结果很感兴趣,可以试试编译器的预处理命令:gcc -E (GCC)
,cl /E or /P (VC)
。
对象
这里所说的对象(object),泛指一切类型的实例,不只是类的实例。
关于对象,我们将探讨以下几个方面:
对象的大小(size)
按存储(storage)分类的对象
聚合(aggregate)
自由存储(free-store)对象的构造和析构
RAII
对象的大小
先来考虑几个问题:
sizeof
是一个函数吗?你知道
sizeof(int)
,sizeof(long)
各为多少吗?为什么应该用
size_t
?
C/C++ 的数据模型
为了回答这些问题,有必要了解一下 C/C++ 的数据模型标记(Data Model Notation)。
大写字母代表数据类型(I: int; L: long; LL: long long; P: 指针
),而下标则表示这个类型的大小(一般为 16/32/64)。
有了这个标记,就可以方便地表示不同平台上 C/C++ 的数据模型。比如 32 位
x86 Linux 平台,数据模型为I32L32LL64P32
,或者简写成IL32LL64P32
。
size_t
标准库里到处都是size_t
的身影:
void *malloc(size_t n);
void *memcpy(void *s1, void const *s2, size_t n);
size_t strlen(char const *s);
回到前面的问题,不难理解以下几点:
size_t
是sizeof
返回值的类型size_t
是一个typedef
sizeof
不是一个函数,它是一个编译时操作符size_t
能够表示任何类型理论上可能的数组的最大大小
其实,size_t
一般就是unsigned int
的typedef
,那为什么不直接用unsigned int
?在IP16
或IP32
平台上(即int
和指针大小一致时),确实没有问题,但I16LP32
就不行了。此外,直接用unsigned long
固然没错,但毕竟得多花了几个字节,稍微有点浪费了。反正只要用size_t
,你就可以同时得到正确性和可移植性。
数据对齐
请问mixed_data
的大小是多少?是 8 吗?
struct mixed_data {
char data1;
short data2;
int data3;
char data4;
};
在 32 位 x86 平台上编译后的样子:
struct mixed_data {
char data1;
char padding1[1];
short data2;
int data3;
char data4;
char padding2[3];
};
为了数据对齐,编译器塞了一些边角料进去,最终的大小为 12。
按存储分类的对象
C/C++ 的对象,按存储类型分为以下几种:
自动的(auto, register)
静态的(static)
自由存储的(free-store)
关键字auto
有点多余,下面两条声明语句其实等价,b
前面的auto
加不加一个效果:
{
int a;
auto int b;
}
到了 C++11,auto
这个关键字就被拿来另作他用了:auto
可以让编译器从变量的初始化上自动推断出它的类型:
auto a = std::max(1.0, 4.0); // 编译器推断出 a 的类型为 double
聚合
首先,什么叫聚合?
对 C 来说,数组和结构是聚合。
对 C++ 来说,除了数组外,满足以下条件的类(或结构)也是聚合:
没有用户声明的构造函数
没有
private
或protected
非静态数据成员没有基类
没有虚函数
所以,下面几个类型都是聚合:
int[5];
struct person {
std::string name;
int age;
};
boost::array;
初始化自动的聚合对象
自动聚合对象可以用“初始化列表”,即花括号,这种初始化方式非常方便。
typedef int ints_t[5];
ints_t ints1 = {};
// { 0, 0, 0, 0, 0 }
ints_t ints2 = { 0, 1, 2 };
// { 0, 1, 2, 0, 0 }
ints_t ints3 = { 0, 1, 2, 3, 4 };
// { 0, 1, 2, 3, 4 }
struct person {
std::string name;
int age;
};
person p1 = {};
// { "", 0 }
person p2 = { "john" };
// { "john", 0 }
person p3 = { "john", 26 };
// { "john", 26 }
boost::array<int, 3> a = { 0, 1, 2 };
对聚合对象来说,缺省初始化(default-initalization)就是指零值初始化(zero-initialization)。
如果不指定初始化列表的话,对象各元素的初始值就不确定了。
boost::array<int, 3> a; // 三个元素的初始值不确定
初始化自由存储的聚合对象
初始化列表对自由的聚合对象并不适用,但是自由的聚合对象可以通过()
来做零值初始化。
struct list_node {
int value;
list_node* next;
};
list_node* n1 = new list_node();
list_node* n2 = new list_node;
n1
和n2
的差别在于,n1
所指的对象是经过零值初始化的,而n2
所指的对象则不确定。具体来说,n1
为{ 0, NULL }
,而n2
的value
和next
是什么就说不准了。
成员聚合对象的零值初始化
成员名字后面加
()
就缺省初始化了这个成员。对于聚合成员来说,缺省初始化就是指零值初始化。
如果成员有一个 non-trivial 的构造函数,那么缺省初始化就意味着调用它的缺省构造函数。
如何实现下面这个类的缺省构造函数?
class C {
public:
C(); // 如何实现这个?
private:
struct S {
int x;
int* p;
bool b;
};
S s;
int d[5];
};
由 C 转过来的 C++ 程序员可能会这样实现:
C::C() {
memset(&s, 0, sizeof(S));
d[0] = d[1] = d[2] = d[3] = d[4] = 0;
}
没错,百分之百正确!但是,更简单更 C++ 的方式是:
C::C() : s(), d() {}
因为s
和d
这两个成员都是聚合对象,使用()
就可以初始化为零值。
成员聚合对象的非零值初始化
只能手动赋值了:
C::C() {
s.x = 5;
s.p = new char[s.x];
s.b = true;
d[0] = 9;
...
}
C++11 新标准允许声明时使用初始化列表:
class C {
S s { 5, new char[5], true }
int d[5] { 9, 9, 9, 9 }
};
虽然成员变量的初始化变得简单直观了,但是在头文件里干这种事也有暴露实现细节的嫌疑。
自由存储对象的构造和析构
new operator & operator new
用new operator
创建一个std::string
对象:
std::string* name = new std::string("Adam");
new operator
的实现大致如下:
void* p = ::operator new(sizeof(std::string));
std::string* name = static_cast<std::string*>(p);
name->basic_string::basic_string("Adam");
先用operator new
分配内存,再转型成std::string
指针,然后再调用std::string
的构造函数。
负责分配内存的operator new
的实现大致如下:
void* operator new(...) {
void* p;
while ((p = malloc(size)) == 0) {
// 尝试调用 new handler 来得到更多可用内存,要不就返回 NULL 或抛出异常
}
return p;
}
nothrow & throw
new operator
分配内存失败后,缺省的行为不是返回NULL
,而是抛出异常std::bad_alloc
。所以判断返回值是否为NULL
没有任何意义。
A* a = new A();
if (a == NULL) { // 没有任何意义!
return;
}
在new
后面加上(std::nothrow)
可以改变这一行为。
char* p = new (std::nothrow) char[0x7ffffffe];
if (p == NULL) { // 现在可以这样判断了
// 内存分配失败了!
}
否则就得用 try / catch
:
char* p;
try {
p = new char[0x7ffffffe];
} catch (std::bad_alloc&) {
// 哦,内存分配失败了!
// ...
}
实践中一般不必这么麻烦,可以假定new operator
总能成功。毕竟连一般对象的内存都分配不了时,程序也没有继续执行的意义了。
placement new
placement new
在一块预先分配好的内存上创建对象,一般用来实现内存池。比如:
先分配容得下 3 个std::string
的内存:
const size_t count = 3;
char* buf = new char[sizeof(std::string) * count];
然后声明三个std::string
对象指针,并逐一调用placement new
进行初始化:
std::string* strs[count];
for (size_t i = 0; i < count; ++i) {
strs[i] = new (buf + i * sizeof(std::string)) std::string("A");
}
最后清理的时候,不能用delete
,要手动调用std::string
的析构函数~basic_string()
来充值对象状态,而内存还留在buf
里。要释放buf
就单独对它调用delete[]
。
for (size_t i = 0; i < count; ++i) {
strs[i]->~basic_string();
}
RAII
RAII 指“资源获取即初始化”(Resource Acquisition Is Initialization),概念上不是很好理解。
也不是所有语言都能支持 RAII。
有些语言,可以把自定义(user-defined)类型的对象分配在栈上(C/C++ 的术语叫“自动的”对象),并且于正常的栈清理时(要么是函数返回,要么是异常抛出)也能一并清理对象,那么它就支持 RAII。典型的语言如 C++。
有些语言,有基于引用计数的垃圾收集,也因此对于只有一个引用的对象具备可预测的清理时,那么它也是支持 RAII 的。典型的语言如 Python。
RAII 的类,设计上都比较纯粹,或者至少主要是用来提供 RAII 的语意。这些类一般都是为某种资源提供一种抽象级别的访问。
C++ STL 中有不少 RAII 类,比如颇具争议性的std::auto_ptr
。还有 std::basic_ifstream
,std::basic_ofstream
,std::basic_fstream
,等等。
C 没有 RAII 这东西,C 也没有自定义类型一说(C 的结构不是一种独立的类型)。
以文件操作为例,C 的做法是:
{
FILE* file = fopen( ... );
// ...
fclose(file);
}
而 C++ 就方便很多,不需要手动关闭文件,因为std::ofstream
在析构时会自动释放文件资源,即使中途有异常发生也不会出现问题。
{
std::ofstream file( ... );
// 继续操作文件 ...
} // 在此 file 对象被自动清理,它的析构函数负责释放文件资源。
RAII 的原理
Stack Winding & Unwinding
当程序运行时,每一个函数(包括数据、寄存器、程序计数器,等等)在调用时,都被映射到栈上。这就是 stack winding。
Unwinding 是以相反顺序把函数从栈上移除的过程。
正常的 stack unwinding 发生在函数返回时;不正常的情况,比如引发异常,调用setjmp
和longjmp
,也会导致 stack unwinding。
关于异常发生时,stack unwinding 的过程,不妨引用一段《C++ 程序设计语言》里的原文(Bjarne Stroustrup, The C++ Programming Language, 14.4):
The process of searching “up through the stack” to find a handler for an exception is commonly called “stack unwinding.” As the call stack is unwound, the destructors for constructed local objects are invoked.
可见 stack unwinding 的过程中,局部对象的析构函数将逐一被调用。这也就是 RAII 工作的原理,它是由语言和编译器来保证的。
第一部分完。