C++语言的设计者希望它能够处理各种各样的问题。因此,C++的某些特征可能对于一些特殊的应用非常重要,而在另外一些情况下没什么作用。本章将介绍C++语言的几种未被广泛使用的特征。
某些应用程序对内存分配有特殊的需求,因此我们无法将标准内存管理机制直接应用于这些程序。为了实现这一目的,应用程序需要重载new运算符和delete运算符以控制内存分配的过程。
当我们使用一条new表达式时:
// new表达式
string *sp = new string("a value"); // 分配并初始化一个string对象
string *arr = new string[10]; // 分配10个默认初始化的string对象
实际执行了3个步骤:
当我们使用一条delete表达式删除一个动态分配的对象时:
delete sp; // 销毁*sp,然后释放sp指向的内存空间
delete [] arr; // 销毁数组中的 元素,然后释放对应的内存空间
实际执行了2个步骤:
operate new接口和operate delete接口
标准库定义了operate new函数和operate delete函数的8个重载版本。其中前4个版本可能抛出bad_alloc异常,后4个版本则不会抛出异常:
// 这些版本可能抛出异常
void *operate new(size_t); // 分配一个对象
void *operate new[](size_t); // 分配一个数组
void *operate delete(void*) noexcept; // 释放一个对象
void *operate delete[](void*) noexcept; // 释放一个数组
// 这些版本承诺不会抛出异常
void *operate new(size_t, nothrow_t&) noexcept; // 分配一个对象
void *operate new[](size_t, nothrow_t&) noexcept; // 分配一个数组
void *operate delete(void*, nothrow_t&) noexcept; // 释放一个对象
void *operate delete[](void*, nothrow_t&) noexcept; // 释放一个数组
与析构函数类似,operate delete也不允许抛出异常。当我们重载这些运算符时,必须使用noexcept异常说明符指定其不抛出异常。
标准库函数operator new和operator delete的名字容易让人误解。和其他operater函数不同(比如operator=),这两个函数并没有重载new表达式或delete表达式。实际上,我们根本无法自定义new表达式或delete表达式的行为。
一条new表达式执行的过程总是先调用operator new函数以获取内存空间,然后在得到的内存空间中构造对象。与之相反,一条delete表达式的执行过程总是先销毁对象,然后调用operator delete函数释放对象所占空间。
我们提供新的operator new函数和operator delete函数的目的在于改变内存分配的方式,但是不管怎样,我们都不能改变new运算符和delete运算符的基本含义。
malloc函数与free函数
当你定义了自己的全局operator new和operator delete后,这两个函数必须以某种方式执行分配内存与释放内存的操作。
为此我们可以使用名为malloc和free的函数,C++从C语言中继承了这些函数,并将其定义在cstdlib头文件中。
如下所示是编写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传递一个地址,此时定位new的形式如下:
new (place_address) type
new (place_address) type (initializers)
new (place_address) type [size]
new (place_address) type [size] { braced initializer list }
其中place_address必须是一个指针,同时在initializers中提供一个(可能为空的)以逗号分隔的初始值列表,该初始值列表将用于构造新分配的对象。
运行时类型识别(run-time type identification, RTTI)的功能由两个运算符实现:
当我们将这两个运算符用于某种类型的指针或引用,并且该类型含有虚函数时,运算符将使用指针或引用所绑定对象的动态类型。
dynamic_cast运算符的使用形式如下所示:
dynamic_cast<type*>(e)
dynamic_cast<type&>(e)
dynamic_cast<type&&>(e)
其中,type必须是一个类类型,并且通常情况下该类型应该含有虚函数。第一种形式中,e必须是一个有效指针;第二种形式中,e必须是一个左值;第三种形式中,e不能是左值。
指针类型的dynamic_cast
举个简单例子,假定Base类至少含有一个虚函数,Dervied是Base的公有派生类。如果有一个指向Base的指针bp,则我们可以在运行时将它转换成指向Derived的指针,具体代码如下:
if (Derived* dp = dynamic_cast<Derived*>(bp))
{
// 使用dp指向的Derived对象
} else { // bp指向一个Base对象
// 使用bp指向的Base对象
}
引用类型的dynamic_cast
当对引用的类型转换失败时,程序抛出一个名为std::bad_cast的异常,该异常定义在typeinfo标准库头文件中。
我们可以按照如下的形式改写之前的程序,令其使用引用类型:
void f(const Base& b)
{
try {
const Drivered& d = dynamic_cast<const Derived&>(b);
// 使用b引用的Derived对象
} catch (bad_cast) {
// 处理类型转换失败的情况
}
}
为RTTI提供的第二个运算符是typeid运算符(typeid operator),它允许程序向表达式提问:你的对象是什么类型?
typeid表达式的形式是typeid(e),其中e可以是任意表达式或类型的名字。typeid操作的结果是一个常量对象的引用,该对象的类型是标准库类型type_info或者ytpe_info的公有派生类。
当运算对象不属于类类型或者是一个不包含任何虚函数的类时,typeid运算符指示的是运算对象的静态类型。而当运算对象是定义了至少一个虚函数的类的左值时,typeid的结果直到运行时才会求得。
使用typeid运算符
通常情况下,我们使用typeid比较两条表达式的类型是否相同,或者比较一条表达式的类型是否与指定类型相同:
Derived* dp = new Derived;
Base* bp = dp; // 两个指针都指向Derived对象
// 在运行时比较两个对象的类型
if (typeid(*bp) == type(*dp)) {
// bp与dp指向同一类型的对象
}
// 检查运行时类型是否某种指定的类型
if (typeid(*bp) == type(Derived)) {
// bp实际指向Derived对象
}
注意,typeid应该作用于对象,因此我们使用*bp而非bp:
// 下面的检查永远是失败的:bp的类型是指向Base的指针
if (typeid(bp) == type(Derived)) {
// 此处的代码永远不会执行
}
typeid是否需要运行时检查决定了表达式是否会被求值。只有当类型含有虚函数时,编译器才会对表达式求值。反之,如果类型不含有虚函数,则typeid返回表达式的静态类型;编译器无需对表达式求值也能知道表达式的静态类型。
在某些情况下RTTI非常有用,比如我们想为具有继承关系的类实现相等运算符时。
类的层次关系
为了更好的解释上述概念,我们定义两个示例类:
class Base {
friend bool operator==(const Base&, const Base&);
public:
// Base的接口成员
protected:
virtual bool equal(const Base&) const;
// Base的数据成员和其他用于实现的成员
};
class Derived : public Base {
public:
// Derived的其他接口成员
protected:
bool equal(const Base&) const;
// Derived的数据成员和其他用于实现的成员
};
类型敏感的相等运算符
接下来介绍我们是如何定义整体的相等运算符的:
bool operator==(const Base& lhs, const Base& rhs) {
// 如果typeid不相同,返回false;否则虚调用equal
return typeid(lhs) == typeid(rhs) && lhs.equal(rhs);
}
虚equal函数
bool Derived::equal(const Base& rhs) const
{
// 我们清楚这两个类型是相等的, 所以转换过程不会抛异常
auto r = dynamic_cast<const Derived&>(rhs);
// 执行比较两个Derived对象的操作并返回结果
}
基类equal函数
bool Base::equal(const Base& rhs) const
{
// 执行比较Base对象的操作
}
type_info的操作:
操作 | 含义 |
---|---|
t1 == t2 | type_info对象表示同一类型 |
t1 != t2 | type_info对象表示不同类型 |
t.name() | 表示类型名字的可打印形式 |
t1.before(t2) | 返回一个bool值,表示t1是否位于t2之前 |
枚举类型使我们可以将一组整形常量组织在一起。和类一样,每个枚举定义了一种新的类型。枚举属于字面值常量类型。
C++包含两种枚举:限定作用域的和不限定作用域的。限定作用域:
enum class open_modes {input, output, append};
不限定作用域的枚举类型:
enum color {red, yellow, green}; // 不限定作用域的枚举类型
// 未命名的,不限定作用域的枚举类型
enum {floatPrec = 6, doublePrec = 10, double_doublePrec = 10};
如果enum是未命名的,则我们只能在定义该enum时定义它的对象。
形参匹配与枚举类型
要想初始化一个enum对象,必须使用该enum类型的另一个对象或者它的一个枚举成员。因此,即使某个整形值恰好与枚举成员的值相等,它也不能作为函数的enum实参使用:
// 不限定作用域的枚举类型,潜在类型因机器而异
enum Tokens {INLINE = 128, VIRTUAL = 129};
void ff(Tokens);
void ff(int);
int main() {
Tokens curTok = INLINE;
ff(128); // 精确匹配ff(int)
ff(INLINE); // 精确匹配ff(Tokens)
ff(curTok); // 精确匹配ff(Tokens)
return 0;
}
尽管我们不能直接将整形值传给enum形参,但是可以将一个不限定作用域的枚举类型的对象或枚举成员传给整形形参。此时,enum的值提生成int或更大的整形,实际提升的结果由枚举类型的潜在类型决定:
void newf(unsigned char);
void newf(int);
unsigned char uc = VIRTUAL;
newf(VIRTUAL); // 调用newf(int)
newf(uc); // 调用newf(unsigned char)
成员指针(point to member)是指可以指向类的非静态成员的指针。一般情况下,指针指向一个对象,但是成员指针指示的是类的成员,而非类的对象。类的静态成员不属于任何对象,因此无需特殊的指向静态成员的指针,指向静态成员的指针与普通指针没有什么区别。
成员指针的类型囊括了类的类型以及成员的类型。当初始化这样的一个指针时,我们令其指向类的某个成员,但是不指定该成员所指的对象;直到使用成员指针时,才提供成员所属的对象。
为了解释成员指针的原理,不妨使用Screen类:
class Screen {
public:
typedef std::string::size_type pos;
char get_cursor() const { return contents[cursor]; }
char get() const;
char get(pos ht, pos wd) const;
private:
std::string contents;
pos cursor;
pos height, width;
};
我们必须在*之前添加classname::以表示当前定义的指针可以指向classname的成员。例如:
// pdata可以指向一个常量(非常量)Screen对象的string成员
const string Screen::*pdata;
当我们初始化一个成员指针(或者向它赋值)时,需要指定它所指的成员。例如,我们可以令pdata指向某个非特定Screen对象的contents成员:
pdata = &Screen::contents;
其中,我们将取地址运算符作用于Screen类的成员而非内存中的一个该类对象。
使用数据成员指针
读者必须清楚的一点是,当我们初始化一个成员指针或为成员指针赋值时,该指针并没有指向任何数据。成员指针指定了成员而非该成员所属的对象,只有当解引用成员指针时我们才提供对象的信息。
Screen myScreen, * pScreen = &myScreen;
// .*解引用pdata以获得myScreen对象的contents成员
auto s = myScreen.*pdata;
// ->*解引用pdata以获得pScreen所指对象的contents成员
s = pScreen->*pdata;
返回数据成员指针的函数
class Screen {
public:
// data是一个静态成员,返回一个成员指针
static const std::string Screen::* data() {
return &Screen::contents;
}
// 其他成员与之前版本一致
};
从右往左阅读函数的返回类型,可知data返回的是一个指向Screen类的const string成员的指针。函数体对contents成员使用了取地址运算符,因此函数将返回指向Screen类contents成员的指针。
使用方式:
// data()返回一个指向Screen类的contents成员的指针
const string Screen::*pdata = Screen::data();
// 获得myScreen对象的contents成员
auto s = myScreen.*pdata;
我们也可以定义指向类的成员函数的指针。与指向数据成员的指针类似,对于我们来说创建一个指向成员函数的指针,最简单的方法就是使用auto来推断类型:
// pmf是一个指针,它可以指向Screen的某个常量成员函数
// 前提是该函数不接受任何实参,并且返回一个char
auto pmf = &Screen::get_cursor;
和普通的函数指针类似,如果成员存在重载的问题,则我们必须显示地声明函数类型以明确指出我们想要使用的是哪个函数。例如,我们可以声明一个指针,令其指向含有两个形参地get:
char (Screen::*pmf2) (Screen::pos, Screen::pos) const;
pmf2 = &Screen::get;
使用成员函数指针
Screen myScreen, *pScreen = &myScreen;
// 通过pScreen所指地对象调用pmf所指地函数
char c1 = (pScreen->*pmf)();
// 通过myScreen对象将实参0, 0传给含有两个形参的get函数
char c2 = (myScreen.*pmf2)(0, 0);
使用成员指针的类型别名
使用类型别名或typedef可以让成员指针更容易理解。
// Action是一种可以指向Screen成员函数的指针,它接受两个pos实参,返回一个char
using Action = char (Screen::*) (Screen::pos, Screen::pos) const;
Action get = &Screen::get; // get指向Screen的get成员
// action接受一个Screen的引用,和一个指向Screen成员函数的指针
Screen& action(Screen&, Action = &Screen::get);
Screen myScreen;
// 等价的调用
action(myScreen); // 使用默认实参
action(myScreen, get); // 使用我们之前定义的变量get
action(myScreen, &Screen::get); // 显示地传入地址
如我们所知,要想通过一个指向成员函数的指针进行函数调用,必须首先利用.*运算符或->*运算符将该指针绑定到特定的对象上。因此与普通的函数指针不同,成员指针不是一个可调用的对象,这样的指针不支持函数调用运算符。
auto fp = &string::empty; // fp指向string的empty函数
// 错误,必须使用.*或->*调用成员指针
find_if(svec.begin(), svec.end(), fp);
// 检查对当前元素的断言是否为真
if (fp(*it)) // 错误:要想通过成员指针调用函数,必须使用->*运算符
使用function生成一个可调用对象
从指向成员函数的指针获取可调用对象的一种方式是使用标准库模板function:
function <bool (const string&)> fcn = &string::empty;
find_if(svec.begin(), svec.end(), fcn);
我们告诉function一个事实:即empty是一个接受string参数并返回bool值的函数。通常情况下,执行成员函数的对象将被传给隐式的this形参。当我们想要使用function为成员函数生成一个可调用对象时,必须首先“翻译”该代码,使得隐式的形参变成显示的。
使用mem_fn生成一个可调用的对象
mem_fn生成的可调用对象可以通过对象调用,也可以通过指针调用:
auto f = mem_fn(&string::empty); // f接受一个string或者一个string*
f(*svec.begin()); // 正确:传入一个string对象,f使用.*调用empty
f(&svec[0]); // 正确:传入一个string的指针,f使用->*调用empty
使用bind生成一个可调用对象
处于完整性的考虑,我们还可以使用bind从成员函数生成一个可调用对象:
// 选择范围中的每个string,并将其bind到empty的第一个隐式实参上
auto it = find_if(svec.begin(), svec.end(), bind(&string::empty, _1));
和function类似的地方是,当我们使用bind时,必须将函数中用于表示执行对象的隐式形参转换成显示的。和mem_fn类似的地方是,bind生成的可调用对象的第一个实参既可以是string的指针,也可以是string的引用:
auto f = bind(&string::empty, _1);
f(*svec.begin()); // 正确:实参是一个string,f使用.*调用empty
f(&svec[0]); // 正确:实参是一个string的指针,f使用->*调用empty
一个类可以定义在另一个类的内部,前者称为嵌套类或者嵌套类型。嵌套类常用于定义作为实现部分的类。
嵌套类是一个独立的类,与外层类基本没有什么关系。特别的是,外层类的对象和嵌套类的对象是相互独立的。在嵌套类的对象中不包含任何外层类定义的成员;类似的,在外层类的对象中也不包含任何嵌套类定义的成员。
声明一个嵌套类
class TextQuery {
public:
class QueryResult; // 嵌套类稍后定义
// 其他成员
};
在外层类之外定义一个嵌套类
我们在TextQuery内声明了QueryResult,但是没有给出它的定义。和成员函数一样,嵌套类必须声明在类的内部,但是可以定义在类的内部或外部。
当我们在外层之外定义一个嵌套类时,必须以外层类的名字限定嵌套类的名字:
// QyeryResult是TextQuery的成员,下面的代码负责定义QueryResult
class TextQuery::QueryResult {
// 位域类的作用域内,因此我们不必对QueryResult形参进行限定
friend std::ostrem& print(std::ostream&, const QueryResult&);
public:
// 无需定义QueryResult::line_no
// 嵌套类可以直接使用外层类的成员,无需对该成员的名字进行限定
QueryResult(std::string,
std::shared_ptr<std::set<line_no>>,
std::shared_ptr<std::vector<std::string>>);
定义嵌套类的成员
在这个版本的QueryResult类中,我们并没有在类的内部定义其构造函数。要为其定义构造函数,必须指明QueryResult是嵌套在TextQuery的作用域之内的。具体做法是使用外层的名字限定嵌套类的名字:
// QueryResult类嵌套在TextQuery类中
// 下面的代码为QueryResult类定义名为QueryResult的成员
TestQuery::QueryResult::QueryResult(string s,
shared_ptr<set<line_no>> p,
shared_ptr<vector<string>> f) :
sought(s), lines(p), file(f) { }
联合(union)是一种特殊的类。一个union可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当我们给union的某个成员赋值之后,该union的其他成员就变成未定义的状态了。分配给一个union对象的存储空间至少要能容纳它的最大的数据成员。和其他类一样,一个union定义了一种新类型。
定义union
union提供了一种有效的途径使我们可以方便地表示一组类型不同的互斥值。
// Token类型的对象只有一个成员,该成员的类型可能是下列类型中的任意一种
union Token {
// 默认情况下成员是共有的
char cval;
int ival;
double dval;
};
使用union类型
Token first_token = { 'a' }; // 初始化cval成员
Token last_token; // 未初始化的Token对象
Token* pt = new Token; // 指向一个未初始化的Token对象的指针
如果提供了初始值,则该初始值被用于初始化第一个成员。因此,first_token的初始化过程实际上是给cval成员赋了一个初值。
我们使用通用的成员访问运算符访问一个union对象的成员:
last_token.cval = 'z';
pt->ival = 42;
为union的一个数据成员赋值都会令其他数据成员变成未定义的状态。
类可以定义在某个函数的内部,我们称这样的类为局部类(local class)。局部类定义的类型只能在它的作用域内可见。和嵌套类不同,局部类的成员受到严格限制。
在实际编程的过程中,因为局部类的成员必须完整定义在类的内部,所以成员函数的复杂性不可能太高。局部内的成员函数一般只有几行代码,否则我们就很难读懂它了。
类似的,在局部类中也不允许声明静态数据成员,因为我们没法定义这样的成员。
局部类不能使用函数作用域中的变量
局部类对其外层作用域中名字的访问权限收到很多限制,局部类只能访问外层作用域定义的类型名,静态变量以及枚举类型。如果局部类定义在某个函数内部,则该函数的普通局部变量不能被该局部类使用:
int a, val;
void foo(int val)
{
static int si;
enum Loc { a = 1024, b };
// Bar是foo的局部类
struct Bar {
Loc locVal; // 正确:使用一个局部类型名
int barVal;
void fooBar(Loc l = a) // 正确:默认实参是Loc::a
{
barVal = val; // 错误:val是foo的局部变量
barVal = ::val; // 正确:使用一个全局对象
barVal = si; // 正确:使用一个静态局部对象
locVal = b; // 正确:使用一个枚举成员
}
};
// ...
}
为了支持低层编程,C++定义了一些固有的不可移植(nonportable)的特性。所谓不可移植的特性是指因机器而异的特性,当我们将不可移植特性的程序从一台机器转移到另一台机器上时,通常需要重新编写该程序。算术类型的大小在不同机器上不一样,这是我们使用过的不可移植特性的一个典型示例。
本节将介绍C++从C语言继承而来的另外两个不可移植的特性:位域和volatile限定符。此外,我们还将介绍链接指示,它是C++新增的一种不可移植的特性。
类可以将其(非静态)数据成员定义成为位域(bit-field),在一个位域中含有一定数量的二进制位。当一个程序需要向其他程序或硬件设备传递二进制数据时,通常会用到位域。
位域的类型必须是整形或枚举类型。因为带符号位域的行为是由具体实现确定的,所以在通常情况下我们使用无符号类型使用一个位域。位域的声明形式是在成员名字之后紧跟着一个冒号以及一个常量表达式,该表达式用于指定成员所占的二进制位数:
typedef unsigned int Bit;
class File {
Bit mode : 2; // mode占2位
Bit modified : 1; // modified占1位
Bit prot_owner : 3; // prot_owner占3位
Bit prot_group : 3; // prot_group占3位
Bit prot_world : 3; // prot_world占3位
// File 的操作和数据成员
public:
// 文件类型以八进制的形式表示
enum modes { READ = 01, WRITE = 02, EXECUTE = 03 };
File& open(modes);
void close();
void write();
bool isRead() const;
void setWrite();
};
直接处理硬件的程序常常包含这样的数据元素,它们的值由程序直接控制之外的过程控制。例如,程序可能包含一个由系统时钟定时更新的变量。当对象的值可能在程序的控制或检测之外改变时,应该将该对象声明为volatile。关键字volatile告诉编译器不应对这样的变量进行优化。
volatile限定符的用法和const很相似,它起到对类型额外修饰的作用:
volatile int display_register; // 该int值可能发生改变
volatile Task *curr_task; // curr_task指向一个volatile对象
volatile int iax[max_size]; // iax的每个元素都是volatile
volatile Screen bitmapBuf; // bitmapBuf的每个成员都是volatile
C++程序有时需要调用其他语言编写的函数,最常见的是调用C语言编写的函数。C++使用链接指示(linkage directive)指出任意非C++函数所用的语言。
声明一个非C++的函数
链接指示可以有两种形式:单个的或复合的。链接指示不能出现在类的定义或函数定义的内部。同样的链接指示必须在函数的每个声明中都出现。
举个例子,接下来的声明显示了cstring头文件的某些C函数是如何声明的:
// 可能出现在C++头文件中的链接指示
// 单语句链接指示
extern "C" size_t strlen(const char *);
// 复合语句链接指示
extern "C" {
int strcmp(const char*, const char*);
char* strcat(char*, const char*);
}
指向extern ”C“函数的指针
// pf指向一个C函数, 该函数接受一个int返回void
extern "C" void (*pf) (int);
当我们使用pf调用函数时,编译器认定当前调用的是C函数。
void (*pf1) (int); // 指向一个C++函数
extern "C" void (*pf2) (int); // 指向一个C函数
pf1 = pf2; // 错误:pf1和pf2的类型不同
导出C++函数到其他语言
通过使用链接指示对函数进行定义,我们可以令一个C++函数在其他语言编写的程序中可用:
// calc函数可以被C程序调用
extern "C" double calc(double dparm) { /* ... */ }
编译器将为该函数生成适合于指定语言的代码。
值得注意的是,可被多种语言共享的函数的返回类型或形参类型受到很多限制。例如,我们不太可能把一个C++类的对象传递给C程序,因为C程序根本无法理解构造函数,析构函数以及其他类特有的操作。
C++为解决某些特殊问题设置了一系列特殊的处理机制。
有的程序需要精确控制内存分配过程,它们可以通过在类的内部或在全局作用域中自定义operator new和operator delete来实现这一目的。如果应用程序为这两个操作定义了自己的版本,则new和delete表达式将优先使用应用程序定义的版本。
有的程序需要在运行时直接获取对象的动态类型,运行时类型识别(RTTI)为这种程序提供了语言级别的支持。RTTI只对定义了虚函数的类有效;对没有定义虚函数的类,虽然也可以得到其类型信息,但只是静态类型。
当我们定义指向类成员的指针时,在指针类型中包含了该指针所指成员所属类的类型信息。成员指针可以绑定到该类当中任意一个具有指定类型的成员上。当我们解引用成员指针时,必须提供获取成员所需的对象。
C++定义了另外几种聚集类型:
- 嵌套类,定义在其他类的作用域中,嵌套类通常作为外层类的实现类。
- union,是一种特殊的类,它可以定义几个数据成员但是任意时刻只有一个成员有值,union通常在其他类的内部。
- 局部类,定义在函数的内部,局部类的所有成员都必须定义在类内,局部类不能含有静态数据成员。
C++支持几种固有的不可移植的特性,其中位域和volatile使得程序更容易访问硬件;链接指示使得程序更容易访问用其他语言编写的代码。