Artykuł techniczny

Macierz kompilacji Delphi: HotXLS od XE5

HotXLS dostarcza jedną bazę kodu Object Pascal do każdej wersji Delphi i C++Builder od XE5 wzwyż, a build-All-Lib-TRIAL.cmd jest skryptem, który tego dowodzi: 43 przebiegi obejmujące 12 wersji Delphi na Win32 i Win64 oraz 10 pakietów C++Builder Win32 i 9 Win64. Od v2.363 do v2.374 skrypt nigdy nie został doprowadzony do końca, a przebieg XE5 przez cały ten czas był zepsuty

Gdy już zobaczyło się błąd, nic w nim nie było subtelnego. Pięć różnych konstrukcji, które obecny kompilator przyjmuje bez komentarza, to twarde błędy w RAD Studio XE5, oznaczonym w macierzy jako 12.0. Wydanie v2.375.0 naprawiło wszystkie pięć, a macierz znów zazieleniła się przy wyniku 43 z 43. Poniżej opisuję każde odrzucenie, wyjaśniam, dlaczego stary kompilator ma prawdopodobnie rację w dwóch przypadkach odrzucanych ze względów typów, oraz pokazuję bardziej krępujący szczegół: skrypt sondy napisany do diagnozowania bałaganu przy pierwszym uruchomieniu zgłosił fałszywy sukces

Dlaczego przebieg XE5 psuł się bez niczyjej uwagi?

Przebieg XE5 psuł się, ponieważ codzienny development uruchamiał tylko cztery skrypty dla 37.0, a zielony lokalny build nic nie mówi o kompilatorze, którego nie wywołałeś. Pełna macierz jest osobnym, wolnym skryptem uruchamianym przez instalator trial przed zebraniem plików przez Inno Setup, więc jest ćwiczona w czasie pakowania, a nie przy commicie. Dwanaście wydań mieści się w tej luce

Warto rozpisać arytmetykę przebiegów, bo właśnie w niej mieszka złudzenie pokrycia. DELPHI_TRIAL_VERSIONS wylicza wersje od 12.0 do 37.0, a każda z 12 wersji buduje się dwukrotnie, dla Win32 i Win64. CB_TRIAL_WIN32_VERSIONS zawiera 10 wersji, a CB_TRIAL_WIN64_VERSIONS tylko 9, ponieważ XE5 ma projekt pakietu C++Builder, ale nie dostarcza obiektu startowego pakietu Win64 c0pkg64.o. Dwanaście plus dwanaście plus dziesięć plus dziewięć daje 43. Uruchomienie czterech z nich i nazwanie bazy kodu przenośną to błąd kategorii, dokładnie ten, który pozwolił na tę sytuację

HotXLS oberwał już od tej samej klasy problemu z przeciwnej strony. Nowy unit osiągalny przez klauzulę uses, ale nieobecny na liście plików .cbproj, kompiluje się pod Delphi bez problemu, bo dcc niejawnie dołącza jednostki nieumieszczone na liście i w najgorszym razie emituje ostrzeżenie W1033. C++Builder emituje .obj tylko dla unitów nazwanych w <DelphiCompile>, więc ten sam kod umiera na etapie ilink z nierozwiązanym symbolem zewnętrznym. Jeden toolchain ukrywa to, co łapie drugi. To cały argument za uruchamianiem macierzy zamiast ufania reprezentatywnemu kompilatorowi

Twarde rzutowania typów odrzucane przez stare kompilatory Win32

Dwa z pięciu odrzuceń są tym samym błędem w dwóch przebraniach: twardym rzutowaniem zastosowanym do wyrażenia zmiennoprzecinkowego zamiast do zmiennej. Na Win32 starsze kompilatory wykonują arytmetykę przez stos x87, więc dodawanie zawierające Double jest wykonywane z 80-bitową nadmiarową precyzją, a jego typ statyczny staje się 10-bajtowym Extended. Rzutowanie 10 bajtów do 8-bajtowego TDateTime nie jest legalnym rzutowaniem typu i kompilator zgłasza E2089 Invalid typecast

Najbardziej irytujący szczegół jest taki, że forma ze zmienną jest poprawna. TDateTime(Serial) kompiluje się w każdej wersji macierzy, ponieważ Serial ma już 8 bajtów, a rzutowanie zachowuje rozmiar. Dodaj do niego cokolwiek, a wyrażenie rozszerzy się pod spodem. Poprawką nie jest szersze rzutowanie ani warunkowy define, lecz zaprzestanie rzutowania: niejawne przypisanie real-to-real konwertuje poprawnie w każdym obsługiwanym przez HotXLS kompilatorze i mówi dokładnie to, co kod ma na myśli

// Odrzucane w XE5 (Win32): każde dodawanie jest obliczane jako 10-bajtowy
// Extended, a zawężenie z 10 do 8 bajtów przez rzutowanie zgłasza E2089
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // to jest akceptowane: bez dodawania

// Bezpieczne dla wersji: pozwól, aby konwersję wykonało przypisanie real-to-real
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// Ta sama klasa odrzucenia w pakowarce wartości komórek: twarde rzutowanie Double
// liczby całkowitej. Zamiast rzutowania dziel - operator już zwraca wartość rzeczywistą
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // przenośne
  ;

Gałąź Serial < 60 dotyczy fikcji roku przestępnego 1900, a nie różnicy o jeden: numer seryjny 60 jest nieistniejącym 1900-02-29 Excela, więc numery poniżej niego potrzebują dodatkowego dnia, zanim zobaczy je DecodeDate. Praca nad przenośnością nigdy nie powinna po cichu zmieniać takiej logiki, dlatego bezpieczna edycja usuwa rzutowanie i pozostawia arytmetykę bez zmian

Co się psuje, gdy nil jest argumentem proceduralnym?

Samodzielne nil przekazane tam, gdzie oczekiwany jest typ proceduralny, nie wiąże się podczas rozwiązywania przeciążenia w starszych kompilatorach. Miejsce wywołania w HotXLS to ResolveIndexedColor, które jest przeciążone i przyjmuje callback TXLSTryResolveSystemColor, niepotrzebny większości wywołujących. Nowsze kompilatory dopasowują nil do parametru proceduralnego i wybierają właściwe przeciążenie. XE5 tego nie robi, a komunikat wskazuje zbiór przeciążeń zamiast argumentu, przez co tracisz dwadzieścia minut

Przenośną odpowiedzią jest nadanie callbackowi null typu. Zmienna na poziomie unitu typu proceduralnego jest zerowana przez język, więc bez inicjalizatora już ma wartość nil i niesie informację o typie, której chce stary resolver. Gdy zmienna unit-level byłaby przesadą, lokalna zmienna typowana i przypisana do nil robi to samo

var
  // Literał proceduralny nil nie wiąże się w starszych kompilatorach
  // podczas rozwiązywania przeciążenia; typowana, wyzerowana zmienna wiąże się poprawnie
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

FWorkbook.ResolveIndexedColor(AIndexedColor, xicsBiffIcv, ARole,
  NilSystemColorResolver, Resolution);

// Ta sama poprawka z lokalną zmienną typowaną, w skoroszycie XLSX
function TXLSXWorkbook.ResolveIndexedColor(AIndex: Int64;
  ASpace: TXLSIndexedColorSpace;
  out AResolution: TXLSIndexedColorResolution): Boolean;
var
  NoResolver: TXLSTryResolveSystemColor;
begin
  NoResolver := nil;
  Result := ResolveIndexedColor(AIndex, ASpace, xicrGeneral, NoResolver,
    AResolution);
end;

Zauważ, że to rzeczywista różnica na poziomie języka, a nie błąd kompilatora, który warto obchodzić definami. Zmienna wyzerowana przez język jest poprawna w każdej wersji macierzy i kosztuje jedną linię, więc nie ma tu w ogóle kompilacji warunkowej. Po {$IF CompilerVersion} sięgaj tylko wtedy, gdy platforma naprawdę różni się między wydaniami, a w tym batchu zachodzi to dokładnie raz

Chronione metody VCL przenoszą się między wydaniami

TPicture.LoadFromStream jest public w obecnym VCL i protected w starszych wersjach obsługiwanych przez HotXLS, więc bezpośrednie wywołanie kompiluje się teraz, a wtedy nie. HotXLS używa go do sprawdzenia, czy payload obrazu tła arkusza rzeczywiście się dekoduje, a kontrola sygnatury odbywa się przed tym, jak eksporter HTML zdecyduje się osadzić bajty. Obowiązuje klasyczna odpowiedź Pascala: zadeklaruj potomka w tym samym unicie wyłącznie po to, aby poszerzyć widoczność, a w miejscu wywołania rzutuj przez niego

type
  // TPicture.LoadFromStream jest protected w starszych wersjach VCL, które
  // obsługuje biblioteka; potomek w tym samym unicie udostępnia metodę
  TXlsxPictureAccess = class(TPicture);

// ...

Stream.WriteBuffer(AData[1], Length(AData));
Stream.Position := 0;
TXlsxPictureAccess(Picture).LoadFromStream(Stream);
Result := (Picture.Graphic <> nil) and not Picture.Graphic.Empty and
  (Picture.Graphic.Width > 0) and (Picture.Graphic.Height > 0);

Sztuczka z klasą dostępową jest tutaj bezpieczna, ponieważ potomek nie dodaje pól i nigdy nie jest tworzony; rzutowanie zmienia tylko to, co kompilator pozwoli nazwać. Mimo to warto zostawić komentarz przy deklaracji, bo czytelnik budujący wyłącznie na obecnym IDE uzna inaczej ten typ za bezcelowy. Obsługa obrazu tła pojawia się również w ścieżce renderowania niestandardowej siatki VCL, gdzie ten sam zdekodowany payload zasila arkusz na ekranie

Typ tokenu GdiplusStartup zmieniał się dwukrotnie

Jedyne odrzucenie w tym batchu, które naprawdę wymaga kompilacji warunkowej, dotyczy typu parametru var w GdiplusStartup, zmienionego między generacjami VCL w sposób wykluczający jedną poprawną pisownię wszędzie. Sondowanie wersji po wersji ustaliło faktyczne zachowanie: przebiegi 12.0 do 20.0 przyjmują wyłącznie Cardinal, przebiegi 21.0 i 22.0 wyłącznie THandle albo ULONG_PTR, a 23.0 i 37.0 przyjmują oba. W nazwach wydań oznacza to Cardinal od XE5 do 10.3 Rio oraz THandle od 10.4 Sydney wzwyż. Ponieważ akceptowane zakresy nie nakładają się dla 12.0 do 22.0, żadna bezwarunkowa deklaracja nie działa: strażnik opiera się na CompilerVersion >= 34, czyli Sydney, a wywołanie jest w pełni kwalifikowane jako Winapi.GDIPAPI.GdiplusStartup, aby kolejność rozwiązywania unitów nie podstawiła innej deklaracji w żadnej wersji ze środka zakresu

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // Typ parametru var GdiplusStartup w GDIPAPI zależy od generacji VCL
  // Cardinal do Rio, THandle od Sydney wzwyż
  {$IF CompilerVersion >= 34}
  StartupToken: THandle;
  {$ELSE}
  StartupToken: Cardinal;
  {$IFEND}
  TiffEncoder: TGUID;
begin
  FillChar(StartupInput, SizeOf(StartupInput), 0);
  StartupInput.GdiplusVersion := 1;
  CheckStatus(Winapi.GDIPAPI.GdiplusStartup(StartupToken, @StartupInput,
    nil), 'startup');
  if GetEncoderClsid('image/tiff', TiffEncoder) < 0 then
    raise EInvalidGraphic.Create('GDI+ TIFF encoder is unavailable');
  // ... encode ...
end;

To gałąź TIFF eksportera obrazów strony, więc promień rażenia błędu obejmuje całą powierzchnię eksportu rastrowego, w tym ścieżki opisane w artykule o eksporcie zakresu komórek jako pojedynczego obrazu. Zauważ też, czego strażnik nie twierdzi: ULONG_PTR i THandle mają tę samą szerokość na obu platformach, więc wybór dotyczy identyfikatora użytego w deklaracji, a nie poprawności 32-bitowej kontra 64-bitowej

Dlaczego pierwsze uruchomienie sondy niczego nie zgłosiło?

Sonda wersji niczego nie zgłosiła przy pierwszym uruchomieniu, ponieważ przypisania res=$(...) wykonywały się w podpowłoce, a stamtąd nie propagują się do rodzica. dcc32 kończy się kodem 0 po sukcesie, więc kod wyjścia był właściwym sygnałem do przechwycenia, a skrypt przechwytywał go do zmiennej, która znikała jedną linię później. Każdy przebieg wracał pusty, a wyjście wyglądało jak sonda, która niczego nie skompilowała, co dokładnie było prawdą

Druga awaria była gorsza, ponieważ dała złą odpowiedź zamiast żadnej. Sonda klasyfikowała przebieg, licząc linie pasujące do Error, a Delphi nie poprzedza tym słowem każdego błędu krytycznego. F1026 File not found jest błędem krytycznym i nie pasuje, więc sonda, która nie potrafiła w ogóle rozwiązać unitu, otrzymała wynik czystego sukcesu. XE5 nie dostarcza Winapi.GDIPOPS.dcu, pierwsza sonda trafiła dokładnie na to i fałszywie zazieleniła wynik. Wynikająca z tego zasada jest wąska i warto ją powiedzieć wprost: sondę kompilatora oceniaj po wyprodukowanym artefakcie albo własnej linii podsumowania kompilatora, nigdy po grepciu wyjścia pod kątem słowa kluczowego. Grepowanie stderr pod kątem Error jest heurystyką, która zawodzi w kierunku, na który nie możesz sobie pozwolić, po cichu raportując sukces

Ile naprawdę kosztuje obsługa dekady kompilatorów

Uczciwe rozliczenie jest takie, że zmiany w kodzie są tutaj banalne, a zmiany procesu już nie. Cztery z pięciu odrzuceń naprawiono przez napisanie zwyklejszego Pascala, a nie przez dodawanie mechaniki wersji: usuń rzutowanie, dziel zamiast rzutować, nadaj nil typ, zadeklaruj klasę dostępową. Tylko GdiplusStartup zasłużył na {$IF}. Baza kodu obejmująca XE5 do bieżącego wydania nie zamienia się w gąszcz warunkowych definicji, chyba że wcześniej pozwolisz, aby twarde rzutowania i idiomy najnowszego kompilatora zaczęły się gromadzić

Rzeczywisty koszt to czas budowania i dyscyplina. Czterdzieści trzy przebiegi to wolny skrypt, właśnie dlatego dryfował w stronę czasu pakowania, a potem w stronę nigdy. Rozsądnym środkiem jest pozostawienie szybkiej pętli czterech skryptów do iteracji i uruchamianie pełnej macierzy według harmonogramu, którego nie da się pominąć, bo tryb awarii nie polega na zepsutym buildzie, który zauważysz, lecz na wspieranym IDE, które po cichu przestało być wspierane dwanaście wydań temu

Ten obowiązek jest drugą stroną dostarczania natywnego komponentu. HotXLS odczytuje i zapisuje XLS, XLSX oraz ODS wyłącznie przez Object Pascal, bez instalacji Excela i zależności COM, co umożliwia automatyzację skoroszytów bez Office na zablokowanym serwerze. Ta sama właściwość oznacza, że kompilator jest całą umową platformy, więc każda wersja w macierzy jest obietnicą, którą trzeba ponownie sprawdzić, a nie założyć

Macierz kompilacji między kompilatorami i opisany tutaj kod bezpieczny dla wersji są częścią komponentu arkuszy HotXLS dla Delphi, który obsługuje Delphi i C++Builder od XE5 do bieżącego wydania, dostarczając gotowe binaria bibliotek dla każdego wspieranego IDE