Pascal 编程/指针
本章介绍的新数据类型为您的技巧库增加了另一层抽象:指针是迄今为止最复杂的数据类型。如果您掌握了它们,您就具备了应对甚至最顶尖的汇编编程学科的能力。所以,让我们开始吧!
在 Pascal 中,有两种变量类型。
- 到目前为止,我们一直在使用静态变量。它们在整个代码块执行期间存在,例如在
program
运行期间或仅在例程执行期间。 - 还有一种叫做动态变量。它们不一定在整个代码块期间“存在”。这意味着,没有分配静态内存,但使用的内存空间每次程序运行时都会有所不同。
在使用静态变量时,编译器[fn 1]已经提前知道将使用哪个内存块。[fn 2]然而,动态变量顾名思义是动态的,这意味着它们将占据不同且不可预测的内存段。
内存通过地址引用。地址在CS中,只是一个数字,我们可以说是一个integer
值。[fn 3] 当您想引用某个内存块时,您会使用它的地址。
指针数据类型是一个存储地址的值。然后可以使用此地址访问它所引用的内存。然而,指针仅仅是指针:它只是指向,而没有说明“指向谁”,这个内存块“属于”哪个变量。
在 Pascal 中,指针数据类型声明以 ↑
(向上箭头)开头,或者更常见地用 ^
(插入符)字符开头,后面跟着数据类型名称。
program pointerDemo(output);
type
charReference = ^char;
这种指针数据类型的变量可以指向单个char
值(而不是其他数据类型)。在 Pascal 中,所有指针数据类型都必须指示指针所引用的值的类型。这是因为指针本身仅仅是一个地址:地址只是指向内存块的起始位置。没有关于此块大小、长度的说明。域限制,即目标值数据类型的规范,告诉编译器“内存块有多大”,因此如何正确读写、如何访问它。
与任何其他数据类型不同,指针数据类型是唯一可以 使用尚未声明的数据类型的类型。下面您将看到一个使用场景,但让我们在脚本中继续。
当您在var
部分声明变量时,您是在声明一个静态变量。在以下代码片段中,c
是一个静态变量,因此它的内存位置已知。
var
c: charReference;
begin
{ artificially stall the program without breakpoints }
readLn;
在此时,尚未为char
值分配任何内存空间。已经存在存储指针值的空间,即char
值的地址,但我们没有可用空间来放置它,即一个char
值,在任何地方。
在 Pascal 中,您首先需要调用procedure new
来为您的program
分配内存。 New
接受一个指针变量作为参数,并将保留足够的内存空间来容纳一个指针域值。
new(c);
此操作之后
- 您将为(在本例中)一个
char
值占用额外的内存,而program
之前没有“拥有”它,并且 c
,即指针变量本身,将为我们提供这个新分配的内存的地址。
与任何类型的变量一样,我们现在获得的内存空间是完全未定义的(未初始化)。
为了使用我们刚刚获得的内存,我们将不得不跟踪指针。这是通过在指针变量名称后追加 ↑
(或通常是 ^
)来实现的。
c^ := 'X';
writeLn(c^);
这个动作叫做解除引用。指针是对底层char
值的(一种)引用。这个char
值没有名称,但无论如何您都可以使用指针来访问它。
在该解除引用的变量上,我们可以执行对指针域数据类型允许的所有操作。例如,在这里,我们可以为它分配一个char
值'X'
,然后在writeLn
中使用它,如上所示。
请注意,像c := 'X'
这样的操作将不起作用,因为在这种情况下,c
仅指指针,即地址存储器。
- 表达式
c
的数据类型为charReference
。 - 表达式
c^
的数据类型为char
。
在 Pascal 中,除了使用 new
之外,禁止直接将地址分配给指针。有关 nil
的特殊情况,请参见下文。
释放内存
[edit | edit source]调用 new
后,相应的内存将专门保留给你的 program
。这种内存管理发生在你的 program
之外。它是相应 OS 的典型任务。
为了反转 new
的操作,有一个专门的 procedure
用于“取消保留”内存:Dispose
。
readLn;
dispose(c);
readLn;
end.
Dispose
接收指针变量的名称,并释放之前使用 new
分配的内存。在 dispose
之后,你不能再跟踪或解引用指针。但是,指针本身仍然存储了地址,在该地址中,曾经引用了 char
值。同时,该“释放的”内存可以被再次使用或被其他人使用。
生命周期
[edit | edit source]在 Pascal 中,动态变量的内存将保持保留状态
- 只要它可访问,这意味着至少有一个指针必须指向它,或者
- 直到你明确地 请求“取消保留”内存。
如果一块内存由于某种操作而变得不可访问,它将自动释放。这可能会隐式发生:在上面的 program
中,指针变量 c
在 program
终止时将“消失”。由于此变量是/曾经是指向我们先前保留的 char
值的唯一指针(剩余),因此将自动进行一个“不可见的” dispose
。因此,我们方面的显式 dispose
是没有必要的。
但是,不幸的是,并非所有编译器都符合 Pascal ISO 标准中规定的此规范。例如,Delphi 以及 FPC(即使在它的 {$mode ISO}
兼容模式下,截至版本 3.2.0)都不会发出自动的 dispose
。在那里,显式的 dispose
是必要的。[fn 5] 请放心,使用 GPC 则没有必要;GPC 完全符合 ISO 标准 7185 级别 1。
请注意,内存可访问性是传递的:这意味着,例如,指向指向内存的指针的指针仍然满足可访问性要求。
指示
[edit | edit source]分配和释放内存的额外管理工作似乎很麻烦,那么什么时候这样做是有意义的呢?
- 在
var
部分中声明的所有变量都需要提前指定其大小。但是,对于某些应用程序,你不知道你需要存储和处理多少数据。指针是一种克服此限制的方法。下面我们将探讨如何实现。 - 指针值可以用于表示数据图、网络,使你能够将所有内容相互关联。这意味着你不需要多次存储相同的数据。指针值通常在内存需求方面是一个相对较小的数据类型。处理指针以较低的内存空间需求为代价换取更高的复杂度。
此外,指针值经常用于实现例程的 可变参数:由于其较小的大小,传递单个指针值可能比传递(即复制)例如整个 array
更快。这种指针的使用是完全透明的。Pascal 为你提供了足够好的语言结构;你将在有关 作用域 的章节中学习更多关于可变参数的信息。
链接
[edit | edit source]空指针
[edit | edit source]所有指针都可以分配一个字面量值 nil
。 nil
指针值表示“不指向任何特定位置”的概念。
巧合的是,nil
是唯一可以用于指针字面量的指针值。
const
nowhere = nil;
你无法在源代码中的任何地方显式指定任何其他指针值。这也意味着你不能显式地 比较 任何特定的指针值,除了 nil
。
请注意,nil
从根本上不同于未初始化的变量。你可以读取已被分配值 nil
的指针的值,但仍然禁止尝试读取尚未分配任何值的变量的值。
尝试 解引用当前拥有值 nil 的指针会导致致命错误。 |
允许的操作符
[edit | edit source]在引言中,我们使用了将指针与 integer
值进行比较的类比。但是,这确实是事实。与 integer
值不同,指针绝不“有序”;它们不属于序数数据类型的类别。对于指针没有定义 ord
、succ
、pred
,但同样排序比较运算符(如 <
或 >=
)也不适用于指针,更不用说任何算术运算符与指针值结合使用都是无效的。
唯一适用于指针的操作符是[fn 6]
=
,两个指针值是否引用相同的地址,<>
,两个指针值是否引用不同的地址,以及:=
,将指针值(nil
或相同数据类型的已定义指针变量的值)分配给指针变量。
乍一看,这似乎是一个很大的限制,但它可以防止你执行可能造成损害,甚至只是愚蠢的行为。
先有鸡还是先有蛋
[edit | edit source]指针是唯一一种可以使用尚未声明的数据类型声明的数据类型。[fn 7] 这种情况使得声明包含指针的数据类型成为可能,这些指针可能指向正在声明的数据类型本身或其他尚未声明的数据类型。这是因为指向 foo
的指针具有与指向 bar
或任何其他数据类型的指针相同的内存需求。指针的域限制不会(必要地/显式地)存储在 program
中。
在下面的代码片段中,numberListItem
尚未声明,但您仍然可以声明一个新的指针数据类型。
program listDemo(input, output);
type
numberListItemReference = ^numberListItem;
numberListItem = record
value: real;
nextItemLocation: numberListItemReference;
end;
但是,您不能颠倒 numberListItemReference
和 numberListItem
的声明顺序;在实际看到/读取相应的声明之前,编译器无法神奇地推断出 nextItemLocation
是一个指针。
将所有内容整合在一起
[edit | edit source]现在我们可以使用这种数据结构来动态存储一系列数字。请注意在下面的代码中何时取消指针的引用。
var
numberListStart: numberListItemReference;
begin
new(numberListStart);
readLn(numberListStart^.value);
new(numberListStart^.nextItemLocation);
readLn(numberListStart^.nextItemLocation^.value);
dispose(numberListStart^.nextItemLocation);
dispose(numberListStart);
end.
整个 program
包含一个静态变量。只有变量 numberListStart
由您声明。但是,在运行时,当 program
正在运行时,您将在某个时候拥有两个额外的 real
值。
请注意此示例中 dispose
语句的顺序:提供的指针变量必须有效,因此在这种特定情况下,逆序是不可能的。
诚然,这个例子本来可以通过简单地声明两个 real
变量来更好地实现。指针的真正威力在您不像上面的代码那样使用指针作为抽象手段时变得显而易见。本章的 练习 将深入探讨这一点。
例程
[edit | edit source]特别是,让我们首先探索一种特殊类型的指针:例程参数,即函数和过程参数,是例程的参数,它们允许您通过虚拟传递另一个例程的地址来静态修改例程的行为。让我们看看它是如何工作的。
声明和使用
[edit | edit source]在例程的形式参数列表中,您可以声明一个看起来像例程签名的参数。
program routineParameter(output);
procedure fancyPrint(function f: integer);
begin
writeLn('❧ ', f:1, ' ☙')
end;
在 fancyPrint
的定义中,您可以像使用在 fancyPrint
之前和外部定义和声明的常规 function
一样使用参数 f
。但是,此时尚不知道将使用哪个函数。实际参数 f
实际上是一个指针。[fn 8] 我们只知道这个指针的“域”是任何不带参数且返回 integer
值的 function
,但这已经足够了。
一个例程适合所有
[edit | edit source]要调用这种类型的例程,您需要指定一个合适的例程指示符,该指示符在参数的顺序、数量和数据类型以及(如果适用)返回值的数据类型方面与签名匹配。
function getRandom: integer;
begin
{ chosen by fair dice roll: guaranteed to be random }
getRandom := 4
end;
function getAnswer: integer;
begin
{ the answer to the ultimate question of life, the universe and everything }
getAnswer := 42
end;
begin
fancyPrint(getRandom);
fancyPrint(getAnswer)
end.
要向例程提供例程参数值,只需命名一个兼容的例程即可。请注意,在这种情况下,您永远不会指定任何参数,因为您没有进行调用,而是被调用的例程将“代表”您进行调用。指定例程的名称,从而传递其地址,足以实现这一点。
诸如 writeStr (EP) 或 sin 之类的标准例程不能以这种方式使用,[fn 9] 因为它们是语言的组成部分。没有(单个)例程定义。
|
注意事项
[edit | edit source]作为初学者,指针很难驯服。没有经验,您将经常观察到(对您而言)“意外”的行为。这里介绍了一些陷阱。
with
子句
[edit | edit source]在将指针与 with
子句 结合使用时,必须特别小心。with
子句顶部的表达式将在执行任何后续语句之前被评估一次。在整个 with
语句中,使用“简短”表示法的表达式实际上将使用一个不可见的瞬态值。这加快了执行速度,因为不会反复评估相同的值,但它也存在一个陷阱。
令人惊讶的是,使用 FQI 的长表示法可能会变得无效,而简短表示法一开始似乎仍然有效。下面的 program
演示了这个问题。
program withDemo(output);
type
foo = record
magnitude: integer;
end;
fooReference = ^foo;
var
bar: fooReference;
begin
new(bar);
bar^.magnitude := 42;
with bar^ do
begin
dispose(bar);
bar := nil;
{ Here, bar^.magnitude would fail horribly, }
{ but you can still do the following: }
writeLn(magnitude);
end;
end.
当您编译并运行这个 program
时,您会
- 注意到它打印的不是
42
,但是 - 它仍然可以打印任何东西,这应该相当令人惊讶。
writeLn(magnitude)
实际上使用的是一个“隐藏(指针)变量”,而不是 bar
。这个变量的值是在 with
子句顶部评估的一次。编译器不会(也不可能)抱怨 bar
同时变得无效。您没有对实际使用的隐藏变量进行任何赋值(即它仍然被认为具有有效的值),因此没有理由抱怨。
限制
[edit | edit source]本节主要针对 Delphi 和 FPC 的用户,以及可能的其他一些编译器。GPC 的用户可以跳过本节,但鼓励了解理论。 |
内存不是无限的资源。这对我们有一些严重的影响。
大多数 OS 会尽力满足进程的请求。使用非 ISO 兼容编译器,下面的 program 注定会失败。program oomDemo;
var
p: ^integer;
begin
while true do
begin
new(p);
end;
end.
program ` 覆盖了先前的指针值,从而使以前关联的 `integer ` 值无法访问,但现在无法访问的内存仍然专门保留给您的 `program `。根据操作系统的内部结构以及用于编译您的 `program ` 的编译器,您的计算机最终将冻结(对任何输入无响应),或者(一个健壮的操作系统)将杀死您的 `program `(术语指立即终止它,而不给它任何机会修复问题),并回收曾经保留但从未释放的内存。 |
没有办法检查任何后续的 `new
` 是否会耗尽有限资源内存。在多任务操作系统上,有可能在您查询可用内存空间大小和实际请求更多内存之间,另一个同时运行的 `program
` 已经获得了内存,因此没有可用内存,或者您的内存不足。这种情况被称为检查时到使用时。您只需要以一种决定性的方式请求更多内存。
对于本教科书的范围,这个问题更像是理论上的问题。21 世纪或之后制造的标准台式计算机不会因为这里给出的任何编程练习而耗尽内存。这并不意味着你可以浪费内存。 |
不要囤积内存:为了减轻潜在的内存不足(OOM)情况,通常明智的做法是在确定不再使用内存后立即 `dispose ` 它。 |
任务
[edit | edit source]program listDemo
` 使其能够接受未知数量的项目。该 `program
` 应该首先打印总项目的数量,然后打印项目列表。function readNumber: numberListItemReference;
var
result: numberListItemReference;
begin
new(result);
with result^ do
begin
readLn(value);
nextItemLocation := nil;
end;
readNumber := result;
end;
{ === MAIN ============================================================= }
var
numberListRoot: numberListItemReference;
currentNumberListItem: numberListItemReference;
numberListLength: integer;
begin
writeLn('Enter numbers and finish by abandoning input:');
{ input - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - }
numberListRoot := readNumber;
numberListLength := 1;
currentNumberListItem := numberListRoot;
while not EOF(input) do
begin
with currentNumberListItem^ do
begin
nextItemLocation := readNumber;
currentNumberListItem := nextItemLocation;
end;
numberListLength := numberListLength + 1;
end;
{ output - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - }
writeLn('You’ve entered ', numberListLength:1, ' numbers as follows:');
currentNumberListItem := numberListRoot;
while currentNumberListItem <> nil do
begin
with currentNumberListItem^ do
begin
writeLn(value);
currentNumberListItem := nextItemLocation;
end;
end;
{ release memory - - - - - - - - - - - - - - - - - - - - - - - - - - - }
currentNumberListItem := numberListRoot;
while currentNumberListItem <> nil do
begin
with currentNumberListItem^ do
begin
dispose(currentNumberListItem);
{ Note that at _this_ point, after dispose(…), writing
… := currentNumberListItem^.nextItemLocation
would be illegal! }
currentNumberListItem := nextItemLocation;
end;
end;
end.
procedure
` ,该过程接受一个 `real function
` 并绘制其函数值,类似于 *
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
为此,请完成以下 `procedure
`
program graphPlots(output);
const
lineWidth = 80;
procedure plot(
function f(x: real): real;
xMinimum: real; xMaximum: real; xDelta: real;
yMinimum: real; yMaximum: real
);
{
this is the part you are supposed to implement
}
function wave(x: real): real;
begin
wave := sin(x);
end;
begin
plot(wave, 0.0, 6.283, 0.196, -1.0, 1.0);
end.
plot
` 实现可能如下所示procedure plot(
function f(x: real): real;
xMinimum: real; xMaximum: real; xDelta: real;
yMinimum: real; yMaximum: real
);
var
x: real;
y: real;
column: 0..lineWidth;
begin
x := xMinimum;
while x < xMaximum do
begin
y := f(x);
{ always reset `column` in lieu of doing that in an `else` branch }
column := 0;
{ is function value within window? }
if (y >= yMinimum) and (y <= yMaximum) then
begin
{ move everything toward zero }
y := y - yMinimum;
{ scale [yMinimum, yMaximum] range to [0..79] range }
y := y * (lineWidth - 1) / (yMaximum - yMinimum);
{ convert to integer }
column := round(y) + 1;
end;
以下 `write
` / `writeLn
` 的使用实际上是扩展 Pascal(EP)的扩展。在 ISO 标准 7185 中规定的标准 Pascal 中,所有格式说明符都需要是正整数。扩展 Pascal 也允许使用零值。虽然对于打印整数,宽度说明符仍然表示最小宽度,但对于字符和字符串,它表示精确宽度。因此,以下代码可以在 `column` 为零时打印空行,即当函数值超出窗口范围时。
writeLn('*':column);
如果您的编译器不支持这种 EP 扩展,那么您应该很容易地调整 `writeLn` 行。
x := x + xDelta;
end;
end;
注释
- ↑ Pascal ISO 标准将这个概念称为识别变量。
- ↑ 为了简单起见,我们说这是编译器的任务。通常,它更像是链接器(链接编辑器)的任务,它确定并替换特定地址。
- ↑ 实际上,编译器不知道将使用哪个(物理)内存,但由操作系统管理的另一个抽象层称为虚拟内存使我们能够这样思考。
- ↑ 这只是一个为了解释目的而进行的类比。整数值的范围不一定对应于允许的指针值(即地址)。例如,在 x32 目标上,指针具有 32 个有效位,但整数占用 64 位。
- ↑ 无法释放内存可能会不被注意。您的程序将编译和运行,而无需使用适当的 `dispose`。但是,最终有限的资源“内存”将被耗尽,这种情况称为内存泄漏。如果没有足够的可用内存,任何 `new` 都将失败并立即终止程序。
- ↑ 一些手册将 `↑` / `^` 称为“运算符”。然而,这种说法并不精确。`↑` 不会改变程序的状态,也不会执行操作,而仅仅指示编译器以与没有箭头存在时不同的方式处理标识符。
- ↑ 指针数据类型的声明和引用数据类型的声明必须发生在相同的范围内,在同一个块中,换句话说,在一个相同的 `type` 部分中。
- ↑ 这是一个实现细节,没有由 ISO 标准指定,虽然实际上大多数编译器将它实现为指针。
- ↑ 一些编译器没有这个限制,但是 ISO 标准需要“激活”,而对于标准例程来说,这种情况根本不会发生。