跳到主要内容

外部函数接口 (FFI)

你可以在月兔中通过外部函数接口来使用外部的函数,与宿主环境交互。一般在嵌入浏览器环境或命令行环境(通过Wasmtime等项目)时使用。

⚠ 警告:月兔还在初期开发阶段,内容可能会过时。

外部函数接口

声明外部引用

你可以定义一个这样的外部引用类型:

type Canvas_ctx

这将会定义一个外部对象的引用。在我们的例子中,它代表了宿主JavaScript环境中的一个CanvasRenderingContext2D对象。

声明外部函数

你可以像这样定义一个外部函数:

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

它和正常的函数定义十分相像,除了函数体被替换为两个字符串。

对于Wasm(GC)后端,这两个字符串是用来在Wasm导入的对象中识别特定的函数:第一个字符串是模块名称,第二个字符串是函数名称。对于JS后端,这两个字符串被用于访问全局命名空间中的一个静态函数。上述例子会编译为类似const cos = (d) => Math.cos(d)

你也可以定义内联函数,函数体是一个字符串。

对于WasmGC后端,你可以以一个不含名称的Wasm函数定义它(名称将会在之后自动生成):

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

而对于JS后端,你可以定义一个箭头函数表达式:

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

在声明之后,你可以像普通的函数那样使用外部函数。

对于多后端项目,你可以在以.wasm.mbt .wasm-gc.mbt .js.mbt结尾的文件中定义后端相关代码。

你也可以定义一个使用外部引用类型的外部函数,就像这样:

fn begin_path(self: Canvas_ctx) = "canvas" "begin_path"

之后可以将它应用到拥有的外部对象的引用上,如:context.begin_path()

导出函数

公开函数(非方法、非多态)可以被导出,需要在对应包的moon.pkg.json中添加链接设置:

{
"link": {
"wasm": {
"exports": [
"add",
"fib:test"
]
},
"wasm-gc": {
"exports": [
"add",
"fib:test"
]
},
"js": {
"exports": [
"add",
"fib:test"
],
"format": "esm"
}
}
}

每一个后端都有一个单独的定义。对JS后端,还有一个额外的format选项,可以用来指定生成的JS文件,是ES Module(esm),还是CommonJS module(cjs),还是立即调用函数表达式(iife)。

上面的例子中,addfib函数将会在编译时被导出,并且fib函数将被以test为名导出。

对于Wasm(GC)后端,_start函数总是应当被使用,以初始化月兔程序中定义的全局实例。

使用编译的Wasm

使用编译后的Wasm,你需要首先在宿主环境中初始化Wasm模块。这一步需要满足Wasm模块对外部函数的依赖。之后可以使用Wasm模块提供的函数。

提供宿主函数

使用编译后的Wasm,你需要在Wasm导入对象中提供所有声明过的外部函数。

例如,在JavaScript中使用包含上述代码片段编译的Wasm:

WebAssembly.instantiateStreaming(fetch("xxx.wasm"), {
Math: {
cos: (d) => Math.cos(d),
},
});

具体信息可以查阅嵌入Wasm的宿主环境的文档,例如MDN

例子:笑脸

让我们来看一个使用月兔利用画布(Canvas)API画一个简单笑脸的例子。假设利用moon new draw创建了一个新项目。

lib/draw.mbt
// 我们首先定义一个类型,代表着绘画的上下文
type Canvas_ctx

// 我们再定义外部函数接口
fn begin_path(self : Canvas_ctx) = "canvas" "beginPath"
fn arc(self : Canvas_ctx, x : Int, y : Int, radius : Int, start_angle : Double,
end_angle : Double, counterclockwise : Bool) = "canvas" "arc"
fn move_to(self : Canvas_ctx, x : Int, y : Int) = "canvas" "moveTo"
fn stroke(self : Canvas_ctx) = "canvas" "stroke"

fn get_pi() -> Double = "math" "PI"
let pi : Double = get_pi()

// 我们使用这些函数来定义一个在绘画上下文中绘制的函数
pub fn draw(self : Canvas_ctx) -> Unit {
self.begin_path()
self.arc(75, 75, 50, 0.0, pi * 2.0, true) // Outer circle
self.move_to(110, 75)
self.arc(75, 75, 35, 0.0, pi, false) // Mouth (clockwise)
self.move_to(65, 65)
self.arc(60, 65, 5, 0.0, pi * 2.0, true) // Left eye
self.move_to(95, 65)
self.arc(90, 65, 5, 0.0, pi * 2.0, true) // Right eye
self.stroke()
}

// 我们在这里也演示`println`的功能
pub fn display_pi() -> Unit {
println("PI: \(pi)")
}
lib/moon.pkg.json
{
"link": {
"wasm": {
"exports": ["draw", "display_pi"]
},
"wasm-gc": {
"exports": ["draw", "display_pi"]
}
}
}

我们使用moon build --target wasm-gc构建项目。我们推荐尽可能地使用wasm-gc特性。如果宿主环境不支持,那么可以省略--target wasm-gc选项。

在JavaScript中使用它:

./index.html
<html lang="en">
<body>
<canvas id="canvas" width="150" height="150"></canvas>
</body>
<script>
// 定义宿主函数的导入对象
const importObject = {
// TODO
}

const canvas = document.getElementById("canvas");
if (canvas.getContext) {
const ctx = canvas.getContext("2d");
WebAssembly.instantiateStreaming(fetch("target/wasm-gc/release/build/lib/lib.wasm"), importObject).then(
(obj) => {
// 总是调用_start来初始化环境
obj.instance.exports._start();
// 将JS对象当作参数传递以绘制笑脸
obj.instance.exports["draw"](ctx);
// 显示PI的值
obj.instance.exports["display_pi"]();
}
);
}
</script>
</html>

对于导入对象,我们需要提供先前定义的程序中用到的外部函数接口:画布的绘制接口、数学接口以及printlnprint使用的往控制台输出的接口。

对于画布的接口和数学接口,我们可以用以下代码,把对象的方法转化为一个函数,使得第一个参数是对象;并且将对象的常值转化为一个获得该值的函数:

function prototype_to_ffi(prototype) {
return Object.fromEntries(
Object.entries(Object.getOwnPropertyDescriptors(prototype))
.filter(([_key, value]) => value.value)
.map(([key, value]) => {
if (typeof value.value == 'function')
return [key, Function.prototype.call.bind(value.value)]
// TODO: 我们也可以将属性转化为getter和setter
else
return [key, () => value.value]
})
);
}

const importObject = {
canvas: prototype_to_ffi(CanvasRenderingContext2D.prototype),
math: prototype_to_ffi(Math),
// ...
}

至于我们的输出功能,我们可以定义如下的闭包:它提供了一个缓存来存储字符串的字节,直到需要被输出到控制台为止:

const [log, flush] = (() => {
var buffer = [];
function flush() {
if (buffer.length > 0) {
console.log(new TextDecoder("utf-16").decode(new Uint16Array(buffer).valueOf()));
buffer = [];
}
}
function log(ch) {
if (ch == '\n'.charCodeAt(0)) { flush(); }
else if (ch == '\r'.charCodeAt(0)) { /* noop */ }
else { buffer.push(ch); }
}
return [log, flush]
})();

const importObject = {
// ...
spectest: {
print_char: log
},
}

// ...
WebAssembly.instantiateStreaming(fetch("target/wasm-gc/release/build/lib/lib.wasm"), importObject).then(
(obj) => {
obj.instance.exports._start();
// ...
flush()
}
);

现在我们可以把之前的内容结合起来,获得我们最终的index.html

<!DOCTYPE html>
<html>

<head></head>

<body>
<canvas id="canvas" width="150" height="150"></canvas>
<script>
function prototype_to_ffi(prototype) {
return Object.fromEntries(
Object.entries(Object.getOwnPropertyDescriptors(prototype))
.filter(([_key, value]) => value.value)
.map(([key, value]) => {
if (typeof value.value == 'function')
return [key, Function.prototype.call.bind(value.value)]
else
return [key, () => value.value]
})
);
}

const [log, flush] = (() => {
var buffer = [];
function flush() {
if (buffer.length > 0) {
console.log(new TextDecoder("utf-16").decode(new Uint16Array(buffer).valueOf()));
buffer = [];
}
}
function log(ch) {
if (ch == '\n'.charCodeAt(0)) { flush(); }
else if (ch == '\r'.charCodeAt(0)) { /* noop */ }
else { buffer.push(ch); }
}
return [log, flush]
})();



const importObject = {
canvas: prototype_to_ffi(CanvasRenderingContext2D.prototype),
math: prototype_to_ffi(Math),
spectest: {
print_char: log
},
}

const canvas = document.getElementById("canvas");
if (canvas.getContext) {
const ctx = canvas.getContext("2d");
WebAssembly.instantiateStreaming(fetch("target/wasm-gc/release/build/lib/lib.wasm"), importObject).then(
(obj) => {
obj.instance.exports._start();
obj.instance.exports["draw"](ctx);
obj.instance.exports["display_pi"]();
flush()
}
);
}
</script>
</body>

</html>

确保draw.wasm以及index.html在同一个文件夹下,之后在文件夹中启动HTTP服务器,例如使用Python:

python3 -m http.server 8080

在浏览器中打开 http://localhost:8080 后,应该可以看到屏幕上的一个笑脸以及控制台的输出: