外部函数接口 (FFI)#

我们已经介绍的是纯粹的计算。在现实中,需要与真实世界互动。然而,对于每个后端(C、JS、Wasm、WasmGC),“世界”是不同的,并且基于运行时(Wasmtime、Deno、浏览器等)。

后端#

MoonBit 目前有五个后端:

  • Wasm

  • Wasm GC

  • JavaScript

  • C

  • LLVM(实验性)

Wasm 是指包含一些 MVP 之后的提案的 WebAssembly,包括:

  • bulk-memory-operations

  • multi-value

  • reference-types

为了更好的兼容性,init 函数会被编译成 start function,而 main 函数会被导出为 _start

备注

对于 Wasm 后端,所有与外部世界交互的函数都依赖于宿主环境。例如,Wasm 和 Wasm GC 后端的 println 函数依赖于导入一个函数 spectest.print_char,它在每次调用时打印一个 UTF-16 码元。标准库中的 env 包和一些 moonbitlang/x 包依赖于 MoonBit 运行时定义的特定宿主函数。如果您想让生成的 Wasm 可移植,请避免使用它们。

Wasm GC 指的是使用垃圾回收提案的 WebAssembly,这意味着数据结构将使用引用类型(例如 structarray)表示,线性内存不会被默认使用。它还支持其他 MVP 之后的提案,包括:

  • multi-value

  • JS string builtins

为了更好的兼容性,init 函数会被编译成 start function,而 main 函数会被导出为 _start

备注

对于 Wasm 后端,所有与外部世界交互的函数都依赖于宿主环境。例如,Wasm 和 Wasm GC 后端的 println 函数依赖于导入一个函数 spectest.print_char,它在每次调用时打印一个 UTF-16 码元。标准库中的 env 包和一些 moonbitlang/x 包依赖于 MoonBit 运行时定义的特定宿主函数。如果您想让生成的 Wasm 可移植,请避免使用它们。

JavaScript 后端会生成一个 JavaScript 文件,这个文件可以是一个 CommonJS 模块、ES 模块或 IIFE,具体取决于 配置

C 后端会生成一个 C 文件。MoonBit 工具链还会编译项目并根据 配置 生成可执行文件。

LLVM 后端会生成一个对象文件。该后端是实验性的,不支持 FFI。

声明外部类型#

您可以利用 extern 关键字,像这样声明一个外部类型:

extern type ExternalRef

这将被解释为一个 externref

这将被解释为一个 JavaScript 值。

这将被解释为 void*

声明外部函数#

要和外部世界互动,您可以声明外部函数。

备注

MoonBit 不支持多态外部函数。

声明外部函数有两种方式:导入一个函数或编写一个内联函数。

您可以通过模块名和函数名导入一个函数:

fn cos(d : Double) -> Double = "math" "cos"

或者,您可以使用 Wasm 语法编写一个内联函数:

extern "wasm" fn identity(d : Double) -> Double =
  #|(func (param f64) (result f64))

备注

编写内联函数时,请不要提供函数名称。

声明外部函数有两种方式:导入一个函数或编写一个内联函数。

您可以通过模块名和函数名导入一个函数,它会被解读为 {模块名}.{函数名}。例如,

fn cos(d : Double) -> Double = "Math" "cos"

会引用函数 const cos = (d) => Math.cos(d)

或者,您可以编写一个内联函数,定义一个 JavaScript lambda:

extern "js" fn cos(d : Double) -> Double =
  #|(d) => Math.cos(d)

您可以通过函数名称导入一个外部函数:

extern "C" fn put_char(ch : UInt) = "function_name"

如果一个包需要动态链接外部 C 库,可以在它的 moon.pkg.json 里添加 cc-link-flags。它会被直接传递给 C 编译器。

{
  // ...
  "link": {
    "native": {
      "cc-link-flags": "-l<c library>"
    }
  },
  // ...
}

要定义 C 胶水函数来连接 C 与 MoonBit,可以在一个包中引入一些 C 胶水文件,并向 moon.pkg.json 文件加入下面的内容:

{
  // ...
  "native-stub": [ 
    // <包含胶水函数的 C 文件列表>
  ],
  // ...
}

你可能会想要 #include "moonbit.h",这个头文件包含了 MoonBit 的 C FFI 接口中的类型定义和一些实用的辅助函数。这个头文件自身通常位于 ~/.moon/include,如果想要了解 moonbit.h 中有哪些可用的定义,可以直接查看它的内容。

类型#

当声明函数时,您需要确保函数签名与实际的外部函数相对应。当一个函数不返回任何值(例如 void)时,忽略函数声明中的返回类型注释。下面的表格展示了一些 MoonBit 类型的底层表示:

MoonBit type

ABI

Bool

i32

Int

i32

UInt

i32

Int64

i64

UInt64

i64

Float

f32

Double

f64

constant enum

i32

external type (extern type T)

externref

FuncRef[T]

funcref

MoonBit type

ABI

Bool

i32

Int

i32

UInt

i32

Int64

i64

UInt64

i64

Float

f32

Double

f64

constant enum

i32

external type (extern type T)

externref

String

externref iff JS string builtin is on

FuncRef[T]

funcref

MoonBit type

ABI

Bool

boolean

Int

number

UInt

number

Float

number

Double

number

constant enum

number

external type (extern type T)

any

String

string

FixedArray[Byte]/Bytes

UInt8Array

FixedArray[T] / Array[T]

T[]

FuncRef[T]

Function

备注

对于数字类型,FixedArray[T] 在将来可能会被迁移到 TypedArray

MoonBit type

ABI

Bool

int32_t

Int

int32_t

UInt

uint32_t

Int64

int64_t

UInt64

uint64_t

Float

float

Double

double

constant enum

int32_t

abstract type (type T)

pointer (must be valid MoonBit object)

external type (extern type T)

void*

FixedArray[Byte]/Bytes

uint8_t*

FixedArray[T]

T*

FuncRef[T]

Function pointer

备注

如果 FuncRef[T] 中的 T 的返回类型是 Unit,那么它指向一个返回 void 的函数。

上表中未提及的类型不具有稳定的二进制接口,请尽量避免依赖它们的实际二进制表示。

回调#

有时,我们想要将 MoonBit 函数作为回调传递给外部接口。在 MoonBit 中,可以定义闭包。根据 MDN 的定义:

闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。

在一些场合,我们希望将传递一个回调函数,其不捕获任何自由变量。为此,MoonBit 提供了一个特殊的类型 FuncRef[T],它表示类型为 T 的无捕获函数。类型为 FuncRef[T] 的值必须是类型为 T 的无捕获函数,否则会产生 类型错误

在其他情况下,MoonBit 函数参数会被表示为一个函数和一个包含周围状态的对象。

对于 Wasm 后端,回调函数会被传递为 externref,它表示宿主的一个函数。然而,将函数和捕获的数据转换为宿主的函数是非常重要的。

为此,Wasm 模块会在模块 moonbit:ffi 下导入一个函数 make_closure。这个函数接受一个函数和一个对象,其中函数的第一个参数应该是这个对象;并且应该返回一个宿主的函数。也就是说,宿主负责做部分调用。一个可能的实现是:

{ 
  "moonbit:ffi": {
    "make_closure": (funcref, closure) => funcref.bind(null, closure)
  } 
}

JavaScript 支持闭包,因此这里没有什么特别需要做的。

有些 C 函数允许在回调函数之外额外提供一些附加数据。例如,假设我们有下面的 C 函数:

void register_callback(void (*callback)(void*), void *data);

通过一个小技巧,我们可以向这个 C 函数传递 MoonBit 中的闭包:

extern "C" fn register_callback_ffi(
  call_closure : FuncRef[(() -> Unit) -> Unit],
  closure : () -> Unit
) = "register_callback"

fn register_callback(callback : () -> Unit) -> Unit {
  register_callback_ffi(
    fn (f) { f() },
    callback
  )
}

自定义常量枚举的整数表示#

在所有后端,常量枚举(所有构造器都没有参数的枚举)都会被编译成整数。在此基础上,MoonBit 允许用户自定义常量枚举的构造器的整数表达式。只需在构造器的声明后加上 = <整数字面量> 即可:

enum SpecialNumbers {
  Zero = 0
  One
  Two
  Three
  Ten = 10
  FourtyTwo = 42
}

如果一个构造器没有用户指定的整数值,默认的值是上一个构造器的值加一。(第一个构造器的默认值是 0)。自定义整数表示的功能在绑定一些 C 库里的 flag 是很有用。

导出函数#

对于既不是方法也不是多态的公开函数,可以通过配置 链接选项 中的 exports 字段来导出它们。

{
  "link": {
    "<backend>": {
      "exports": [ "add", "fib:test" ]
    }
  }
}

上述例子中导出函数 addfib,其中 fib 会被导出为 test

备注

这仅对配置它的包有效,即它不会影响下游包。

备注

这仅对配置它的包有效,即它不会影响下游包。

还有另一个 format 选项可以导出为 CommonJS 模块(cjs)、ES 模块(esm)或立即调用的函数表达式(iife)。

备注

这仅对配置它的包有效,即它不会影响下游包。

目前不支持重命名导出的函数。

生命周期管理#

MoonBit 是一门具有垃圾回收的编程语言。因此在处理外部对象或将 MoonBit 对象传递给宿主时,必须牢记生命周期管理。目前,MoonBit 对 Wasm 后端和 C 后端使用引用计数。对于 Wasm GC 后端和 JavaScript 后端,复用运行时的垃圾回收机制。

外部对象的生命周期管理#

在 MoonBit 中处理来自外部的对象和资源时,需要及时释放这些外部对象占用的内存和资源以避免泄漏。

备注

仅限 C 后端

moonbit.h 中提供了一个实用的函数 moonbit_make_external_object,它可以借助 MoonBit 的自动内存管理系统来管理外部对象的生命周期:

void *moonbit_make_external_object(
  void (*finalize)(void *self),
  uint32_t payload_size
);

moonbit_make_external_object 会创建一个大小为 payload_size + sizeof(finalize) 的新的 MoonBit 对象,这个对象的内存布局如下:

| MoonBit 对象头 | ... 外部数据 | 释放资源的回调 |
                 ^
                 |
                 |_
                    `moonbit_make_external_object` 返回的指针

因此,moonbit_make_external_object 返回的指针可以直接当作指向外部数据的指针使用。当 MoonBit 的自动内存管理系统发现 moonbit_make_external_object 返回的对象生命周期已经结束时,它会以对象自身为参数,调用创建对象时提供的 finalize 函数来释放这个对象占有的外部资源。

备注

finalize 绝对不能 释放对象自身,因为这部分工作由 MoonBit 运行时负责。

在 MoonBit 侧,moonbit_make_external_object 返回的对象应当被绑定到 抽象 类型,即用 type T 语法声明的类型。这样一来,MoonBit 的内存管理系统就不会无视这个对象。

MoonBit 对象的生命周期管理#

当通过函数将 MoonBit 对象传递给宿主时,必须注意 MoonBit 对象本身的生命周期管理。如前所述,MoonBit 的 Wasm 后端和 C 后端使用编译器优化的引用计数来管理对象的生命周期。为了避免内存错误或泄漏,FFI 函数必须正确维护 MoonBit 对象的引用计数。

备注

仅限 C 后端和 Wasm 后端。

引用计数的调用约定#

MoonBit 的引用计数默认使用被调用者持有所有权的调用约定。也就是说,被调用的函数需要调用 moonbit_decref 函数来释放它的参数。如果参数被多次使用,被调用的函数需要调用 moonbit_incref 函数来增加引用计数。下面是不同场合下维护正确引用计数需要做的操作:

场合

操作

读取字段/元素

什么都不做

存储进数据结构

调用 incref

作为参数传递给 MoonBit 函数

调用 incref

作为参数传递给其他外部函数

什么都不做

作为返回值被返回

什么都不做

作用域结束(且没有返回)

调用 decref

下面的例子是一个正确维护引用计数的、标准的打开文件的 open 函数的绑定:

extern "C" open(filename : Bytes, flags : Int) -> Int = "open_ffi"
int open_ffi(moonbit_bytes_t filename, int flags) {
  int fd = open(filename, flags);
  moonbit_decref(filename);
  return fd;
}

被管理的类型#

下面的类型不是分配在堆上的,不需要管理生命周期:

  • 内置数字类型,例如 IntDouble

  • 常量枚举(所有构造器都不带参数的枚举)

下面的类型总是分配在堆上的,并且需要引用计数:

  • FixedArray[T], Bytes and String

  • 抽象类型(type T

外部类型(extern type T)也被分配在堆上,但它们表示外部指针,因此 MoonBit 不会对它们执行任何引用计数操作。

struct/有参数的 enum 的内存表示是不稳定的。

borrow 标记#

为了正确维护引用计数,往往需要写一个胶水 C 函数来调用 moonbit_decref。对这种情况,MoonBit 提供了 #borrow 标记来改变 C FFI 的调用约定,把引用计数的调用约定改为传递借用。#borrow 标记的语法是:

#borrow(params..)
extern "C" fn c_ffi(..) -> .. = ..

其中,paramsc_ffi 的参数列表的一个子集。

#borrow 标记的参数会使用基于借用的调用约定,也就是说,被调用的函数不需要对这些参数调用 decref。如果 FFI 函数只会在运行期间读取它的参数(不会返回它的参数或把它们存进数据结构),那么使用 #borrow 标记可以直接绑定 FFI 函数。上面的 open 的例子可以使用 #borrow 标记来简化:

#borrow(filename)
extern "C" fn open(filename : Bytes, flags : Int) -> Int = "open"

这里不再需要 C 胶水函数:我们直接绑定到了原版 open#borrow 标记保证了这个简化的版本依然能正确维护引用计数。

即使由于某些其他原因依然需要写 C 胶水函数,#borrow 标记也可以用于简化 C 胶水内部的生命周期管理。下面是不同场合下,正确维护 借用参数 的引用计数需要做的操作:

场合

操作

读取字段/元素

什么都不做

存储进数据结构

调用 incref

作为参数传递给 MoonBit 函数

调用 incref

传递给其他 C 函数 / #borrow MoonBit 函数

什么都不做

作为返回值被返回

调用 incref

作用域结束(且没有返回)

什么都不做