HotXLS 从 XE5 开始,向每个 Delphi 和 C++Builder 版本交付同一套 Object Pascal 代码库,证明这一点的脚本是 build-All-Lib-TRIAL.cmd:共 43 个构建分支,覆盖 Win32 和 Win64 上的 12 个 Delphi 版本,以及 10 个 C++Builder Win32 和 9 个 Win64 包构建。从 v2.363 到 v2.374,这个脚本从未完整运行过,而 XE5 分支一直处于损坏状态
问题一旦被看见就不再隐蔽。当前编译器可以毫无提示接受的五种不同构造,在 RAD Studio XE5 上都是硬错误,构建矩阵将其标记为 12.0。v2.375.0 修复了全部五项,矩阵重新以 43/43 变绿。下面会逐一说明每个拒绝,解释旧编译器在其中两个类型问题上的判断为何有道理,以及更尴尬的部分:用于诊断混乱的探针脚本第一次运行时报告了一个假阳性
XE5 分支为什么会在无人察觉中腐烂
XE5 分支会腐烂,是因为日常开发只运行 37.0 的四脚本集合,而本地构建是绿色,并不能说明你没有调用的编译器。完整矩阵是一个独立的慢脚本,试用版安装器会在 Inno Setup 收集文件前调用它,因此它是在打包时而不是提交时运行。十二个版本就在这段空档中被遗漏了
把分支算式写出来很有必要,因为覆盖率错觉就藏在这里。DELPHI_TRIAL_VERSIONS 枚举 12.0 到 37.0,这 12 个版本各自构建 Win32 和 Win64 两次。CB_TRIAL_WIN32_VERSIONS 列出 10 个版本,CB_TRIAL_WIN64_VERSIONS 只有 9 个,因为 XE5 有 C++Builder 包项目,却没有 Win64 包启动对象 c0pkg64.o。12 加 12 加 10 加 9 等于 43。只运行其中四个,然后称代码库可移植,是分类错误,也是让问题发生的具体错误
HotXLS 还从相反方向遇到过相同形态的问题。新单元如果通过 uses 子句可达,却不在 .cbproj 文件列表中,那么在 Delphi 下仍能完美编译,因为 dcc 会隐式将未列出的单元拉入包,最多发出 W1033 提示。C++Builder 只会为单元生成 .obj,而单元名称必须出现在 <DelphiCompile> 中,因此相同代码会在 ilink 阶段以未解析外部符号失败。一套工具链会隐藏另一套工具链会捕获的问题。这正是应该运行矩阵,而不是信任代表性编译器的全部理由
旧 Win32 编译器拒绝的硬类型转换
五个拒绝中有两个是同一个 bug 换了衣服:把硬类型转换应用于浮点表达式,而不是变量。在 Win32 上,旧编译器通过 x87 栈计算算术,因此涉及 Double 的加法会以 80 位额外精度执行,静态类型变成 10 字节的 Extended。把 10 字节缩窄为 8 字节 TDateTime 不是合法类型转换,编译器会以 E2089 Invalid typecast 报错
令人抓狂的细节是变量形式没有问题。TDateTime(Serial) 在矩阵中的每个版本都能编译,因为 Serial 已经是 8 字节,转换保持大小不变。只要对它做任何加法,表达式就在底层扩大。修复不是采用更宽的转换,也不是加入条件定义,而是停止转换:隐式的实数到实数赋值会在 HotXLS 支持的所有编译器上正确转换,而且它表达的正是代码的含义
// XE5(Win32)拒绝:每次加法都按 10 字节的 Extended 计算,
// 10 到 8 字节的缩窄转换会触发 E2089
if Dates1904 then
Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
Value := TDateTime(Serial + 1)
else
Value := TDateTime(Serial); // 这一处可接受:没有加法
// 版本安全写法:让实数到实数的赋值完成转换
if Dates1904 then
Value := Serial + XLSDate1904Offset
else if Serial < 60 then
Value := Serial + 1
else
Value := Serial;
// 单元格值打包器中还有同类拒绝:对整数做硬 Double 转换
// 直接除法即可,运算符已经会产生实数
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then // E2089
;
if (Scaled = intVal) and (intVal / 100 = AValue) then // 可移植
;
Serial < 60 分支处理的是 1900 闰年的虚构日期,而不是 off-by-one:序列号 60 是 Excel 不存在的 1900-02-29,因此低于它的序列号在交给 DecodeDate 前需要补一天。可移植性工作绝不能悄悄改变这类逻辑,这正是这里移除转换却保留算术不变的原因
nil 作为过程参数时会发生什么
当期望的是过程类型时,向旧编译器传入裸 nil 无法在重载解析阶段绑定。HotXLS 中的调用点是 ResolveIndexedColor,它有重载,并接受大多数调用方不需要的 TXLSTryResolveSystemColor 回调。新编译器可以将 nil 解析到过程参数并选择正确重载,XE5 却不能;诊断指向重载集合而不是参数,因此很容易浪费二十分钟
可移植的答案是给空回调一个类型。过程类型的单元级变量会由语言初始化为零,因此即使没有初始化器它也是 nil,同时还携带旧解析器所需的类型信息。如果单元级变量显得过度,使用显式赋值为 nil 的类型化局部变量也能完成同样的工作
var
// 旧编译器的重载解析无法绑定裸 nil 过程字面量
// 类型化且零初始化的变量可以完成绑定
NilSystemColorResolver: TXLSTryResolveSystemColor;
// ...
FWorkbook.ResolveIndexedColor(AIndexedColor, xicsBiffIcv, ARole,
NilSystemColorResolver, Resolution);
// 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;
要注意,这是真正的语言级差异,不是值得用定义绕过的编译器 bug。零初始化变量在矩阵的每个版本上都正确,只增加一行,因此这里完全不需要条件编译。只有平台在版本之间确实不同的地方才使用 {$IF CompilerVersion},而本批次恰好只有一处属于这种情况
受保护的 VCL 方法会随版本移动
TPicture.LoadFromStream 在当前 VCL 中是 public,在 HotXLS 支持的旧版本中却是 protected,因此直接调用现在能编译,换到旧版本就会失败。HotXLS 使用它验证工作表背景图像载荷确实能够解码,这是 HTML 导出器提交嵌入字节前运行的一项完整性检查。经典 Pascal 答案适用于这里:在同一单元中声明一个后代类,纯粹为了扩大可见性,然后在调用点通过它进行转换
type
// TPicture.LoadFromStream 在库支持的较旧 VCL 版本中是 protected;
// 同单元后代类将其暴露出来
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);
这个访问器类技巧在这里是安全的,因为后代类不增加字段,也从未实例化;转换只改变编译器允许你命名的内容。不过声明处仍然值得保留注释,否则只在当前 IDE 上构建的读者会把这个类型看成无意义的声明。背景图像处理还会出现在自定义 VCL 网格渲染路径中,同一个已解码载荷会被送到屏幕工作表
GdiplusStartup 的令牌类型改变了两次
本批次中唯一真正需要条件编译的拒绝,是 GdiplusStartup 的 var 参数类型,它在不同 VCL 世代之间发生改变,没有一种写法能在所有地方有效。逐版本探测锁定了实际行为:12.0 到 20.0 分支只接受 Cardinal,21.0 和 22.0 分支只接受 THandle 或 ULONG_PTR,23.0 和 37.0 则两者都接受。换成发行版名称,就是 XE5 到 10.3 Rio 使用 Cardinal,10.4 Sydney 起使用 THandle。由于 12.0 到 22.0 的两个接受范围没有交集,所以不存在无条件声明;保护条件以 CompilerVersion >= 34 为界,也就是 Sydney,并将调用完全限定为 Winapi.GDIPAPI.GdiplusStartup,避免单元解析顺序在范围中间的某个版本替换出不同声明
function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
StartupInput: TGdiplusStartupInput;
// GDIPAPI 的 GdiplusStartup var 参数类型跟随 VCL 世代
// 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');
// ... 编码 ...
end;
这是页面图像导出器的 TIFF 分支,因此如果这里出错,影响范围就是整个光栅导出表面,包括将单元格范围导出为单张图像所描述的路径。还要注意保护条件没有声称什么:ULONG_PTR 和 THandle 在两个平台上都是相同宽度,因此选择的是声明使用哪个标识符,而不是 32 位与 64 位正确性之间的区别
第一次探针运行为什么什么也没报告
版本探针第一次什么也没报告,是因为 res=$(...) 赋值发生在子 shell 中,无法传播回父 shell。dcc32 成功时退出码为 0,因此应该捕获退出码作为信号;脚本却把它捕获到一个只存在一行的变量中。每个分支都返回空结果,输出看起来像是探针根本没有编译任何内容,而事实正是如此
第二个失败更糟,因为它产生了错误答案而不是没有答案。探针通过统计匹配 Error 的行来分类分支,而 Delphi 不会在每个致命错误前加上这个单词。F1026 File not found 是致命错误,却不会匹配,因此完全无法解析某个单元的探针被打成了干净通过。XE5 不提供 Winapi.GDIPOPS.dcu,第一次探测正好遇到它,却错误地变成绿色。由此得到的规则很窄,但值得直说:应根据生成的产物或编译器自己的摘要行判断编译器探针,绝不要通过 grep 输出中的关键字来判断。对 stderr 中的 Error 做 grep 是一种在最不该失败的方向上失败的启发式,会静默报告成功
支持十年编译器实际要付出什么
诚实的账目是:这里的代码改动很琐碎,流程改动却不琐碎。五个拒绝中有四个是通过写更普通的 Pascal 修复的,而不是添加版本机制:去掉转换、用除法替代转换、给 nil 一个类型、声明访问器类。只有 GdiplusStartup 配得上一个 {$IF}。只要不让硬转换和最新编译器习惯一开始就积累起来,覆盖 XE5 到当前版本的代码库不会变成条件定义丛林
真正的成本是构建时间和纪律。43 个分支是一个很慢的脚本,这正是它逐渐漂移到打包时间,最终被完全遗忘的原因。可辩护的中间方案是保留快速的四脚本循环用于迭代,同时按一个无法跳过的计划运行完整矩阵,因为故障模式不是你会注意到的构建损坏,而是一个已经十二个版本没有被支持、却仍然被宣称支持的 IDE
这项义务也是交付原生组件的另一面。HotXLS 只通过 Object Pascal 读写 XLS、XLSX 和 ODS,不需要安装 Excel,也不依赖 COM,这正是无 Office 工作簿自动化可以在锁定服务器上实现的原因。同一特性也意味着编译器就是完整的平台契约,因此矩阵中的每个版本都是必须重新验证的承诺,不能靠假设
这里讨论的跨编译器构建矩阵和版本安全代码属于 HotXLS Delphi Spreadsheet Component,支持从 XE5 到当前版本的 Delphi 和 C++Builder,并为每个受支持的 IDE 提供预构建库二进制文件