Una funzione Delphi o FPC che restituisce un record non ottiene un Result fresco e azzerato a ogni chiamata. Quella variabile Result nascosta parte a zero esattamente una volta, e nulla la riazzera automaticamente tra una chiamata e l'altra, quindi svuotarla all'ingresso è compito proprio della funzione. Fai quella pulizia con FillChar(Result, SizeOf(Result), 0) e, dalla seconda chiamata in poi, la routine sovrascrive un riferimento vivo a stringa o array dinamico invece di rilasciarlo, rendendo orfano qualunque blocco heap a cui quel riferimento puntasse
Lo scenario in cui questo morde è banale. Un processo batch apre una pila di PDF di terze parti e percorre ogni annotazione su ogni pagina, estraendo il testo del commento in un log di audit. Nulla in quel ciclo sembra pericoloso: ogni chiamata è una semplice funzione che restituisce un semplice record, nessun puntatore in vista, nulla che assomigli a gestione manuale della memoria. Il conteggio dei riferimenti dentro un record è una semplice regola di contabilità di Object Pascal, non una stranezza specifica di una singola libreria, e qualsiasi codebase Delphi o FPC che mescola FillChar con tipi record che contengono stringhe o array dinamici è esposta allo stesso difetto
Perché FillChar su un Risultato Record Perde Stringhe?
FillChar perde stringhe perché non ha idea di che tipo di dato stia sovrascrivendo. FillChar(X, Count, Value) funziona su qualsiasi variabile: prende un blocco non tipizzato di Count byte e stampiglia ognuno di essi con Value, ed è questo l'intero contratto. Questo è esattamente ciò che rende FillChar veloce e general-purpose, perché non ispeziona mai il tipo di X e non si dirama mai in base a cosa significhino i byte sottostanti. Un campo UnicodeString o WideString dentro un record non è il carattere stesso; è un puntatore a un blocco heap che porta un conteggio di riferimenti prima dei dati carattere. FillChar vede una manciata di byte che capitano di contenere un valore puntatore e li sovrascrive con zero esattamente come farebbe con un campo Integer o Double. Il puntatore scompare, il conteggio di riferimenti che avrebbe dovuto decrementare prima non viene mai toccato, e il blocco a cui puntava resta allocato senza più nulla che vi faccia riferimento
Come il Compilatore Traccia Stringhe e Array Dinamici Dentro un Record
Object Pascal chiama gestito un tipo quando il compilatore deve eseguire codice extra per mantenerlo corretto attraverso assegnazione e uscita dall'ambito. I tipi stringa lunga come AnsiString, UnicodeString, e WideString qualificano, e così anche gli array dinamici, le interfacce, e i Variant, insieme a qualsiasi record o array a dimensione fissa che ne contenga uno come campo. Per ogni campo gestito, il compilatore emette silenziosamente la contabilità che altrimenti sarebbe tediosa e facile da sbagliare a mano: incrementa un conteggio di riferimenti all'assegnazione, lo decrementa quando la variabile contenitrice viene sovrascritta o esce dall'ambito, e libera il blocco sottostante una volta che quel conteggio raggiunge zero. Quel meccanismo è il motivo per cui il normale codice Pascal non alloca né rilascia mai manualmente una string, e perché assegnare un array dinamico a un altro è un'operazione economica e sicura invece di un ciclo di copia manuale. System.Default e Finalize sono i due modi documentati per invocare a richiesta quella stessa logica di rilascio, e sono ciò che il codice di pulizia di un record dovrebbe chiamare invece di un riempimento di memoria grezzo
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;
Perché la Perdita Inizia Solo alla Seconda Chiamata?
La prima chiamata in un ciclo è sempre innocua, il che è esattamente ciò che rende questo difetto facile da perdere nei test. Una variabile locale di un tipo record gestito parte a zero, e nulla la riazzera automaticamente tra un passaggio di ciclo e il successivo, quindi la prima volta che un ciclo assegna il valore di ritorno di una funzione a quella variabile, il suo campo Description o ContentsText è ancora nil. FillChar sovrascrive nil con zero, il che non cambia nulla per quanto riguarda il conteggio dei riferimenti, e la chiamata ritorna sembrando interamente corretta. La seconda chiamata è diversa: la stessa variabile locale contiene già qualunque cosa la prima chiamata vi abbia scritto, e il Result della nuova chiamata viene scritto direttamente in quella stessa memoria invece che in memoria fresca e vuota. FillChar in cima a quella seconda chiamata azzera un campo che non è più nil, e tutto ciò che segue da quello schema di byte è silenziosamente sbagliato da quel momento in poi. Un test che chiama la funzione una volta sola e ispeziona il risultato non vedrà mai il problema; solo un ciclo, o qualsiasi percorso di codice che chiama la funzione ripetutamente contro la stessa destinazione, lo espone
Una Perdita Reale: Annotazioni, Segnalibri e Record di Link
PDFiumPas distribuiva esattamente questo difetto prima della versione 1.56.4, in tre funzioni che restituiscono ciascuna un record che contiene almeno un campo gestito: il lettore di annotazioni a livello di pagina restituisce un TPdfAnnotation che porta le stringhe ContentsText e AuthorText, il lettore di segnalibri restituisce un TBookmark che porta una stringa Title, e il lettore di annotazioni link restituisce un TLinkAnnotation che porta una stringa ActionPath e un array dinamico Points. Tutte e tre iniziavano con la stessa forma mostrata sotto: pulire Result con un FillChar grezzo, poi riempire i campi uno alla volta dai dati di pagina sottostanti. Percorrere ogni annotazione su una pagina una alla volta, il modo normale di costruire un elenco di audit o un pannello di revisione, chiamava il lettore di annotazioni in un ciclo e perdeva il testo dell'annotazione precedente a ogni passaggio dopo il primo; un PDF creato con un numero insolitamente grande di annotazioni con testo poteva far crescere la memoria di un processo a lunga esecuzione per tutto il tempo in cui quel processo continuava a girare. La correzione ha toccato una riga in ciascuna funzione: sostituire FillChar(Result, SizeOf(Result), 0) con Result := Default(TPdfAnnotation) è bastato, perché assegnare Default a un record gestito esegue la normale sequenza rilascia-poi-azzera del compilatore invece di un riempimento di memoria grezzo
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;
Lo Stesso Rischio Dietro un Parametro var
Il lettore di segnalibri mostra una versione più sottile dello stesso problema, perché il record che viene pulito con FillChar non è il Result proprio della funzione ma un parametro var una chiamata più in basso. SetBookmarkData accetta il proprio output come var Data: TBookmark e in passato puliva Data in cima al proprio corpo con FillChar; GetBookmark, la funzione pubblica che effettivamente restituisce un TBookmark, chiama SetBookmarkData e passa il proprio Result direttamente come quell'argomento var. Un parametro var viene passato per riferimento, quindi Data dentro SetBookmarkData e Result dentro GetBookmark sono la stessa memoria sotto due nomi, e qualunque rischio di aliasing si applichi al Result proprio di una funzione si applica altrettanto direttamente a qualsiasi routine helper che lo riceva per riferimento. Revisionare solo le funzioni che dichiarano letteralmente un tipo di ritorno record perde questa forma; la ricerca deve seguire anche ogni parametro var e out in cui un Result venga inoltrato
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;
Quando FillChar È Ancora la Chiamata Giusta?
FillChar resta corretto, e spesso leggermente più economico, per un record costruito interamente da ordinali, campi in virgola mobile, array a dimensione fissa di quei tipi, o altri semplici record fatti degli stessi, perché non c'è nulla al suo interno che il compilatore debba finalizzare. Il tipo rettangolo proprio di PDFiumPas è esattamente questo caso: TPdfRectangle porta quattro campi Double e nient'altro, e pulirne uno con FillChar non rilascia nulla perché non c'è nulla con conteggio di riferimenti da rilasciare. Il controllo che separa i due casi è semplice da enunciare: qualche campo del record, a qualsiasi profondità di annidamento, ha tipo string, AnsiString, WideString, un array dinamico, un'interfaccia, o un Variant? Un record può sembrare perfettamente numerico al livello superiore e comunque fallire quel test se uno dei suoi campi è esso stesso un record che seppellisce una stringa qualche livello più in basso, quindi il controllo deve seguire i record annidati fino in fondo invece di fermarsi all'elenco di campi più esterno. Verificare una codebase esistente per questo schema è meccanico piuttosto che esaustivo: cerca ogni chiamata FillChar il cui target sia una variabile record, poi controlla l'elenco di campi di quel record contro l'elenco di tipi gestiti sopra. L'audit v1.56.4 proprio di PDFiumPas ha eseguito esattamente quella ricerca sull'intera libreria e ha trovato questa esposizione in un'unica unit; ogni altro punto di chiamata FillChar stava già pulendo un semplice record numerico, dove FillChar era, e resta, lo strumento giusto
Lo stesso comportamento del compilatore che rende pericoloso qui un Result riutilizzato guida anche una famiglia correlata di disaccordi tra Delphi e FPC altrove in questa codebase; un articolo di approfondimento sulle insidie cross-compiler tratta un caso in cui FPC e Delphi non concordano su esattamente quando un temporaneo di risultato-record venga finalizzato dentro una singola espressione, un sintomo diverso dello stesso fatto sottostante che il Result record di una funzione non è sempre la memoria fresca e privata che sembra essere. Il ciclo di annotazioni usato come esempio ricorrente in tutto questo articolo non è ipotetico, nemmeno: è lo stesso percorso pagina-per-pagina che scriveresti mentre costruisci un pannello di revisione annotazioni, che è esattamente la forma di codice che ha trasformato un FillChar di una riga in una lenta perdita di memoria in primo luogo
Nulla di tutto ciò richiede di cambiare libreria o inseguire un bug nel codice compilato di qualcun altro: è una proprietà del linguaggio Object Pascal stesso, una con cui ogni sviluppatore Delphi e FPC lavora quotidianamente, e la correzione è una singola chiamata di funzione una volta che sai cosa cercare. Le API di annotazioni, segnalibri e annotazioni link descritte qui fanno parte del componente PDFium per Delphi, C++Builder e Lazarus/FPC, insieme al resto della superficie di lettura, rendering e annotazione PDF trattata altrove su questo blog