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