Техническая статья

Матрица сборки Delphi для компиляторов: HotXLS с XE5

HotXLS поставляет одну кодовую базу Object Pascal для каждого выпуска Delphi и C++Builder начиная с XE5, а build-All-Lib-TRIAL.cmd — скрипт, который это доказывает: 43 build legs, охватывающих 12 версий Delphi в Win32 и Win64, а также 10 package builds C++Builder Win32 и 9 Win64. С v2.363 по v2.374 этот скрипт ни разу не проходил до конца, и leg XE5 всё это время оставался сломанным

После обнаружения ничего тонкого в сбое не осталось. Пять разных конструкций, которые текущий compiler принимает без комментариев, являются hard errors в RAD Studio XE5, которое build matrix обозначает как 12.0. Релиз v2.375.0 исправил все пять, и matrix снова стала зелёной: 43 из 43. Ниже разобраны все отказы, причины, по которым старый compiler, возможно, прав насчёт двух ошибок на уровне типов, и более неловкая часть: probe script, написанный для диагностики, при первом запуске сообщил ложный успех

Почему leg XE5 испортился, а никто этого не заметил?

Leg XE5 испортился, потому что повседневная разработка запускала только набор из четырёх скриптов для 37.0, а зелёная локальная сборка ничего не говорит о compiler, который вы не вызывали. Полная matrix — отдельный медленный скрипт, который trial installer запускает перед тем, как Inno Setup собирает файлы, поэтому он проверяется во время packaging, а не во время commit. В этот зазор поместились двенадцать релизов

Арифметику leg стоит расписать, потому что именно здесь живёт иллюзия покрытия. DELPHI_TRIAL_VERSIONS перечисляет версии от 12.0 до 37.0, и каждая из этих 12 версий собирается дважды — Win32 и Win64. CB_TRIAL_WIN32_VERSIONS содержит 10 версий, а CB_TRIAL_WIN64_VERSIONS — только 9, поскольку XE5 имеет C++Builder package project, но не поставляет Win64 package startup object c0pkg64.o. Двенадцать плюс двенадцать плюс десять плюс девять дают 43. Запустить четыре из них и назвать codebase переносимым — логическая ошибка категорий, и именно она позволила случиться этому дефекту

HotXLS уже сталкивался с таким же перекосом в обратную сторону. Новый unit, достижимый через клаузу uses, но отсутствующий в списке файлов .cbproj, прекрасно компилируется в Delphi, потому что dcc неявно подтягивает незаписанные units в package и в худшем случае выдаёт hint W1033. C++Builder создаёт .obj только для units, названных в <DelphiCompile>, поэтому тот же код умирает на стадии ilink с unresolved external. Одна toolchain скрывает то, что ловит другая. В этом весь аргумент за запуск matrix, а не за доверие к representative compiler

Жёсткие type cast, которые отвергают старые Win32 compilers

Два из пяти отказов — одна и та же ошибка в разной одежде: hard type cast применяется к floating-point expression, а не к переменной. В Win32 старые compilers вычисляют arithmetic через x87 stack, поэтому сложение с Double выполняется с 80-битной excess precision, а static type выражения становится 10-байтным Extended. Приведение 10 байт к 8-байтному TDateTime не является допустимым typecast, и compiler сообщает E2089 Invalid typecast

Самая раздражающая деталь в том, что форма с переменной корректна. TDateTime(Serial) компилируется во всех версиях matrix, потому что Serial уже занимает 8 байт и cast сохраняет размер. Добавьте что-нибудь — и выражение расширяется под вами. Исправление не в более широком cast и не в conditional define, а в отказе от cast: неявное присваивание real-to-real правильно выполняет преобразование во всех поддерживаемых HotXLS compilers и говорит именно то, что реально означает код

// Отклоняется в XE5 (Win32): каждое сложение вычисляется как 10-байтный
// Extended, и сужающий cast 10 к 8 вызывает E2089
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // этот вариант принят: сложения нет

// Безопасный для версий вариант: пусть присваивание real-to-real выполнит преобразование
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// Такой же класс отказа в cell value packer: жёсткий cast Double
// целого числа. Делить вместо этого — оператор уже выдаёт real
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portable
  ;

Ветка Serial < 60 — фикция високосного года 1900, а не off-by-one: serial 60 — несуществующий 1900-02-29 в Excel, поэтому serial меньше него нужен дополнительный день до того, как DecodeDate их увидит. Работа над portability никогда не должна молча менять такую логику, поэтому безопасное редактирование здесь убирает cast, оставляя arithmetic нетронутой

Что ломается, когда nil является процедурным аргументом?

Обычный nil, переданный туда, где ожидается procedural type, не привязывается во время overload resolution в старых compilers. Call site в HotXLS — ResolveIndexedColor, который перегружен и принимает callback TXLSTryResolveSystemColor, не нужный большинству вызывающих. Новые compilers разрешают nil для procedural parameter и выбирают правильный overload. XE5 этого не делает, а диагностика указывает на overload set, а не на аргумент, поэтому легко потерять двадцать минут

Переносимый ответ — придать null callback тип. Переменная unit level процедурного типа инициализируется языком нулём, поэтому уже является nil без initializer и несёт type information, которую хочет старый resolver. Если переменная уровня unit была бы избыточной, typed local, присвоенный nil, даёт тот же результат

var
  // Процедурный литерал nil не привязывается в overload resolution
  // старых compilers; typed-переменная с нулевой инициализацией привязывается
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

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

// То же исправление с 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;

Заметьте, что это реальное различие на уровне языка, а не compiler bug, который стоит обходить defines. Переменная с нулевой инициализацией корректна во всех версиях matrix и стоит одной строки, поэтому conditional compilation здесь вообще не нужен. Обращайтесь к {$IF CompilerVersion} только когда платформа действительно отличается между релизами, а в этой batch такой случай ровно один

Protected VCL methods перемещаются между релизами

TPicture.LoadFromStream является public в текущем VCL и protected в старых версиях, которые поддерживает HotXLS, поэтому прямой вызов сейчас компилируется, а тогда — нет. HotXLS использует его, чтобы проверить, что payload фонового изображения worksheet действительно декодируется, до того как HTML exporter решит встраивать байты. Здесь применяется классический ответ Pascal: объявить descendant в том же unit исключительно для расширения visibility и привести через него в call site

type
  // TPicture.LoadFromStream является protected в старых версиях VCL,
  // которые поддерживает library; descendant того же unit открывает его
  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 безопасен, потому что descendant не добавляет полей и никогда не инстанцируется; cast лишь меняет то, что compiler разрешит назвать. Но комментарий у declaration всё равно полезен, поскольку reader, который всегда собирает на current IDE, иначе увидит бессмысленный type. Обработка background image снова встречается в пути рендеринга custom VCL grid, где тот же decoded payload питает sheet на экране

Тип токена GdiplusStartup менялся дважды

Единственный отказ batch, действительно требующий conditional compilation, связан с типом var-параметра GdiplusStartup, который между поколениями VCL изменился так, что одного написания для всех версий нет. Пошаговое probing по версиям зафиксировало реальное поведение: legs 12.0–20.0 принимают только Cardinal, legs 21.0 и 22.0 — только THandle или ULONG_PTR, а 23.0 и 37.0 принимают оба. В названиях релизов это Cardinal от XE5 до 10.3 Rio и THandle начиная с 10.4 Sydney. Поскольку два принимающих диапазона не пересекаются для 12.0–22.0, безусловное declaration не работает: guard проверяет CompilerVersion >= 34, то есть Sydney, а вызов полностью квалифицирован как Winapi.GDIPAPI.GdiplusStartup, чтобы порядок разрешения units не подменил declaration на одной из промежуточных версий

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // Тип var-параметра GdiplusStartup в GDIPAPI следует поколению VCL:
  // 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 page image exporter, поэтому blast radius ошибки — вся поверхность raster export, включая пути, описанные в статье о экспорте диапазона ячеек как одного изображения. Заметьте также, чего guard не утверждает: ULONG_PTR и THandle имеют одинаковую ширину на обеих платформах, поэтому выбор касается имени, которое использует declaration, а не корректности 32-bit против 64-bit

Почему первый запуск probe сообщил ничего?

Version probe при первом запуске не сообщил ничего, потому что присваивания res=$(...) выполнялись внутри subshell и не передавались родителю. dcc32 завершается с кодом 0 при успехе, поэтому код выхода был правильным сигналом для захвата, а script сохранял его в переменную, которой строкой позже уже не существовало. Каждый leg вернулся пустым, и output выглядел как probe, который ничего не компилировал, что ровно так и было

Второй failure был хуже, потому что дал неправильный ответ, а не отсутствие ответа. Probe классифицировал leg по числу строк, совпадавших с Error, а Delphi не ставит это слово перед каждой fatal error. F1026 File not found является fatal и не совпадает, поэтому probe, который вообще не мог разрешить unit, получил оценку clean pass. XE5 не поставляет Winapi.GDIPOPS.dcu, первый probe ровно в него и упёрся, но стал falsely green. Получившееся правило узкое и его стоит сформулировать прямо: оценивайте compiler probe по созданному artifact или по собственной summary line compiler, никогда не grep-айте output по keyword. Grep stderr на Error — эвристика, которая ломается именно в направлении, которое нельзя себе позволить, молча сообщая об успехе

Сколько на самом деле стоит поддержка десятилетия compilers

Честный итог в том, что изменения кода здесь тривиальны, а изменения процесса — нет. Четыре из пяти отказов исправлены обычным Pascal, а не добавлением version machinery: убрать cast, выполнить division вместо cast, придать nil тип, объявить accessor class. Только GdiplusStartup заслужил {$IF}. Codebase, охватывающий XE5 до текущего release, не превращается в чащу conditional defines, если изначально не позволять накапливаться hard cast и idiom-ам нового compiler

Настоящая цена — время сборки и дисциплина. Сорок три legs — медленный script, именно поэтому он сначала сдвинулся на packaging, а потом перестал запускаться вообще. Защищаемая середина — сохранить быстрый цикл из четырёх scripts для итераций и запускать полную matrix по расписанию, которое нельзя пропустить, потому что failure mode — не сломанная сборка, которую вы замечаете, а поддерживаемая IDE, которая тихо перестала поддерживаться двенадцать релизов назад

Это обязательство — обратная сторона поставки native component. HotXLS читает и записывает XLS, XLSX и ODS только через Object Pascal, без установленного Excel и без COM dependency, что делает возможной автоматизацию workbook без Office на заблокированном сервере. То же свойство означает, что compiler — весь platform contract, поэтому каждая версия в matrix является обещанием, которое нужно перепроверять, а не предполагать

Cross-compiler build matrix и version-safe code, обсуждённые здесь, входят в HotXLS Delphi Spreadsheet Component, поддерживающий Delphi и C++Builder от XE5 до текущего release с готовыми library binary для каждой поддерживаемой IDE