ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java到Delphi迁移实战:类型映射、工程适配与避坑

Java到Delphi迁移实战:类型映射、工程适配与避坑

简介:Java2OP for XE10是一款专门为Embarcadero Delphi XE10.2开发人员设计的工具,核心用途是把Java的JAR包解析并转换为Delphi工程可直接引用的PAS单元文件,让Delphi程序员不必重写底层代码就能复用大量Java库,适用于希望整合Java生态的跨平台项目。资源包共八个文件,压缩后约十六点四兆字节,其中主程序、两个JAR库、模板与配置文件、说明文档和运行日志各司其职,整体覆盖了从读取.class字节码、解析类与方法元数据,到最终生成PAS文件并支持跨语言调用的完整流程。目前已有两百五十八人学习下载,对涉及网络通信、消息解析等场景的Delphi开发者来说,具有直接参考价值。拿到这套程序后,可直接运行转换工具,配合模板与说明文档完成jar到PAS的转换;日志文件和缓存信息能帮助排查转换异常,理解类名映射、数据类型转换和调用约定等实操细节。尽管部分复杂Java特性需要手工微调,但整体足以支撑常见第三方库的接入,特别适合使用XE10.2版本、希望扩展跨平台开发能力的Delphi工程师。

1. java2op for xe10:把 Java 遗产搬进 Delphi 前要明白的事

java2op for xe10 这个标题,说的是把 Java 源码翻译成 Object Pascal,并让它在 Delphi XE10 工程里能编译、能运行、能维护。它不是某个新出的黑科技,而是很多老系统迁移时都会遇到的工程问题:服务端沉淀了大量 Java 算法和业务类,客户端却是 XE10 时代的 Delphi 项目,两边必须共用同一套逻辑。真正能落地的做法,是先对照语言差异定好映射规则,再用脚本搭骨架、手工补逻辑,回到 XE10 里调编译和运行参数。这篇笔记按“映射规则 → 转换流程 → 工程适配 → 避坑 → 验证”推进,让一个 Java 类经过你手里变成可交付的 Pascal 单元。适合正在面对 Java 与 Delphi 双栈复用、准备做代码迁移的开发者。

我接过几个类似需求,最深的体会是:这类迁移的难点从来不是“不会翻译”,而是“翻完之后行为和原版不一致”。类型越界、字符串编码、泛型擦除、对象生命周期,每一样都能在运行一小时之后突然爆发。所以我不建议上来就写代码,先把两类语言的边界画清楚。

2. 先立住转换的地基:Java 与 Object Pascal 的映射规则

2.1 类型映射:基础类型选错会让数值悄悄越界

Java 和 Object Pascal 都是静态类型语言,类型名看着像,范围和默认行为却差很多。下面这张表是我每次开工前都会先贴到文档里的对照表,它能避免一半以上的低级翻车。

Java 类型Delphi XE10 类型说明
byteShortIntJava 的 byte 是有符号 8 位,Delphi 对应 ShortInt;若语义是无符号 0~255 则用 Byte
shortSmallInt有符号 16 位,无歧义
intInteger有符号 32 位,最常用
longInt64Java long 恒为 64 位,不要写 LongInt
floatSingle都是 32 位浮点
doubleDouble都是 64 位浮点
charWideCharJava char 是 16 位 UTF-16 码元,别用 Char 映射成 AnsiChar
booleanBooleanDelphi Boolean 是 8 位,True 的值为 1
StringstringXE10 默认 UnicodeString,UTF-16 编码,和 Java String 语义最接近
byte[]TBytes无符号字节数组,表达 Java byte[] 的存储效果
List<T>TList<T>来自 System.Generics.Collections
Map<K,V>TDictionary<K,V>哈希表语义接近
Throwable / ExceptionException对应 SysUtils.Exception 体系

我在某个跨平台 Demo 里见过有人把 Java long 直接写成 Delphi 的 LongInt,结果 64 位订单号被截成 32 位,数据同步怎么都对不上。还有人把 Java boolean 转成 Byte,Java 里的 true 被当成 1 还好,一旦有代码期望非 0 即真,表现就会不一致。所以这里我宁愿多花十分钟做映射评审,也不愿意在运行期追数字。

实际转换时还要看变量的用途。Java 的 byte 在文件解析场景里经常代表无符号字符,如果原代码对字节值做了 0~255 的算术,我会改成 Byte 而不是 ShortInt。同理,Java 的 char 在 XE10 里优先用 WideChar,因为 Java 字符串和 Delphi UnicodeString 一样按 UTF-16 编码,char 和 string 互相截取时语义才吻合。

2.2 类、接口和继承:单继承与多接口的排法

Java 的继承模型是一个类可以 extends 一个父类,同时 implements 多个接口;Delphi XE10 的继承模型本质上也是单继承加多接口。Java 的类继承直接映射成 Pascal 的 class 继承,Java 的接口映射成 Delphi 的 interface,Java 类实现的多个接口逐一列在类声明里。规则简单,但落地有细节。

先看接口。Delphi 的 interface 默认继承自 IInterface,自带 QueryInterface、_AddRef、_Release 三个方法。如果转换成的接口只是用来做类型约束,实例由类自己持有,并不想走引用计数,那可以在声明里写上

type IRepository = interface procedure Save(const AData: string); end;

不强制写祖先,但 XE10 里这样声明的接口其实也隐含 IInterface 语义。常见做法是直接继承 IInterface,让接口引用自动管理对象生命周期。缺点是接口引用必须返回支持接口的对象,TObject 默认实现了这三个方法,所以转换类只管继承 TObject 即可,不必额外实现。但如果你转换的类原本继承自另一个 Java 父类,就要小心接口分派问题:Delphi 中接口方法不能和基类方法同名冲突,必要时给接口方法加 GUID 并显式声明 implements。

再说抽象类。Java 的 abstract class 加抽象方法,在 Delphi 中对应类里的 virtual; abstract 方法:

type TAbstractParser = class public function Parse(const AText: string): Integer; virtual; abstract; end;

Delphi 没有单独的 abstract 关键字修饰类,类里只要存在 abstract 方法,编译器就不允许直接实例化,效果等同。Java 的 final 类映射成 Delphi 里不写 virtual 方法的普通类;Java 的 static 方法映射成 Delphi 的 class 方法或常规类方法,取决于它是否依赖实例状态。

多个接口的实现要写成这样:

type TOrderService = class(TObject, IRepository, IAuditable) private function SaveInternal: Boolean; public procedure Save(const AData: string); procedure Audit(const AEvent: string); end;

Java 中 implements 的两个接口如果存在同名方法,Java 允许内部类分别实现,Delphi 需要通过方法别名或聚合接口来绕开。遇到这种情况,我一般的做法是拆成两个内部对象分别实现接口,再让主类转发调用。与其在语言特性上纠缠,不如先把复杂度隔离掉。

2.3 异常、泛型和匿名类的三个边界

异常处理是最容易被低估的一块。Java 用 try/catch/finally 三个块,Delphi 用 try/except/end 和 try/finally/end 两个组合。Java 的多个 catch 在 Delphi 里缩进成同一个 except 块里的多个 on E: Exception do 分支:

try DoSomething; except on E: EConvertError do HandleConvertError(E); on E: EAccessViolation do HandleAccessViolation(E); else HandleOther; end;

Java 的 finally 在 Delphi 里是紧跟在 try 后的另一段结构,它和 except 不能混在同一层里。如果同时需要资源清理和异常捕获,就得写两套:

try try UseResource; except LogException; end; finally FreeResource; end;

这个嵌套关系在转换脚本里很难自动生成,通常需要看原 Java 方法的语义手工判断。

泛型的差异更明显。Java 泛型有通配符和上下界,List 这样的写法在 Delphi 里没有直接等价物。由于 Java 在运行时做了类型擦除,很多容器到了 Pascal 侧就必须具体化。我一般把 List 转成 TList 或 TList ,具体看调用处取出来之后做什么运算;List 则只能转成 TList ,取出元素后做 is 类型判断。泛型方法同理,Java 里的 <T extends Comparable > 到了 Delphi 变成约束为 class 或 constructor 的泛型参数,但无法表达 Comparable 这样的接口约束,只能退一步让调用方传入一个比较函数。

匿名类在 Java 里很常见,例如 new Runnable() 或 Comparator。Delphi XE10 没有匿名类,但有匿名方法。常见做法是把单方法接口转成 TProc / TFunc 类型,把匿名内部类转成匿名方法:

list.ForEach( procedure(const X: TObject) begin ProcessItem(X); end );

多方法接口的匿名类不适合转成 TProc,提取成具名小类是更稳定的方案。别小看这个决定,某个模拟项目里我图省事把 Comparator 直接改成匿名方法传递,后来发现compare 逻辑里还要读外部状态,最后不得不重构为具名类。边界越早处理,后面的测试越省事。

3. 用脚本先生成骨架,再手工补全:java2op 的落地流程

3.1 一个最小可用的 Python 预处理脚本

真正做迁移时,我不会指望某个现成工具一键完成。常见做法是写一个几十行的脚本,把机械的、可重复的替换先做掉,留下需要人判断的部分。下面这个脚本是我每次开工前都会准备的,它只负责剥掉包名和 import,做基础类型替换,输出一个可读的 Pascal 骨架。

import re TYPE_MAP = { "int": "Integer", "long": "Int64", "float": "Single", "double": "Double", "boolean": "Boolean", "char": "WideChar", "String": "string", "byte[]": "TBytes", "List": "TList", "Map": "TDictionary", } def prepare_java_source(path: str) -> str: with open(path, "r", encoding="utf-8") as f: src = f.read() src = re.sub(r"package\s+[^;]+;", "", src) src = re.sub(r"import\s+[^;]+;", "", src) for java_type, delphi_type in TYPE_MAP.items(): src = re.sub(rf"\b{java_type}\b", delphi_type, src) return src

这里的逻辑很直接:先用正则把 package 和 import 整行删掉,再按单词边界替换类型名。为什么要按 \b 单词边界?因为不这么做会把“Integer”中间的“int”也换掉,把“StringBuffer”误换成“stringBuffer”。替换顺序也有讲究,要先替换 byte[] 再替换 byte,否则数组类型会被拆成两个碎片。

脚本只处理能确定的机械替换。public class、private 字段、方法体里的逻辑,脚本一概不动。我一般会在此基础上再加一步:把 Java 的方法参数名转换为 Pascal 惯用的 A 前缀,把类名加上 T 前缀,再输出一份“待手工处理清单”。这些转换规则直接写死在替换映射末尾,每次跑完脚本后,我再按清单逐类处理。这样做的好处是过程可重现,不会漏改。

3.2 一个示例:Java 类转换成的完整 Pascal 单元

看一个具体的例子。Java 侧是一个订单服务类,包含税率字段、商品列表和计算逻辑:

package com.example.core; import java.util.List; import java.util.ArrayList; public class OrderService { private double taxRate; private List<String> items; public OrderService(double taxRate) { this.taxRate = taxRate; this.items = new ArrayList<String>(); } public void addItem(String item) { items.add(item); } public double total() { double sum = 0; for (String item : items) { sum += priceOf(item) * (1 + taxRate); } return sum; } private double priceOf(String item) { return item.length() * 10.0; } }

经过脚本预处理和手工补充后,Pascal 单元大致长这样:

unit OrderService; interface uses System.Generics.Collections; type TOrderService = class private FTaxRate: Double; FItems: TList<string>; function PriceOf(const AItem: string): Double; public constructor Create(ATaxRate: Double); destructor Destroy; override; procedure AddItem(const AItem: string); function Total: Double; end; implementation constructor TOrderService.Create(ATaxRate: Double); begin FTaxRate := ATaxRate; FItems := TList<string>.Create; end; destructor TOrderService.Destroy; begin FItems.Free; inherited; end; procedure TOrderService.AddItem(const AItem: string); begin FItems.Add(AItem); end; function TOrderService.PriceOf(const AItem: string): Double; begin Result := Length(AItem) * 10.0; end; function TOrderService.Total: Double; var Item: string; begin Result := 0; for Item in FItems do Result := Result + PriceOf(Item) * (1 + FTaxRate); end; end.

这里有几个需要人判断的转换点。Java 的构造方法变成了 Delphi 的 constructor,参数名从 rate 改成了 A 前缀的 ATaxRate,目的是避免和字段名 FTaxRate 冲突。Java 的 List 只声明了接口类型,Delphi 的 TList 是具体类,必须在构造函数里 Create。Java 没有析构概念,Delphi 必须补 Destroy,把 FItems 释放。PriceOf 里的 item.length() 在 Java 里返回 UTF-16 码元数量,Delphi 的 Length(AItem) 在 UnicodeString 下同样返回 UTF-16 码元数量,行为一致,这个边界我特别确认过。

这段代码虽然简单,但已经覆盖了字段初始化、资源释放、字符串长度语义三个最容易出错的点。做迁移时不要只盯语法,更要盯这种隐式行为。

3.3 转换顺序:按依赖关系决定迁移批次

直接从头到尾把所有 Java 文件一次性转完,是大忌。我吃过亏:某次迁移里我先把高层的控制器类转了,结果它依赖的底层工具类还没转,编译错误像连锁反应一样,改完一个冒出三个。后来我把迁移顺序定为“纯数据类 → 工具类/组件类 → 服务类 → 入口/控制器”。

底层纯数据类没有外部依赖,转完就能编译,适合先用单元测试验证字段和构造函数。工具类通常只依赖数据类和标准库,转起来风险低。服务类依赖工具类和接口,需要等前面的类稳定后再动手。入口和控制器放在最后,因为它们的编译错误往往只是下游类问题的投影。

每个批次转完之后,立刻编译并运行现有测试,通过后再进入下一批。这个节奏看着慢,实际是整体最快的路径。如果团队多人并行迁移,还要把依赖关系写进任务分配文档里,避免两个人同时改同一个中间类。

4. 接进 XE10 工程:编译参数、Unicode 与内存管理

4.1 项目文件与搜索路径:让新单元进入编译链

把新转换的 .pas 单元放进 Delphi XE10 工程,有两种方式:直接加入项目文件 .dpr 的 uses 子句,或者通过搜索路径让编译器自动找到。我习惯在项目里维护一个 Source 目录,把所有新单元放进来,再在 .dpr 里显式引用,这样编译顺序清晰。

一个典型的小项目 .dpr 文件长这样:

program ClientApp; uses System.SysUtils, System.Classes, OrderService in 'Source\OrderService.pas', PriceCalculator in 'Source\PriceCalculator.pas'; {$R *.res} begin try Run; except on E: Exception do Writeln(E.Message); end; end.

uses 子句里的顺序会影响初始化顺序,但一般 unit 初始化不依赖其他同层 unit 时不用太担心。真正要注意的是文件名和 unit 名必须一致,Delphi 编译时按文件名找源文件。如果 OrderService.pas 里写的是 unit OrderService;,两者不一致会直接报“Unit not found”或拼写警告。

命令行构建在 XE10 下我一般用 MSBuild 而不是老的 dcc32,因为 .dproj 已经成了标准工程格式:

msbuild ClientApp.dproj /t:Build /p:Config=Debug /p:Platform=Win32

参数说明:/t:Build 表示执行编译,/p:Config 指定调试或发布配置,/p:Platform 指定目标平台。XE10 是跨平台版本,Win32 只是其中之一,换 Android 目标时平台名换成 Android,同时需要配置好对应的 SDK 路径。

4.2 Unicode 与字符串:XE10 的默认行为

XE10 的 string 默认就是 UnicodeString,内部按 UTF-16 编码,这跟 Java 的 String 表示方式一致,是转换中少见的利好。但 Java 代码里经常出现的 String.getBytes("UTF-8") 和 new String(bytes, "UTF-8") 不能直接翻译成字符串赋值,必须把边界函数改成显式编码转换:

// Java: byte[] bytes = text.getBytes("UTF-8"); // Pascal: function JavaGetBytesUtf8(const AValue: string): TBytes; begin Result := TEncoding.UTF8.GetBytes(AValue); end;

反向操作对应 TEncoding.UTF8.GetString(AValue, 0, Length(AValue))。这里 Length 参数必须用字节数组的长度,不能直接用字符串 Length。很多迁移后的乱码问题,十有八九是这里把字符数和字节数搞混了。

另一个坑是 Java 字符串的索引操作,比如 charAt(i) 在 Delphi 里对应 string 的下标访问 AValue[i+1],因为 Delphi 的 string 从 1 开始,Java 从 0 开始。如果 Java 代码大量使用 charAt 和 substring,建议把整套操作封装成工具函数,避免下标偏移到处出错。

4.3 内存管理:手动释放还是引用计数

Java 有垃圾回收,Delphi 桌面版没有。XE10 的桌面平台,TObject 对象必须手动释放;移动平台曾经有一代编译器的自动引用计数特性,但在 XE10 的桌面目标里并不存在。转换过来的类如果持有其他对象引用,必须在析构函数里处理,否则就会内存泄漏。

最简单的模式就是用 try/finally 保证释放:

var Service: TOrderService; begin Service := TOrderService.Create(0.1); try Service.AddItem('book'); WriteLn(Service.Total); finally Service.Free; end; end;

如果转换的类要跨多个方法传参,手动管理很繁琐,常见做法是让对象实现接口并采用接口引用计数。Delphi 接口引用的生命周期是自动的,赋值为 nil 或离开作用域时引用计数归零,对象自动释放。但要注意循环引用,两个接口对象互相持有对方时,引用计数永远不为零,内存照样泄漏。遇到这种情况,我会把其中一个引用改成弱引用或使用 System.WeakReferencing.TWeakReference。

X 平台的 ARC 行为在不同 XE10 子版本里有差异,如果工程目标同时包含 Windows 和 Android,我建议统一按手动释放来处理,不要依赖编译器版本差异带来的自动释放,否则换台机器或升级编译器后行为就变了。这种“换个环境就崩”的问题最让人头大。

5. java2op 转换后的避坑手册:现象、原因和现场处理

5.1 重载与默认参数:编译通过,调用却进错了分支

现象:Java 类里有多个重载方法,比如 calc(double) 和 calc(double, int)。转换时图省事,把第二个方法的参数写成了默认参数 procedure Calc(APrice: Double; AQuantity: Integer = 1)。结果界面代码调用 Calc(price) 时,编译器给出“Ambiguous overload”或直接选中了错误版本,运行时数值差了一截。

原因:Java 的重载是方法签名的一部分,Delphi 里的默认参数会生成隐式重载,两个方法的调用形式可以完全一样,编译器又优先匹配带默认值的版本,于是歧义产生。这不是编译器的错,而是转换时把两个本来不同的方法强行合并了。

解决:不要用默认参数代替重载,保留两个显式的方法声明,顶多拆成 CalcBase、CalcWithQuantity 这样的命名。如果原 Java 方法确实只有逻辑分支,那就在一个方法内部用 if 判断参数是否默认,而不是用默认参数语法。

5.2 泛型通配符与类型擦除:List<?> 变成 TList 后取元素报错

现象:Java 方法返回 List<? extends Number>,脚本机械替换后成了 TList 。调用方拿到元素后直接强转成 Double,运行时抛 Invalid Cast,测试用例全部失败。

原因:Java 泛型在运行时被擦除,List<? extends Number> 里面的具体类型只有开发者知道。Delphi 泛型是具体化的,TList 里放的就是 TObject,取出来想当 Double 用,必须先做类型判断。脚本无从判断原来的元素类型,只能变成 TObject。

解决:在转换时看调用点,凡是知道具体类型的容器,全部改成具体泛型,比如 TList 。确实需要装载多种类型的,保留 TList ,但取出后要写

var Item: TObject; begin for Item in List do begin if Item is TDouble then ProcessDouble(TDouble(Item).Value); end; end;

把 is 判断写清楚,再考虑强转。这个坑最容易出现在从 JSON 反序列化的数据上,因为 JSON 本来就是弱类型,Java 侧用通配符掩盖了不确定性,到了 Delphi 侧就暴露了。

5.3 字符串编码与静态单例:两个最隐蔽的状态污染源

这个子章节要写两个坑,它们都发生在运行一段时间后才暴露。

第一个坑:现象是从 Java 接口拿到的 JSON 字符串,经过转换后的函数处理变成乱码,或者长度截断。原因是边界处没有做编码转换,Java 侧的 byte[] 直接赋值给了 Delphi 的 string,或者反过来的 string 被当成了字节数组。解决方法是所有涉及字节流的地方统一走 TEncoding.UTF8 转换,禁止直接 CopyMem 或类型转换。

第二个坑:现象是 Java 静态方法 getInstance() 被转成 Delphi 的全局函数后,多次调用或模块卸载再加载时状态错乱,单例保存的配置变成残留值。原因是 Java 静态变量的初始化由 JVM 保证,Delphi 的全局变量初始化时机受 unit 初始化块顺序影响,类方法里的局部静态变量也不存在。

解决方法是把 getInstance 转成 class function,并在类内部用一个初始化完成的私有标志来托管:

type TConfigService = class private class var FInstance: TConfigService; class var FInitialized: Boolean; public class function Instance: TConfigService; end; class function TConfigService.Instance: TConfigService; begin if not FInitialized then begin FInstance := TConfigService.Create; FInitialized := True; end; Result := FInstance; end;

这种写法的好处是,即使编译器对全局初始化排序有问题,FInitialized 也会在首次访问时被修正。代价是线程安全问题:多线程环境下需要在类方法里加 TMonitor.Enter,或者用 XE10 提供的 TSingletonImplementation。业务场景简单时我通常先不上锁,避免过度设计。

6. 用单元测试和对照日志给转换结果上一道保险

6.1 单元测试先行:每个转换类至少三个用例

Java 类转完后,第一件事不是丢进业务代码里跑,而是写单元测试。我在 XE10 里用的是 DUnitX 测试框架,针对 OrderService 的测试至少要覆盖正常计算、空列表和边界值:

procedure TestOrderService.TestTotalWithTax; var Service: TOrderService; begin Service := TOrderService.Create(0.05); try Service.AddItem('book'); Assert.AreEqual(105.0, Service.Total, 1e-9); finally Service.Free; end; end;

Assert.AreEqual 里的第三个参数是浮点误差上限。Java 侧 double 和 Delphi Double 的运算结果可能差最后几位,不要用完全相等,否则无意义。每个转换类我都要求至少包含正常路径、异常路径、空数据三个用例,测试能过再谈接入业务。

6.2 对拍日志:同一组输入跑 Java 和 Pascal,对比结果

单元测试只能证明 Pascal 侧输出符合预期,不能证明和 Java 原版一致。我后来养成的习惯是做对拍:构造一组固定的输入数据,Java 原版程序输出一份结果文件,转换后的 Pascal 程序加载同样输入,输出另一份结果文件,再用差异比对工具对比。

对拍的数据集要有针对性:数值边界、空值、超长字符串、特殊字符、并发调用。我见过一个模拟项目级的迁移,普通数据全部一致,唯独传入 0 和负数时两边结果差一位小数,最后定位发现是负数取整方向不同。这种问题靠手工测试很难发现,对拍日志却能直接暴露。

我把对拍脚本放在迁移目录里,文件和输入输出格式统一为标准 JSON,Java 侧用 Main 方法读取,Pascal 侧写一个临时命令行程序读取,两边各自输出结果 JSON。对比工具不需要多花哨,普通的文本比对就行,关键是数据要覆盖边界。

吃过一次亏之后,我现在每个迁移模块都强制要求对拍记录放在项目文档里。没有对拍结果的转换类,不允许合入主干代码。这样虽然前期慢,但总比上线后半夜被叫起来修边界问题强得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进