HotXLS 從 XE5 開始,向每個 Delphi 和 C++Builder 版本交付同一套 Object Pascal 程式碼庫,證明這一點的腳本是 build-All-Lib-TRIAL.cmd:共 43 個建置分支,涵蓋 Win32 和 Win64 上的 12 個 Delphi 版本,以及 10 個 C++Builder Win32 和 9 個 Win64 套件建置。從 v2.363 到 v2.374,這個腳本從未完整執行過,而 XE5 分支一直處於損壞狀態
問題一旦被看見就不再隱蔽。目前編譯器可以毫無提示接受的五種不同結構,在 RAD Studio XE5 上都是硬錯誤,建置矩陣將其標記為 12.0。v2.375.0 修復了全部五項,矩陣重新以 43/43 變綠。下面會逐一說明每個拒絕,解釋舊編譯器在其中兩個型別問題上的判斷為何有道理,以及更尷尬的部分:用於診斷混亂的探針腳本第一次執行時回報了一個假陽性
XE5 分支為什麼會在無人察覺中腐爛
XE5 分支會腐爛,是因為日常開發只執行 37.0 的四腳本集合,而本機建置是綠色,並不能說明你沒有呼叫的編譯器。完整矩陣是一個獨立的慢腳本,試用版安裝程式會在 Inno Setup 收集檔案前呼叫它,因此它是在封裝時而不是提交時執行。十二個版本就在這段空檔中被遺漏了
把分支算式寫出來很有必要,因為涵蓋率錯覺就藏在這裡。DELPHI_TRIAL_VERSIONS 列舉 12.0 到 37.0,這 12 個版本各自建置 Win32 和 Win64 兩次。CB_TRIAL_WIN32_VERSIONS 列出 10 個版本,CB_TRIAL_WIN64_VERSIONS 只有 9 個,因為 XE5 有 C++Builder 套件專案,卻沒有 Win64 套件啟動物件 c0pkg64.o。12 加 12 加 10 加 9 等於 43。只執行其中四個,然後稱程式碼庫可攜,是分類錯誤,也是讓問題發生的具體錯誤
HotXLS 還從相反方向遇到過相同形態的問題。新單元如果透過 uses 子句可達,卻不在 .cbproj 檔案清單中,那麼在 Delphi 下仍能完美編譯,因為 dcc 會隱式將未列出的單元拉入套件,最多發出 W1033 提示。C++Builder 只會為單元產生 .obj,而單元名稱必須出現在 <DelphiCompile> 中,因此相同程式碼會在 ilink 階段以未解析外部符號失敗。一套工具鏈會隱藏另一套工具鏈會捕獲的問題。這正是應該執行矩陣,而不是信任代表性編譯器的全部理由
舊 Win32 編譯器拒絕的硬式型別轉換
五個拒絕中有兩個是同一個 bug 換了衣服:把硬式型別轉換套用於浮點表達式,而不是變數。在 Win32 上,舊編譯器透過 x87 堆疊計算算術,因此涉及 Double 的加法會以 80 位元額外精度執行,靜態型別變成 10 位元組的 Extended。把 10 位元組縮窄為 8 位元組 TDateTime 不是合法型別轉換,編譯器會以 E2089 Invalid typecast 報錯
令人抓狂的細節是變數形式沒有問題。TDateTime(Serial) 在矩陣中的每個版本都能編譯,因為 Serial 已經是 8 位元組,轉換保持大小不變。只要對它做任何加法,表達式就在底層擴大。修復不是採用更寬的轉換,也不是加入條件定義,而是停止轉換:隱式的實數到實數賦值會在 HotXLS 支援的所有編譯器上正確轉換,而且它表達的正是程式碼的含義
// XE5(Win32)拒絕:每次加法都按 10 位元組的 Extended 計算,
// 10 到 8 位元組的縮窄轉換會觸發 E2089
if Dates1904 then
Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
Value := TDateTime(Serial + 1)
else
Value := TDateTime(Serial); // 這一處可接受:沒有加法
// 版本安全寫法:讓實數到實數的賦值完成轉換
if Dates1904 then
Value := Serial + XLSDate1904Offset
else if Serial < 60 then
Value := Serial + 1
else
Value := Serial;
// 儲存格值打包器中還有同類拒絕:對整數做硬式 Double 轉換
// 直接除法即可,運算子已經會產生實數
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then // E2089
;
if (Scaled = intVal) and (intVal / 100 = AValue) then // 可攜
;
Serial < 60 分支處理的是 1900 閏年的虛構日期,而不是 off-by-one:序號 60 是 Excel 不存在的 1900-02-29,因此低於它的序號在交給 DecodeDate 前需要補一天。可攜性工作絕不能悄悄改變這類邏輯,這正是這裡移除轉換卻保留算術不變的原因
nil 作為程序參數時會發生什麼
當預期的是程序型別時,向舊編譯器傳入裸 nil 無法在多載解析階段繫結。HotXLS 中的呼叫點是 ResolveIndexedColor,它有多載,並接受大多數呼叫方不需要的 TXLSTryResolveSystemColor 回呼。新編譯器可以將 nil 解析到程序參數並選擇正確多載,XE5 卻不能;診斷指向多載集合而不是參數,因此很容易浪費二十分鐘
可攜的答案是給空回呼一個型別。程序型別的單元層級變數會由語言初始化為零,因此即使沒有初始化器它也是 nil,同時還攜帶舊解析器所需的型別資訊。如果單元層級變數顯得過度,使用明確賦值為 nil 的型別化區域變數也能完成同樣工作
var
// 舊編譯器的多載解析無法繫結裸 nil 程序常值
// 型別化且零初始化的變數可以完成繫結
NilSystemColorResolver: TXLSTryResolveSystemColor;
// ...
FWorkbook.ResolveIndexedColor(AIndexedColor, xicsBiffIcv, ARole,
NilSystemColorResolver, Resolution);
// XLSX 活頁簿中使用型別化區域變數的同一修復
function TXLSXWorkbook.ResolveIndexedColor(AIndex: Int64;
ASpace: TXLSIndexedColorSpace;
out AResolution: TXLSIndexedColorResolution): Boolean;
var
NoResolver: TXLSTryResolveSystemColor;
begin
NoResolver := nil;
Result := ResolveIndexedColor(AIndex, ASpace, xicrGeneral, NoResolver,
AResolution);
end;
要注意,這是真正的語言層級差異,不是值得用定義繞過的編譯器 bug。零初始化變數在矩陣的每個版本上都正確,只增加一行,因此這裡完全不需要條件編譯。只有平台在版本之間確實不同的地方才使用 {$IF CompilerVersion},而本批次恰好只有一處屬於這種情況
受保護的 VCL 方法會隨版本移動
TPicture.LoadFromStream 在目前 VCL 中是 public,在 HotXLS 支援的舊版本中卻是 protected,因此直接呼叫現在能編譯,換到舊版本就會失敗。HotXLS 使用它驗證工作表背景影像載荷確實能夠解碼,這是 HTML 匯出器提交嵌入位元組前執行的一項完整性檢查。經典 Pascal 答案適用於這裡:在同一單元中宣告一個後代類別,純粹為了擴大可見性,然後在呼叫點透過它進行轉型
type
// TPicture.LoadFromStream 在程式庫支援的較舊 VCL 版本中是 protected;
// 同單元後代類別將其公開
TXlsxPictureAccess = class(TPicture);
// ...
Stream.WriteBuffer(AData[1], Length(AData));
Stream.Position := 0;
TXlsxPictureAccess(Picture).LoadFromStream(Stream);
Result := (Picture.Graphic <> nil) and not Picture.Graphic.Empty and
(Picture.Graphic.Width > 0) and (Picture.Graphic.Height > 0);
這個存取器類別技巧在這裡是安全的,因為後代類別不增加欄位,也從未被實例化;轉型只改變編譯器允許你命名的內容。不過宣告處仍然值得保留註解,否則只在目前 IDE 上建置的讀者會把這個型別看成無意義的宣告。背景影像處理還會出現在自訂 VCL 網格渲染路徑中,同一個已解碼載荷會被送到螢幕工作表
GdiplusStartup 的權杖型別改變了兩次
本批次中唯一真正需要條件編譯的拒絕,是 GdiplusStartup 的 var 參數型別,它在不同 VCL 世代之間發生改變,沒有一種寫法能在所有地方有效。逐版本探測鎖定了實際行為:12.0 到 20.0 分支只接受 Cardinal,21.0 和 22.0 分支只接受 THandle 或 ULONG_PTR,23.0 和 37.0 則兩者都接受。換成發行版名稱,就是 XE5 到 10.3 Rio 使用 Cardinal,10.4 Sydney 起使用 THandle。由於 12.0 到 22.0 的兩個接受範圍沒有交集,所以不存在無條件宣告;保護條件以 CompilerVersion >= 34 為界,也就是 Sydney,並將呼叫完全限定為 Winapi.GDIPAPI.GdiplusStartup,避免單元解析順序在範圍中間的某個版本替換出不同宣告
function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
StartupInput: TGdiplusStartupInput;
// GDIPAPI 的 GdiplusStartup var 參數型別跟隨 VCL 世代
// Rio 之前是 Cardinal,Sydney 起是 THandle
{$IF CompilerVersion >= 34}
StartupToken: THandle;
{$ELSE}
StartupToken: Cardinal;
{$IFEND}
TiffEncoder: TGUID;
begin
FillChar(StartupInput, SizeOf(StartupInput), 0);
StartupInput.GdiplusVersion := 1;
CheckStatus(Winapi.GDIPAPI.GdiplusStartup(StartupToken, @StartupInput,
nil), 'startup');
if GetEncoderClsid('image/tiff', TiffEncoder) < 0 then
raise EInvalidGraphic.Create('GDI+ TIFF encoder is unavailable');
// ... 編碼 ...
end;
這是頁面影像匯出器的 TIFF 分支,因此如果這裡出錯,影響範圍就是整個點陣匯出表面,包括將儲存格範圍匯出為單張影像所描述的路徑。還要注意保護條件沒有宣稱什麼:ULONG_PTR 和 THandle 在兩個平台上都是相同寬度,因此選擇的是宣告使用哪個識別字,而不是 32 位元與 64 位元正確性之間的差別
第一次探針執行為什麼什麼也沒回報
版本探針第一次什麼也沒回報,是因為 res=$(...) 賦值發生在子 shell 中,無法傳播回父 shell。dcc32 成功時結束碼為 0,因此應該捕獲結束碼作為訊號;腳本卻把它捕獲到一個只存在一行的變數中。每個分支都回傳空結果,輸出看起來像是探針根本沒有編譯任何內容,而事實正是如此
第二個失敗更糟,因為它產生了錯誤答案而不是沒有答案。探針透過統計匹配 Error 的行來分類分支,而 Delphi 不會在每個致命錯誤前加上這個單字。F1026 File not found 是致命錯誤,卻不會匹配,因此完全無法解析某個單元的探針被打成了乾淨通過。XE5 不提供 Winapi.GDIPOPS.dcu,第一次探測正好遇到它,卻錯誤地變成綠色。由此得到的規則很窄,但值得直說:應根據產生的產物或編譯器自己的摘要行判斷編譯器探針,絕不要透過 grep 輸出中的關鍵字來判斷。對 stderr 中的 Error 做 grep 是一種在最不該失敗的方向上失敗的啟發式,會靜默報告成功
支援十年編譯器實際要付出什麼
誠實的帳目是:這裡的程式碼改動很瑣碎,流程改動卻不瑣碎。五個拒絕中有四個是透過寫更普通的 Pascal 修復的,而不是新增版本機制:移除轉換、用除法取代轉換、給 nil 一個型別、宣告存取器類別。只有 GdiplusStartup 配得上一個 {$IF}。只要不讓硬式轉換和最新編譯器習慣一開始就累積起來,涵蓋 XE5 到目前版本的程式碼庫不會變成條件定義叢林
真正的成本是建置時間和紀律。43 個分支是一個很慢的腳本,這正是它逐漸漂移到封裝時間,最後被完全遺忘的原因。可辯護的中間方案是保留快速的四腳本迴圈用於反覆運算,同時按一個無法跳過的計畫執行完整矩陣,因為故障模式不是你會注意到的建置損壞,而是一個已經十二個版本沒有被支援、卻仍然被宣稱支援的 IDE
這項義務也是交付原生元件的另一面。HotXLS 只透過 Object Pascal 讀寫 XLS、XLSX 和 ODS,不需要安裝 Excel,也不依賴 COM,這正是無 Office 活頁簿自動化可以在鎖定伺服器上實現的原因。同一特性也意味著編譯器就是完整的平台契約,因此矩陣中的每個版本都是必須重新驗證的承諾,不能靠假設
這裡討論的跨編譯器建置矩陣和版本安全程式碼屬於 HotXLS Delphi Spreadsheet Component,支援從 XE5 到目前版本的 Delphi 和 C++Builder,並為每個受支援的 IDE 提供預先建置的程式庫二進位檔