:面向对象、异常、泛型与模板全解析)
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.mdPragmas 一节与 doc/nimc.md命令行额外特性一节。2. 面向对象编程OOPNim 对 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.nimRootObj* {.compilerproc, inheritable.} object ## The root of Nims 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 CastsNim 严格区分两种概念类型强转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 SyntaxNim 提供了一种调用例程的语法糖可以用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 属性Propertiesgetter/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.nimException* {.compilerproc, magic: Exception.} object of RootObj ## \ ## Base exception class. parent*: ref Exception ## Parent exception (can be used as a stack). name*: cstring ## The exceptions name is its Nim identifier. msg* {.exportc: message.}: string ## The exceptions message. Defect* object of Exception ## \ ## Abstract base class for all exceptions that Nims 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 的内置addpreorder使用显式栈避免递归是性能友好的遍历写法。方法调用语法下使用泛型的特殊[: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.nimtemplate 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 aa in b被转换为contains(b, a)notin和isnot含义自明分别是「不属于」与「不是」。5.1 模板的惰性求值以日志为例模板特别适合惰性求值场景。考虑一个简单的日志 procconst 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 typesuntyped、typed或typetype表示只允许传入类型符号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. 编译到 JavaScriptNim 代码可以编译为 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.mdRootObj/Exception层级定义lib/system.nimnewException模板实现lib/system.nimin/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),仅供参考