기술 문서

Delphi 크로스 컴파일 빌드 매트릭스: XE5 이후 HotXLS

HotXLS는 XE5 이후 모든 Delphi와 C++Builder release에 하나의 Object Pascal codebase를 제공하며 build-All-Lib-TRIAL.cmd가 이를 증명하는 script입니다. Win32와 Win64의 12 Delphi version, C++Builder Win32 package build 10개와 Win64 package build 9개를 합쳐 43 build leg를 수행합니다. v2.363부터 v2.374까지 이 script는 한 번도 끝까지 실행되지 않았고 XE5 leg는 내내 깨져 있었습니다

한번 드러나고 나면 failure는 미묘하지 않았습니다. current compiler가 아무 말 없이 accept하는 다섯 가지 construct가 build matrix에서 12.0으로 표시되는 RAD Studio XE5에서는 hard error였습니다. v2.375.0 release에서 다섯 가지를 모두 수정했고 matrix는 43/43 green으로 돌아왔습니다. 이어지는 내용은 각각의 rejection과 type 관점에서 old compiler가 거부한 두 항목이 어쩌면 옳았던 이유, 그리고 더 당황스러운 부분인 문제를 진단하기 위해 쓴 probe script가 첫 실행에서 false pass를 보고한 이유입니다

아무도 알아차리지 못한 채 XE5 leg가 썩은 이유

XE5 leg가 썩은 것은 일상 개발이 37.0 네 script set만 실행했고 local build가 green이라는 사실은 호출하지 않은 compiler에 대해 아무것도 말해 주지 않기 때문입니다. full matrix는 별도의 느린 script이며 trial installer가 Inno Setup이 file을 모으기 전에 호출하므로 commit 시점이 아니라 packaging 시점에 실행됩니다. 그 간극에 release 열두 개가 들어갔습니다

coverage illusion이 생기는 곳이므로 leg arithmetic을 적어 둘 가치가 있습니다. DELPHI_TRIAL_VERSIONS는 12.0부터 37.0까지 12 version을 열거하고 각각을 Win32와 Win64로 두 번 build합니다. CB_TRIAL_WIN32_VERSIONS는 10 version을 나열하고 CB_TRIAL_WIN64_VERSIONS는 9개뿐입니다. XE5에는 C++Builder package project는 있지만 Win64 package startup object c0pkg64.o가 없기 때문입니다. 12 더하기 12 더하기 10 더하기 9는 43입니다. 그중 네 개만 실행하고 codebase가 portable하다고 부르는 것은 category error이며 이번 일을 가능하게 한 정확한 오류입니다

HotXLS는 반대 방향에서도 같은 모양의 문제를 겪었습니다. uses 절을 통해 도달할 수 있지만 .cbproj file list에는 없는 새 unit은 Delphi에서 완벽히 compile됩니다. dcc가 나열되지 않은 unit을 package로 implicit하게 끌어오고 최악의 경우 W1033 hint를 내기 때문입니다. C++Builder는 <DelphiCompile>에 이름을 올린 unit에 대해서만 .obj를 내므로 같은 code가 ilink 단계의 unresolved external로 죽습니다. 한 toolchain은 다른 toolchain이 잡는 것을 숨깁니다. 대표 compiler 하나를 믿지 말고 matrix를 실행해야 하는 이유가 전부 여기에 있습니다

오래된 Win32 compiler가 거부하는 hard type cast

다섯 rejection 중 두 개는 옷만 다른 같은 bug입니다. floating-point expression이 아니라 variable에 적용해야 하는 hard type cast입니다. Win32에서 old compiler는 x87 stack을 통해 arithmetic을 평가하므로 Double을 포함한 addition은 80-bit excess precision으로 수행되고 static type은 10-byte Extended가 됩니다. 10 byte를 8-byte TDateTime으로 줄이는 cast는 legal typecast가 아니며 compiler는 E2089 Invalid typecast라고 말합니다

괴로운 세부 사항은 variable form이 괜찮다는 것입니다. TDateTime(Serial)Serial이 이미 8 byte이고 cast가 size-preserving이므로 matrix의 모든 version에서 compile됩니다. 여기에 무엇인가를 더하면 expression 아래에서 width가 늘어납니다. 수정은 더 넓은 cast나 conditional define이 아니라 cast를 그만두는 것입니다. implicit real-to-real assignment는 HotXLS가 지원하는 모든 compiler에서 올바르게 convert하며 code가 실제로 뜻하는 바를 그대로 말합니다

// XE5 (Win32)에서 거부됩니다: 각 addition이 10-byte Extended로
// 평가되고 10-to-8 narrowing cast가 E2089를 발생시킵니다
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // addition이 없어 accept됩니다

// version-safe: real-to-real assignment가 conversion을 수행하게 합니다
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// cell value packer에서도 같은 종류의 rejection: integer에 대한 hard Double cast
// 나누기를 사용합니다. operator 자체가 이미 real을 반환합니다
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portable
  ;

Serial < 60 branch는 off-by-one이 아니라 1900 leap-year fiction입니다. serial 60은 Excel에 존재하지 않는 1900-02-29이므로 DecodeDate가 보기 전에 그보다 작은 serial에는 extra day가 필요합니다. portability 작업에서 이런 logic을 조용히 바꾸어서는 안 되며 그래서 여기의 safe edit는 cast를 제거하고 arithmetic은 그대로 둡니다

nil이 procedural argument일 때 깨지는 것

procedural type이 필요한 자리에 bare nil을 넘기면 old compiler에서 overload resolution 중 binding에 실패합니다. HotXLS의 call site는 overloaded이고 대부분 caller가 필요로 하지 않는 TXLSTryResolveSystemColor callback을 받는 ResolveIndexedColor입니다. 최신 compiler는 procedural parameter에 맞춰 nil을 resolve하고 올바른 overload를 선택하지만 XE5는 그렇게 하지 않으며 diagnostic은 argument가 아니라 overload set을 가리켜 20분을 잃게 합니다

portable answer는 null callback에 type을 주는 것입니다. procedural type의 unit-level variable은 language에 의해 zero-initialize되므로 initializer 없이도 이미 nil이며 old resolver가 원하는 type information도 함께 갖습니다. unit-level variable이 과하면 typed local에 nil을 대입해도 같은 역할을 합니다

var
  // older compiler의 overload resolution에서는 nil procedural literal이 bind하지 않습니다
  // type이 있는 zero-initialized variable은 bind합니다
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

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

// XLSX workbook에서 typed local을 사용하는 같은 수정
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;

이것은 define으로 우회할 compiler bug가 아니라 실제 language-level difference입니다. zero-initialized variable은 matrix의 모든 version에서 올바르고 한 줄 비용뿐이므로 여기에는 conditional compilation이 전혀 없습니다. {$IF CompilerVersion}는 platform이 release마다 실제로 다를 때만 사용하세요. 이 batch에서는 정확히 한 번만 그 경우가 나옵니다

release 사이에서 이동하는 protected VCL method

TPicture.LoadFromStream은 current VCL에서는 public이고 HotXLS가 지원하는 오래된 version에서는 protected이므로 직접 call은 지금은 compile되고 예전에는 실패합니다. HotXLS는 이를 사용해 worksheet background image payload가 실제로 decode되는지 validate하며 HTML exporter가 byte를 embedding하기로 commit하기 전에 실행되는 signature check입니다. 고전적인 Pascal 답이 적용됩니다. 같은 unit에서 visibility를 넓히기 위한 descendant를 선언하고 call site에서 이를 거쳐 cast합니다

type
  // TPicture.LoadFromStream은 지원하는 오래된 VCL version에서 protected이며
  // 같은 unit의 descendant가 이를 노출합니다
  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가 field를 추가하지 않고 instantiate되지 않으므로 안전합니다. cast는 compiler가 name을 허용할 범위만 바꿉니다. 그래도 declaration에 comment를 둘 가치가 있습니다. current IDE에서만 build하는 reader는 그렇지 않으면 이 type을 쓸모없는 것으로 보기 때문입니다. background image handling은 custom VCL grid rendering path에도 다시 나타나며 같은 decoded payload가 on-screen sheet를 공급합니다

GdiplusStartup token type이 두 번 바뀐 이유

batch에서 진짜로 conditional compilation이 필요한 유일한 rejection은 GdiplusStartupvar parameter type입니다. VCL generation 사이에서 바뀌어 모든 곳에서 유효한 하나의 표기가 없습니다. version별 probing으로 실제 behavior를 고정했습니다. 12.0부터 20.0 leg는 Cardinal만 accept하고 21.0과 22.0 leg는 THandle 또는 ULONG_PTR만 accept하며 23.0과 37.0은 둘 다 accept합니다. release name으로 쓰면 XE5부터 10.3 Rio까지는 Cardinal이고 10.4 Sydney부터는 THandle입니다. 12.0부터 22.0까지 두 acceptance range가 겹치지 않으므로 unconditional declaration은 작동하지 않습니다. guard는 Sydney인 CompilerVersion >= 34에 걸고 call을 Winapi.GDIPAPI.GdiplusStartup으로 fully qualify해 unit resolution order가 중간 version에서 다른 declaration을 대신 선택하지 않도록 합니다

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // GDIPAPI의 GdiplusStartup var-parameter type은 VCL generation을
  // 따릅니다: Rio까지 Cardinal, Sydney부터 THandle
  {$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;

이것은 page image exporter의 TIFF branch이므로 잘못하면 cell range를 하나의 image로 export하는 path를 포함한 전체 raster export surface가 영향받습니다. guard가 주장하지 않는 것도 알아 두세요. 두 platform 모두 ULONG_PTRTHandle은 같은 width이므로 선택은 32-bit와 64-bit correctness가 아니라 declaration이 어떤 identifier name을 쓰는지에 관한 것입니다

첫 probe run이 아무것도 보고하지 않은 이유

첫 실행에서 version probe가 아무것도 보고하지 않은 이유는 res=$(...) assignment가 subshell 안에서 수행되어 parent로 전파되지 않았기 때문입니다. dcc32는 성공 시 0으로 종료하므로 exit code가 capture할 올바른 signal이었지만 script는 그 값을 한 줄 뒤 사라지는 variable에 저장하고 있었습니다. 모든 leg가 empty로 돌아왔고 output은 compile이 아무것도 하지 않은 probe처럼 보였는데 실제로 정확히 그런 상태였습니다

두 번째 failure는 output이 없던 것보다 더 나빴습니다. no answer가 아니라 wrong answer를 만들었기 때문입니다. probe는 Error와 일치하는 line을 세어 leg를 분류했지만 Delphi는 모든 fatal 앞에 그 단어를 붙이지 않습니다. F1026 File not found는 fatal이지만 match하지 않으므로 unit을 전혀 resolve하지 못한 probe가 clean pass로 분류되었습니다. XE5에는 Winapi.GDIPOPS.dcu가 없고 첫 probe가 정확히 이를 만났지만 false green이 되었습니다. 여기서 나온 규칙은 좁고 분명합니다. compiler probe는 keyword를 grep하지 말고 produced artifact 또는 compiler 자체 summary line으로 판정하세요. stderr에서 Error를 grep하는 것은 감당할 수 없는 방향으로 실패하는 heuristic이며 조용히 성공을 보고합니다

10년치 compiler를 지원하는 실제 비용

정직하게 계산하면 code change는 사소하고 process change는 그렇지 않습니다. 다섯 rejection 중 네 개는 version machinery를 추가하지 않고 더 평범한 Pascal을 써서 고쳤습니다. cast를 버리고, cast 대신 나누고, nil에 type을 주고, accessor class를 선언한 것입니다. GdiplusStartup{$IF}를 얻었습니다. XE5부터 current release까지 걸친 codebase가 conditional define의 덤불이 되는 것은 처음부터 hard cast와 최신 compiler idiom을 쌓아 두었을 때뿐입니다

실제로 드는 비용은 build time과 discipline입니다. 43 leg는 느린 script이고 바로 그래서 packaging time으로 밀려났다가 아예 실행되지 않았습니다. 방어 가능한 중간 지점은 빠른 네-script loop를 iteration용으로 유지하고 full matrix는 건너뛸 수 없는 schedule로 실행하는 것입니다. failure mode가 눈에 띄는 broken build가 아니라 12 release 전부터 조용히 지원되지 않게 된 supported IDE이기 때문입니다

그 의무는 native component를 배포하는 일의 반대편입니다. HotXLS는 Excel install이나 COM dependency 없이 Object Pascal만으로 XLS, XLSX와 ODS를 읽고 쓰며 locked-down server에서 Office-free workbook automation이 가능한 이유가 됩니다. 같은 속성은 compiler가 전체 platform contract라는 뜻이므로 matrix의 모든 version은 가정하지 말고 다시 검증해야 하는 약속입니다

여기서 다룬 cross-compiler build matrix와 version-safe code는 Delphi와 C++Builder에서 XE5부터 current release까지 지원하고 각 IDE용 prebuilt library binary를 제공하는 HotXLS Delphi Spreadsheet Component에 포함되어 있습니다