C++面向对象特性实现机制的初步分析

来源:互联网 发布:搜索引擎优化seo的原理 编辑:程序博客网 时间:2024/06/04 18:07
 

摘要… …

Abstract… …


目录

前言


1准备知识

1.1 程序对内存的使用方法

1.2 C++ Class内存格局

1.3 编译期和执行期

2封装

2.1 封装的目的和意义

2.2 封装的实现机制

2.2.1 类成员函数的调用方法

2.2.2 封装的性能问题


3继承

类继承的内存结构,子类,父类之间的关系

接口继承和实现继承

继承带来的开销

继承带来的开销

继承带来的开销

继承带来的开销


4多态

多态--就是指事物不同的方面

虚函数

纯虚函数,虚拟继承

虚函数与函数重载的区别

 

5 All in one,演示

 

6附录


致谢


参考书目

Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=14995

Chapter 1 准备知识

 

 

 

 

C++是一种面向对象的高级语言,要了解它的一些内部机制,我们有必要先熟悉其二进制代码的编译过程,并且要了解运行这些二进制代码时内存中各个区域的变化情况。

 

 

 

 

1.程序对内存的使用方法

代码区

全局数据区

堆区

栈区

code area

data area

stack area

heap area

任何需要CPU执行的程序都必须以二进制代码的形式存储在内存中,因此,程序代码在内存中的存储方式和结构是我们首先需要了解的。当程序得到操作系统分配给它的内存区域之后,它将这个区域分为四个部分,见右图:

1-1

这四个区域存储含有不同逻辑意义的二进制信息,请看下面的代码:

 

 

 

 

/////////////////////////////////////////////

//Source Code 1.1

#include <iostream.h>

 

 

 

 

static int a=5;

static int b=1;

 

 

 

 

class Test

{

public:   

       Test(){cout<<"Create!"<<endl;}

       ~Test(){cout<<"Destroy!"<<endl;}

private:

       int count;

};

 

 

 

 

int foo(int x, int y)

{

       return x+y;

}

 

 

 

 

void main(void)

{

       int i=0;

       i=foo(a,b);

       cout<<"i= "<<i<<endl;

 

 

 

 

       Test* pTest;

       pTest= new Test;

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//静态全局变量,存储在data area

//静态全局变量,存储在data area

 

 

 

 

// Test, 用来测试对heap区域的使用

 

 

 

 

 

 

 

 

//构造函数

//析构函数

 

 

 

 

//私有数据

 

 

 

 

 

 

 

 

//函数,编译器生成代码后,将其存储在code

//area中,函数运行时的中间变量,如形参x,y

//以及x+y产生的临时变量,都保存在stack

//area中,在函数返回时,系统自动清空函数

//压入stack area内的变量

 

 

 

 

 

 

 

 

// main()函数为程序的入口,程序开始运行时,// CPU中的指令寄存器被设置为code area

// main()函数的位置

 

 

 

 

//声明指向Test类的指针

//new操作符在heap中构造Test类的实例

//并将指针pTest指向Test实例的入口地址

 

 

 

 

将上面的程序编译,系统将各个函数的代码存放到代码区,将静态变量,常量,全局变量保存在全局数据区。运行时,CPU首先执行main()函数中的代码,系统向栈区内压入main()函数的局部变量i,程序运行到对foo()函数的调用时,系统将CPU指令寄存器压栈,并将其内容设定为foo()的地址,控制转到foo()函数。foo()函数在栈区中创建其局部变量x,y, 完成x+y的运行后将结果以临时变量的方式返回给调用者。return之后,系统清空foo()在栈区中存放的变量,并将先前压栈的指令寄存器内容出栈,转而执行main()中接下来的代码。

 

 

 

 

声明Test* pTest后,main在栈区中创建一个指针变量,然后调用new在堆区中创建一个Test的实例,并将pTest指向它。运行该程序,我们可以发现,Test类的构造函数被执行,但析构函数没有执行。原因是这样的,栈区内的变量值在创建这个变量的函数以内有效,例如,函数foo 返回后,形参x, y从栈中清除,main返回后,指针变量pTest也被从栈区中清除;但指针指向的堆区中的Test实例,仍然存在,类的析构函数没有被调用。由此我们可以看出栈区和堆区得区别:

 

 

 

 

栈区(stack area):  存放程序的局部数据,即各个函数中的局部变量

堆区(heap area):  存放程序运行中的动态数据

 

 

 

 

对栈区的使用,在编译的时候就已经确定,而堆区的使用是在程序运行中动态进行的。堆区中的数据的创建和删除要由程序员来自己控制,系统不会对它像栈区那样进行自动的清除,使用堆区要防止产生例子程序中那样的错误,用new创建对象时候,使用完毕后一定要把它delete, 不然会出现内存泄漏(memory leak)

 

 

 

 

栈区和堆区的区别,后文还会有进一步的分析。

 

 

 

 

以上是对程序执行过程中对各个内存区使用情况的一个简单介绍。实际情况可能比这里描述的要复杂一些,比如传递函数返回值的那个临时变量的处理,函数如何对栈进行清除(涉及到调用规范__cdecl, __fastcall __stdcall)。本节主要是了解程序对内存的使用情况,对这样的细节就不做深入地分析了。

 

 

 

 

2. C++ Class内存格局

 

 

 

 

C++引入了Class这个概念,Class封装了一组相关的数据和对这些数据的操作,这是实现面向对象编程的基础。在下面一节中,我将针对Class的内存布局,做一些分析。

 

 

 

 

我们知道,Class中有成员函数和成员变量,其中,成员函数分为普通成员函数,静态成员函数和虚函数,成员变量分为普通成员变量和静态成员变量。我们从最简单的情况开始分析。请看下面的代码:

 

 

 

 

/////////////////////////////////////////////

//Source Code 1.2

#include<iostream.h>

 

 

 

 

class Test1

{

public:

       int foo(){return 0;}

private:

       int     member_1;

       float   member_2;

       double* p;

};

 

 

 

 

class Test2

{

public:

       int foo(){return 0;}

       virtual int v_foo(){return 0;}

private:

       int     member_1;

       float   member_2;

       double* p;

};

 

 

 

 

class Test3

{

public:

       int foo(){return 0;}

       int static s_member;

private:

       int     member_1;

       float   member_2;

       double* p;

};

 

 

 

 

class Test4

{

public:

       int foo(){return 0;}

       static int s_foo(){return 0;}

private:

       int     member_1;

       float   member_2;

       double* p;

};

 

 

 

 

void main(void)

{

int a=sizeof Test1;

int b=sizeof Test2;

int c=sizeof Test3;

int d=sizeof Test4;

 

 

 

 

cout<<"Size of class Test1 is:"<<a<<endl

       <<"Size of class Test2 is:"<<b<<endl

       <<"Size of class Test3 is:"<<c<<endl

       <<"Size of class Test4 is:"<<d<<endl;

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//Test1类,其中封装了三个普通的成员变量

//一个普通成员函数

 

 

 

 

//返回int型值的普通成员函数

//32位系统中,各个变量的尺寸如下

//sizeof (int)      -->4

//sizeof (float)     -->4

//sizeof (double*)  -->4

 

 

 

 

 

 

 

 

//Test2类在Test1的基础上增加了虚函数

 

 

 

 

 

 

 

 

 

 

 

 

//虚函数

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//Test3类在Test1的基础上增加了静态成员//变量

 

 

 

 

 

 

 

 

//静态成员变量

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//Class4类在Test1的基础上增加了静态成

//员函数

 

 

 

 

 

 

 

 

//返回int型值的静态成员函数

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//main中测试每一个类的尺寸

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//输出:Size of class Test1 is: 12

//输出:Size of class Test2 is: 16

//输出:Size of class Test3 is: 12

//输出:Size of class Test4 is: 12

 

 

 

 

 

 

 

 

现在我们来分析一下程序输出的结果:

 

 

 

 

Test1类的尺寸为12,恰好为三个普通成员变量尺寸之和,那成员函数foo()哪里去了?留下这个疑问,继续往下看。Test2Test1多了一个虚函数,结果尺寸比Test1多了4个字节,一个函数怎么会只有4个字节?留下第二个疑问,继续。Test3类和Test4类分别比Test1类多了一个静态成员变量和静态成员函数,但这个增加却没有在类的尺寸上反映出来,不解!

 

 

 

 

通过归纳,我们可以总结出这样的事实

a. 类中包括普通成员变量,但不包括普通成员函数

b. 虚函数以某种形式包含在类中,但函数体本身肯定不在类中

c. 类的静态成员变量和静态成员函数不包括在类中

 

 

 

 

进一步思考,我们可以想到:

 

 

 

 

为了提高性能,减少对内存的需要,我们没有必要把类的函数包含在类的实体中,创建10Test1的实例,这10个实例中包含10份相同的的foo()代码是非常愚蠢的。类中包含类本身数据(普通成员变量),当要对数据进行操作时,通过一定的方式调用成员函数即可。类实际上是调用Test1::foo()来访问成员函数的(这其中的一些细节会在“封装”一章中进一步阐述)。

 

 

 

 

再看虚函数的问题,增加虚函数后,类的体积增加了4个字节,这恰好是一个指针的尺寸,我们有理由认为这4个字节是指向虚函数入口的函数指针,这样我们第二个问题也可以得到很好地解释。可是我们为什么不用Test2::v_foo()的方式来访问类的虚函数呢?这个问题会在“多态”一章中,给出完美的答案。(其实类中的这个指针指向的是虚拟函数表而非虚函数的实际入口地址,具体请参考“多态”一章)

 

 

 

 

现在再来看静态成员函数和静态成员变量,根据上面的思路,一切都已经很清楚了。静态成员为所有类的实例所共享,没有必要把它们都放到实例中去,可以像寻址成员函数那样对它们进行操作:Test3::s_member Test4::s_foo()

 

 

 

 

通过下面这张图,可以更清楚地了解C++ Class的内存格局

 

 

 

 

 

 

 

 

Test::V_foo()

int  member_1

float member_2

double* p

指向虚函数的指针

Test

实际上此处为虚拟函数表

 

 

 

 

Test::foo()

Test::s_foo()

Test::s_member

普通成员函数

静态成员函数

静态成员变量

文本框: ….. …..

1-2

 

3. 编译期和运行期的区别

 

 

 

 

编译期是源代码向二进制指令转化的过程,而运行期则是CPU执行这些指令的过程。

Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=14996

Chapter 2 封装

 

 

 

 

2.1 封装的目的和意义

 

 

 

 

广义的说,封装是为了使这个世界以更加简单的方式呈现在我们的面前。买一台电冰箱,我不必要知道里面压缩机的运作过程,不必了解详细的制冷过程,我所要所的是,只是给它通电,通过调整一些旋钮来设置温度,剩下的事情,就不归我管了。

 

 

 

 

就面向对象程序设计来说,封装是为了隐藏实现的细节,使类的用户通过公开的接口来使用类的功能。在C++中,声明为public的成员函数,就是这样的接口,类的用户可以通过借口来安全的修改类的内部数据(设想,如果冰箱厂商让用户直接调节压缩机参数,那将是非常危险的)。对类内部方法的封装,可以增强代码的可复用性,用户是通过接口在访问类的功能的,类成员函数的实现细节发生变化,但只要结构不变,所有的使用类的客户代码都不需要修改。

 

 

 

 

很多人会认为C++中的class的封装效果,用C中的struct也可以实现。其实不然,看代码:

 

 

 

 

/////////////////////////////////////////////

//Source Code 2.1

#include<iostream.h>

struct s_Test

{

       int (*p_add)(s_Test*);      

       int i;

};

int add(s_Test* p){return ++(p->i);}

 

 

 

 

class c_Test

{

public:

       c_Test(){i=0;}

       int  add(){return ++i;}

       void show(){cout<<i<<endl;}

private:

       int i;

};

 

 

 

 

void main(void)

{

s_Test s;

s.i=0;

s.p_add=add;

s.p_add(&s);

cout<<s.i<<endl;

 

 

 

 

c_Test c;

c.add();

c.show();

}

 

 

 

 

 

 

 

 

 

 

 

 

//定义结构体 s_Test

//其中包含两个成员

//p_add为指向函数的指针

//i 为整形数据

 

 

 

 

//定义参数为指向结构体s_Test的指针的函数

 

 

 

 

//定义类c_Test

 

 

 

 

 

 

 

 

//构造函数对内部数据成员初始化

//成员函数add()完成对内部数据成员i+1操作

//成员函数show()显示内部数据成员i的值

 

 

 

 

//内部数据成员

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//创建结构体 s

//s的数据成员i赋值

//初始化p_add指针,使其指向int add(s_Test* p)

//通过指向函数的指针来调用add(s_Test* p)

//输出i的值

 

 

 

 

//创建类c_Test的对象 c

//调用add()成员函数

//使用show()输出i的值

 

 

 

 

通过观察上述的代码,我们可以发现,struct可以将数据和操作“包装”在一起,但这种包装是松散和不安全的。首先,struct没有访问控制机制,任何代码都可以操作其内部数据i,其次,结构体内指针指向的函数与结构体本身没有必然的联系,任何代码都可以调用add(s_Test* p)函数。Struct只是一个框架,结构体中所有的成员对外来说都是可见和可访问的。再看Class,通过使用publicprivate关键字,类很好的对借口和内部数据进行的分离。对内部数据的访问,都要通过成员函数来实现,例如int i的初始化,改变i的值,先是输出等,都实现了封装。

 

 

 

 

我们可以得出这样的结论

a.封装的作用是提供接口

b.封装可以保护内部数据

c.封装可以代码的可复用性

 

 

 

 

2.2. 封装的实现机制

 

 

 

 

2.2.1 类成员函数的调用方法

在文章的第一部分中,我们探讨了C++ Class的内存格局,那么,类的封装和其内存格局之间,有着什么样的联系呢?成员函数对内部变量的访问,又是怎么实现的呢?

 

 

 

 

通过观察图1-2,我们知道,类的实例中只包括数据成员,类的成员函数是被排除在对象封装之外的。编译器通过“类名::函数名”的方式,区分每一个成员函数。这样的内存布局,使人认为class对成员函数的处理方法,跟上节程序中struct的函数指针像类似。我们先看看类对其成员函数的调用方法与struct中使用的函数指针在形式上的区别,看代码片断:

 

 

 

 

void main(void)

{

s_Test s;

s.i=0;

s.p_add=add;

s.p_add(&s);

cout<<s.i<<endl;

 

 

 

 

c_Test c;

c.add();

c.show();

}

//完整代码见上节Source Code 2.1

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//通过函数指针调用add()时,我们把结构体指针传给了函数,//通过这个指针使add()函数访问并操作结构体中的数据

 

 

 

 

 

 

 

 

//调用类的成员函数,不需要传递指针

 

 

 

 

我们知道c_Test可以被创建很多份实例,但其成员函数在内存中只有一份,那么,这一份成员函数在没有参数传入的情况下,如何分辨这若干个c_Test的实例呢?调用类成员函数的时候,真的没有参数传入吗?让我们看看编译器针对c.add();语句生成的二进制代码,这样,任何隐藏的机制,都将一目了然。

 

 

 

 

Main函数中调用c.add()时,系统生成如下的代码,其中

00401050   lea         ecx,[ebp-4]

00401053   call        @ILT+5(c_Test::add) (0040100a)

 

 

 

 

call  @ILT+5(c_Test::add) (0040100a) 将控制转向地址0040100a,这里是一条跳转指令

0040100A   jmp         c_Test::add (004010c0)

 

 

 

 

地址004010c0为函数c_Test::add的真实入口地址,我们再看一下004010c0处的代码

004010C0

004010C1

004010C3

004010C6

004010C7

004010C8

004010C9

004010CA

004010CD

004010D2

004010D7

004010D9

004010DA

004010DD

004010E0

004010E2

004010E5

004010E8         

004010EA          

004010ED          

004010EF        

004010F0          

004010F1           

004010F2          

004010F4          

004010F5  

push

mov

sub

push

push

push

push

lea

mov

mov

rep stos

pop

mov

mov

mov

add

mov

mov 

mov

mov 

pop  

pop

pop

mov

pop

ret

ebp

ebp,esp

esp,44h

ebx

esi

edi

ecx

edi,[ebp-44h]

ecx,11h

eax,0CCCCCCCCh

dword ptr [edi]

ecx

dword ptr [ebp-4],ecx

eax,dword ptr [ebp-4]

ecx,dword ptr [eax]

ecx,1

edx,dword ptr [ebp-4]

dword ptr [edx],ecx

eax,dword ptr [ebp-4]

eax,dword ptr [eax]

edi

esi

ebx

esp,ebp

ebp

 

 

 

 

 

 

 

 

上述代码为c_Test::adde的具体实现代码。

 

 

 

 

通过上述汇编代码的分析,我们可以发现add()函数有一个隐藏的参数,这是一个指向调用add()函数的那个类实例(c这个对象的地址)的参数,因此,实际上编译器会将c.add();的调用转化为如下代码:

Test::add((Test*)&c);

 

 

 

 

int  add(){return ++i;}函数实际上是

int  add((Test*)&this)

{return ++((Test*)&this).i;}

 

 

 

 

这里,隐藏的参数(Test*)&this就是我们常说的this指针。我们会发现,这样的做法与使用结构体中指向函数的指针的方法相似(s.p_add(&s);),但C++中这一步骤是由编译器来实现的,通常用户不需要对this指针进行操作。这便是封装的好处,把复杂而且容易出错的部分交给编译器来实现。

 

 

 

 

2.2.2 封装的性能问题

 

 

 

 

封装就是将事物的内容和行为都隐藏在实现里,用户不需要知道其内部实现。但有得必有失,有时候我们无法保证封装的高效性。C++对封装的实现到底有没有性能上的损失呢?要是有的话,有多少呢?

 

 

 

 

请看下面的测试程序和相应的代码注释

//////////////////////////////////

//Source Code 2.2

//C++ class封装的性能

 

 

 

 

const double COUNT=1000000;

#include <iostream.h>

#include <time.h>

#include <sys/timeb.h>

void showtime()

 

 

 

 

class Test{

public:

       Test(){i=0;}

       int foo(){return 0;}

       double i;

};

 

 

 

 

int foo(){return 0;}

double i=0;

 

 

 

 

void main(void){

double j=0;

Test t;   

showtime();

for(j=0;j<COUNT;j++)i++;

showtime();

for(j=0;j<COUNT;j++)t.i++; 

 

 

 

 

showtime();

for(j=0;j<COUNT;j++)foo();

showtime();

for(j=0;j<COUNT;j++)t.foo();

showtime();

 

 

 

 

 

 

 

 

i++;

 

 

 

 

 

 

 

 

 

 

 

 

t.i++;

 

 

 

 

 

 

 

 

 

 

 

 

foo();

 

 

 

 

 

 

 

 

t.foo();

 

 

 

 

 

 

 

 

}

//测试在英特尔赛扬800MHz CPU

//VC++ 6.0 ,Windows 2000环境下完成

 

 

 

 

 

 

 

 

//定义循环次数

 

 

 

 

 

 

 

 

 

 

 

 

//输出毫秒级精度的时间函数,实现代码略

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//循环控制变量

//Test类的实例

//以下代码为性能测试,输出循环运行时间

 

 

 

 

 

 

 

 

//时间精确到毫秒级,i++t.i++运行时间相同

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

//时间精确到毫秒级,foo()t.foo()调用运行时间相同

 

 

 

 

//代码检验,输出对变量和函数访问的实际汇编代码

//0040128E   fld         qword ptr [i (0042f220)]

//00401294 fadd qword ptr [__real@8@3fff8000000000000000 (0042b050)]

//0040129A   fstp        qword ptr [i (0042f220)]

 

 

 

 

//004012A0   fld         qword ptr [ebp-10h]

//004012A3 fadd qword ptr [__real@8@3fff8000000000000000 (0042b050)]

//004012A9   fstp        qword ptr [ebp-10h]

 

 

 

 

//004012AC   call        @ILT+35(foo) (00401028)

//地址 00401028 处为一跳转指令,指向foo()函数的入口

 

 

 

 

//004012B1   lea         ecx,[ebp-10h]

//004012B4   call        @ILT+40(Test::foo) (0040102d)

//地址 0040102d 处为一跳转指令,指向Test::foo()函数的入口

 

 

 

 

从上面的结果我们可以知道,对类中封装了的数据和函数进行访问的时间与访问未封装的数据与函数相同。从上述反汇编出来的代码中我们可以发现,程序对封装数据和未封装数据的访问方式,在汇编代码的级别上是完全一样的。下面来看对函数的访问。

 

 

 

 

调用foo()生成的汇编代码为

004012AC   call        @ILT+35(foo) (00401028)

而调用类成员函数生成的代码为

004012B1   lea         ecx,[ebp-10h]

004012B4   call        @ILT+40(Test::foo) (0040102d)

 

 

 

 

这条指令引起了性能问题吗?前面进行的时间测试告诉我们这两个函数运行的时间是相同的,而且,针对一个函数调用过程,编译器会产生至少上百条汇编代码,因此,这里多出来的一条指令,在考虑性能问题的时候,完全可以忽略。那么,这条指令的作用是什么呢?在2.2.1 类成员函数的调用方法这一节中我分析了this指针的作用和实现,这条指令,便是起这个作用,具体的细节,请参考2.2.1节。

 

 

 

 

通过上面的分析,我们知道封装在普通成员变量和普通成员函数方面没有引起性能上的额外开销,参考C++ Class的内存格局一节,我们可以很容易的发现封装对静态成员也没有任何的性能开销,那么,虚函数呢?这的确是一个问题。

 

 

 

 

系统访问虚函数,实现要通过指针索引虚拟函数表,并且考虑到派生类对虚函数的改写,虚拟函数表实际上是在运行期才进行绑定的。关于虚函数的详细分析,将在多态一部分中展开。

 

 

 

 

根据权威测试,C++由封装而引起的性能损失相比同类的C语言,大概在5%以内。5%的性能损失,换来面向对象的编程,我想,任何程序员都是会做出明智的选择的。



Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=14997

原创粉丝点击