Nim作为一门胶水语言,像极了当年作为一种全栈语言大杀四方的Python。然而对其特色的跨语言调用功能的描述却散落在不同的文档中,缺乏有效的整理。本文尝试整理一下Nim与JavaScript互操作的用法。

LeetCode作为一个编程竞赛网站,本身并不支持Nim,但其对JavaScript的支持比较友好(如更宽松的执行时间,以及调整到2^53之内的数据范围)。其上的题型恰好能覆盖大部分与JavaScript进行互操作的情况。所以本文就以LeetCode为例进行整理。

1. 输出JavaScript源代码

要在LeetCode上使用Nim,可以让Nim编译器输出JavaScript[1]:

$ nim js solution.nim

默认输出的代码会包含大量调试信息和运行时检查,不需要的时候可以去掉来减少代码体积[2]:

$ nim js -d:danger solution.nim

2. 导出函数

LeetCode上的题目通常是要你实现一个函数,提交后会有一些驱动代码来调用这个函数,所以需要让Nim输出一个具有相同签名的JavaScript函数:

# 2235. Add Two Integers
proc sum(num1, num2: int): int =
  num1 + num2

直接将上述代码编译为JavaScript的话会发现输出里没有这个函数。这是因为其没有被使用到。需要用{.exportc.}将其标记为导出[3]:

# 2235. Add Two Integers
proc sum(num1, num2: int): int {.exportc.} =
  num1 + num2

3. 原生数据类型

导出的函数需接收JavaScript中不同类型的参数。Nim中预定义了与C类型对应的兼容类型如cintcdouble等,但在JavaScript中只会用到其中有限的几种。

3.1 Boolean

Nim中用bool接收。

3.2 Null

Nim中用nil接收。

3.3 Undefined

Nim中没有对应的类型,无法对其进行操作,但可以传递。

此外,在用于判断真值的时候可以用nil代替,因为Nim生成的JavaScript代码会用==进行比较,而在JavaScript中undefined == null。不清楚其中微妙后果的读者可以参考这篇

3.4 Array

Nim中可用seq接收。

3.5 Number

intclonglongcdouble等都可以用来接收。由于JavaScript中的Number是64位浮点数,所以最准确的对应类型应该是cdouble/float64。但考虑到类型转换的开销以及LeetCode上题目的数据范围,大部分情况下用int就可以,对应32位整数。

显然,如果在Nim中使用了int64,则在JavaScript中可能会损失精度。在JavaScript中,需要用到64位整数的地方就只能用BigInt,并会带来一定的性能开销。

3.6 BigInt

Nim中需要用JsBigInt[4]。比如在对数字进行取模运算时,牵涉到乘法的时候:

import std/jsbigints

const MOD = 1e9.int + 7

type modInt = distinct int

proc `*`(x, y: modInt): modInt =
  (x.int.big * y.int.big mod MOD.big).toNumber.modInt

3.7 String

Nim中只能用cstring来接收,存在一定的类型转换开销。所以原则上是能不转就不转,通过std/cstrutils[5]来直接处理cstring。然而从经验上来看大部分情况下是不够用的,还是需要转换。

  • cstringstring$str
  • stringcstringstr.cstring

4. 自定义类型

光能接收原生类型显然是不够的。像LeetCode上一些基础的面试题如翻转链表/二叉树等,就需要接收自定义的数据类型:

# 104. Maximum Depth of Binary Tree
type
  TreeNode = ref object
    val: int
    left, right: TreeNode

proc maxDepth(root: TreeNode): int {.exportc.} =
  if root == nil: 0
  else: 1 + max(maxDepth(root.left), maxDepth(root.right))

由于Nim中的object是值传递的,需在编译时确定大小,所以定义递归类型时必须使用引用(ref),否则编译器会报错。

此外,定义递归类型时通常会用到{.acyclic.}[6],但由于编译成JavaScript以后就靠JavaScript进行垃圾回收了,所以在这里也就没必要使用了。

5. 使用闭包时的注意事项

在开启-d:danger选项时,如果用闭包捕获值类型的变量,运行时会发生错误ReferenceError: nimCopy is not defined

proc median(arr: seq[int]): int {.exportc.} =
  proc medianInner(): int = arr[arr.len shr 1]
  result = medianInner()

解决办法是手动复制一下需捕获的变量:

proc median(arr: seq[int]): int {.exportc.} =
+  let arr = arr
  proc medianInner(): int = arr[arr.len shr 1]
  result = medianInner()

使用-d:release-d:debug时则没有这个问题。

6. 导入函数

LeetCode上有一类交互题,要求你实现的函数去调用另一个黑箱的函数。这就需要在Nim中声明JavaScript中的函数:

# 374. Guess Number Higher or Lower
proc guess(num: int): int {.importc.}

proc guessNumber(n: int): int {.exportc.} =
  var (lo, hi) = (1, n + 1)
  while lo < hi:
    let mid = lo + ((hi - lo) shr 1)
    if guess(mid) > 0: lo = mid + 1
    else: hi = mid
  lo

JavaScript中的函数通过{.importc.}[7]声明就能在Nim中调用,Nim中的函数通过{.exportc.}声明就能在JavaScript中调用,相当方便。

Nim中的FFI还有更精细的控制,同样的功能也可以用其他的pragma实现[8],比如:

proc guess(num: int): int {.importjs: "guess(#)".}

甚至是:

proc guess(num: int): int = {.emit: "return guess(`num`)".}

具体的使用场景下面会讲到。

7. 导入对象方法

LeetCode上的另一类交互题要求调用一个给定对象中的方法。还是一样的思路:

# 1237. Find Positive Integer Solution for a Given Equation
type CustomFunction = ref object of JsRoot
proc f(cf: Customfunction, x, y: int): int {.importcpp.}

const MAX = 1e3.int

proc findSolution(customFunction: CustomFunction, z: int): seq[seq[int]] {.exportc.} =
  var (x, y) = (1, MAX)
  while x <= MAX:
    while y >= 1 and customFunction.f(x, y) > z: y.dec
    if y >= 1 and customFunction.f(x, y) == z: result.add(@[x, y])
    x.inc

区别在于导入的对象方法需要用{.importcpp.}[9]而不是{.importc.}。有兴趣的读者可以比对下这两者编译出来的JavaScript代码。

8. 导出对象

LeetCode上还有一类设计题,要求实现一个类,驱动的JavaScript代码会构造这个类的对象并调用其上的方法。比如1603. Design Parking System,其代码模版是这样的:

/**
 * @param {number} big
 * @param {number} medium
 * @param {number} small
 */
var ParkingSystem = function(big, medium, small) {

};

/** 
 * @param {number} carType
 * @return {boolean}
 */
ParkingSystem.prototype.addCar = function(carType) {

};

/**
 * Your ParkingSystem object will be instantiated and called as such:
 * var obj = new ParkingSystem(big, medium, small)
 * var param_1 = obj.addCar(carType)
 */

这个问题可分成两步解决。

8.1 导出构造函数

这一步的关键是,JavaScript中的函数在被当作类的构造函数(通过new调用)时,this对象会指向类的新实例,并会在函数返回时被隐式返回[10]。由于Nim中只有普通的函数,就需要重现这个逻辑:

type ParkingSystem = ref object
  slots: seq[int]

proc newParkingSystem(big: int, medium: int, small: int): ParkingSystem {.exportc: "ParkingSystem".} =
  var this {.importc, nodecl.}: ParkingSystem

  this.slots = @[0, big, medium, small]

  this

在这里,由于this是隐式声明的,所以需要用{.nodecl.}[11]来跳过变量声明的生成。还可以看到{.exportc.}可以支持别名,所以Nim中的函数名可以与JavaScript中的对应函数名不一致。

8.2 导出方法

这一步的关键是,需要把方法绑定到类对象的prototype上。为此这里用上了{.emit.}[12]:

proc addCar(this: ParkingSystem, carType: int): bool =
  if this.slots[carType] == 0: false
  else: this.slots[carType].dec; true

{.emit: "ParkingSystem.prototype.addCar = addCar".}
proc addCarJs(carType: int): bool {.exportc: "addCar".} =
  var this {.importc, nodecl.}: ParkingSystem
  this.addCar(carType)

{.emit.}可以直接输出目标代码,非常强大也非常容易被滥用,因为如果全部代码都用其来生成的话,还不如直接用目标语言来写呢。

此外这里多声明了一个wrapper函数,在其中导入了隐式的this对象,作为对应的Nim函数的第一个参数传递。这样Nim对象的方法之间也能够相互调用;去掉wrapper函数就是纯粹的Nim代码,语言之间的分界线也变得更加明显。

由于声明wrapper需要写很多脚手架代码,我已在之前的LeetCode解题工具中[13]加入了自动生成脚手架的模版。

9. 小结

至此,在LeetCode出现的绝大部分题目都可以用Nim来解决了。下一回我们会讲讲Nim标准库和JavaScript标准库在互操作中的问题。

参考资料