Nim 语言写 Cirru Parser 的上手记录

句句换行, 因为我写的是代码啊!

前端码农, 写了多年的动态语言了, TypeScript 算下来也用了两年.
之前试过 Go, 但是 interface {} 简直是 any 一般的存在.
由于 Clojure 语言本身有开销, 所以尝试考虑学 Nim 来应对一些极端性能的情况.

性能

从网上的资料看, Nim 编译到 C 运行, 能跑到媲美 C 的程度,
https://github.com/kostya/ben...
总体上不是最快, 但是在第一梯队, 而且也存在一些优化的空间.
不过, 就像论坛上讨论的, 关键性的性能还是跟算法和数据结构等因素相关,
https://forum.nim-lang.org/t/...
Nim 提供了相对简洁的写法和概念, 能快速写出性能还不错的代码,
如果想继续优化, 可以关掉自动的 GC 做更深层的优化再提高性能.

我在论坛上也看到个例子, 用 Nim 普通的写法处理文件, 还不如 Python 快,
后面有人调试代码, 优化了一个依赖库, 干掉了瓶颈以后十几倍的性能提升.. 还是算法.

Clojure 的问题是, 作为动态语言, 本身有巨大的运行时.
就运行的来说, 性能不会太慢的, 只是运行时本身的开销基本上无法优化掉.
在 ClojureScript 里面, Closure Library 的阴影一直是在的, JavaScript 的开销也在.
而 Nim 编译出来的 Binary 显然跟 C 类似, 可以直接跳过这些东西.

比如 Cirru Parser 完成一次文件读取和解析, 代码比较短, 几毫秒就完成了,

=>> cat ../example/echo.cirru

println 1 2
=>> time ./main ../example/echo.cirru
1 2

real  0m0.011s
user  0m0.003s
sys 0m0.006s
=>> time ./main ../example/echo.cirru
1 2

real  0m0.008s
user  0m0.003s
sys 0m0.004s
=>> time ./main ../example/echo.cirru
1 2

real  0m0.008s
user  0m0.003s
sys 0m0.004s

而 nodejs 脚本启动一次就花比这长的时间了. 更不用说 ClojureScript 那一整套,

=>> cat a.js

console.log("100")
=>> time node a.js
100

real  0m0.071s
user  0m0.049s
sys 0m0.016s
=>> time node a.js
100

real  0m0.072s
user  0m0.047s
sys 0m0.019s
=>> time node a.js
100

real  0m0.068s
user  0m0.048s
sys 0m0.016s

语法

Nim 语法借鉴 Python 挺多的. 因为 CoffeeScript 当初就跟 Python 相似, 所以比较熟悉.
基本上就是借鉴来借鉴去的, 沿着缩进这一派, 简化了很多干扰的符号.
比较不一样的地方, 主要是 Nim 是带类型的, 所以多出了一些写法, 可能局部会遇到困惑.
普通的逻辑代码, 整体看上去跟 Python 跟 CoffeeScript 都非常相似.

一些特征的地方, Nim 里面没有直接用 def, lambda 或者 () ->,
Nim 的函数是用 proc 表示的, 这在高级语言里边不多见,
但是对于 Clojure 用户我觉得这个就是很明确的信号 procedure 而不是纯函数.
proc 可以被写成多行的匿名函数用, 大致感觉还可以, 但不如 CoffeeScript 方便.

Nim 当中的模块, 如果暴露公共函数的话要在函数或者变量之后加上 * 作为标记.
相比 Go 当中用大写开头来标记, 这个算是友好多了, 不会影响到函数名.

写 JavaScript 或者 Go 的时候, 基本上用的就是 var 声明变量.
JavaScript 后面加上了 let 加上了 const 区别也并不大.
即便 const 会要求赋值不可修改, 如果定义的是对象, 后面还会被修改掉.
在 Nim 当中比较明确还有严格,

  • let 定义的就是不可变的变量结构, 不能再被赋值, 也不能被修改熟悉,
  • var 定义可变的结构, 而且定义在参数当中如果可变, 也需要对应的 ref 标记.

编译器会提示, 区分得相当明确了.

跟 CoffeeScript 类似, Nim 有默认的返回值. 算是语法糖.

这样的缩进语法, 逻辑代码写下来, 除了类型以外, 跟 Python CoffeeScript 就及其相似了,

proc resolveComma*(expr: CirruNode): CirruNode =
  case expr.kind
  of cirruString:
    return expr
  of cirruSeq:
    var buffer: seq[CirruNode]
    for i, child in expr.list:
      case child.kind
      of cirruString:
        buffer.add child
      of cirruSeq:
        if child.list.len > 0 and child.list[0].kind == cirruString and child.list[0].text == ",":
          let resolvedChild = resolveComma(child)
          for j, x in resolvedChild.list[1..^1]:
            buffer.add resolveComma(x)
        else:
          buffer.add resolveComma(child)
    return CirruNode(kind: cirruSeq, list: buffer, line: expr.line, column: expr.column)

函数重载

关于函数的重载, 我长期用 CoffeeScript Clojure TypeScript 接触比较少,
比如 Clojure 一般是对于不同参数个数存在函数重载.
TypeScript 类似, 主要还是参数个数所以可以重载. 对于不同类型的函数重载都没用过.
主要还是动态语言经常就是 uni-type 的习惯, 即便编译, 编译期函数不方便重名, Go 都不行..
然后就要等到运行时才能对函数进行重载, 除非说是不同参数个数算是例外..
动态类型再极端点要做重载要 Python 的 __add__ 或者 JavaScript 的 Proxy 进行骚操作了.

但是 Nim 就是静态类型语言啊, 直接对 proc 做不同类型的函数重载啰.

proc zero[T: int]() = 0
proc zero[T: float]() = 0.0
proc zero[T: int32]() = 0'i32

在 Cirru Parser 当中我重载了 == 函数用于节点的比较:

proc `==`*(x, y: CirruNode): bool =
  # overload equality function
  return cirruNodesEqual(x, y)

proc `!=`*(x, y: CirruNode): bool =
  # overload equality function
  return not cirruNodesEqual(x, y)

社区氛围

https://forum.nim-lang.org

论坛比较活跃, 跟 Clojure 社区挺像的, 比较友好.
我在上面发了几次提问, 都很快有人回答我, 答案也是切中要害, 也不嫌弃我新手.
就文档来说, 我感觉这个是比 Clojure 好的, 容易上手.
Clojure 那边很多东西在 Slack 上, 搜索不到, 但是 Nim 社区能搜到的东西很多.
而且 Nim 有个好处是编译器提示比较清晰, 这比 Clojure 明确多了.

不过跟 Clojure 时不时刷上 Hacker News 不一样, Nim 显得低调很多.
Clojure 社区时不时看到有人秀 Macro, 当然, 用得也是比较随意的,
在 Nim 论坛上没怎么看到, 估计是用的人不那么多吧, 高级技巧.

包管理

https://github.com/nim-lang/n...

包管理是发布模块依赖的功能, nim 提供了 nimble 命令用于项目管理,
npm 的使用还好, Go 跟 Clojure 的包管理都有一些坑的,
Clojure 使用的验证机制要搞 GPG 秘钥, 初次使用配置起来挺烦的.
Nim 干脆直接用 GitHub 来维护模块列表, 放在一个仓库里, 简单粗暴.
所以发布模块的时候需要 fork 仓库提交信息, 等待人工合并, 相对麻烦一点.
目前 Nim 的总共的模块数量相对来说不是那么多, 不像 Web 开发圈这么多样.
不过上手的门槛确实不高, 相信有 GitHub 使用经验的人很快都能搞定.

另外对于脚手架, 对于测试, 对于本地安装, 对于依赖管理等等, 都提供了简单的方案.
刚开始按照教程一步步走下来, 基本没有遇到什么大的坑, 提示也比较明确.

项目最初的开发, 由于比较简单, 也就很快能用命令直接运行, 比较省事,

nim c -r main.nim

Object Variants

具体到 Cirru Parser 的开发, 由于用到递归的数据结构, 刚开始遇到的麻烦,
Nim 不像动态语言轻易定义任意结构, 也不像 Haskell 直接有代数类型的递归结构,
论坛问了一圈, 意识到 Nim 需要用 Object Variants 的用法专门处理.
https://nim-lang.org/docs/man...

比如 Cirru 表达式的结构就需要这样定义出来,

type
  CirruNodeKind* = enum
    cirruString,
    cirruSeq

  CirruNode* = object
    line*: int
    column*: int
    case kind*: CirruNodeKind
    of cirruString:
      text*: string
    of cirruSeq:
      list*: seq[CirruNode]

跟 Clojure 的 dispatch function 有点相似, 需要选定字段专门用于表示类型,
后面都在运行时基于这个字段做判断, 然后定义不同的逻辑,
个人感觉远远没有代数类型里面的设计优雅, 也没有 TypeScript 直观, 但是使用当中还是够用的.

其他

用了 Clojure 以后我比较习惯用 Persistent Data 和尾递归做抽象了,
当然, 尾递归相对来说是在编程语言里加限制了, 编码的灵活性反而少一点,
这次在 Nim 当中为了性能, 全部用的是可变数据的操作, 还是蛮新鲜的...
确实用 mutable 写法, 有点黑科技的感觉, 算法很巧妙, 也很脏, 偏偏性能很快.

Nim 编译器还支持 WebAssembly 和 JavaScript 的后端, 目前没有用到.
最初选 Nim 有一个原因也是考虑以后上手 WebAssembly 希望可以方便一点吧.

目前使用比较浅, 数据结构用得也比较单一, 基本参考文档还是能解决.
完成的代码在 https://github.com/Cirru/pars...
等到后续有想法再记录.

阅读 1.1k

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

500 人关注
251 篇文章
专栏主页