6.3 字符串数据类型
Object Pascal
中的字符串数据类型比简单的字符数组复杂得多,其功能远远超出了大多数编程语言为类似数据类型提供的功能。在本节中,我将介绍这种数据类型背后的关键概念;在接下来的章节中,我们将更详细地探讨其中的一些特性。
在以下要点中,我总结了理解语言中字符串工作原理的关键概念(请记住,即使不了解这些概念,您也可以使用字符串,因为字符串的内部行为是非常透明的):
- 字符串类型的数据在堆上动态分配。字符串变量只是对实际数据的引用。不必过多担心这一点,因为编译器会透明处理这个问题。与动态数组一样,当声明新字符串时,该字符串是空的。
- 尽管可以以多种方式向字符串分配数据,但还可以通过调用SetLength函数来分配特定数量的内存。参数是字符串应该能够容纳的字符数(每个2字节)。扩展字符串时,现有数据将被保留(但可能会被移动到新的物理内存位置)。而缩小字符串时,部分内容可能会丢失。很少有必要设置字符串的长度。唯一常见的情况是需要将字符串缓冲区传递给指定平台的操作系统函数。。
- 如果要增加字符串在内存中的大小(通过与另一个字符串连接),但相邻内存中有其他内容,则字符串不能在相同的内存位置中增长,因此必须在另一个位置制作字符串的完整副本。
- 要清除字符串,不要对引用本身进行操作,只需将其设置为空字符串,即’ ',或者可以使用相对应的Empty常量。
- 根据
Object Pascal
的规则,字符串的长度(可以通过调用Length获得)是有效元素的数量,而不是分配的元素的数量。与C不同,C具有字符串终止符(#0)的概念,从早期开始,所有版本的 Pascal 都倾向于使用特定的内存区域(字符串的一部分)来存储实际的长度信息。不过,有时你会发现字符串也有结束符。 Object Pascal
字符串使用引用计数机制,该机制会记录有多少个字符串变量在内存中引用给定的字符串。当字符串不再被使用时,即不再有字符串变量引用数据且引用计数为零时,引用计数将释放内存。- 字符串使用写时复制技术,非常高效。当将字符串赋值给另一个字符串或将其传递给例程的字符串参数时,不会复制数据,并且引用计数会增加。但是,如果更改其中一个引用的内容,系统将首先制作副本,然后仅修改该副本,其他引用保持不变。
- 使用字符串连接向现有字符串添加内容的速度通常非常快,而且没有明显的缺点。虽然还有其他方法,但字符串连接既快速又强大。这在当今许多编程语言中并非如此。
我想这样的描述可能会让人有点困惑,所以让我们来看看字符串在实际中的使用。稍后,我将通过一个演示来展示上述的一些操作,包括引用计数和写时复制。不过,在此之前,让我先回到字符串辅助操作和其他一些管理字符串的基本 RTL 函数上来。
首先,让我们根据实际代码测验一下前面列表中的结论。由于字符串操作非常完美,除非您开始查看字符串的内存结构(在本书的较晚部分我会这样做,因为这在目前的阶段过于高级),否则很难完全理解发生了什么。因此,让我们从 Strings101 示例中提取的一些简单字符串操作开始:
var
String1, String2: string;
begin
String1 := 'Hello world';
String2 := String1;
Show('1: ' + String1);
Show('2: ' + String2);
String2 := String2 + ', again';
Show('1: ' + String1);
Show('2: ' + String2);
end;
当执行此第一个代码段时,如果将两个字符串赋值相同的内容,修改其中一个不会影响另一个。也就是说,String1不受对String2的更改的影响:
1: Hello world
2: Hello world
1: Hello world
2: Hello world, again
不过,在后面的演示中我们会更好地了解到,初始赋值并不会导致字符串的完全复制,复制是延迟的,这种特性被称为写时复制(copy-on-write)。
另一个需要了解的重要特性是如何管理长度。如果询问字符串的长度,就会得到实际值(实际值存储在字符串元数据中,因此操作速度非常快)。但如果调用 SetLength,则需要分配内存,而内存通常不会被初始化。这通常用于将字符串作为缓冲区传递给外部系统函数。
如果需要一个空字符串,可以使用伪构造函数Create。最后,您可以使用SetLength修剪字符串。以下代码演示了所有这些情况:
var
String1: string;
begin
String1 := 'Hello world';
Show(String1);
Show('Length: ' + String1.Length.ToString);
SetLength(String1, 100);
Show(String1);
Show('Length: ' + String1.Length.ToString);
String1 := 'Hello world';
Show(String1);
Show('Length: ' + String1.Length.ToString);
String1 := String1 + string.Create(' ', 100);
SetLength(String1, 100);
Show(String1);
Show('Length: ' + String1.Length.ToString);
end;
输出大致如下:
Hello world
Length: 11
~ֳ~ו~~؛~ف~٦~ڋ~گ~ۓ~~Helloworld~̆~~̫ ͌~ʹ~Η~υ~ϧ~Ј~Щ~ы~ѭ~ҏ~ұ~Ә~Ӽ~ԟ~Շ~հ
~〈~⌇~۵~ܚ~ܼ~ݡ~~ރ~ޤ~ߊ~߰~~~~~ࢮ~ ~~ Length: 100
Hello world
Length: 11
Hello world
Length: 100
节要强调的第三个概念是空字符串。当字符串的内容是空字符串时,它就是空字符串。无论是赋值还是测试,都可以使用两个连续的引号或特定函数:
var
String1: string;
begin
String1 := 'Hello world';
if String1 = '' then
Show('Empty')
else
Show('Not empty');
String1 := ''; // 或者 String1.Empty;
if String1.IsEmpty then
Show('Empty')
else
Show('Not empty');
end;
上述代码生成这个简单的输出:
Not empty
Empty
6.3.1 传递字符串作为参数
正如我已经解释过的,如果你将一个字符串赋值给另一个字符串,你只是复制了一个引用,而内存中的实际字符串并没有被复制。但是,如果你编写的代码改变了该字符串,那么字符串就会首先被复制(仅在该点),然后被修改。
将字符串作为参数传递给函数或过程时,也会发生类似的情况。默认情况下,你会获得一个新的引用,如果你在函数中修改了字符串,这并不会影响原始字符串。如果想获得不同的行为,即在函数中修改原始字符串,则需要使用 var 关键字通过引用传递字符串(就像大多数其他简单数据类型和托管数据类型一样)。
但如果不想修改作为参数传递的字符串呢?在这种情况下,可以对参数使用 const 修饰符进行优化。这样做意味着编译器不会让你在函数或过程中修改字符串,但也会因此优化参数传递操作。事实上,const 字符串不需要函数在开始时增加字符串引用计数,也不需要在结束时减少字符串引用计数,因为函数知道字符串不能被修改。
虽然字符串管理的例程非常快,但执行数千或数百万次也会给程序增加一点开销。这就是为什么在函数不需要修改字符串参数值的情况下,建议将字符串作为常量传递(尽管下面的注释中提到了潜在存在的问题)。
用编码术语来说,这是三个以不同方式传递字符串参数的过程的声明:
procedure ShowMsg1(Str: string);
procedure ShowMsg2(var Str: string);
procedure ShowMsg3(const Str: string);
注解:近年来,除非函数和方法需要对字符串进行修改,否则强烈所有字符串参数都应作为常量传递。但有一个非常重要的注意事项:对于常量字符串参数,编译器获取字符串引用后不会对其进行 “管理”(不进行引用计数等),而是将其视为指向内存位置的指针。编译器会正确检查例程的代码是否不会更改字符串参数。但是,编译器无法控制参数所指向的原始字符串会发生什么变化。
对该字符串的更改会影响其内存布局和位置,这一点普通字符串参数可以处理(具有多个引用的字符串会自动执行写时复制操作),而常量字符串参数则会受到这些更改的影响。换句话说,对原始字符串的更改会使引用它的常量参数失效,使用它很可能会导致内存访问错误。