跳转至

第19章 特殊工具与技术


控制内存分配

某些程序需要自定义内存分配的细节,比如使用关键字new将对象放置在特定的内存空间中。为了实现这一目的,应用程序需要重载new运算符和delete运算符以控制内存分配的过程。

重载new和delete

当我们使用一条new表达式时:

string *sp = new string("a value");
string *arr = new string[10];

实际执行了三步操作。

  1. new表达式调用一个名为operator new(或者operator new[])的标准库函数。该函数分配一块足够大的、原始的、未命名的内存空间以便存储特定类型的对象(或者对象的数组)。

  2. 编译器运行相应的构造函数以构造这些对象,并为其传入初始值。

  3. 对象被分配了空间并构造完成,返回一个指向该对象的指针。

当我们使用一条delete表达式删除一个动态分配的对象时:

delete sp;
delete [] arr;

实际执行了两步操作。

  1. 对sp所指的对象或者arr所指的数组中的元素执行对应的析构函数。

  2. 编译器调用名为operator delete(或者operator delete[])的标准库函数释放内存空间。

如果我们定义了自己版本的operator new函数和operator delete函数,编译器将使用我们自定义的版本替换标准库定义的版本。

Warning

当自定义了全局的operator new函数和operator delete函数后,我们就担负起了控制动态内存分配的职责。这两个函数必须是正确的:因为他们是程序整个处理过程中至关重要的一部分。

应用程序可以在全局作用域定义operator new函数和operator delete函数,也可以将它们定义为成员函数。当编译器发现一条new表达式或delete表达式后,将在程序中查找可供调用的operator函数。如果被分配(释放)的对象是类类型,则编译器首先在类及其基类的作用域中查找。如果没有找到,编译器在全局作用域查找,如果找到了用户自定义的版本,则使用该版本执行new表达式或delete表达式;如果没有找到,则使用标准库定义的版本。

operator new接口和operator delete接口

标准库定义了operator new和operator delete的8个重载版本,前四个可能会抛出bad_alloc异常,后四个则不会:

// 这些版本可能抛出异常
void *operator new(size_t);                 // 分配一个对象
void *operator new[](size_t);               // 分配一个数组
void *operator delete(void*) noexcept;      // 释放一个对象
void *operator delete[](void*) noexcept;    // 释放一个数组

// 这些版本承诺不会抛出异常
void *operator new(size_t, nothrow_t&) noexcept;
void *operator new[](size_t, nothrow_t&) noexcept;
void *operator delete(void*, nothrow_t&) noexcept;
void *operator delete[](void*, nothrow_t&) noexcept;

当我们将上述函数定义成类的成员时,它们是隐式静态的。更多讨论见p728。

new 表达式与operator new函数

一条new表达式的执行过程总是先调用operator new函数以获取内存空间,然后在得到的内存空间中构造对象。与之相反,一条delete表达式的执行过程总是先销毁对象,然后调用operator delete函数释放对象所占的空间。

我们提供新的operator new函数和operator delete函数的目的在于改变内存分配方式,但是不管怎样,我们都不能改变new运算符和delete运算符的基本含义。

malloc函数与free函数

malloc和free定义在cstdlib头文件中。

malloc函数接受一个表示待分配字节数的size_t,返回指向分配空间的指针或者返回0以表示分配失败。free函数接受一个void*,它是malloc返回的指针的副本,free将相关内存返回给系统。调用free(0)没有任何意义。

如下所示是编写operator new和operator delete的一种简单方式,其他版本与之类似:

void *operator new(size_t size) {
    if (void *mem = malloc(size))
        return mem;
    else
        throw bad_alloc();
}

void operator delete(void *mem) noexcept { free(mem); }

定位new表达式

与allocator不同的是,对于operator new分配的内存空间来说我们无法使用construct函数构造对象。我们应该使用new的定位new(placement new)形式构造对象:

new (place_address) type
new (place_address) type (initializers)
new (place_address) type [size]
new (place_address) type [size] { braced initializers list }

当仅通过一个地址值调用时,定位new使用operator new(size_t, void*)“分配”它的内存。这是一个我们无法自定义的operator new版本。该函数不分配任何内存,它只是简单地返回指针实参;然后由new表达式负责在指定的地址初始化对象以完成整个工作。事实上,定位new允许我们在一个特定的、预先分配的内存地址上构造对象。

Note

当只传入一个指针类型的实参时,定位new表达式构造对象但是不分配内存。

显式的析构函数调用

我们既可以通过对象调用析构函数,也可以通过对象的指针或引用调用析构函数,这与调用其它成员函数没什么区别:

string *sp = new string("a value");
sp->~string();

箭头运算符解引用指针sp以获得sp所指的对象,然后我们调用析构函数。和调用allocator的destroy类似,调用析构函数可以清除给定的对象但是不会释放该对象所在的空间。如果需要的话,我们可以重新使用该空间。

Note

调用析构函数会销毁对象,但是不会释放内存。

运行时类型识别

运行时类型识别(run-time type identification, RTTI)的功能由两个运算符实现:

  • typeid运算符,用于返回表达式的类型。

  • dynamic_cast运算符,用于将基类的指针或引用安全地转换成派生类的指针或引用。

当我们将这两个运算符用于某种类型的指针或引用,并且该类型含有虚函数时,运算符将使用指针或引用所绑定对象的动态类型。

这两个运算符特别适用于以下情况:我们想使用基类对象的指针或引用执行某个派生类操作并且该操作不是虚函数。

Warning

使用RTTI必须加倍小心。在可能的情况下,最好定义虚函数而非直接接管类型管理的重任。

dynamic_cast运算符

dynamic_cast运算符(dynamic_cast operator)的使用形式如下所示:

dynamic_cast<type*>(e)
dynamic_cast<type&>(e)
dynamic_cast<type&&>(e)

其中,type必须是一个类类型,并且通常情况下该类型应该含有虚函数。第一种情况中,e必须是一个有效的指针。

如果e的类型是type的公有派生类、e的类型是type的公有基类或者e的类型就是type,则转换可以成功。否则转换失败。如果一条dynamic_cast语句的转换目标是指针类型并且失败了,则结果为0。如果转换目标是引用类型并且失败了,则dynamic_cast运算符将抛出一个bad_cast异常。

指针类型的dynamic_cast

举一个简单的例子:

if (Derived *dp = dynamic_cast<Derived*>(bp))
{
    // 使用dp指向的Derived对象
} else {    // bp指向一个Base对象
    // 使用bp指向的Base对象
}

引用类型的dynamic_cast

改写之前的程序:

void f(const Base &b)
{
    try {
        const Derived &d = dynamic_cast<const Derived&>(b);
        // 使用b引用的Derived对象
    } catch (bad_cast) {
        // 处理类型转换失败的情况
    }
}

typeid运算符

typeid运算符允许程序向表达式提问:你的对象是什么类型?

typeid表达式的形式是typeid(e),其中e可以是任意表达式或类型的名字。typeid操作的结果是一个常量对象的引用,该对象的类型是标准库类型type_info或者type_info的公有派生类型。type_info类定义在typeinfo头文件中。

typeid运算符可以用作于任意类型的表达式。其中:

  • 顶层const会被忽略。

  • 如果表达式是一个引用,则typeid返回该引用所引对象的类型。

  • 当typeid作用于数组或函数时,并不会执行向指针的标准类型转换。

当运算对象不属于类类型或者是一个不包含任何虚函数的类时,typeid运算符指示的是运算对象的静态类型。而当运算对象是定义了至少一个虚函数的类的左值时,typeid的结果直到运行时才会求得。

my note

必须在使用 typeid 运算符前包含头文件 typeinfo

使用typeid运算符

Derived *dp = new Derived;
Base *bp = dp;          // 两个指针都指向Derived对象

// 在运行时比较两个对象的类型
if (typeid(*bp) == typeid(*dp)) {
    // bp 和 dp 指向同一类型的对象
}

// 检查运行时类型是否是某种指定的类型
if (typeid(*bp) == typeid(Derived)) {
    // bp 实际指向Derived对象
}

typeid是否需要运行时检查决定了表达式是否会被求值。只有当类型含有虚函数时,编译器才会对表达式求值。如果类型不含虚函数,则typeid返回表达式的静态类型。

如果p是一个指向了有虚函数的类的空指针,则typeid(*p)将抛出一个名为bad_typeid的异常。

type_info类

type_info类的精确定义随着编译器的不同而略有差异。不过,C++标准规定type_info类必须定义在typeinfo头文件中,并至少提供下面的操作:

操作 说明
t1 == t2 如果type_info对象t1和t2表示同一种类型,返回true,否则返回false
t1 != t2 和上面相反
t.name() 返回一个C风格字符串,表示类型名字的可打印形式。类型名字的生成方式因系统而异
t.before(t2) 返回一个bool值,表示t1是否位于t2之前。before所采用的顺序关系是依赖于编译器的

我们只能通过typeid运算符创建type_info对象。

枚举类型

枚举类型(enumeration)使我们可以将一组整型常量组织在一起。和类一样,每个枚举类型定义了一种新的类型。枚举属于字面值常量类型。

C++包含两种枚举:限定作用域的和不限定作用域的。

enum class open_modes { input, output, append };        // 限定作用域的枚举类型
enum color { red, yellow, green };                      // 不限定作用域的枚举类型
enum { floatPrec = 6, doublePrec = 10 };                // 未命名的、不限定作用域的枚举类型

枚举成员

如果是对于限定作用域的枚举类型,我们需要通过作用域运算符在外部显式地访问枚举成员。而对于不限定作用域的枚举类型,则没有这个要求。

默认情况下,枚举值从0开始,依次加1。不过我们也能为一个或几个枚举成员指定专门的值,而且值可以重复(不唯一)。

更多关于枚举的讨论见p738。

类成员指针

成员指针(pointer to member)指的是可以指向类的非静态成员的指针。对于指向静态成员的指针和普通的指针没有什么区别。

当初始化一个成员指针时,我们令其指向类的某个成员,但是不指定该成员所属的对象;直到使用成员指针时,才提供成员所属的对象。

为了解释本节的概念,使用下面这个类作为例子:

class Screen {
public:
    typedef std::string::size_type pos;
    char get_cursor() const { return contents[cursor]; }
    char get() const;

private:
    std::string contents;
    pos cursor;
    pos height, width;
};

数据成员指针

我们必须在*之前添加classname::以表示当前定义的指针可以指向classname的成员。例如:

const string Screen::*pdata;

初始化的方式:

pdata = &Screen::contents;

在C++11中声明成员指针最简单的方式是使用auto或decltype:

auto pdata = &Screen::contetns;

使用数据成员指针

必须清楚的一点是:当我们初始化一个成员指针或为成员指针赋值时,该指针并没有指向任何数据。只有当解引用成员指针时我们才提供对象的信息。

Screen myScreen, *pScreen = &myScreen;

auto s = myScreen.*pdata;
s = pScreen->*pdata;

返回数据成员指针的函数

因为数据成员一般情况下是私有的,所以我们通常不能直接获得数据成员的指针。如果一个像Screen这样的类希望我们可以访问它的contents成员,最好定义一个函数,令其返回值是指向该成员的指针:

class Screen {
public:
    static const std::string Screen::*data() {
        return &Screen::contents;
    }
};

当我们调用data函数时,将获得一个成员指针:

const std::string Screen::*pdata = Screen::data();

// 获得myScreen对象的contents成员
auto s = myScreen.*pdata;

成员函数指针

定义成员函数指针最简单的方法是使用auto:

auto pmf = &Screen::get_cursor;

和指向数据成员的指针意义,我们使用classname::*的形式声明一个指向成员函数的指针。

如果成员存在重载的问题,我们必须显式地声明函数类型以明确指出我们想要使用哪个函数。例如,我们可以声明一个指针,令其指向含有两个形参的get:

char (Screen::*pmf2)(Screen::pos, Screen::pos) const;
pmf2 = &Screen::get;

和普通指针不同的是,在成员函数和指向该成员的指针之间不存在自动转换规则:

pmf = &Screen::get;         // 必须显式地使用取地址运算符
pmf = Screen::get;          // 错误,在成员函数和指针之间不存在自动转换规则

使用成员函数指针

和使用指向数据成员的指针一样,我们使用.*或者->*运算符作用于指向成员函数的指针,以调用类的成员函数:

Screen myScreen, *pScreen = &myScreen;
char c1 = (pScreen->*pmf)();
char c2 = (myScreen.*pmf)(0, 0);

之所以上面使用成员指针两边的括号必不可少,是因为调用运算符的优先级更高。

使用成员指针的类型别名

using Action = char (Screen::*)(Screen::pos, Screen::pos) const;

Action get = &Screen::get;

成员指针函数表

对于普通函数指针和指向成员函数的指针来说,一种常见的用法是将其存入一个函数表当中。

将成员函数用作可调用对象

要想通过一个指向成员函数的指针进行函数调用,必须首先利用.*->*将该指针绑定到一个对象上。因此,成员指针不是一个可调用对象,这样的指针不支持函数调用运算符。

使用function生成一个可调用对象

一种方法是使用标准库模板function:

function<bool (const string&)> fcn = &string::empty;
find_if(svec.begin(), svec.end(), fcn);

我们告诉function一个事实:即empty是一个接受string参数并返回bool值的函数。通常情况下,指向成员函数的对象将被传给隐式的this形参。当我们想要使用function为成员函数生成一个可调用对象时,必须首先“翻译”该代码,使隐式的形参变成显式的。

我们提供给function的形式中还必须指明对象是否以指针或引用的形式传入。

vector<string*> pvec;
function<bool (string*)> fp = &string::empty;
find_if(pvec.begin(), pvec.end(), fp);

使用mem_fn生成一个可调用对象

要想使用function,我们必须提供成员的调用形式。我们也可以采取另外一种方法,通过使用标准库功能mem_fcn来让编译器负责推断成员的类型。mem_fcn也定义在functional头文件中,并且可以从成员指针生成一个可调用对象。

find_if(svec.begin(), svec.end(), mem_fcn(&string::empty));

我们使用mem_fcn(&string::empty)生成一个可调用对象,该对象接受一个string实参,返回一个bool值。

mem_fcn生成的可调用对象既可以通过对象调用,也可以通过指针调用:

auto f = mem_fcn(&string::empty);
f(s);
f(ps);

使用bind生成一个可调用对象

我们还可以使用bind从成员函数生成一个可调用对象:

auto it = find_if(svec.begin(), svec.end(), bind(&string::empty, _1));

和function类似的地方是,当我们使用bind时,必须将函数中用于表示执行对象的隐式形参转换成显式的。和mem_fcn类型的是,bind生成的可调用对象的第一个实参可以是对象,也可以是指针。

嵌套类

一个类可以定义在另一个类的内部,前者称为嵌套类(nested class)或嵌套类型(nested type)。嵌套类常用于定义作为实现部分的类。

嵌套类是一个独立的类,与外层类基本没什么关系。

嵌套类的名字在外层类作用域中是可见的,在外层类作用域之外不可见。需要通过访问限定符来访问。

在外层类之外定义一个嵌套类

嵌套类必须声明在类的内部,但是可以定义在类的内部或者外部。具体例子可以参考:p747

union:一种节省空间的类

联合(union)是一种特殊的类。一个union可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当我们给union的某个成员赋值之后,该union的其他成员就变成未定义的状态了。分配给一个union的存储空间至少能容纳它的最大的数据成员。

类的某些特性对union同样适用,但并非所有特性都如此。union不能包含引用类型的成员。

union可以定义包括构造函数和析构函数在内的成员函数。但是由于union既不能继承自其他类,也不能作为基类使用,所以在union中不能含有虚函数。

定义union

union提供了一种有效的途径使得我们可以方便地表示一组类型不同的互斥值。比如:

// Token类型的对象只有一个成员,该成员的类型可能是下列类型中的任意一种
union Token {
    char cval;
    int ival;
    double dval;
};

使用union

union的名字是一个类型名。和其他内置类型一样,默认情况下union是未初始化的。我们可以像显式地初始化聚合类一样使用一对花括号内的初始值显式地初始化一个union。

Token first_token = {'a'};          // 初始化cval成员
Token last_token;                   // 未初始化的Token对象
Token *pt = new Token;              // 指向一个未初始化的Token对象的指针

如果提供了初始值,则该初始值被用于初始化第一个成员。

我们使用通用的成员访问运算符访问一个union对象的成员:

last_token.cval = 'z';
pt->ival = 42;

为union的一个数据成员赋值会令其他数据成员变成未定义的状态。因此,我们使用union时,必须清楚地知道当前存储在union中的值到底是什么类型。

匿名union

一旦我们定义了一个匿名union,编译器就自动为该union创建一个未命名的对象:

union {
    char cval;
    int ival;
    double dval;
};  // 定义了一个未命名的对象,我们可以直接访问它的成员

cval = 'c';
ival = 42;

Note

匿名union不能包含受保护的成员或私有成员,也不能定义成员函数。

含有类类型成员的union

C++11中,如果union的成员类型定义了自己的构造函数和/或拷贝控制成员,则该union的用法将变得很复杂。

我们需要分别构造或析构该类类型的成员:当我们将union的值改为类类型成员对应的值时,必须运行该类型的构造函数;反之,当我们将类类型成员的值改为一个其他值时,必须运行该类型的析构函数。

当union包含的是内置类型的成员时,编译器将按照成员的次序依次合成默认构造函数或拷贝控制成员。但是如果union含有类类型的成员,并且该类型自定义了默认构造函数或拷贝控制成员,则编译器将为union合成对应的版本并将其声明为删除的。

我们通常把含有类类型成员的union内嵌在另一个类当中。这个类管理并控制与union的类类型成员有关的状态转换。具体见书本p751。

局部类

类可以定义在某个函数的内部,我们称这样的类为局部类(local class)。局部类定义的类型只在定义它的作用域内可见。

Note

局部类的所有成员(包括函数在内)都必须完整定义在类的内部。

在实际编程中,因为局部类的成员必须完整定义在类的内部,所以成员函数的复杂性不可能太高。局部类的成员函数一般只有几行代码,否则就很难读懂了。

固有的不可移植的特性

为了支持低层编程,C++定义了一些固有的不可移植(nonportable)的特性。所谓不可移植的特性是指因机器而异的特性,当我们将含有不可移植特性的程序从一台机器转移到另一台机器上时,通常需要重新编写该程序。

本节介绍C++从C语言继承而来的两种不可移植的特性:位域和volatile限定符。详见书本。

链接指示:extern "C"

C++程序有时需要调用其他语言编写的函数,最常见的是调用C语言编写的函数。其他语言中的函数名字也必须在C++中进行声明,并且该声明必须指定返回类型和形参列表。对于其他语言编写的函数来说,编译器检查其调用的方式与处理普通C++函数的方式相同,但是生成的代码有所区别。C++使用链接指示(linkage directive)指出任意非C++函数所用的语言。

声明一个非C++的函数

链接指示可以有两种形式:单个的或复合的。链接指示不能出现在类定义或函数定义的内部。

比如:

// 可能出现在C++头文件<cstring>中的链接指示
// 单语句链接指示
extern "C" size_t strlen(const char*);

// 复合语句链接指示
extern "C" {
    int strcmp(const char*, const char*);
    char *strcat(char*, const char*);
}

Note

有时候可能使用C编译器或C++编译器编译同一个源文件,这时候可以使用预处理器宏 __cplusplus 来条件引入 extern "C" 指示。比如:

#ifdef __cplusplus
extern "C"
#endif
int strlen_custom(const char *str);

更多讨论见书本p759。