Технічна стаття

Cross-compiler build matrix Delphi: HotXLS від XE5

HotXLS постачає одну Object Pascal codebase для кожного release Delphi та C++Builder, починаючи з XE5, а build-All-Lib-TRIAL.cmd — script, який це доводить: 43 build legs, що охоплюють 12 Delphi versions на Win32 і Win64, плюс 10 C++Builder Win32 та 9 Win64 package builds. Від v2.363 до v2.374 цей script жодного разу не запускався до завершення, і XE5 leg весь цей час був broken

Щойно failure побачили, нічого subtle в ньому не залишилося. П’ять різних constructs, які current compiler приймає без comment, є hard errors у RAD Studio XE5, який build matrix позначає як 12.0. Release v2.375.0 виправив усі п’ять, і matrix знову стала green: 43 із 43. Далі йде кожна rejection, чому старий compiler, можливо, має рацію щодо двох type-based rejections, і ще більш незручна частина: probe script, написаний для діагностики цього безладу, під час першого запуску повідомив false pass

Чому XE5 leg згнив так, що ніхто не помітив?

XE5 leg згнив, бо повсякденна development run-ила лише four-script set для 37.0, а green local build нічого не каже про compiler, який ви не викликали. Full matrix — окремий slow script, який trial installer викликає перед тим, як Inno Setup збирає files, тому він exercise-иться під час packaging, а не під час commit. Дванадцять releases помістилися в цю gap

Leg arithmetic варто розписати, бо саме там живе coverage illusion. DELPHI_TRIAL_VERSIONS перелічує 12.0–37.0, і кожна з цих 12 versions build-иться двічі, Win32 та Win64. CB_TRIAL_WIN32_VERSIONS містить 10 versions, а CB_TRIAL_WIN64_VERSIONS лише 9, бо XE5 має C++Builder package project, але не постачає Win64 package startup object c0pkg64.o. Twelve plus twelve plus ten plus nine — 43. Запустити чотири з них і назвати codebase portable — category error, і саме ця помилка дозволила цьому статися

HotXLS уже зустрічав ту саму форму проблеми з протилежного боку. New unit, до якого веде uses clause, але якого немає у file list .cbproj, чудово компілюється в Delphi, бо dcc implicit pull-ить unlisted units у package і в гіршому разі видає W1033 hint. C++Builder створює .obj лише для units, названих у <DelphiCompile>, тому той самий code вмирає на ilink stage з unresolved external. Один toolchain ховає те, що ловить інший. У цьому й весь аргумент за запуск matrix замість довіри representative compiler

Hard type casts, які відхиляють старі Win32 compilers

Дві з п’яти rejections — одна й та сама bug у різному одязі: hard type cast застосовано до floating-point expression, а не до variable. На Win32 старі compilers виконують arithmetic через x87 stack, тому addition із Double зберігається з 80-bit excess precision, а static type стає 10-byte Extended. Cast 10 bytes у 8-byte TDateTime є illegal typecast, і compiler повідомляє про це як E2089 Invalid typecast

Найбільш дратівлива detail — variable form працює. TDateTime(Serial) компілюється в кожній version matrix, бо Serial уже має 8 bytes, і cast зберігає size. Додайте щось до нього — і expression розширюється під вами. Fix — не ширший cast і не conditional define, а повна відмова від cast: implicit real-to-real assignment правильно виконує conversion на кожному compiler, який підтримує HotXLS, і каже саме те, що означає code

// Відхилено в XE5 (Win32): кожне addition обчислюється як 10-byte
// Extended, а narrowing cast 10-to-8 піднімає E2089
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // цей accepted: без addition

// Version-safe: доручити real-to-real assignment зробити conversion
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// Та сама class rejection у cell value packer: hard Double cast
// integer. Замість нього divide — operator уже повертає real
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portable
  ;

Branch Serial < 60 — fiction про leap year 1900, а не off-by-one: serial 60 — неіснуючий 1900-02-29 у Excel, тому serials нижче нього потребують extra day, перш ніж DecodeDate їх побачить. Portability work не має тихо змінювати таку logic, саме тому safe edit прибирає cast і залишає arithmetic без змін

Що ламається, коли nil є procedural argument?

Голий nil, переданий там, де очікується procedural type, не прив’язується під час overload resolution на старих compilers. Call site у HotXLS — ResolveIndexedColor, який overloaded і приймає callback TXLSTryResolveSystemColor, непотрібний більшості callers. Newer compilers розв’язують nil проти procedural parameter і вибирають правильний overload. XE5 цього не робить, а diagnostic вказує на overload set, а не на argument, — так ви й втрачаєте двадцять хвилин

Portable answer — надати null callback type. Unit-level variable procedural type language zero-initializes, тому він уже nil без initializer і несе type information, якого потребує старий resolver. Коли unit-level variable була б зайвою, typed local, якому присвоєно nil, робить те саме

var
  // Nil procedural literal не прив’язується під час overload resolution
  // старих compilers; typed, zero-initialized variable прив’язується
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

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

// Та сама fix із typed local у 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;

Зауважте: це справжня language-level difference, а не compiler bug, яку варто обходити defines. Zero-initialized variable правильна в кожній version matrix і коштує one line, тому тут узагалі немає conditional compilation. Звертайтеся до {$IF CompilerVersion} лише коли platform справді відрізняється між releases, а в цьому batch це саме один випадок

Protected VCL methods переміщуються між releases

TPicture.LoadFromStream є public у current VCL і protected у старіших versions, які підтримує HotXLS, тому direct call тепер компілюється, а там — ні. HotXLS використовує його, щоб перевірити, що worksheet background image payload справді decodes, signature check, який виконується до того, як HTML exporter commit-ить embedding bytes. Класична Pascal answer тут підходить: оголосити descendant у тому самому unit лише для widening visibility і зробити cast через нього в call site

type
  // TPicture.LoadFromStream protected у старих VCL versions, які
  // підтримує library; same-unit descendant exposes його
  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);

Accessor-class trick безпечний тут, бо descendant не додає fields і ніколи не instantiate-иться; cast лише змінює те, що compiler дозволить назвати. Але comment біля declaration усе одно корисний, бо reader, який build-ить лише на current IDE, інакше побачить безглуздий type. Background image handling знову з’являється в custom VCL grid rendering path, де той самий decoded payload живить on-screen sheet

Тип token GdiplusStartup змінювався двічі

Єдина rejection у batch, яка справді потребує conditional compilation, — type var parameter у GdiplusStartup, який змінювався між VCL generations так, що жодне одне spelling не є valid всюди. Version-by-version probing зафіксувало фактичну behavior: legs 12.0–20.0 приймають лише Cardinal, legs 21.0 і 22.0 — лише THandle або ULONG_PTR, а 23.0 і 37.0 приймають обидва. У release names це Cardinal від XE5 до 10.3 Rio і THandle від 10.4 Sydney. Оскільки accepting ranges не перетинаються у 12.0–22.0, unconditional declaration не працює: guard перевіряє CompilerVersion >= 34, що означає Sydney, а call повністю qualified як Winapi.GDIPAPI.GdiplusStartup, щоб unit resolution order не підставив іншу declaration на якійсь version посеред range

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // GDIPAPI GdiplusStartup var-parameter type залежить від VCL
  // generation: Cardinal через Rio, THandle від 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;

Це TIFF branch page image exporter, тому blast radius помилки — уся raster export surface, включно з paths, описаними в експорті cell range як single image. Також зверніть увагу, чого guard не стверджує: ULONG_PTR і THandle мають однакову width на обох platforms, тому вибір стосується того, яке identifier називає declaration, а не 32-bit проти 64-bit correctness

Чому перший probe run нічого не повідомив?

Version probe на першому run нічого не повідомив, бо assignments res=$(...) виконувалися всередині subshell, де не передавалися до parent. dcc32 виходить із code 0 у разі success, тому exit code був правильним signal для capture, але script зберігав його у variable, яка зникала рядком пізніше. Кожен leg повертав empty, і output виглядав як probe, що нічого не компілював, — саме так і було

Другий failure був гіршим, бо дав wrong answer замість no answer. Probe класифікував leg за кількістю lines, що match-или Error, а Delphi не prefix-ить цим word кожен fatal. F1026 File not found є fatal, але не match-иться, тому probe, який взагалі не міг resolve unit, отримував clean pass. XE5 не постачає Winapi.GDIPOPS.dcu, перший probe натрапив саме на це і falsely went green. Rule, який із цього вийшов, вузький, але його варто проговорити: оцінюйте compiler probe за produced artifact або власним summary line compiler, ніколи не grep-айте output за keyword. Grepping stderr на Error — heuristic, що провалюється саме в напрямку, який не можна собі дозволити: silently report-ить success

Скільки насправді коштує підтримка десятиліття compilers

Чесний accounting такий: code changes тут trivial, а process changes — ні. Four із five rejections виправили написанням більш ordinary Pascal, а не version machinery: прибрати cast, divide замість casting, надати nil type, оголосити accessor class. Лише GdiplusStartup заслужив {$IF}. Codebase, що охоплює XE5 до current release, не перетвориться на thicket conditional defines, якщо не дозволяти hard casts і newest-compiler idioms накопичуватися з самого початку

Справжня cost — build time та discipline. Forty-three legs — slow script, саме тому він сповз до packaging time, а потім до never. Defensible middle ground — залишити fast four-script loop для iteration і запускати full matrix за schedule, який не можна пропустити, бо failure mode — не broken build, який ви помічаєте, а supported IDE, що тихо перестала бути supported twelve releases тому

Це зобов’язання — зворотний бік постачання native component взагалі. HotXLS читає та записує XLS, XLSX і ODS лише через Object Pascal, без Excel install і COM dependency, що й робить Office-free workbook automation можливою на locked-down server. Та сама властивість означає, що compiler є всім platform contract, тому кожна version у matrix — promise, яку треба re-verify, а не assume

Cross-compiler build matrix і version-safe code, обговорені тут, постачаються як частина HotXLS Delphi Spreadsheet Component, який підтримує Delphi та C++Builder від XE5 до current release із prebuilt library binaries для кожної supported IDE