把 C++ 方法暴露给 Java、Dart 或 Objective-C,单独看都不难:JNI、FFI 和 Objective-C++ 已经提供了足够的基础设施。真正困难的是,当项目同时存在多种语言,并且接口数量持续增长以后,如何让每条调用链都拥有一致的类型语义、对象身份和生命周期。

如果每个模块都手写一层桥接代码,问题通常不会在第一天出现,而是在半年后集中爆发:同一个接口在不同平台上的空值规则不一致;回调对象被提前释放;父类参数传入子类后丢失真实类型;某次同步调用恰好发生在错误线程;一个字段新增后只改了三端中的两端。此时“胶水代码”已经变成了一个没有规范、没有测试矩阵的隐式框架。

这篇文章讨论的不是某个具体库,而是一套可复用的设计方法:用 IDL 定义语言无关的接口,以 C++ 作为可选的运行时枢纽,由生成器产出各端适配代码,再用一套跨语言引用协议维持对象身份与生命周期。

一、先明确框架真正要解决什么

跨语言框架至少承担五类职责:

  1. 接口一致性:方法、字段、静态成员、继承关系只定义一次。
  2. 类型转换:基础类型、字符串、容器、二进制、协议对象和自定义对象有明确映射。
  3. 调用派发:任意一端实现的对象,都能被其他语言调用。
  4. 对象管理:同一个逻辑对象跨过多次边界后,仍然只有一个身份和一套生命周期事实。
  5. 运行约束:线程切换、同步返回、异常传播和销毁顺序不能依赖调用者“刚好做对”。

这意味着框架的核心产物并不是几组 native 方法,而是一个小型的类型系统和对象运行时。

一个比较清晰的分层如下:

IDL 源文件


词法/语法解析 ──> AST ──> 语义检查

          ┌──────────────┼──────────────┐
          ▼              ▼              ▼
      C++ 生成器      Dart 生成器    Java/ObjC 生成器
          │              │              │
          └──────────────┼──────────────┘

               跨语言运行时与对象注册表

解析器只负责“读懂语法”还不够。很多错误应该在生成阶段就被拒绝,例如:异步方法却声明了同步返回值、某语言不支持的嵌套泛型、父接口与子接口字段重名、非空参数给了 null 默认值。越早失败,越少把错误推迟到运行期。

二、IDL 应该描述语义,而不是复刻某一种语言

IDL 最容易走偏的地方,是照着当前主语言设计语法。例如用 C++ 的指针语法表达空值,用 Java 的类规则表达继承,或把 Dart 的 Future 当成所有语言共同的异步模型。这样做生成第一门目标语言很顺手,扩展到第二、第三门语言时却会不断打补丁。

更合适的做法,是只保留各语言能够共同理解的语义。一个实用的类型集合通常包括:

IDL 类型C++DartJava/Kotlin说明
bool/int32/int64/float/double对应定宽类型bool/int/double基础类型需定义溢出与有符号规则
stringstd::stringStringString统一为 UTF-8 还是平台字符串要明确
bytesbuffer/spanUint8Listbyte[]/ByteBuffer所有权比类型名更重要
list<T>std::vector<T>List<T>List<T>限定允许的元素类型与空值
enumenum classenum 或封装类enum要处理未知枚举值
messageProtobuf 对象Protobuf 对象Protobuf 对象跨边界通常以序列化数据传递
interface/class智能指针wrapperwrapper需要对象身份和引用协议
function/callback函数对象或接口closureSAM/接口本质上也是一个跨语言对象

还需要把以下语义直接纳入 IDL,而不是藏在生成器约定中:

  • 参数和返回值是否可空;
  • 方法是实例方法还是静态方法;
  • 调用是同步还是异步;
  • 字段是只读、可写、静态还是常量;
  • 接口继承关系;
  • 默认参数及其合法范围;
  • 某个声明是否只生成到指定平台;
  • 兼容旧接口所需的重命名或补丁规则。

异步不是简单加一个关键字

不同语言对异步的表达方式不同。比较稳妥的中间语义是“调用立即返回,结果通过一次性完成回调交付”。各端再降级到本地习惯:

IDL async method
  ├─ C++: method(args, Completion completion)
  ├─ Dart: Future<Result> method(args)
  ├─ Kotlin: suspend fun method(args): Result
  └─ Objective-C: method:completion:

这样,IDL 不必绑定到 Future 或协程的具体实现,同时也能在生成层统一处理完成一次、取消、错误和对象保活。

三、生成代码要薄,但不能没有运行时

一个常见误区是追求“纯代码生成”,希望生成器把所有逻辑直接展开,从而不需要运行时。接口少时可行,接口多以后会产生巨量重复代码,并让对象注册、引用管理、错误检查散落在每个生成文件里。

更合理的边界是:

  • 生成层负责静态信息:类型转换、方法编号、参数编解码、具体 wrapper、平台声明。
  • 运行时负责动态信息:对象注册、真实类型、线程上下文、引用计数、弱引用、错误状态。

以一个接口 Player 为例,生成物通常不只是一个类:

player.hpp              C++ 抽象接口和代理声明
player_cpp_binding.cc   C++ 调用目标语言的适配
player.dart             Dart 接口与 wrapper
player_ffi.dart         Dart FFI 声明和入口注册
Player.java             Java 接口或抽象类
PlayerJni.cpp           JNI 参数转换与派发
Player+Bridge.mm        Objective-C++ 适配

生成文件应当可重复生成、禁止手改。确实需要定制时,使用扩展点、partial class、类别或单独的 patch 配置,而不是修改生成结果。否则下一次升级生成器时,很难判断哪些差异是业务定制,哪些只是旧版本产物。

四、双向调用的关键是“对象由谁实现”

跨语言接口不应默认只有 C++ 实现。更一般的模型是:接口可以由任意语言实现,其他语言拿到的是代理。

假设一个对象由 Dart 实现并传给 C++:

Dart concrete object


Dart-side identity handle
        │ FFI

C++ proxy implementing the same abstract interface

反方向,C++ 对象传给 Dart:

C++ concrete object


C++ identity registry
        │ FFI

Dart wrapper forwarding calls to C++

所以 wrapper 至少要记录两项信息:对象 ID,以及对象的实现端。仅传裸指针通常不够,因为垃圾回收语言不能理解这个地址的所有权,地址复用也会让过期句柄指向错误对象。

一种常见的句柄结构是:

struct ObjectHandle {
  uint64_t id;
  uint32_t generation;
  Language owner;
  TypeId runtime_type;
};

generation 用于识别已经释放后又复用的槽位,runtime_type 用于恢复真实类型,owner 决定调用应被派发到哪一端。

五、继承与多态:必须按运行时类型创建代理

假设接口 Dog 继承 Animal,一个实际由 Dart 实现的 DogAnimal 参数传入 C++,随后又被传给 Java。如果桥接层只看参数的静态类型,就会在 Java 端创建 Animal 代理,Dog 的能力永久丢失。

正确做法是始终保留对象的运行时类型:

  1. 本地对象第一次跨边界时,记录真实 TypeId
  2. 目标语言根据 TypeId 查询构造器注册表。
  3. 创建最具体的 wrapper,再以父接口类型返回给调用者。
using ProxyFactory = std::function<LocalObject(ObjectHandle)>;

LocalObject makeProxy(const ObjectHandle& handle) {
  auto it = factories.find(handle.runtime_type);
  if (it == factories.end()) {
    return makeUnknownBaseProxy(handle);
  }
  return it->second(handle);
}

反过来,C++ 对象输出到其他语言时,可以让基类提供虚拟的 runtimeType()toForeignObject(),由派生类返回实际类型。这里不能只依赖模板参数或调用点的声明类型。

还要定义版本不一致时的策略:旧客户端遇到新子类,是退化成已知父类,还是拒绝创建?对长期演进的接口,退化为父类通常比直接崩溃更可控,但前提是父类契约真的完整。

六、跨语言生命周期不是“全部改成 shared_ptr”

各语言的内存模型并不等价:

  • C++ 的 RAII 和引用计数能确定性析构,但引用环需要显式打破;
  • Objective-C ARC 也是引用计数,但有自己的 autorelease 和弱引用语义;
  • Java、Dart 使用追踪式 GC,对象何时回收并不确定;
  • FFI/JNI 句柄还有线程和虚拟机生命周期限制。

因此,桥接层需要定义一套语言无关的对象协议,而不是假设某一端的内存模型能够覆盖所有语言。

6.1 强引用链

当目标语言的 wrapper 被业务强持有时,它必须向对象所有端表达“这个逻辑对象仍然存活”。可以是增加运行时引用计数,也可以是创建全局引用,但必须满足:

本地 wrapper 存活
    ⇒ 跨语言句柄有效
    ⇒ 实现对象不会被释放

同一个对象多次跨边界时,应从 identity map 返回已有 wrapper,而不是不断创建新代理。否则不仅浪费对象,还会使引用计数、相等判断和监听器移除全部失真。

6.2 弱引用比想象中更复杂

如果 C++ 持有一个跨语言对象的弱引用,不能只把 C++ 侧的 shared_ptr 换成 weak_ptr。完整链路可能是:

C++ weak proxy ──> foreign weak handle ──> foreign object

链上任何一段偷偷使用强引用,弱引用就退化成泄漏;但如果所有中间代理都只被弱持有,它们又可能先于真实对象消失,导致 lock() 时找不到通道。

一个可行的折中是:真实对象使用弱链表达可达性,同时由对象注册表反向强持有必要的轻量代理;当真实对象被回收时,通过弱回调移除注册项并释放代理。于是 lock() 的含义变成一次跨语言原子操作:

  1. 验证句柄和代数仍然有效;
  2. 尝试把目标语言弱引用提升为强引用;
  3. 成功后为本次返回值建立新的强引用协议;
  4. 任一步失败都返回空,不暴露半失效对象。

6.3 析构必须幂等

跨语言释放可能从多个入口发生:业务主动关闭、GC finalizer、引擎退出、进程生命周期变化、运行时清表。释放协议必须允许重复调用:

bool release(ObjectHandle h) {
  auto entry = registry.find(h.id);
  if (!entry || entry->generation != h.generation) return false;
  if (entry->released.exchange(true)) return false;
  // detach foreign handles, then drop the implementation reference
  return true;
}

幂等并不等于可以忽略顺序。一般应先阻止新调用,再等待或取消在途调用,最后解除各端引用并删除注册项。

七、线程约束必须进入接口契约

某些虚拟机允许 native 线程发送异步消息,却不允许在任意线程同步执行语言函数。尤其是带返回值的“C++ 同步调用 Dart”之类接口,如果发生在非目标 Isolate 线程,桥接层没有安全的魔法可以把返回值立刻拿回来。

可选策略只有几种:

  1. 要求调用者已经处于目标线程,并在运行时断言;
  2. 把方法改成无返回值的投递;
  3. 把返回结果改成异步完成;
  4. 通过调度器切换到目标线程,但调用端必须接受等待、取消和重入风险。

框架应给每个方法生成线程元数据,并在 debug 环境主动失败。静默切线程虽然“好用”,却会隐藏锁内跨线程调用、同步等待和回调重入,最终形成更难排查的死锁。

八、生成器的质量取决于测试矩阵

生成器测试不能只验证文件是否生成。至少需要覆盖:

  • 每种基础类型的入参、返回值和边界值;
  • 可空与非空;
  • 字符串中的空字符、非 ASCII 字符;
  • 大小不同的二进制数据;
  • 同步、异步、错误和取消;
  • 对象由每一种语言实现,再传给其他所有语言;
  • 强引用、弱引用、循环引用和重复释放;
  • 接口继承、父类参数承载子类实例;
  • 静态方法、字段、静态字段、常量字段;
  • 默认参数与旧版本兼容;
  • 生成补丁和平台条件;
  • 多线程调用、运行时退出和在途调用销毁。

最有效的结构是用同一组 IDL 构造对称测试:C++ 实现一遍,Dart 实现一遍,Java 实现一遍,然后对所有调用方向跑同一份语义断言。这样才能发现“单端看起来正确、换一个实现端就失败”的问题。

九、把失败设计成可观察状态

跨语言错误如果只返回空对象,线上几乎无法定位。运行时至少应记录:

  • 调用方向、接口和方法编号;
  • 对象 ID、代数、实现语言和运行时类型;
  • 当前线程、目标运行时和 Isolate/VM 状态;
  • 失败发生在参数转换、派发、执行还是返回值转换;
  • 对象是否正在销毁;
  • 同一调用的序列号和耗时。

日志中不要直接写业务参数,可以只记录类型、长度、哈希或枚举后的错误码。这样既能排查问题,也不会把敏感数据带进日志。

十、最后的设计检查表

一套跨语言绑定框架是否可靠,可以用下面这些问题快速检查:

  • IDL 表达的是跨语言语义,还是某一门语言的语法翻版?
  • 新增目标语言时,是否需要修改已有业务接口?
  • 同一个逻辑对象跨边界往返后,能否保持身份一致?
  • 父接口参数传入子类实例后,真实类型是否还在?
  • 强引用和弱引用在整条跨语言链上是否一致?
  • 运行时退出时,能否先停止调用,再安全清理对象?
  • 不允许同步返回的线程场景,是否在接口层就被约束?
  • 所有语言实现方向是否都进入了自动化测试矩阵?

跨语言框架的价值不只是少写 JNI 或 FFI。它真正提供的是一份可执行的契约:接口如何演进、类型如何映射、对象由谁持有、调用在哪个线程发生,以及异常时留下什么证据。只有这些问题被系统化以后,多语言协作才不会随着业务规模增长而线性增加风险。