对Object Pascal编译器给类对象分配堆内存细节的一种大胆猜测

读过我以前写的文章的网友,都知道我是一个喜欢“刨根问底”、“死钻牛角尖”的家伙。最近由于工作需要转学DELPHI,在接触Object Pascal之后,果然领会到了它的 整洁和优美,怪不得连《程序设计语言:设计与实现》一书的作者也称赞 pascal 一种极优美的语言 。但在学习过程中遇到了好多问题,特别是对于像我这样由 C++ 转至 OP[ Object Pascal 的简称 ] 学习的人,由于两种语言风格不同,问号就会更多了。其中, OP C++ 语言的一个很大的区别就是:类对象 [ 或称之为类实例 ] 的内存分配机制不同。其中有两方面要说:
一、什么时候分配?
C++ 中,定义了对象,那么马上分配其内存,之后调用其构造函数,这个内存可能在堆中,也可能在栈内,也可能在全程数据区内。但 OP 却截然不同,定义一对象,如: obj : TObject; 只是为其分配了 4 字节的一个指针空间,而真正的对象空间还没有分配,那怎么用?在使用前当然要给对象分配空间,不然就会造成访问内存出错,给对象分配空间的办法也很简单:
obj := Tobject.Create;
OK ,这个对象空间是分配在堆内的,大家知道,栈内空间可以在使用期过后自动回收,但堆内存需要程序员自己管理,所以在使用完类对象之后,别忘了 obj.Free [ 真正实现析构的是 obj.Destroy ,但 obj.free 是一种更安全的方式 ]
“什么时候分配”这个问题在 OP C++ 上的答案确实不同,但还不至于让我“疑惑”。知道了 OP 类对象是通过调用这样的语句(构造函数): obj := Tobject.Create; 来得到堆内存的,但在这个处理细节上,编译器在内部是如何实现分配堆内存的呢?
请看下一个问题:
二、 OP 编译器是如何分配的内存?
首先要感谢 Lippman 的《 Inside C++ Ojbect Model 》,这是一本不可多得的好书,她告诉了你对于 C++ 编译器实现的一些你最迷惑、也是最想关心的细节,但不知 DELPHI 业界内有没有这样一本书,可以让我清楚的了解到 OP 编译器具体 [ 具体到每个细节 ] 是如何给一个类对象分配堆内存的 [ 如果有这样的书,您一定要通知我: coder@vip.sina.com]
我大胆的做了猜测!
一些小动作都是在 Tobject 类内部事先已经定义好的!下面让我们来关注一下这几个 Tobject 类方法 (Tobject 定义于 System.pas)
 TObject = class
    ……
    constructor Create;
    procedure Free;
    class function InitInstance(Instance: Pointer): TObject;
    procedure CleanupInstance;
    class function InstanceSize: Longint;
    class function NewInstance: TObject; virtual;
    procedure FreeInstance; virtual;
    destructor Destroy; virtual;
 end;
从方法的名称上我们能隐约的感觉到: NewInstance FreeInstance 肯定和类对象的构造和析构有些关联!
先来分析一下 NewInstance
class function TObject.NewInstance: TObject;
begin
 Result := InitInstance(_GetMem(InstanceSize));
end;
只有一句代码,但却调用了三个其它方法:
1
class function TObject.InstanceSize: Longint;
begin
 Result := PInteger(Integer(Self) + vmtInstanceSize)^;
end;
这个方法是 OP 类实现 RTTI 的一个重要方法,它能返回类对象所需要占用堆内存的大小,注意它并非是类对象所占有内存大小,因为类对象是一指针,那么在 32 位环境下,指针永远是 4 字节!
大家可能对这句代码比较疑惑 Result := PInteger(Integer(Self) + vmtInstanceSize)^; 下面我定义一个 OP 类:
TBase = class(TObject)
    x : Integer;
    y : Double;
    constructor Create;
end;
然后分配内存:
b : Tbase ;
 
b := TBase.Create;
我设想分配后的内存布局应是这样的 [ C++ 对象的内存考虑联想的 ]
再来看这句: Result := PInteger(Integer(Self) + vmtInstanceSize)^; 它的目的是取到 VMT Index = -40[ 注意:常量 vmtInstanceSize = -40] 的格子中的内容。大家看这里的
Self 变量是什么值呢?是 b 的值也就是 VPTR ADDRESS 吗?绝对不是!因为程序在执行到 TObject.InstanceSize 时只是想通过调用它知道得划分多少堆内存,但还没有正式分配堆内存,也就是说, VPTR X Y 还不存在 [ VMT 是和类一同建立起来的,它包含了和类有关的一些信息,如类实例需要请求的堆内存的大小等等 ] ,当然这个 Self 也就不能是 b 的值了,我猜测它的内容是 VMT index = 0 的格子的 Address, 只有这样,这里的代码和下面要讲的代码才能被正常解释,但, Self 是怎么被 Assigned 为这个值的,我想是编译器所做的处理吧。
这样, Result := PInteger(Integer(Self) + vmtInstanceSize)^ 自然得到了类对象所需要堆内存大小的信息!
为了证明我上面的猜测是正确的,大家可以实验以下代码:
var
b :Tbase;
size_b : Integer;
begin
b := TBase.Create;
ShowMessage(Format('InitanceSize of TBase : %d',[b.InstanceSize]));
 
size_b := PInteger(PInteger(b)^ - 40)^;
ShowMessage(Format('InitanceSize of TBase : %d',[size_b]));
……
end;
大家可以看到,两种方法得到的是同一个值!
好,现在我们回过头来讲解 TObject.NewInstance 中要调用的第二个函数。
2 function _GetMem(Size: Integer): Pointer;
它在 System.pas  中的定义如下:
function _GetMem(Size: Integer): Pointer;
{$IF Defined(DEBUG) and Defined(LINUX)}
var
 Signature: PLongInt;
{$IFEND}
begin
 if Size > 0 then
 begin
{$IF Defined(DEBUG) and Defined(LINUX)}
    Signature := PLongInt(MemoryManager.GetMem(Size + 4));
    if Signature = nil then
      Error(reOutOfMemory);
    Signature^ := 0;
    Result := Pointer(LongInt(Signature) + 4);
{$ELSE}
    Result := MemoryManager.GetMem(Size);
    if Result = nil then
      Error(reOutOfMemory);
{$IFEND}
 end
 else
    Result := nil;
end;
具体代码就不分析了,但我们终于看到了 OP 中分配堆内存的具体函数,原来是 OP 是通过一个内存管理器 MemoryManager 来管理类对象所取得的堆内存空间的!
TObject.NewInstance 中第三个调用的方法:
3
class function TObject.InitInstance(Instance: Pointer): TObject;
{$IFDEF PUREPASCAL}
var
 IntfTable: PInterfaceTable;
 ClassPtr: TClass;
 I: Integer;
begin
 FillChar(Instance^, InstanceSize, 0);
 PInteger(Instance)^ := Integer(Self);
 ClassPtr := Self;
 while ClassPtr <> nil do
 begin
    IntfTable := ClassPtr.GetInterfaceTable;
    if IntfTable <> nil then
      for I := 0 to IntfTable.EntryCount-1 do
 with IntfTable.Entries[I] do
 begin
    if VTable <> nil then
      PInteger(@PChar(Instance)[IOffset])^ := Integer(VTable);
 end;
    ClassPtr := ClassPtr.ClassParent;
 end;
 Result := Instance;
end;
{$ELSE}
asm
        PUSH    EBX
        PUSH    ESI
        PUSH    EDI
        MOV     EBX,EAX
        MOV     EDI,EDX
        STOSD
        MOV     ECX,[EBX].vmtInstanceSize
        XOR     EAX,EAX
        PUSH    ECX
        SHR     ECX,2
        DEC     ECX
        REP     STOSD
        POP     ECX
        AND     ECX,3
        REP     STOSB
        MOV     EAX,EDX
        MOV     EDX,ESP
@@0:    MOV     ECX,[EBX].vmtIntfTable
        TEST    ECX,ECX
        JE      @@1
        PUSH    ECX
@@1:    MOV     EBX,[EBX].vmtParent
        TEST    EBX,EBX
        JE      @@2
        MOV     EBX,[EBX]
        JMP     @@0
@@2:    CMP     ESP,EDX
        JE      @@5
@@3:    POP     EBX
        MOV     ECX,[EBX].TInterfaceTable.EntryCount
        ADD     EBX,4
@@4:    MOV     ESI,[EBX].TInterfaceEntry.VTable
        TEST    ESI,ESI
        JE      @@4a
        MOV     EDI,[EBX].TInterfaceEntry.IOffset
        MOV     [EAX+EDI],ESI
@@4a:   ADD     EBX,TYPE TInterfaceEntry
        DEC     ECX
        JNE     @@4
        CMP     ESP,EDX
        JNE     @@3
@@5:    POP     EDI
        POP     ESI
        POP     EBX
end;
{$ENDIF}
刚才知道 _GetMem 已经得到了堆内存空间,而我们现在要讨论的这个方法是进行一些必须的初始化。其它代码不管,只看这两句:
FillChar(Instance^, InstanceSize, 0);
PInteger(Instance)^ := Integer(Self);
第一就是给类对象清零,现在我们知道为什么 OP 的类实例的字段会自动被初始化为零了吧 [String 就为空,指针就为 nil]
第二条语句,是让 VTPR 指针指向 VMT 表的 0 号格子 [ 读者请参考结构图自行分析,此处也证明上面我对 Self 值的猜测的正确性 ]
 
到了这里,你也许会说,说了半天,都是猜测,或许, OP 编译器根本就不会调用那个 TObject.NewInstance 方法呢!
问得好,再做实验!
还是以上面的那个 Tbase 类为例,重载 TObject.NewInstance 方法,如下:
TBase = class(TObject)
    x : Integer;
    y : Double;
    class function NewInstance: TObject; override;
    procedure FreeInstance; override;
    constructor Create;
 end;
 
{ 实现 }
constructor TBase.Create;
begin
 self.x := 2;
 self.y := 3.14;
end;
 
procedure TBase.FreeInstance;
begin
 inherited;
 ShowMessage(Format('Call %s.FreeInstance!!!',[self.ClassName]));
end;
 
class function TBase.NewInstance: TObject;
begin
 ShowMessage(Format('call %s.NewInstance',[self.ClassName]));
 result := inherited NewInstance;
end;
 
之后进行简单的声明对象:
var
b : Tbase;
begin
b := Tbase.Create;     ß 在这里设断点!
b.Free;
end;
通过对代码进行跟踪果然在一进入 Create 就马上调用 NewInstance 方法。
[ 说明:一定要重载它才能跟踪到它,在断点处,观察 CPU ,从反汇编后的代码中可以发现,是先调用一个 _ClassCreate ,然后才调用 NewInstance]
用同样的方法可以分析出 b.Free 会最终调用到 FreeInstance; 来释放对象。
 
我想基本上大的问题已经说请了, Object Pascal 为了实现分配堆内存,在你调用构造器的时候:
b := Tbase.Create;
在构造方法内你的代码前,安插了代码调用 NewInstance 方法,析构时,则在析构函数中你的代码后,调用 FreeInstance 函数。
 
那么,现在再来看这种情况:派生
TBase = class(TObject)
    x : Integer;
    y : Double;
    class function NewInstance: TObject; override;
    procedure FreeInstance; override;
    constructor Create;
 end;
 
 TSub = class (TBase)
    m : Integer;
    n : Double;
    constructor Create;
 end;
 
{ 实现 }
constructor TBase.Create;
begin
 self.x := 2;
 self.y := 3.14;
end;
 
procedure TBase.FreeInstance;
begin
 inherited;
 ShowMessage(Format('Call %s.FreeInstance!!!',[self.ClassName]));
 
end;
 
class function TBase.NewInstance: TObject;
begin
 ShowMessage(Format('call %s.NewInstance',[self.ClassName]));
 result := inherited NewInstance;
 
end;
 
{ TSub }
 
constructor TSub.Create;
begin
 inherited Create;         ß 注意这里!
 self.m := 4;
 self.n := 12.32;
end;
我们已经知道,
var
s : Tsub;
 
s := Tsub.Create;
时,在进入 Tsub.Create 内部马上得到了它想要的内存 [ 这里是 32 字节 ] ,那么当:
inherited Create; 时,在 Tbase.Create 内部,还有内存分配的动作吗?我们可以通过三点证明:这里, Tbase.Create 只是完成程序员给出的初始化代码,没有进行内存分配的动作。
第一点, ReturnValue := inherited Create; 所得到的返回地址和调用 Tsub.Create 所得到的返回地址相同。
第二点,如果在 Tbase.Create 内部又分配新的内存,那么
self.x := 2;
self.y := 3.14;
只是针对新的内存操作,而原来的 S 对象中从 TBASE 中继承来的 X Y 不会变,还是 0 ,但我们发现, S 中的 X Y 已经改变,所以也可以证明 Tbase.Create 没有分配新的内存,只是对原有内存中的 X Y 进行设置。
第三点,跟踪。这是最简单,最一目了然的方法,看看 inherited Create; 到底有没有调用 NewInstance ,实验证明,跟本没有调用。
 
但是,如果把 Tsub.Create 中的 inherited Create; 改为 Tbase.Create; 情况则大不同了,用上面三种方式发现,它又分配了新的堆内存,这样不但没有达到程序员初始化数据的目的,反而造成了内存泄漏,而这样的 BUG 是很难找到的。
也就是说,编译器发现如果是通过类来调用构造函数,就会当成是新的类对象进行构造、分配堆内存,如果是在构造器内部 inherited Create ;只是按常规的处理 类方法 的方式进行处理。我想,对于 Anders Hejlsberg[DELPHI 设计者 ] ,想在编译器中实现这样的功能并非一件难事 [ 实际上,我们通过查看汇编代码也能分析出个中原由,有兴趣者请注意其中的 TEST d1,d1 指令和其下的跳转指令 ]
 
 
PS :刚才被网友告知有本书叫《 delphi的原子世界 》,我很想得到它,如果您手上有它的 E-BOOK 版,希望您能发给我:  coder@vip.sina.com
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值