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 標準需要“啟用”,而對於標準例程來說,這種情況根本不會發生。