當 Delphi 程式庫長出一個沒有視覺框架的建置設定時,錯誤就藏在替代類別裡。不是平台,不是編譯器,而是那些替身。PDFlibPas 有一個圖形層,為沒有 VCL 的建置提供點陣圖、畫布、字型、中繼檔與印表機的對應物,而移植到 Free Pascal 的過程,把一個替身可能有的每種失敗模式都翻了出來。它們按診斷成本整齊排序,而且排序與直覺相反
會拋例外的替身很便宜就能找到;例外名稱會指出方法。回傳空資料的替身很昂貴,因為失敗出現在離成因好幾層的地方。回報成功的替身是最壞的,因為回傳碼有效、錯誤碼為零、沒有例外拋出,唯一能證明出錯的證據在產出的位元組裡
形狀三:空 XObject 之上的有效影像識別碼
向量中繼檔轉換器在非 VCL 設定下是一個空的程序主體。它之上的每個東西都繼續運作。EMF 匯入進入點與畫布擷取進入點跑完整個流程並回傳一個合法的影像識別碼,呼叫端接著把它放到頁面上。落進檔案裡的是一個內容長度為零的 form XObject。頁面渲染出來是白的
沒有任何東西回報問題,包括程式庫自己針對這個功能的示範程式,它畫了一頁空白而且沒有察覺。沒有失敗的回傳值可檢查,因為那串呼叫確實全部成功;唯一不對的是產出串流的大小。診斷這類缺陷意味著問一個不同的問題:不是「呼叫失敗了嗎」,而是「產物合理嗎」。長度為零的 form XObject、零像素的影像、零內容位元組的頁面,這些就是能抓到它的斷言
修復有兩半,而後半容易忘記。第一,讓空實作拋例外,讓失敗至少有一個通道。第二,在影像工廠把那個例外轉換成 null 結果,並在兩個消費影像識別碼的地方加上 null 檢查,否則「乾淨的失敗」會直接變成存取違規,因為頁面樹對空值解參照。一個會拋例外的 stub,只有在呼叫端為它們以前從未能收到的失敗做好準備時,才算是一種改進
形狀二:空資料,離當機三層遠
中繼檔畫布替身沒有填入它的實體尺寸。那個值會除進頁面幾何計算,所以計算產出零,所以包圍盒計算除以零。一個裸例外處理器吞掉了它,影像工廠回傳 null 結果,存取違規最終在頁面樹使用 null 時發生。成因與症狀之間隔著三層,中間還有一個例外處理器在抹除證據
同一個單元裡還有兩個同模式的實例。字型類別有空的 Assign 與建構子主體,這比看起來更要緊,因為畫布的 font 屬性是唯讀的:對它指派是交付字型的唯一方式,所以空實作讓字型選擇無聲失效,文字以預設值輸出。而每英吋像素為零,讓每個依字型度量決定畫布大小的呼叫端產出零乘零的畫布,結果是空白頁與成功回傳
// 替身單元裡要找的形狀:既不拋例外也不做任何事的方法。
// 以下兩者都能編譯,而且都產出無輸出的「成功」
procedure TMetafileCanvasStandIn.Create(...);
begin
// 沒有 inherited 呼叫,沒有欄位初始化
end;
function TBitmapStandIn.LoadFromStream(Stream: TStream): Boolean;
begin
Result := True; // 而點陣圖仍然是空的
end;
只留下第一個字元的寬字元結構
這一個根本不是替身問題,但它屬於同一本目錄,因為症狀離成因同樣遙遠。印表機列舉結構被宣告成全部十二個字串成員都是指向單位元組字元的指標,而填它的函式是列舉 API 的寬字元變體
指標大小相同,所以結構配置正確,什麼也不會當機。實際發生的是:把 UTF-16 字串當單位元組字串讀,會停在第一個零位元組,對任何 ASCII 印表機名稱來說,那就是第二個字元的高位元組半部。每個印表機名稱都剛好回傳一個字元。下游,名稱驗證失敗、印表機建立失敗、機器上每台真實印表機的列印都失敗,而這些症狀沒有一個指向結構宣告
// 錯誤:大小正確,元素型別錯誤。沒有編譯錯誤,沒有當機,
// 每個字串都被截斷成一個字元
type
TPrinterInfo2Wrong = record
pServerName: PAnsiChar;
pPrinterName: PAnsiChar;
// ... 還有十個
end;
// 正確:*W 結構的成員全部都是寬字元版
type
TPrinterInfo2W = record
pServerName: PWideChar;
pPrinterName: PWideChar;
// ... 還有十個
end;
由此得出的規則是機械性的,值得不假思索地套用:對任何名稱以 W 結尾的 Win32 結構,逐欄位驗證每個字串成員都是寬字元變體。把 ANSI 與寬字元兩個世界混在一起,既不會有編譯診斷也不會當機,只有無聲的截斷,同樣的規則反向適用於 ANSI 變體
裸例外處理器才是真正的對手
這些調查的每一起都被同一個構造拖慢:一個捕捉一切並把例外轉換成 false 回傳值的處理器。圍繞影像解碼器這樣寫是合理的,因為一張損壞的影像不應該拖垮整個文件工作。但它同時也是一個用來刪除您需要的唯一一條資訊的裝置
實務上的回應是讓處理器暫時變得吵鬧。在裸處理器內部、以除錯條件編譯傾印例外類別、訊息與堆疊回溯,能把無法解釋的 null 回傳變成帶有名稱與位置的例外。上述三個案例中有兩個,就是這一步終結了調查,因為例外是除以零,或是替身方法裡的存取違規,而那個方法的名字說明了一切
採用替身路徑的檢查清單
四個項目,按回報順序排列。在呼叫替代類別之前,先讀您即將使用的方法並確認每個都有真實主體;空主體不是實作細節,是缺失的功能。偏好會拋例外的替身,而不是回傳中性值的替身,並搭配在工廠現在可能合理回傳空值的地方加上 null 檢查。透過檢視產物而不是回傳碼來驗證功能,因為這整個失敗模式就是對空產物回報乾淨的回傳碼;文件實際內容的位元組級拆解是看清它的最快方式,檔案體積稽核文章涵蓋了那套工具。而當某個功能沒有可行的替代實作時,把受影響的樣本導向真正可用的路徑並在註解裡說明原因,而不是留下一個悄悄產出空白輸出的示範
更廣的論點適用於遠超出一個程式庫的範圍。任何帶有條件式第二實作、mock 層、無頭模式或平台墊層的程式碼庫,都暴露於形狀三。它藏得這麼好的原因是:團隊平時依賴的每一道品質關卡——回傳碼、錯誤碼、例外、結束狀態——都是狀態通道,而形狀三讓它們全部保持乾淨。只有輸出會背叛它。這也是在處理不受信任輸入時檢查產物而非狀態的理由,見不受信任 PDF 解析文章;也是跨引擎比較渲染輸出而非信任單一引擎的理由,見多引擎渲染
PDFlibPas 是一款支援 Delphi、C++Builder 與 Free Pascal 的原生 Object Pascal PDF 程式庫,它的非 VCL 設定正是無頭與跨工具鏈建置得以可能的原因;目前的設定涵蓋範圍列於 losLab PDF Developer Library 產品頁