Ten sam kod źródłowy w Object Pascalu może zachowywać się różnie w Delphi i FPC/Lazarus na cztery sposoby, które regularnie utrudniają pracę z kodem komponentu PDFium: FPC usuwa tymczasowe rekordy zwracane przez funkcje przed zakończeniem testu przynależności in, dcc32 jest dostarczany z wyłączonym sprawdzaniem zakresów, co sprawia, że wyjście poza tablicę po cichu odczytuje śmieci z pamięci, tylko Delphi 13 pozwala na przypisanie anonimowego typu array of Byte do TBytes bez rzutowania, a delphiowa konkatenacja typów AnsiString może uszkodzić bajty o wartościach $80 lub wyższych poprzez niejawne konwersje strony kodowej. Każdy z tych przypadków sprawia, że kod działa na jednym kompilatorze, a na drugim zgłasza błędy lub, co gorsza, daje nieprawidłowe wyniki w milczeniu
Jeśli po raz pierwszy konfigurujesz projekt na dwa kompilatory, przewodnik po Lazarusie i FPC opisuje optymistyczną ścieżkę: pakiety, ścieżki wyszukiwania i wyświetlanie okna renderowania na ekranie. Ten artykuł jest przeciwieństwem samouczka — to lista problemów, na które natknęliśmy się po uruchomieniu podstawowej konfiguracji, gdy testy przechodziły pomyślnie na FPC i Delphi, a następnie zmiana zatwierdzona na jednej platformie powodowała błędy na drugiej. Każda z poniższych pułapek pochodzi z rzeczywistych błędów w zestawie testów PDFiumPas lub jego wersjach demonstracyjnych, a ich analiza została skrócona do minimalnego kodu odtwarzającego problem, wskazania przyczyny i wypracowanego rozwiązania
Dlaczego pod FPC zbiór wydaje się pusty, a w Delphi nie?
W skrócie: FPC może usunąć zmienną tymczasową przechowującą wynik rekordu z funkcji, zanim wyrażenie odczytujące jej pole zostanie zakończone. Oznacza to, że zapis X in Func().Issues może testować przynależność do już zwolnionego zbioru, podczas gdy to samo wyrażenie w Delphi działa poprawnie. Nasze testy zgodności z PDF/E napotkały ten problem w pierwszej wersji. Walidator zwraca rekord, którego pole Issues jest zbiorem flag naruszeń, a asercje wywoływały funkcję bezpośrednio w linii
// 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;
Wywołanie bezpośrednie odczytywało zbiór jako pusty w FPC, przez co każda asercja oczekująca flagi kończyła się błędem, podczas gdy ta sama kompilacja w Delphi działała prawidłowo. Przyczyną jest różnica w sposobie zarządzania czasem życia obiektów tymczasowych wewnątrz złożonych wyrażeń przez oba kompilatory: Delphi utrzymuje obiekt tymczasowy do końca instrukcji, natomiast FPC usuwa go w sposób, który może wyprzedzić działanie operatora sprawdzania przynależności do zbioru. Dokumentowaliśmy już to zachowanie w komentarzu do funkcji pomocniczej FlagPresent w testach PDF/A, a mimo to ponownie popełniliśmy ten sam błąd przy pisaniu nowych testów, co pokazuje, jak naturalnie wygląda błędny zapis. Rozwiązanie jest proste i warto przyjąć je jako zasadę: nigdy nie odwołuj się do pola ani zbioru bezpośrednio z wywołania funkcji zwracającej rekord — najpierw przypisz wynik do zmiennej lokalnej, a dopiero potem odczytaj pole. Kosztuje to jedną linijkę, a eliminuje całą klasę problemów zależnych od kompilatora
Dlaczego Delphi akceptuje indeks tablicy, którego FPC nie pozwala nawet skompilować?
W skrócie: dcc32 kompiluje indeks wykraczający poza zakres tablicy o stałym rozmiarze i, przy domyślnie wyłączonym sprawdzaniu zakresów, odczytuje lub zapisuje sąsiednią pamięć bez żadnego błędu, podczas gdy FPC odrzuca taki indeks na etapie kompilacji. Komponent PDFium deklaruje punkty czworokąta jako tablicę indeksowaną od 1: TQuadrilateralPoint = array [1..4] of TPdfPoint, co odpowiada typowej numeracji QuadPoints w plikach PDF. Wersja demonstracyjna, która wypełniała ją tradycyjną pętlą zaczynającą się od zera, działała bez problemów w Delphi przez wiele miesięcy
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;
Kompilacja w Delphi dała fałszywy wynik pozytywny: przy wyłączonym sprawdzaniu zakresów (co jest domyślnym ustawieniem dcc32) indeks 0 trafiał na pole poprzedzające tablicę w rekordzie, a demo wydawało się działać prawidłowo. Przeniesienie tego kodu do środowiska Lazarus natychmiast wygenerowało błąd sprawdzania zakresu w FPC. Naprawienie indeksu ujawniło kolejny błąd w ścieżce adnotacji biblioteki, który był maskowany przez nieprawidłowe odczyty pamięci, opisany w artykule o adnotacjach na bazie czworokątów. Wyciągnęliśmy z tego dwie nauki: po pierwsze, używaj funkcji Low() i High() zamiast wpisanych na sztywno limitów, gdy tablica nie zaczyna się od zera. Po drugie, traktuj kompilację w FPC lub przynajmniej kompilację w Delphi z włączoną dyrektywą {$R+} jako obowiązkowy test dla każdego nowego kodu testowego — domyślne ustawienia dcc32 nie poinformują Cię o tym błędzie, a fakt, że program działa, nie oznacza, że robi to poprawnie
Przypisanie do TBytes, które akceptuje wyłącznie Delphi 13
W skrócie: przypisanie pola zadeklarowanego jako anonimowa tablica array of Byte do zmiennej typu TBytes kompiluje się w Delphi 13 (wersja kompilatora 37.0), ale kończy się błędem w Delphi 12 Athens i wcześniejszych wersjach jako E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Nie jest to różnica typu Delphi vs FPC, lecz Delphi vs jego starsze wersje, niemniej jednak wpływa to na wielokompilatorowy kod w ten sam sposób: najnowszy kompilator po cichu akceptuje konstrukcję, którą starsze odrzucają
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;
Wdrożyliśmy dokładnie ten zapis w procedurze walidacji, napisanej i przetestowanej lokalnie w Delphi 13, gdzie ta niejawna konwersja była akceptowana. Nasz instalator ze źródłami obsługuje jednak wielu użytkowników korzystających z Delphi 12 i starszych wersji, u których ten kod się nie kompilował. Poprawka strukturalna polega na jawnym rzutowaniu (jak powyżej), co jest bezpieczne, ponieważ anonimowa tablica bajtów oraz TBytes dzielą identyczny układ w pamięci, lub — co jest lepszym podejściem — na zadeklarowaniu pola od razu jako typu TBytes. Wnioski proceduralne są ważniejsze: fakt, że kod kompiluje się na najnowszym narzędziu, nie oznacza, że zadziała u użytkowników na starszych wersjach, a tego typu regresje są niewidoczne, dopóki nie skompilujesz kodu na każdej wspieranej wersji. Nasze skrypty wydawnicze kompilują teraz bibliotekę na pełnej macierzy kompilatorów, ponieważ lokalna kompilacja w wersji 37.0 nie wykryje specyficznych dla wersji 13 ułatwień
Bajt AnsiString, który znika na chińskiej wersji systemu Windows
W skrócie: łączenie surowych bajtów o wartościach $80 lub wyższych z ciągiem AnsiString za pomocą operatora + może w Delphi po cichu zastąpić dany bajt znakiem zapytania ? ($3F), ponieważ wyrażenie wykonuje niejawną konwersję AnsiString -> UnicodeString -> AnsiString przez systemową stronę kodową. Wykryliśmy to podczas testu PDF/A, który tworzy nazwę zawierającą pojedynczy bajt $FE (który nie jest prawidłowym bajtem wiodącym UTF-8), aby upewnić się, że walidator prawidłowo oznacza nazwy niezgodne z UTF-8 (zgodnie z ISO 19005-2 klauzula 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;
W systemie Windows z chińską stroną kodową 936 połączony ciąg znaków w ogóle nie zawierał bajtu $FE, więc biblioteka zgodnie z prawdą nic nie zgłaszała, a test kończył się niepowodzeniem, sugerując błąd w kodzie biblioteki. Sama biblioteka działała poprawnie: test pod FPC, który przetwarzał plik PDF faktycznie zawierający bajt $FE, zakończył się sukcesem. Uszkodzenie następowało wewnątrz pliku wykonywalnego testu w Delphi podczas ewaluacji wyrażenia łańcuchowego, ponieważ zorientowany na standard Unicode model w Delphi konwertuje złożone wyrażenia AnsiString przez typ UnicodeString, a $FE nie jest poprawnym bajtem wiodącym w CP936, przez co konwersja zastępuje go znakiem zapytania. Bądźmy szczerzy: na jednobajtowej zachodniej stronie kodowej (jak CP1252) to wyrażenie zazwyczaj działa, dlatego błąd ten nie ujawnia się na większości maszyn deweloperskich, a pojawia się dopiero na systemach azjatyckich lub w zlokalizowanych środowiskach CI. Przyjęta zasada: nigdy nie buduj binarnych wektorów testowych zawierających bajty $80 lub wyższe przez konkatenację AnsiString — zamiast tego modyfikuj bajty w miejscu po utworzeniu ciągu lub od początku twórz wektor w formacie TBytes
Co powinien domyślnie sprawdzać przepływ pracy oparty na dwóch kompilatorach
Cztery pułapki, jeden wzorzec: każdy kompilator informuje o innym podzbiorze błędów. Analiza zakresów w FPC na etapie kompilacji wykryła błąd indeksowania, który dcc32 wykonywał po cichu przez miesiące, a model Unicode w dcc32 ujawnił zależność od systemowej strony kodowej, której czysty, zorientowany bajtowo test w FPC nigdy by nie wywołał. Praktyczny wniosek: żaden pojedynczy potok kompilacji nie jest wystarczający. Wieloplatformowość to nie tylko kwestia przenośności kodu — to drugi analizator statyczny i drugi model wykonawczy uruchamiany na tym samym kodzie źródłowym, w tym samym duchu co zabezpieczenia opisane w artykule o utwardzaniu ABI i bezpieczeństwie pamięci
Reguły, które wypracowaliśmy po tych incydentach, są łatwe do zapamiętania. Zawsze przypisuj rekordy zwracane przez funkcje do zmiennych lokalnych przed odczytem ich pól. Przechodź przez tablice o stałym rozmiarze za pomocą funkcji Low() i High() oraz przetestuj kod z włączonym sprawdzaniem zakresów pod FPC przed zatwierdzeniem zmian. Rzutuj jawnie anonimowe tablice dynamiczne (lub używaj typów nazwanych) i kompiluj pełną macierz przed wydaniem wersji. Unikaj konkatenacji znaków o wysokich wartościach bajtowych w typach AnsiString. Żadne z tych działań nie kosztuje wiele wysiłku, gdy stanie się nawykiem, a każde z nich eliminuje błędy, których pojedynczy kompilator strukturalnie nie jest w stanie wykryć
Wszystkie cztery problemy zostały zidentyfikowane i naprawione w trakcie prac nad PDFium Component, który dostarcza ten sam kod źródłowy dla Delphi, C++Builder i FPC/Lazarus i uruchamia swoje pakiety testów na każdym z tych środowisk, dzięki czemu opisane tutaj pułapki są kontrolowane przez testy, a nie jedynie przez pamięć deweloperów