2.6.1引用计数与增加引用
Delphi内部对“引用(Reference)”的处理稍显混乱。赋值运算符“=”、函数的值参数入口和一些内部例程都可能导致引用。但是相同的规则并不能应用于所有支持计数的数据类型,并且,对于一些看似类同的数据类型,规则的作用范围也不尽相同。
从内部来看,Delphi已经尽可能地将不同类型的“增加引用”和“减少引用”例程分离开来。例如长字符串的“增加引用”调用_LStrAddRef(),动态数组则调用DynArrayAddRef()。但是一些例程名字却着实让人误解,例如AddRefArray(O并非增加数组的引用,而是成组地增加一批变量的引用。
支持“引用计数”的只有少数的几个数据类型,如长字符串、动态数组、接口和变体类型。但是由于一些复杂的数据类型中的子域会声明成上述这些类型,因此,操作复杂数据类型的时候,也必须逐一考查其子域,对支持“引用计数”的数据进行计数修正。因此,虽然这些复杂数据类型也有“增加引用”的行为,但发生计数修改的是它们的子域,而非其自身。表2-3、表2-4说明了这些类型的不同。
2.6.2“增加引用”何时发生
Delphi中,例程中的代码可以对例程入口的值参数进行写操作。为了应付这种情况,Delphi须在进入例程之前,为一个值参数创建一个备份。例程中对值参数的操作都实际发生在这个备份上,而不会对原来的值参数造成影响。
理论上讲,这样的备份应该是对值参数的一个完全复制。但是,Delphi实际上只是对简单类型进行这样的处理,例如将整型值参数的值复制到堆栈,或者将集合放到寄存器。对于复杂的数据类型(例如数组、记录),为了节约内存开销和提高系统性能,编译器对数据类型的每一个部分进行考查,并确认哪些采用“值复制”,那些采用“引用计数”,并将采用“引用计数”的域的信息附加在类型信息中的一张表里。在运行期根据这张表进行的“引用计数”操作,就是“增加引用(AddRef)”的过程。
因此,通常会看到记录和数组“增加引用”的过程发生在例程的入口处。下面的例子会在例程的begin中调用_AddRefRecord();
//Sizeof(TRec)=8,例程将在栈上开8bytes,用于生成一个入口值参数备份 type TRec =record I:Integer; s:String; end; procedure TestRecordAddRef(R:TRec); begin R.S:= ''; end; //.…以下反汇编TestRecordAddRef()的begin处理 Unitl.pas.31:procedure TestRecordAddRef(R:TRec); Unit1.pas.32:begin 0044C9BC 55 push ebp 0044C9BD 8BEC mov ebp,esp //在栈上开8字节的空间,设为_Buf,地址为[ebp-$08] 0044C9BF 83C4F8 add esp,-$08 //以下6条指令,用于完成入口参数R到_Buf的复制 //现在相当于完成了调用move(R,Buf,sizeof(TRec)) 0044C9C256 push esi 0044C9C357 push edi 0044C9C4 8BF0 mov esi,eax 0044C9C6 8D7DF8 1ea edi,[ebp-$08] 0044C9C9 A5 movsd 0044C9CA A5 movsd //调用_AddRefRecord(),为记录的域增加引用 0044C9CB 8D45F8 1ea eax,[ebp-$08]//_Buf地址到eax,开始操作备份 0044C9CE 8B15A0C94400 mov edx,[$0044c9a0] 0044C9D4 E8CB7FFBFF call BAddRefRecord //以下开始在栈上填入异常帧(参见相关章节) 0044C9D9 33C0 xor eax,eax 0044C9DB55 push ebp 0044C9DC 680ACA4400 push $0044ca0a 0044C9E1 64FF30 push dword ptr fs:[eax] 0044C9E4648920 mov fs:[eaxl,esp Unit1.pas.33://..…
此后,编译器会将每个对入口参数R的操作,定位到[ebp-$08]上。例如代码:
R.S:= '';
会被编译为:
Unit1. pas.33:R.S := ''; 0044C9EE 8D45FC 1ea eax,[ ebp-$04]//ebp-$08+Sizeof(P.I) 0044C9F1 E81A74FBFF ca11 QLStrclr
2.6.3增加引用的操作是依赖类型信息来实现的
只有使用类型信息,才能在运行期分析数据类型。因此在System,pas中,数组和记录操作是为数不多的几个使用运行期类型信息(RTTI)的地方。
可以通过function TypeInfo()来取得一个指向类型信息的指针,这包括全部的基本数据类型和一些构造类型(如果它包含一些“具有引用计数特性的数据类型”的元素、字段或域,则该构造类型也具有类型信息)。和Pi()一样,function TypeInfo()也不是一个真的函数。在编译期,Delphi能确定每一个类型的类型信息地址,因此一旦编译器遇到function TypeInfo(),总是直接替换成相应的类型地址。如下例:
Unitl.pas.29:ptr:=TypeInfo(TForm); //TFrom的类型信息在地址$00441054,直接送入寄存器eax 0044C878A154104400 mov eax,[$00441054] //eax送入栈上的ptr 0044C87D 8945F8 mov [ebp-$08],eax
在AddRefRecord()例程中就隐含了一个typeinfo参数,该例程实际的声明是:
Drocedure AddRefRecord (p:Pointer;typeInfo:Pointer);
该例程的具体实现代码可查阅system.,pas,下面是根据源码改写的PUREPASCAL版本的AddRefRecord():
//define in system.pas type TFieldInfo =packed record TypeInfo:PPTypeInfo; Offset:Cardinal; end; procedure _AddRefArray (p:Pointer;typeInfo:Pointer; elemcount:Longint); begin // code in system.pas end; procedure _AddRefRecord(const R;typeInfo:Pointer); var i,maxI:Integer; D:Pointer; begin inc(PByte(typeInfo));//skip kind inc(PByte(typeInfo),Byte(typeInfo~)+1);// skip name(ShortString) inc(PByte(typeInfo),4); maxI:=PInteger(typeInfo);//取引用表(数组)的长度 inc(PByte(typeInfo),4);//定位到引用表的头部 for i:=0 to maxI-1 do begin p:=Pointer(Integer(eR)+TFieldInfo(typeInfo").offset); AddRefArray(p,TPieldInfo(typeInfo~).TypeInfo,1); end; end;
AddRefRecord()总是在记录的typeInfo中找出引用列表(数组)的长度和表首地址。引用列表(数组)只保存需要定位的类型变量的信息,它的元素类型为TFieldInfo。TFieldInfo.Offset 表示需要定位的元素自变量首地址开始的偏移字节数。因此,对于如下的记录:
type TRec=record I:Integer; S:String; end;
来说,引用列表(数组)只有一个元素,其FieldInfo.TypeInfo指向TRec.S的类型信息(即String的类型信息),而FieldInfo.Offset值为4,表明TRec.S的地址相对于记录首部的偏移为4Byte。
整型是简单数据类型,所以TRec.I直接复制值;而String类型使用引用计数机制,所以TRec.S被加入引用列表(数组)——这是它们的不同。
2.6.4写复制与值参数的备份
写复制技术被用在很多的地方,例如DLL的映像,但这是在运行期由操作系统决定的行为。在Delphi中,写复制是在编译期就被决定的,它通过内核例程和编译器的语义分析两个方面来实现。
只有使用引用计数的数据类型会发生写复制。具体的说,是指长字符串、宽字符串、动态数组、接口和变体这五种数据类型。写复制机制内嵌于类型的基本操作例程中,且只有在数据发生修改时,写复制才会发生。
以长字符串为例。编译器在语义分析时,会检查每一段使用下标访问字符串的语句,并在最前面插入“ca11@UniqueStringA”。这个例程用来检查字符串的引用计数,如果发现字符串被引用(引用计数大于1),就复制一个新的字符串并返回给原变量。这样,操作这个字符串就不会再影响到引用它的其他字符串。
但是,字符串的内部例程并不调用_UniqueStringA()。例如_LStrCat(),该例程会首先调用_LStrSetLength()来为结果字符串分配内存,而_LStrSetLength()则先检测引用计数位,如果大于1,则调用_NewAnsiString()来创建新的字符串。源代码如下:
procedure LStrSetLength(var str: Ansistring; newLength: Integer); asm CMP [ EAX-skew]. StrRec. refcnt,1 JNE @@copyString //... @@ecopyString: MOV EAX, EDX CALL_NewAnsistring //.. end;
值参数的备份源自于例程的调用约定:值参数可以被改写,但是不会影响到传值的原始变量。因此,编译器需要为值参数制作一个备份,而无论代码中是否要写该值参数。这个复制操作在编译时就被决定了,例程被调用一次,就发生一次复制操作。这与写复制是不同的。前面已经讲述过,在值参数备份中,针对不同的数据类型,生成备份的方法不同,具体如下:
- 如果是简单数据类型,则直接复制参数的值。
- 如果是具有引用计数位的数据类型,则调用相应的函数产生一个引用。
- 如果是数组或记录,则将元素或域按照上述规则分别处理。
■其他
数组和记录的引用列表(数组)其实也是它们的初始化表。在第4章的“初始化与结束化过程”一节中,会再一次讲到它们。
如果确知在代码中不需要改写入口的值参数,那么可以用const声明它。这样做的结果是编译器将向例程直接传入参数的地址,而不再是使用参数值的引用。正是因此,如果使用指针访问该地址,就可以对原值进行直接修改。下面的例子揭示如何修改用const声明的值参数:
($O-} (SAPPTYPE CONSOLE} type PRec=~TRec; TRec=record I: Integer; s: String; end; procedure ChangeConstParam(const R: TRec); begin PRec(QR)".I:=6; end; var Rec: TRec; begin Rec.I:=5; writeln(Rec.I); ChangeConstParam(Rec); writeln(Rec.I); end.