Tehnični članak

FillChar v rezultatu funkcije povzroča uhajanje nizov v Object Pascalu

Funkcija Delphi ali FPC, ki vrača zapis, ob vsakem klicu ne dobi novega, z ničlami napolnjenega rezultata. Ta skrita spremenljivka Result se začne z ničlo natanko enkrat in se med klici samodejno ne ponastavi, zato jo mora funkcija počistiti sama. Če to počistite z FillChar(Result, SizeOf(Result), 0), rutina od drugega klica naprej prepiše živi sklic na niz ali dinamično polje, namesto da bi ga sprostila, zato pomnilniški blok, na katerega je sklic kazal, ostane osirotel

Primer, v katerem se to pokaže, je povsem vsakdanji. Paketni proces odpre zbirko PDF-jev drugih izdelovalcev in pregleda vsako opombo na vsaki strani ter besedilo komentarja zapiše v revizijski dnevnik. V tej zanki ni ničesar, kar bi bilo videti nevarno: vsak klic je navadna funkcija, ki vrača navaden zapis, brez očitnih kazalcev in brez česa, kar bi spominjalo na ročno upravljanje pomnilnika. Štetje referenc znotraj zapisa je običajno pravilo Object Pascala, ne posebnost ene same knjižnice, zato je ista napaka mogoča v vsaki kodni zbirki Delphi ali FPC, ki združuje FillChar z vrstami zapisov, ki vsebujejo nize ali dinamična polja

Zakaj FillChar v rezultatu zapisa povzroči uhajanje nizov?

FillChar povzroči uhajanje nizov, ker ne ve, kakšno vrsto podatkov prepisuje. FillChar(X, Count, Value) deluje na katerikoli spremenljivki: vzame netipiziran blok Count bajtov in vsakega označi z vrednostjo Value, kar je celotna pogodba. Prav to naredi FillChar hitrega in splošno uporabnega, saj nikoli ne pregleda vrste X in ne razveji izvajanja glede na pomen osnovnih bajtov. Polje UnicodeString ali WideString v zapisu niso sami znaki, temveč kazalec na kopico, ki pred znakovnimi podatki vsebuje števec referenc. FillChar vidi nekaj bajtov, ki po naključju hranijo vrednost kazalca, in jih prepiše z ničlami, tako kot bi prepisal polje Integer ali Double. Kazalec izgine, števec referenc, ki bi ga moral prej zmanjšati, se nikoli ne dotakne, blok, na katerega je kazal, pa ostane alociran brez preostalih sklicev

Kako prevajalnik sledi nizom in dinamičnim poljem v zapisu

Object Pascal imenuje tip upravljan, kadar mora prevajalnik izvesti dodatno kodo, da ostane pravilen pri prirejanju in izhodu iz obsega. Dolgi nizi, kot so AnsiString, UnicodeString in WideString, izpolnjujejo ta pogoj, prav tako dinamična polja, vmesniki in Variant, pa tudi vsak zapis ali fiksno polje, ki vsebuje enega od teh tipov. Za vsako upravljano polje prevajalnik tiho izpiše knjigovodstvo, ki bi ga bilo sicer zamudno in preprosto napačno napisati ročno: pri prirejanju poveča števec referenc, ga zmanjša, ko je hranilna spremenljivka prepisana ali zapusti obseg, ter sprosti osnovni blok, ko števec doseže nič. Ta mehanizem je razlog, da običajna koda Pascal nikoli ročno ne alocira ali sprošča string in da je prirejanje enega dinamičnega polja drugemu poceni in varno namesto ročne zanke za kopiranje. System.Default in Finalize sta dokumentirana načina za sprožitev iste logike sproščanja na zahtevo in prav njiju mora koda za čiščenje zapisa poklicati namesto surovega zapolnjevanja pomnilnika

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;

Zakaj se uhajanje začne šele pri drugem klicu?

Prvi klic v zanki je vedno neškodljiv, prav zato je to napako pri testiranju tako lahko spregledati. Lokalna spremenljivka upravljanega zapisa se začne z ničlo in se med enim prehodom zanke in naslednjim samodejno ne ponastavi, zato je polje Description ali ContentsText prvič, ko zanka v to spremenljivko priredi vrnjeno vrednost funkcije, še vedno nil. FillChar prepiše nil z ničlo, kar glede števca referenc ničesar ne spremeni, klic pa se vrne navidez povsem pravilno. Drugi klic je drugačen: ista lokalna spremenljivka že vsebuje vse, kar je zapisal prvi klic, rezultat novega klica pa se zapiše neposredno v isto shrambo in ne v svež, prazen pomnilnik. FillChar na začetku drugega klica zato z ničlami prepiše polje, ki ni več nil, in vse, kar je odvisno od tega vzorca bajtov, je od takrat naprej tiho napačno. Test, ki funkcijo pokliče enkrat in pregleda rezultat, težave nikoli ne bo opazil; razkrije jo šele zanka ali katerakoli pot, ki funkcijo večkrat pokliče za isti cilj

Resnično uhajanje: opombe, zaznamki in zapisi povezav

PDFiumPas je imel to napako pred različico 1.56.4 v treh funkcijah, ki vsaka vrača zapis z vsaj enim upravljanim poljem: bralnik opomb na ravni strani vrača TPdfAnnotation s nizoma ContentsText in AuthorText, bralnik zaznamkov vrača TBookmark z nizom Title, bralnik opomb povezav pa vrača TLinkAnnotation z nizom ActionPath in dinamičnim poljem Points. Vse tri so se začele z enako obliko kot spodaj: z neobdelanim FillChar počistijo Result, nato pa eno za drugim zapolnijo polja iz podatkov osnovne strani. Pri zaporednem pregledovanju vsake opombe na strani, kar je običajen način za izdelavo revizijskega seznama ali pregledne plošče, se je bralnik opomb klical v zanki in po vsakem prehodu po prvem puščal besedilo prejšnje opombe; PDF z nenavadno velikim številom opomb z besedilom bi lahko dolg proces polnil s pomnilnikom, dokler je ta proces deloval. Popravek je posegel v eno vrstico vsake funkcije: zamenjava FillChar(Result, SizeOf(Result), 0) z Result := Default(TPdfAnnotation) je zadostovala, ker prirejanje Default upravljanemu zapisu izvede običajno zaporedje prevajalnika za sprostitev in nato čiščenje namesto surovega zapolnjevanja pomnilnika

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;

Ista nevarnost za parametrom var

Bralnik zaznamkov pokaže bolj prefinjeno različico iste težave, ker zapis, ki ga FillChar počisti, ni lastni Result funkcije, temveč parameter var en klic nižje. SetBookmarkData sprejme izhod kot var Data: TBookmark in je nekoč na začetku telesa z FillChar počistil Data; GetBookmark, javna funkcija, ki dejansko vrača TBookmark, pokliče SetBookmarkData in neposredno posreduje svoj Result kot ta argument var. Parameter var se posreduje po sklicu, zato sta Data znotraj SetBookmarkData in Result znotraj GetBookmark ista shramba pod dvema imenoma, nevarnost vzdevkov, ki velja za lastni Result funkcije, pa velja enako neposredno za vsako pomožno rutino, ki ga prejme po sklicu. Če pregledate samo funkcije, ki dobesedno deklarirajo vračanje zapisa, boste to obliko zgrešili; iskanje mora slediti tudi vsakemu parametru var in out, v katerega je Result posredovan naprej

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;

Kdaj je FillChar še vedno prava izbira?

FillChar je še vedno pravilna izbira in je za zapis, sestavljen izključno iz ordinalov, polj s plavajočo vejico, fiksnih polj takih vrednosti ali drugih navadnih zapisov iz istih sestavin, pogosto nekoliko cenejši, saj v njem ni ničesar, kar bi moral prevajalnik dokončati. Lastni pravokotni tip PDFiumPas je prav tak primer: TPdfRectangle vsebuje štiri polja Double in ničesar drugega, zato čiščenje z FillChar ničesar ne sprosti, ker ni ničesar s štetjem referenc, kar bi bilo treba sprostiti. Preverjanje, ki loči oba primera, je preprosto: ali ima katerokoli polje zapisa na katerikoli globini gnezdenja tip string, AnsiString, WideString, dinamično polje, vmesnik ali Variant? Zapis je na vrhnji ravni lahko videti povsem številski, pa vendar ne prestane preverjanja, če je eno od njegovih polj zapis, ki nekaj ravni nižje skriva niz, zato mora preverjanje slediti gnezdenim zapisom do konca in se ne sme ustaviti pri zunanjem seznamu polj. Pregled obstoječe kodne zbirke za ta vzorec je mehanski in ne zahteva izčrpnega branja: poiščite vsak klic FillChar, katerega cilj je spremenljivka tipa zapis, nato pa seznam polj tega zapisa primerjajte z zgornjim seznamom upravljanih tipov. Revizija PDFiumPas v1.56.4 je izvedla natanko to iskanje po celotni knjižnici in to izpostavljenost našla v eni enoti; vsako drugo mesto klica FillChar je že čistilo navaden številski zapis, kjer je bil FillChar in ostaja prava izbira

Enako vedenje prevajalnika, zaradi katerega je tukaj ponovno uporabljeni Result nevaren, poganja tudi povezano skupino nesoglasij med Delphijem in FPC drugje v tej kodni zbirki; spremni članek o pasteh med prevajalniki obravnava primer, v katerem se FPC in Delphi ne strinjata o tem, kdaj se začasni rezultat zapisa dokonča znotraj enega izraza, kar je drugačen simptom istega osnovnega dejstva, da Result zapisa funkcije ni vedno sveža, zasebna shramba, kot je videti. Zanka opomb, ki je ves članek uporabljena kot tekoči primer, prav tako ni hipotetična: to je isti sprehod po straneh, ki bi ga napisali pri izdelavi pregledne plošče za opombe, in prav ta oblika kode je enovrstični FillChar spremenila v počasno uhajanje pomnilnika

Za nič od tega ni treba zamenjati knjižnic ali iskati napake v prevedeni kodi nekoga drugega: gre za lastnost jezika Object Pascal, s katero vsak razvijalec Delphi in FPC dela vsak dan, popravek pa je en sam klic funkcije, ko veste, kaj iskati. Tukaj opisani API-ji za opombe, zaznamke in opombe povezav so del komponente PDFium za Delphi, C++Builder in Lazarus/FPC ter so na voljo poleg preostalega vmesnika za branje, upodabljanje in opombe PDF, ki je obravnavan drugje na tem blogu