ANSI/ISO C++ Professional Programmer's Handbook(13)

 摘自: http://sttony.blogspot.com/search/label/C%2B%2B

 

13


与C语言的兼容性问题


by Danny Kalev




简介


C是C++的子集。理论上讲。每一个有效的C 程序也是有效的C++ 程序。但是实际上仍然有微妙的不兼容性,并且在两种语言表面上相同的部分也有不相同的部分。大多数不同可以被编译器检查到。其他是可以避免的,在非常罕见的情况下,他们可能产生令人惊讶的效果。


尽管可能在大多数情况下需要将旧的C代码和新的C++代码一起使用,有时也会反过来:C++代码在基于C的应用程序中使用。例如,用C写的关系数据库中事务处理监视器和用C++写的模块结合使用。本章首先讨论ISO C 和ANSI/ISO C++的C子集之间的区别,并示范如何将旧的C代码移植到C++环境中去。之后,在带你探索C++对象模型的底层机制,包括对象的内存布局、成员函数、虚成员函数、虚基类和存取限制,你将学习如何用C代码去存取C++对象。


ISO C 和ANSI/ISO C++的C子集之间的区别


除了一些微不足道不同,C++是C的超集。下面的章节指出C++的子集和ISO C之间的区别。


函数形参列表


在以前的C标准中,函数的形参列表可以这样申明:



/* 以前标准的C, 在ISO C中仍然有效,在C++中无效*/
int negate (n)
int n; /* 形参的申明出现在这里*/
{
return -n;
}

换句话说,仅有形参的名字出现在圆括号内,而他们的类型在后面申明。未申明的形参的默认类型是int。在ISO C中,与C++中一样,名字和类型都必须出现在括号中:



/* ISO C and C++ */
int negate (int n)
{
return -n;
}

旧风格的形参列表在ISO C仍然是合法的,但是是被反对的。在C++中,它是非法的。包含旧风格的形参列表的C 代码必须修改以在C++中有效。


函数申明


在C中,函数可以没有先申明就调用。在C++中,没有申明函数就不能调用。例如



/* 在C中有效但在C++中无效 */
int main()
{
int n;
n = negate(5); /* 未申明的函数;在C中有效但在C++中无效 */
return 0;
}

在C中函数可以被申明,在C++中必须这样:



/* C/C++ */
int negate(int n);
int main()
{
int n;
n= negate(5);
return 0;
}

在C中推荐用函数申明(也叫函数原型),因为它使编译器能检查到类型和实参数量的错误。但是,它不是强制的。


空的形参列表


在C中,申明未空形参列表的函数可以这样



int f();
void g( int i)
{
f(i) /* 在C中有效,在C++无效 */
}

可以接受任意类型和任意数量的实参。在C++中,这样的函数不能接受任何实参。


隐含的int 申明


在C 和以前标准的C++中,没有申明变量的默认类型是int。例如



/* 在C中有效,在C++无效 */
void func()
{
const k =0; /*在C中假定是int 类型,在C++中为非法*/
}

现在修订的ISO C也禁止了隐含int 申明。


全局变量的重复申明


在C中,全局可以不用extern修饰符就申明多次。只要所有的变量只有一个初始化,连接器解决所有的重复申明:



/* 在C中有效,在C++无效 */
int flag;
int num;
int flag; /* 全局变量的重复申明 */
void func()
{
flag = 1;
}

在C++中,一个实体必须只定义一次。在不同的translation units中重复申明同一实体导致连接错误。


隐含的转换void 指针


在C中,void指针可以隐含的转换成其他任何类型的指针。例如



/* 在C中有效,在C++无效 */
#include <stdlib.h>
long * p_to_int()
{
long * pl = malloc(sizeof(long)); /* 隐含的将void* 转换成long* */
return pl;
}

一般情况下,隐含的void *的转换是不受欢迎的,因为它肯能导致不能被编译器检查的错误。考虑下面的例子:



/* 在C中有效,在C++无效 */
#include <stdlib.h>
long * p_to_int()
{
long * pl = malloc(sizeof(short)); /* 糟糕! */
return pl;
}

在C++中,void 指针必须显式的转换成所需的类型。显式转换使的程序员的意图更明显,并且减少了错误的可能。


NULL指针的底层表示


NULL是一个依赖编译器的constnull指针。C的实现常是这么定义NULL的:



#define NULL ((void*)0)

但是在C++中,NULL常被定义成字面上的00L,但是从不会是void *



const int NULL = 0; //有些C++ 编译器使用这个约定
#define NULL 0; //其他使用这个习惯

C和C++的NULL指针的不同的底层机制,源于C++的指针是强壮类型而C的不是。如果C++保留了C的习惯,一个向这样的C++语句



char * p = NULL;

就成了这样



char * p = (void*) 0; //编译期错误:矛盾的指针类型

因为0是C++中所有类型指针的通用初始化算子,它用来代替传统的C习惯;事实上许多程序员简单的用00L来代替NULL


全局变量的默认连接类型


在C中,全局默认const变量的默认连接类型是extern。为初始化的const变量被隐含的初始化为0。例如



/*** 在C中有效,在C++无效***/
/* file error_flag.h */
const int error; /*默认extern连接*/
/*** end file ***/
#include"error_flag.h"
int func();
int main()
{
int status = func();
if( status == error)
{
/*do something */
}
return 0;
}

在C++中,没有显式申明为extern的全局const变量是静态连接的。另外,一个const变量必须初始化。


以Null结尾的字符数组


在C中,字符数组可以用字面上的字符初始化,而不需要以NULL结尾。例如



/*** 在C中有效,在C++无效 ***/
const char message[5] = "hello"; /* 不包括 null结束标志 */

在C++中,通过字面上的串初始化的字符数组必须要容纳NULL结束标志。


将整数赋值给枚举类型


在C中,将整数赋值给枚举the assignment of integers to an enumerated type is valid. For example



/*** 在C中有效,在C++无效***/
enum Status {good, bad};
void func()
{
Status stat = 1; /* 整数赋值*/
}

在C++中,enum是一种强壮类型。仅有同一个enum类型的枚举子(enumerators)可以赋值给enum变量。否则就需要类型转换。例如



//C++
enum Status {good, bad};
void func()
{
Status stat = static_cast<Status> (1); // stat = bad
}

在函数形参列表中和返回类型中定义结构


在C中,结构可以在函数的形参表和返回类型中定义。例如



/*** 在C中有效,在C++无效 ***/
/* 在返回类型和形参列表中定义结构*/
struct Stat { int code; char msg[10];}
logon (struct User { char username[8]; char pwd[8];} u );

在C++中,这是非法。


迂回初始化


一个jump无条件的跳转控制流程。一个jump经常是:一个goto语句、跳转到case标签的条件switch语句、break语句、continue语句或return语句。在C中,变量的初始化可能被jump 跳过,就像在下面的例子中一样:



/*** 在C中有效,在C++无效 ***/
int main()
{
int n=1;
switch(n)
{
case 0:
int j=0;
break;
case 1: /* 跳过了j的初始化 */
j++; /* 未定义的*/
break;
default:
break;
}
return 0;
}

在C++中,迂回初始化是非法的。


C和C++之间的不显著的不同


迄今为止展示的不同都可以被C++编译器简单的检测出来。但是C和C++在特定构造的解释之间有一些语义上的不同。这些不同不会被编译器诊断出;因此必须特别注意他们。


枚举类型的大小


在C中,枚举类型的大小等于sizeof(int)。在C++中,枚举的实际类型不一定是int——它可能要比int小。此外,如果枚举子的值太大以致不能用一个unsigned int表示,编译器允许使用更大的单元。例如enum { SIZE = 5000000000UL };

字符常量的大小


在C中,对字符常量使用sizeof的结果——例如,sizeof('c');——等于sizeof(int)。而在C++中,表达式sizeof('c');等于sizeof(char)


预定义宏


C 和 C++ 编译器定义了下列宏:



__DATE__ /*以"Apr 13 1998"的形式,包含字面的编辑日期,*/
__TIME__ /*以"10:01:07"的形式,包含字面的编辑时间 */
__FILE__ /*包含被编译源文件的文件名 */
__LINE__ /* 当前源文件的行数*/

C++编译器专有的定义下列宏:



__cpluplus

遵循标准的C 编译器定义下列宏记号:



__STDC__

C++编译器是否也定义了宏记号__STDC__是依赖与编译器的。


main()的默认返回值


在C中,当控制流程到了main()而没有碰到return语句,其效果是向环境返回一个未定义值。但是在C++中,main()隐含的执行一个



return 0;

语句。





注意:你可能注意到在本书中所有的代码列表中在 main()末尾都有显式的 return语句,即使它是不必要的。有两个原因:第一,许多编译器没有按标准作,但忽略 return语句时会成生警告消息。第二,在错误的情况下显式 return语句返回非0值。



从C到C++的移植


解决C和C++语法和语义的不同是从C到C++移植的第一步。这个过程保证了C 的代码在C++编译器下能够通过,并且能按期望的运行。在C++编译器下编译C代码还有其他明显的优点:C++编译器使用的严格的类型检查可以检查出潜在的C编译器不能检查出的错误。前面展示的C和C++之间区别的列表都是因为C++修复了C中的漏洞和潜在的危险造成的。但是问题是关于的性能——C++编译器产生的目标代码是否比C编译器产生的目标代码低效?这个主题在第十二章“优化你的代码”中详细讨论过。但是,必须注意一个好的C++编译器可以比好的C编译器作的更好,因为它可以使用一般C编译器不支持的优化技术,比如函数内联和命名返回值(也在第十二章讨论)。


虽然如此,为了享受丰富的面向对象程序设计的好处,需要更坚定的修改代码。幸运的是,从过程化到面向对象的程序设计的转变可以是渐进的。下面的章节展示了一种通过额外的代码层来包装函数的技术,这使得最小化的暴露实现细节。下面讨论如何用完全类来包装旧的代码来获得面向对象的好处。


函数包装


底层的诸如支撑例程和API函数这样的代码可以被同一个工程的不同小组使用。正常的情况下,这些代码由第三方厂商或工程中的特殊小组开发和维护。例如



int retrievePerson (int key, Person* recordToBefilled); /* C 函数 */

()的接口改变时可能产生问题:每一次函数调用必须因此而改变。考虑在已存在程序作这样一个小小的修改:



/*
函数修改:现在key被改为char *来代替原来的int
每一个函数的调用都必须因此改变
*/
int retrievePerson (const char * key, Person* recordToBefilled);

就像你在第五章“面向对象的编程和设计”中看到的,过程化程序设计的一个最显著缺点是很能作这样的修改;但是即使在最严格的过程化语言里你也可以使用包装函数(wrapper function)来使修改的效果局域化。一个包装函数调用原来的函数再返回其结果。下面是一个例子:



/* A wrapper function */
int WrapRetrievePerson(int key, Person* recordToBefilled)
{
return retrievePerson (key, recordToBefilled);
}

包装函数为代码片断提供一个用于扩展和修改的稳定接口。当使用包装函数,一个API函数接口的改变仅仅反映在他相应的包装函数的定义中。程序的其他部分不受改变的影响。这与通过访问和修改函数来间接的存取非公有成员是十分类似的。在下面的例子中,包装函数的函数体由于key的类型由int变成char *而不得不改变。但是注意他的接口保持稳定:



/*** file DB_API.h ***/
int retrievePerson (const char *strkey, Person* precordToBefilled);
typedef struct
{
char first_name[20];
char last_name[20];
char address [50];
} Person;
/*** file DB_API.h ***/
#include <stdio.h>
#include " DB_API.h "
int WrapRetrievePerson(int key, Person* precordToBefilled) //保持稳定
{
/* 包装的实现因为API的改变而改变*/
char strkey[100];
sprintf (strkey, "%d", key); /* 将int转换成string */
return retrievePerson (strkey, precordToBefilled);
}

通过系统的对其他小组或厂商维护的每一个函数使用这种技术。你可以保证一个稳定的接口,即使当底层实现改变时。


尽管函数包装技术提供对实现细节的一种保护,它并不提供面向对象程序设计的其他优点,包括将相关操作的封装到一个类中,构造器和销毁器和继承。移植的第二步时将相关操作的集合封装到一个单独的包装类中。但是这个技术需要通晓面向对象的概念和原理。


设计旧代码的包装类


在许多原来以C写的后来移植到C++的frameworks中,一个通用的——但错误的——习惯是包装C函数到一个包装类(wrapper class)。作为他们的接口这样的类提供一组操作来间接的映射原来的函数。下面的networking函数提供了一个例子:



/*** file: network.h ***/
#ifndef NETWORK_H
#define NETWORK_H
/* 函数与UDP协议相关*/
int UDP_init();
int UDP_bind(int port);
int UDP_listen(int timeout);
int UDP_send(char * buffer);
/* 函数与X.25 协议相关*/
int X25_create_virtual_line();
int X25_read_msg_from_queue(char * buffer);
/* 通用功能函数 */
int hton(unsigned int); //颠倒来自host的字节到network顺序
int ntoh(unsigned int); //颠倒来自network的字节到host顺序
#endif
/*** network.h ***/

包装类的一个可能的实现可能简单的将所有函数嵌入到下面的单一类中:



#include "network.h"
class Networking
{
private:
//...stuff
public:
//构造器和销毁器
Networking();
~Networking();
//成员
int UDP_init();
int UDP_bind(int port);
int UDP_listen(int timeout);
int UDP_send(char * buffer);
int X25_create_virtual_line();
int X25_read_msg_from_queue(char * buffer);
int hton(unsigned int); //颠倒来自host的字节到network顺序
int ntoh(unsigned int); //颠倒来自network的字节到host顺序
};

但是,这种方法实现的包装类是不推荐的。X.25和UDP协议用于不同的目的而且没有什么相同。从长远来看将这两种协议绑在一个接口中会导致维护困难——而且它破坏了起先要向面向对象设计移植的原因。此外,由于它的无组织接口,Networking并不是其他派生类的理想接口。Networking和相似类的问题是他们不是真正的遵循了面向对象的方针。他们仅仅是不相关操作的集合。一个好的设计步骤是将旧函数按意义划分为自包含的单元,再将每一个单元包装在单独的类中。例如:



#include "network.h"
class UDP_API
{
private:
//...stuff
public:
//构造器和销毁器
UDP_API();
~UDP_API();
//members
int UDP_init();
int UDP_bind(int port);
int UDP_listen(int timeout);
int UDP_send(char * buffer);
};
class X25_API
{
private:
//...stuff
public:
//构造器和销毁器
X25_API();
~X25_API();
//members
int X25_create_virtual_line();
int X25_read_msg_from_queue(char * buffer);
};
class Net_utility
{
private:
//...stuff
public:
//构造器和销毁器
Net_utility();
~Net_utility();
//members
int hton(unsigned int); //颠倒来自host的字节到network顺序
int ntoh(unsigned int); //颠倒来自network的字节到host顺序
};

现在每一个类提供一致的接口。其他优点是简化了协议的使用;例如类X25_API用户不必去接受一个UDP协议的接口,反之亦然。


多语言环境





注意:在本节中,C代码与C++的差别是通过文件扩展名来区别的。.h扩展名用于C头文件,而C++头文件通过.hpp来指示。同样的,.c和.cpp扩展名分别用来区别C和C++源文件。另外,在C文件中仅用C风格注释。



迄今为止,本章集中与单向的移植过程:从C 到C++。然而,许多系统并不限与单一程序设计语言。典型信息系统可以同时为图形接口使用一种语言,另一种语言来存取数据库的数据,用第三种语言来开发服务应用程序。一般的,这些程序必须共享其他程序的数据和代码。本节将关注如何在系统种同时使用C和C++语言。


保证用C写的模块和用C++写的模块之间兼容性的最简单的方法是追随两种语言的公共部分。再将C++当作一种过程化语言(“更好的C”)来使用,就不会麻烦了——你可以简单的只使用C。将面向对象的C++代码和过程化的C代码无缝的联合使用痕有挑战性——但是他们提供许多优点。


C 和C++ 的连接习惯


默认情况下,C++函数有C++连接,它与C连接不兼容。因此,全局C++函数不能被C代码调用除非C++函数显式的申明为有C连接类型。


强制C++函数有C连接


为了覆盖默认的C++ 连接,C++函数必须申明为extern "C"。例如



// filename decl.hpp
extern "C" void f(int n); //强制C连接,这样f()才可以被C代码调用,尽管它是用C++编译器编译的
// decl.hpp

extern "C"前缀指示C++编译器对函数f()使用C连接而不是默认的C++连接。这意味着C++编译器不会对f()使用命名分裂(name mangling)(参见下面的标题“什么是命名分裂?“)。因此,在C代码中对f()的调用都可以被C连接器正确的解决。一个C++连接器也能定位f()的编译版本,即使它有C连接类型。换句话说,申明C++函数为extern "C"保证了在C++和C之间的协同能力(也适用与其他使用C调用习惯的过程化语言)。但是,强制C连接有一个代价:不能重载其他也申明为extern "C"f()的函数。例如


// filename decl.hpp
extern "C" void f(int n);
extern "C" void f(float f); //错误,第二个有c连接的f是非法的
// decl.hpp

注意你再申明重载版本的f(),它将不能申明为extern "C"



// filename decl.hpp
extern "C" void f(int n); //OK,可以被C 和 C++ 代码调用
void f(float f); //OK, 没有使用C 连接。仅能被C++代码调用
void f(char c); //OK, 没有使用C 连接。仅能被C++代码调用
// decl.hpp

它是怎么工作的的呢?C代码的函数调用被转换成接着函数名的CALL汇编指令。申明一个C++函数为extern "C"保证了被C++编译器产生的名字与C编译器导出的名字是一致的。另一方面,如果被调用函数没有extern "C"修饰符而用C++编译器编译,它将被命名分裂,而C编译器仍然在CALL指令之后放置命名分裂的名字,结果导致连接期错误。





什么是命名分裂?

命名分裂(更正式的术语,尽管很少使用,是 命名装饰(name decoration))是一种C++编译器在程序种产生独一无二名字的方法。准确的算法是依赖编译器的,而且他们可能随版本而变。命名分裂保证了表面上有相同名字的实体有独一无二的名字。被分裂的名字包含连接器需要的所有信息,包括连接类型、范围、调用习惯等等。例如,当全局函数被重载时,为每一个重载函数产生一个唯一的分裂名字。命名分裂也适用与变量。因而,用户给予同样名字的局域变量和全局变量仍然得到不同的分裂名字。分裂名字时怎么产生的?编译器选择用户给出的名字的标识符,再用变量的基本类型、类或函数等信息装饰。对于函数,分裂名字嵌入它的范围和连接类型、它的申明所在的命名空间、形参列表、形参的传递机制和形参的cv资格。一个成员函数的分裂名字合并了许多附加信息比如类名字,它是不是 const成员函数,以及其他依赖于编译器、连接器和运行环境的东西。下面是一个例子:对于全局函数 void func(int);,一个给定的编译器可以产生相应的分裂名字 __x_func@i@,前缀 x出指示这是一个函数, func是用户指定的函数名, @指示开始形参列表, i指示形参类型而后面的 @符号指示形参列表的结束。重载版本的 f()有不同的分裂名字因为它有不同的形参列表。原来用户给出的函数名字可以从分裂名字中复制,所以连接器可以以可读的格式产生错误消息。




就象先前规定的,给定编译器的命名分裂可以随版本的不同而改变(例如,如果新版本支持命名空间,而旧版本不支持)。这是经常是你必须用新编译器重编译代码的原因。其他重要的含义是编译器和连接器需要来自同一厂商而且必须是兼容版本。这保证他们使用同一命名习惯,以及产生兼容的二进制代码。



从C代码调用C++ 代码


迄今为止,你看到的都是C++一边的故事。一个C程序不能#include头文件decl.hpp,因为extern "C"修饰符不能被C 编译器识别。为了保证申明能被C编译器解析,extern "C"需要被C++编译器看见——而不是C编译器。一个有C连接的C++ 函数必须以两种不同形式申明,一个用于C++,一个用于C。通过分离C和C++的头文件,就可以容易的办到。C头文件看上去象下面这样:



/*** filename decl.h ***/
void f(int n); /* 与 C++头文件一样,指示没有extern "C" */
/*** decl.h ***/

头文件可以在C源文件中#included,以便调用函数f()。例如



/*** filename do_something.c ***/
#include "decl.h"
void do_something()
{
f(5);
}
/*** do_something.c ***/

但是分开C 和 C++的头文件不是一个文雅的解决。两个都文件必须总是保持一致,当使用许多头文件时,这将带来严重的维护问题。一个更好的选择是为申明使用多个C头文件。例如



/*** filename f.h ***/
void f(int n); /* 与C++ 头文件一致但没有extern "C" */
/*** f.h ***/
/*** filename g.h ***/
void g(const char * pc, int n);
/*** g.h ***/

之后,将在C头文件#included在C++头文件的extern "C"块中:



// filename decl.hpp
extern "C"
{
#include "f.h"
#include "g.h"
}
// filename decl.hpp

extern "C"块的效果就象是在被#include头文件中的所有申明有 了一个extern "C"修饰符。其他的方法是直接修改C头文件增加#ifdef标识来使得extern "C"申明仅对C++编译器可见。例如



/*** filename decl.h ***/
#ifdef __cplusplus
extern "C" { //仅对C++编译器可见
#endif
void g(const char * pc, int n);
void f(int n);
#ifdef __cplusplus
} //仅对C++编译器可见
#endif
/*** g.h ***/

这种方法仅需要一个头文件。但是直接修改C头文件有时是不可能的。在不能直接修改的情况下,就需要使用前面的技术了。请注意,从C代码中调用的C++代码是一个普通的C++函数。它可能实例化对象,调用对象的成员函数或则使用其他C++特性。然而,有些编译器可能需要特殊的配置设置来保证连接器存去C++代码和模板代码。


编译main()


函数即可以被C编译器编译也可以被C++编译器编译。但是,C++编译器必须编译main()。这使C++ 编译器能照顾模板,静态初始化和其他依赖编译器的需要main()完成的操作。在C编译器中编译main()很可能导致连接期错误,因为在C和C++中main()有不同语义。


最小化C 与C++ 代码之间的接口


通常,你可以从C++代码中不做任何特殊调整就调用C函数。反过来,就象你前面看到的,也是可能的——只是需要附加的调整。因此推荐你将良种语言之间的接口最小化。例如申明每一个C++ 函数为extern "C"是不好的。这样不仅意味着对头文件要做附加的修改,而且也不能重载。记住,你也不能申明一个成员函数为extern "C"。 对于必须从C代码调用的C++函数,使用有extern "C"修饰符的函数包装是非常有利的。此时,被包装的C++ 函数可以有C++连接。例如



void g(const char * pc, int n); //extern "C" 不是必须的
void f(int n);
extern "C" void f_Wrapper(int n) //仅仅只有包装函数要从C代码中调用
{
f(n);
}
extern "C" void g_Wrapper(const char *pc, int n)
{
g(pc, n);
}

混合<iostream> 类和 <stdio.h>函数


在程序中同时使用<iostream>类和<stdio.h>库函数是可能的,只要他们不同时存取同样的文件。例如,你可以使用<iostream>对象cin来从键盘读取数据,再使用<stdio.h>函数来将数据写到一个磁盘文件,就像下面的程序中这样:



#include <iostream>
#include <cstdio>
using namespace std;
int main()
{
int num;
cin>>num;
cout<<"you enetred: "<< num <<endl;
FILE *fout = fopen("data.dat", "w");
if (fout) //将num 写入磁盘文件
{
fprintf(fout, "%d/n", num);
}
fclose(fout);
return 0;
}

甚至使用<iostream><stdio.h>来操作同一个文件也是可能的;例如,程序可以同时输出到stdoutcout,尽管这是不被推荐的。为了使同时存取同一文件,你必须先调用ios::sync_with_stdio(true);来同步I/O 操作。但是注意这种同步降低性能。因此,仅在使用<iostream><stdio.h>存取同一文件时才使用ios::sync_with_stdio(true);。例如



#include <iostream>
#include <cstdio>
using namespace std;
int main()
{
ios::sync_with_stdio(true);//使混合I/O可以开始
int num;
printf("please enter a number/n");
cin>>num;
cout<<"you enetred: "<< num << "please enter another one " << endl;
scanf("%d", &num);
return 0;
}

一般的,你不能写这样的代码。但是,当一个巨大的工程结合了使用<stdio.h>的旧C代码和使用了<iostream>的C++对象时,I/O同步不可避免,因为最终,同样的系统资源同时被<stdio.h><iostream>使用。


<iostream><stdio.h>联合使用有很多优点。另外,从C到C++的移植过程可能是很多余,而且让C代码和C++代码一起工作证明是很困难的。


在C代码存取C++对象


不知道对象语义的C代码可以直接存取C++对象的数据成员吗?简单的答案是“可以,但是”。有一个前提是关于对象的底层内存布局是一定的;C代码可以使用这个前提,将C++对象作为一个普通的数据结构,倘若下面所有的约束都适用于类的对象:




  • 类没有非虚成员函数(包括从基类继承的虚函数)。





  • 在整个继承体系中类没有虚基类。





  • 类没有包含虚成员函数和虚基类的成员对象。





  • 所有类的数据成员都没有加存取修饰符。





对象在内存中的底层表示法


彻底的检查这些限制,给出下面类Date的申明:



class Date
{
public:
int day;
int month;
int year;
//构造器和销毁器
Date(); //当前数据
~Date();
//一个非虚成员函数
bool isLeap() const;
bool operator == (const Date& other);
};

标准保证了在每一个类Date的实例中,数据成员以他们申明的顺序放置(静态成员存储在对象之外,在这里忽略它它)。这里不要求成员放置在邻接的内存区域;编译器可以在数据成员之间插入附加的对齐字节(参见第十一章“内存管理”)来保证对对齐。但是,在C中也是这样的,所以你可以安全的假定Date对象于下面C的结构有同样的内存布局:



/*** filename POD_Date.h***/
struct POD_Date
/* 下面结构的内存布局于Date对象的是一致的。
{
int day;
int month;
int year;
};
/*** POD_Date.h***/

因此,一个Date对象可以被传递到C 代码,并被当作一个POD_Date的实例来对待。此时在C中的内存布局和在C++中的内存布局是一致的,这很令人惊讶;除了数据成员以外类Date还定义成员函数,但是没有迹象表明成员函数在对象的内存布局中。成员函数存储在什么地方呢?C++将非静态函数当作静态函数来处理。换句话说,成员函数是普通函数。他们于全局函数没有区别,只是他们接受一个隐含的this参数。this保证了他们在调用时能存取它的成员函数。成员函数调用被转换成函数调用,编译器插入了附加代码来保存对象的地址。考虑下面的例子:



void func()
{
Date d;
bool leap = d.isLeap(); //1
}

在(1)的成员函数isLeap()的调用被C++编译器转换成如下形式



_x_isLeap?Date@KPK_Date@(&d); //伪C++代码

又发生了什么呢?让我们仔细的解析。圆括号包含this参数,这个参数被编译器插入到每一个非成员函数调用中。就像你已经知道的,函数名会被分裂。_x_isLeap?Date@KPK_Date@是假定的成员函数bool Date::isLeap() const;的分裂名字。在假象的C++编译器中,每一个分裂名字之前更着一下划线来使名字和用户给定名字冲突的可能最小。之后的x指出这是函数,于变量区别。isLeap是用户给出的函数名。?是与类名字的分界线。类名字后面的@指示形参列表,开始的KPKDate指示一个指向const Dateconst指针( const成员函数的this实参是一个指向const对象的const指针)。最后,后面的@指出形参列表的结束。因此_x_isLeap?Date@KPK_Date@是成员函数bool Date::isLeap() const;的底层名字。其他编译器可能使用不同的分裂算法,但是细节与例子展示的十分类似。你肯定认为:“这和过程化程序操作数据的方法类似。”。重要的不同是这一切都是编译器完成的,而不是程序员。


C++ 对象模型是高效的


C++的对象模型是支持面向对象概念的底层机制,这些概念包括构造器和销毁器,封装,继承和多态。类成员函数的底层表示有几个优点。这在执行速度和内存占用方面是很长高效的,因为对象不存储它成员函数的指针。另外,非虚成员函数的调用不需要附加查找和删除指针操作。第三个优点是与C的兼容性;一个Date类型的对象可以安全的传递给C代码,因为这样的对象与相应的C结构在二进制表示上是一致的。其他面向对象语言使用更本上不同的对象模型,不能与C或C++兼容。大部分使用引用语义(reference semantics)。在基于引用的对象模型中,对象被作为一个引用(指针或句柄),引用一个存储数据成员和指向成员指针的内存块。引用语义有许多优点;例如,在这种语言中可以简单的实现引用计数和垃圾收集器,这些语言也经常提供自动的引用计数和垃圾收集器。但是,垃圾收集器也产生附加的运行期开销,而且基于引用的模型也与C不兼容。另一方面,C++ 对象模型使的C++编译器可以用C编写,并且(就像你在第六章“异常处理”中看到的)早期的C++编译器只是将C++转换成C。


派生对象的内存布局


标准没有指定在派生类中基类子对象的内存布局。但是,习惯上所有的C++ 编译器使用同样的习惯:基类子对象先出现(在多继承中采用从左到右的顺序),其后才是派生类的数据成员。C 代码可以存取派生类,只要派生类的基类满足前面的限制。例如,考虑继承于Date的非多态类,再增加附加数据成员:



class DateTime: public Date
{
public: //附加成员
long time;
bool PM; //显示时间是AM 还是PM?
DateTime();
~DateTime();
long getTime() const;
};

DateTime的两个附加数据成员被附加到基类Time的三个数据成员之后,所以DateTime对象的内存布局与下面的C结构等价:



/*** filename POD_Date.h***/
struct POD_DateTime
{
int day;
int month;
int year;
long time
bool PM;
};
/*** POD_Date.h***/

由于同样的关系,DateTime的非多态成员函数对对象大小和内存布局都没有影响。


C++非多态对象和C结构兼容内存布局有许多有用的应用。例如,它使得关系数据库可以检索插入对象到表中。不支持对象语义的数据操作语言,比如SQL,仍将"live"对象当作一块生鲜内存。事实上,许多商业数据库依赖这种兼容性来为底层关系数据模型提供面向对象接口。其他应用是将对象转换成从一台机器到另一台机器字节流的能力。


对虚函数的支持


当对象变成多态时发生了什么?此时,与C的兼容性是不管用的。就像前面要你注意的,允许编译器除了用户申明数据成员以外还插入其他数据成员。这些成员可能填充字节来保证对齐。在有虚函数的时候,一个附加的成员被加入到类中:一个指向虚函数表的指针,叫做_vptr_vptr保存一个函数指针的静态表的地址(多态类的运行期信息也是这样;参见第七章“运行期类型识别”)。_vptr的精确位置是依赖于编译器的。传统上,它在类的用户申明数据成员之后。但是为了提供性能,有些编译器将它移到了类的开始处。理论上讲, _vptr的位置可以在类的任何位置——甚至在用户申明成员之间。


虚成员函数,与非成员函数一样,是一个普通函数。但是当一个派生类覆盖它时,就存在多个版本的函数存在了。并不总能在编译期决定到底改调用那一个函数。例如



#include <iostream>
using namespace std;
class PolyDate
{
public:
//PolyDate有一个和Date一样的成员,但是它是多态的
virtual void name() const { cout<<"PolyDate"<<endl;}
};
class PolyDateTime: public PolyDate
{
public:
// 和Date一样的成员,但是它是多态的
void name() const { cout<<"PolyDateTime"<<endl;} //覆盖了PolyDate::name()
};

当类别编译时,假象的编译器产生了两个函数分别对应PolyDate::name()PolyDateTime()::name()



// void PolyDate::name() const的分裂名字
_x_name?PolyDate@KPK_PolyDate@
// void PolyDateTime::name() const的分裂名字;
_x_name?PolyDateTime@KPK_PolyDateTime@

迄今为止,没有什么异常。你已经知道了成员函数时一个接受隐含this参数的普通函数。因为你定义了两个版本的虚函数,你也期望会有两个的分裂名字的函数。但是与普通函数不同,编译器不能总是将虚成员函数的调用转换成一个直接的函数调用。例如



void func(const PolyDate* pd)
{
pd->name();
}

func()不能在分离的源文件中定位,源文件可能在类PolyDateTime定义之前编译。因此,虚函数name()的调用不得不延续到运行期解决。编译器将函数调用转换成如下形式



(* pd->_vptr[2]) (pd);

分析一下;成员_vptr指向产生的虚函数表。虚表的第一个成员常存储的时销毁器的地址,第二个存储的是类的type_info的地址。其他用户定义的虚函数在更高的位置。例如,name()的地址存储在虚函数表的第三个位置(实践中,_vptr的名字也是分裂的)。 因此,表达式 pd->_vptr[2]返回与当前对象关联的name()函数的地址。第二个pd表示的是 this参数。


很明显,在这种情况下定义相应的C结构是很不稳定的,因为需要编译器安排的_vptr的位置和大小。还有其他冒险:_vptr的值是短暂的,这意味着它可能与执行程序进程的地址空间相关。因此当整个多态对象被存储到文件,再取回时,取回的对象可能变成一个无效的对象。由于这些原因,从C代码中存取多态对象是危险的,一般需要避免。


虚继承


C代码也不能存取有虚基类的对象。原因是,虚基类经常以指向虚子对象共享实例的指针来表示的。一样,这个指针的位置也是依赖与编译器的。同样的,指针保存的是一个瞬时的值,它可能在另一个进程中就有不同的值。


不同的存取修饰符


合法的从C代码存取C++对象的第四个限制是类所有的数据成员不能加存取修饰符。理论上讲,这意味着内存布局与下面类似的类



class AnotherDate
{
private:
int day;
private:
int month;
private:
int year;
public:
//构造器和销毁器
AnotherDate(); //current date
~AnotherDate();
//一个非虚成员函数
bool isLeap() const;
bool operator == (const Date& other);
};

可以与以同样的顺序申明同样的数据成员的只是没有加存取修饰符的类不一样。换句话说。对于类AnotherDate,允许编译器将成员month放在成员day之前,将year放在month之前,或者其他。当然这使与C代码的兼容性失效。但是实际上,现在所有的C++编译器忽略存取修饰符并以它申明的顺序存储数据成员。所以存取有几种存取修饰符对象的C代码可能工作——但是不能保证在将来的兼容性。


总结


C++的创建者试图尽可能的保持对C的兼容性。事实上,除了一些例外,每一个C程序也是一个有效的C++程序。但是,在非常相似的两种语言中还是有一些不同。他们之中的大多数,就像你注意到的,来自类型安全的C++,——例如必须在使用函数之前申明它,需要显式的将void指针转换成其他类型的指针,反对隐含的int申明,以及强制null结束符为字符。两种语言之间的其他差异来自类型定义的规则。


从C++代码可以直接调用C代码。在特定条件下从C++代码也可以调用C++代码,但是需要附加的连接类型的调整,而且必须是全局函数。从C代码可以存取C++对象,就像你看到的,但是有许多严格的限制。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值