Delphi越来越多自动释放的类型,到底是简单还是复杂了?

    这几天将D7下的一些组件转移到XE系列的编译环境中,根据网上别人写的总结文章进行改造,整个过程还算比较顺利,很快就可以编译通过并运行测试程序。但是随着测试工作的逐渐深入,一些以前没有注意到的问题慢慢浮上水面,并且花费了很长时间才最终找到问题的原因。

    其中一个困扰我很久的问题就是,动态数组是怎么解释的?回忆了一下,在Delphi7中,我们通常是使用SetLength来为一个动态数组分配内存,比如下面这个例子:

    type
      TTestData = array of Byte;
    function GetTestData(ASize: Integer): TTestData;
    begin
      SetLength(Result, ASize);
    end;

    当调用GetTestData后,我们就获得了一个大小为ASize的动态数组。我们可以将它保存到一个变量中,并且,可以对这个变量进行相应的操作:

    var
      FTestData: TTestData;       
    procedure TForm1.Button1Click(Sender: TObject);
    begin
      FTestData := GetTestData(100);
      FTestData[50] := 10;
    end;

    那当不再需要使用时,我们需要如何释放这个数组呢?一般来说有两种做法,一种是直接将变量置为nil值,如:

  FTestData := nil;

   或者,是使用SetLength将数组长度设置为0   

  SetLength(FTestData, 0);
    这两种写法都可以实现将动态数组释放的功能。以前只知道使用,我一直没想过为什么这两句代码可以将动态数组释放掉的问题。不过一些基础的概念还是知道的,比如说知道动态数组的内存是由系统自动分配的,并且由系统自动回收,而这一功能得益于它的引用计数功能。我们上面调用 SetLength 实际上并不是一个内存分配函数,我们只是告诉 Delphi ,我们需要一个多大的数组, Delphi 自动帮我们申请内存并返回。当我们尝试改动一个动态数组长度的时候,我们来看下这个过程中 Delphi 会帮我们做什么,比如:
    FTestData := nil; //清空变量
    SetLength(FTestData, 50); //第一次设置数组长度,此时Delphi直接申请一个足够存放50长度的数组空间内存。
    SetLength(FTestData, 100);//第二次设置长度,这里需要扩展内存。如果当前内存空间充足,那么就直接在原址进行扩展。
        //如果当前地址段内存空间不足,那么,Delphi会重新申请另一个地址空间,然后将原来数组里的数据搬移过去,最后,
        //再将原来的内存释放掉。整个过程类似于内存重分配函数,ReallocMem

    这个是我所理解的动态数组的管理方法,不知道是否正确,但我想原理应该差不多。我唯一不清楚的是,Delphi如何管理数组的引用计数?引用计数是什么时候被改变的?就因为这个问题没搞清楚,导致我前段时间调试一个程序时一直找不到出现BUG原因在哪,一度纠结了两天时间。于是我决定这次一定要弄清楚,这个引用计数到底是如何管理的。

    我们先看一下动态数组在内存中的保存格式,该结构在D7XE下是一致的:

    引用计数         数组长度          数组内容

    数组地址-8      数组地址-4        第一个元素  ... ...

    用Delphirecord来表示这个结构的话,是这样的:

    type
      TArrayRecord = record
         RefCount: Integer;
         Length: Integer;
         ArrayData: array[0..Length - 1] of T;
      end; 

    有没有觉得眼熟?如果你知道字符串在内存中的保存格式的话,你就应该清楚,这个格式跟D7下字符串的结构是一样的(XE下字符串的结构有所变动,增加了新的附加信息,这里不作讨论)。

    为了知道引用计数是何时变化的,那我们应该首先想个办法看到引用计数的值是多少,下面我们来实际读取一下数组的这些相关信息,以便有一个更直观的认识:

    //编写一个函数,将相关信息打印到Memo1组件中。

  procedure TForm1.ShowArrayInfo(ATD: TTestData);
  begin
    Memo1.Lines.Add(Format('======Address:%d=======', [Cardinal(ATD)]));
    //数组地址往前8个字节处,保存引用计数
    Memo1.Lines.Add(Format('======RefCount:%d=======', [PCardinal((Cardinal(ATD) - 8))^]));
    //往前四个字节,保存数组长度
    Memo1.Lines.Add(Format('======Length:%d=======', [PCardinal((Cardinal(ATD) - 4))^]));
  end;
  //然后,编写程序调用这个函数

    FTestData := nil;

    SetLength(FTestData, 50);

    ShowArrayInfo(FTestData)

   

    如果一切顺利的话,我们应该会看到,引用计数RefCount2Length100.

    为何引用计数会是2?而不是1?我们明明只有一个FTestData指向这个数组吧?仔细一想,哦,应该是在调用ShowArrayInfo时,ATD这个参数也指向了这个数组了,所以引用计数变为2。尝试着将函数参数定义成const或者var类型再试了一下,果然发现引用计数显示为1了。

    接下来,我们定义多几个变量:   

    FTestData1, FTestData2, FTestData3: TTestData;    
    FTestData1 := FTestData;
    FTestData2 := FTestData;
    FTestData3 := FTestData;
    ShowArrayInfo(FTestData);
    实验证明有多少个变量指向了数组,引用计数就是多少。在 XE 下断点跟踪 CPU 的代码,可以发现 Delphi 自动插入了很多代码,用于调用函数维护动态数组引用计数,下面是 XE 下截取的调用 DynArrayAsg 的代码,而在 DynArrayAsg 中就有维护引用计数,至于实现的细节,这里不作讨论。
    lea eax,[ebp-$0c]
    mov edx,[ebp-$04]
    mov edx,[edx+$000003b4]
    mov ecx,[$0072661c]
    call @DynArrayAsg
    从 CPU 代码中可以看出来,引用计数的维护是依靠编译器来实现的!也就是说, Delphi 在编译的时候必须得知道哪些变量是动态数组变量,哪些不是,以便于它能生成正确的机器代码,否则对于引用计数的维护就是混乱以至出错!

    我们把上面的FTestData1FTestData2FTestData3改成Pointer类型,再试验可以看出来,Delphi果然没有增加数组的引用计数!

    好了,问题出来了,如果当FTestData变量也是一个Pointer类型,那结果会是怎样?

    一切准备工作都已经就绪,接下来我们就来做这个很危险的实验。我们来看下面这个代码:  

 //将变量类型改成普通指针类型
    var
      FTestData: Pointer;  //也可以是PChar什么的,或者Delphi7环境下的TBookmark变量
    procedure TForm1.ShowArrayInfo(const ATD: TTestData);
    begin 
      //数组地址往前8个字节处,保存引用计数
      Memo1.Lines.Add(Format('======RefCount:%d=======', [PCardinal((Cardinal(ATD) - 8))^])); 
    end;

    procedure TForm1.Button1Click(Sender: TObject);
    begin
      FTestData := GetTestData(100);
      ShowArrayInfo(FTestData);
    end;      

    procedure TForm1.Button2Click(Sender: TObject);
    begin
      ShowArrayInfo(FTestData);
    end;
    执行 Button1Click ,调用 GetTestData 创建数组,返回时 GetTestData Result 指向了创建的数组,所以数组引用计数为 1 。然后,我们将 Result 赋值给了 FTestData ,按我们之前的讨论,对 FTestData 的赋值不会增加引用计数,所以在退出 Button1Click 之前我们应该可以看到数组引用计数是 1 ,当退出 Button1Click 时,超出了 Result 变量的作用域( Result 实际上是栈中的变量,退出函数就失去作用),数组的引用计数会自减 1 变成 0 ,然后 Delphi 会将动态数组释放!从 CPU 的汇编代码中,我们可以看到的确是调用了清除动态数组的代码(call @DynArrayClear)。

    那按照这个理解,当我们再执行Button2Click时,如果人品还行的话,我们应该不会看到“1”这个数字吧?因为FTestData所指向的地址,引用计数已经被Delphi清零了,内存也已经被Delphi释放了。那我们得到的引用计数,应该是“0”才对。

    但在D7环境下实际测试运行,结果却让我大跌眼镜!返回结果是“1”!我不甘心,于是又在XE下运行测试,结果是“0”!这怎么解释才能通?于是我在D7下打开CPU调试窗口,一句一句地跟是在哪里出现的问题,问题居然出在Format函数中创建字符串的过程!在调用Format之前,Cardinal(ATD) - 8所指向的地址一直是无意义的数字,但执行完Format后,就变成了1! 难道,是因为这块地址被别人使用了?是Format中返回的字符串?而这个1不是FTestData数组的引用计数,而是临时字符串的引用计数?!不至于这么巧吧!不过,仔细一想,Delphi刚把动态数组的内存释放了,现在又要申请内存存放字符串,那申请到一样的地址,也不是不可能的事情,只要程序没有改变过内存中的内容,并且申请的内存块大小不是相差太大,那按一样的算法查找空闲的内存块,就理应会找到一样的地址才是。于是,我将显示引用计数的函数改了一下:

    //注意下面函数的写法,首先改下定义,加上const限定字。注意,加上const或者var限定之后,
    //  Delphi在编译的时候不会对参数ATD的引用计数进行维护,而这正是我们希望的。
    //  其次,定义一个临时变量iRefCount用于保存引用计数,在下面字符串操作开始之前,将记下所得到的引用计数。
    procedure TForm1.ShowArrayInfo(const ATD: TTestData);
    var
      iRefCount: Integer;
    begin
      iRefCount := PCardinal((Cardinal(ATD) - 8))^;    
      Memo1.Lines.Add(Format('======RefCount:%d=======', [iRefCount])); 
    end;   
    使用这个函数再去执行上述的调试代码,终于,看到引用计数是 0 了。在 XE 下得到的引用计数一直是 0 ,我猜测要么是字符串在内存中的格式不一致了,要么是 XE 对空闲内存有其它的管理方法,具体原因是什么不是本文的重点,所以没有细究。不过到了这里也已经指出了本文要讨论的重点内容,那就是,普通的指针变量,不能存放动态数组!

    这个问题在正常情况下可能很容易被我们忽略,但是,在将D7程序转移到XE下的时候,却需要我们高度提高警惕!因为在XE中增加了泛型的概念,很多以前使用指针进行操作的地方,现在都改成了使用动态数组。比如,在D7下的TBookmark类型是一个Pointer,所以我们可以将TBookmark变量保存到PCharPointer等类型的变量中,我们还可以使用一个TList来保存多个TBookmark。这样的代码搬到XE下的话,会出现什么问题?没错,变量保存完,一退出函数,TBookmark就立马被释放了,当我们再去访问时,就有可能会出现内存访问错误。记住,是有可能,而不是必然出错!因为内存释放并不会清空内存,只要内存没被清空,你去访问那个地址仍然可以找到你想要的东西,只要当内存被其它内容占用的时候,才有可能会出错!有时可能也不会出错,但是你却无法得到你预想的结果。 

    另外还有一点需要注意,在D7下对一个动态数组进行释放时,即使引用计数不正常,程序也不会出现错误,而在XE下,很可能就会抛出内存访问错误。比如,程序中用一个Pointer类型指针保存一个TBookmark变量,由于Pointer类型无法保存动态数组,所以,这个指针实际指向的数组很可能已经被释放掉了,假如这时候将这个指针当作实参调用一个带TBookmark类型参数的函数的话,在函数退出时很可能就会直接抛出内存访问错误,因为函数退出时会自动释放内存,但是指向的内存早就已经被释放了,所以就出错了。

    讲得可能有点乱,不过现在脑子里就是乱乱的。以前使用动态数组或者是字符串都没感觉到什么压力,但现在突然间发现这一切并不像自己想像的那样简单,编译器编译出来的代码并不像自己想象中的那样天衣无缝。那自己以前写过的代码中,到底有没有存在这类似的BUG而一直没有发现?一想到这就觉得很是后怕,自动释放对象功能虽然简化了我们的操作,但也让我们对这一切都失去掌控的能力,一段你无法掌控它执行行为的代码,我无法想象它可以对你的程序造成怎样的破坏?!

  如果自动等于失控,那我情愿回到一切都能自己操控的年代。如果不想失控,那还得重新掌握Delphi编译器的编译规则,不然,实在不知道它会编译出什么内容的代码。除非Delphi完全禁使用Pointer、PChar这些指针类型,不然,这些自动释放的类型太多了,对目前这种过渡阶段实在不见得是什么好事。

你可能感兴趣的:(个人日志)