跳轉到內容

編譯器構造/處理錯誤

來自華夏公益教科書

處理錯誤

[編輯 | 編輯原始碼]

即使是有經驗的程式設計師也會犯錯誤,因此他們會感謝編譯器在識別錯誤方面提供的任何幫助。新手程式設計師可能會犯很多錯誤,並且可能不太瞭解程式語言,因此他們需要清晰、準確、無術語的錯誤報告。特別是在學習環境中,編譯器的主要功能是報告源程式中的錯誤;作為偶爾的副作用,您實際上可能會得到翻譯和執行的程式。

作為一般規則,編譯器編寫者應該嘗試用比較簡單的英語表達錯誤訊息,而不是參考官方程式語言定義(一些語言定義使用了一些模糊或專門的術語)。

例如,訊息“無法將字串轉換為整數”可能比“未找到強制轉換”更清晰。

歷史筆記

[編輯 | 編輯原始碼]

在 1960 年代和 1970 年代的大部分時間裡,批處理是使用(大型)大型機計算機的正常方式(個人計算機直到 1980 年代初才開始成為家庭用品)。從您將一疊穿孔卡片交給接待員到您收集卡片以及列印的程式列表(伴隨著錯誤訊息或一些有用的結果)可能需要幾個小時,甚至一天的時間。

在這種情況下,編譯器報告儘可能多的錯誤非常重要,因此編寫編譯器的一部分工作是“從錯誤中恢復”並繼續檢查(但不翻譯),以期發現更多錯誤。不幸的是,一旦發生錯誤(特別是如果錯誤影響了宣告),編譯器很可能會變得混亂併產生大量虛假的錯誤報告。

程式設計師然後需要決定哪些錯誤要嘗試修復,哪些錯誤要忽略,希望它們在修復了早期錯誤後會消失。一些編譯器特別容易產生虛假的錯誤報告。幫助臺工作人員唯一有用的建議是:修復第一個錯誤,因為編譯器在那時還沒有機會讓自己混亂。

大量的編譯器開發工作通常都投入到錯誤恢復的嘗試中。您可以嘗試猜測程式設計師可能想做什麼,或者插入一些令牌以至少允許繼續解析,或者乾脆放棄該語句並跳到下一個分號。後一項操作可能會跳過一個 **end** 或其他重要的程式結構令牌,從而使編譯器更加混亂。

整合開發環境 (IDE)

[編輯 | 編輯原始碼]

現在有快速的個人計算機,因此 IDE 越來越受歡迎,編輯器和編譯器緊密耦合,可以從一個圖形介面使用。許多 IDE 還包含一個偵錯程式。在某些情況下,編輯器是語言敏感的,因此它可以提供匹配的括號和/或語句模式以幫助減少微不足道的錯誤數量。IDE 還可以使用不同的顏色表示源語言中的不同概念,例如將保留字用 **粗體** 表示,將註釋用綠色表示,將常量用藍色表示,等等。

這種速度和緊密耦合允許編譯器編寫者採用一種更簡單的方法來處理錯誤:編譯器只要發現錯誤就停止,然後編輯器將游標放在源文字中檢測到錯誤的位置並顯示一些具體的錯誤訊息。請注意,檢測到錯誤的位置很可能是在實際發生錯誤的位置之後的一段距離。

早在 1964 年就出現了行模式 IDE,許多 BASIC 系統就是這類系統的例子;我們將在本書的 案例研究 - 一個簡單的直譯器 部分實現類似的東西。

編譯時錯誤

[編輯 | 編輯原始碼]

在編譯期間,始終可以給出檢測到錯誤的確切位置。可以透過將編輯器游標放在精確的位置來顯示此位置,或者(批處理模式)透過列出有問題的行,後面跟著包含某種標記(例如“|”)的行,該標記位於錯誤點的下方,或者(不太方便)透過提供該點的行號和列號。

請記住,錯誤的實際位置(區別於檢測到的位置)很可能是在程式中更早的某個點;在某些情況下(例如括號不匹配),編譯器可能能夠指示早期錯誤的性質。

錯誤訊息必須清晰、正確且相關。

Murray Langton 遇到的最糟糕的反例是一個編譯器,它在報告“缺少分號”時,實際錯誤是在錯誤位置的額外空格。為了進一步混淆,沒有給出程式中錯誤位置的任何指示。更令人惱火的是,源語言甚至沒有使用分號!

詞法分析階段的錯誤

[編輯 | 編輯原始碼]

在詞法分析階段,可以檢測到的錯誤相對較少。

奇怪的字元

一些程式語言不使用所有可能的字元,因此出現的任何奇怪字元都可以被報告。但是請注意,幾乎任何字元都可以在引號字串中使用。

長引號字串 (1)

許多程式語言不允許引號字串跨越多行;在這種情況下,可以檢測到缺少的引號。這類語言通常有一些方法可以自動將連續的引號字串連線在一起,以允許非常長的字串。

長引號字串 (2)

如果引號字串可以跨越多行,那麼缺少的引號會導致大量文字在檢測到錯誤之前被“吞掉”。錯誤很可能在下一個引號字串的文字中被報告,這在作為程式的一部分時不太可能產生意義。

無效的數字

像 123.45.67 這樣的數字可以在詞法分析期間被檢測為無效(前提是語言不允許句點出現在數字的後面)。一些編譯器編寫者更願意將此視為詞法分析的兩個連續數字 123.45 和 .67,並將其留給語法分析器報告錯誤。一些語言不允許數字以句點/小數點開頭,在這種情況下,詞法分析器可以輕鬆檢測到這種情況。

語法分析階段的錯誤

[編輯 | 編輯原始碼]

在語法分析期間,編譯器通常試圖根據對少量令牌之一的期望來決定下一步要做什麼。因此,在大多數情況下,可以透過簡單地列出此時可接受的令牌來自動生成有用的錯誤訊息。

  Source:  A + * B
  Error:       | Found '*', expect one of: Identifier, Constant, '('

在括號不匹配的情況下可能需要更具體的定製錯誤訊息。

  Source:  C := ( A + B * 3 ;
  Error:                    | Missing ')' or earlier surplus '('

語義分析階段的錯誤

[編輯 | 編輯原始碼]

在語義分析期間報告的最常見錯誤之一是“識別符號未宣告”;要麼是您省略了宣告,要麼是您拼錯了識別符號。

在語義分析期間檢測到的其他常見錯誤與型別的使用不相容有關,例如嘗試將邏輯值(例如 **true**)分配給字元字串。其中一些錯誤可能非常微妙,但同樣容易自動生成相當精確的錯誤訊息。

  Source: SomeString := true;
  Error:  Can't assign logical value to character string

這種型別檢查的程度在很大程度上取決於源語言。

  • PL/1 允許驚人的各種自動型別轉換,因此幾乎沒有檢查可能進行。
  • Pascal 更加挑剔;您甚至不能將實數值分配給整型變數,而無需明確指定您是否要對該值進行舍入或截斷。
  • 一些編寫者認為,應該擴充套件型別檢查以涵蓋適當的單位,以進行更多檢查,例如,將距離乘以溫度是沒有意義的。

語義錯誤的其他可能來源是引數計數錯誤和下標計數錯誤。 通常,將子程式宣告為具有 4 個引數,然後用 5 個引數呼叫該子程式是錯誤的(但某些語言確實允許子程式具有可變數量的引數)。 通常,將陣列宣告為具有 2 個下標,然後嘗試使用 3 個下標訪問陣列元素也是錯誤的(但某些語言可能允許使用比宣告中更少的下標來選擇陣列的“切片”)。

報告執行時錯誤的位置

[編輯 | 編輯原始碼]

人們普遍認為,應該檢測和報告諸如除以 0 之類的執行時錯誤。 但是,關於如何報告錯誤位置,存在很大差異。

  • 某些系統僅提供導致錯誤的指令的十六進位制地址。 如果您的編譯器/連結器生成了載入對映,那麼您可能能夠執行一些十六進位制運算來識別它是哪個例程。
  • 某些系統會告訴您發生錯誤的例程的名稱,以及可能當時所有處於活動狀態的例程的名稱。
  • 少數友好的系統會提供原始碼行號,這非常有用。 但是請注意,廣泛的程式最佳化可能會移動程式碼並混合語句,在這種情況下,行號可能只是近似的。 從實現者的角度來看,有幾種方法可以提供行號細節或等效內容。
    • 編譯後的程式可以包含將當前行號放在某個固定位置的指令;這會使程式更長、更慢。 當然,編譯器只需為可能實際導致錯誤的語句新增這些指令。
    • 編譯後的程式可以包含一個表,指示每個原始碼行在編譯後的程式碼中開始的位置。 發生錯誤時,特殊程式碼可以查詢此表並確定涉及的原始碼行。 這會使編譯後的程式碼更長,但不會降低其速度。
    • 在一些未經最佳化的系統中,可能能夠從編譯後的程式碼中推斷出一些源資訊,例如,Elliott 503 Algol 60 編譯器可以報告:“在例程'xyz'的第三個begin之後的第二個除法處除以 0”。 這不會影響程式碼大小或速度,但可能並不總是可行。


執行時速度與安全性

[編輯 | 編輯原始碼]

本節中的某些內容可能存在爭議。

存在某些潛在的執行時錯誤,許多系統甚至沒有嘗試檢測。 語言定義可能只是說違反某些語言規則的結果是未定義的,即您可能會收到錯誤訊息,或者您可能會在沒有任何警告的情況下得到錯誤的答案,或者您可能在某些情況下得到正確的答案,或者您可能每次執行程式都會得到不同的答案,或者您可能會觸發第三次世界大戰(“未定義”確實意味著任何事情都可能發生)。

過去,有一些計算機(Burroughs 5000+、Elliott 4130)具有硬體支援,可以快速檢測到其中一些錯誤。 許多當前的 IDE 確實具有除錯選項,可以幫助檢測其中一些執行時錯誤

  • 嘗試除以 0。
  • 算術運算期間的溢位(以及可能的下溢)。
  • 嘗試在變數被設定為某個合理值(未定義變數)之前使用它。
  • 嘗試引用不存在的陣列元素(無效下標)。
  • 嘗試將變數(定義為具有有限範圍)設定為此範圍之外的某個值。
  • 與指標相關的各種錯誤
    • 嘗試在指標被設定為指向某個有用位置之前使用它。
    • 嘗試使用nil指標,該指標明確地沒有指向任何有用位置。
    • 嘗試使用指向它應該指向的陣列之外的指標。
    • 嘗試在它指向的記憶體被釋放後使用指標。

歷史上,不做這些檢查的主要原因是效能的影響。 當 FORTRAN 首次開發(大約在 1957 年)時,它必須與用匯編語言編寫的程式碼競爭;事實上,現代編譯器中使用的許多最佳化技術最初是在那個時候開發和使用的。 C 語言(大約在 1971 年)最初被開發為組合語言的替代品,供經驗豐富的系統程式設計師在編寫作業系統時使用。

在這兩種情況下,都有充分的理由不做這些檢查。 如今,計算機硬體比 1957 年或 1971 年快得多,並且有更多經驗不足的程式設計師編寫程式碼,因此避免檢查的理由要弱得多。 實際上,在看似正常的程式上新增檢查會很有啟發性/令人驚訝/令人尷尬;即使是那些“工作”了多年的程式也可能發現存在令人驚訝數量的錯誤。

Hoare(快速排序的發明者)在 1960 年代初期負責一個 Algol 60 編譯器;下標檢查總是完成的。 事實上,Hoare 在“關於程式語言設計的提示”中說:“在測試過程中進行檢查,然後在生產中抑制它們,就像一個水手在陸地上訓練時穿著救生衣,然後在出海時脫掉救生衣一樣。”

在“計算機程式設計心理學”一書中,Weinberg 回憶了以下軼事

經過幾個月的努力,一個特定的應用程式仍然無法正常工作,因此公司另一部門的顧問被叫來。 他得出結論,現有的方法永遠無法可靠地實現。 在回家的路上,他意識到如何完成這項工作。 經過幾天的工作,他有一個演示程式可以正常工作,並將其展示給最初的程式設計團隊。
團隊領導:您的程式在處理時需要多長時間?
顧問:每個案例大約需要 10 秒。
團隊領導:但我們的程式只需要 1 秒。 {團隊此時沾沾自喜}
顧問:但您的程式不工作。 如果程式不必工作,我可以讓它變得像您想要的一樣快。

Wirth 將 Pascal 設計為一種教學語言(大約在 1972 年);對於許多 Pascal 編譯器來說,預設情況下是執行所有安全檢查。 一些 Pascal 系統有一個選項可以抑制對程式某些有限部分的檢查。

當一種程式語言允許使用指標和指標運算來訪問陣列元素時,對訪問不存在的陣列元素進行檢查的成本可能很高。 請注意,它確實可以完成:每個指標都足夠大,可以包含三個地址,第一個是程式設計師直接操作和使用的地址,另外兩個地址是第一個的上下限。 當語言允許整數和指標之間的相互轉換時,這種方法可能會遇到問題。

在“未定義變數”的情況下,請注意,將所有變數最初設定為 0 實際上是一個壞主意(除非語言當然要求這樣做)。 這樣的初始設定會降低程式的可移植性,也可能會掩蓋嚴重的邏輯錯誤。

廉價檢測“未定義”

[編輯 | 編輯原始碼]

Murray Langton(本華夏公益教科書的主要作者)在檢查一個 140,000 行安全關鍵的遺留 Fortran 程式中的“未定義”方面取得了一些成功。 基本思路是將所有全域性變數設定為可以識別的奇怪值,這些值在使用時極有可能產生明顯奇怪的結果。

對於 IBM 大型機,奇怪的值是

  • REAL 設定為 -9.87654E70
  • INTEGER 設定為 -123456789
  • CHAR 設定為 '?'

請注意,使用的特定值取決於您的系統,尤其是用於 REAL 的大數絕對依賴於硬體。 對於具有 IEEE 浮點算術的機器(大多數 PC),REAL 的最佳選擇是 NaN(非數字),可能的替代選擇是 -9.87654E37

選擇大的負數值的原因是,它們在列印或顯示為輸出時往往非常明顯,並且在用於算術運算時往往會導致數值錯誤(溢位)。 此外,在 Fortran 中,所有輸出都在固定寬度欄位中,任何無法容納在欄位中的輸出都將顯示為一個充滿星號的欄位,這很容易發現。

在上面引用的安全關鍵示例中,編寫了一個程式來識別所有全域性變數(透過分析 COMMON 塊),排除那些(在 BLOCK DATA 中)顯式初始化的變數,然後編寫一個 Fortran 例程來設定所有這些奇怪的值。 如果對 COMMON 塊進行了任何更改,只需重新執行此分析程式即可。

在執行過程中,設定無效值的例程使用的 CPU 時間不到總 CPU 時間的 0.1%。當這些無效值首次使用時,花費了幾個月的時間才追蹤並消除在輸出中出現的星號和問號氾濫,儘管該程式已經“執行”了 20 多年。


如何檢查“未定義”

[edit | edit source]

基本思路是確保在宣告時將所有變數標記為“未定義”。某些語言允許同時宣告和初始化,在這種情況下,變數被標記為“已定義”。每當將值賦給變數時,該標記將更改為“已定義”。每當使用變數時,都會檢查該標記,如果該標記為“未定義”,則會報告錯誤。

過去,一些幸運的實現者在硬體方面獲得了幫助,即每個記憶體字都附加了一個額外的位(Burroughs 5000+)。在現代位元組定址機器上,可以為每個變數附加一個額外的位元組來儲存標記。不幸的是,由於對齊要求,這將導致資料所需的記憶體量翻倍(許多系統要求 4 位元組項(如數字)的地址為 4 的倍數;即使允許不對齊,使用它也可能會使程式速度明顯降低)。

提供標記的最簡單方法是使用一些在實踐中不太可能出現的特定值。具體的值取決於所涉及變數的型別。

布林值

此類變數最有可能分配一個位元組的儲存空間,其中 0 代表假,1 代表真。255 或 128 之類的值是合適的標記。

字元

在用於二進位制輸入/輸出時,任何值都可能出現,因此無法進行檢查。因此,在這種情況下必須能夠關閉檢查。
用作字元時,存在許多可能的非列印字元。127 或 128 或 255 可能是合適的選擇。

整數

大多數計算機系統使用二進位制補碼錶示負數,這會產生非對稱範圍(對於 16 位,範圍為 -32768 到 +32767)。我們可以使用最大的負數作為“未定義”標記來恢復對稱性。

實數

如果您的硬體符合 IEEE 標準(大多數 PC 都符合),則可以使用 NaN(非數字)。


如何在編譯時檢查

[edit | edit source]

您可能認為所有這些檢查(未定義、下標錯誤、超出範圍等)會使程式速度明顯降低。情況並沒有您想象的那麼糟糕,因為實際上很多檢查可以在編譯時完成,如下所述。

首先,一些統計資料來向您展示可以做什麼

  • 僅將檢查新增到現有編譯器中會導致為一個 6000 行的程式生成 1800 個檢查。
  • 在編譯器中新增幾百行程式碼使其能夠在編譯時完成許多檢查,並將執行時檢查的數量減少到只有 70 個。然後該程式的執行速度比包含所有檢查的版本快 20% 以上。

我們已經提到,在宣告時被賦予初始值的變數永遠不需要檢查是否未定義。

接下來的幾個測試需要一些簡單的流程控制分析,例如,僅在一個分支中設定的變數在 **if** 語句之後再次變為未定義,除非您可以確定變數在所有可能的分支上都已定義。

  • 一旦變數被設定(透過賦值或從檔案讀取),它就被認為已定義,以後就不需要再測試了。
  • 一旦變數被測試為“未定義”,就可以假定它以後已定義。

如果您的程式語言允許您區分例程的輸入引數和輸出引數,則可以在呼叫之前檢查所有輸入引數是否已定義。在例程中,您可以假定所有輸入引數都已定義。

對於離散變數(如整數和列舉),您通常可以在編譯時跟蹤該變數在程式中的任何時刻可以具有的最大值和最小值。如果您的源語言允許變數被宣告為具有某些有限範圍(例如 Pascal),這將特別容易。當然,對這種有界變數的任何賦值都必須檢查以確保該值在指定的範圍內。

對於作為下標的有界變數的許多用途,它通常表明該變數的已知限制在該下標範圍內,因此不需要檢查。

在計數控制迴圈中,您通常可以透過在進入迴圈之前檢查迴圈邊界來檢查控制變數的範圍,這可能會減少迴圈內需要的下標檢查。

詞彙表

[edit | edit source]

詞彙表旨在提供與編譯相關的詞語或短語的定義。它並非旨在提供通用計算術語的定義,對此,參考維基百科可能更合適。

華夏公益教科書