十七、用于大型程序的工具
1. 优化内存分配
C++类型分配是一个类型化操作:new为特定类型分配内存,并在新分配的内存中构造对象。new表达式会为每个动态分配的类自动调用构造函数。但有些时候需要将内存分配与对象构造分开,因为对预先分配,但以后却不使用的对象进行构造很是浪费的。
C++提供两种方法,分配和释放未构造的的原始内存:
1:allocator类,它提供可感知类型的内存分配。这个类使用allocate成员分配内存,使用deallocate成员释放内存。
2:标准库中的operator new 和operator delete,它们分配和释放需要大小的原始的、未类型化的内存。
C++还提供不同的方法,在原始内存中构造和撤销对象。
1:allocator类定义了名为construct和destroy的成员,其操作正如它们的名字所指出的那样。construct成员在未构造的内存中调用复制构造函数初始化对象。destroy成员在对象上运行适当的析构函数。
2:定位new表达式(placement new experssion)接受指定未构造内存的指针,并在该空间初始化一个对象或数组。
3:可以直接调用对象的析构函数来撤销对象。运行析构函数不释放对象所在的内存。
4:算法uninitialized_fill和uninitialized_copy与fill和copy类似。只是它们在给定地址调用复制构造函数构造对象。
现代C++一般应该使用allocator来分配内存。它更安全更灵活。但是在构造对象时用new表达式比allocator::construct成员更灵活。且有几种情况必须使用new。
allocator类是一个模板,它将内存的分配和对象构造分开。当allocator对象分配内存的时候,它分配适当大小指定类型对象的空间。但是它分配的空间是未构造的,allocator的用户必须分别使用construct和destroy构造和析构对象。
allocator<T> a定义名为a的allocator对象,它用于分配内存或构造T类型的对象。
allocate(n); 分配原始的、未构造的内存,保存T类型的n个对象。
deallocate(p,n); 释放内存,在类型为T*的指针p指向的地址,保存着n个对象,运行deallocate之前调用destroy是用户的责任。传给deallocate一个零指针是不合法的
a.contruct(p,t); 在T*类型指针p所指向的内存中构造一个新元素。运行T类型的复制构造函数用t初始化该对象。
a.destroy(p); 运行T*类型指针p所指向对象的析构函数。
uninitialized_fill(b,e,t); 将由迭代器b和e标记的范围的对象,初始化为t的副本,它是用复制构造函数构造对象。
uninitiated_copy(b,e,b2)从迭代器b和e指出的输入范围,将元素复制到从迭代器b2开始的,未构造的原始内存中。该函数在目的地构造元素,而不是给它们赋值。假定b2指出的目的地址足以保存输入范围中元素的副本。
示例:vector的实现
当使用new操作符时,实际发生了三个步骤:1:调用名为operator new的标准库函数。分配足够大的原始的未类型化的内存,以保存指定类型的对象。2:运行该类型的一个构造函数,用指定初始化式构造对象。3:返回指向新分配并构造对象的指针。
使用delete操作符时,发生两个步骤:1:对指向的对象运行析构函数。2:调用名为operator delete的标准库函数释放该对象的内存。
operator new与operator delete没有重载new或delete表达式。
operator new和operator delete有两个不同的版本。每个版本支持相关的new表达式和delete表达式。
void *operator new(size_t); // 分配一个对象
vodi *operator new [](size_t); // 分配一个数组
void operator delete(void*);
void operator delete[](void*);
通常operator new和operator delete的设计意图,是供new和delete表达式使用,但是我们仍然可以使用它们获得未构造的内存。这与allocator的allocate和deallocate功能相同。
如:
T* newelements=alloc.allocate(num);
用operator new替代为:T *newelements=static_cast<T*> (operator new[] (num*sizeof(T));
alloc.deallocate(elements,end-elements);
用operator delete替换为:operator delete[](elements);
注意operator new和operator delete与allocate和deallocate的区别就是:它们在void*的指针上进行操作。而allocate和deallocate在类型为T的指针上进行操作。
allocator分配类型化的内存,无须转换因此allocator比直接使用operator new和operator更为类型安全。
定位new表达式:
定位new表达式在已分配的原始内存中初始化一个对象。它与new的其他版本的不同之处在于:它不分配内存。它的形式为:
new(place_address) type
new(place_address)类型(初始化列表)
其中place_address必须为一个指针,初始化表是在构造新分配的对象时使用的。
Alloc.construct(first_free,t); 用定位new替代为:new(first_free)T(t);
使用定位new表达式比使用allocator类的construct成员更灵活。因为定位new在初始化一个对象的时候,它可以使用任何构造函数,而construct函数总是使用复制构造函数。
析构函数可以被显式调用,如p->~T();它调用类型T的析构函数,适当的清楚对象本身,但是没有释放对象所占内存。注意:调用operator delete不会运行析构函数,它只释放指定内存。
默认情况下new表达式通过由标准库定义的operator new版本分配内存,通过自定义的名为operator new和operator delete的成员函数,类可以管理应用于自身类型的内存。
编译器在看到类类型的new或delete表达式时,它查看该类是否有operator new或operator delete成员。如果该类定义或继承了自己的operator new和operator delete函数,则使用它们为对象分配和释放内存。否则调用标准库的版本。
自定义的operator new和operator delete默认为静态的。不必显式的声明为static,编译器默认将它们视为static函数。因为它们要么在构造对象之前使用,要么在撤销对象之后使用,因此,这些函数不依赖类的对象而存在。
当类定义了自己的operator new和operator delete,标准库的operator new和operator delete就被屏蔽。但是可以通过全局作用域操作符强制new或delete表达式使用全局的库函数。如:
T*p=::new T;
::delete p;
一个内存分配器基类可以预先分配一块原始内存,来保存未构造的对象。创建新元素时,可以在预先分配的内存中构造,释放元素时将它们放回预先分配对象的块中,而不是将内存实际返还给系统。这种策略常被称为维持一个自由列表(freelist)。
2. 运行时类型识别(RTTI)
通过运行时类型识别(RTTI),程序能够使用基类的指针或引用来检查这些指针或引用所指向对象的实际类型。
通过两个操作符提供RTTI:
1:typeid操作符,它返回指针或引用所指对象的实际类型。如typeid(*p); typeid(ref);
2:dynamic_cast操作符,将基类类型的指针或引用安全的转换为派生类类型的指针或引用。
只有当类带有一个或多个虚函数时,这些操作符在运行时才返回动态类型信息。其他类型则在编译过程中,返回静态类型信息。
当基类指针需要调用派生类特有的操作时,需要动态强制类型转换。但是要特别注意:程序员必须知道应该将对象强转为哪种类型,并且检查转换是否成功。使用这种机制很容易出错。
dynamic_cast:
dynamic_cast操作符用于将基类类型的对象的引用或指针,转换为同一继承层次的 ,派生类类型的引用或指针。此操作符涉及运行时类型检查,判断绑定到引用或指针的对象是不是目标类型的对象。如果转换到指针的dynamic_cast 失败,则返回0值。如果转换到引用的dynamic_cast失败,则抛出一个bad_cast异常。
假定base是至少带一虚函数的类。并且derived继承自base。
如:
if(derived*dptr=dynamic_cast<derived*>(pbase))
{
//转换成功,dptr指向派生类对象。
}
else
{
//转换失败,使用pbase指向的基类对象。
}
在运行过程中,如果bptr实际指向derived对象,则转换成功。并且dptr被初始化为derived对象。否则转换的结果为0。转换成功后,dptr就可以访问派生类中特有的成员。
对值为0的指针应用dynamic_cast,这样做的结果是0。 调用dynamic_cast进行转换后,必须判断是否转换成功。防止转换失败被误用。
在转换引用时,因为不存在空引用,因此不能对引用,使用用于指针强制类型转换的检查策略。当失败时,它抛出std::bad_cast异常。该异常在库头文件typeinfo中定义。
typeid操作符:
typeid操作符能问一个表达式你是什么类型。如typeid(e);
如果表达式是类类型且包含一个或多个虚函数,则表达式的动态类型不同于它的静态编译类型。如:如果表达式对基类指针解引用,则该表达式的静态编译时类型是基类类型,但是如果指针实际指向派生类对象,则typeid操作符返回派生类的类型。
typeid操作符可以与任何类型的表达式一起使用。内置类型以及常量都可以作为它的操作数。如果操作数不是类类型或是没有虚函数的类,则typeid操作符指出操作数的静态类型。typeid的操作符返回名为type_info标准库类型对象的引用 。
如:
Base *bp;
Derived *dp;
if(typeid(*bp) == typeid(*dp)){}
if(typeid(*bp) == typeid(Derived)){} // 注意typeid()中不是指针
typeid(p) 当p是指针时将返回指针的静态类型。所以在判断某一指针所指对象的类型时应使用*p。
如果指针p为0,且p指向带虚函数的类型,则typeif(*p)将抛出一个bad_typeid异常。
type_info类的确切定义随编译器而变化。
typeid返回type_info对象。该对象不能定义、复制或赋值。唯一产生该对象的方法是使用typeid操作符 。name成员函数返回类型名c风格字符串。
3. 类成员的指针
成员指针包括成员变量指针和成员函数指针。它可以绑定到成员变量或成员函数,通过指针对成员变量或成员函数进行间接操作。
如:
class Test
{
public :
int i;
void print();
int show(const string &,conststring&);
};
我们可以定义指向Test的成员变量i的成员指针,形如:
int Test::*pi=&Test::i;
成员函数的指针必须在以下三个方面与所指函数类型匹配:
1:函数形参类型和数目,包括成员是否为const。
2:返回类型。
3:所属类的类型。
也可以定义指向Test的成员函数print和show的指针,形如:
void (Test::*pprint)()=&Test::print;
int (Test::*pshow)(const string &,conststring&);
pshow=&Test::show;
由于调用操作符()的优先级高于成员指针操作符,因此包围Test::*的括号是必须的。
由于成员函数中指针定义太长,我们可以使用typedef来定义别名。如
typedef int (Test::*alias)(conststring&,const string &);
定义后,alias是Test类的接受两个const string类型引用的形参并返回int类型的成员指针的别名。可以使用它来声明函数形参和函数返回类型。
可以使用.*和->*来调用类成员指针。如
void (Test::*pprint)()=&Test::print;
int (Test::*pshow)(const string&,conststring&)=&Test::pshow;
int (Test::*pi)=&Test::i;
Test t;
Test *pt=&t;
t.*pi=20;
pt->*pi=39;
(t.*pprint)();//调用操作符的优先级很高,注意括号。
(pt->*pshow)(“Hello”,”world!”);
函数指针和成员函数指针一个公共用途是将它们存在函数表中。函数表存储相同类型的函数指针。一般用数组实现。
4. 嵌套类
可以在一个类内部定义另一个类。这样的类称为嵌套类(nested class)。也称为嵌套类型。嵌套类最常用于定义执行类。外围类与嵌套类的对象时相互独立的。
嵌套类不具备外围类定义的成员,外围类的成员也不具备嵌套类所定义的成员 。
嵌套类的名字在其外围类的作用域中可见。它与外围类定义的类型成员一样。当其为public时,可以在外围类之外访问它,当其为protected时,只能在外围类、外友元或是外围类的派生类访问它。
可以在外围类内部定义并实现嵌套类。当然也可以在外围类的外部定义嵌套类的成员。注意:嵌套类的成员不能定义在外围类的内部。
另外一种定义嵌套类的方法为:在嵌套类中声明嵌套类,但是在外围类外部定义整个类。如:
class outer
{
public:
outer(){}
class inner;
};
class outer::inner
{
inner() { i=0;}
void show()//当然,也可以在外部定义。
{ }
};
如果嵌套类有一个静态成员,则它的定义要在嵌套类外部:
int outer::inners=20;
虽然嵌套类与外围类互相独立,但是嵌套类可以直接引用外围类的静态成员、类型名和枚举成员。
5. 联合:节省空间的类
联合是一种特殊的类。一个union对象可以有多个成员,但只能有一个成员有值。当一个值赋给其中一个成员时,其他成员都变为未定义的。
union对象所占空间至少与最大数据成员一样大。
union的定义以关键字union开始,后接可选的union名字和一对以分号结尾的花括号。
union TokenValue{
char cval;
int ival;
double dval;
}
union可以跟类一样指定保护标记,使成员成为公用的、私有的或受保护的。默认情况下 ,union表现的像struct。
union不能有静态数据成员、引用成员或定义了构造函数、析构函数或赋值操作符类类型成员。
union illegal_members{
Screen s;
static int is;
int &dval;
Screen *ps; // ok
}
默认情况下,union是未初始化的,可以显式初始化,但是只能为第一个成员提供初始化式。
union经常用作嵌套类型:用个判别式(discriminant)来跟踪存储了什么值。
class Token{
public:
enum TokenKind{INT, CHAR, DBL};
TokenKind tok;
union {
char cval;
int ival;
double dval;
} val;
}
未给union对象命名的的union成为匿名联合。由于没提供访问其成员的途径,匿名联合的名字出现在外围作用域中。供外围作用域直接访问。
6. 局部类(local class)
在函数体内定义的类称为局部类。局部类定义了一个类型,但是只能在定义它的局部作用域中可见。
与嵌套类不同,局部类是严格受限的。局部类的所有成员必须完全定义在类定义体内部。因此成员函数一般仅有数行代码。
局部类不能有static成员,且不能访问函数作用域中的变量。
局部类只能访问外围作用域中的类型名、static变量和枚举成员,不能使用定义该类的函数中的变量。
外围类不能访问局部类的private成员。实际上,局部类中的private成员通常是没有必要的。一般局部类的所有成员都是pubic的。
也可以将一个类嵌套在局部类的内部。此时,嵌套类定义可以出现在局部类定义体之外,函数内部。
7. 固有的不可移植的特征
C++从c语言继承而来的不可移植的特征:位域、volatile限定符以及链接指示。
位域(bit-field):特殊的类数据成员,保存特定的位数。
当程序需要将二进制数据传递给另一程序或硬件设备的时候,通常使用位域。位域在内存中的布局是机器相关的。
位域必须是整型数据类型,可以使signed或unsigned。通过在成员名后面接一个冒号以及指定位数的常量表达式,指出成员是一个位域。
typedef unsigned int Bit;
class File{
Bit mode : 2;
Bit modified : 1;
}
关键字volatile给编译器的指示,指出对这个对象不应该执行优化。
如:
volatile int i=10;
int j = i;
int k = i;
volatile 告诉编译器i是随时可能发生变化的,每次使用它的时候必须从i的地址中读取,因而编译器生成的可执行码会重新从i的地址读取数据放在k中。
而优化做法是,由于编译器发现两次从i读数据的代码之间的代码没有对i进行过操作,它会自动把上次读到寄存器的数据放在k中。而不是重新从i里面读到寄存器中。这样一来,如果i是一个寄存器变量或者表示一个端口数据就容易出错,所以说volatile可以保证对特殊地址的稳定访问,不会出错。
volatile限定符与指针的相互作用,与const与指针的相互作用相同。extern 有两种含义:
1:被extern "C"限定的函数或变量是extern类型的;
extern是C/C++语言中表明函数和全局变量作用范围(可见性)的关键字,该关键字告诉编译器,其声明的函数和变量可以在本模块或其它模块中使用。记住,下列语句:extern int a;仅仅是一个变量的声明,其并不是在定义变量a,并未为a分配内存空间。变量a在所有模块中作为一种全局变量只能被定义一次,否则会出现连接错误。
通常,在模块的头文件中对本模块提供给其它模块引用的函数和全局变量以关键字extern声明。例如,如果模块B欲引用该模块A中定义的全局变量和函数时只 需包含模块A的头文件即可。这样,模块B中调用模块A中的函数时,在编译阶段,模块B虽然找不到该函数,但是并不会报错;它会在连接阶段中从模块A编译生 成的目标代码中找到此函数。
2: 被extern "C"修饰的变量和函数是按照C语言方式编译和连接的;
作为一种面向对象的语言,C++支持函数重载,而过程式语言C则不支持。函数被C++编译后在符号库中的名字与C语言的不同。例 如,假设某个函数的原型为:
void foo( int x, int y );
该函数被C编译器编译后在符号库中的名字为_foo,而C++编译器则会产生像_foo_int_int之类的名字(不同的编译器可能生成的名字不同,但是都采用了相同的机制,生成的新名字称为“mangled name”)。_foo_int_int这样的名字包含了函数名、函数参数数量及类型信息,C++就是靠这种机制来实现函数重载的。例如,在C++中,函 数void foo( int x, int y )与void foo( int x, float y )编译生成的符号是不相同的,后者为_foo_int_float。
C++程序有时需要调用其他程序设计语言编写的函数,最常见的是c语言,编译器按处理普通C++函数一样的方式检查对外部语言函数的调用,但是编译器必须产生不同的代码来调用其他语言编写的函数。C++使用链接指示指出任意非C++函数所用的语言。
链接指示不能出现在类或函数定义的内部,它必须出现在函数的第一次声明上。
链接指示有两种形式:
1:单个的:如:
extern “C” size_t strlen(const char*);
由关键字extern后接字符串字面值,再接普通的函数声明。
2复合的。
extern “C”//以C语言的方式使用此函数。
{
intstrcmp(const char*,const char*);
char*strcat(char *,const char *);
}
通过将几个函数的声明放在链接指示之后的花括号内部。花括号的作用是将应用链接指示的声明聚合起来。
也可以将多重声明形式应用于整个头文件,如
extern “C”//如果使用的是C++的方式链接这个C库文件的话,那么就会出现链接错误.
{
#include<string.h>
}
通过对函数定义使用链接指示,使得其他语言可以使用C++函数:
extern “C” double calc(double val)
{
}
当编译器为该函数产生代码时,它将产生适用于指定语言的代码。
C函数的指针与C++函数的指针具有不同的类型,不能将C函数的指针初始化或赋值为C++函数的指针,反之亦然。