Isti Object Pascal izvorni kod može se različito ponašati pod Delphijem i FPC/Lazarusom na četiri načina koji stalno pogađaju kod PDFium komponente: FPC odbacuje privremene zapise rezultata funkcije pre nego što in test pripadnosti završi sa njihovim čitanjem, dcc32 dolazi sa isključenom proverom raspona pa indeksi niza van granica tiho čitaju smeće iz memorije, samo Delphi 13 prihvata dodeljivanje anonimnog array of Byte varijabli TBytes bez eksplicitnog pretvaranja (cast), a Delphi-jevo spajanje AnsiStringova može uništiti bajtove na vrednosti $80 ili iznad nje kroz skriveni povratni krug kodne stranice (code-page round-trip). Svaki od njih stvara testni skup koji je zelen na jednom prevodiocu, a crven, ili što je još gore, tiho pogrešan na drugom
Ako prvi put postavljate projekat sa dvostrukim prevodiocem, pregled Lazarus i FPC vizualizatora pokriva lakši put: pakete, staze pretraživanja i postavljanje prozora za renderovanje na ekran. Ovaj je članak suprotnost vodiču. To je popis problema na koje smo naišli nakon što je taj lakši put proradio, kada je CI bio zelen pod FPC-om, zelen pod Delphijem, a onda je promena koja je prošla na jednoj strani detonirala na drugoj. Svaka zamka u nastavku dolazi iz stvarnog neuspeha u testnom skupu PDFiumPas ili njegovim demo projektima, sa forenzikom na nivou commita sažetom u minimalnu reprodukciju, temeljni uzrok i popravak koji smo standardizovali
Zašto se skup pod FPC-om učitava kao prazan, ali ne i u Delphiju?
Verzija u jednoj rečenici: FPC može finalizirati privremenu varijablu koja drži rezultat zapisa funkcije pre nego što izraz koji čita polje tog rezultata završi, pa X in Func().Issues može testirati pripadnost u odnosu na već oslobođeni skup, dok ekvivalentni Delphijev izraz radi. Naši testovi usaglašenosti PDF/E naišli su na to u svojoj prvoj verziji. Validator vraća zapis čije je polje Issues skup zastavica kršenja, a tvrdnje (assertions) su sadržavale ugniježđeni poziv
// Nepouzdano pod FPC-om: privremeni zapis rezultata funkcije
// može biti oslobođen pre nego što test 'in' pročita Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Pouzdano na oba prevodioca: prvo pričvrstite rezultat na lokalnu varijablu
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
Ugniježđeni oblik čitao je skup kao prazan pod FPC-om, pa je svaka tvrdnja koja je očekivala zastavicu zakazala, dok je identična Delphijeva inačica prošla. Temeljni uzrok je razlika u tome kako dva prevodioca upravljaju životnim vekom privremenih rezultata funkcija unutar većih izraza: Delphi održava privremeni rezultat na životu do kraja naredbe, dok FPC-ovo odbacivanje privremenog zapisa može prestići operator pripadnosti skupu koji ga još uvek čita. Već smo jednom dokumentovali isto ponašanje u komentaru na pomoćnu funkciju FlagPresent u testnoj jedinici PDF/A, a zatim smo ponovo uveli istu grešku pišući nove testne ispočetka, što vam govori koliko prirodno izgleda taj neispravni oblik. Ispravak je mehanički i vredi ga usvojiti kao opšte pravilo: nikada ne povezujte pristup polju ili test skupa direktno na poziv funkcije koja vraća zapis; najpre dodelite rezultat lokalnoj varijabli, a zatim pročitajte polje. To košta jedan redak i uklanja celu klasu nestabilnosti ovisnih o prevodiocu
Zašto Delphi prihvata indeks niza koji FPC odbija kompajlirati?
Verzija u jednoj rečenici: dcc32 kompajlira indeks van raspona u niz sa fiksnim granicama i, sa onemogućenom zadanom proverom raspona, čita ili piše u susednu memoriju tokom izvršavanja bez ikakve greške, dok FPC odbija isti indeks u vreme kompajliranja. PDFium komponenta deklarise točke četvorougla kao 1-bazirani niz, TQuadrilateralPoint = array [1..4] of TPdfPoint, što odgovara uobičajenom numerisanju QuadPoints unosa u PDF-u. Demo koji ga je punio refleksivnom 0-baziranom petljom radio je mesecima pod Delphijem
var
I: Integer;
begin
for I := 0 to 3 do // pogrešno: niz je [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 podrazumevano: kompajlira se, indeks 0
// tiho dodiruje susednu memoriju
// FPC: greška provere raspona u vreme kompajliranja
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // ispravno na oba prevodioca
end;
Delphijeva kompilacija bila je lažno pozitivna: sa isključenom proverom raspona, što je zadana postavka za dcc32, indeks 0 je sleteo na bilo koje polje koje prethodi nizu u zapisu, a demo je naizgled radio. Portovanje istog demo projekta na Lazarus proizvelo je trenutnu grešku provere raspona u vreme kompajliranja iz FPC-a, a ispravljanje indeksa potom je otkrilo drugu, dublju grešku u stazi zabeleški knjižnice koju su tiha čitanja smeća maskirala, onu analiziranu u članku o označavanju QuadPoints-a. Iz tog su incidenta proizašle dve lekcije. Prvo, dajte prednost funkcijama Low() i High() nad doslovnim granicama kad god tip niza nije po konstrukciji 0-baziran. Drugo, tretirajte FPC kompilaciju, ili barem jednu Delphijevu sa omogućenim {$R+}, kao obaveznu proveru za svaki novi demo ili test: zadane postavke dcc32 neće vas obavestiti o ovoj klasi grešaka, a program koji radi nije dokaz da je ispravan
Dodela TBytes koju prihvata samo Delphi 13
Verzija u jednoj rečenici: dodeljivanje polja deklarisanog kao anonimni array of Byte varijabli TBytes kompajlira se na Delphiju 13 (verzija prevodioca 37.0), ali ne uspeva na Delphiju 12 Athens i svim ranijim izdanjima uz grešku E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Ovo nije toliko podela Delphi-naspram-FPC-a, koliko podela Delphi-naspram-svoje-vlastite-prošlosti, ali pogađa istu bazu koda sa više prevodioca na isti način: najnoviji prevodilac tiho prihvata konstrukciju koju sve ostalo odbija
type
TValidator = class
private
FBuffer: array of Byte; // anonimni tip dinamičkog niza
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // samo Delphi 13; E2010 na Delphi 12
// Athens i ranije
OrigBytes := TBytes(FBuffer); // kompajlira se svuda; isti raspored bajtova,
// bezbedno čvrsto pretvaranje
end;
Isporučili smo tačno to u validacionoj rutini, razvijenoj i testiranoj lokalno na Delphiju 13, gde je implicitna pretvorba bila tiho prihvaćena. Instalacioni program sa celovitim izvornim kodom služi velikom broju korisnika na Delphiju 12 i starijim verzijama, a za njih se jedinica jednostavno nije kompajlira. Strukturno rešenje je ili čvrsto pretvaranje tipa (hard cast) prikazano gore, što je bezbedno jer anonimni array of Byte i TBytes dele identičan raspored dinamičkog niza, ili još bolje, deklarisanje polja kao imenovanog tipa poput TBytes od samog početka kako do pretvorbe uopšte ne bi dolazelo. Popravak procesa je još važniji: konstrukcija koja se kompajlira na vašem najnovijem lancu alata ne dokazuje ništa o starijim prevodiocima koje vaši korisnici stvarno pokreću, i ova je kategorija regresije nevidljiva sve dok ne pokrenete izgradnju protiv svih podržanih verzija. Naše skripte za izdanja sada kompajliraju biblioteku kroz celu matricu prevodioca upravo zato što lokalna 37.0 kompilacija ne može uhvatiti popustljivost specifičnu za Delphi 13
Bajt AnsiStringa koji nestaje na kineskom sistemu Windows
Verzija u jednoj rečenici: spajanje sirovog bajta na vrednosti $80 ili više u AnsiString pomoću operatora + može tiho zameniti taj bajt znakom ? ($3F) pod Delphijem, jer izraz prolazi kroz implicitni povratni krug iz AnsiStringa u UnicodeString i nazad u AnsiString kroz sistemsku kodnu stranicu. To smo otkrili kroz PDF/A test koji konstruiše naziv koji sadrži izolovani bajt $FE, koji nikada nije ispravan UTF-8 vodeći bajt, kako bi proverio označava li validator nazive koji nisu valjani UTF-8 prema ISO 19005-2 klauzuli 6.1.8
var
BadName: AnsiString;
begin
// Na Delphiju sa višebajtnom sistemskom kodnom stranicom (uočeno na CP936),
// spajanje prolazi povratni krug kroz UnicodeString, a $FE, koje
// nije valjana CP936 sekvenca, vraća se kao '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Bezbedno: izgradite sa ASCII rezervisanim mestom, zatim zakrpite bajt na licu mesta;
// indeksirana dodela u već smireni AnsiString ne prolazi povratni krug
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
Na kineskom Windows sistemu koji koristi kodnu stranicu 936, spojeni niz uopšte nije sadržavao $FE, pa biblioteka opravdano nije prijavila ništa i test je propao, što je izgledalo kao greška biblioteke. Biblioteka zapravo nikada nije bila u krivu: FPC okruženje koje je ubacilo PDF koji stvarno sadrži bajt $FE dobilo je očekivanu zastavicu. Korupcija se dogodila unutar Delphijeve testne izvršne datoteke tokom procene izraza niza, jer Delphijev Unicode-prvi model znakovnih nizova pretvara mešovite AnsiString izraze kroz UnicodeString, a $FE nije valjan vodeći bajt u CP936 pa ga povratno putovanje zamenjuje. Budimo pošteni prema granicama: na jednobajtnoj zapadnoj kodnoj stranici kao što je CP1252 isti izraz obično preživljava, što je upravo razlog zašto se ovaj bug skriva na većini razvojnih mašina i pojavljuje se samo na istočnoazijskim sistemima ili lokalizovanim CI serverima. Pravilo koje smo usvojili: nikada ne gradite binarne testne vektore koji sadrže bajtove na $80 ili više spajanjem AnsiStringova; ili zakrpite bajtove na licu mesta nakon što se niz smiri, kao gore, ili od početka konstruišite vektor u tipu TBytes
Šta bi radni tok sa dvostrukim prevodiocem preradio prema zadanim postavkama
Četiri zamke, jedan obrazac: svaki prevodilac vas obaveštava o različitom podskupu vaših grešaka. FPC-ova analiza raspona u vreme kompajliranja uhvatila je indeks van granica koji je dcc32 tiho izvršavao mesecima, a dcc32-ov Unicode model znakovnih nizova otkrio je ovisnost o kodnoj stranici koju čisti bajt-orijentirani FPC build nikada ne pokreće. Praktična posledica je da nijedan samostalan zeleni cjevovod nije dovoljan. Unakrsno kompajliranje (cross-compiling) nije samo kvačica za portabilnost, to je drugi statički analizator i drugi runtime model primenjen na isti izvor, u istom duhu kao i odbrambene provere granica u članku o očvršćavanju ABI-ja i bezbednosti memorije
Pravila koja su proizašla iz ovih incidenata dovoljno su kratka da se zapamte. Pričvrstite rezultate zapisa funkcija na lokalnu varijablu pre čitanja polja. Prolazite kroz nizove sa fiksnim granicama koristeći Low() i High(), te pokrenite barem jednu proveru raspona ili FPC kompilaciju pre nego što poverujete novom demo projektu. Eksplicitno pretvarajte anonimna polja dinamičkih nizova ili ih deklarišite imenovanim tipovima i izgradite celu matricu prevodioca pre izdanja. Potpuno izbacite sirove visoke bajtove iz spajanja AnsiStringova. Ništa od ovoga ne košta merljiv trud kada jednom postane navika, a svaka stavka zatvara način neuspeha koji radni tok sa jednim prevodiocem strukturno ne može uočiti
Sva četiri problema pronađena su i ispravljena tokom održavanja komponente PDFium Component, koja isporučuje isti Object Pascal izvorni kod za Delphi, C++Builder i FPC/Lazarus te pokreće svoje testne usaglašenosti i regresije na svakom od tih lanaca alata, tako da su zamke u ovom članku osigurane testovima, a ne sećanjem