light

专注于基于.Net平台的服务器应用.

导航

请注意代码是否最终被链入程序文件

Posted on 2004-07-26 14:55  light's cafe  阅读(622)  评论(0)    收藏  举报

请注意代码是否最终被链入程序文件

 

一.             问题摘要

Delphi的链接器只会将项目中已经使用到的代码链接到生成的程序文件内。这种链接器特性在某些时候造成一些比较难以查找的bug

 

二.             问题概述

       Delphi的链接器有着这样的一种特性:虽然我们的项目源代码当中会Uses很多的单元文件,而这些单元当中又包含很多行源代码,但是Delphi的链接器只会将项目当中最终用到的代码链接到随后生成的程序文件中。其实对于Delphi的链接器来说,它必须支持这个特性。如果不是这样处理的话,那么Delphi链接器最终所链接生成的程序文件大小将是我们所无法接受的。拿Delphi7来说,新建一个只有单From项目,然后查看这个FormPascal单元,会发现它Uses了如表1所示的其它单元:

 

1Delphi7中一个基于Form的单元文件所默认Uses的其它单元文件及其Dcu大小

 

Dcu文件名

文件大小(KB

Windows.dcu

606KB

Messages.dcu

29KB

Sysutils.dcu

136KB

Variants

86KB

Classes.dcu

198KB

Graphics.dcu

116KB

Controls.dcu

203KB

Forms.dcu

169KB

Dialogs.dcu

62KB

合记

1611(约1.57兆)

      

       由表1可知,如果Delphi编译器不支持这种“选择式链接”特性的话,那么最终链接生成的程序文件大小将要超过1.57兆。我之所以很肯定的说要“超过”1.57兆,是因为这些被Form单元默认Uses的单元文件中还会Uses其它的单元文件。如此往复,最终形成了一棵被Uses单元的树。现在我们所见到的只不过是这棵树上的几片“树叶”而已。如果将这棵树中所有单元文件内的代码都链接到程序文件中的话,那么我们也许会马上感觉到1.57兆文件的可爱:->

    然而,Delphi链接器所支持的这种“选择式链接”特性给我们进行程序调式工作带来了一定的麻烦。因为使用Delphi的开发者如果不是在工具的支持下,是根本无法在调试阶段得知一段代码是否已经被链接到程序文件中——它是否是可执行的。试想,你为一行代码设置了一段断点,然后你充满希望的把它当成一扇探究程序执行时逻辑之门时,它却始终没有在你所处的时空当中出现,那么你将会是什么样的感觉。这种感觉我和电影《Matrix》当中的neo都曾经感受过,只不过我所面对的处境要比neo安全的多罢了。

       为了解决这个问题,BorlandDelphi的代码编辑器增加了一种“提示”的功能,它会告诉你哪些代码是“被最终链接入程序文件中”的代码,而那些代码又是无效的。图1中展示了这种“提示”的长相。

      

       1

      

 

       注意,它们就是在左边显示的那个蓝色的小圆点(哦,我忘记,我正在Word当中键入文字,也许你看到的将只是一个黑白的小圆点)。这里有一个问题请你一定要小心,有些时候,当你编译程序时Delphi编译器并不会及时更新你代码编辑窗口当中的“它”。所以我建议你使用biuld all的方式进行编译。不过不管怎样,请多留意一下它,因为我在下面将要介绍到的问题就是由于我忽略了它们所造成的。另外,你一定要记得,不要为那些没有显示圆点的代码行设置断点哦,否则你就会感受到“没门”的痛苦了。

 

三.             问题实例

我还记得这个问题是我若干年前刚进入到公司的时候发生的,当时我受命撰写一个自动化类(Automation)。这个自动化类所需要实现的逻辑相对简单,它就是一个供ASP页面调用的字符串集合。在ASP页面上可以调用AddDeleteItemIndexOf等方法对它进行操作。我当时对这个自动化类的实现设计也是十分的简单明了:这个COM对象就是对Delphi所提供的TStringList类的Wrapper。它的代码大致如下所示:

 

代码段1

 

unit Files;

 

interface

 

uses

  Classes, ComObj, ActiveX, IOComponent_TLB, StdVcl;

 

type

  TFiles = class(TAutoObject, IFiles)

  private

    f_fileList: TStringList;

  protected

    function Get_Count: Integer; safecall;

    //other methods.....

    { Protected declarations }

  public

    constructor Create;

    destructor  Destroy; override;

  end;

 

implementation

 

uses ComServ;

 

{ TFiles }

 

constructor TFiles.Create;

begin

  inherited Create;

  f_fileList := TStringList.Create;

end;

 

destructor TFiles.Destroy;

begin

  f_fileList.Free;

  inherited Destroy;

end;

 

function TFiles.Get_Count: Integer;

begin

  result := f_fileList.Count;

end;

 

initialization

  TAutoObjectFactory.Create(ComServer, TFiles, Class_Files,

    ciMultiInstance, tmApartment);

end.

 

       其实这段代码真的是简单的不能再简单,它本身所做的工作只是简单的委托,并没有实现任何的逻辑。然而就是这么简单的一段代码,当我使用如下的vbs代码段对其进行测试的时候,却弹出了一个“灾难性故障”的错误提示窗口。这对于我的一切美好想法来说无异于当头一棒。

 

代码段2

set ioObj = CreateObject("IOComponent.Files")

MsgBox ioObj.Count

set ioObj = nothing

 

那么我的自动化类代码究竟哪里有错呢,当时我的经验并不丰富,但幸运的是,我很快就在帮助文件当中查找到了自动化类的基类TAutoObject当中有一个名称叫Initializeoverride方法。查看了它的文档后我发现,它就是用来在COM对象当中做初始化工作的。我在我的自动化类当中override了这个虚方法并将对象构造的代码移入其中,再次调试就没有再出现这个问题了。

由于当时我的时间比较紧,加之自己的经验并不丰富,调试可能需要很大的工作量,所以就暂时将这个问题放下,而没有再继续研究。

时至今日回过头来看这个问题,它给我的感觉已经不再像当年的那么恐怖,也很容易就调试出问题的原因。不过当我找到了造成这个问题的原因的时候,却发现这个造成问题的原因是我当时根本不可能想到的:这个自动化类的构造方法(Create)没有被最终链接到程序文件当中,也就是说TStringList对象根本就不会在自动化对象运行时被构造,而那个“灾难性错误”的提示框就是因为在自动化对象内部访问了未经创建的对象(f_fileList)所造成的(注1)。

       要透彻的了解为什么会造成这个问题,我想我们有必要简单的了解一下一个项目中什么样的代码才会被最终链接到程序文件内。

       如果我们去一个迷宫探险的话,那么我们那么第一步我们需要做什么呢?关于这个问题可能一千个人有一千种答案,不过对于像我这样的脚踢实地的实践者来说,答案一定会是——先找到迷宫的入口,如果这个迷宫有多个入口的话,那么我还要选择到底从哪个入口进入迷宫。其实对于Delphi的链接器来说也是一样的道理,Delphi的链接器在链接生成程序文件的时候,整个项目包含的所有Dcu 文件就好像是一个迷宫。而Delphi链接器呢,它就像是一个迷宫的探险者一样,需要从一个入口进入,然后寻找一条路径,进而走出迷宫。Delphi链接器在结束这次“探险”后,便会根据探险的结果,明确在整个项目当中哪些代码是被使用到的,然后将这些代码链接成为一个程序文件。

       在上面,我将Delphi的链接器比做是一个迷宫的探险者,其实有些不确切,更合理的比喻应该将Delphi的链接器比喻成一个探险团队,它由两位探险者组成,他们分别从不同的入口进入,然后得出两张“探险地图”。此后,Delphi的链接器根据这两张地图产生最终的产品——“迷宫指南”(最终的程序文件)。

       那么Delphi链接器到底是将哪两个位置作为入口点来探究整个项目的“已使用代码”的呢?正确的答案:一个入口点是程序的起初点(即项目文件的begin..end处,相当于C++代码当中的WinMainDllMain所标识的地方);而另一个入口点则是在项目中所有被Uses的单元文件内使用initializationfinalization关键字所标识的代码段。

       initializationfinalizationDelphi的两个关键字,用于标识单元文件需要在程序初始化时所做的工作。在Delphi所生成的程序文件被执行时,initialization段内的代码要早于程序起始点的代码被执行(注2)。下面让我们举一个例子来证明这一点:在程序的起始点中,我们可以对Delphi Rtl所提供的全局变量CmdLine进行访问,以使用命令行参数。而这个CmdLine全局变量就是在System.pas文件的Initialization段内被赋值的(详见Delphi7 System.pas单元文件的第17946行)。正是由于Delphi编译器有这种一定会将被uses单元initializationfinalization段中的代码链接入程序文件的特性,所以我们可以通过试着删除项目内所uses的含有initializationfinalization段的单元来给程序文件“减肥”。例如,我们可以在一个COM项目中,删除掉类型库Pascal单元文件中对Classes Graphics Variants等单元的uses。然后再次编译,我们就会发现COM组件将小上几十KB。当然,说这个就些点跑题了:->

       Delphi链接器对代码遍历的过程中,那些直接被调用到的代码一定会被链接入程序文件。除了这种显而易见的被调用到的代码外,还有一类代码,因为Delphi链接器在链接程序文件时并不能确定这些代码会不会在运行时被使用到,所以这些代码也将被Delphi链接器最终链接到程序文件中。

       提到“在运行时才能被确定是否被使用的代码”,也许你马上会想到面向对象技术的多态特性和面向对象程序设计语言中支持多态特性的虚方法。的确,如果Delphi链接器确认了一个类会在项目中被使用到的话,那么这个类内的所有虚方法或者从基类override下来的方法,不管有没有被显示的调用,都会被最终链接到可执行文件中。像这种“在运行时才能确定是否被使用的代码”Delphi编译器还支持几种,它们如下所示。最终这些代码都将被链接到程序文件中。

n         动态方法:动态方法是Delphi当中特有的概念,不过其实现理念和虚方法并没有什么本质的不同,二者都是在运行时通过查找一张表来执行相应的方法。所以如果Delphi链接器认为一个类将会在项目中被使用到,那么这个类中所有的动态方法就一定会被Delphi链接器链接到程序文件中。

n         Published成员。如果Delphi编译器认为一个类会在项目中被使用到的话,那么在这个类中的所定义的所有Published成员就一定会被Delphi链接器最终链接到程序文件中。这是因为将一个类成员定义为Published成员的话,那么也就表明了Delphi编译器需要为这个类成员产生RTTI信息,以供运行时解析。值得注意的是Delphi编译器将一个Form类中的VCL Components成员也视为Published成员,只不过Delphi的源代码编辑器内没有明确的将这一点表示出来罢了(注3)。

n         实现接口方法的方法。如果Delphi编译器认为一个实现接口的类在项目中会被使用到的话,那么Delphi链接器一定会将这个类中所有实现接口方法的方法链接到程序文件中。Delphi链接器之所以这样做,是因为Delphi的接口设计之初就是用来映射COM接口的,由于COM接口本身被使用的方式是由外部宿主所调用,Delphi编译器所以也就无法在编译期得知COM接口中到底有什么方法会被使用到。正因为这样Delphi链接器才必须将这些实现接口方法的方法统统链接到程序文件中。

 

       在基本了解了什么样的代码会被Delphi链接器最终链接入程序文件后,我们回到正题。在我们的单元文件中,只有一个地方引用了我们自定义的自动化类,那就是在initialization段中的TAutoObjectFactory类构造方法里面。TAutoObjectFactory类构造方法的第二个参数需要传递一个类型为TAutoClassClass-reference types(它是所有自动化类的基类TAutoObjectClass-reference types)。在TAutoObjectFactory类构造方法的内部,再使用这个Class-reference types参数来创建具体的自动化对象。Class-reference types支持多态调用,而我们的自动化类又是从TAutoObject类继承下来的,所以在这里可以直接将我们的自动化类传递给TAutoObjectFactory类的构造方法。有了在TAutoObjectFactory类构造方法中对我们自动化类的这一处引用,就保证了我们所创建的自动化类的代码有机会被最终链接到程序文件中。

       我们已经确认了在我们的项目中会使用到我们的自动化类,那么在我们的自动化类当中又有哪些方法会被编译器最终链接入程序文件呢?如前面原理描述部分所描述的那样,在我们的自动化类当中所定义的Get_Count等方法是实现接口方法的方法,所以它一定会被链接到程序文件中。而析构函数Destroy是从基类override下来的虚方法,它也会被链接到程序文件中。

在进行了上面的两次确认之后,在我们的观察范围中已经只剩下自动化对象的构造方法Create。虽然我们明知道它最终没有被链接器链接入程序文件,但是我还是希望我们能继续按这种步进式的求解方式一直走下去,直到我们找出这个问题的真正原因为止。毕竟我们的目的是希望从过程当中学到一些东西吗,我希望你能够同意我的观点。好了,让我们继续向前前进。

正如前面所述的那样, Class-reference types支持以多态的方来创建对象。从这个角度来看使用Class-reference types来创建对象和为接口或者抽象类创建对象的语义很相近,它们都会使用子类型的对象模型来创建对象,然后使用基类型所提供的对象模型使用对象。一般来说这样做的意义在于,为基类所描述的抽象概念提供一个具体的实现语义。而这个时候我们并不在乎子类相对于基类提供了什么样新的方法,事上当我们在使用这种抽象对象的时候由于,使用的是基类的对象模型,所以那些属于子类的新方法是没有机会被调用到的(当然我们可以使用向上转型的方式来使基类型转换到子类型,但是这种方式也就迫使子类和基类之间相互耦合,是一种不被推荐使用的方式)。

虽然为Class-reference types创建对象的语义与为接口、抽象类创建对象的语义相近。但在他们在实作时却还是有一些很微妙的差异,这些差异足以使我们在实作完全分开对待它们。请看下面两段代码,它们分别为抽象类和Class-reference types创建了对象。

 

代码段3——创建抽象类的对象:

procedure TForm1.ButtonClick(Sender: TObject);

var

  aObj: TMyAbstractClass;

begin

  aObj := TMyImplementClass.Create;

  aObj.ShowName;

end;

 

代码段4——使用Class-reference types创建对象:

type

TMyAbstractClassType = class of TMyAbstractClass;

 

function  CreateClassInstance(const ClassType: TMyAbstractClassType): TMyAbstractClass;

begin

  result := ClassType.Create();

end;

 

procedure TForm1.ButtonClick(Sender: TObject);

begin

  CreateClassInstance(TMyImplementClass).ShowName();

end;

 

       请注意观察以上两段代码中对象构造的代码行。在代码段3中,虽然我们将最终创建出的子类对象赋给了一个表示它基类对象的变量,但是这个对象却是完全按照子类的对象模型来创建的,在对象创建的过程中,子类自身的构造方法将会被调用。而代码段4中的对象创建过程则与之不同,虽然我们将一个子类的Class-reference types传递给了工场方法CreateClassInstance,但是在CreateClassInstance方法内部则完全是按照基类的对象模型来创建对象的。基类的对象模型只知道关于基类本身和其继承层次结构的信息,而它对我们子类的信息却是一无所知,所以在创建对象的时候它只会使用它自己或者上层基类的构造方法对对象进行构造,我们的子类的构造方法是根本不会被调用到的(注4)。唔。这正是造成我们问题的真正原因,在经过了一番探索之后我们终于找到了它。

       写到这里,我想我对这个问题的解释应该可以算是功德圆满了。但是在进入文章的下一个环节之前,我却想再这里延伸一下我们的讨论,因为我感觉那将是有趣和必要的。

       前面已经提到过。一个已经被项目到使用的类内,所有的虚方法都将被Delphi链接器链接到程序文件中。以这种理论进行推导,如果我们将我们自动化类的构造方法置为虚方法,然后再实际编译并执行程序,又会得到怎么样的结果呢?唔,这一次的最终结果是虽然我们自动化类的构造方法被链接器链接到了程序文件中,但是它却永远不会被执行到。测试脚本在运行的时候一样会弹出“灾难性故障”对话框。“被链入到程序内的代码没有按期望被执行”,这仿佛是给我们在程序调试时出的又一道难题。

       其实,不管是我们主要讨论的“代码是否被最终被链入程序文件”的问题,还是我们上面提到的“被链入到程序内的代码没有按期望被执行 ”的问题,它们不同的只是外部的表象,而究其本质它们都是相同的。既,都是由于 “期望的代码没有被执行”所造成的。在这里,我希望你能够认清这个问题的本质,并且能够举一反三,如果可以这样的话,那么以后你再遇到这类问题,它们将不会再成为阻挡你前进的障碍。

      

四.             解决方案

       从实践经验的角度来看,由这个问题所造成的程序bug是比较容易解决的。因为它所带来的连带成分应该并不多。在发现问题之后我们往往只需要做很少的修改就可以解决它。但是相对于这类bug的易解决性来说,由于它所造成的错误效果一般看起来都会比较玄妙,所以它们是比较难以被发现的,这对于那些经验还并不丰富的年轻Delphi程序员来说(比如当年的我),尤为如此。那么我们如何在实际开发过程中尽量的避免此类bug的出现呢?以下几条建议是我在实践中总结出来的比较有效的方法:

 

1.  保持一个积极主动的心态

我强烈建议你把这个问题加入到大脑内的“Delphi开发经验库”中。然后在编程的过程中或者编程的间歇期,使用这个“库”内的条目来对你的源代码进行检查。对于这种“需要时刻保持着一个清醒头脑”的问题来说,没有什么是比这个办法更行之有效的了。

当然,我这么说并不是想要你把这条经验死记硬背下来,然后在项目编译结束之后又更加强硬的对你的源代码施加检查过程。因为这样做始终是一个被动的行为,而被动的行为我是从来不指望它们能够坚持多久的。当一个人所面对的压力更来越大的时候就更是如此。所以我希望,你应该尽量的调整你的心态,先真正理解这个问题,然后以一种积极主动的心态把它应用到实际的工作当中去,毕竟你现在所做的一切都是为了你自己。

 

2.  用测试驱动的实践方式来开发程序

关于测试驱动开发的概念和实践方式大家可以先参考本书相关章节的内容,或者此领域专家们的著作(我向你推荐kent beck的那本书)。我在这里之所以建议大家使用测试驱动开发的方式来避免此类问题的出现,是因为测试驱动开发是一种以小步伐进行持续稳健开发的方式。这种方式可以将代码本身的缺陷度降低到一个比较理想的百分点,而且多数时候我们所需要面对的只是少量的代码(就是指正在编写/测试的代码段),由于我们所需要面对的代码比较少,所以我们也就能够更好的控制它们。测试驱动开发本身也是一种敏捷开发的实践,你应该在开发的过程中时刻保持着一种“敏捷”的心态,待机而动,在测试驱动开发实践的支持下将此类程序bug杀死在萌芽之中。

 

五.             相关主题

1.  如果您对Class-reference types的话题感兴趣的话,那么请参看“使用Class-reference types”主题,我会对Class-reference types的使用和由于使用不当所造成的问题做一个更深入详细的介绍。

2.  如果您想了解“将基类构造方法置为虚函数”的更多细节问题的话,那么请参看“请将基类的构造方法设置为virtual”主题,在这里有关于此问题的更多说明。

3.  在“请注意单元的包含顺序”主题中,所举的实例最终也是由于没有将正确的代码链接入程序文件所造成的问题。

4.  如果您对测试驱动开发话题感兴趣的话,那么请参看“使用XP的实践改良程序设计过程”主题。在这里,我会详细的描述测试驱动开发的基本概念、实践方式,还有实践中可能会遇到的一些问题。

_______________________________________________________________________________

 

1:当自动化对象在执行时,其内部如果访问到了一个无效对象,那么则会抛出一个Delphi异常。由于COM组件的异常规格与Delphi的异常规格是不同,所以Delphi Rtl的系统就不能Delphi所产生的异常直接抛给外层调用COM组件的COM库。在这里,Delphi编译器的解决方案是创建一种新的方法调用约定safecall。然后又在TObject类中提供了一个名称为SafeCallException的虚方法。Delphi编译器在编译代码时会为safecall方法在方法开始处增加一个try..except块。一旦在这个方法在执行过程当中抛出了一个异常,那么,这个异常马上就会被上面所定义的那个except块所拦截住。在这个except块内,SafeCallException方法将被调用。缺省情况下来SafeCallException方法将返回一个E_UNEXPECTED常量,以通知COM库,被调用COM组件产生了一个异常(如果您对上面所描述的关于COM的细节问题不明确的话,那么您建议阅读潘爱民先生的《COM原理与应用》)。

 

2:在一个项目中,不管一个单元文件被其它单元uses了多少次,这个单元的initializationfinalization段代码始终只会在程序初始化时被执行一次。

 

3:从表面上看,我们在Form上拖放几个组件(Componet),然后在运行时得到与设计时一样的窗体效果是一件很自然的事情。然而实际上这个过程却是一个相当复杂的对象持久化和反持久化过程。众所周知的是Delphi在生成程序文件的时候,会将Form文本(Form序列化成的文本)插入到程序文件的资源段中,在程序运行时VCL的相应代码会根据Form文本来创建具体的组件对象(Form文本反序列化为对象)。在成功反序列化一个组件对象后,VCL的相应代码需要将这个组件对象赋值给用来表示它的相应类数据成员。这也就要求表示组件的类数据成员一定要支持RTTI,以使VCL的相应代码能够在运行根据它的名称找到它的实例,并进行赋值操作。

 

4:我个人感觉Class-reference types的这种对象创建机制本身是Delphi语言上的一种缺陷。因为,既然Delphi语言允许Class-reference types以多态的形式去调用,那么Delphi语言就必须保证Class-reference types在以多态形式被调用时行为的正确性。造成这个语言缺陷的原因在于Delphi编译器并没有将Class-reference types当成一个对象的语义来处理。关于这方面的详细介绍,请参看本书中关于Class-reference types的章节