你可能不知道的 C++(一)

lmmy 2019-06-20

说明:本文面向有经验的 C++ 程序员,不适合初学者。

第二部分:你可能不知道的 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);

foo1foo2可以隐式地转型为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)。

你可能不知道的 C++(一)

大写字母代表数据类型(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_tsizeof返回值的类型

  • size_t是一个typedef

  • sizeof不是一个函数,它是一个编译时操作符

  • size_t能够表示任何类型理论上可能的数组的最大大小

其实,size_t一般就是unsigned inttypedef,那为什么不直接用unsigned int?在IP16IP32平台上(即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++ 来说,除了数组外,满足以下条件的类(或结构)也是聚合:

  • 没有用户声明的构造函数

  • 没有privateprotected非静态数据成员

  • 没有基类

  • 没有虚函数

所以,下面几个类型都是聚合:

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;

n1n2的差别在于,n1所指的对象是经过零值初始化的,而n2所指的对象则不确定。具体来说,n1{ 0, NULL },而n2valuenext是什么就说不准了。

成员聚合对象的零值初始化

  • 成员名字后面加()就缺省初始化了这个成员。

  • 对于聚合成员来说,缺省初始化就是指零值初始化。

  • 如果成员有一个 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() {}

因为sd这两个成员都是聚合对象,使用()就可以初始化为零值。

成员聚合对象的非零值初始化

只能手动赋值了:

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_ifstreamstd::basic_ofstreamstd::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 发生在函数返回时;不正常的情况,比如引发异常,调用setjmplongjmp,也会导致 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 工作的原理,它是由语言和编译器来保证的。


第一部分完。

第二部分:你可能不知道的 C++(二)

相关推荐