مقاله فنی

matrix ساخت cross-compiler در Delphi: HotXLS از XE5 به بعد

HotXLS یک codebase از نوع Object Pascal را به هر release از Delphi و C++Builder از XE5 به بعد تحویل می‌دهد و build-All-Lib-TRIAL.cmd scriptی است که این ادعا را ثابت می‌کند: 43 build leg که 12 version از Delphi را روی Win32 و Win64، به‌اضافه 10 build از packageهای C++Builder Win32 و 9 build از packageهای Win64 پوشش می‌دهد. از v2.363 تا v2.374، آن script هرگز تا پایان اجرا نشد و leg مربوط به XE5 در تمام این مدت شکسته بود

وقتی failure دیده شد، هیچ چیز ظریفی در آن نبود. پنج construct متفاوت که compiler فعلی بدون comment می‌پذیرد، روی RAD Studio XE5 که matrix آن را 12.0 label می‌کند hard error هستند. release نسخه 2.375.0 هر پنج مورد را fix کرد و matrix دوباره با 43 از 43 سبز شد. آنچه در ادامه می‌آید هر rejection، دلیل اینکه compiler قدیمی درباره دو مورد type-based تا حدی حق دارد و بخش شرم‌آورتر است: probe scriptی که برای تشخیص این آشفتگی نوشته شده بود، در نخستین run یک pass جعلی گزارش کرد

چرا leg مربوط به XE5 بدون اینکه کسی متوجه شود فرسوده شد؟

leg مربوط به XE5 فرسوده شد چون development روزمره فقط مجموعه چهاراسکریپتی 37.0 را اجرا می‌کرد و build سبز محلی درباره compilerی که invoke نکرده‌اید هیچ چیز نمی‌گوید. matrix کامل scriptی جدا و کند است که trial installer آن را پیش از جمع کردن fileها توسط Inno Setup call می‌کند؛ بنابراین هنگام packaging exercise می‌شود نه هنگام commit. دوازده release در این فاصله جا گرفتند

arithmetic این leg ارزش توضیح دارد، چون توهم coverage دقیقاً همین‌جا زندگی می‌کند. DELPHI_TRIAL_VERSIONS نسخه‌های 12.0 تا 37.0 را فهرست می‌کند و هرکدام از این 12 version دوبار build می‌شوند، یک بار Win32 و یک بار Win64. CB_TRIAL_WIN32_VERSIONS ده version را list می‌کند و CB_TRIAL_WIN64_VERSIONS فقط 9 تا را، چون XE5 project مربوط به package C++Builder را دارد اما startup object مربوط به package Win64 یعنی c0pkg64.o را ship نمی‌کند. دوازده به‌اضافه دوازده به‌اضافه ده به‌اضافه نه می‌شود 43. اجرای چهار مورد و نامیدن codebase به‌عنوان portable یک خطای category است و همین خطای مشخص اجازه داد این اتفاق رخ دهد

HotXLS از جهت مخالف نیز با شکل مشابهی از مسئله برخورد کرده است. unit جدیدی که از طریق clause مربوط به uses reachable باشد اما در file list مربوط به .cbproj نباشد، زیر Delphi کاملاً compile می‌شود، چون dcc unitهای list‌نشده را به‌صورت implicit وارد package می‌کند و در بدترین حالت hintای از نوع W1033 می‌دهد. C++Builder فقط برای unitهایی که نامشان در <DelphiCompile> آمده .obj تولید می‌کند؛ بنابراین همان code در مرحله ilink با unresolved external می‌میرد. یک toolchain چیزی را پنهان می‌کند که دیگری می‌گیرد و تمام استدلال اجرای matrix به‌جای اعتماد به compiler نماینده همین است

hard type castهایی که compilerهای قدیمی Win32 رد می‌کنند

دو مورد از پنج rejection در واقع یک bug هستند با دو لباس متفاوت: hard type castی که به expression اعشاری اعمال شده نه به variable. در Win32، compilerهای قدیمی arithmetic را از طریق x87 stack ارزیابی می‌کنند؛ بنابراین addition شامل Double با excess precision هشتادبیتی انجام می‌شود و type static آن به Extended ده‌بایتی تبدیل می‌شود. cast کردن 10 byte به TDateTime هشت‌بایتی typecast قانونی نیست و compiler با E2089 Invalid typecast آن را اعلام می‌کند

جزئیات اعصاب‌خردکن این است که شکل variable درست است. TDateTime(Serial) روی هر version matrix compile می‌شود، چون Serial از قبل 8 byte است و cast اندازه را حفظ می‌کند. چیزی به آن اضافه کنید و expression زیر دست شما wide می‌شود. fix، cast عریض‌تر یا conditional define نیست؛ باید cast کردن را متوقف کنید: assignment ضمنی real به real روی هر compiler مورد پشتیبانی HotXLS conversion درست را انجام می‌دهد و همان معنایی را می‌گوید که code واقعاً دارد

// در XE5 (Win32) رد می‌شود: هر addition به‌صورت 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);        // این مورد پذیرفته می‌شود: addition ندارد

// نسخه امن: بگذار assignment از real به real conversion را انجام دهد
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// همین دسته rejection در cell value packer: یک Double cast سخت روی integer
// به‌جای آن تقسیم کن؛ operator از قبل real برمی‌گرداند
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // قابل‌حمل
  ;

branch مربوط به Serial < 60 افسانه leap-year سال 1900 است، نه off-by-one: serial 60، 1900-02-29 غیرموجود Excel است؛ بنابراین serialهای پایین‌تر از آن پیش از اینکه DecodeDate آن‌ها را ببیند به روز اضافی نیاز دارند. کار portability هرگز نباید این نوع logic را بی‌سروصدا تغییر دهد و دقیقاً به همین دلیل edit امن، cast را حذف می‌کند و arithmetic را دست‌نخورده می‌گذارد

وقتی nil آرگومان procedural باشد چه چیزی می‌شکند؟

یک nil خالی که در جایی انتظار procedural type می‌رود، در compilerهای قدیمی هنگام overload resolution bind نمی‌شود. call site در HotXLS، ResolveIndexedColor است که overload شده و callbackی از نوع TXLSTryResolveSystemColor می‌گیرد که بیشتر callerها به آن نیاز ندارند. compilerهای جدید nil را با parameter procedural resolve می‌کنند و overload درست را انتخاب می‌کنند. XE5 این کار را نمی‌کند و diagnostic را به‌جای argument روی overload set می‌گذارد؛ همان‌طور که بیست دقیقه از شما می‌گیرد

پاسخ قابل‌حمل این است که به null callback یک type بدهید. variableای در سطح unit از این procedural type به‌صورت zero-initialized توسط language ساخته می‌شود؛ پس بدون initializer از قبل nil است و type information مورد نیاز resolver قدیمی را حمل می‌کند. جایی که variable سطح unit بیش از حد است، local typedای که به nil assign شده همین کار را انجام می‌دهد

var
  // literal مربوط به nil procedural در overload resolution compilerهای قدیمی bind نمی‌شود
  // variable دارای type که zero-initialized است این کار را انجام می‌دهد
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

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

// همین fix با local دارای type در workbook مربوط به XLSX
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 است، نه compiler bugای که ارزش دور زدن با defineها را داشته باشد. variable صفرشده در هر version matrix درست است و فقط یک line هزینه دارد، پس اینجا اصلاً conditional compilation نداریم. فقط وقتی به {$IF CompilerVersion} متوسل شوید که platform واقعاً بین releaseها فرق کند؛ در این batch دقیقاً یک بار چنین وضعی وجود دارد

methodهای protected در VCL بین releaseها جابه‌جا می‌شوند

TPicture.LoadFromStream در VCL فعلی public است و در versionهای قدیمی‌تری که HotXLS پشتیبانی می‌کند protected؛ بنابراین call مستقیم حالا compile می‌شود و آن زمان fail می‌کند. HotXLS از این method برای validate کردن اینکه payload تصویر background worksheet واقعاً decode می‌شود استفاده می‌کند؛ checkای که پیش از آن اجرا می‌شود که HTML exporter به embed کردن byteها commit کند. پاسخ کلاسیک Pascal اینجا کاربرد دارد: descendantی را در همان unit فقط برای wide کردن visibility declare کنید و در call site از طریق آن cast کنید

type
  // TPicture.LoadFromStream در versionهای قدیمی VCL که library پشتیبانی می‌کند
  // protected است؛ descendant هم‌واحد آن را expose می‌کند
  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 کنید تغییر می‌دهد. با این حال comment کنار declaration همچنان ارزش دارد، چون readerی که فقط روی IDE فعلی build می‌کند وگرنه typeای بی‌دلیل می‌بیند. مدیریت background image دوباره در مسیر rendering گرید سفارشی VCL دیده می‌شود، جایی که همان payload decode‌شده sheet روی screen را تغذیه می‌کند

نوع token مربوط به GdiplusStartup دو بار تغییر کرد

تنها rejection این batch که واقعاً conditional compilation لازم دارد، type مربوط به parameter از نوع var در GdiplusStartup است که در نسل‌های مختلف VCL تغییر کرده و هیچ spelling واحدی را در همه جا معتبر باقی نمی‌گذارد. probing نسخه به نسخه رفتار واقعی را مشخص کرد: legهای 12.0 تا 20.0 فقط Cardinal را می‌پذیرند، legهای 21.0 و 22.0 فقط THandle یا ULONG_PTR را و 23.0 و 37.0 هر دو را می‌پذیرند. در نام releaseها یعنی Cardinal از XE5 تا 10.3 Rio و از 10.4 Sydney به بعد THandle. چون دو بازه پذیرفته‌شده برای 12.0 تا 22.0 overlap ندارند، هیچ declaration بدون شرطی کار نمی‌کند: guard روی CompilerVersion >= 34 که Sydney است key می‌شود و call کاملاً با Winapi.GDIPAPI.GdiplusStartup qualify می‌شود تا ترتیب resolution unit نتواند در یکی از versionهای میانی declaration دیگری را جایگزین کند

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // type مربوط به var-parameter در GDIPAPI برای GdiplusStartup از نسل VCL پیروی می‌کند
  // از Cardinal تا Rio و از 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;

این branch مربوط به TIFF در page image exporter است؛ بنابراین blast radius اشتباه کردن آن کل سطح raster export را دربرمی‌گیرد، از جمله مسیرهایی که در export کردن cell range به‌صورت یک image توضیح داده شده‌اند. همچنین guard ادعا نمی‌کند که ULONG_PTR و THandle در دو platform width متفاوتی دارند؛ هر دو در هر دو platform هم‌اندازه‌اند، پس انتخاب درباره identifier مورد استفاده در declaration است، نه درستی 32-bit در برابر 64-bit

چرا نخستین run مربوط به probe هیچ چیز گزارش نکرد؟

version probe در نخستین run هیچ چیز report نکرد چون assignmentهای res=$(...) داخل subshell انجام می‌شدند و به parent propagate نمی‌شدند. dcc32 در success با exit code صفر خارج می‌شود، پس exit code signal درست برای capture بود و script آن را داخل variableای capture می‌کرد که یک line بعد دیگر وجود نداشت. هر leg خالی برمی‌گشت و output شبیه probeای به نظر می‌رسید که هیچ چیز compile نکرده، که دقیقاً هم همین بود

failure دوم بدتر بود، چون به‌جای no answer، جواب غلط می‌داد. probe یک leg را با شمردن lineهایی که با Error match می‌شدند classify می‌کرد و Delphi هر fatal را با آن word prefix نمی‌کند. F1026 File not found fatal است و match نمی‌شود، پس probeای که اصلاً نمی‌توانست unit را resolve کند، clean pass امتیاز می‌گرفت. XE5، Winapi.GDIPOPS.dcu را ship نمی‌کند؛ نخستین probe دقیقاً به همین رسید و به‌طور دروغین سبز شد. rule حاصل باریک اما ارزش گفتن دارد: compiler probe را با artifact تولیدشده یا line summary خود compiler قضاوت کنید، هرگز با grep کردن output برای keyword. grep کردن stderr برای Error heuristicای است که در جهتی fail می‌شود که توان پرداختش را ندارید و success را بی‌سروصدا گزارش می‌کند

پشتیبانی واقعی از یک دهه compiler چه هزینه‌ای دارد؟

حساب‌وکتاب صادقانه این است که تغییرات code اینجا trivial و تغییرات process اینجا trivial نیستند. چهار مورد از پنج rejection با نوشتن Pascal معمولی‌تر fix شدند، نه با افزودن version machinery: cast را حذف کن، به‌جای cast تقسیم کن، به nil type بده و accessor class declare کن. فقط GdiplusStartup سزاوار یک {$IF} شد. codebaseای که از XE5 تا release فعلی span دارد، تا وقتی بگذارید hard cast و idiomهای compiler جدید در ابتدا جمع شوند، به thicketی از conditional define تبدیل نمی‌شود

هزینه واقعی، زمان build و discipline است. 43 leg script کندی است و دقیقاً به همین دلیل از زمان packaging به هرگز اجرا نشدن drift کرد. میانه قابل دفاع این است که loop سریع چهاراسکریپتی را برای iteration نگه دارید و matrix کامل را طبق scheduleای اجرا کنید که نتوان آن را skip کرد، چون failure mode یک build شکسته‌ای نیست که ببینید؛ IDE پشتیبانی‌شده‌ای است که دوازده release پیش بی‌سروصدا دیگر پشتیبانی نمی‌شده است

این تعهد روی دیگر سکه shipping کردن native component است. HotXLS فقط با Object Pascal، XLS، XLSX و ODS را می‌خواند و می‌نویسد، بدون نصب Excel و بدون COM dependency؛ همین است که workbook automation بدون Office را روی server قفل‌شده ممکن می‌کند. همین property یعنی compiler کل platform contract است، بنابراین هر version در matrix وعده‌ای است که باید دوباره verify شود، نه چیزی که بتوان فرض گرفت

matrix ساخت cross-compiler و code امن نسبت به version که اینجا درباره آن صحبت شد، بخشی از HotXLS Delphi Spreadsheet Component هستند که Delphi و C++Builder را از XE5 تا release فعلی با library binaryهای ازپیش‌ساخته برای هر IDE پشتیبانی‌شده پشتیبانی می‌کند