DELPHI的原子世界(2)
关键词:Delphi控件杂项
第二节 TClass原子
在System.pas单元中,TClass是这样定义的:
TClass = class of TObject;
它的意思是说,TClass是TObject的类。因为TObject本身就是一个类,所以TClass就是所谓的类的类。
从概念上说,TClass是类的类型,即,类之类。但是,我们知道DELPHI的一个类,代表着一项VMT数据。因此,类之类可以认为是为VMT数据项定义的类型,其实,它就是一个指向VMT数据的指针类型!
在以前传统的C++语言中,是不能定义类的类型的。对象一旦编译就固定下来,类的结构信息已经转化为绝对的机器代码,在内存中将不存在完整的类信息。一些较高级的面向对象语言才可支持对类信息的动态访问和调用,但往往需要一套复杂的内部解释机制和较多的系统资源。而DELPHI的Object Pascal语言吸收了一些高级面向对象语言的优秀特征,又保留可将程序直接编译成机器代码的传统优点,比较完美地解决了高级功能与程序效率的问题。
正是由于DELPHI在应用程序中保留了完整的类信息,才能提供诸如as和is等在运行时刻转换和判别类的高级面向对象功能,而类的VMT数据在其中起了关键性的核心作用。有兴趣的朋友可以读一读System单元的AsClass和IsClass两个汇编过程,他们是as和is操作符的实现代码,以加深对类和VMT数据的理解。
有了`类的类型,就可以将类作为变量来使用。可以将类的变量理解为一种特殊的对象,你可以象访问对象那样访问类变量的方法。例如:我们来看看下面的程序片段:
type
TSampleClass = class of TSampleObject;
TSampleObject = class( TObject )
public
constructor Create;
destructor Destroy; override;
class function GetSampleObjectCount:Integer;
procedure GetObjectIndex:Integer;
end;
var
aSampleClass : TSampleClass;
aClass : TClass;
在这段代码中,我们定义了一个类TSampleObject及其相关的类类型TSampleClass,还包括两个类变量aSampleClass和aClass。此外,我们还为TSampleObject类定义了构造函数、析构函数、一个类方法GetSampleObjectCount和一个对象方法GetObjectIndex。
首先,我们来理解一下类变量aSampleClass和aClass的含义。
显然,你可以将TSampleObject和TObject当作常量值,并可将它们赋值给aClass变量,就好象将123常量值赋值给整数变量i一样。所以,类类型、类和类变量的关系就是类型、常量和变量的关系,只不过是在类的这个层次上而不是对象层次上的关系。当然,直接将TObject赋值给aSampleClass是不合法的,因为aSampleClass是TObject派生类TSampleObject的类变量,而TObject并不包含与TSampleClass类型兼容的所有定义。相反,将TSampleObject赋值给aClass变量却是合法的,因为TSampleObject是TObject的派生类,是和TClass类型兼容的。这与对象变量的赋值和类型匹配关系完全相似。
然后,我们再来看看什么是类方法。
所谓类方法,就是指在类的层次上调用的方法,如上面所定义的GetSampleObjectCount方法,它是用保留字class声明的方法。类方法是不同于在对象层次上调用的对象方法的,对象方法已经为我们所熟悉,而类方法总是在访问和控制所有类对象的共同特性和集中管理对象这一个层次上使用的。在TObject的定义中,我们可以发现大量的类方法,如ClassName、ClassInfo和NewInstance等等。其中,NewInstance还被定义为virtual的,即虚的类方法。这意味作你可以在派生的子类中重新编写NewInstance的实现方法,以便用特殊的方式构造该类的对象实例。
在类方法中你也可使用self这一标识符,不过其所代表的含义与对象方法中的self是不同的。类方法中的self表示的是自身的类,即指向VMT的指针,而对象方法中的self表示的是对象本身,即指向对象数据空间的指针。虽然,类方法只能在类层次上使用,但你仍可通过一个对象去调用类方法。例如,可以通过语句aObject.ClassName调用对象TObject的类方法ClassName,因为对象指针所指向的对象数据空间中的头4个字节又是指向类VMT的指针。相反,你不可能在类层次上调用对象方法,象TObject.Free的语句一定是非法的。
值得注意的是,构造函数是类方法,而析构函数是对象方法!
什么?构造函数是类方法,析构函数是对象方法!有没有搞错?
你看看,当你创建对象时分明使用的是类似于下面的语句:
aObject := TObject.Create;
分明是调用类TObject的Create方法。而删除对象时却用的下面的语句:
aObject.Destroy;
即使使用Free方法释放对象,也是间接调用了对象的Destroy方法。
原因很简单,在构造对象之前,对象还不存在,只存在类,创建对象只能用类方法。相反,删除对象一定是删除已经存在的对象,是对象被释放,而不是类被释放。
最后,顺便讨论一下虚构造函数的问题。
在传统的C++语言中,可以实现虚析构函数,但实现虚构造函数却是一个难题。因为,在传统的C++语言中,没有类的类型。全局对象的实例是在编译时就存在于全局数据空间中,函数的局部对象也是编译时就在堆栈空间中映射的实例,即使是动态创建的对象,也是用new操作符按固定的类结构在堆空间中分配的实例,而构造函数只是一个对已产生的对象实例进行初始化的对象方法而已。传统C++语言没有真正的类方法,即使可以定义所谓静态的基于类的方法,其最终也被实现为一种特殊的全局函数,更不用说虚拟的类方法,虚方法只能针对具体的对象实例有效。因此,传统的C++语言认为,在具体的对象实例产生之前,却要根据即将产生的对象构造对象本身,这是不可能的。的确不可能,因为这会在逻辑上产生自相矛盾的悖论!
然而,正是由于在DELPHI中有动态的类的类型信息,有真正虚拟的类方法,以及构造函数是基于类实现的等等这些关键概念,才可实现虚拟的构造函数。对象是由类产生的,对象就好象成长中的婴儿,而类就是它的母亲,婴儿自己的确不知道自己将来会成为什么样的人,可是母亲们却用各自的教育方法培养出不同的人,道理是相通的。
正是在TComponent类的定义中,构造函数Create被定义为虚拟的,才能使不同类型的控件实现各自的构造方法。这就是TClass创造的类之类概念的伟大,也是DELPHI的伟大。
......................................
第三章 WIN32的时空观
我的老父亲看着地上玩玩具的小孙子,然后对我说:“这孩子和小时的你一样,喜欢把东西拆开,看过究竟才罢手”。想想我小时侯,经常将玩具车、小闹钟、音乐盒,等等,拆得一塌糊涂,常常被母亲训斥。
我第一次理解计算机的基本原理,与我拆过的音乐盒有关。那是在念高中时的一本漫画书上,一位白胡子老头在讲解智能机的理论,一位留八字胡的叔叔在说计算机和音乐盒。他们说,计算机的中央处理器就是音乐盒中用来发音的那一排音乐簧片,计算机程序就是音乐盒中那个小圆筒上密布的凸点,小圆筒的转动相当于中央处理器的指令指针的自然移动,而小圆筒上代表音乐的凸点控制音乐簧片振动发音相当于中央处理器执行程序的指令。音乐盒发出美妙的旋律,是按工匠早已刻在小圆筒上的音乐谱演奏的,计算机完成复杂的处理,是根据程序员预先编制好的程序实现的。上大学之后,我才知道那个白胡子老头就是科学巨匠图灵,他的有限自动机理论推动了整个信息革命的发展,而那个留八字胡的叔叔就是计算机之父冯.诺依曼,冯氏计算机体系结构至今仍然是计算机的主要体系机构。音乐盒没白拆,母亲可以宽心。
有深入浅出的理解,才能有高深而又简洁的创造。
这一章我们将讨论Windows的32位操作系统中与我们编程有关的基本概念,建立WIN32中正确的时空观。希望阅读完本章之后,我们能更加深入地理解程序、进程和线程,理解执行文件、动态连接库和运行包的原理,看清全局数据、局部数据和参数在内存中的真相。
第一节 理解进程
由于历史的原因,Windows是起源于DOS。而在DOS时代,我们一直只有程序的概念,而没有进程的概念。那时侯,只有操作系统的正规军,如UNIX和VMS等等,才有进程的概念,而且多进程就意味着小型机、终端和多用户,也意味着金钱。我绝大多数的时间只能使用相对廉价的微机和DOS系统,只是在学操作系统这门课程时才开始接触进程和小型机。
在Windows 3.X之后,Microsoft才在图形界面的操作系统站住脚跟,而我也是在这时开始正式面对多任务和进程的概念。以前在DOS下,同一时间只能执行一个程序,而在Windows下同一时间可执行多个程序,这就是多任务。在DOS下运行一个程序的同时,不能执行相同的程序,而在Windows下,同一程序可以同时有两个以上的副本在运行,每一个运行的程序副本就是一个进程。更确切地说,任何程序的一次运行就产生一个任务,而每个任务就是一个进程。
当将程序和进程放到一起理解时,可以认为程序一词说的是静态的东西,一个典型的程序是由一个EXE文件或一个EXE文件加上若干DLL文件组成的静态代码和数据。而进程是程序的一次运行,是在内存中动态运行的代码和动态变化的数据。当静态的程序要求运行时,操作系统将为本次运行提供一定的内存空间,把静态的程序代码和数据调入这些内存空间,将程序的代码和数据进行重定位映射之后,就在该空间内执行程序,这样就产生了动态的进程。
同一个程序同时运行着的两个副本,意味着在系统内存中有两个进程空间,只不过它们的程序功能是一样的,但处于不同的动态变化的状态之中。
从进程运行的时间上来说,各进程是同时执行的,专业术语称为并行执行或并发执行。但这主要是操作系统给我们的表面感觉,实际上各进程是分时执行的,也就是各进程轮流占用CPU的时间来执行进程的程序指令。对于一个CPU来说,同一时间只有一个进程的指令在执行。操作系统是调度进程运行的幕后操纵者,它不断保存和切换各进程在CPU中执行的当前状态,使得每一个被调度的进程都认为自己是完整和连续地运行着。由于进程分时调度的速度非常快,所以给我们的感觉就是进程都是同时运行的。其实,真正意义上的同时运行只有在多CPU的硬件环境中才有。稍后在讲述线程一节时,我们将发现,真正推动进程运转的是线程,进程更重要的是提供了进程空间。
从进程占据的空间上来说,各进程空间是相对独立的,每一个进程在自己独立的空间中运行。一个程序既包括代码空间又包括数据空间,代码和数据都要占据进程空间。Windows为每一进程所需的数据空间分配实际的内存,而对代码空间一般都采用共享手段,将一个程序的一份代码映射给该程序的多个进程。这意味着,如果一个程序有100K的代码并需要100K的数据空间,也就是总共需要200K的进程空间,则第一次运行程序时操作系统将分配200K的进程空间,而运行程序的第二个进程时,操作系统只分配100K的数据空间,而代码空间则共享前一个进程的空间。
上面所说的是Windows操作系统中进程的基本时空观,其实Windows的16位和32位操作系统在进程的时空观上有很大的差异。
从时间上来说,16位的Windows操作系统,如Windows 3.x等,进程管理是非常简单的,它实际上只是一个多任务管理操作系统。而且,操作系统对任务的调度是被动的,如果一个任务不自己放弃对消息的处理,操作系统就必须等待。由于16位Windows系统在管理进程方面的缺陷,一个进程运行时,完全占有着CPU的资源。在那个年代,为了16位Windows可以有机会调度别的任务,微软公司大力赞扬开发Windows应用程序的开发者是心胸宽阔的程序员,以使得他们乐意多编写几行恩赐给操作系统的代码。相反,WIN32的操作系统,如Windows 95和NT等,才是具备了真正的多进程和多任务操作系统的能力。WIN32中的进程完全由操作系统调度,一旦进程运行的时间片结束,不管进程是否还在处理数据,操作系统将主动切换到下一进程。严格地说,16位的Windows操作系统不能算是完整的操作系统,而32位的WIN32操作系统才是真正意义上的操作系统。当然,微软公司不会说WIN32弥补了16位Windows的缺陷,而是宣称WIN32实现了一种称为“抢占式多任务”的先进技术,这是商业手段。
从空间上看,16位的Windows操作系统中的进程空间虽然相对独立,但进程之间可已很容易地互相访问对方的数据空间。因为,这些进程实际是在相同的物理空间中的不同的数据段而已,而且不当的地址操作很容易造成错误的空间读写,并使操作系统崩溃。然而,在WIN32操作系统中,各进程空间完全是独立的。WIN32为每一个进程提供一个可达4G的虚拟的,并且是连续的地址空间。所谓连续的地址空间,是指每一个进程都拥有从$00000000到$FFFFFFFF的地址空间,而不是向16位Windows的分段式空间。在WIN32中,你完全不必担心自己的读写操作会无意地影响到其他进程空间中的数据,也不用担心别的进程会来骚扰你的工作。同时,WIN32为你的进程提供的连续的4G虚拟空间,是操作系统在硬件的支持下将物理内存映射给你的,你虽然拥有如此广阔的虚拟空间,但系统决不会浪费一个字节的物理内存。
第二节 进程空间
在我们用DELPHI编写WIN32的应用程序时,很少去关心进程在运行时的内部世界。因为WIN32为我们的进程提供了4G的连续虚拟进程空间,可能目前世界上最庞大的应用程序也只用到了其中的部分空间。似乎进程空间是无限的,但4G的进程空间是虚拟的,而你机器的实际内存可能与此相差甚远。虽然,进程拥有如此广阔的空间,但有些复杂算法的程序还是会因为堆栈溢出而无法运行,特别是含有大量递归算法的程序。
因此,深入地认识和了解这4G的进程空间的结构,以及它与物理内存的关系等等,将有助于我们更清楚地认识WIN32的时空世界,从而可在实际的开发工作中运用正确的世界观和方法论解决各种难题。
下面,我们将通过简单的实验,来了解WIN32的进程空间的内部世界。这可能需要一些对CUP寄存器和汇编语言的知识,但我尽量用简单的语言来说明。
当启动DELPHI时,将自动产生一个Project1的项目,我们就拿它开刀。在Project1.dpr原程序的任意位置设一断点,比如,就在begin一句处设一断点。然后运行程序,当程序运行到断点时会自动停下来。这时,我们就可以打开调试工具中的CPU窗口来观察进程空间的内部结构了。
当前的指令指针寄存器Eip是停在$0043E4B8,从程序指令所在地址的最高两位16进制数都是零,可以看出当前的程序处在4G进程空间相当底端的地址位置,其占据$00000000到$FFFFFFFF的相当少的地址空间。
在CPU窗口中的指令框中,你可以向上查看进程空间中的内容。当查看小于$00400000的空间内容时,你会发现小于$00400000的内容出现一串串的问号“????”,那是因为该地址空间还未映射到实际物理空间的缘故。如果在这时,你查看一下全局变量HInstance的16进制值就会发现它也是$00400000。虽然HInstance反映的是进程实例的句柄,其实,它就是程序被加载到内存中的起始地址值,在16位Windows中也是如此。因此,我们可以认为进程的程序是从$00400000开始加载的,也就是从4G虚拟空间中的4M以后的空间开始是程序加载的空间。
从$00400000往后,到$0044D000之前,主要是程序代码和全局数据的地址空间。在CPU窗口中的堆栈框中,可以查看到当前堆栈的地址。同样,你会发现当前堆栈的地址空间是从$0067B000到$00680000的,长度为$5000。其实,进程最小的堆栈空间大小就是$5000,它是根据编译DELPHI程序时在ProjectOptions中Linker页中设置的Min stack size值,加上$1000而得到的。堆栈是由高端地址向底端增长的,当程序运行的堆栈不够时,系统将自动向地端地址方向增加堆栈空间的大小,这一过程将把更多的实际内存映射到进程空间。可在编译DELPHI程序时,通过设置ProjectOptions中Linker页中Max stack size的值,控制可增加的最大堆栈空间。特别是在含有深层次的子程序调用关系或运用递归算法的程序中,一定要合理地设置Max stack size的值。因为,调用子程序是需要耗用堆栈空间,而堆栈耗尽之后,系统就会抛出“Stack overflow”的错误。
似乎,从堆栈空间之后的进程空间就应该是自由的空间了吧。其实不然,WIN32的有关资料说,$80000000之后的2G空间是系统使用的空间。看来,进程能够真正拥有的只有2G空间。其实,进程能真正拥有的空间连2G都不够,因为从$00000000到$00400000的这4M空间也是禁区。
但不管怎样,我们的进程可以使用的地址还是非常广阔的。特别是堆栈空间之后到$80000000之间,是进程空间的主战场。进程从系统分配的内存空间将被映射到这块空间,进程加载的动态连接库将被映射到这块空间,新建线程的线程堆栈空间也将映射到这块空间,几乎所有涉及分配内存的操作都将映射到这块空间。请注意,这里所说的映射,意味着实际内存与这块虚拟空间的对应,没有映射为实际内存的进程空间是无法使用的,就象调试时CPU窗口指令框中的那一串串的“????”。
新闻热点
疑难解答
图片精选