Tekninen artikkeli

FillChar funktion tuloksessa vuotaa merkkijonoja Object Pascalissa

Delphi- tai FPC-funktio, joka palauttaa tietueen, ei saa tuoretta, nollattua Result-muuttujaa joka kutsulla. Tuo piilotettu Result-muuttuja alkaa nollasta täsmälleen kerran, eikä mikään nollaa sitä automaattisesti uudelleen kutsujen välillä, joten sen tyhjentäminen sisääntulossa on funktion oma tehtävä. Tee tuo tyhjennys kutsulla FillChar(Result, SizeOf(Result), 0), ja toisesta kutsusta eteenpäin rutiini ylikirjoittaa elävän merkkijono- tai dynaamisen taulukon viittauksen sen vapauttamisen sijaan, jättäen orvoksi minkä tahansa kasalohkon, johon tuo viittaus osoitti

Skenaario, jossa tämä purree, on arkinen. Eräprosessi avaa pinon kolmannen osapuolen PDF-tiedostoja ja käy läpi jokaisen annotaation jokaisella sivulla, vetäen kommenttitekstin tarkastuslokiin. Missään tuossa silmukassa ei näytä olevan mitään vaarallista: jokainen kutsu on tavallinen funktio, joka palauttaa tavallisen tietueen, ei osoittimia näkyvissä, ei mitään, mikä muistuttaisi manuaalista muistinhallintaa lainkaan. Viittauslaskenta tietueen sisällä on tavallinen Object Pascal -kirjanpitosääntö, ei minkään tietyn kirjaston erikoisuus, ja mikä tahansa Delphi- tai FPC-koodikanta, joka sekoittaa FillCharia tietuetyyppeihin, joilla on merkkijonoja tai dynaamisia taulukoita, on alttiina samalle vialle

Miksi FillChar tietuetuloksessa vuotaa merkkijonoja?

FillChar vuotaa merkkijonoja, koska sillä ei ole aavistustakaan, minkälaista dataa se ylikirjoittaa. FillChar(X, Count, Value) toimii minkä tahansa muuttujan kanssa: se ottaa tyypittömän lohkon Count tavua ja leimaa jokaisen niistä arvolla Value, ja se on koko sopimus. Juuri tämä tekee FillCharista nopean ja yleiskäyttöisen, koska se ei koskaan tarkasta X:n tyyppiä eikä koskaan haaroita sen mukaan, mitä taustalla olevat tavut tarkoittavat. UnicodeString- tai WideString-kenttä tietueen sisällä ei ole itse merkit; se on osoitin kasalohkoon, joka kantaa viittauslaskurin ennen merkkidataa. FillChar näkee kourallisen tavuja, jotka sattuvat kantamaan osoitinarvon, ja ylikirjoittaa ne nollalla täsmälleen samalla tavalla kuin se ylikirjoittaisi Integer- tai Double-kentän. Osoitin katoaa, viittauslaskuri, jota sen olisi pitänyt ensin vähentää, ei koskaan kosketa, ja lohko, johon se osoitti, istuu varattuna ilman että mikään enää viittaa siihen

Miten kääntäjä seuraa merkkijonoja ja dynaamisia taulukoita tietueen sisällä

Object Pascal kutsuu tyyppiä hallituksi, kun kääntäjän on ajettava ylimääräistä koodia pitääkseen sen oikeana sijoituksen ja soveltamisalan poistumisen yli. Pitkät merkkijonotyypit kuten AnsiString, UnicodeString ja WideString täyttävät tämän, samoin dynaamiset taulukot, rajapinnat ja Variant-tyypit, sekä mikä tahansa tietue tai kiinteä taulukko, joka sisältää jonkin niistä kenttänä. Jokaiselle hallitulle kentälle kääntäjä tuottaa hiljaa kirjanpidon, joka olisi muuten työlästä ja helppo saada väärin käsin: kasvata viittauslaskuria sijoituksessa, vähennä sitä, kun pitävä muuttuja ylikirjoitetaan tai poistuu soveltamisalasta, ja vapauta taustalla oleva lohko heti, kun tuo laskuri saavuttaa nollan. Tuo koneisto on syy siihen, miksi tavallinen Pascal-koodi ei koskaan varaa tai vapauta string-muuttujaa käsin, ja miksi yhden dynaamisen taulukon sijoittaminen toiseen on halpa, turvallinen toiminto eikä käsin tehty kopiointisilmukka. System.Default ja Finalize ovat kaksi dokumentoitua tapaa kutsua samaa vapautuslogiikkaa pyynnöstä, ja ne ovat se, mitä tietueen tyhjennyskoodin pitäisi kutsua raa'an muistintäytön sijaan

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;

Miksi vuoto alkaa vasta toisesta kutsusta?

Silmukan ensimmäinen kutsu on aina harmiton, mikä on juuri se, mikä tekee tästä viasta helpon ohittaa testauksessa. Hallitun tietuetyypin paikallinen muuttuja alkaa nollasta, eikä mikään nollaa sitä automaattisesti uudelleen yhden silmukkakierroksen ja seuraavan välillä, joten ensimmäisellä kerralla, kun silmukka sijoittaa funktion paluuarvon tuohon muuttujaan, sen Description- tai ContentsText-kenttä on yhä nil. FillChar ylikirjoittaa nilin nollalla, mikä ei muuta mitään viittauslaskurin osalta, ja kutsu palautuu näyttäen täysin oikealta. Toinen kutsu on erilainen: sama paikallinen muuttuja jo kantaa mitä tahansa, mitä ensimmäinen kutsu kirjoitti siihen, ja uuden kutsun Result kirjoitetaan suoraan siihen samaan varastoon eikä tuoreeseen, tyhjään muistiin. FillChar tuon toisen kutsun alussa nollaa kentän, joka ei ole enää nil, ja kaikki alavirrassa tuosta tavukuviosta on hiljaa väärin siitä eteenpäin. Testi, joka kutsuu funktiota kerran ja tarkastaa tuloksen, ei koskaan näe ongelmaa; vain silmukka, tai mikä tahansa koodipolku, joka kutsuu funktiota toistuvasti samaa kohdetta vasten, paljastaa sen

Todellinen vuoto: annotaatiot, kirjanmerkit ja linkkitietueet

PDFiumPas toimitti juuri tämän vian ennen versiota 1.56.4, kolmessa funktiossa, joista kukin palauttaa tietueen, jolla on ainakin yksi hallittu kenttä: sivutason annotaatiolukija palauttaa TPdfAnnotation-olion, joka kantaa ContentsText- ja AuthorText-merkkijonoja, kirjanmerkkilukija palauttaa TBookmark-olion, joka kantaa Title-merkkijonon, ja linkkiannotaatiolukija palauttaa TLinkAnnotation-olion, joka kantaa ActionPath-merkkijonon ja Points-dynaamisen taulukon. Kaikki kolme avautuivat alla näkyvällä samalla muodolla: tyhjennä Result raa'alla FillCharilla, täytä sitten kentät yksi kerrallaan taustalla olevasta sivudatasta. Jokaisen sivun jokaisen annotaation läpikäynti kerrallaan, tavallinen tapa rakentaa tarkastuslista tai tarkistuspaneeli, kutsui annotaatiolukijaa silmukassa ja vuoti edellisen annotaation tekstin joka kierroksella ensimmäisen jälkeen; PDF, joka on laadittu epätavallisen suurella määrällä tekstiä kantavia annotaatioita, saattoi kasvattaa pitkään käynnissä olevan prosessin muistia niin kauan kuin prosessi pysyi käynnissä. Korjaus kosketti yhtä riviä kussakin funktiossa: FillChar(Result, SizeOf(Result), 0):n korvaaminen kutsulla Result := Default(TPdfAnnotation) riitti, koska Default-arvon sijoittaminen hallittuun tietueeseen ajaa kääntäjän tavallisen vapauta-sitten-tyhjennä-sekvenssin raa'an muistintäytön sijaan

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;

Sama vaara var-parametrin takana

Kirjanmerkkilukija näyttää hienovaraisemman version samasta ongelmasta, koska tietue, joka tyhjennetään FillCharilla, ei ole funktion oma Result vaan var-parametri yhden kutsun päässä. SetBookmarkData ottaa tulosteensa muodossa var Data: TBookmark ja tyhjensi aiemmin Data:n rungon alussa FillCharilla; GetBookmark, julkinen funktio, joka todella palauttaa TBookmark-olion, kutsuu SetBookmarkData-funktiota ja välittää oman Result-arvonsa suoraan tuoksi var-argumentiksi. var-parametri välitetään viittauksena, joten Data SetBookmarkData:n sisällä ja Result GetBookmark:n sisällä ovat sama varasto kahdella nimellä, ja mikä tahansa aliasointiriski, joka koskee funktion omaa Result-arvoa, koskee yhtä suoraan mitä tahansa apurutiinia, joka vastaanottaa sen viittauksena. Vain niiden funktioiden tarkastelu, jotka kirjaimellisesti ilmoittavat tietuepaluutyypin, ohittaa tämän muodon; haun on seurattava myös jokaista var- ja out-parametria, johon Result välitetään edelleen

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;

Milloin FillChar on yhä oikea valinta?

FillChar on yhä oikein, ja usein hieman halvempi, tietueelle, joka on rakennettu kokonaan ordinaaleista, liukulukukentistä, kiinteäkokoisista taulukoista niitä tai muista tavallisista tietueista, jotka koostuvat samoista, koska siinä ei ole mitään, mitä kääntäjän tarvitsisi finalisoida. PDFiumPas:n oma suorakulmiotyyppi on juuri tuo tapaus: TPdfRectangle kantaa neljä Double-kenttää eikä mitään muuta, ja yhden tyhjentäminen FillCharilla ei vapauta mitään, koska mitään viittauslaskettua ei ole vapautettavana. Tarkistus, joka erottaa nämä kaksi tapausta, on yksinkertainen esittää: onko missään tietueen kentässä, missä tahansa sisäkkäisyyssyvyydessä, tyyppi string, AnsiString, WideString, dynaaminen taulukko, rajapinta tai Variant? Tietue voi näyttää täysin numeeriselta ylimmällä tasolla ja silti epäonnistua tuossa testissä, jos yksi sen kentistä on itse tietue, joka hautaa merkkijonon muutaman kerroksen syvemmälle, joten tarkistuksen on seurattava sisäkkäisiä tietueita loppuun asti sen sijaan, että se pysähtyisi uloimpaan kenttälistaan. Olemassa olevan koodikannan tarkastaminen tämän mallin varalta on mekaanista eikä tyhjentävää: etsi jokainen FillChar-kutsu, jonka kohde on tietuemuuttuja, tarkista sitten tuon tietueen kenttälista yllä olevaa hallittujen tyyppien listaa vasten. PDFiumPas:n oma v1.56.4-tarkastus ajoi juuri tuon haun koko kirjaston yli ja löysi tämän altistuksen yhdessä yksikössä; jokainen muu FillChar-kutsupaikka tyhjensi jo tavallisen numeerisen tietueen, jossa FillChar oli, ja pysyy, oikeana työkaluna

Sama kääntäjäkäytös, joka tekee uudelleenkäytetystä Result-muuttujasta vaarallisen täällä, ajaa myös liittyvää Delphi-vastaan-FPC-erimielisyyksien perhettä muualla tässä koodikannassa; rinnakkaisartikkeli kääntäjien välisistä sudenkuopista käsittelee tapauksen, jossa FPC ja Delphi ovat eri mieltä siitä, milloin tarkalleen tietuetulosteen väliaikaisolio finalisoidaan yhden lausekkeen sisällä, eri oire samasta taustalla olevasta tosiasiasta, että funktion tietue-Result ei aina ole se tuore, yksityinen varasto, jolta se näyttää. Tämän artikkelin läpi käytetty annotaatiosilmukka-esimerkki ei ole hypoteettinen sekään: se on sama sivu-sivulta-läpikäynti, jonka kirjoittaisit rakentaessasi annotaatiotarkistuspaneelia, mikä on juuri se koodimuoto, joka muutti yhden rivin FillCharin hitaaksi muistivuodoksi alun perin

Mikään tästä ei vaadi kirjaston vaihtamista tai jonkun muun käännetyn koodin bugin jäljittämistä: se on itse Object Pascal -kielen ominaisuus, jonka kanssa jokainen Delphi- ja FPC-kehittäjä työskentelee päivittäin, ja korjaus on yksi funktiokutsu heti, kun tiedät etsiä sitä. Tässä kuvatut annotaatio-, kirjanmerkki- ja linkkiannotaatio-API:t toimitetaan osana PDFium-komponenttia Delphille, C++Builderille ja Lazarus/FPC:lle, yhdessä tämän blogin muualla käsitellyn PDF:n luku-, renderöinti- ja annotaatiopinnan kanssa