Odborný článok

Delphi vs FPC: 4 skryté úskalia PDF kódu v zostaveniach PDFium

Rovnaký zdrojový kód v Object Pascale sa môže pod Delphi a FPC/Lazarus správať odlišne štyrmi spôsobmi, ktoré opakovane zasahujú kód komponentu PDFium: FPC odstraňuje dočasné záznamy výsledkov funkcií skôr, ako operátor členstva in dokončí ich čítanie; dcc32 sa dodáva s vypnutou kontrolou rozsahov, takže indexy polí mimo rozsah potichu čítajú odpad; iba Delphi 13 akceptuje priradenie anonymného array of Byte do TBytes bez pretypovania; a spájanie AnsiStringov v Delphi môže zničiť bajty na hodnote $80 alebo nad ňou kvôli skrytej konverzii kódovej stránky. Každý z týchto prípadov vytvára kód, ktorý prejde na jednom kompilátore a zlyhá (alebo čo je horšie, správa sa potichu nesprávne) na druhom

Ak nastavujete projekt pre dva kompilátory prvýkrát, sprievodca prehliadačom pre Lazarus a FPC popisuje tú bezproblémovú cestu: balíky, vyhľadávacie cesty a zobrazenie okna vykresľovania na obrazovke. Tento článok je opakom tutoriálu. Je to zoznam problémov, na ktoré sme narazili po tom, čo táto cesta začala fungovať, keď bol testovací systém zelený pod FPC aj pod Delphi a potom zmena, ktorá na jednej strane prešla, na druhej vybuchla. Každé úskalie nižšie pochádza z reálneho zlyhania v testovacej sade PDFiumPas alebo jej ukážkach, pričom analýza na úrovni commitov je zhrnutá do minimálnej reprodukcie, hlavnej príčiny a riešenia, ktoré sme štandardizovali

Prečo sa množina pod FPC javí ako prázdna, ale v Delphi nie?

Verzia jednou vetou: FPC môže uvoľniť dočasnú premennú obsahujúcu záznam výsledku funkcie skôr, ako sa dokončí vyhodnotenie výrazu čítajúceho pole tohto výsledku, takže test členstva X in Func().Issues sa môže vykonávať voči už uvoľnenej množine, zatiaľ čo ekvivalentný výraz v Delphi funguje. Naše testy zhody PDF/E na to narazili hneď v prvej verzii. Validátor vracia záznam, ktorého pole Issues je množinou príznakov porušenia, a asercie toto volanie vkladali priamo do riadku (inline)

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Inlined form read the set as empty under FPC, so every assertion that expected a flag failed, while the identical Delphi build passed. The root cause is a difference in how the two compilers manage the lifetime of function-result temporaries inside larger expressions: Delphi keeps the temporary alive to the end of the statement, whereas FPC's disposal of the record temporary can race the set-membership operator that is still reading it. We had already documented the same behavior once before, in a comment on the FlagPresent helper in the PDF/A test unit, and then reintroduced the bug anyway when writing new tests from scratch, which tells you how natural the broken form looks. The fix is mechanical and worth adopting as a blanket rule: never chain a field access or set test directly onto a function call that returns a record; assign the result to a local variable first, then read the field. It costs one line and removes an entire class of compiler-dependent flakiness

Prečo Delphi akceptuje index poľa, ktorý FPC odmieta skompilovať?

Verzia jednou vetou: dcc32 skompiluje index mimo rozsahu do poľa s pevnými hranicami a pri predvolene vypnutej kontrole rozsahov číta alebo zapisuje susednú pamäť za behu bez akejkoľvek chyby, zatiaľ čo FPC rovnaký index odmietne už pri kompilácii. Komponent PDFium deklaruje body štvoruholníka ako pole indexované od 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, čo zodpovedá zvyčajnému číslovaniu položiek QuadPoints v PDF. Ukážka, ktorá ho plnila pomocou klasického cyklu od 0, fungovala pod Delphi celé mesiace

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

The Delphi build was a false positive: with range checking off, which is the dcc32 default, index 0 landed on whatever field precedes the array in the record, and the demo appeared to run. Porting the same demo to Lazarus produced an immediate compile-time range check error from FPC, and fixing the index then exposed a second, deeper bug in the library's annotation path that the garbage reads had been masking, the one dissected in the quad-points annotation article. Two lessons came out of that incident. First, prefer Low() and High() over literal bounds whenever the array type is not 0-based by construction. Second, treat an FPC compile, or at minimum one Delphi build with {$R+} enabled, as a mandatory first-run gate for any new demo or test: dcc32's defaults will not tell you about this class of bug, and a program that runs is not evidence that it is correct

Priradenie do TBytes, ktoré akceptuje iba Delphi 13

Verzia jednou vetou: priradenie poľa deklarovaného ako anonymné array of Byte do premennej typu TBytes sa skompiluje v Delphi 13 (verzia kompilátora 37.0), ale zlyhá na Delphi 12 Athens a všetkých starších verziách s chybou E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Toto nie je ani tak rozdiel medzi Delphi a FPC, ako skôr rozdiel medzi Delphi a jeho vlastnou minulosťou, no zasahuje rovnaký viac-kompilátorový kód rovnakým spôsobom: najnovší kompilátor potichu akceptuje konštrukciu, ktorú všetky ostatné odmietajú

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

We shipped exactly this in a validation routine, developed and tested locally on Delphi 13, where the implicit conversion was silently accepted. The full-source installer serves users on Delphi 12 and older in large numbers, and for them the unit simply did not compile. The structural fix is either the hard cast shown above, which is safe because an anonymous array of Byte and TBytes share an identical dynamic-array layout, or better, declaring the field as a named type such as TBytes in the first place so no conversion ever arises. The process fix matters more: a construct that compiles on your newest toolchain proves nothing about the older compilers your users actually run, and this category of regression is invisible until you build against every supported version. Our release scripts now compile the library across the full compiler matrix precisely because a local 37.0 build cannot catch a 13-only leniency

Bajt v AnsiStringu, ktorý na čínskom systéme Windows zmizne

Verzia jednou vetou: spájanie surových bajtov na hodnote $80 alebo vyššej do AnsiStringu pomocou + môže v Delphi potichu nahradiť tento bajt znakom ? ($3F), pretože výraz vykonáva skrytú obojsmernú konverziu AnsiString -> UnicodeString -> AnsiString cez kódovú stránku systému. Našli sme to vďaka testu PDF/A, ktorý vytvára názov obsahujúci izolovaný bajt $FE (čo nikdy nie je platný úvodný bajt UTF-8), aby sme overili, že validátor označí názvy, ktoré nie súp latným UTF-8 podľa ISO 19005-2 klauzuly 6.1.8

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

Na čínskom systéme Windows s kódovou stránkou 936 spájaný reťazec vôbec neobsahoval bajt $FE, takže knižnica správne nič nenahlásila a test zčervenal, hoci to vyzeralo ako chyba knižnice. Knižnica sa však nemýlila: testovací program pod FPC, ktorý poslal PDF so skutočným bajtom $FE, dostal očakávaný príznak. K poškodeniu došlo vo vnútri testovacieho programu Delphi počas vyhodnocovania reťazcového výrazu, pretože Unicode-prvý model reťazcov v Delphi konvertuje zmiešané AnsiString výrazy cez UnicodeString a keďže $FE nie je platný úvodný bajt v CP936, obojsmerná konverzia ho nahradí. Priznajme si hranice: na jednobajtovej západnej kódovej stránke (napr. CP1252) rovnaký výraz zvyčajne prežije, čo je presne dôvod, prečo sa táto chyba skrýva na väčšine vývojárskych počítačov a objavuje sa len na východoázijských systémoch alebo lokalizovaných CI serveroch. Pravidlo, ktoré sme prijali: nikdy nevytvárať binárne testovacie vektory obsahujúce bajty $80 a viac spájaním AnsiStringov; buď upravte bajty na mieste po usadení reťazca (ako je uvedené vyššie), alebo vektor od začiatku zostavte v TBytes

Čo by mal pracovný postup pre dva kompilátory predvolene kontrolovať

Štyri úskalia, jeden vzor: každý kompilátor vám povie o inej časti vašich chýb. Analýza rozsahov kompilátora FPC odhalila index mimo rozsahu, ktorý dcc32 vykonával bez varovania celé mesiace, a Unicode model reťazcov v dcc32 odhalil závislosť na kódovej stránke, ktorú čisto bajtovo orientované zostavenie FPC nikdy nespustí. Praktickým dôsledkom je, že ani jedna úspešná linka sama o sebe nestačí. Cross-kompilácia nie je len políčkom pre prenosnosť, je to druhý statický analyzátor a druhý behový model aplikovaný na rovnaký kód, v rovnakom duchu ako defenzívne kontroly ohraničenia v článku o zosilnení ABI a pamäťovej bezpečnosti

Pravidlá, ktoré z týchto incidentov vyplynuli, sú dostatočne krátke na to, aby ste si ich zapamätali. Priraďte dočasné záznamy výsledkov funkcií do lokálnej premennej pred čítaním polí. Prechádzajte polia s pevnými hranicami pomocou Low() a High() a pred nasadením akejkoľvek novej ukážky spustite aspoň jedno zostavenie s kontrolou rozsahov alebo pod FPC. Pretypujte polia anonymných dynamických polí explicitne (alebo ich rovno deklarujte s pomenovanými typmi) a pred vydaním zostavte celú maticu kompilátorov. Surové vysoké bajty úplne vylúčte zo spájania AnsiStringov. Nič z toho nestojí merateľné úsilie, akonáhle sa z toho stanú návyky, a každý z týchto krokov uzatvára chybový stav, ktorý jednovláknový alebo jednokompilátorový vývoj zo svojej podstaty nedokáže zachytiť

Všetky štyri problémy boli odhalené a opravené počas údržby komponentu PDFium, ktorý poskytuje rovnaký zdrojový kód Object Pascal pre Delphi, C++Builder aj FPC/Lazarus a spúšťa svoje testy zhody a regresie na každom z týchto nástrojov, takže úskalia v tomto článku strážia testy a nie ľudská pamäť

Domov · Hľadať · losLab.com