看一看内存中的字符串
为了帮你更好地理解字符串的内存管理细节,在程序中声明了两个全程字符串:Str1 和 Str2,当按下第一个按钮时,程序把一个字符串常量赋给第一个变量,然后把第一个变量赋给第二个:
Str1 := 'Hello';
Str2 := Str1;
除了字符串操作外,程序还用下面的StringStatus 函数在一个列表框中显示字符串的内部状态:
function StringStatus (const Str: string): string;
begin
Result := 'Address: ' IntToStr (Integer (Str))
', Length: ' IntToStr (Length (Str))
', References: ' IntToStr (PInteger (Integer (Str) - 8)^)
', Value: ' Str;
end;
在StringStatus 函数中,用常量参数传递字符串至关重要。用拷贝方式(值参)传递会引起副作用,因为函数执行过程中会产生一个对字符串的额外引用;与此相反,通过引用(var)或常量(const)参数传递不会产生这种情况。由于本例不希望字符串被修改,因此选用常量参数。
为获取字符串内存地址(有利于识别串的实际内容也有助于观察两个不同的串变量是否引用了同一内存区),通过类型映射把字符串类型强行转换为整型。字符串实际上是引用,也就是指针:字符串变量保存的是字符串的实际内存地址。
通过运行这个例子,你会看到两个串内容相同、内存位置相同、引用记数为2,如图7.1中列表框上部所示。现在,如果你改变其中一个字符串的值,那么更新后字符串的内存地址将会改变。这是copy-on-write技术的结果。
图 7.1: 例StrRef显示两个串的内部状态,包括当前引用计数
第二个按钮(Change)的OnClick 事件代码如下,结果如图7.1列表框第二部分所示:
procedure TFormStrRef.BtnChangeClick(Sender: TObject);
begin
Str1 [2] := 'a';
ListBox1.Items.Add ('Str1 [2] := ''a''');
ListBox1.Items.Add ('Str1 - ' StringStatus (Str1));
ListBox1.Items.Add ('Str2 - ' StringStatus (Str2));
end;
注意,BtnChangeClick 只能在执行完BtnAssignClick 后才能执行。为此,程序启动后第二个按钮不能用(按钮的Enabled 属性设成False);第一个方法结束后激活第二个按钮。你可以自由地扩展这个例子,用StringStatus 函数探究其它情况下长字符串的特性。
Delphi 字符串与 Windows PChar字符串
长字符串为零终止串,这意味着长字符串完全与Windows使用的C语言零终止串兼容,这给长字符串使用带来了便利。一个零终止串是一个字符序列,该序列以一个零字节(或null)结尾。零终止串在Delphi中可用下标从零开始的字符数组表示,C语言就是用这种数组类型定义字符串,因此零终止字符数组在Windows API 函数(基于C语言)中很常见。由于Pascal长字符串与C语言的零终止字符串完全兼容,因此当需要把字符串传递给Windows API 函数时,你可以直接把长字符串映射为PChar 类型。
下例把一个窗体的标题拷贝给PChar 字符串(用API 函数GetWindowText),然后再把它拷贝给按钮的Caption 属性,代码如下:
procedure TForm1.Button1Click (Sender: TObject);
var
S1: String;
begin
SetLength (S1, 100);
GetWindowText (Handle, PChar (S1), Length (S1));
Button1.Caption := S1;
end;
注意:代码中用SetLength函数为字符串分配内存,假如内存分配失败,那么程序就会崩溃;如果你直接用PChar 类型传递值(而不是象以以上代码那样接受一个值),那么代码会很简单,因为不需要定义临时字符串,也不需要初始化串。下面代码把一个Label(标签)控件的Caption 属性作为参数传递给了API函数,只需要简单地把属性值映射为PChar类型:
SetWindowText (Handle, PChar (Label1.Caption));
当需要把WideString 映射为Windows兼容类型时,你必须用PWideChar 代替PChar进行转换,WideString常用于OLE和 COM 程序。
刚才展现了长字符串的优点,现在谈谈它的弊端。当你把长字符串转换为PChar 类型时可能会引发一些问题,问题根本在于:转换以后字符串及其内容将由你来负责,Delphi 不再管了。现在把上面Button1Click代码稍作修改:
procedure TForm1.Button2Click(Sender: TObject);
var
S1: String;
begin
SetLength (S1, 100);
GetWindowText (Handle, PChar (S1), Length (S1));
S1 := S1 ' is the title'; // this won't work
Button1.Caption := S1;
end;
程序编译通过,但执行结果会令你惊讶,因为按钮的标题并没变,所加的常量字符串没有添加到按钮标题中。问题原因是Windows写字符串时(在GetWindowText API调用中),Windows 没有正确设置Pascal 长字符串的长度。Delphi 仍可以输出该字符串,并能通过零终止符判断字符串何时结束,但是如果你在零终止符后添加更多的字符,那么这些字符将被忽略。
怎么解决这个问题呢?解决方法是告诉系统把GetWindowText API函数返回的字符串再转换成Pascal字符串。然而,如果你用以下代码:
S1 := String (S1);
Delphi 系统将不予理睬,因为把一种类型转换为它自己的类型是无用的操作。为获得正确的Pascal 长字符串,需要你把字符串重新映射为一个PChar 字符串,然后让Delphi 再把它转回到字符串:
S1 := String (PChar (S1));
实际上,你可以跳过字符串转换(S1 := PChar (S1));, 因为在Delphi中Pchar转换到string是自动执行的,最终代码如下:
procedure TForm1.Button3Click(Sender: TObject);
var
S1: String;
begin
SetLength (S1, 100);
GetWindowText (Handle, PChar (S1), Length (S1));
S1 := String (PChar (S1));
S1 := S1 ' is the title';
Button3.Caption := S1;
end;
另一个办法是用PChar 字符串的长度重新设定Delphi 字符串长度,可以这样写:
SetLength (S1, StrLen (PChar (S1)));
在例LongStr中你可以看到三种方法的结果,分别由三个按钮执行。如果只想访问窗体标题,仅需要用到窗体对象本身的Caption 属性,没有必要写这段迷糊人的代码,这段代码只是用来说明字符串转换问题。当调用Windows API 函数时会遇到这种实际问题,那时你就不得不考虑这一复杂情况了。
格式化字符串
使用加号( )操作符和转换函数(如IntToStr),你确实能把已有值组合成字符串,不过另有一种方法能格式化数字、货币值和其他字符串,这就是功能强大的Format 函数及其一族。
Format 函数参数包括:一个基本文本字符串、一些占位符(通常由%符号标出)和一个数值数组,数组中每个值对应一个占位符。例如,把两个数字格式化为字符串的代码如下:
Format ('First %d, Second %d', [n1, n2]);
其中n1和n2是两个整数值,第一个占位符由第一个值替代,第二个占位符由第二个值替代,以此类推。如果占位符输出类型(由%符号后面的字母表示)与对应的参数类型不匹配,将产生一个运行时间错误,因此设置编译时间类型检查会有利于Format 函数的使用。
除了%d外,Format 函数还定义了许多占位符,见表7.1。这些占位符定义了相应数据类型的默认输出,你可以用更深一层的格式化约束改变默认输出,例如一个宽度约束决定了输出中的字符个数,而精度约束决定了小数点的位数。例如
Format ('%8d', [n1]);
该句把数字n1转换成有8个字符的字符串,并通过填充空白使文本右对齐,左对齐用减号(-) 。
表 7.1: Format函数的占位符
占位符 |
说明 |
d (decimal) |
将整型值转换为十进制数字字符串 |
x (hexadecimal) |
将整型值转换为十六进制数字字符串 |
p (pointer) |
将指针值转换为十六进制数字字符串 |
s (string) |
拷贝字符串、字符、或字符指针值到一个输出字符串 |
e (exponential) |
将浮点值转换为指数表示的字符串 |
f (floating point) |
将浮点值转换为浮点表示的字符串 |
g (general) |
使用浮点或指数将浮点值转换为最短的十进制字符串 |
n (number) |
将浮点值转换为带千位分隔符的浮点值 |
m (money) |
将浮点值转换为现金数量表示的字符串,转换结果取决于地域设置,详见Delphi帮助文件的Currency and date/time formatting variables主题 |
动态数组
传统的Pascal 语言其数组大小是预先确定的,当你用数组结构声明数据类型时,你必须指定数组元素的个数。
Delphi 中增加了非常简单的动态数组实现方法,实现过程效仿前面讲过的动态长字符串。与长字符串一样,动态数组的内存动态分配并且引用记数,不过动态数组不支持 copy-on-write 技术。这不是个大问题,因为你可以把变量值设置为nil释放数组内存。
这样你就可以声明一个不指定元素个数的数组,并用SetLength 过程给数组分配一个特定大小的内存,SetLength 过程还可以改变数组大小而不影响其内容,除此外还有一些字符串过程也可用于数组,如Copy 函数。
以下摘录的代码突出了一点,这就是:定义数组后必须先为它分配内存,然后才能开始使用:
procedure TForm1.Button1Click(Sender: TObject);
var
Array1: array of Integer;
begin
Array1 [1] := 100; // error
SetLength (Array1, 100);
Array1 [99] := 100; // OK
...
end;
如果你只定义一个数组元素个数,那么索引总是从0开始。Pascal 中的普通数组既能用不为零的下标,也能用非整数的下标,但动态数组均不支持这两种下标。象普通数组一样,你可以通过Length、High和Low 函数了解到动态数组的状况,不过对于动态数组,Low 函数返回值总是0,High函数返回数组大小减1,这意味着空的动态数组其函数High返回值是-1,这是一个很怪的值,因为它比Low的返回值还小。
图 8.1: 例 DynArr 窗体
以上作了简短的介绍,现在举个简例,例名DynArr ,见图8.1。例子实在是很简单,其实动态数组没有什么特别复杂地方。我想通过该例说明几个程序员可能犯的错误。程序中声明了两个全程数组并在OnCreate 事件中初始化了第一个数组:
var
Array1, Array2: array of Integer;
procedure TForm1.FormCreate(Sender: TObject);
begin
// allocate
SetLength (Array1, 100);
end;
这样就把数组所有值设置为0。完成这段代码你马上就能读写数组元素的值,而不用害怕内存出错,当然条件是你没有试图访问超过数组上界的元素。为了更好地初始化,程序中添加了一个按钮,执行数组元素赋值操作:
procedure TForm1.btnFillClick(Sender: TObject);
var
I: Integer;
begin
for I := Low (Array1) to High (Array1) do
Array1 [I] := I;
end;
Grow 按钮用于修改数组大小,但并不影响数组内容。单击Grow 按钮后,你可以用Get value按钮进行检验:
procedure TForm1.btnGrowClick(Sender: TObject);
begin
// grow keeping existing values
SetLength (Array1, 200);
end;
procedure TForm1.btnGetClick(Sender: TObject);
begin
// extract
Caption := IntToStr (Array1 [99]);
end;
Alias 按钮的OnClick 事件代码稍复杂些,程序通过 := 算子把一个数组拷贝给另一个数组,从而有效地创建了一个别名(一个新变量,但引用内存中同一数组)。从中可见,如果你改变了其中一个数组,那么另一个同样也会改变,因为它们指向同一个内存区:
procedure TForm1.btnAliasClick(Sender: TObject);
begin
// alias
Array2 := Array1;
// change one (both change)
Array2 [99] := 1000;
// show the other
Caption := IntToStr (Array1 [99]);
在btnAliasClick 事件中增加了两部分操作内容。第一部分是数组等同测试,不过并不是测试实际的数组元素,而是测试数组所引用的内存区,检测变量是不是内存中同一数组的两个别名:
procedure TForm1.btnAliasClick(Sender: TObject);
begin
...
if Array1 = Array2 then
Beep;
// truncate first array
Array1 := Copy (Array2, 0, 10);
end;
btnAliasClick 事件的第二部分内容是调用Copy 函数。该函数不仅把数据从一个数组移到另一个数组,而且用函数创建的新数组取代第一个数组,结果变量Array1 所引用的是11个元素的数组,因此,按Get value 和Set value 按钮将产生一个内存错误,并且触发一个异常(除非你把范围检查range-checking 选项关掉,这种情况下,错误仍在但屏幕上不会显示异常)。虽然如此,Fill 按钮仍能正常工作,因为需要修改的数组元素由数组当前的下标范围确定。