开始使用 Nim(翻译)

原文: http://howistart.org/posts/nim/1


Nim 是一门年轻的, 让人兴奋的命令式编程语言, 即将发布 1.0 办法.
我对与 Nim 最主要的兴趣在于性能/生成力的比值, 以及使用 Nim 写程序带来的乐趣.
这份教程里我会展示一下我是怎么展开一个 Nim 项目的.

现在我们的目标是写一个 Brainfuck 语言的简单的解释器.
Nim 是一个使用的编程语言, 有着各种有趣的功能, Brainfuck 正好相反:
它很不实用, 它的全局功能就 8 个简单字符代码的指令.
不过 Brainfuck 对我们来说还是不错的, 因为它够简单, 写解释器也就很简单.
后面我们还会写一个高性能的编译器, 把 Brainfuck 在编译时转换为 Nim.
所有代码会被包装成 [nimble 模块]然后在网上发布

安装

安装 Nim 步骤不错, 你可以看官方的说明. Windows 的二进制包是现成的.
其他操作系统你可以用 build.sh 教程编译生成的 C 代码, 一般的操作系统一分钟内能完成.

这向我们透露了 Nim 第一个有意思的事实: 它主要是编译为 C (也可以 C++, ObjectiveC, 甚至 JavaScript)
然后用高度优化的 C 编译器把代码编译成为实际的程序.
你直接就能从 C 的生态系统当中获益.

如果你选择从 Nim 的编译器 自举, 也就是 Nim 语言自身实现的版本, 那么,
你可以看一看编译器是怎么一步一步把自己编译起来的(两分钟以内能完成):

bash$ git clone https://github.com/Araq/Nim
$ cd Nim
$ git clone --depth 1 https://github.com/nim-lang/csources
$ cd csources && sh build.sh
$ cd ..
$ bin/nim c koch
$ ./koch boot -d:release

这样你得到的是开发版本的 Nim. 要追上最新版本, 按下边两步应该就可以了:

bash$ git pull
$ ./koch boot -d:release

如果能从来没做过, 那么这个时候安装一下 git 也是很不错的.
大部分的 nimble 模块托管在 GitHub 上, 我们需要用 git 来获取.
在基于 Debian 的发行版当中(比如 Ubuntu), 这样就能安装:

bash$ sudo apt-get install git

安装好以后, 把 nim 二进制文件加入到你的 PATH 环境变量当中去. 用 Bash 的话是这样做:

bash$ echo 'export PATH=$PATH:$your_install_dir/bin' >> ~/.profile
$ source ~/.profile
$ nim
Nim Compiler Version 0.10.2 (2014-12-29) [Linux: amd64]
Copyright (c) 2006-2014 by Andreas Rumpf
::

    nim command [options] [projectfile] [arguments]

Command:
  compile, c                compile project with default code generator (C)
  doc                       generate the documentation for inputfile
  doc2                      generate the documentation for the whole project
  i                         start Nim in interactive mode (limited)
...

nim 命令返回起版本跟用法, 就可以继续后面的步骤了.

现在 [Nim 的标准模块]只要 import 一下就好了.
其他的模块都可以用 nimble 来获取, 也就是 Nim 的包管理工具.
我们要看一下基础的安装说明.
同样, Windows 平台有编译好的包, 不过从源码编译也挺轻松的:

bash$ git clone https://github.com/nim-lang/nimble
$ cd nimble
$ nim c -r src/nimble install

Nimble 的二进制目录也要加到 PATH 环境变量当中去:

bash$ echo 'export PATH=$PATH:$HOME/.nimble/bin' >> ~/.profile
$ source ~/.profile
$ nimble update
Downloading package list from https://github.com/nim-lang/packages/raw/master/packages.json
Done.

现在我们来浏览可用的 nimble 模块或者从命令行当中进行搜索:

bash$ nimble search docopt
docopt:
  url:         git://github.com/docopt/docopt.nim (git)
  tags:        commandline, arguments, parsing, library
  description: Command-line args parser based on Usage message
  license:     MIT
  website:     https://github.com/docopt/docopt.nim

我们来安装刚才找到的 docopt 模块, 待会可能会用到:

bash$ nimble install docopt
...
docopt installed successfully.

看看安装模块多块(我这里小于 1 秒). 这是 Nim 另一个好处.
基本上模块的源代码只是被下载, 共享的模块当中没有什么要被编译的.
而是在我们使用到模块的时候, 模块才会被静态编译到程序当中.

可以找到关于 Nim 的编辑器支持 的一个列表,
比如 Emacs(nim-mode), Vim(nimrod.vim[nimrod-vim], 我的用的), 还有 Sublime(Nimlime).
对于这篇教程范围来说, 什么编辑器都是可以的.

项目设置

现在我们开始建项目:

bash$ mkdir brainfuck
$ cd brainfuck

第一步: 要在终端打印 Hello World, 我们先建立一个 hello.nim 包含以下内容:

nimecho "Hello World"

编译代码, 然后运行, 先用两个独立的步骤:

bash$ nim c hello
$ ./hello
Hello World

然后可以用一个步骤, 指明 Nim 编译器在生成二进制文件以后顺便运行一下:

bash$ nim c -r hello
Hello World

把代码改得稍微复杂一点, 那么运行起来就能久一点:

nimvar x = 0
for i in 1 .. 100_000_000:
  inc x # increase x, 增加 x, 顺便说下这是注释

echo "Hello World ", x

现在我们是初始化变量 x0, 每次增加 1 一共一亿次. 继续编译, 运行.
注意这一次运行了多久. Nim 的性能很不堪么? 当然不是, 事实上正好相反.
上边我们是在调试模式下生成的二进制文件, 添加了整数溢出的检测, 数组超出范围, 以及很多, 而且我们一点没做优化.
使用 -d:release 选项可以帮助我们切换到 release 模式, 提供全速:

bash$ nim c hello
$ time ./hello
Hello World 100000000
./hello  2.01s user 0.00s system 99% cpu 2.013 total
$ nim -d:release c hello
$ time ./hello
Hello World 100000000
./hello  0.00s user 0.00s system 74% cpu 0.002 total

实际上者也太快了. C 编译器直接把整个 for 循环给优化没了. Oops.

要创建一个新项目用 nimble init 可以成成基本的模块配置文件:

bash$ nimble init brainfuck

新生成的 brainfuck.nimble 应该是这样的:

ini[Package]
name          = "brainfuck"
version       = "0.1.0"
author        = "Anonymous"
description   = "New Nimble project for Nim"
license       = "BSD"

[Deps]
Requires: "nim >= 0.10.0"

我们加上实际作者, 描述, 还有 docopt 这个依赖, 按照 [nimble 开发者信息]中描述的.
最重要的, 我们要定义好想要创建的二进制文件:

ini[Package]
name          = "brainfuck"
version       = "0.1.0"
author        = "The 'How I Start Nim' Team"
description   = "A brainfuck interpreter"
license       = "MIT"

bin           = "brainfuck"

[Deps]
Requires: "nim >= 0.10.0, docopt >= 0.1.0"

因为我们已经安装了 git, 我们要记录源码全局的版本, 还有发到线上, 那么初始化一下 Git 仓库:

bash$ git init
$ git add hello.nim brainfuck.nimble .gitignore

其中我的 .gitignore 是这样的:

bashnimcache/
*.swp

Git 需要 ignore 掉 Vim 的 swap 文件, 还有 nimcache 文件中包含的生成的当前项目的 C 代码.
如果你对 Nim 怎么生成 C 代码感兴趣, 可以看一下.

要展示 nimble 的能力, 我们来初始化 brainfuck.nim, 写上 main 程序:

nimecho "Welcome to brainfuck"

我们可以像之前编译 hello.nim 一样进行编程, 不过考虑我们已经在模块里定义好 brainfuck 的二进制文件,
我们用 nimble 来做这个工作吧:

bash$ nimble build
Looking for docopt (>= 0.1.0)...
Dependency already satisfied.
Building brainfuck/brainfuck using c backend...
...
$ ./brainfuck
Welcome to brainfuck

nimble install 可以用来在我们的系统当中安装二进制文件, 然后我们可以随处运行:

bash$ nimble install
...
brainfuck installed successfully.
$ brainfuck
Welcome to brainfuck

程序能运行了是很棒的事情, 但是 nimble build 实际上做的是 release build.
这会比调试中的 builg 过程更漫长, 而且去掉开发过程中很重要的检查,
所以这个时候 nim c -r brainfuck 还是比较适合这种情况的.
开发过程当中多执行几次程序, 感受一下每个地方是怎么运行的.

编码

Nim 有文档可以参考, 不过你不知道怎么找到某些东西的话, 还有个索引你可以搜索.

我们开始修改 brainfuck.nim 开发我们的解释器吧:

nimimport os

首先我们引入 os 模块, 那么我们可以读取命令行的参数:

nimlet code = if paramCount() > 0: readFile paramStr(1)
           else: readAll stdin

paramCount() 可以告诉我们传给应用的命令行参数的个数.
我们拿到命令行参数的话, 我们设想会是文件名, 那么直接通过 readFile paramStr(1) 读取文件.
否则我们直接从标准输入读取所在的东西. 两种情况下, 结果都是存储在 code 变量,
这个变量被 let 关键字声明为不可修改的.

要看是否正常运行, 我们可以 echo 一下 code:

nimecho code

然后试一试:

nim$ nim c -r brainfuck
...
Welcome to brainfuck
I'm entering something here and it is printed back later!
I'm entering something here and it is printed back later!

你输入完"代码"以后要用 ctrl-d 来结束.
或者你可以传入一个文件名, nim c -r brainfuck 命令后面所有的都作为命令行参数传给生成的可执行文件:

nim$ nim c -r brainfuck .gitignore
...
Welcome to brainfuck
nimcache/
*.swp

然后我们写:

nimvar
  tape = newSeq[char]()
  codePos = 0
  tapePos = 0

我们定义一些会用到的变量. 需要保存 code 字符串当中的当前位置(codePos)延迟 tape 上的位置(tapePos).
Brainfuck 运行在一卷无限长延伸的 tape 上, 表示为一个 seqchar(字符的序列).
序列是 Nim 当中动态长度的 array, 除了协程 newSeq 你也可以用 var x = @[1, 2, 3] 初始化.

我们花一点时间来回味一下不用为变量申明类型带来的方便, 它们都是自动推断的.
如果非要写得更明确一点, 我们可以写:

nimvar
  tape: seq[char] = newSeq[char]()
  codePos: int = 0
  tapePos: int = 0

然后我们写一个小的 procedure, 然后在后边马上调用:

nimproc run(skip = false): bool =
  echo "codePos: ", codePos, " tapePos: ", tapePos

discard run()

有些事情可以注意的:

  • 我们传入了 skip 参数, 初始化为 false
  • 很明显这个参数的类型是 bool
  • 返回值也是 bool 类型的, 但是我们什么都没返回么? 每个返回结果都是默认二进制 0, 我们是返回的 `false
  • 我们可以明确用 result 变量在每个 proc 表示返回值, 设置为 result = true
  • 控制流可以被改变, 使用 return true 可以立即返回结果
  • 我们需要明确 discard 掉调用 run() 返回的 bool 数值.
    否则编译器会警告 brainfuck.nim(16, 3) Error: value of type 'bool' has to be discarded.
    这是用来防止我们忘记处理返回结果的.

继续之前, 我们来想一下 Brainfuck 是怎样运行的.
如果之前你接触过图灵机, 那么其中一些地方你会感到很熟悉.
我们会输入一个字符串 code, 还有一个包含 chartape 会在一个方向无线延伸.
输入的字符串当中会出现 8 中命令, 其他的字符都会被忽略掉:

操作符   含义                           Nim 对应代码
>       在 tape 上向右移动              inc tapePos
<       在 tape 上向左移动              dec tapePos
+       增加 tape 上的数值              inc tape[tapePos]
-       减小 tape 上的数值              dec tape[tapePos]
.       输出 tape 上的数值              stdout.write tape[tapePos]
,       输入值到 tape 上                tape[tapePos] = stdin.readChar
[       如果 tape 上的值是 \0, 向前移动到匹配了 ] 之后的命令
]       如果 tape 上不是 \0, 向后移动到匹配 [ 之后的命令

仅仅依靠上边这些, Brainfuck 成为了最简单的图灵完全的编程语言之一.

前面 6 条指令可以被转化为 Nim 当中的 case 区别:

nimproc run(skip = false): bool =
  case code[codePos]
  of '+': inc tape[tapePos]
  of '-': dec tape[tapePos]
  of '>': inc tapePos
  of '<': dec tapePos
  of '.': stdout.write tape[tapePos]
  of ',': tape[tapePos] = stdin.readChar
  else: discard

到这里我们是处理单个字符的输入, 然后我们写一个处理全部字符的循环:

nimproc run(skip = false): bool =
  while tapePos >= 0 and codePos < code.len:
    case code[codePos]
    of '+': inc tape[tapePos]
    of '-': dec tape[tapePos]
    of '>': inc tapePos
    of '<': dec tapePos
    of '.': stdout.write tape[tapePos]
    of ',': tape[tapePos] = stdin.readChar
    else: discard

    inc codePos

我们来测试一下这样一个简单的程序:

text$ echo ">+" | nim -r c brainfuck
Welcome to brainfuck
Traceback (most recent call last)
brainfuck.nim(26)        brainfuck
brainfuck.nim(16)        run
Error: unhandled exception: index out of bounds [IndexError]
Error: execution of an external program failed

结果让人诧异, 我们的代码 crash 了! 什么地方写错了?
tape 被认为是无限延伸的, 但我们到现在一点都没增加它的长度!
可以在 case 代码上边很容易地 fix 掉:

nimif tapePos >= tape.len:
  tape.add '\0'

最后两条指令, [] 组成了简单的循环. 我们也可以在代码里写出来:

nimproc run(skip = false): bool =
  while tapePos >= 0 and codePos < code.len:
    if tapePos >= tape.len:
      tape.add '\0'

    if code[codePos] == '[':
      inc codePos
      let oldPos = codePos
      while run(tape[tapePos] == '\0'):
        codePos = oldPos
    elif code[codePos] == ']':
      return tape[tapePos] != '\0'
    elif not skip:
      case code[codePos]
      of '+': inc tape[tapePos]
      of '-': dec tape[tapePos]
      of '>': inc tapePos
      of '<': dec tapePos
      of '.': stdout.write tape[tapePos]
      of ',': tape[tapePos] = stdin.readChar
      else: discard

    inc codePos

如果我们遇到一个 [ 我们就递归地调用 run 函数自身,
一直循环直到对应的 ] tape 上没有 \0 的一个 tapePos.

就这样. 我们有了一个可以运行的 Brainfuck 解释器.
为了做测试, 我们创建一个 examples 文件夹, 其中包含 3 个文件:
helloworld.b, rot13.b, mandelbrot.b.

text$ nim -r c brainfuck examples/helloworld.b
Welcome to brainfuck
Hello World!
$ ./brainfuck examples/rot13.b
Welcome to brainfuck
You can enter anything here!
Lbh pna ragre nalguvat urer!
ctrl-d
$ ./brainfuck examples/mandelbrot.b

在最后一个程序运行的时候你课以看到我们解释器有多么.
使用 -d:release 命令编译可以显著提升性能, 但还是花了 90 秒的时候在我电脑上画 Mandelbrot 集.
为了达到更高的性能, 后面我们要把 brainfuck 编译到 Nim, 而不是解释它.
Nim 的元编程能力对于这项任务是完美的.

首先我们保持它的简单. 我们的解释器是可以运行的, 那没我们可以把它变成一个可以重用的库.
我们所需要做的就是把代码包含在一个大的 proc 当中:

nimproc interpret*(code: string) =
  var
    tape = newSeq[char]()
    codePos = 0
    tapePos = 0

  proc run(skip = false): bool =
    ...

  discard run()

when isMainModule:
  import os

  echo "Welcome to brainfuck"

  let code = if paramCount() > 0: readFile paramStr(1)
             else: readAll stdin

  interpret code

注意我们在 proc 后面加上了一个 *, 这表示 proc 被暴露可以在模块外部访问.
其他一切都是私有的.

在问问的结尾我们依然保留我们的二进制文件.
when isMainModule 保证了代码只会在模块是主模块时才会被编译.
经过短暂的 nimble install 之后这个 Brainfuck 模块就全局可用了, 这样:

nimimport brainfuck
interpret "++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++."

看着不错! 到这里我们已经能跟别人共享代码了, 不过我们还是先加上一些文档:

nimproc interpret*(code: string) =
  ## Interprets the brainfuck `code` string, reading from stdin and writing to
  ## stdout.
  ...

执行 nim doc brainfuck 可以生成文档, 你可以[在线上看到][bf-docs]全部.

元编程

就像前面说的, 我们的解释器对于 Mandelbrot 程序来说还是非常慢的.
我们还是来写一个 procedure 在编译时成成 Nim 代码的 AST 吧.

nimimport macros

proc compile(code: string): PNimrodNode {.compiletime.} =
  var stmts = @[newStmtList()]

  template addStmt(text): stmt =
    stmts[stmts.high].add parseStmt(text)

  addStmt "var tape: array[1_000_000, char]"
  addStmt "var tapePos = 0"

  for c in code:
    case c
    of '+': addStmt "inc tape[tapePos]"
    of '-': addStmt "dec tape[tapePos]"
    of '>': addStmt "inc tapePos"
    of '<': addStmt "dec tapePos"
    of '.': addStmt "stdout.write tape[tapePos]"
    of ',': addStmt "tape[tapePos] = stdin.readChar"
    of '[': stmts.add newStmtList()
    of ']':
      var loop = newNimNode(nnkWhileStmt)
      loop.add parseExpr("tape[tapePos] != '\\0'")
      loop.add stmts.pop
      stmts[stmts.high].add loop
    else: discard

  result = stmts[0]
  echo result.repr

其中的 addStmt template 只是用来减少代码模版的.
我们也完全可以在目前用了 addStmt 的未必谬次明确写上相同的操作.
(那也就是现在的 template 所做的事情!)
parseStmt 把一段 Nim 代码转换成对应的 AST, 然后我们把他存放在数组里.

大部分的代码跟解释器是相似的, 出来代码现在不是马上被执行的, 而是被添加到语句的列表里.
[] 就更复杂了, 它们被翻译到一个加载了代码的 while 循环.

这里我们取巧了, 使用定长的 tape 而不再去检查是否在范围内, 有没有溢出.
这只是为了简化一下. 要了解代码的行为, 在最后一行, echo result.repr 可以打印出生成的 Nim 代码.

然后在一个 static 的代码块里调用一下, 这可以强制在编译时运行:

nimstatic:
  discard compile "+>+[-]>,."

编译过程中生成的代码会被打印出来:

nimvar tape: array[1000000, char]
var codePos = 0
var tapePos = 0
inc tape[tapePos]
inc tapePos
inc tape[tapePos]
while tape[tapePos] != '\0':
  dec tape[tapePos]
inc tapePos
tape[tapePos] = stdin.readChar
stdout.write tape[tapePos]

通常可以用到 dumpTree 这个宏, 可以打印代码真实的 AST 出来, 比如:

nimimport macros

dumpTree:
  while tape[tapePos] != '\0':
    inc tapePos

会显示出如下的树:

nimStmtList
  WhileStmt
    Infix
      Ident !"!="
      BracketExpr
        Ident !"tape"
        Ident !"tapePos"
      CharLit 0
    StmtList
      Command
        Ident !"inc"
        Ident !"tapePos"

比如我就是通过这个办法知道需要的是 StmtList.
用 Nim 进行元编程的时候, 通常用 dumpTree 打印出从 AST 生成的代码会很有用.

宏生成的代码可以被直接插入到程序当中:

nimmacro compileString*(code: string): stmt =
  ## 编译 Brainfuck `code` 字符串到 Nim 代码,
  ## 从 stdin 读取数据, 在 stdout 写输出内容
  compile code.strval

macro compileFile*(filename: string): stmt =
  ## 编译过程从 `filename` 读取 Brainfuck 代码编译到 Nim
  ## 从 stdin 读取, 在 stdout 写输出的内容
  compile staticRead(filename.strval)

这样可以就可以很容易地吧 Mandelbrot 程序编译到 Nim 了:

nimproc mandelbrot = compileFile "examples/mandelbrot.b"

mandelbrot()

开启全部的优化仅限编程的话时间会很长(大约 4s), 因为 Mandelbrot 程序很大, GCC 需要时间优化.
最终结果程序的运行只需要一秒钟:

text$ nim -d:release c brainfuck
$ ./brainfuck

编译器的设置

Nim 默认使用 GCC 来编译到中间层的 C 代码, 不过 Clang 经常编译得更快, 得到的代码也更高效.
所以值得试一试. 要用 Clang 编译的话, 使用 nim -d:release --cc:clang c hello.
如果你打算一直使用 Clang 编译 hello.nim, 可以创建 hello.nim.cfg 文件, 内容写 cc = clang.
还可以编辑 Nim 目录中的 config/nim.cfg 文件修改默认的编译后端.

说到改变编译器默认的选项, Nim 编译器有时挺多嘴的, 可以在 config/nim.cfg 里设置 hints = off 关闭.
一个更意想不到的编译器警告是使用 l(小写的 L)作为标识符, 因为它看起来像 1(壹):

texta.nim(1, 4) Warning: 'l' should not be used as an identifier; may look like '1' (one) [SmallLshouldNotBeUsed]

如果你看不上的话, 写上 warning[SmallLshouldNotBeUsed] = off 就可以让编译器安静.

Nim 还有个好处是可以使用 C 支持的 debugger, 比如 GDB.
nim c --linedir:on --debuginfo c hello 命令编译然后运行 gdb ./hello 进行 debug.

解析命令行参数

前面一直是用手写的代码解析命令行参数. 既然已经安装了 dotopt.nim, 现在来用一下:

nimwhen isMainModule:
  import docopt, tables, strutils

  proc mandelbrot = compileFile("examples/mandelbrot.b")

  let doc = """
brainfuck

Usage:
  brainfuck mandelbrot
  brainfuck interpret [<file.b>]
  brainfuck (-h | --help)
  brainfuck (-v | --version)

Options:
  -h --help     Show this screen.
  -v --version  Show version.
"""

  let args = docopt(doc, version = "brainfuck 1.0")

  if args["mandelbrot"]:
    mandelbrot()

  elif args["interpret"]:
    let code = if args["<file.b>"]: readFile($args["<file.b>"])
               else: readAll stdin

    interpret(code)

docopt 模块一个好处是文档写在函数当中作为规范, 很容易使用:

text$ nimble install
...
brainfuck installed successfully.
$ brainfuck -h
brainfuck

Usage:
  brainfuck mandelbrot
  brainfuck interpret [<file.b>]
  brainfuck (-h | --help)
  brainfuck (-v | --version)

Options:
  -h --help     Show this screen.
  -v --version  Show version.
$ brainfuck interpret examples/helloworld.b
Hello World!

重构

随着项目变大, 可以把代码移到 src 目录, 再添加一个 test 目录,
很快我们会需要这个目录, 最终文件结构是这样的:

text$ tree
.
├── brainfuck.nimble
├── examples
│   ├── helloworld.b
│   ├── mandelbrot.b
│   └── rot13.b
├── license.txt
├── readme.md
├── src
│   └── brainfuck.nim
└── tests
    ├── all.nim
    ├── compile.nim
    ├── interpret.nim
    └── nim.cfg

这样 nimble 文件也需要修改一下:

nimsrcDir = "src"
bin    = "brainfuck"

为了让代码容易重用, 我们做一些重构. 同时保证程序使用读取 stdin 和写入stdout.

在直接接受 code: string 这样的命令行参数之外, 扩展 interpret procedure 来接收输入输出的流.
引入一个 streams 模块FileStreamsStringStream 进行支持:

nim## :Author: Dennis Felsing
##
## This module implements an interpreter for the brainfuck programming language
## as well as a compiler of brainfuck into efficient Nim code.
##
## Example:
##
## .. code:: nim
##   import brainfuck, streams
##
##   interpret("++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++.")
##   # Prints "Hello World!"
##
##   proc mandelbrot = compileFile("examples/mandelbrot.b")
##   mandelbrot() # Draws a mandelbrot set

import streams

proc interpret*(code: string; input, output: Stream) =
  ## Interprets the brainfuck `code` string, reading from `input` and writing
  ## to `output`.
  ##
  ## Example:
  ##
  ## .. code:: nim
  ##   var inpStream = newStringStream("Hello World!\n")
  ##   var outStream = newFileStream(stdout)
  ##   interpret(readFile("examples/rot13.b"), inpStream, outStream)

这里还为模块添加了文档, 模块代码做为类库怎样使用. 看一下生成的文档.

大部分代码可以不变, 除了与 Brainfuck 操作符 ., 相关的代码,
后面将使用 output 替代 stdout, 用 input 代替 stdin:

nim        of '.': output.write tape[tapePos]
        of ',': tape[tapePos] = input.readCharEOF

为什么有个奇怪的 readCharEOF 而不是 readChar, 作用是什么呢?
很多系统的 EOF(end of file) 代表 -1, 我们这个 Brainfuck 程序也经常这样用.
这也意味着这个 Brainfuck 程序实际上不会在所有的系统都能运行.
同时 streams 模块也会处理系统不一致, 在 EOF 时返回 0.
这里用 readCharEOF 显式地转化到 -1:

nimproc readCharEOF*(input: Stream): char =
  result = input.readChar
  if result == '\0': # Streams 返回 0 表示 EOF
    result = 255.chr # BF 希望 EOF 是 -1

这里你可能注意到了标识符声明的顺序在 Nim 当中是有影响的.
如果你在 interpret 后面声明 readCharEOF, 就不能在 interpret 中调用到.
我个人希望遵循这一点, 因为这构成了每个模块中一个简单代码到复杂代码这样的层级.
如果你还是希望绕过这一点, 就把 readCharEOF 的声明从定义拆分出来放到 interpret 前面:

nimproc readCharEOF*(input: Stream): char

然后可以像之前一样去使用解释器, 也很简单:

nimproc interpret*(code, input: string): string =
  ## 解释执行 Brainfuck `code` 字符串, 从 `input` 读取内容,
  ## 直接打印出结果.
  var outStream = newStringStream()
  interpret(code, input.newStringStream, outStream)
  result = outStream.data

proc interpret*(code: string) =
  ## 解释执行 Brainfuck `code` 字符串, 从 stdin 读取内容,
  ## 输出写到 stdout.
  interpret(code, stdin.newFileStream, stdout.newFileStream)

现在的 interpret procedure 可以返回一个字符串. 这对后边的测试来说很重要:

nimlet res = interpret(readFile("examples/rot13.b"), "Hello World!\n")
interpret(readFile("examples/rot13.b")) # with stdout

编译器部分的重写有点复杂. 首先要把 inputoutput 作为字符串,
那么用户使用这个 proc 的时候就可以用任何他们想要的 stream 了:

nimproc compile(code, input, output: string): PNimrodNode {.compiletime.} =

还需要两条语句对输入跟输出的 stream 进行初始化然后作为字符串参数:

nim  addStmt "var inpStream = " & input
  addStmt "var outStream = " & output

当然我在我们就要用 outStreaminpStream 来代替 stdout 跟 stdin 了, 还有 readCharEOF 代替 readChar.
主要可以直接用解释器已有的 readCharEOF procedure, 不需要重复写:

nim    of '.': addStmt "outStream.write tape[tapePos]"
    of ',': addStmt "tape[tapePos] = inpStream.readCharEOF"

我们还可以加上语句在用户用法有误时弹出好懂的错误信息:

nim  addStmt """
    when not compiles(newStringStream()):
      static:
        quit("Error: Import the streams module to compile brainfuck code", 1)
  """

然后把 compile procedure 连接到 compileFile 这个宏, 再使用 stdin 跟 stdout:

nimmacro compileFile*(filename: string): stmt =
  compile(staticRead(filename.strval),
    "stdin.newFileStream", "stdout.newFileStream")

读取输入的字符串, 写入输出的字符串:

nimmacro compileFile*(filename: string; input, output: expr): stmt =
  result = compile(staticRead(filename.strval),
    "newStringStream(" & $input & ")", "newStringStream()")
  result.add parseStmt($output & " = outStream.data")

这段复杂的代码让我们能够编译 rot13 procedure, 连接 input 字符串跟 result 内容到编译后的程序:

nimproc rot13(input: string): string =
  compileFile("../examples/rot13.b", input, result)
echo rot13("Hello World!\n")

未来方便我对给 compileString 写了一样的代码. 可以在 GitHub 上看 brainfuck.nim 完整代码.

测试

未翻译

持续继承

未翻译

总结

Nim 的生态系统到这里已经介绍完了, 希望你喜欢, 而且能跟我一样享受写 Nim 代码.

你要继续学习 Nim 的话, 我最近写了 what is special about Nim
what makes Nim practical, 还有个小程序的珍贵的收藏.

如果你想要用更传统的方法开始学 Nim, 官方教程Nim by Example 对你会有用.

Nim 社区还是蛮热情的. 谢谢大家.

阅读 8.3k

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

495 人关注
248 篇文章
专栏主页
目录