原文: 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
包含以下内容:
nim
echo "Hello World"
编译代码, 然后运行, 先用两个独立的步骤:
bash
$ nim c hello $ ./hello Hello World
然后可以用一个步骤, 指明 Nim 编译器在生成二进制文件以后顺便运行一下:
bash
$ nim c -r hello Hello World
把代码改得稍微复杂一点, 那么运行起来就能久一点:
nim
var x = 0 for i in 1 .. 100_000_000: inc x # increase x, 增加 x, 顺便说下这是注释 echo "Hello World ", x
现在我们是初始化变量 x
为 0
, 每次增加 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
是这样的:
bash
nimcache/ *.swp
Git 需要 ignore 掉 Vim 的 swap 文件, 还有 nimcache
文件中包含的生成的当前项目的 C 代码.
如果你对 Nim 怎么生成 C 代码感兴趣, 可以看一下.
要展示 nimble 的能力, 我们来初始化 brainfuck.nim
, 写上 main 程序:
nim
echo "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
开发我们的解释器吧:
nim
import os
首先我们引入 os 模块, 那么我们可以读取命令行的参数:
nim
let code = if paramCount() > 0: readFile paramStr(1) else: readAll stdin
paramCount()
可以告诉我们传给应用的命令行参数的个数.
我们拿到命令行参数的话, 我们设想会是文件名, 那么直接通过 readFile paramStr(1)
读取文件.
否则我们直接从标准输入读取所在的东西. 两种情况下, 结果都是存储在 code
变量,
这个变量被 let
关键字声明为不可修改的.
要看是否正常运行, 我们可以 echo
一下 code
:
nim
echo 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
然后我们写:
nim
var tape = newSeq[char]() codePos = 0 tapePos = 0
我们定义一些会用到的变量. 需要保存 code
字符串当中的当前位置(codePos
)延迟 tape
上的位置(tapePos
).
Brainfuck 运行在一卷无限长延伸的 tape
上, 表示为一个 seq
的 char
(字符的序列).
序列是 Nim 当中动态长度的 array, 除了协程 newSeq
你也可以用 var x = @[1, 2, 3]
初始化.
我们花一点时间来回味一下不用为变量申明类型带来的方便, 它们都是自动推断的.
如果非要写得更明确一点, 我们可以写:
nim
var tape: seq[char] = newSeq[char]() codePos: int = 0 tapePos: int = 0
然后我们写一个小的 procedure, 然后在后边马上调用:
nim
proc 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
, 还有一个包含 char
的 tape
会在一个方向无线延伸.
输入的字符串当中会出现 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 区别:
nim
proc 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
到这里我们是处理单个字符的输入, 然后我们写一个处理全部字符的循环:
nim
proc 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 掉:
nim
if tapePos >= tape.len: tape.add '\0'
最后两条指令, [
和 ]
组成了简单的循环. 我们也可以在代码里写出来:
nim
proc 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
当中:
nim
proc 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 模块就全局可用了, 这样:
nim
import brainfuck interpret "++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++."
看着不错! 到这里我们已经能跟别人共享代码了, 不过我们还是先加上一些文档:
nim
proc interpret*(code: string) = ## Interprets the brainfuck `code` string, reading from stdin and writing to ## stdout. ...
执行 nim doc brainfuck
可以生成文档, 你可以[在线上看到][bf-docs]全部.
元编程
就像前面说的, 我们的解释器对于 Mandelbrot 程序来说还是非常慢的.
我们还是来写一个 procedure 在编译时成成 Nim 代码的 AST 吧.
nim
import 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
的代码块里调用一下, 这可以强制在编译时运行:
nim
static: discard compile "+>+[-]>,."
编译过程中生成的代码会被打印出来:
nim
var 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 出来, 比如:
nim
import macros dumpTree: while tape[tapePos] != '\0': inc tapePos
会显示出如下的树:
nim
StmtList WhileStmt Infix Ident !"!=" BracketExpr Ident !"tape" Ident !"tapePos" CharLit 0 StmtList Command Ident !"inc" Ident !"tapePos"
比如我就是通过这个办法知道需要的是 StmtList
.
用 Nim 进行元编程的时候, 通常用 dumpTree
打印出从 AST 生成的代码会很有用.
宏生成的代码可以被直接插入到程序当中:
nim
macro 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 了:
nim
proc 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
(壹):
text
a.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, 现在来用一下:
nim
when 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 文件也需要修改一下:
nim
srcDir = "src" bin = "brainfuck"
为了让代码容易重用, 我们做一些重构. 同时保证程序使用读取 stdin 和写入stdout.
在直接接受 code: string
这样的命令行参数之外, 扩展 interpret
procedure 来接收输入输出的流.
引入一个 streams 模块 对 FileStreams
和 StringStream
进行支持:
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
:
nim
proc 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
前面:
nim
proc readCharEOF*(input: Stream): char
然后可以像之前一样去使用解释器, 也很简单:
nim
proc 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 可以返回一个字符串. 这对后边的测试来说很重要:
nim
let res = interpret(readFile("examples/rot13.b"), "Hello World!\n") interpret(readFile("examples/rot13.b")) # with stdout
编译器部分的重写有点复杂. 首先要把 input
跟 output
作为字符串,
那么用户使用这个 proc 的时候就可以用任何他们想要的 stream 了:
nim
proc compile(code, input, output: string): PNimrodNode {.compiletime.} =
还需要两条语句对输入跟输出的 stream 进行初始化然后作为字符串参数:
nim
addStmt "var inpStream = " & input addStmt "var outStream = " & output
当然我在我们就要用 outStream
和 inpStream
来代替 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:
nim
macro compileFile*(filename: string): stmt = compile(staticRead(filename.strval), "stdin.newFileStream", "stdout.newFileStream")
读取输入的字符串, 写入输出的字符串:
nim
macro 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
内容到编译后的程序:
nim
proc 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 社区还是蛮热情的. 谢谢大家.