Technischer Artikel

Delphi-Cross-Compiler-Matrix: HotXLS seit XE5

HotXLS liefert eine Object-Pascal-Codebasis für jede Delphi- und C++Builder-Version ab XE5, und build-All-Lib-TRIAL.cmd beweist das: 43 Build-Läufe mit 12 Delphi-Versionen unter Win32 und Win64 sowie 10 C++Builder-Win32- und 9-Win64-Package-Builds. Von v2.363 bis v2.374 wurde dieses Skript nie vollständig ausgeführt, und der XE5-Lauf war die ganze Zeit kaputt

Sobald man den Fehler sah, war nichts daran subtil. Fünf unterschiedliche Konstrukte, die der aktuelle Compiler kommentarlos akzeptiert, sind unter RAD Studio XE5 harte Fehler, den die Build-Matrix als 12.0 bezeichnet. Die Version 2.375.0 behob alle fünf, und die Matrix war mit 43 von 43 wieder grün. Im Folgenden geht es um jede Zurückweisung, darum, warum der alte Compiler bei den beiden typbezogenen Fällen zumindest vertretbarerweise Recht hat, und um den peinlicheren Teil: Das zum Diagnostizieren geschriebene Probe-Skript meldete beim ersten Lauf einen falschen Erfolg

Warum verrottete der XE5-Lauf, ohne dass es jemand bemerkte?

Der XE5-Lauf verrottete, weil die tägliche Entwicklung nur den Vier-Skript-Satz für 37.0 ausführte, und ein grüner lokaler Build nichts über einen Compiler sagt, den man nicht aufgerufen hat. Die vollständige Matrix ist ein eigenes, langsames Skript, das der Trial-Installer vor dem Einsammeln der Dateien durch Inno Setup aufruft. Sie wird also beim Packaging und nicht beim Commit ausgeführt. Zwölf Releases passten in diese Lücke

Die Arithmetik der Läufe lohnt sich, weil darin die Abdeckungsillusion steckt. DELPHI_TRIAL_VERSIONS listet 12.0 bis 37.0 auf, und jede dieser 12 Versionen wird zweimal gebaut, für Win32 und Win64. CB_TRIAL_WIN32_VERSIONS enthält 10 Versionen, CB_TRIAL_WIN64_VERSIONS nur 9, weil XE5 ein C++Builder-Packageprojekt besitzt, aber kein Win64-Package-Startobjekt c0pkg64.o liefert. Zwölf plus zwölf plus zehn plus neun ist 43. Vier davon auszuführen und die Codebasis portabel zu nennen, ist ein Kategorienfehler – und genau dieser Fehler ließ das hier geschehen

HotXLS wurde von derselben Problemform auch in der Gegenrichtung getroffen. Eine neue Unit, die über eine uses-Klausel erreichbar, aber in der Dateiliste der .cbproj fehlt, kompiliert unter Delphi problemlos, weil dcc nicht gelistete Units implizit in das Package zieht und höchstens einen Hinweis W1033 ausgibt. C++Builder erzeugt ein .obj nur für Units, die in <DelphiCompile> benannt sind. Derselbe Code scheitert daher in der ilink-Phase mit einem unresolved external. Eine Toolchain verbirgt, was die andere findet. Genau deshalb muss man die Matrix ausführen und darf sich nicht auf einen repräsentativen Compiler verlassen

Harte Typcasts, die die alten Win32-Compiler ablehnen

Zwei der fünf Zurückweisungen sind derselbe Fehler in unterschiedlicher Kleidung: Ein harter Typecast wird auf einen Fließpunktausdruck statt auf eine Variable angewendet. Unter Win32 werten die älteren Compiler Arithmetik über den x87-Stack aus, daher wird eine Addition mit einem Double in 80-Bit-Extrapräzision berechnet und ihr statischer Typ wird zum 10 Byte großen Extended. Das Heruntercasten von 10 Bytes auf ein 8 Byte großes TDateTime ist kein legaler Typecast, und der Compiler meldet E2089 Invalid typecast

Das ärgerliche Detail ist, dass die Variablenform in Ordnung ist. TDateTime(Serial) kompiliert in jeder Matrixversion, weil Serial bereits 8 Bytes groß ist und der Cast die Größe beibehält. Addiert man etwas, wird der Ausdruck darunter breiter. Die Lösung ist kein breiterer Cast und kein bedingtes Define, sondern den Cast zu entfernen: Eine implizite Real-zu-Real-Zuweisung konvertiert auf jedem von HotXLS unterstützten Compiler korrekt und sagt aus, was der Code tatsächlich meint

// Unter XE5 (Win32) abgelehnt: Jede Addition wird als 10 Byte großes
// Extended ausgewertet, der Cast von 10 auf 8 Bytes löst E2089 aus
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // akzeptiert: keine Addition

// Versionssicher: Die Real-zu-Real-Zuweisung soll konvertieren
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// Dieselbe Art Zurückweisung im Cell-Value-Packer: ein harter Double-Cast
// eines Integers. Stattdessen teilen – der Operator liefert bereits eine Realzahl
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portabel
  ;

Der Zweig Serial < 60 ist die Schaltjahrfiktion von 1900 und kein Off-by-one: Die Seriennummer 60 ist Excels nicht existierender 29.02.1900, daher brauchen Serien darunter den zusätzlichen Tag, bevor DecodeDate sie sieht. Portabilitätsarbeit darf solche Logik niemals still ändern. Genau deshalb entfernt die sichere Änderung nur den Cast und lässt die Arithmetik unverändert

Was bricht, wenn nil ein prozedurales Argument ist?

Ein nacktes nil an einer Stelle, an der ein prozeduraler Typ erwartet wird, kann bei den älteren Compilern während der Overload-Auflösung nicht gebunden werden. Die Aufrufstelle in HotXLS ist ResolveIndexedColor, überladen und mit einem Callback vom Typ TXLSTryResolveSystemColor, den die meisten Aufrufer nicht benötigen. Neuere Compiler lösen nil gegen den prozeduralen Parameter auf und wählen die richtige Überladung. XE5 tut das nicht, und die Diagnose zeigt auf die Overload-Menge statt auf das Argument. So sind zwanzig Minuten schnell verloren

Die portable Antwort ist, dem Null-Callback einen Typ zu geben. Eine Variable dieses prozeduralen Typs auf Unit-Ebene wird von der Sprache auf null initialisiert, ist also bereits nil ohne Initializer, und trägt die Typinformation, die der alte Resolver benötigt. Wo eine Unit-Variable übertrieben wäre, tut eine typisierte lokale Variable, die auf nil gesetzt wird, dasselbe

var
  // Ein prozedurales Nil-Literal wird bei der Overload-Auflösung älterer
  // Compiler nicht gebunden; eine typisierte, nullinitialisierte Variable schon
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

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

// Dieselbe Korrektur mit einer typisierten lokalen Variable in der XLSX-Arbeitsmappe
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;

Das ist ein echter Unterschied auf Sprachebene und kein Compilerbug, den man mit Defines umschiffen sollte. Die nullinitialisierte Variable ist in jeder Matrixversion korrekt und kostet eine Zeile, daher gibt es hier überhaupt keine bedingte Kompilierung. Greifen Sie auf {$IF CompilerVersion} nur zurück, wenn sich die Plattform zwischen Releases wirklich unterscheidet – genau einmal ist das in diesem Batch der Fall

Geschützte VCL-Methoden wandern zwischen Releases

TPicture.LoadFromStream ist in der aktuellen VCL public und in den älteren von HotXLS unterstützten Versionen protected; ein direkter Aufruf kompiliert jetzt und scheitert dort. HotXLS verwendet die Methode, um zu validieren, dass ein Worksheet-Background-Image-Payload tatsächlich dekodiert, eine Signaturprüfung, die vor der Entscheidung des HTML-Exporters zum Einbetten der Bytes läuft. Die klassische Pascal-Antwort greift: Deklarieren Sie in derselben Unit einen Nachkommen, nur um die Sichtbarkeit zu erweitern, und casten Sie an der Aufrufstelle über ihn

type
  // TPicture.LoadFromStream ist in den älteren unterstützten VCL-Versionen
  // protected; ein Nachkomme in derselben Unit macht es sichtbar
  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);

Der Accessor-Class-Trick ist hier sicher, weil der Nachkomme keine Felder hinzufügt und nie instanziiert wird; der Cast ändert nur, welche Namen der Compiler zulässt. Ein Kommentar an der Deklaration bleibt trotzdem sinnvoll, denn wer ausschließlich auf einer aktuellen IDE baut, würde sonst einen scheinbar nutzlosen Typ sehen. Background-Image-Verarbeitung erscheint erneut im benutzerdefinierten VCL-Grid-Renderingpfad, wo derselbe dekodierte Payload das sichtbare Sheet speist

Der Typ des GdiplusStartup-Tokens änderte sich zweimal

Die einzige Zurückweisung dieses Batches, die tatsächlich bedingte Kompilierung verlangt, ist der var-Parametertyp von GdiplusStartup. Er änderte sich über mehrere VCL-Generationen so, dass keine Schreibweise überall gültig ist. Eine Versionsprüfung nach Version ergab das tatsächliche Verhalten: Die Läufe 12.0 bis 20.0 akzeptieren nur Cardinal, 21.0 und 22.0 nur THandle oder ULONG_PTR, 23.0 und 37.0 beide. Nach Release-Namen heißt das: Cardinal von XE5 bis 10.3 Rio und ab 10.4 Sydney THandle. Weil sich die beiden akzeptierenden Bereiche von 12.0 bis 22.0 nicht überschneiden, funktioniert keine bedingungslose Deklaration. Die Abfrage hängt an CompilerVersion >= 34, also Sydney, und der Aufruf wird vollständig als Winapi.GDIPAPI.GdiplusStartup qualifiziert, damit die Unit-Auflösungsreihenfolge nicht in einer Zwischenversion eine andere Deklaration einschiebt

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // Der var-Parametertyp von GDIPAPI.GdiplusStartup folgt der VCL-
  // Generation: Cardinal bis Rio, THandle ab 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');
  // ... encode ...
end;

Das ist der TIFF-Zweig des Seitenbild-Exporters, daher umfasst der Schadensradius die gesamte Rasterexportfläche einschließlich der in dem Export eines Zellbereichs als ein einziges Bild beschriebenen Pfade. Beachten Sie auch, was das Guard nicht behauptet: ULONG_PTR und THandle sind auf beiden Plattformen gleich breit, daher geht es bei der Auswahl um den Namen, den die Deklaration verwendet, und nicht um die 32-/64-Bit-Korrektheit

Warum meldete der erste Probe-Lauf nichts?

Die Versionsprobe meldete beim ersten Durchlauf nichts, weil Zuweisungen wie res=$(...) in einer Subshell ausgeführt wurden und dort nicht an den Elternprozess weitergegeben werden. dcc32 beendet sich bei Erfolg mit 0, daher war der Exitcode das richtige Signal. Das Skript speicherte ihn aber in einer Variable, die eine Zeile später nicht mehr existierte. Jeder Lauf kam leer zurück, und die Ausgabe sah wie eine Probe aus, die überhaupt nichts kompiliert hatte – genau das war sie

Der zweite Fehler war schlimmer, weil er statt keiner Antwort eine falsche lieferte. Die Probe klassifizierte einen Lauf, indem sie Zeilen mit Error zählte, und Delphi stellt nicht jedem fatalen Fehler dieses Wort voran. F1026 File not found ist fatal und passt nicht. Eine Probe, die eine Unit überhaupt nicht auflösen konnte, wurde deshalb als sauberer Erfolg gewertet. XE5 liefert Winapi.GDIPOPS.dcu nicht aus, die erste Probe traf genau darauf und wurde fälschlich grün. Die daraus entstandene Regel ist eng und eindeutig: Beurteilen Sie eine Compilerprobe anhand des erzeugten Artefakts oder der eigenen Summary-Zeile des Compilers und nie durch Grep der Ausgabe nach einem Schlüsselwort. Nach Error in stderr zu suchen, ist eine Heuristik, die genau in die Richtung versagt, die man nicht gebrauchen kann: Sie meldet still Erfolg

Was kostet die Unterstützung eines Jahrzehnts an Compilern tatsächlich?

Die ehrliche Bilanz ist, dass die Codeänderungen hier trivial und die Prozessänderungen nicht trivial sind. Vier der fünf Zurückweisungen wurden durch gewöhnlicheres Pascal behoben und nicht durch Versionsmechanik: Cast entfernen, statt zu casten teilen, nil einen Typ geben, eine Accessor-Klasse deklarieren. Nur GdiplusStartup verdiente ein {$IF}. Eine Codebasis von XE5 bis zur aktuellen Version wird nicht zwangsläufig zu einem Dickicht bedingter Defines, solange man harte Casts und Idiome des neuesten Compilers nicht zuerst ansammelt

Was wirklich kostet, sind Buildzeit und Disziplin. 43 Läufe sind ein langsames Skript, genau deshalb wanderte es in die Packaging-Zeit und schließlich ins Nie. Der vertretbare Mittelweg ist ein schnelles Vier-Skript-Set für die Iteration und eine vollständige Matrix nach einem Zeitplan, der nicht übersprungen werden kann. Der Fehlerfall ist kein kaputter Build, den man bemerkt, sondern eine unterstützte IDE, die vor zwölf Releases still aufgehört hat, unterstützt zu sein

Diese Pflicht ist die Kehrseite davon, überhaupt eine native Komponente auszuliefern. HotXLS liest und schreibt XLS, XLSX und ODS ausschließlich über Object Pascal, ohne Excel-Installation und ohne COM-Abhängigkeit. Genau das ermöglicht arbeitsmappenbezogene Automatisierung ohne Office auf einem gesperrten Server. Dieselbe Eigenschaft macht den Compiler zum gesamten Plattformvertrag, daher ist jede Version in der Matrix ein Versprechen, das neu verifiziert und nicht angenommen werden muss

Die Cross-Compiler-Build-Matrix und der versionssichere Code aus diesem Artikel gehören zur HotXLS Delphi Spreadsheet Component, die Delphi und C++Builder von XE5 bis zum aktuellen Release mit vorgebauten Bibliotheks-Binaries für jede unterstützte IDE unterstützt