Delphi 或 FPC 中返回記錄的函式,不會在每次呼叫時取得全新的清除 Result。隱藏的 Result 變數只在開始時自動清除一次,呼叫之間不會再次清除,因此進入函式時的清理責任在函式本身。若使用 FillChar(Result, SizeOf(Result), 0),從第二次呼叫開始便會直接覆寫仍然存活的字串或動態陣列參照,而不是先釋放它,使堆積記憶體失去所有者
這個問題常出現在批次處理程序開啟第三方 PDF、走訪每頁註解並將文字寫入稽核記錄的情境。每次呼叫看似只是返回普通記錄的普通函式,沒有指標或手動記憶體管理;但記錄中的參照計數是 Object Pascal 的基本規則,因此任何把 FillChar 與含字串或動態陣列的記錄型別混用的 Delphi 或 FPC 程式碼庫都會暴露在同一缺陷下
為什麼對記錄結果使用 FillChar 會洩漏字串
FillChar 不知道自己覆寫的是哪種資料。FillChar(X, Count, Value) 接收無型別的 Count 位元組並全部寫成 Value,不會檢查 X 的型別。記錄中的 UnicodeString 或 WideString 欄位是指向堆積區塊的指標,並非字元本身;並像覆寫 Integer 或 Double 欄位一樣寫成零;指標被清零後,本應先遞減的參照計數沒有被處理,所指向的區塊便失去所有參照
編譯器如何追蹤記錄中的字串和動態陣列
受管理型別需要編譯器在指派和離開作用域時額外維護。AnsiString、UnicodeString、WideString、動態陣列、介面、Variant,以及包含它們的記錄或固定陣列都屬於受管理型別。編譯器會在指派時增加參照計數,在覆寫或離開作用域時遞減並於歸零後釋放區塊,因此普通 Pascal 程式碼不必手動配置或釋放 string。System.Default 和 Finalize 是按需呼叫相同釋放邏輯的方式,記錄清理程式碼應使用它們,而不是原始記憶體填充
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
為什麼洩漏只會從第二次呼叫開始
迴圈第一次呼叫時,受管理記錄型別的區域變數仍為零,Description 或 ContentsText 仍是 nil,所以 FillChar 看起來沒有造成問題。第二次呼叫時,同一變數已儲存第一次呼叫的內容,FillChar 卻直接清除不再是 nil 的欄位,後續處理便會出錯。只呼叫一次函式的測試看不到問題,反覆呼叫同一目標的程式路徑才能暴露它
真實的洩漏:註解、書籤和連結記錄
PDFiumPas 在 1.56.4 之前存在這個缺陷:註解讀取器返回帶有 ContentsText 和 AuthorText 的 TPdfAnnotation,書籤讀取器返回帶有 Title 的 TBookmark,連結註解讀取器返回帶有 ActionPath 和 Points 的 TLinkAnnotation。三者都先以 FillChar 清除 Result,再填入頁面資料;將 FillChar(Result, SizeOf(Result), 0) 替換為 Result := Default(TPdfAnnotation),即可讓受管理記錄執行先釋放再清除的序列
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
var 參數背後的同一種風險
書籤讀取器展示了更隱蔽的同一問題:被 FillChar 清除的是向下傳遞一層的 var 參數,而不是函式自己的 Result。SetBookmarkData 接收 var Data: TBookmark,並曾在函式主體開頭用 FillChar 清除 Data;GetBookmark 將自己的 Result 傳入,並返回 TBookmark。var 參數按參照傳遞,因此 Data 與 Result 實際上指向同一塊 儲存空間。只檢查記錄返回型別的函式會漏掉這種形態,搜尋也必須追蹤所有接收 Result 的 var 和 out 參數
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
什麼時候 FillChar 仍然是正確選擇
完全由整數、浮點欄位、固定大小陣列或其他普通記錄構成的記錄,仍可使用 FillChar,因為沒有需要終結的內容。PDFiumPas 的 TPdfRectangle 只有四個 Double 欄位。若記錄在任意巢狀深度含有 string、AnsiString、WideString、動態陣列、介面或 Variant,就必須使用受管理清除方式;稽核時應逐層檢查記錄欄位
跨編譯器陷阱的配套文章說明 FPC 和 Delphi 對記錄結果暫存值何時終結存在的差異,這是同一事實的另一種表現:函式的記錄 Result 不一定是全新的私有儲存空間。本文的註解迴圈也正是你在建立註解檢閱面板時會撰寫的逐頁走訪
這是 Object Pascal 語言本身的特性,修正只需要呼叫一個函式。本文介紹的註解、書籤和連結註解 API,作為面向 Delphi、C++Builder 和 Lazarus/FPC 的 PDFium Component 一部分提供,並涵蓋 PDF 讀取、算繪和註解功能