Nim 高级特性实战指南(Part II):面向对象、异常、泛型与模板全解析
【免费下载链接】NimNim is a statically typed compiled systems programming language. It combines successful concepts from mature languages like Python, Ada and Modula. Its design focuses on efficiency, expressiveness, and elegance (in that order of priority).项目地址: https://gitcode.com/gh_mirrors/ni/Nim
导读:本文基于 Nim 官方教程第二篇(doc/tut2.md)编写,系统讲解 Nim 的高级语言构造——包括面向对象编程(继承、接口模拟、对象变体、动态分派)、异常处理体系、泛型编程与模板元编程,并补充 JavaScript 后端编译注意事项。文中所有示例均取自官方教程原稿,并结合仓库源码(lib/system.nim、tests/method 等)给出底层实现依据。读完本文,你将掌握 Nim 中类型系统与运行时多态的完整用法,能够写出符合 Nim 设计哲学的、既高效又表达力强的代码。
Nim 是一门静态类型、可编译的系统编程语言,其设计目标按优先级排序为:效率、表现力、优雅。本教程(Part II)是面向已经掌握基础语法(见 doc/tut1.md)的读者的进阶指南,聚焦于高级语言构造。需要说明的是,官方文档指出本教程部分内容略有年代感,更丰富的进阶示例请以 doc/manual.md 为准。
1. Pragmas:给编译器的额外指令
Pragmas 是 Nim 在不引入大量新关键字的前提下,向编译器传递额外信息/指令的机制。Pragmas 被包裹在特殊的{.和.}花括号中。例如本文后续会遇到的{..}、{.inline.}、{.base.}、{.raises: [].}都是 pragma 的具体形态。
本教程不展开讲解 pragma 的完整用法,可用 pragma 的清单见 doc/manual.md(Pragmas 一节)与 doc/nimc.md(命令行额外特性一节)。
2. 面向对象编程(OOP)
Nim 对 OOP 的支持是极简主义的,但这并不意味着它能力不足——恰恰相反,强大的 OOP 技法在 Nim 中完全可用。关键的设计理念是:
- OOP 只是程序设计的一种方式,不是唯一方式;
- 过程式(procedural)方案往往能写出更简单、更高效的代码;
- 组合(has-a)优于继承(is-a),这通常是更好的设计。
2.1 继承(Inheritance)
在 Nim 中,继承完全是可选的。要启用带有运行时类型信息的继承,对象类型需要继承自RootObj——可以直接继承,也可以通过继承一个已经继承自RootObj的类型来间接实现。通常,使用继承的类型还会被标记为ref类型(虽然这并不是强制要求)。要在运行时检查某个对象是否属于某类型,使用of操作符。
type Person = ref object of RootObj name*: string # 带 * 表示 name 字段可从其他模块访问 age: int # 不带 * 表示该字段对其他模块隐藏 Student = ref object of Person # Student 继承自 Person id: int # 并带有 id 字段 var student: Student person: Person assert(student of Student) # 为 true # 对象构造: student = Student(name: "Anton", age: 5, id: 2) echo student[]关键语法与语义:
object of语法实现继承;Nim 目前不支持多重继承;- 如果对象类型没有合适的祖先,可以用
RootObj作为祖先,但这只是约定; - 没有祖先的对象类型是隐式 final的(不可再被继承);
- 可以使用
inheritablepragma 引入新的对象根,而不必局限于system.RootObj(例如 GTK 封装中就使用了这一机制)。
从源码看,RootObj定义于 lib/system.nim:
RootObj* {.compilerproc, inheritable.} = object ## The root of Nim's object hierarchy. ## ## Objects should inherit from `RootObj` or one of its descendants. ## However, objects that have no ancestor are also allowed. RootRef* = ref RootObj ## Reference to `RootObj`.注意其中{.inheritable.}pragma——这正是「没有祖先的对象隐式 final」这一规则背后的实现事实。此外,RootRef* = ref RootObj提供了对RootObj的引用别名。
关于 ref 对象的重要提醒:只要使用继承,就应当使用 ref 对象。虽然这不是严格必需的,但如果是非 ref 对象,类似let person: Person = Student(id: 123)的赋值会截断子类字段,造成数据丢失。
设计建议:组合(has-a 关系)往往比继承(is-a 关系)更适合简单的代码复用。由于 Nim 的对象是值类型,组合的效率和继承一样高。
2.2 用闭包对象模拟接口(Interfaces)
抽象类、协议、trait、接口等概念,在 Nim 中都可以用闭包对象来模拟——即一个持有 proc 字段的对象:
type IntFieldInterface = object getter: proc (): int setter: proc (x: int) proc outer: IntFieldInterface = var captureMe = 0 proc getter(): int = result = captureMe proc setter(x: int) = captureMe = x result = IntFieldInterface(getter: getter, setter: setter)这里getter/setter捕获了局部变量captureMe,构成了一个「对象 + 行为」的整体,等效于其他语言中接口的实现。这是 Nim 用组合表达多态的典型手法。
2.3 互递归类型(Mutually Recursive Types)
对象、元组和引用可以建模相互依赖的复杂数据结构,它们被称为互递归类型。在 Nim 中,这类类型只能在同一个 type 区块中声明(任何其他方式都需要任意的符号前瞻,会拖慢编译速度)。
type Node = ref object # 指向一个对象的引用,该对象含有以下字段: le, ri: Node # 左、右子树 sym: ref Sym # 叶子节点包含对 Sym 的引用 Sym = object # 一个符号 name: string # 符号名 line: int # 符号被声明的行号 code: Node # 符号的抽象语法树这是一个典型的 AST(抽象语法树)建模:Node和Sym相互引用,形成循环结构,在同一个type区块内声明即可编译通过。
2.4 类型转换 vs 类型强转(Type Conversions and Casts)
Nim 严格区分两种概念:
- 类型强转(cast):使用
cast操作符,强制编译器将某个位模式解释为另一种类型,不做任何语义检查; - 类型转换(conversion):一种更「礼貌」的转换方式,保留抽象的值(而非必然保留位模式)。如果类型转换不可行,编译器会报错或抛出异常。
类型转换的语法与普通调用一致,即目标类型(要转换的表达式):
proc getID(x: Person): int = Student(x).id如果x不是Student,则会抛出InvalidObjectConversionDefect异常。
2.5 对象变体(Object Variants)
在某些场景下,一个对象层级显得过于笨重,这时使用简单的变体类型就够了。下面的例子展示了如何用对象变体建模抽象语法树:
# 这是如何在 Nim 中建模抽象语法树的一个示例 type NodeKind = enum # 不同的节点类型 nkInt, # 带整数值的叶子 nkFloat, # 带浮点值的叶子 nkString, # 带字符串值的叶子 nkAdd, # 加法 nkSub, # 减法 nkIf # if 语句 Node = ref object case kind: NodeKind # `kind` 字段是判别器(discriminator) of nkInt: intVal: int of nkFloat: floatVal: float of nkString: strVal: string of nkAdd, nkSub: leftOp, rightOp: Node of nkIf: condition, thenPart, elsePart: Node var n = Node(kind: nkFloat, floatVal: 1.0) # 下面这条语句会抛出 `FieldDefect` 异常,因为 # n.kind 的值与字段不匹配: n.strVal = ""从示例可以看出,对象变体相对对象层级的一个优势是:不同对象类型之间无需转换。当然,访问与判别器值不匹配的字段会抛出异常(如上面的FieldDefect)。
2.6 方法调用语法(Method Call Syntax)
Nim 提供了一种调用例程的语法糖:可以用obj.methodName(args)代替methodName(obj, args)。如果没有剩余参数,括号可以省略:obj.len(代替len(obj))。
这种语法不限于对象,可用于任何类型:
import std/strutils echo "abc".len # 等价于 echo len("abc") echo "abc".toUpperAscii() echo({'a', 'b', 'c'}.card) stdout.writeLine("Hallo") # 等价于 writeLine(stdout, "Hallo")(另一种理解方式是:方法调用语法补全了 Nim 缺失的「后缀表示法」。)
因此,写出「纯面向对象风格」的代码轻而易举:
import std/[strutils, sequtils] stdout.writeLine("Give a list of numbers (separated by spaces): ") stdout.write(stdin.readLine.splitWhitespace.map(parseInt).max.`$`) stdout.writeLine(" is the maximum!")上面的管道式写法把readLine→splitWhitespace→map(parseInt)→max→$串成一条数据流,是方法调用语法与高阶函数结合的最佳示范。
2.7 属性(Properties):getter/setter 与数组下标
如上例所示,Nim不需要单独的 get-属性:普通的 get 过程配合方法调用语法即可达到同样效果。但赋值(set)不同,需要专门的 setter 语法:
type Socket* = ref object of RootObj h: int # 由于缺少星号,无法从模块外部访问 proc `host=`*(s: var Socket, value: int) {.inline.} = ## host 地址的 setter s.h = value proc host*(s: Socket): int {.inline.} = ## host 地址的 getter s.h var s: Socket new s s.host = 34 # 等价于 `host=`(s, 34)(这个例子同时展示了inline过程。)setter 的名字必须用反引号写成`host=`这种运算符形式,才能被s.host = 34语法触发。
[]数组访问运算符也可以重载,从而提供数组属性:
type Vector* = object x, y, z: float proc `[]=`* (v: var Vector, i: int, value: float) = # setter case i of 0: v.x = value of 1: v.y = value of 2: v.z = value else: assert(false) proc `[]`* (v: Vector, i: int): float = # getter case i of 0: result = v.x of 1: result = v.y of 2: result = v.z else: assert(false)不过教程也坦率指出:这个例子其实是「多此一举」——向量用元组建模更好,元组本身就已提供v[]式下标访问。
2.8 动态分派(Dynamic Dispatch)
过程(proc)总是使用静态分派。需要动态分派时,把proc关键字换成method即可:
type Expression = ref object of RootObj ## 表达式的抽象基类 Literal = ref object of Expression x: int PlusExpr = ref object of Expression a, b: Expression # 注意:'eval' 依赖动态绑定 method eval(e: Expression): int {.base.} = # 覆盖这个基方法 quit "to override!" method eval(e: Literal): int = e.x method eval(e: PlusExpr): int = eval(e.a) + eval(e.b) proc newLit(x: int): Literal = Literal(x: x) proc newPlus(a, b: Expression): PlusExpr = PlusExpr(a: a, b: b) echo eval(newPlus(newPlus(newLit(1), newLit(2)), newLit(4)))注意例子中的细节:构造器newLit和newPlus是 proc,因为用静态绑定更合理;而eval必须是 method,因为它需要动态绑定。基类方法上标注的{.base.}pragma 用于标记「这是一个应被子类覆盖的基方法」。
多方法(Multi-methods):从 Nim 0.20 起,要使用多方法必须在编译时显式传入--multimethods:on。在多方法中,所有具有对象类型的参数都会参与分派:
type Thing = ref object of RootObj Unit = ref object of Thing x: int method collide(a, b: Thing) {.inline.} = quit "to override!" method collide(a: Thing, b: Unit) {.inline.} = echo "1" method collide(a: Unit, b: Thing) {.inline.} = echo "2" var a, b: Unit new a new b collide(a, b) # 输出: 2如上例所示,多方法的调用不会产生歧义:collide 2优先于collide 1,因为解析是从左到右进行的,所以Unit, Thing优先于Thing, Unit。仓库测试 tests/method/tmultim.nim 中也有对应的碰撞分派测试(collide分别对TThing/TUnit的四种组合输出不同的提示),其测试矩阵为--multimethods:on,与本教程的编译前提一致。
性能提示:Nim 默认会为方法生成分派树(dispatch trees)。通过--experimental:vtables也可以选择为方法生成虚方法表(vtable),这通常能带来更好的整体性能,尤其是对象层级很深的时候。不过要注意:编译期求值、死代码消除等其他优化对 method 不生效。
3. 异常处理(Exceptions)
在 Nim 中,异常是对象。按惯例,异常类型以Error后缀命名。lib/system.nim 模块定义了一套推荐遵循的异常层级,所有异常都派生自system.Exception,它提供了公共接口。
从源码可以看到完整的异常基类层级(lib/system.nim):
Exception* {.compilerproc, magic: "Exception".} = object of RootObj ## \ ## Base exception class. parent*: ref Exception ## Parent exception (can be used as a stack). name*: cstring ## The exception's name is its Nim identifier. msg* {.exportc: "message".}: string ## The exception's message. Defect* = object of Exception ## \ ## Abstract base class for all exceptions that Nim's runtime raises ## but that are strictly uncatchable... CatchableError* = object of Exception ## \ ## Abstract class for all exceptions that are catchable.要点:
- 异常必须分配在堆上,因为其生命周期不可预知;编译器会阻止你抛出在栈上创建的异常;
- 所有被抛出的异常至少应在
msg字段中说明抛出原因(源码注释甚至直接写明:不提供异常消息是坏风格); - 惯例是:异常只在真正的异常情况下抛出,不应作为控制流的替代手段。
3.1 raise 语句
抛出异常用raise语句:
var e: ref OSError new(e) e.msg = "the request to the OS failed" raise e如果raise关键字后面不跟表达式,则重新抛出当前异常(re-raise)。
为了避免反复书写这种常见模式,lib/system.nim 提供了newException模板:
template newException*(exceptn: typedesc, message: string; parentException: ref Exception = nil): untyped = ## Creates an exception object of type `exceptn` and sets its `msg` field ## to `message`. Returns the new exception object. (ref exceptn)(msg: message, parent: parentException)于是上面冗长的写法可以简化为一行:
raise newException(OSError, "the request to the OS failed")从源码可见,newException本质上是一个类型化模板,接收异常类型exceptn、消息message以及可选的父异常,构造(ref exceptn)(msg: message, parent: parentException)对象。这印证了「异常必须分配在堆上」:模板生成的正是ref引用类型。
3.2 try 语句
try语句用于处理异常:
from std/strutils import parseInt # 读取一个文本文件的前两行(应包含数字) # 并尝试将它们相加 var f: File if open(f, "numbers.txt"): try: let a = readLine(f) let b = readLine(f) echo "sum: ", parseInt(a) + parseInt(b) except OverflowDefect: echo "overflow!" except ValueError: echo "could not convert string to integer" except IOError: echo "IO error!" except CatchableError: echo "Unknown exception!" # 重新抛出未知异常: raise finally: close(f)语义要点:
try之后的语句按序执行,除非抛出了异常;- 抛出异常后,执行匹配的
except分支; - 空的
except分支:当出现未显式列出的异常时执行,类似于if语句中的else部分; finally部分:无论是否发生异常,都会在处理完异常处理器后总是执行;- 异常在某个
except分支中被消费;若未被处理,则沿调用栈向上传播。这意味着一旦发生异常,过程中finally子句之外剩余代码通常不会被执行。
如果需要访问实际的异常对象或消息,可以使用 lib/system.nim 提供的getCurrentException()与getCurrentExceptionMsg()过程:
try: doSomethingHere() except CatchableError: let e = getCurrentException() msg = getCurrentExceptionMsg() echo "Got exception ", repr(e), " with message ", msg3.3 用{.raises.}pragma 标注抛出异常
通过可选的{.raises.}pragma,可以声明一个 proc只会抛出特定的一组异常,或者一个都不抛。一旦使用了该 pragma,编译器会验证这一声明是否属实。例如,如果你声明某 proc 只抛IOError,而它(或它调用的某个 proc)在某个时刻开始抛出新异常,编译器会阻止该 proc 编译通过。用法示例:
proc complexProc() {.raises: [IOError, ArithmeticDefect].} = ... proc simpleProc() {.raises: [].} = ...一旦这样的代码就位,如果抛出的异常列表发生变化,编译器会报错,指出:
- 哪个 proc 的哪一行停止通过 pragma 校验;
- 那个未被捕获的异常是在哪个文件、哪一行抛出的。
这能帮你快速定位发生变化的出错代码。
如何给既有代码加{.raises.}:编译器也能帮你。可以给 proc 加上{.effects.}pragma 语句,编译器会输出截至该点的所有推断效果(异常追踪是 Nim 效果系统的一部分)。另一种迂回方式是使用nim doc命令为整个模块生成文档,文档会为所有 proc 标注其抛出的异常列表。更多关于效果系统的内容见 doc/manual.md。
4. 泛型(Generics)
泛型是 Nim 用类型参数对 proc、迭代器或类型进行参数化的手段。泛型参数写在方括号内,例如Foo[T]。泛型最适合用于高效的类型安全容器:
type BinaryTree*[T] = ref object # BinaryTree 是带泛型参数 `T` 的泛型类型 le, ri: BinaryTree[T] # 左右子树;可以为 nil data: T # 节点存储的数据 proc newNode*T: BinaryTree[T] = # 节点构造器 new(result) result.data = data proc add*T = # 向树中插入节点 if root == nil: root = n else: var it = root while it != nil: # 比较数据项;使用泛型 `cmp` 过程 # 它适用于任何定义了 `==` 和 `<` 运算符的类型 var c = cmp(it.data, n.data) if c < 0: if it.le == nil: it.le = n return it = it.le else: if it.ri == nil: it.ri = n return it = it.ri proc add*T = # 便捷 proc: add(root, newNode(data)) iterator preorder*T: T = # 二叉树的前序遍历。 # 使用显式栈(这比递归迭代器工厂更高效)。 var stack: seq[BinaryTree[T]] = @[root] while stack.len > 0: var n = stack.pop() while n != nil: yield n.data add(stack, n.ri) # 把右子树压入栈 n = n.le # 然后沿左指针前进 var root: BinaryTree[string] # 用 `string` 实例化 BinaryTree add(root, newNode("hello")) # 实例化 `newNode` 和 `add` add(root, "world") # 实例化第二个 `add` proc for str in preorder(root): stdout.writeLine(str)这个例子展示了一棵泛型二叉树。关键点:
- 根据上下文,方括号既可以用于引入类型参数(
BinaryTree*[T]),也可以用于实例化泛型 proc/迭代器/类型(BinaryTree[string]); - 泛型与重载协同工作:
add的两个重载版本按最佳匹配规则选择,同时内置的序列add过程并未被隐藏——preorder迭代器里add(stack, n.ri)调用的正是 seq 的内置add; preorder使用显式栈避免递归,是性能友好的遍历写法。
方法调用语法下使用泛型的特殊[:T]语法:
proc fooT = discard var i: int # i.foo[int]() # Error: expression 'foo(i)' has no type (or is ambiguous) i.foo[:int]() # Success当把泛型 proc 与方法调用语法结合时,i.foo[int]()会因解析歧义而报错,必须改用[:T]形式i.foo[:int]()才能成功调用。
5. 模板(Templates)
模板是一种作用于 Nim 抽象语法树(AST)的简单替换机制。模板在编译器的语义分析阶段被处理,与语言其余部分无缝集成,完全不具备 C 预处理宏的那些缺陷。
调用模板的方式与调用过程一样。示例:
template `!=` (a, b: untyped): untyped = # 该定义存在于 system 模块中 not (a == b) assert(5 != 6) # 编译器将其重写为: assert(not (5 == 6))事实上,!=、>、>=、in、notin、isnot这些运算符本身就是模板。这样做的好处是:只要你重载了==运算符,!=运算符就会自动可用并做正确的事(IEEE 浮点数除外——NaN 会破坏基本布尔逻辑)。
从 lib/system.nim 源码可以找到这些运算符模板的真实定义(lib/system.nim):
template `in`*(x, y: untyped): untyped {.dirty, callsite.} = contains(y, x) template `notin`*(x, y: untyped): untyped {.dirty, callsite.} = not contains(y, x) template `isnot`*(x, y: untyped): untyped {.callsite.} = not (x is y)对应教程中的描述:
a > b被转换为b < a;a in b被转换为contains(b, a);notin和isnot含义自明(分别是「不属于」与「不是」)。
5.1 模板的惰性求值:以日志为例
模板特别适合惰性求值场景。考虑一个简单的日志 proc:
const debug = true proc log(msg: string) {.inline.} = if debug: stdout.writeLine(msg) var x = 4 log("x has the value: " & $x)这段代码有个缺陷:如果某天debug被设为 false,相当昂贵的$和&运算依然会被执行!(过程的参数求值是**急切(eager)**的。)
把log从 proc 改成模板即可解决:
const debug = true template log(msg: string) = if debug: stdout.writeLine(msg) var x = 4 log("x has the value: " & $x)因为模板是语法层面的替换,"x has the value: " & $x只有在debug为 true 时才会真正被求值——这就是模板实现惰性求值的威力。
5.2 模板参数类型与语句块传递
模板的参数类型可以是普通类型,也可以是元类型(meta types)untyped、typed或type:
type表示只允许传入类型符号;untyped表示在表达式传给模板之前不进行符号查找和类型解析。
如果模板没有显式返回类型,则与 proc 和 method 保持一致,默认使用void。
向模板传递语句块:最后一个参数使用untyped即可:
template withFile(f: untyped, filename: string, mode: FileMode, body: untyped) = let fn = filename var f: File if open(f, fn, mode): try: body finally: close(f) else: quit("cannot open: " & fn) withFile(txt, "ttempl3.txt", fmWrite): txt.writeLine("line 1") txt.writeLine("line 2")在这个例子中,两条writeLine语句被绑定到body参数上。withFile模板封装了样板代码,帮助避免一个常见 bug:忘记关闭文件。注意其中let fn = filename语句的作用——它确保filename只被求值一次。
5.3 实战案例:Lifting Procs(提升标量过程)
模板的元编程能力还可以用来「提升」过程:把只接受标量参数的 proc 变成能处理seq[T]乃至嵌套序列的重载版本:
import std/math template liftScalarProc(fname) = ## 将接收单个标量参数并返回标量值的 proc ## (例如 `proc sssssT: float`) ## 提升为可处理单个 seq[T] 或嵌套 seq[seq[]] 参数 ## 的模板化过程 ## ## ```Nim ## liftScalarProc(abs) ## # 现在 abs(@[@[1,-2], @[-2,-3]]) == @[@[1,2], @[2,3]] ## ``` proc fnameT: auto = var temp: T type outType = typeof(fname(temp)) result = newSeqoutType for i in 0..<x.len: result[i] = fname(x[i]) liftScalarProc(sqrt) # 让 sqrt() 支持序列 echo sqrt(@[4.0, 16.0, 25.0, 36.0]) # => @[2.0, 4.0, 5.0, 6.0]该模板通过typeof(fname(temp))推导输出类型,为每个输入元素调用标量版本并收集到新序列中。一次liftScalarProc(sqrt)调用,就让sqrt拥有了向量化能力,充分展示了「模板 = 编译期 AST 替换」的灵活性。
6. 编译到 JavaScript
Nim 代码可以编译为 JavaScript。要编写 JavaScript 兼容的代码,需要记住以下几点:
addr和ptr在 JavaScript 中有略微不同的语义:如果不确定它们如何被翻译为 JavaScript,建议避免使用;castT在 JavaScript 中被翻译为(x),唯一的例外是带符号/无符号整数之间的强转,此时其行为等同于 C 语言中的静态转换;cstring在 JavaScript 中就是 JavaScript 字符串。好的实践是仅在语义合适时才使用cstring,例如不要把cstring当作二进制数据缓冲区来用。
这些注意事项让同一份 Nim 代码可以安全地在 C/C++ 与 JavaScript 两个后端间移植。
7. 下一步:Part III
本教程的下一部分 doc/tut3.md 完全专注于宏(macros)元编程,是理解 Nim 编译期能力的进阶内容,建议继续阅读。
附:本文涉及的仓库资源索引
| 资源 | 路径 |
|---|---|
| 本教程原文 | doc/tut2.md |
| 教程第一部分(基础语法) | doc/tut1.md |
| 教程第三部分(宏与元编程) | doc/tut3.md |
| 语言手册(更完整的进阶特性示例) | doc/manual.md |
| 编译器用户指南 | doc/nimc.md |
RootObj/Exception层级定义 | lib/system.nim |
newException模板实现 | lib/system.nim |
in/notin/isnot运算符模板 | lib/system.nim |
异常获取过程getCurrentException* | lib/system.nim |
| 多方法测试(碰撞分派) | tests/method/tmultim.nim |
| 单方法测试 | tests/method/tsingle_methods.nim |
【免费下载链接】NimNim is a statically typed compiled systems programming language. It combines successful concepts from mature languages like Python, Ada and Modula. Its design focuses on efficiency, expressiveness, and elegance (in that order of priority).项目地址: https://gitcode.com/gh_mirrors/ni/Nim
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考