Technisch artikel

Delphi cross-compiler buildmatrix: HotXLS sinds XE5

HotXLS levert vanaf XE5 één Object Pascal-codebase aan elke Delphi- en C++Builder-release, en build-All-Lib-TRIAL.cmd is het script dat dit bewijst: 43 buildlegs met 12 Delphiversies op Win32 en Win64 plus 10 C++Builder Win32- en 9 Win64-packagebuilds. Van v2.363 tot en met v2.374 werd dat script nooit volledig uitgevoerd en de XE5-leg was al die tijd stuk

Niets aan de fout was subtiel toen die eenmaal zichtbaar was. Vijf verschillende constructies die de huidige compiler zonder commentaar accepteert, zijn harde fouten op RAD Studio XE5, dat de buildmatrix als 12.0 labelt. De v2.375.0-release repareerde alle vijf en de matrix werd weer groen met 43 van 43. Hieronder volgt elke weigering, waarom de oude compiler volgens een verdedigbare lezing gelijk heeft over de twee die hij op typegronden afwijst en het gênantere deel: het probescript dat was geschreven om de puinhoop te diagnosticeren meldde bij de eerste run een valse pass

Waarom verouderde de XE5-leg zonder dat iemand het merkte?

De XE5-leg verouderde omdat de dagelijkse ontwikkeling alleen de vier scripts van 37.0 draaide, en een groene lokale build niets zegt over een compiler die je niet hebt aangeroepen. De volledige matrix is een afzonderlijk, traag script dat de trialinstaller uitvoert voordat Inno Setup bestanden verzamelt, dus hij wordt tijdens packaging getest en niet tijdens commit. Twaalf releases passen in dat gat

De rekenkunde van de leg is het uitschrijven waard, omdat daar de dekkingsillusie zit. DELPHI_TRIAL_VERSIONS somt 12.0 tot en met 37.0 op en elk van die 12 versies wordt tweemaal gebouwd, Win32 en Win64. CB_TRIAL_WIN32_VERSIONS bevat 10 versies en CB_TRIAL_WIN64_VERSIONS slechts 9, omdat XE5 wel een C++Builder-packageproject heeft maar niet het Win64-package-startupobject c0pkg64.o meelevert. Twaalf plus twaalf plus tien plus negen is 43. Vier ervan draaien en de codebase portable noemen is een categoriefout, en precies die fout heeft dit laten gebeuren

HotXLS is vanuit de andere richting door dezelfde vorm van probleem geraakt. Een nieuwe unit die via een uses-clause bereikbaar is maar ontbreekt in de bestandslijst van .cbproj, compileert perfect onder Delphi, omdat dcc niet-vermelde units impliciet het package in trekt en hoogstens een W1033-hint uitstuurt. C++Builder emit alleen een .obj voor units die in <DelphiCompile> staan, dus dezelfde code sterft in de ilink-fase met een unresolved external. De ene toolchain verbergt wat de andere vangt. Dat is het hele argument om de matrix te draaien in plaats van op een representatieve compiler te vertrouwen

Harde typecasts die de oude Win32-compilers afwijzen

Twee van de vijf afwijzingen zijn dezelfde bug in verschillende kleren: een harde typecast die op een floating-point-expressie wordt toegepast in plaats van op een variabele. Op Win32 evalueren de oudere compilers arithmetic via de x87-stack, dus een optelling met een Double wordt met 80-bit excess precision uitgevoerd en het statische type wordt het 10-byte-Extended. Tien bytes terugcasten naar een 8-byte-TDateTime is geen legale typecast en de compiler zegt dat met E2089 Invalid typecast

Het frustrerende detail is dat de variabelenvorm prima is. TDateTime(Serial) compileert op elke versie in de matrix, omdat Serial al 8 bytes is en de cast de grootte behoudt. Voeg je er iets aan toe, dan wordt de expressie onder je voeten breder. De fix is geen bredere cast en ook geen conditional define, maar stoppen met casten: een impliciete real-to-real-assignment converteert correct op elke compiler die HotXLS ondersteunt en zegt wat de code daadwerkelijk bedoelt

// Afgewezen op XE5 (Win32): elke optelling wordt als een 10-byte
// Extended geëvalueerd en de narrowing-cast van 10 naar 8 geeft E2089
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // deze wordt geaccepteerd: geen optelling

// Versie veilig: laat de real-to-real-assignment de conversie doen
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// Zelfde soort afwijzing in de cell-valuepacker: een harde Double-cast
// van een integer. Deel in plaats daarvan - de operator levert al een real
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portable
  ;

De tak Serial < 60 is de 1900-schrikkeljaar-fictie en geen off-by-one: serial 60 is Excel's niet-bestaande 1900-02-29, dus serials daaronder hebben de extra dag nodig voordat DecodeDate ze ziet. Portabiliteitswerk mag dat soort logica nooit stilletjes veranderen, en precies daarom verwijdert de veilige edit hier de cast maar laat hij de arithmetic intact

Wat breekt er wanneer nil een procedural argument is?

Een kale nil die wordt doorgegeven waar een procedural type wordt verwacht, kan bij de oudere compilers niet binden tijdens overload resolution. De callsite in HotXLS is ResolveIndexedColor, die overloaded is en een callback TXLSTryResolveSystemColor neemt die de meeste callers niet nodig hebben. Nieuwere compilers resolven nil tegen de procedural parameter en kiezen de juiste overload. XE5 doet dat niet en de diagnose wijst naar de overloadset in plaats van naar het argument, en zo ben je twintig minuten kwijt

Het portable antwoord is het nulcallback een type geven. Een variable op unitniveau van het procedural type wordt door de taal op nul geïnitialiseerd, dus hij is zonder initializer al nil en draagt de type-informatie die de oude resolver wil. Waar een variable op unitniveau overdreven zou zijn, doet een typed local waaraan nil wordt toegewezen hetzelfde

var
  // Een nil procedural literal bindt niet in de overloadresolutie van de
  // oudere compilers; een typed, nulgeïnitialiseerde variabele doet dat wel
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

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

// Dezelfde fix met een typed local in de XLSX-workbook
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;

Merk op dat dit een echt verschil op taalniveau is en geen compilerbug waarvoor je met defines moet werken. De nulgeïnitialiseerde variabele is op elke versie in de matrix correct en kost één regel, dus hier is helemaal geen conditional compilation. Grijp pas naar {$IF CompilerVersion} wanneer het platform tussen releases werkelijk verschilt, en dat is precies één keer in deze batch

Protected VCL-methoden verhuizen tussen releases

TPicture.LoadFromStream is public in de huidige VCL en protected in de oudere versies die HotXLS ondersteunt, waardoor een directe call nu compileert en toen faalt. HotXLS gebruikt hem om te valideren dat een worksheet-background-image-payload werkelijk decodeert, een signaturecheck die draait voordat de HTML-exporter zich vastlegt op het embedden van de bytes. Het klassieke Pascal-antwoord is van toepassing: declareer in dezelfde unit puur om de zichtbaarheid te verbreden een descendant en cast er bij de callsite doorheen

type
  // TPicture.LoadFromStream is protected in de oudere VCL-versies die de
  // library ondersteunt; een descendant in dezelfde unit maakt hem zichtbaar
  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);

De accessor-class-truc is hier veilig omdat de descendant geen velden toevoegt en nooit wordt geïnstantieerd; de cast verandert alleen wat de compiler je laat benoemen. Toch is een commentaar bij de declaratie de moeite waard, omdat een lezer die alleen op een actuele IDE bouwt anders een zinloos type ziet. Background-image-afhandeling komt opnieuw terug in het custom-VCL-grid-renderingpad, waar dezelfde gedecodeerde payload de sheet op het scherm voedt

Het type van het GdiplusStartup-token veranderde tweemaal

De enige afwijzing in de batch die echt conditional compilation vereist, is het type van de var-parameter van GdiplusStartup, dat tussen VCL-generaties veranderde op een manier waardoor nergens één spelling geldig is. Probing per versie legde het feitelijke gedrag vast: de legs van 12.0 tot en met 20.0 accepteren alleen Cardinal, de legs 21.0 en 22.0 alleen THandle of ULONG_PTR en 23.0 en 37.0 accepteren beide. In releasenamen is dat Cardinal van XE5 tot en met 10.3 Rio en THandle vanaf 10.4 Sydney. Omdat de twee accepterende bereiken voor 12.0 tot en met 22.0 niet overlappen, werkt geen onvoorwaardelijke declaratie: de guard gebruikt CompilerVersion >= 34, oftewel Sydney, en de call wordt volledig gekwalificeerd als Winapi.GDIPAPI.GdiplusStartup, zodat de volgorde waarin units worden geresolved op geen enkele versie in het midden van het bereik een andere declaratie kan invullen

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // Het var-parametertype van GDIPAPI's GdiplusStartup volgt de VCL-
  // generatie: Cardinal tot en met Rio, THandle vanaf Sydney
  {$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');
  // ... encoderen ...
end;

Dit is de TIFF-tak van de page-image-exporter, dus de blast radius van het fout doen is het volledige rasterexportsurface, inclusief de paden die worden beschreven in een celbereik als één afbeelding exporteren. Merk ook op wat de guard niet beweert: ULONG_PTR en THandle zijn op beide platforms even breed, dus de keuze gaat over welke identifier de declaratie benoemt en niet over 32-bits versus 64-bits correctness

Waarom rapporteerde de eerste probe-run niets?

De versieprobe rapporteerde bij de eerste run niets omdat assignments van de vorm res=$(...) binnen een subshell werden uitgevoerd en daar niet naar de parent doorwerken. dcc32 eindigt bij succes met 0, dus de exitcode was het juiste signaal om te vangen, en het script ving die op in een variabele die één regel later niet meer bestond. Elke leg kwam leeg terug en de output zag eruit als een probe die niets had gecompileerd, en dat was precies wat er was gebeurd

De tweede fout was erger omdat die een verkeerd antwoord produceerde in plaats van geen antwoord. De probe classificeerde een leg door regels te tellen die op Error matchten, en Delphi prefixeert niet elke fatal met dat woord. F1026 File not found is fataal maar matcht niet, dus een probe die een unit helemaal niet kon resolven werd als clean pass gescoord. XE5 levert Winapi.GDIPOPS.dcu niet mee, de eerste probe raakte precies dat en werd vals groen. De regel die hieruit kwam is smal en verdient het om expliciet te worden gezegd: beoordeel een compilerprobe op het geproduceerde artefact of op de eigen summaryregel van de compiler, nooit door in de output op een keyword te greppen. stderr op Error doorzoeken is een heuristiek die faalt in de richting die je je niet kunt veroorloven: stilletjes succes rapporteren

Wat kost een decennium aan compilers echt?

De eerlijke boekhouding is dat de codewijzigingen hier triviaal zijn en de proceswijzigingen niet. Vier van de vijf afwijzingen zijn opgelost door gewonere Pascal te schrijven en niet door version machinery toe te voegen: laat een cast vallen, deel in plaats van te casten, geef nil een type en declareer een accessorclass. Alleen GdiplusStartup verdiende een {$IF}. Een codebase van XE5 tot de huidige release wordt geen struikgewas van conditional defines tenzij je harde casts en idiomen van de nieuwste compiler zich in eerste instantie laat opstapelen

Wat het werkelijk kost is buildtijd en discipline. Drieënveertig legs is een traag script, precies waarom het naar packagingtijd en daarna naar nooit draaien is afgedreven. De verdedigbare middenweg is de snelle set van vier scripts voor iteratie te behouden en de volledige matrix volgens een schema te draaien dat niet kan worden overgeslagen, want de faalmodus is niet een kapotte build die je opmerkt, maar een ondersteunde IDE die twaalf releases geleden stilletjes ophield ondersteund te zijn

Die verplichting is de keerzijde van überhaupt een native component leveren. HotXLS leest en schrijft XLS, XLSX en ODS alleen via Object Pascal, zonder Excel-installatie en zonder COM-dependency, wat Office-free workbook automation op een vergrendelde server mogelijk maakt. Diezelfde eigenschap betekent dat de compiler het hele platformcontract is, dus elke versie in de matrix is een belofte die opnieuw moet worden geverifieerd en niet mag worden aangenomen

De cross-compiler-buildmatrix en de versieveilige code die hier wordt besproken worden geleverd als onderdeel van de HotXLS Delphi Spreadsheet Component, die Delphi en C++Builder van XE5 tot en met de huidige release ondersteunt met voorgebouwde librarybinaries voor elke ondersteunde IDE