Une fonction Delphi ou FPC qui renvoie un enregistrement ne reçoit pas un Result frais et mis à zéro à chaque appel. Cette variable Result cachée démarre à zéro exactement une fois, et rien ne la remet automatiquement à zéro entre les appels, si bien que la vider à l'entrée est le propre travail de la fonction. Faites ce vidage avec FillChar(Result, SizeOf(Result), 0) et, à partir du deuxième appel, la routine écrase une référence de chaîne ou de tableau dynamique vivante au lieu de la libérer, rendant orphelin quel que soit le bloc de tas vers lequel cette référence pointait
Le scénario où cela mord est banal. Un processus par lot ouvre une pile de PDF tiers et parcourt chaque annotation sur chaque page, extrayant le texte de commentaire dans un journal d'audit. Rien dans cette boucle ne paraît dangereux : chaque appel est une simple fonction renvoyant un simple enregistrement, aucun pointeur en vue, rien qui ressemble à une gestion manuelle de la mémoire. Le comptage de références à l'intérieur d'un enregistrement est une simple règle comptable d'Object Pascal, pas une bizarrerie propre à une bibliothèque particulière, et toute base de code Delphi ou FPC qui mélange FillChar avec des types d'enregistrement portant des chaînes ou des tableaux dynamiques est exposée au même défaut
Pourquoi FillChar sur un résultat d'enregistrement fuit-il des chaînes ?
FillChar fuit des chaînes parce qu'il n'a aucune idée du type de données qu'il écrase. FillChar(X, Count, Value) fonctionne sur n'importe quelle variable : il prend un bloc non typé de Count octets et estampille chacun d'eux avec Value, et c'est là tout le contrat. C'est exactement ce qui rend FillChar rapide et à usage général, car il n'inspecte jamais le type de X et ne branche jamais selon ce que signifient les octets sous-jacents. Un champ UnicodeString ou WideString à l'intérieur d'un enregistrement n'est pas les caractères eux-mêmes ; c'est un pointeur vers un bloc de tas qui porte un compteur de références en amont des données de caractères. FillChar voit une poignée d'octets qui se trouvent contenir une valeur de pointeur et les écrase avec zéro exactement comme il écraserait un champ Integer ou Double. Le pointeur disparaît, le compteur de références qu'il aurait dû d'abord décrémenter n'est jamais touché, et le bloc vers lequel il pointait reste alloué sans plus rien le référençant
Comment le compilateur suit les chaînes et les tableaux dynamiques à l'intérieur d'un enregistrement
Object Pascal appelle un type géré lorsque le compilateur doit exécuter du code supplémentaire pour le garder correct à travers l'assignation et la sortie de portée. Les types de chaîne longue tels qu'AnsiString, UnicodeString, et WideString se qualifient, ainsi que les tableaux dynamiques, les interfaces, et les Variant, ainsi que tout enregistrement ou tableau de taille fixe qui en contient un comme champ. Pour chaque champ géré, le compilateur émet silencieusement la comptabilité qui serait autrement fastidieuse et facile à mal faire à la main : incrémenter un compteur de références à l'assignation, le décrémenter quand la variable qui le détient est écrasée ou sort de portée, et libérer le bloc sous-jacent une fois ce compteur atteignant zéro. Cette mécanique est la raison pour laquelle du code Pascal ordinaire n'alloue ou ne libère jamais manuellement une string, et pourquoi assigner un tableau dynamique à un autre est une opération bon marché et sûre plutôt qu'une boucle de copie manuelle. System.Default et Finalize sont les deux moyens documentés d'invoquer cette même logique de libération à la demande, et ce sont eux que le code de vidage d'un enregistrement devrait appeler plutôt qu'un remplissage mémoire brut
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;
Pourquoi la fuite ne commence-t-elle qu'au deuxième appel ?
Le premier appel dans une boucle est toujours inoffensif, ce qui est précisément ce qui rend ce défaut facile à manquer lors des tests. Une variable locale d'un type d'enregistrement géré démarre à zéro, et rien ne la remet automatiquement à zéro entre un passage de boucle et le suivant, si bien que la première fois qu'une boucle assigne la valeur de retour d'une fonction dans cette variable, son champ Description ou ContentsText est encore nil. FillChar écrase nil avec zéro, ce qui ne change rien en ce qui concerne le compteur de références, et l'appel revient d'apparence entièrement correcte. Le deuxième appel est différent : la même variable locale détient déjà ce que le premier appel y a écrit, et le Result du nouvel appel est écrit directement dans ce même stockage plutôt que dans une mémoire fraîche et vide. FillChar en tête de ce deuxième appel met à zéro un champ qui n'est plus nil, et tout ce qui suit ce motif d'octets en aval est silencieusement faux à partir de là. Un test qui appelle la fonction une fois et inspecte le résultat ne verra jamais le problème ; seule une boucle, ou tout chemin de code qui appelle la fonction de façon répétée contre la même destination, l'expose
Une véritable fuite : annotations, signets, et enregistrements de lien
PDFiumPas a livré exactement ce défaut avant la version 1.56.4, dans trois fonctions qui renvoient chacune un enregistrement portant au moins un champ géré : le lecteur d'annotation au niveau page renvoie un TPdfAnnotation portant les chaînes ContentsText et AuthorText, le lecteur de signet renvoie un TBookmark portant une chaîne Title, et le lecteur d'annotation de lien renvoie un TLinkAnnotation portant une chaîne ActionPath et un tableau dynamique Points. Les trois s'ouvraient avec la même forme montrée ci-dessous : vider Result avec un FillChar brut, puis remplir les champs un par un à partir des données de page sous-jacentes. Parcourir chaque annotation d'une page une à la fois, la façon ordinaire de construire une liste d'audit ou un panneau de relecture, appelait le lecteur d'annotation dans une boucle et faisait fuir le texte de l'annotation précédente à chaque passage après le premier ; un PDF construit avec un nombre inhabituellement grand d'annotations portant du texte pouvait faire croître la mémoire d'un processus de longue durée aussi longtemps que ce processus continuait de tourner. La correction n'a touché qu'une ligne dans chaque fonction : remplacer FillChar(Result, SizeOf(Result), 0) par Result := Default(TPdfAnnotation) a suffi, car assigner Default à un enregistrement géré exécute la séquence ordinaire du compilateur libérer-puis-vider plutôt qu'un remplissage mémoire brut
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;
Le même danger derrière un paramètre var
Le lecteur de signet montre une version plus subtile du même problème, car l'enregistrement qui est vidé avec FillChar n'est pas le propre Result de la fonction mais un paramètre var un appel plus bas. SetBookmarkData prend sa sortie comme var Data: TBookmark et vidait auparavant Data en tête de son corps avec FillChar ; GetBookmark, la fonction publique qui renvoie effectivement un TBookmark, appelle SetBookmarkData et transmet directement son propre Result comme cet argument var. Un paramètre var est transmis par référence, si bien que Data à l'intérieur de SetBookmarkData et Result à l'intérieur de GetBookmark sont le même stockage sous deux noms, et tout risque d'aliasing qui s'applique au propre Result d'une fonction s'applique tout aussi directement à toute routine assistante qui le reçoit par référence. Ne revoir que les fonctions qui déclarent littéralement un type de retour d'enregistrement manque cette forme ; la recherche doit aussi suivre chaque paramètre var et out dans lequel un Result est transmis
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;
Quand FillChar reste-t-il le bon choix ?
FillChar reste correct, et souvent légèrement moins coûteux, pour un enregistrement construit entièrement à partir d'ordinaux, de champs à virgule flottante, de tableaux de taille fixe de ceux-ci, ou d'autres enregistrements simples faits des mêmes, car il n'y a rien à l'intérieur que le compilateur doive finaliser. Le propre type rectangle de PDFiumPas est exactement ce cas : TPdfRectangle détient quatre champs Double et rien d'autre, et en vider un avec FillChar ne libère rien car il n'y a rien à compteur de références à libérer. La vérification qui sépare les deux cas est simple à énoncer : un champ quelconque de l'enregistrement, à quelque profondeur d'imbrication que ce soit, a-t-il pour type string, AnsiString, WideString, un tableau dynamique, une interface, ou un Variant ? Un enregistrement peut avoir l'air parfaitement numérique au niveau supérieur et tout de même échouer à ce test si un de ses champs est lui-même un enregistrement qui enfouit une chaîne quelques couches plus bas, si bien que la vérification doit suivre les enregistrements imbriqués jusqu'au bout plutôt que de s'arrêter à la liste de champs la plus externe. Auditer une base de code existante pour ce schéma est mécanique plutôt qu'exhaustif : rechercher chaque appel FillChar dont la cible est une variable d'enregistrement, puis vérifier la liste de champs de cet enregistrement contre la liste des types gérés ci-dessus. Le propre audit v1.56.4 de PDFiumPas a exécuté exactement cette recherche à travers toute la bibliothèque et a trouvé cette exposition dans une seule unité ; chaque autre point d'appel FillChar vidait déjà un enregistrement numérique simple, où FillChar était, et reste, le bon outil
Le même comportement de compilateur qui rend un Result réutilisé dangereux ici pilote aussi une famille apparentée de désaccords Delphi-contre-FPC ailleurs dans cette base de code ; un article compagnon sur les pièges de compilation croisée couvre un cas où FPC et Delphi sont en désaccord sur exactement quand un temporaire de résultat d'enregistrement est finalisé à l'intérieur d'une seule expression, un symptôme différent du même fait sous-jacent qu'un Result d'enregistrement de fonction n'est pas toujours le stockage frais et privé qu'il paraît être. La boucle d'annotation utilisée comme exemple filé tout au long de cet article n'est pas hypothétique non plus : c'est le même parcours page par page que vous écririez en construisant un panneau de relecture d'annotations, ce qui est exactement la forme de code qui a transformé un FillChar d'une ligne en une fuite mémoire lente en premier lieu
Rien de tout cela ne nécessite de changer de bibliothèque ni de traquer un bogue dans le code compilé de quelqu'un d'autre : c'est une propriété du langage Object Pascal lui-même, une avec laquelle chaque développeur Delphi et FPC travaille quotidiennement, et la correction est un seul appel de fonction une fois qu'on sait la chercher. Les API d'annotation, de signet, et d'annotation de lien décrites ici font partie du composant PDFium pour Delphi, C++Builder, et Lazarus/FPC, aux côtés du reste de la surface de lecture, de rendu, et d'annotation PDF couverte ailleurs sur ce blog