Функция на Delphi или FPC, която връща запис, не получава нов, занулен Result при всяко извикване. Тази скрита променлива за резултата започва като нула точно веднъж и между извикванията нищо не я занулява автоматично, затова самата функция трябва да се погрижи за изчистването ѝ. Ако го направите с FillChar(Result, SizeOf(Result), 0), от второто извикване нататък процедурата презаписва активна препратка към низ или динамичен масив, вместо да я освободи, и оставя без собственик блока в динамичната памет, към който е сочила препратката
Сценарият, в който това създава проблем, е напълно обичаен. Пакетен процес отваря поредица PDF файлове от външни източници и обхожда всяка анотация на всяка страница, като записва текста на коментарите в одитен журнал. В този цикъл няма нищо очевидно опасно: всяко извикване е обикновена функция, която връща обикновен запис, няма видими указатели и нищо не прилича на ръчно управление на паметта. Броенето на препратки в записа е стандартно правило на Object Pascal, а не особеност на конкретна библиотека, така че същият дефект засяга всеки код на Delphi или FPC, който комбинира FillChar с типове записи, съдържащи низове или динамични масиви
Защо FillChar върху резултат от запис изпуска низове?
FillChar изпуска низове, защото не знае какъв тип данни презаписва. FillChar(X, Count, Value) работи с всяка променлива: приема нетипизиран блок от Count байта и записва във всеки от тях стойността Value — това е целият му договор. Именно това прави FillChar бърз и универсален, защото той никога не проверява типа на X и не избира поведение според значението на подлежащите байтове. Поле от тип UnicodeString или WideString в запис не съдържа самите символи, а указател към блок в динамичната памет, пред който има брояч на препратките. FillChar вижда няколко байта, които случайно съдържат стойност на указател, и ги заменя с нули точно както би заменил поле от тип Integer или Double. Указателят изчезва, броячът на препратките, който е трябвало първо да бъде намален, не се променя и блокът, към който е сочил, остава заделен без нито една останала препратка към него
Как компилаторът следи низовете и динамичните масиви в запис?
Object Pascal нарича даден тип управляван, когато компилаторът трябва да изпълнява допълнителен код, за да го поддържа коректен при присвояване и излизане от обхват. Дългите низови типове като 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 заменя nil с нула, което не променя нищо от гледна точка на брояча на препратките, и извикването приключва с напълно коректен вид. Второто извикване е различно: същата локална променлива вече съдържа записаното при първото извикване, а резултатът на новото извикване се записва директно в същото място, а не в нова празна памет. FillChar в началото на второто извикване занулява поле, което вече не е nil, и от този момент нататък всичко, което зависи от този байтов шаблон, е мълчаливо повредено. Тест, който извиква функцията веднъж и проверява резултата, никога няма да види проблема; той се проявява само в цикъл или във всеки път на изпълнение, който многократно извиква функцията към една и съща целева променлива
Реално изтичане: анотации, отметки и записи за връзки
PDFiumPas съдържаше точно този дефект преди версия 1.56.4 в три функции, всяка от които връща запис с поне едно управлявано поле: четецът на анотации на ниво страница връща TPdfAnnotation с низове ContentsText и AuthorText, четецът на отметки връща TBookmark с низово поле Title, а четецът на анотации за връзки връща TLinkAnnotation с низово поле ActionPath и динамичен масив Points. И трите започваха по един и същ начин: изчистване на Result с необработен FillChar, след което полетата се попълваха едно по едно от данните на страницата. При обхождане на всички анотации на страница, една по една — обичайният начин за изграждане на одитен списък или панел за преглед — четецът на анотации се извикваше в цикъл и изпускаше текста от предишната анотация при всеки проход след първия; PDF файл с необичайно голям брой анотации, съдържащи текст, можеше да увеличава паметта на дълго работещ процес, докато той продължава да работи. Поправката засегна по един ред във всяка функция: замяната на FillChar(Result, SizeOf(Result), 0) с Result := Default(TPdfAnnotation) беше достатъчна, защото присвояването на Default към управляван запис изпълнява обичайната за компилатора последователност от освобождаване и изчистване вместо необработено запълване на паметта
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, не е собственият Result на функцията, а параметър var, подаден едно ниво по-надолу. SetBookmarkData приема изхода си като var Data: TBookmark и преди е изчиствал Data в началото на тялото си с FillChar; публичната функция GetBookmark, която действително връща TBookmark, извиква SetBookmarkData и подава директно собствения си Result като този аргумент var. Параметърът var се предава по препратка, така че Data вътре в SetBookmarkData и Result вътре в GetBookmark са едно и също място, назовано по два начина, а рискът от псевдонимиране за собствения Result на функцията важи също толкова пряко за всяка помощна процедура, която получава този Result по препратка. Ако преглеждате само функциите, които буквално декларират тип на връщан запис, ще пропуснете този случай; търсенето трябва да проследи и всеки параметър var или out, към който Result се препраща
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 и нищо друго, а изчистването му с FillChar не освобождава нищо, защото няма нищо с броене на препратки, което да бъде освобождавано. Проверка, която ясно разделя двата случая, може да се формулира просто: има ли някое поле на записа, на каквато и да е дълбочина, тип string, AnsiString, WideString, динамичен масив, интерфейс или Variant? Записът може да изглежда изцяло числов на най-горното ниво и пак да не премине тази проверка, ако едно от полетата му е друг запис, който скрива низ няколко слоя по-надолу, затова трябва да проследите вложените записи докрай, вместо да спрете при списъка с полета на външния запис. Одитът на съществуваща кодова база за този шаблон е механичен, а не изчерпателен: потърсете всяко извикване на FillChar, чиято цел е променлива от тип запис, след което проверете списъка с полета на този запис спрямо горния списък с управлявани типове. Одитът на PDFiumPas във версия 1.56.4 извърши точно такова търсене в цялата библиотека и откри този риск в един модул; всички останали места с FillChar вече изчистваха обикновен числов запис, където FillChar беше и остава правилният инструмент
Същото поведение на компилатора, което прави повторно използвания Result опасен тук, стои и зад свързана група разлики между Delphi и FPC на други места в тази кодова база; придружаващата статия за капаните при различни компилатори разглежда случай, в който FPC и Delphi не са съгласни точно кога се финализира временният резултат от запис в рамките на един израз — различен симптом на същия основен факт, че Result на функция, връщаща запис, не винаги е новото, частно място за съхранение, за което изглежда. Цикълът по анотации, използван като текущ пример в тази статия, също не е хипотетичен: това е същото обхождане страница по страница, което бихте написали при изграждане на панел за преглед на анотации, и точно тази форма на кода превърна едноредовия FillChar в бавно изтичане на памет
Нищо от това не изисква смяна на библиотеката или преследване на грешка в компилиран код на друг производител: това е свойство на самия език Object Pascal, с което всеки разработчик на Delphi и FPC работи ежедневно, а поправката е едно извикване на функция, когато знаете какво да търсите. Разгледаните тук API за анотации, отметки и анотации на връзки са част от PDFium Component за Delphi, C++Builder и Lazarus/FPC, заедно с останалите възможности за четене, изобразяване и анотиране на PDF, разгледани на други места в този блог