技術記事

Delphi cross-compiler matrix:XE5以降のHotXLS

HotXLSはXE5以降のすべてのDelphiとC++Builder releaseへ1つのObject Pascal codebaseを出荷しています。それを証明するscriptがbuild-All-Lib-TRIAL.cmdです。Win32とWin64の12 Delphi versionに加え、10 C++Builder Win32と9 Win64 package buildをカバーする43 build legがあります。v2.363からv2.374まで、このscriptはcompletionまで一度も実行されず、XE5 legはその間ずっと壊れていました

failureを見た後なら何もsubtleではありませんでした。current compilerがcommentなしに受け入れる5つのconstructが、RAD Studio XE5、build matrixで12.0と名付けられたcompilerではhard errorになります。v2.375.0 releaseで5つすべてを修正し、matrixは43 of 43でgreenへ戻りました。以下では各rejection、old compilerがtype groundsで拒否する2つについてなぜある意味正しいか、そしてより恥ずかしい部分、つまり混乱を診断するために書いたprobe scriptが初回runでfalse passを報告した理由を説明します

XE5 legが誰にも気づかれず腐った理由

XE5 legが腐ったのは、日々のdevelopmentが37.0の4-script setだけを実行していたためです。green local buildは、invokeしていないcompilerについて何も教えません。full matrixは別の遅いscriptで、trial installerがInno Setupでfileを集める前に呼びます。そのためcommit時ではなくpackaging時にexerciseされます。この隙間に12 releaseが入りました

coverage illusionがある場所なので、leg arithmeticを明記する価値があります。DELPHI_TRIAL_VERSIONSは12.0から37.0までの12 versionをenumerateし、12 versionはそれぞれWin32とWin64の2回buildします。CB_TRIAL_WIN32_VERSIONSは10 versionをlistし、CB_TRIAL_WIN64_VERSIONSは9だけです。XE5にはC++Builder package projectはありますが、Win64 package startup object c0pkg64.oが付属しないためです。12 plus 12 plus 10 plus 9は43です。4つだけを実行してcodebaseをportableと呼ぶのはcategory errorであり、それが今回の原因です

HotXLSは反対方向から同じ形に襲われたこともあります。uses clauseを通じてreachできる新しいunitが.cbproj file listにない場合、Delphiでは完全にcompileできます。dccがunlisted unitをpackageへimplicitにpullし、せいぜいW1033 hintを出すだけだからです。C++Builderは<DelphiCompile>に書かれたunitだけについて.objを出力するため、同じcodeがilink stageでunresolved externalにより死にます。一方のtoolchainが隠すものを他方が捕捉します。代表的なcompilerを信じずmatrixを実行する理由はこれだけです

old Win32 compilerがrejectするhard type cast

5つのrejectionのうち2つは、服装が違うだけで同じbugです。floating-point expressionではなくvariableへ適用すべきhard type castです。Win32ではolder compilerがx87 stackを通してarithmeticをevaluateするため、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)はmatrix内のすべてのversionでcompileします。Serialはすでに8 byteで、castがsize-preservingだからです。そこへ何かを足すとexpressionが内部でwideになります。fixはwider castやconditional defineではなくcastをやめることです。implicitなreal-to-real assignmentがcompilerごとに正しくconvertし、codeが実際に意味するものを表します

// XE5(Win32)ではrejectされる。各additionが10-byteの
// Extendedとしてevaluateされ、10-to-8 narrowing castがE2089をraiseする
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // 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;

// cell value packerにも同じ種類のrejection:integerへのhard Double cast
// 代わりにdivideする。operatorがすでにrealを返す
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portable
  ;

Serial < 60 branchは1900 leap-year fictionであり、off-by-oneではありません。serial 60はExcelに存在しない1900-02-29なので、DecodeDateが見る前のserialがそれ未満ならextra dayが必要です。portability workでこの種のlogicを静かに変えてはいけません。そのためsafe editはcastを除くだけでarithmeticをそのまま残します

nilがprocedural argumentになると壊れるもの

procedural typeが期待される場所にbare nilを渡すと、older compilerではoverload resolution中にbindできません。HotXLSのcall siteはoverloadされたResolveIndexedColorで、多くのcallerが不要とするTXLSTryResolveSystemColor callbackを受け取ります。newer compilerはnilをprocedural parameterへresolveし、正しいoverloadを選びます。XE5はそうせず、diagnosticはargumentではなくoverload setを指すため、20分を失います

portableな答えはnull callbackにtypeを与えることです。unit-level variableはlanguageによってzero-initializeされるため、initializerなしですでにnilであり、old resolverが欲しがるtype informationを持ちます。unit-level variableが大げさな場所では、typed localへnilをassignしても同じです

var
  // nil procedural literalはolder compilerのoverload resolutionでbindしない
  // typedでzero-initializedなvariableならbindできる
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

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

// XLSX workbookではtyped localを使った同じfix
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;

これはrealなlanguage-level differenceであり、defineで避けるべきcompiler bugではありません。zero-initialized variableはmatrix内のすべてのversionで正しく、1行のコストなので、ここにconditional compilationはありません。{$IF CompilerVersion}を使うのはrelease間でplatformが本当に異なる場合だけにしてください。このbatchで該当するのはちょうど1回です

protected VCL methodがrelease間で移動する

TPicture.LoadFromStreamはcurrent VCLではpublicですが、HotXLSがsupportするolder versionではprotectedです。そのためdirect callは現在はcompileしても、当時は失敗します。HotXLSはworksheet background image payloadが本当にdecodeできることをvalidateするためにこれを使います。HTML exporterがbyteのembeddingをcommitする前に行うsignature checkです。classic Pascalの答えを使います。同じunitでvisibilityを広げるだけのdescendantを宣言し、call siteではそれを通してcastします

type
  // TPicture.LoadFromStreamはsupport対象のolder VCL versionでprotected
  // same-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はここではsafeです。descendantはfieldを追加せず、instantiateされないからです。castが変えるのはcompilerがnameを許す範囲だけです。それでもdeclarationにはcommentを残す価値があります。current IDEだけでbuildするreaderには、pointlessなtypeに見えるためです。background image handlingはcustom VCL grid rendering pathにも再登場し、同じdecode済みpayloadがon-screen sheetへ供給されます

GdiplusStartup token typeが2回変わった

このbatchで本当にconditional compilationが必要なのはGdiplusStartupvar parameter typeだけです。VCL generation間で、どこでも有効な1つのspellingが残らない形で変わりました。versionごとのprobeで実際の動作を固定しました。12.0から20.0のlegはCardinalだけを受け入れ、21.0と22.0はTHandleまたはULONG_PTRだけを受け入れ、23.0と37.0は両方を受け入れます。release nameで言えば、XE5から10.3 RioまではCardinal、10.4 Sydney以降はTHandleです。12.0から22.0では受け入れ範囲が重ならないため、unconditional declarationは使えません。guardはSydneyであるCompilerVersion >= 34にし、callはWinapi.GDIPAPI.GdiplusStartupとしてfully qualifyします。そうすればunit resolution orderがrange途中の別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なので、間違えたときのblast radiusは、cell rangeを1つのimageとしてexportする処理を含むraster export surface全体です。またguardが主張しないことにも注意してください。ULONG_PTRTHandleは両platformで同じwidthなので、選択は32-bitと64-bitのcorrectnessではなく、declarationがどのidentifierをnameするかの問題です

初回probe runが何も報告しなかった理由

version probeの初回runが何も報告しなかったのは、res=$(...) assignmentがsubshell内で行われ、parentへpropagateしなかったためです。dcc32はsuccess時にexit 0なので、captureすべきsignalはexit codeでした。しかしscriptはそれを1行後には存在しないvariableへcaptureしていました。すべてのlegがemptyになり、compileを何もしていないprobeのように見えました。実際、そうでした

2つ目のfailureはさらに悪く、no answerではなくwrong answerを出しました。probeはErrorに一致するlineを数えてlegをclassifyしていましたが、Delphiはすべてのfatalの先頭にそのwordを付けません。F1026 File not foundはfatalですがmatchしないため、unitをまったくresolveできないprobeがclean passとしてscoreされました。XE5にはWinapi.GDIPOPS.dcuが付属せず、初回probeはそこへ命中しましたがfalse greenになりました。そこから出たruleは狭いですが明確です。compiler probeはproduced artifactまたはcompiler自身のsummary lineで判定し、outputのkeyword grepでは判定してはいけません。stderrのErrorをgrepするのは、もっとも避けたい方向、つまりsuccessを静かに報告する方向へ失敗するheuristicです

10年分のcompilerをsupportする実際のコスト

正直なaccountingでは、ここでのcode changeはtrivialですが、process changeはそうではありません。5つのrejectionのうち4つはversion machineryを追加せず、普通のPascalを書くことで修正しました。castを落とし、castの代わりにdivideし、nilへtypeを与え、accessor classを宣言するだけです。GdiplusStartupだけが{$IF}を獲得しました。XE5からcurrent releaseまでをspanするcodebaseは、最初からhard castとnewest-compiler idiomを蓄積させなければconditional defineの茂みにはなりません

本当にかかるのはbuild timeとdisciplineです。43 legは遅いscriptであり、それがpackaging timeへ流れ、最後にはneverへ流れた理由でもあります。守れる中間案は、iteration用にfast four-script loopを残し、skipできないscheduleでfull matrixを実行することです。failure modeは壊れたbuildとして気づけるものではなく、12 release前からsupport対象IDEが静かにsupport対象でなくなっていることだからです

native componentを出荷する義務もそこから生まれます。HotXLSはObject Pascalだけで、Excel installもCOM dependencyもなくXLS、XLSX、ODSをreadとwriteします。これがlocked-down serverでOffice-free workbook automationを可能にします。同じ性質がcompilerをplatform contract全体にします。matrix内の各versionは、仮定ではなく再検証しなければならないpromiseです

ここで説明したcross-compiler build matrixとversion-safe codeは、DelphiとC++Builder向けHotXLS Delphi Spreadsheet Componentの一部です。XE5からcurrent releaseまでをsupportし、対応する各IDE向けにprebuilt library binaryを提供します