先说一下,2年没更新了,最近一年基本上没碰 Delphi,尤其 XE2 测试阶段开始。本来也不打算更新,今天算是赶巧。要是接下来一两年又没动静了也千万别觉得奇怪。

按照正常顺序,这一篇文章应该是在了解一些基本参数概念之后才进行学习的。最基本的就是参数修饰符(无修饰符/const/var/out)的含义。但正好这两天看到有人问这个问题,就回答了一下,顺便也算整理了思路,于是直接落笔了。

预备知识(先占坑):

既然有文章还没写,那就暂时把应该复习的东西变成预习吧。

参数修饰符包括:无修饰,const,var,out 共4种。参数的修饰符是确定一种参数传递的方式,调用方和被调用方按照相应的方式进行处理,这样才会保证不会出问题。所以,在介绍的时候一般会按照调用方、被调用方的处理方式进行划分。先说一下相同的吧。任何情况下,对于无修饰与 const 参数,调用方的处理都是相同的;对于 var 与 out,被调用方的处理都是相同的。语义上,无修饰时是按值转递,参数实参的一份拷贝,在被调用方函数中,任何对参数的任何修改都不影响调用方的实参值;const 修饰是要求在被调用函数中,不能修改该参数的值;var 则是按引用传递,参数可以看成是调用方实参的一个别名,任何作用结果都会影响源数据的值。out 和 var 的区别最简单,对于某几大类类型来说(主要是自管理生存期的类型,主要特点是一般都带有引用计数,如 string、动态数组、interface 等),调用方会在调用前清理原有的值并将其清零,然后再按照 var 的方式传递。

接下来介绍数组实参的传递方式。很遗憾的是,由于静态数组与动态数组是两种不同的类型(一个是值类型,一个是引用类型),编译器采取完全不同的策略,再加上还有一种长得很像动态数组、行为却更像静态数组的特殊“数组”——参数数组(开放数组),所以没有办法统一介绍。

在介绍开始之前,再简单说一下 Delphi 对变量类型的判断方式。对于命名类型的变量(即有明确的类型名,或在 type 段中使用 TBlah = blahblah 定义了类型名,然后在声明变量时使用 blah: TBlah),Delphi 在编译时通过去符号表查找符号找到相应的类型并进行匹配。对于未命名的复合类型变量(如 a: record...end; b: array of Integer;),编译器只认为在同一个冒号前声明的变量的类型是相同的。所以,不论是静态还是动态数组,都不能用未命名类型作为函数的参数,如:procedure foo(x: array[0..2]of Byte)。因为用它做参数的话,只要先在 interface 段写声明,再在 implementation 段写实现的同时也加上了参数表(语法上是可选的,也就是说实现段可以不写参数表、函数返回类型等),两者的参数列表就不相同。当然也不是没有解决的方法,只有通过对类型进行命名,然后再做参数的时候自然就合法了。也许你要问:procedure foo(x: array of Byte) 明明是合法的,你怎么能说动态数组不能做参数呢?要是把这个参数当做动态数组的话,你就不幸掉到 Delphi 语法的坑里了。这种声明完全是另外一种数组:参数数组(开放数组)。验证的方式很简单,反证法:如果是动态数组的话,那么任何动态数组都应该可以(只要 of 的后面是命名类型);现在有动态数组 array of array of Byte,放进去——编译出错,得证。


好了,预备知识勉强算是介绍完了吧(虽然还有很多细节没讲),那么就正式开始吧。首先是最简单的,静态数组参数。

type
  TArr1 = array[Byte] of Integer;

procedure callee(arr1: TArr1);
//...

procedure caller;
var bar1: TArr1;
begin
  callee(bar1);
end;

观察这段代码,由于编译器知道了 arr1 的长度、元素的类型等信息,而 TArr1 是又是值类型,所以在传参数的时候,只要传入该值类型的地址这一个参数就够了。前面说过,无修饰与 const 在调用方的处理都是相同的,这也就意味着,在被调用方采用不同的处理方式就能够完成语义上的工作。所以 const 的限制很简单,只要在语法上禁止写入(也就是 arr1[n] := xxx)就可以了。但这样的话如果不带修饰符,一旦改变 arr1 的值,就会对 bar1 也产生影响。Delphi 采用了一种非常危险的解决方式:被调用方在栈上直接复制一份,任何操作都只对栈上这份拷贝起作用。你可能要问,这种方式处理的挺好啊,为什么要说它危险呢?道理很简单,因为栈是有固定大小的,默认是1M,一旦这个静态数组稍微大了点儿,比如它的宽度接进1M,那么在复制的过程中就会发生 stack overflow 错误导致程序异常退出。可以看出来,如果把修饰符换成 var 的话,传地址是完全符合要求的,被调用方不需要再做任何额外处理。由于 TArr1 的元素是值类型(Integer),所以 out 也不会在调用方做任何处理;但如果 TArr1 = array[Byte] of String 的话(还有动态数组、inteface 等),调用方则会把 bar1 中的每个元素都清理后,才调用 callee。 


静态的讲完了,下面该讲动态的了:

type
  TArr2 = array of Integer;

procedure callee(const arr2: TArr2);

procedure caller;
var bar2: TArr2;
begin
  callee(bar2);
end;

为避免混淆,命名的数字都加了1。现在 arr2 是一个动态数组。在讲动态数组时我们介绍过(以后再写……),它是引用类型,本身只有一个指针,指向一块带有引用计数的数据,虽然长得和 string 挺像但处理起来却完全不同。先把这段用来理解它的代码放在这里,回头再挪到讲动态数组的文章里去。举个例子,也是许多初学者非常容易弄混的例子,一旦理解了的话,再去理解 python 之类的引用类型就非常容易了。先上示意代码:

procedure bar;
var
  arr1, arr2: array of Byte;
  i: Integer;
begin
  SetLength(arr1, 10);
  for i:=0 to 9 do
    arr1[i] := i;
  arr2 := arr1;
  arr2[0] := 2;

arr1、arr2 是两个动态数组,首先把 arr1 变成了 [0, 1, 2, ..., 9],然后试图得到一个 [2, 1, 2, ..., 9] 的另外一个数组 arr2。可这个时候,如果我们观察 arr1[0] 的话,会发现它的值也变成了2。怎么样,会不会觉得有点儿奇怪:我明明给的是 arr2,为什么 arr1 的值也变了呢。好吧,现在上图来解释静态数组与动态数组。

上图只是一段示例代码,栈中的参数顺序不一定是实际的样子,但大体上的样子是没问题的。右侧是当前函数的栈示意图,动态数组 dyn1、dyn2 在它声明的位置只有4个字节(32位程序,64位为8字节),指向一个动态数组结构。
好了,了解基础知识之后,下面继续前面的话题,看一下示例代码。由于 bar2 实际上只是一个指针,所以当传递无修饰/const 参数的时候,调用方是按值传递的。也就是说,编译器复制了该指针的值作为 callee 的参数,调用 callee。这时候调用栈的样子大体上应该是这样的:

无修饰符的时候,编译器还会在 callee 中生成增加 bar2 指向的引用计数的代码,这样才能保证如果在 callee 中 arr2:=nil 之类的代码不会让 data 的引用计数降至0。如果降至0的话,运行时库(RTL)会销毁 data,这将导致 caller 中对 bar2 的操作失败。当使用 const 修饰的时候,编译器会保证 arr2 本身的值不会被修改(也就是上图中 arr2 所在的格子),但通过访问 arr2 指向的数据(也就是堆中 data 所在的那些格子)是允许的。换句话说,arr2[0]:=100 这样的代码既有效又合法,当退出 callee 后,caller 中 bar2[0] 的值也变成了100。再对比一下无修饰时的操作,arr2[0]:=100 同样会导致 bar[0] 的值变为100。那么你可能要问,这样的话 const 岂不是没意义了?观察下面的代码:

procedure callee(arr2: TArr2);
begin
  SetLength(arr2, 100);
end;

上面的代码会改变 arr2 本身的值。实际中 RTL 操作的流程是这样的(当然前提是 arr2<>nil):

  1. 减少 arr2 指向的空间的引用计数,如果降至0的话则释放这块空间(实际上不会降至0,如前述,因为在进入该函数的时候,编译器自动生成首先增加引用计数的代码);
  2. 申请一块长度为100+8字节的空间,其中8是数组数据头(见上图 DynArray Head),包含两个32位整数,一个是引用计数,一个记录了数组长度(Length(***) 就是取这里的值);
  3. 将 arr2 指向该块数据(即上图中的 DynArray Data,它并不指向数组头,这样在按照偏移量访问的时候可以直接取值,减少不必要的运算)。

这样的结果是,arr2 变成了一个与 bar2 指向的空间完全无关的动态数组。在退出 callee 后,bar2 的长度、数据都没变。

有了这些理解之后,再来看看使用 var/out 修饰时的情况。先说 out,因为前面已经说过一遍了,TArr2 是一个动态数组,调用方会首先对它进行清理。这也就意味着,在进入 callee 之前,caller 已经把 bar2 置为 nil。然后再说被调用方的情况,处理是相同的。不多废话,直接上图( procedure callee(var arr2: TArr2); ):

var 修饰一如继往的按引用传递,也就是实际上传了一个指向 TArr2 的指针,但在处理上表现得和处理该数据相同。如果复习一下字符串的处理的话(不好意思还没写,以后会补上),对 string 取地址时不会增加引用计数,这里也是如此。这时候对 arr2 的任何改变,都视同于 bar2 直接进行操作。所以如果在 callee 中 arr2:=nil,那么 bar2 的那个格子里的值也就变成了 nil,同时减少 data 的引用计数,降至0则释放。
 
现在简单回顾一下静态与动态数组参数的差别:
  1. 对于无修饰参数,静态数组传地址,并在栈上复制调用方传入的数组,任何改变都不会影响源数组;动态数组传值,不会在栈上复制数组,直接对 arr2[n] 赋值会改变源数组值,改变 arr2 本身的值(如 SetLength,arr2:=nil)不会影响 bar2,同时在改变后,arr2 指向完全不同的另一个数组,arr2[n] 的赋值自然也就不会对源数组值产生影响。
  2. 对于 const 参数,静态数组不会复制数组,无法对 arr1[n] 进行赋值;动态数组无法对 arr2 本身进行改变,对 arr2[n] 的赋值会改变 bar2[n] 的值。
  3. 对于 var 参数,静态数组和动态数组都传引用;不同的是,动态数组对 arr2 本身的改变(如 SetLength)视同对 bar2 进行改变;
  4. 对于 out 参数,如果静态数组的元素为 RTL 管理生存期的类型,如 string、动态数组、interface,调用方会先对各元素进行清理,否则没有任何动作;动态数组则会先将 bar2 置为 nil,然后与 var 处理相同。 

好了,上文已经讲了编译器静态数组、动态数组两种相对简单的参数的处理方式,下面来看第3种,也就是最容易让人混淆的数组参数——参数数组(开放数组)。
参数数组的声明方式长的与动态数组很像,在处理上却和静态数组非常类似。
procedure foo(arr3: array of Integer);

当开放数组无修饰或使用 const 修饰时,可以传递3种数组,包括:静态数组,动态数组,立即数数组。前两个好理解,第三种是这样的形式,相信也见过:

foo([1, 2, 3]);

前面说过,处理方式与静态数组参数的处理方式非常像。它的原因是,静态数组的数据结构比动态数组简单,动态数组多了一个指针和数组头。如果按照动态数组的方式进行处理,既要额外生成并复制静态数组,同步数据时(var 修饰传递)又要再复制一遍,之后还要再销毁。这会导致效率非常低下。对于立即数数组,可以理解成在当前函数栈中生成一块静态数组数据,并把它当作静态数组进行对待。

在传递参数的时候,开放数组实际上传入两个参数。第一个参数是该数组的地址,对于静态数组 bar1 来说,就是 bar1 的地址;对于动态数组 bar2 来说,就是 bar2 中的实际值,即指向数组数据的指针值。第二个参数是数组长度-1(Length(***)-1),这样在访问 arr3 时才能知道数组的长度(也就是说,第2个参数在被调用函数使用 High(arr3) 时,实际上访问的就是它)。

这样,就可以按照静态数组参数的处理方式进行接下来的理解了,对于无修饰、const/var/out,传入的参数都一样是这两个。接着还是代码和图:

procedure callee(const arr3: array of Integer);

procedure caller;
var
  bar1: array[0..1] of Integer;
  bar2: array of Integer;
begin
  callee(bar1);
  callee(bar2);
end;

上图左侧是 callee(bar1) 时的栈情况,右侧是 callee(bar2) 的情况。注意使用了 const 修饰符,只有无修饰符时会复制数组,当使用 var/out 时也是相同的处理方式。

  1. 对于无修饰参数,会在被调用方的栈对源数组进行复制,改变 arr3[n] 的值不会影响源数组;
  2. 对于 const 参数,不复制数组,无法改变 arr3[n] 的值;
  3. 对于 var 参数,对 arr3[n] 的改变会直接影响源数组;
  4. out 修饰有些不用,调用方不会进行任何清理,清理的工作在被调用方完成。换句话说,假如有 arr3: array of string,那么不管传的是 bar1: array[Byte] of string 还是 bar2: array of string,调用方都不会对 bar1、bar2 有任何动作;但当进入 callee 时,首先会对 arr3 中的每一个元素进行清理,将它们置为空字符串。

现在再与静态数组参数进行对比,会发现两者的处理方式非常类似。下面再进行一个有意思的常规操作,也是学习语言时非常有用的一个,看一看 SizeOf 的值。看完下面的代码之后,不知道你能不能猜出 SizeOf(arr3) 的值。

type
  TArr1 = array[0..9] of Integer;
  TArr2 = array of Integer;

procedure foo1(const arr1: TArr1);
begin
  Writeln(SizeOf(arr1));
end;

procedure foo2(const arr2: TArr2);
begin
  Writeln(SizeOf(arr2));
end;

procedure foo3(const arr3: array of Integer);
begin
  Writeln(SizeOf(arr3));
end;

procedure caller;
var
  bar1: TArr1;
  bar2: TArr2;
begin
  foo1(bar1);
  foo2(bar2);

  foo3(bar1);
  foo3(bar2);
  SetLength(bar2, 100);
  foo3(bar2);
end;

前两个调用都不会出乎意料,分别应该是40和4(32位下指针长度为4字节,要是64位的话则会输出8)。

以前的文章介绍过,SizeOf 是一个编译期运算符。换句话说,编译器已经知道数据的大小,在编译期就知道它的值,会直接将其替换为相应的立即数,而不会等到运行时才进行计算。但本文刚介绍过,开放数组实际上并不是一个参数,而是两个,那么编译器会怎样处理它呢?

有趣的是 foo3 的3次调用输出结果各不相同。观察结果之后会发现,输出的结果显然是 SizeOf(arr3[0])*Length(arr3)。也就是说,很明显它并不是在编译期求的值,而是在运行时运算取得的结果。这是目前我已知的唯一一处 SizeOf 在运行时求结果的情况。个人认为这种处理破坏了 SizeOf 运算符的语义,使它无法保持编译期求值的语义一致性。类似的也有 C99 标准中的运行时栈数组(懒得去翻文档了,随便起个了名字),我认为它也破坏了 C 语言中 sizeof 运算符的语义一致性,所以我并不喜欢这个新特性。

对于开放数组,还有一种特殊的参数类型:array of const,SysUtils.Format 函数就采用了这样的声明方式:

function Format(const Format: string; const Args: array of const): string;

这样,它在接收参数时,可以接收多种类型的参数,尤其在使用立即数数组时非常方便,如:

Format('%x %d %p %s', [100, 100, @abc, 'a string']);

实际上它会在栈上构造一个 array[0..len-1] of TVarRec 形式的数组,TVarRec 的定义见 System 单元,能接受的类型也都在里面了。

 


说了这么多后,最后再总结几点使用中要注意的情况。对于数组做为参数、无修饰符的情况,我的建议是:无论何时都不要使用。理由是,静态数组和开放数组或导致在栈上复制数组,很容易导致栈溢出;而动态数组 作参数时,一不小心行为就会产生预期不到的效果。因而尽量加上 const 修饰符来避免这些“意外”的发生。在使用的时候千万不要把开放数组与动态数组混淆,例如传入一个 var x: array of T,然后试图去 SetLength(x, ...) 或者 x:=nil 这样的行为是会失败的,因为它的表现更像一个静态数组。理解了动态数组本身是个指针了之后,千万不要为 const x: TSomeDynArray 能够 x[n]:=blah 大惊小怪,在 C 里面 char const*/const char*(这两个相同)与 char *const 是完全不同的两种类型,Delphi 的动态数组参数用 const 修饰后只相当于 char *const。