Swift 处处可达:使用互操作性在 Windows 上构建

这篇文章最初发布于 The Browser Company 的 Speaking in Swift,标题为“互操作性:Swift 的超能力”。


多年来,Swift 有意的设计选择造就了一种语言,它展示了灵活性和兼容性如何不必以可用性为代价。这些设计选择之一是 Swift 对与其他语言的本地互操作性的关注。这种灵活性使其能够在各种环境中愉快地构建丰富的原生 Swift 体验。

传统上,当两种语言需要互操作时,两种语言边界处的函数调用,也称为外部函数接口 (FFI),将使用 libffi 等库通过 C 语言进行。这种方法有一些缺点,例如产生运行时性能成本,并可能产生额外的样板代码。相反,Swift 嵌入了 clang(C 和 C++ 编译器)的副本,clang 能够直接在语言之间进行翻译,从而避免了代码大小和运行时性能方面的损失。这种程度的互操作性与现有系统完美结合,并支持在现有 C 库之上构建复杂的软件。

Windows API

在构建丰富的原生应用程序时,互操作性的一个重要用例是调用特定于平台的 API 的能力。Windows API 表面反映了其悠久的历史;保持向后兼容性的要求导致了不同形式的 API 的积累。因此,API 的很大一部分是旧的且足够底层,可以用 C 语言定义。

由于 Swift 使用 clang 而不是 libffi 来访问 C 函数和数据类型,因此 Swift 编译器使用了 clang 的一个称为(头文件)模块的功能。Clang 模块将一组声明捆绑在一起,识别哪些声明属于特定的库,它可能依赖于哪些其他模块,以及声明的语言是什么。这是通过引入一个名为 module.modulemap 的辅助文件来完成的,该文件包含模块的定义。

因此,要访问 Windows API,我们必须将 Windows SDK 模块化为一个或多个 clang 模块。幸运的是,这不仅仅是一个理论上的想法。Swift 工具链以 WinSDK clang 模块的形式包含 Windows SDK 的模块定义。为了进一步改进这些定义,Swift 模块覆盖了 clang 定义,以在某些情况下提供更友好的 Swift 定义。这公开了 Windows SDK 的 C API 表面积,尽管不包含所有更现代的 API,但使我们能够在 Windows 上构建各种命令行和 GUI 应用程序。

使用 Swift/Win32 编写的 GUI 应用程序的屏幕截图,显示了各种标准控件 使用 Swift/Win32 的 GUI 应用程序,它为较旧的、基于 C 的 Windows UI API 提供了一层 Swift 语法便利。

然而,现代 API 不仅仅使用 C 公开,Windows SDK 的很大一部分以 C++ 公开。意识到 Swift 开发人员可能希望访问的 C++ 代码软件生态系统非常庞大,Swift 5.9 引入了对其语言级互操作性扩展到 C++ 的支持。尽管虚拟方法和可复制类型尚不可用,但随着 Swift 的 C++ 互操作性的成熟,Swift 可用的原生平台 API 表面也将增长,以包括 Windows SDK 中的大多数 C++ API。

这种 C++ 互操作性使一系列新的库(不仅仅是平台 API)可供 Swift 使用。这使 Swift 代码还可以利用 C++ 社区几十年编写的各种高性能、跨平台库。例如,Firebase 是一种常用的云计算服务,并用于许多现代产品中,包括 The Browser Company 的浏览器 Arc。尽管有一个适用于 Firebase 的 Swift SDK,但它仅限于 Apple 平台,并且基于 Objective-C。但是,还有一个跨平台 C++ SDK 可用。现在,借助 C++ 互操作性,可以将此 C++ SDK 公开给 Swift 客户端。正在使用 swift-firebase 构建这样的桥梁。利用这些 C++ 库,可以构建原本难以构建的跨平台 Swift 软件。

组件对象模型 (COM)

虽然库是共享代码的一种机制,但它们不是唯一的方法。另一种代码共享风格是通过进程间通信 (IPC) 实现,IPC 允许两个独立的应用程序相互通信并相互公开功能。Windows 上流行的一种此技术的实现称为 COM(组件对象模型)。

微软在 1990 年更高层次地探索了这个想法,将 DDE(动态数据交换)演变为“对象链接和嵌入”或 OLE。该方法旨在启用自定义文档处理程序的共享,这些处理程序可以嵌入到新应用程序中,而无需重写格式的解析器和渲染器。为了跨进程共享应用程序的实现,应用程序可以实现定义良好的接口(例如 IOleObject),这些接口可以被其他进程使用。最终,OLE 将演变为后来的组件对象模型或 COM。

COM 的设计灵活而强大,使其被广泛采用为多种环境中的通用设计模式。CoreFoundation 为其插件模型采用了它。CFLite 及其各种分支为 Linux 带来了 COM 的实现。XPCOM(跨平台组件对象模型)类似于 COM,并通过 Mozilla 的广泛使用而广受欢迎,Open Office 的 UNO 技术也是如此。该模型甚至通过使用基于 COM 模型的 IOKit 框架进入了驱动程序开发领域,用于内核驱动程序。

COM 的核心是定义接口(通常在接口定义语言或 IDL 中完成),这些接口通过同一地址空间中的库或通过 IPC 的另一个进程公开功能。接口由全局唯一的接口 ID 标识,并且都继承自名为 IUnknown 的基本接口。IUnknown 公开了 COM 的两个基本操作

  1. 对象生命周期管理
  2. 访问对象的功能

与 Swift 类似,对象生命周期管理通过引用计数实现,在 COM 中通过 AddRefRelease 方法公开。访问对象的功能通过 QueryInterface 方法实现,允许使用者动态请求对象的功能。由于使用者动态查询特定的 COM 接口,因此我们无法在构建时静态识别操作。但成本仅限于几个指针间接寻址,类似于 C++ 的虚拟方法,这使得 COM 的性能开销可以忽略不计。

Swift 中使用 C 互操作的 COM 支持

COM 不仅为动态处理软件提供了一个接口,而且还是一个应用程序二进制接口或 ABI,这意味着它定义了参数的传递方式和函数调用的排列方式。如果我们想从 Swift 与 COM 接口通信,我们需要确保我们符合这些 ABI 要求。鉴于 FFI 的通用语言是 C,COM 的 ABI 可以用 C 表示。因此,作为第一步,IUnknown 在 C 中是什么样的?

typedef struct IUnknownVtbl {
  ULONG (STDMETHODCALLTYPE *AddRef)(IUnknown *pUnk);
  ULONG (STDMETHODCALLTYPE *Release)(IUnknown *pUnk);
  HRESULT (STDMETHODCALLTYPE *QueryInterface)(IUnknown *pUnk, REFIID riid, void **ppvObject);
} IUnknownVtbl;

struct IUnknown {
    const struct IUnknownVtbl *lpVtbl;
} IUnknown;

如果我们要用 Swift 描述它,我们希望它是一个带有几个约束的协议

// typealias IID = WinSDK._GUID

public typealias REFIID = UnsafePointer<IID>

public protocol IUnknown: class {
  class var IID: IID { get }

  func AddRef() -> ULONG
  func Release() -> ULONG
  func QueryInterface(_ riid: REFIID, _ ppvObject: UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> HRESULT
}

extension IUnknown {
  func QueryInterface<Interface: IUnknown>() throws -> Interface? {  }
}

协议声明上的 : class 为协议添加了类约束,表明任何符合类型的类型都必须是 Swift 中的类。敏锐的读者会发现 IUnknown 和 Swift 中类约束类型之间的语义相似之处。class Swift 中的类型通过 ARC 采用引用计数,而 COM 通过 MRC(手动引用计数)执行相同的操作,这解释了 AddRefRelease 方法。剩下负责动态查询 COM 接口的 QueryInterface 方法,它映射到 Swift 的类型转换操作。由于无法为 Swift 中的类型提供自定义类型转换操作,因此 QueryInterface() 方法是 as 关键字的一种有趣的拼写。这表明,从概念上讲,IUnknown 只是另一种说法“我在其他地方实现了 Swift 中的类类型”!

由于 COM 概念与 Swift 如此巧妙地桥接,我们现在可以在 COM 和 Swift 之间构建桥梁。相关代码可在 Swift/COM 获得,并演示了与 COM 接口对接的可行性。例如,Windows 通过 DirectX API 提供 3D 加速,DirectX API 公开为一组 C++ 和 COM 接口。DXSample 使用 COM 桥接接口来实现配备着色器的 3D 加速立方体,以演示这种桥接在实际场景中是可能实现和使用的。

接口提供了如何与某些外部类型交互的定义,甚至可能是在不同语言中实现的类型。当使用其他人实现的接口(例如 DirectX 类型)时,我们会收到指向 IUnknown 的原始指针。COM 接口的原始指针表示形式使用起来很麻烦。包装指针以抽象间接寻址使 COM 更容易理解。

open class ID3D12Object: IUnknown {
  public override class var IID: IID { IID_ID3D12Object }

  public func SetName(_ Name: String) throws {
    _ = try perform(as: WinSDK.ID3D12Object.self) { pThis in
      try CHECKED(pThis.pointee.lpVtbl.pointee.SetName(pThis, $0))
    }
  }
  
}

也可以在 Swift 中实现 COM 接口,提供可以从 C/C++ 代码调用的 Swift 实现,尽管此支持尚处于起步阶段,并将随着 Swift 的 C++ 互操作性支持而发展。由于 COM 具有严格的 ABI,一旦正确构造对象,就可以轻松地跨语言边界传递它,因此,Swift 类型可以轻松地传递给任何 COM 客户端。

Swift 中使用 C++ 互操作的 COM 支持

Swift 中不断发展的 C++ 互操作性使 COM 到 Swift 的桥接变得更简单。COM 中的接口模型与 C++ 中的类非常相似。COM 接口直接映射到 C++ 类,COM 接口上的每个函数都映射到 C++ 类型上的虚拟方法。对于作为 COM 类型公开的 Windows API(例如 DirectX API),COM 接口主要作为 C++ 类公开,并具有一些可选性来获取接口的 C 表示形式,以便桥接到其他语言。随着 Swift 的 C++ 互操作性支持的改进,可以将 COM 接口作为 C++ 类导入,这些类自然地桥接到 Swift 类型。这减少了我们在通过 C 桥接到 COM 时看到的样板代码。在撰写本文时,Swift 的 C++ 互操作对虚拟方法调度的支持正在开发中,并且很快就能简化 COM 访问。

Swift 在 C++ 互操作性方面的工作也以其他方式帮助了我们桥接 Swift 和 COM 的工作,使用 SWIFT_SHARED_REFERENCE 注释为 Swift 中的引用计数外部类型添加了支持。由于 COM 提供了引用计数接口,我们可以使用 SWIFT_SHARED_REFERENCE 属性来标记 COM 接口,以便在导入类型时利用 ARC 并免费获得内存管理,从而避免一类内存安全问题。

改进 Swift 语言对 COM 的支持

由于当前 C++ 互操作性的限制,当尝试桥接 COM 接口时,我们必须回退到互操作性的共同分母 - C。当实现包装器类型以使用 COM 接口时,我们注意到这需要大量的样板代码。Swift 的目标之一是拥有清晰、富有表现力的代码,但这无疑会降低代码的清晰度和表现力。一个值得适当演进提案的想法是通过注释扩展 Swift 语言,以更好地支持 COM。想象一下,只需使用如下属性注释类型,即可声明类型可供 COM 访问

@COM(IID: IID_ICustomInterface, CLSID: CLSID_CustomInterface)
open class CCustomInterface: ICustomInterface {
  override open func QueryInterface<Interface: IUnknown>() throws -> Interface? {
    switch riid.pointee {
    case IID_ICustomInterface, IID_IUnknown:
      return Unmanaged<Self>.passRetained(self)
    default:
      return nil
    }
  }
  
}

Swift 宏可以帮助减轻一些样板代码,但为了真正将 Swift 类型桥接到 COM 中,需要考虑对象布局。由于 COM 是 ABI,因此对象在内存中的表示方式必须与 COM 规定的方式相同。控制对象布局会影响 ABI,而这并非仅靠宏就能实现的。我们可以在语言级别实现此 COM 属性的一种方法是向对象添加撕裂入口,该入口将符合 COM 的 ABI 要求,作为 Swift 对象布局的一部分(通过选择加入),从而实现与系统的更透明的桥接,而无需手动重建 vtable。

Swift 正在进行的分布式 Actor 工作也与 COM 非常吻合。分布式 COM (DCOM) 允许 COM 对象具有网络透明性,并支持构建强大的分布式系统。分布式 Actor 不强制要求线路格式,这意味着我们甚至可以重用 DCOM 的标准线路协议 (DCE/RPC)。这将使 Windows 上的原生 Swift 应用程序能够轻松地从命令行应用程序扩展到大规模分布式系统。

Windows 上的互操作性

Swift 的互操作性工具库使其成为在现有平台上构建丰富的原生应用程序和库的强大语言,并以其改进的内存安全性和人体工程学提供了 C 和 C++ 的绝佳替代方案。特别是在 Windows 上,互操作性功能使我们能够访问非常大量的系统 API。最重要的是,由于 COM 在 Windows 生态系统之外使用,因此 Swift 与 Windows 系统 API 集成的改进(例如上面描述的本机 COM 桥接)也将帮助其他平台!C++ 互操作性、宏和分布式 Actor 等新功能正在为以更可移植的方式编写应用程序开辟全新的机会。

这次进军 Windows 以及我们对 Swift 互操作性方法的探索,为我们提供了关于 Swift 的互操作性工具如何使我们能够构建可以访问平台 API 的跨平台应用程序的良好基础。