Delphi 中String类型原理介绍

Delphi中字符串的操作很简单,但幕后情况却相当复杂。Pascal传统的字符串操作方法与Windows不同,Windows吸取了C语言的字符串操作方法。32位Delphi中增加了长字符串类型,该类型功能强大,是Delphi缺省的字符串类型。
字符串类型在Borland公司的TurboPascal和16位Delphi中,传统的字符串类型是一个字符序列,序列的头部是一个长度字节,指示当前字符串的长度。由于只用一个字节来表示字符串的长度,所以字符串不能超过255个字符。这一长度限制为字符串操作带来不便,因为每个字符串必须定长(确省最大值为255),当然你也可以声明更短的字符串以节约存储空间。

字符串类型与数组类型相似。实际上一个字符串差不多就是一个字符类型的数组,因此用[]符号,你就能访问字符串中的字符,这一事实充分说明了上述观点。
为克服传统Pascal字符串的局限性,32位Delphi增加了对长字符串的支持。这样共有三种字符串类型:
ShortString 短字符串类型也就是前面所述的传统Pascal字符串类型。这类字符串最多只能有255个字符,与16位Delphi中的字符串相同。短字符串中的每个字符都属于
ANSIChar类型(标准字符类型)。
ANSIString 长字符串类型就是新增的可变长字符串类型。这类字符串由内存动态分配,引用计数,并使用了更新前拷贝(copy–on-write)技术。这类字符串长度没有限制(可 以存储多达20亿个字符!),其字符类型也是ANSIChar类型。
WideString 长字符串类型与ANSIString 类型相似,只是它基于WideChar字符类型,WideChar字符为双字节Unicode字符。

使用长字符串
如果只简单地用String定义字符串,那么该字符串可能是短字符串也可能是ANSI长字符串,这取决于 H 编 译 指 令 的 值 , H编译指令的值, HH+(确省)代表长字符串(ANSIString类型)。长字符串是Delphi库中控件使用的字符串。
Delphi长字符串基于引用计数机制,通过引用计数追踪内存中引用同一字符串的字符串变量,当字符串不再使用时,也就是说引用计数为零时,释放内存。
如果你要增加字符串的长度,而该字符串邻近又没有空闲的内存,即在同一存储单元字符串已没有扩展的余地,这时字符串必须被完整地拷贝到另一个存储单元。当这种情况发生时,Delphi运行时间支持程序会以完全透明的方式为字符串重新分配内存。为了有效地分配所需的存储空间,你可以用SetLength过程设定字符串的最大长度值,如:
SetLength (String1, 200);
SetLength过程只是完成一个内存请求,并没有实际分配内存。它只是把将来所需的内存预留出来,实际上并没有使用这段内存。这一技术源于Windows操作系统,现被
Delphi用来动态分配内存。例如,当你请求一个很大的数组时,系统会将数组内存预留出来,但并没有把内存分配给数组。
一般不需要设置字符串的长度,不过当需要把长字符串作为参数传递给API函数时(经过类型转换后),你必须用SetLength为该字符串预留内存空间,这一点我会在后面进行说明。
看一看内存中的字符串
为了帮你更好地理解字符串的内存管理细节,我写了一个简例StrRef。在程序中我声明了两个全程字符串: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)参数传递不会产生这种情况。由于本例不希望字符串被修改,因此选用常量参数。 为获取字符串内存地址(有利于识别串的实际内容也有助于观察两个不同的串变量是否引用了同一内存区),我通过类型映射把字符串类型强行转换为整型。字符串实际上是引用,也就是指针:字符串变量保存的是字符串的实际内存地址。
为了提取引用计数信息,我利用了一个鲜为人知的事实:即字符串长度和引用计数信息实际上保存在字符串中,位于实际内容和字符串变量所指的内存位置之前,其负偏移量对字符串长度来说是-4(用Length函数很容易得到这个值),对引用记数来说是-8。
不过必须记住,以上关于偏移量的内部信息在未来的Delphi版本中可能会变,没有写入正式Delphi文档的特性很难保证将来不变。
通过运行这个例子,你会看到两个串内容相同、内存位置相同、引用记数为2,如图7.1中列表框上部所示。现在,如果你改变其中一个字符串的值,那么更新后字符串的内存地址将会改变。这是copy-on-write技术的结果。
第二个按钮(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函数探究其它情况下长字符串的特性。

动态分配可以用任意一个分配内存的函数, 其实系统最终调用的都是GetMem, 其它的New、AllocMem、SetLength等等只不过除了调用GetMem外还做了一些初始化处理比如把内存清零。释放可以用Dispose或者FreeMem, 系统最终都是调用FreeMem的, Dispose相当于Finalize§; FreeMem§;
Finalize的作用简单说就是自动释放结构或者数组中的string和动态数组, FreeMem则是直接释放指针所指向的内存,例如:
type
TMyRec = record
Name: string;
X, Y: Integer;
end;
PMyRec = ^TMyRec;
var
MyRec : PMyRec;
begin
New(MyRec); // 编译器会根据MyRec的大小自动计算需要分配的内存数量然后生成代码调用GetMem并将其中的Name字段清零
MyRec.Name := str1 + str2;
Dispose(MyRec); // 除了调用FreeMem释放MyRec这个结构的内存外还会自动清除其中的Name所用到的内存(如果Name指向的string引用计数=1时);
// FreeMem(MyRec); <-- 如果直接调用FreeMem释放MyRec, 则会造成内存泄露, 因为MyRec.Name指向的字符串没有释放(引用计数-1)
end;

由于delphi关于string的内存管理的特殊性, 可以有很多技巧充分利用其优点生成非常高效的代码, 比如要用TList来保存string(不是TStringList), 一般的做法是TList.Items[i]中保存一个PString指针, 这样就需要重新分配一块内存并复制原串, 大数据量的情况下效率很低, 但是如果充分利用string的引用计数和强制类型转换技巧, 可以直接将string作为指针保存在TList.Items[i]中: 比如:
var
List: TList;
GlobalString1, GlobalString2: string;

procedure Test;
var
tmp: string;
begin
tmp := GlobalString1+GlobalString2;
List.Add(Pointer(tmp)); // 将tmp作为指针保存进List

{ 由于Test过程结束时会自动释放掉tmp, 如果直接退出的话List中就保存了一个无效的指针了, 所以这里要欺骗编译器, 让它认为tmp已经被释放掉了, 等于在不改动tmp引用计数(当前是1)的情况下执行相当于tmp := ''的语句, 由于直接tmp := ''会修改引用计数并可能释放掉内存, 所以用强制类型转换将tmp转成一个Integer并将这个Integer设置成0(也就是nil), 此语句完全等价于pointer(tmp) := nil; 只是个人喜好我喜欢用Integer(tmp) := 0而已.
}
Integer(tmp) := 0;

end;

  1. string是Delphi编译器内在支持的(predefined or built-in),是Delphi的一个基本数据类型,而PChar只是一个指向零终止字符串的指针;
  2. String 所存字符串是在堆分配内存的,String变量实际上是指向零终止字符串的指针,与此同时它还具有引用计数(reference count)功能,并且自身保存字符串长度,当引用计数为零时,自动释放所占用的空间。
    3.将string赋值给另一个string,只是一个简单的指针赋值,不产生copy动作,只是增加string的引用计数;
    4.将一个PChar变量类型赋值给一个string 变量类型会产生真正的Copy动作,即将PChar所指向的字符串整个copy到为string分配的内存中;
    5.将string赋值给一个PChar变量类型,只是简单地将string的指针值赋值给PChar变量类型,而string的引用计数并不因此操作而发生变化,因为这种情况PChar会对string产生依赖,当string的引用计数为零自动释放内存空间后,PChar很可能指向一个无效的内存地址,在你的程序你必须小心对付这种情况。
    6.对PChar的操作速度要远远高于对string操作的速度,但PChar是一种落后的管理字符串的方式,而string则以高效的管理而胜出,PChar它的存在只是为了兼容早期的类型和操作系统(调用Windows API时会经常用到),建议平常使用string。

我们先来看一段代码:
procedure Test(p: PChar);
begin
p^ := ‘X’;
end;

procedure TForm1.Button1Click(Sender: TObject);
var
Str: String;
p1, p2, p3, p4: PChar;
begin
Str := ‘abcDEF’;

// Test( PChar(Str) ); // 位置一

p1 := Addr(Str);
p2 := PChar(Str);
p3 := @Str[1];
p4 := PChar(@Str[1]);

// Test( PChar(Str) ); // 位置二

ShowMessage(
Format(‘Address of:’ + #13#10 +
‘p1 = %d’ + #13#10 +
‘p2 = %d’ + #13#10 +
‘p3 = %d’ + #13#10 +
‘p4 = %d’ + #13#10 +
‘Str = %d’ + #13#10 +
‘@Str = %d’,
[ Integer(p1), Integer(p2), Integer(p3), Integer(p4), Integer(Str), Integer(@Str) ])
);
end;

运行结果如下:
Address of:
p1 = 1242448
p2 = 4878232
p3 = 21637516
p4 = 21637516
Str = 21637516
@Str = 1242448

问题1:
p2为PChar指针,根据Delphi白皮书所说,PChar指针也是一种类型,当它指向字符串时,所指向的位置是字符串第一个字符。
OK,既然指向的是第一个字符,p2的地址就应该是Str字符串的第一个字符的地址,也就是说,p2应该和p3相等,可问题是p2的地址不对。Why?

问题2:
p4同样为PChar指针,同样被显示声明了的。和p2不同的是,这次我强制要求它指向Str[1]的地址。
问题来了,p2和p4按照Delphi说明,指向的都应该是同一个位置,可为何地址不同?为何只有单独p2成了有类型的指针!?

疑似BUG:
a)我们将“位置一”的注释符号去掉,运行会抛出异常,提示数据访问【写】出错。

b)将“位置一”的注释恢复,再将“位置二”的注释符号去掉,这次就正常了。WHY?
“位置一”和“位置二”之间我只进行了几个字符串地址的取值,什么都没做,可将 Test 过程放到取值之后就一切正常!?

c)另外,如果我们将“位置一”的代码直接修改为:
Test( PChar(@Str[1]) );
也不会抛出异常,WHY?WHY?WHY?

ps:以上测试都进行于Delphi2010 14.0.3513.24210之下。

有些问题如果不了解 windows 内存保护机制的话很难说清楚。简单的说,虚拟内存是分页的,有些页面是只读的,里面的内容可以读取,但试图直接写入的话将会失败,同时系统将抛出一个异常给应用程序

然后问题就很简单了,一开始把一个常量字符串赋值给 Str,而 delphi 中的长字符串类型就是一个指针,于是 Str 实际上指向了一个常量字符串。实际上指向的内容是不可以改变的,否则将引发一个异常。但是又很可能出现类似这种情况:
s := ‘abcdef’;
s[1] := ‘0’;
在人看来,这样是没什么不自然的,但在编译器看来却很麻烦:如果 s 指向的是个常量字符串怎么办?在 delphi 中长字符串是用引用计数的方式来管理生存期的,常量字符串的引用计数是-1,普通的变量字符串引用计数大于0。
不知道有没有听过“左值”和“右值”(lvalue/rvalue)的概念,c语言教学里可能更常提一些。具体解释起来比较麻烦,简单来说,使用的时候需要有地址的值是左值。当 delphi 处理长字符串的 s[N] 时,如果是左值,就会自动生成一些代码,首先调用 sytem._UniqueString* 检查该字符串 s,如果指向常量字符串的话,就复制一个新的再让 s 指过去。

所以,在你的代码中
p3 := @Str[1];
这里要对 Str[1] 取地址,也就是说它是个左值,于是 Str 的值就被偷偷换了。类似的,上面的示例中
s[1] := ‘0’;
也需要有地址,所以在它之前也会调用 sytem._UniqueString*(s),如果指向常量字符串的话就复制一份。

而向 PChar 的转换不管字符串是否指向一个常量字符串,而且 p[n](p:PChar)就算是左值也无法得知 p 是否是指向一个字符串,所以没法对它做常量检验,所以编译器不会对 p[n] 做任何特殊处理。
因此,由于你第一次调用 Test 之前没有访问 Str[n] 为左值的情况(你也可以把它挪到 p3 之前),Str 还没有被 sytem._UniqueString
替换成新字符串,所以出现异常了。

顺带再提一个关于效率的问题:
还是你的代码,如果 Str=’’ 的话,你会发现 p2 不是 nil。这是因为在长字符串向 PChar 转换的时候,delphi 也会偷偷调用 system._StrToPChar,这些函数的作用是检查该字符串是否为空串,如果是的话,则返回一个指针,指向一个直接以结束符(#0)为内容的常量“字符串”。
说实话,我真不知道有什么情况需要这么干,这种转换在我的使用范围内显得很多余。所以我一直只用 Pointer(s) 的形式将字符串转为 P
Char,省了一次不必要的调用

  1. 左值和右值是在上下文中使用的概念,也就是说,在一某条语句中,使用这个值时是否有需要它有地址。是否是常量字符串跟是否左值没有必然联系。要注意的是,Str 变量本身只是个指针,它指针的是一个 delphi 字符串结构的数据。'abcdef’是放在程序的只读内存区的。
  2. Str[n] 不代表一定是左值,比如你的代码假如有:
    procedure foo(C: Char);

    Str := ‘abcdef’;
    foo(Str[1]);
    这里的 Str[1] 没必要有地址,只要它本身的内容就够了,所以在这里它是个右值,不会调用 sytem._UniqueString*;但假如是
    procedure foo(var C: Char);
    的话,就需要对 Str[1] 取地址,这回的 Str[1] 就是个左值了。

在赋值的时候不开新空间,应该是出于内存占用和效率的考虑(长字符串使用引用计数机制),而且许多情况下不需要改变字串内容。

后面那个问题我也是今天刚注意到,也是因为今天的一个帖子:
http://bbs.2ccc.com/topic.asp?topicid=337143
也就是说,const V: T = … 这种形式的运行期常量,V 本身不是在只读区,换句话说,假如有这样的代码:
const CStr : string = ‘blah’;

PPointer(@CStr)^ := nil;
Writeln(CStr);
输出将会是个空串。后来我想了一下原因,这应该是出于写 delphi 编译器的人的懒惰。project options -> compiler -> syntax options 中有一个 assignable typed constants,打开这个选项之后,语法上允许对常量赋值(否则通不过编译)。所以一个简单的办法是,直接把运行期常量放在可读写段中,这样,只在语法检查中设置常量赋值的开关就够了,而不需要再给编译器和链接器增加额外的代码了。

顺便再说一下 d2010 的一个 bug,现在我突然意识到跟我原来去 qc 提的时候想的其实不太一样(现在已经 open 了,不过别报太大期望)。

假如有一个单元:
unit foo;

Tbar = class

class destructor ClsDestroy;

const S: string = ‘const string’;

class destructor Tbar.ClsDestroy;
begin
Writeln(‘Tbar.class destructor’);
Writeln(‘S="’, S, ‘"’);
end;

程序退出时的输出结果是
Tbar.class destructor
S=""

这是因为紧跟着 foo.finalization,将会释放本单元所有由 rtl 管理生存期的全局变量,比如动态数组、长字符串、interface等。但因为进行期常量实际上也是个全局变量,所以也在要销毁的列表中,于是 S 就变 nil 了。
class destructor 的发生顺序是在全部单元的 finalization 之后,所以等执行到时就是 nil 了

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ysgs129

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值