HotXLS ส่งมอบ Object Pascal codebase เดียวให้ Delphi และ C++Builder ทุก release ตั้งแต่ XE5 เป็นต้นมา และ build-All-Lib-TRIAL.cmd คือ script ที่พิสูจน์เรื่องนี้ มันมี build leg 43 ขา ครอบคลุม Delphi 12 version บน Win32 และ Win64 รวมถึง C++Builder package build บน Win32 10 version และ Win64 9 version ตั้งแต่ v2.363 ถึง v2.374 script นี้ไม่เคยรันจนจบ และ leg ของ XE5 ก็เสียมาตลอดช่วงนั้น
หลังจากเห็นแล้ว failure ไม่มีอะไรซับซ้อน construct ห้าแบบที่ compiler รุ่นปัจจุบันรับโดยไม่พูดอะไรเป็น hard error บน RAD Studio XE5 ซึ่ง build matrix label เป็น 12.0 release v2.375.0 แก้ครบทั้งห้าและ matrix กลับมาเขียวที่ 43 จาก 43 สิ่งต่อไปนี้คือ rejection แต่ละแบบ เหตุผลที่ compiler เก่าถือว่าถูกต้องในสองกรณีที่ reject ด้วยเรื่อง type และส่วนที่น่าอายกว่า คือ probe script ที่เขียนเพื่อวินิจฉัยปัญหากลับรายงาน false pass ในการรันครั้งแรก
ทำไม XE5 leg จึงค่อย ๆ เสียโดยไม่มีใครเห็น
XE5 leg เสียเพราะ development ประจำวันรันเพียงชุด four-script ของ 37.0 และ local build ที่เขียวไม่ได้บอกอะไรเกี่ยวกับ compiler ที่คุณไม่ได้เรียกใช้ full matrix เป็น script แยกที่ช้า ซึ่ง trial installer เรียกก่อน Inno Setup จะเก็บไฟล์ ดังนั้นมันถูกใช้งานตอน packaging ไม่ใช่ตอน commit ช่องว่างนี้กว้างพอให้สิบสอง release ผ่านไป
ควรเขียน leg arithmetic ให้ชัด เพราะตรงนี้คือที่มาของ coverage illusion DELPHI_TRIAL_VERSIONS enumerate 12.0 ถึง 37.0 และแต่ละ version build สองครั้งคือ Win32 กับ Win64 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 และเป็น error แบบเดียวกับที่ปล่อยให้เรื่องนี้เกิดขึ้น
HotXLS เคยโดนปัญหารูปแบบเดียวกันจากอีกด้าน unit ใหม่ที่เข้าถึงผ่าน uses clause แต่หายจาก file list ของ .cbproj compile ได้สมบูรณ์ใน Delphi เพราะ dcc จะดึง unit ที่ไม่ได้ list เข้า package แบบ implicit และอย่างมากก็ส่ง hint W1033 ออกมา แต่ C++Builder จะ emit .obj เฉพาะ unit ที่ระบุใน <DelphiCompile> ดังนั้น code เดียวกันจะตายที่ขั้น ilink ด้วย unresolved external toolchain หนึ่งซ่อนสิ่งที่อีก toolchain จับได้ นี่คือเหตุผลทั้งหมดที่ต้องรัน matrix ไม่ใช่เชื่อ compiler ตัวแทน
hard type cast ที่ Win32 compiler รุ่นเก่าปฏิเสธ
rejection สองในห้าเป็น bug เดียวกันที่ใส่เสื้อคนละตัว คือ hard type cast ที่ใช้กับ floating-point expression แทนที่จะใช้กับ variable บน Win32 compiler รุ่นเก่าประเมิน arithmetic ผ่าน x87 stack ดังนั้น addition ที่มี Double จะถูกคำนวณด้วย 80-bit excess precision และ static type จะกลายเป็น 10-byte Extended การ cast 10 byte ลง 8-byte TDateTime ไม่ใช่ typecast ที่ถูกต้อง compiler จึงตอบด้วย E2089 Invalid typecast
รายละเอียดที่น่าหงุดหงิดคือรูปแบบที่ cast variable กลับใช้ได้ TDateTime(Serial) compile ได้ทุก version ใน matrix เพราะ Serial กว้าง 8 byte อยู่แล้วและ cast ไม่เปลี่ยนขนาด แต่พอบวกอะไรเข้าไป expression จะ widen อยู่ข้างใต้ วิธีแก้ไม่ใช่ใช้ cast ที่กว้างกว่าและไม่ใช่ conditional define แต่คือหยุด cast การ implicit real-to-real assignment convert ได้ถูกต้องบน compiler ทุกตัวที่ HotXLS รองรับ และสื่อความหมายจริงของ code ได้ตรงกว่า
// XE5 (Win32) ปฏิเสธ: addition แต่ละตัวคำนวณเป็น 10-byte
// Extended และ cast จาก 10 ไป 8 byte ทำให้เกิด E2089
if Dates1904 then
Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
Value := TDateTime(Serial + 1)
else
Value := TDateTime(Serial); // บรรทัดนี้รับได้: ไม่มี addition
// ปลอดภัยทุก version: ให้ real-to-real assignment ทำ conversion
if Dates1904 then
Value := Serial + XLSDate1904Offset
else if Serial < 60 then
Value := Serial + 1
else
Value := Serial;
// rejection แบบเดียวกันใน cell value packer: hard Double cast
// ของ integer ให้ใช้ division แทน operator นี้คืนค่า real อยู่แล้ว
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then // E2089
;
if (Scaled = intVal) and (intVal / 100 = AValue) then // portable
;
branch Serial < 60 คือเรื่องหลอกของ leap year ในปี 1900 ไม่ใช่ off-by-one serial 60 คือ 1900-02-29 ที่ไม่มีอยู่จริงใน Excel ดังนั้น serial ที่ต่ำกว่าจึงต้องเพิ่มวันก่อนให้ DecodeDate เห็น งาน portability ไม่ควรเปลี่ยนตรรกะประเภทนี้โดยไม่ตั้งใจ ซึ่งเป็นเหตุผลที่ safe edit ในที่นี้ลบ cast แต่ปล่อย arithmetic ไว้เหมือนเดิม
เกิดอะไรขึ้นเมื่อ nil เป็น procedural argument
การส่ง nil เปล่า ๆ ในตำแหน่งที่ต้องการ procedural type จะ bind ไม่ได้ระหว่าง overload resolution บน compiler รุ่นเก่า call site ใน HotXLS คือ ResolveIndexedColor ซึ่ง overload อยู่และรับ callback TXLSTryResolveSystemColor ที่ caller ส่วนใหญ่ไม่ต้องใช้ compiler ใหม่ resolve nil เข้ากับ procedural parameter แล้วเลือก overload ถูกตัว แต่ XE5 ทำไม่ได้ และ diagnostic ชี้ไปที่ overload set แทนที่จะชี้ argument ซึ่งเป็นวิธีเสียเวลาไปยี่สิบนาที
คำตอบที่ portable คือให้ type กับ null callback ตัวแปรระดับ unit ที่เป็น procedural type จะถูก zero-initialize ตามภาษา ดังนั้นมันเป็น nil อยู่แล้วโดยไม่ต้องมี initializer และยังพก type information ที่ resolver รุ่นเก่าต้องการ หากตัวแปรระดับ unit ใหญ่เกินความจำเป็น typed local ที่ assign nil ก็ทำงานเดียวกัน
var
// procedural literal แบบ nil bind ไม่ได้กับ overload ของ compiler รุ่นเก่า
// ตัวแปรที่มี type และ zero-initialize แล้วทำงานได้
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 ที่ควรใช้ define หลบ ตัวแปรที่ zero-initialize ถูกต้องบนทุก version ใน matrix และเพิ่มเพียงหนึ่งบรรทัด ดังนั้นไม่มี conditional compilation ในจุดนี้ ใช้ {$IF CompilerVersion} เฉพาะเมื่อ platform ต่างกันจริง ซึ่งเกิดขึ้นเพียงครั้งเดียวใน batch นี้
protected VCL method ย้าย visibility ระหว่าง release
TPicture.LoadFromStream เป็น public ใน VCL ปัจจุบัน แต่เป็น protected ใน version เก่าที่ HotXLS รองรับ ดังนั้น direct call compile ได้ตอนนี้แต่ fail ตอนนั้น HotXLS ใช้มัน validate ว่า worksheet background image payload decode ได้จริง ซึ่งเป็น signature check ก่อน HTML exporter จะ commit การฝัง byte คำตอบแบบ Pascal คลาสสิกคือประกาศ descendant ใน unit เดียวกันเพื่อขยาย visibility แล้ว cast ผ่าน descendant ตอน call
type
// TPicture.LoadFromStream เป็น protected ใน VCL รุ่นเก่าที่
// library รองรับ descendant ใน unit เดียวกันจึง 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 ปลอดภัยที่นี่เพราะ descendant ไม่เพิ่ม field และไม่เคยถูก instantiate cast เปลี่ยนเพียงสิ่งที่ compiler อนุญาตให้คุณเรียกชื่อ แต่ก็ควรมี comment ตรง declaration เพราะคนที่ build บน IDE รุ่นปัจจุบันเท่านั้นจะเห็น type นี้เป็นของไม่มีประโยชน์ การจัดการ background image โผล่อีกครั้งใน custom VCL grid rendering path ที่ payload เดิมซึ่ง decode แล้วถูกใช้เติม sheet บนจอ
ชนิด token ของ GdiplusStartup เปลี่ยนสองครั้ง
rejection เดียวใน batch ที่ต้องใช้ conditional compilation จริงคือ type ของ var parameter ใน GdiplusStartup ซึ่งเปลี่ยนไปตาม VCL generation จนไม่มี spelling เดียวที่ใช้ได้ทุกที่ การ probe ทีละ version ล็อกพฤติกรรมจริงไว้ leg 12.0 ถึง 20.0 รับเฉพาะ Cardinal leg 21.0 และ 22.0 รับเฉพาะ THandle หรือ ULONG_PTR และ 23.0 กับ 37.0 รับทั้งคู่ หากใช้ชื่อ release นี่คือ Cardinal ตั้งแต่ XE5 ถึง 10.3 Rio และเป็น THandle ตั้งแต่ 10.4 Sydney เป็นต้นไป เพราะช่วงที่รับได้ไม่ทับกันสำหรับ 12.0 ถึง 22.0 จึงไม่มี declaration แบบ unconditional ที่ใช้ได้ guard จึงผูกกับ CompilerVersion >= 34 ซึ่งคือ Sydney และ call จะระบุเต็มเป็น Winapi.GDIPAPI.GdiplusStartup เพื่อไม่ให้ unit resolution order ไปเลือก declaration อื่นบน version กลางช่วงโดยไม่ตั้งใจ
function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
StartupInput: TGdiplusStartupInput;
// type ของ GdiplusStartup var-parameter ใน GDIPAPI ขึ้นกับ 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 ทั้งหมด รวมถึง path ที่อธิบายใน การ export cell range เป็น image เดียว โปรดสังเกตด้วยว่า guard ไม่ได้อ้างว่า ULONG_PTR กับ THandle กว้างต่างกันบนสอง platform ทั้งคู่มี width เท่ากัน ดังนั้นสิ่งที่เลือกคือชื่อ identifier ของ declaration ไม่ใช่ความถูกต้องด้าน 32-bit กับ 64-bit
ทำไมการ probe ครั้งแรกจึงไม่รายงานอะไร
version probe ไม่รายงานอะไรในการรันครั้งแรกเพราะ assignment แบบ res=$(...) เกิดขึ้นใน subshell และไม่ propagate กลับไปยัง parent dcc32 exit ด้วย 0 เมื่อสำเร็จ ดังนั้น exit code คือ signal ที่ควร capture แต่ script capture มันไว้ใน variable ที่หายไปในบรรทัดถัดไป ทุก leg จึงกลับมาว่างและ output ดูเหมือน probe ที่ไม่ได้ compile อะไร ซึ่งตรงกับความจริงพอดี
failure ครั้งที่สองแย่กว่า เพราะให้คำตอบผิดแทนที่จะไม่ให้คำตอบ probe จัดประเภท leg ด้วยการนับ line ที่ match Error แต่ Delphi ไม่ได้ prefix fatal ทุกกรณีด้วยคำนั้น F1026 File not found เป็น fatal และไม่ match ดังนั้น probe ที่ resolve unit ไม่ได้เลยจึงถูกนับเป็น clean pass XE5 ไม่มี Winapi.GDIPOPS.dcu probe แรกเจอเรื่องนี้ตรง ๆ แต่กลับเป็นสีเขียวปลอม กฎที่ได้จากเรื่องนี้เฉพาะเจาะจงและควรพูดให้ชัด compiler probe ต้องตัดสินจาก artifact ที่สร้างขึ้นหรือ summary line ของ compiler เอง ไม่ใช่ grep output หา keyword การ grep stderr หา Error เป็น heuristic ที่ fail ในทิศทางที่คุณยอมรับไม่ได้ เพราะมันรายงาน success อย่างเงียบ ๆ
การรองรับ compiler หนึ่งทศวรรษมีต้นทุนอะไรจริง
ถ้าคิดตามจริง code change ในเรื่องนี้เล็กมาก แต่ process change ไม่เล็ก rejection สี่ในห้าถูกแก้ด้วย Pascal ธรรมดาที่ชัดขึ้น ไม่ใช่การเพิ่ม version machinery ได้แก่ลบ cast, ใช้ division แทน cast, ให้ type กับ nil และประกาศ accessor class มีเพียง GdiplusStartup ที่ได้ {$IF} codebase ที่ครอบคลุม XE5 ถึง release ปัจจุบันจะไม่กลายเป็นพง conditional define หากคุณไม่ปล่อยให้ hard cast กับ idiom ของ compiler ใหม่สะสมตั้งแต่แรก
ต้นทุนจริงคือเวลา build กับวินัย 43 leg เป็น script ที่ช้า และนั่นคือเหตุผลที่มันไหลไปอยู่ตอน packaging แล้วกลายเป็นไม่เคยรัน ทางสายกลางที่ป้องกันได้คือเก็บ four-script loop ที่เร็วไว้ใช้ระหว่างพัฒนา และรัน full matrix ตาม schedule ที่ข้ามไม่ได้ เพราะ failure mode ไม่ใช่ build ที่พังแล้วคุณเห็นทันที แต่มันคือ IDE ที่ยังอ้างว่ารองรับแต่หยุดรองรับไปแล้วสิบสอง release
ภาระนี้เป็นอีกด้านของการส่ง native component ออกไป HotXLS อ่านและเขียน XLS, XLSX และ ODS ด้วย Object Pascal ล้วน ไม่มี Excel install และไม่มี COM dependency ซึ่งเป็นเหตุผลที่ Office-free workbook automation ทำงานบน locked-down server ได้ คุณสมบัติเดียวกันนี้หมายความว่า compiler คือ platform contract ทั้งหมด ดังนั้นทุก version ใน matrix คือคำสัญญาที่ต้อง verify ใหม่ ไม่ใช่สิ่งที่เดาเอา
cross-compiler build matrix และ version-safe code ที่กล่าวถึงอยู่ใน HotXLS Delphi Spreadsheet Component ซึ่งรองรับ Delphi และ C++Builder ตั้งแต่ XE5 ถึง release ปัจจุบัน พร้อม prebuilt library binary สำหรับ IDE ที่รองรับทุกตัว