Nim手册

Search:
Group by:

作者:Andreas Rumpf, Zahary Karadjov
版本:|2.0.2+|

"复杂性" 如同 "能量": 终端用户把它转嫁给其他参与者,但给定任务的总量似乎没变。 -- Ran

关于手册

注意: 当前手册还是草案! Nim的一些功能需要更加准确的描述。手册内容也在不断更新,使其逐渐成为规范。

注意: Nim的实验性功能在这里

注意: 赋值、移动和析构在文档特定的析构部分。

当前手册对 Nim 语言的词法、语法和语义做了描述。

打算学习怎样编译 Nim 程序和生成文档,请阅读用户编译指南文档生成工具指南

Nim语言使用"扩展BNF"来解释结构, (a)* 表示 0 个或多个 aa+ 表示 1 个或多个 a(a)? 表示 1 个可选的 a ,圆括号用来分组元素。

& 是预先运算符; &a 表示期望一个 a ,但没有用掉,而在之后的规则中被消耗。

|/ 符号用来标记备选项,优先级最低。/ 是有序选择,要求解析器按照给定的顺序来尝试备选项,/ 常用来消除语法二义性。

非终结符号以小写字母开头,抽象终结符号字母全大写,逐字的终结符号(包括关键词)用 ' 引起来。例如:

ifStmt = 'if' expr ':' stmts ('elif' expr ':' stmts)* ('else' stmts)?

二元的 ^* 运算符表示为 0 或更多,由第二个参数做为间隔;^+ 表示 1 或更多。 a ^+ ba (b a)* 的简写, a ^* b 则是 (a (b a)*)? 的简写。 例如:

arrayConstructor = '' expr ^* ',' ''

Nim 的其他,如作用域规则或运行时语义,使用非标准的描述。

定义

Nim 代码是特定的计算单元,作用于称为 locations "地址"组件构成的内存。 变量本质上是地址的名称,每个变量和地址都有特定的 type "类型",变量的类型被称为 static type "静态类型", 地址的类型被称为 dynamic type "动态类型"。如果静态类型与动态类型不相同,它就是动态类型的父类或子类。

identifier "标识符"是变量、类型、过程等的名称声明符号,一个声明所适用的程序区域被称为该声明的 scope "作用域", 作用域可以嵌套,一个标识符的含义由标识符所声明的最小包围作用域决定,除非重载的解析规则另有建议。

一个表达式特指产生值或地址的计算,产生地址的表达式被称为 l-values "左值",左值可以表示地址,也可以表示该地址包含的值,这取决于上下文。

Nim program "程序"由一个或多个包含 Nim 代码的文本 source files "源文件"组成,由Nim compiler "编译器"处理成 executable "可执行"文件,这个可执行文件的性质取决于编译器实现,例如,它可能是一个本地二进制文件或 JavaScript 源代码。

常规的 Nim 程序,大部分代码被编译至可执行文件,而有些代码可能会在 compile-time "编译期" 执行。 包括常量表达式、宏定义和宏定义使用的 Nim 程序。 编译期执行支持 Nim 语言的大部分,但有限制 -- 详情查看编译期执行限制。 其术语 runtime "运行时"涵盖了编译期执行和可执行文件的代码执行。

编译器将 Nim 源码解析成称为 abstract syntax tree (AST) "抽象语法树"的内部数据结构,在执行代码或将其编译为可执行文件之前,通过 semantic analysis "语义分析"对AST进行转换,增加了语义信息,如表达式类型、标识符的含义,以及在某些情况下表达式的值。在语义分析中检测到的错误被称为 static error "静态错误",当前手册中描述的错误在没有其他约定时,就是静态错误。

panic "恐慌"是在运行时执行检测和报告的错误。这种错误的报告,通过 引发异常以致命错误 结束的方式。 也提供了一种方法来禁用这些 runtime checks "运行时检查"。详见编译指示一节。

恐慌的结果是异常还是致命的错误,实现是特定的,因此,下面的程序无效,尽管代码试图捕获越界访问数组的 IndexDefect ,但编译器可能会以致命错误结束程序。

var a: array[0..1, char]
let i = 5
try:
  a[i] = 'N'
except IndexDefect:
  echo "invalid index"

目前允许通过 --panics:on|off 在不同方式之间切换,当打开时,程序会因恐慌而结束,当关闭时,运行时的错误会变为异常。 --panics:on 的好处是产生的二进制代码更小,编译器可以更自由地优化。

unchecked runtime error "未检查的运行时错误"是不能保证被检测到的错误,它可能导致计算产生意外后果,如果只使用 safe "安全"的语言特性,并且没有禁用运行时检查,就不会产生这类错误。

constant expression "常量表达式",在对包含它的代码进行语义分析时,其值就可以被计算出来,并且不局限于语义分析时求值的能力,例如常量折叠。它从来不会是左值,也不会有副作用。它可以使用编译期支持执行的所有 Nim 语言特性。由于常量表达式可以作为语义分析时的输入,比如定义数组边界,鉴于这种灵活性要求,编译器交错进行语义分析和编译期代码执行。

想象一下,语义分析原本在源代码中从上到下、从左到右地进行,而在必要时,为了计算后续语义分析所需要的数值,交错执行编译期的代码,产生了语义分析并不完全是自上而下、自左而右进行的情况。这一点非常明确,我们将在文档的后面进一步了解。宏调用需要这种交错。

词法分析

编码

所有的 Nim 源文件都采用 UTF-8 编码(或其 ASCII 子集),不支持其他编码。 可以使用任意标准平台的线性序列终端 —— Unix 形式使用 ASCII LF(换行), Windows 形式使用 ASCII 序列 CR LF(换行后返回),或旧的 Macintosh 形式使用 ASCII CR(返回)字符,无论在那个平台上,都可以无差别地使用这些形式。

缩进

Nim 的标准语法描述了 indentation sensitive "缩进敏感"的语言特性,表示其所有的控制结构可以通过缩进来识别,缩进只包括空格,不允许使用制表符。

处理缩进的实现方式如下,词法分析器用前导空格数来解释随后的 Token,缩进不是独立的 Token,这个技巧使得 Nim 解析时只需要预先检查 1 个 Token。

语法分析器使用一个缩进级别的堆栈:该堆栈由计算空格的整数组成,语法分析器在对应的策略位置查询缩进信息,而忽略其他位置。 伪终结符 IND{>} 表示缩进相比堆栈顶部的条目包含更多的空格, IND{=} 表示缩进有相同的空格数,DED 是另一个伪终结符, 表示从堆栈中弹出一个值的 action 动作, IND{>} 则意味着推到堆栈中。

用这些标记,我们现在可以容易地定义出核心语法:语句块。以下是简化的例子:

ifStmt = 'if' expr ':' stmt (IND{=} 'elif' expr ':' stmt)* (IND{=} 'else' ':' stmt)?

simpleStmt = ifStmt / ...

stmt = IND{>} stmt ^+ IND{=} DED # 语句列表 / simpleStmt # 或者单个语句

注释

注释,是在字符串或字符字面值之外的任意位置,以 # 字符开始,注释由 comment pieces "注释段"连接组成, 一个注释段以 # 开始直到行尾,并包括行末的字符。如果下一行只由一个注释段组成,在它和前面的注释段之间没有其他标记,就不会开启一个新的注释。

i = 0     # 这是一个多行注释。
  # 词法分析器将这两部分合并在一起。
  # 注释在这里继续。

Documentation comments "文档注释"以两个 ## 开头,文档注释是 Token 标记,它们属于语法树,只允许在源文件的特定语法位置出现。

多行注释

从语言的 0.13.0 版本开始, Nim 支持多行注释。如下:

#[Comment here.
Multiple lines
are not a problem.]#

多行注释支持嵌套:

#[  #[ Multiline comment in already
   commented out code. ]#
proc p[T](x: T) = discard
]#

还有多行文档注释,同样支持嵌套:

proc foo =
  ##[Long documentation comment
     here.
  ]##

你也可以使用 discard 语句三引号字符串字面量一起创建多行注释:

discard """ 你可以在此处使用 Nim 代码的文本注释,没有任何缩进限制。
      yes("我可以问一个无聊的问题吗?") """

这是 0.13.0 版本之前创建多行注释的方法,并且用于为单元测试框架提供规格说明。

标识符和关键字

Nim 中的标识符可以是任何字母、数字和下划线组成的字符串,但有以下限制:

  • 字母开头
  • 不允许下划线 _ 结尾
  • 不允许双下划线 __ 结尾。

    letter ::= 'A'..'Z' | 'a'..'z' | '\x80'..'\xff'
    digit ::= '0'..'9'
    IDENTIFIER ::= letter ( ['_'] (letter | digit) )*

目前,任何序数值大于 127 的 Unicode 字符(非 ASCII )都被归类为 letter "字", 因而可以做为标识符的一部分,但以后的语言版本可能会将一些 Unicode 字符指定为运算符。

以下关键词被保留,不能作为标识符使用:

addr and as asm
bind block break
case cast concept const continue converter
defer discard distinct div do
elif else end enum except export
finally for from func
if import in include interface is isnot iterator
let
macro method mixin mod
nil not notin
object of or out
proc ptr
raise ref return
shl shr static
template try tuple type
using
var
when while
xor
yield

有些关键词是未使用的保留字,提供给语言未来拓展。

标识符相等

如果以下算法返回真,则认为两个标识符相等:

proc sameIdentifier(a, b: string): bool =
  a[0] == b[0] and
    a.replace("_", "").toLowerAscii == b.replace("_", "").toLowerAscii

这意味着,在进行比较时,只有第一个字母是区分大小写的,其他字母在 ASCII 范围内不区分大小,并忽略下划线。

这种相当非正统的标识符比较方式被称为 partial case-insensitivity "部分大小写不敏感",比传统的大小写敏感有一些优势。

它允许程序员使用自己喜欢的拼写风格。 humpStyle "驼峰风格"还是 snake_style "蛇形风格",但应要求不同程序员编写的库不能使用不兼容的约定。 另一个好处是,按 Nim 思考的编辑器或 IDE 可以显示首选的标识符,使程序员不必记住标识符的准确拼写。 而第一个字母例外的原因是,允许明确解析如 var foo: Foo 这种常见代码。

需注意,这个规则也适用于关键字,即 notinnotInnot_in 相同,建议关键字的书写方式首选全小写方式,如 notin, isnot

Nim 曾经是一种 style-insensitive 完全"大小写不敏感"的语言,意味着不区分大小写,忽略下划线,甚至 fooFoo 之间没有区别。

作为标识符的关键词

如果一个关键词被括在反撇号里,就失去了关键词的属性,变成了一个普通的标识符。

Examples

var `var` = "Hello Stropping"

type Obj = object
  `type`: int

let `object` = Obj(`type`: 9)
assert `object` is Obj
assert `object`.`type` == 9

var `var` = 42
let `let` = 8
assert `var` + `let` == 50

const `assert` = true
assert `assert`

字符串字面值

语法中的终结符号: STR_LIT .

字符串可以用配对的双引号来分隔,可以包含以下 escape sequences"转义字符":

转义字符含义
\p平台特定的换行符: Windows 上的 CRLF , Unix上 的 LF
\r, \ccarriage return 回车
\n, \lline feed 换行(通常叫创建新行 newline)
\fform feed 换页
\ttabulator 制表符
\vvertical tabulator 垂直制表符
\\backslash 反斜线
\"quotation mark 双引号
\'apostrophe 撇号
\ '0'..'9'+character with decimal value d; 十进制值字符 后面的所有十进制数字都用于该字符
\aalert 警报
\bbackspace 退格符
\eescape [ESC]
\x HHcharacter with hex value HH ; 十进制值HH 只允许两个十六进制数字
\u HHHHunicode codepoint with hex value HHHH; 十进制值HHHH 只允许四个十六进制数字
\u {H+}unicode codepoint; unicode字码元素 包含在 {} 中的所有十六进制数字都用于字码元素

Nim 中的字符串可以包含任意 8-bit 值,甚至嵌入零,然而,某此操作可能会将第一个二进制零解释为终止符。

三重引用字符串字面值

语法中的终结符号: TRIPLESTR_LIT.

字符串字面值也可以用三个双引号 """ ... """ 来分隔,这种形式支持多行,可以包含 " ,并且不解释任何转义字符,为了方便,开头 """ 后面换行符以及空格并不包括在字符串中,字符串的结尾定义为 """[^"] 模式,如下:

""""long string within quotes""""

产生:

"引号内的长字符串"

原始字符串字面值

语法中的终结符号: RSTR_LIT

还有原始字符串字面值,前面为字母 rR ,并匹配一对双引号普通字符串,它不解释转义字符,这在正则表达式或 Windows 的路径中使用时很方便。

var f = openFile(r"C:\texts\text.txt") # 是原始字符串, 所以 ``\t`` 不是制表符

要在原始字符串字面值中含有 " 则必须成双。

r"a""b"

产生:

a"b

不能用 r"""" 这个标记,因为原始字符串中又引入了三引号的字符串字面值。 r"""""" 是相同的,三引号原始字符串字面值也不解释转义字符。

广义的原始字符串字面值

语法中的终结符号: GENERALIZED_STR_LIT , GENERALIZED_TRIPLESTR_LIT .

identifier"string literal" 结构是广义的原始字符串字面值,注意,标识符和开头的引号之间没有空格。它是 identifier(r"string literal") 结构的简写方式,表示以原始字符串字面值为唯一参数的常规调用。广义的原始字符串字面值的价值,在于便捷嵌入mini语言,例如正则表达式。

还有 identifier"""string literal""" 结构,是 identifier("""string literal""") 的简写方式。

字符字面值

字符字面值用单引号 '' 括起来,可以包含与字符串相同的转义字符 —— 但有一种例外:不允许与平台有关的 newline (\p) "换行符",因为它可能比一个字符宽(它可能是一对CR/LF)。下面是有效的 escape sequences "转义字符"字面值。

转义字符含义
\r, \ccarriage return 回车
\n, \lline feed 换行(通常叫创建新行 newline)
\fform feed 换页
\ttabulator 制表符
\vvertical tabulator 垂直制表符
\\backslash 反斜线
\"quotation mark 双引号
\'apostrophe 撇号
\ '0'..'9'+character with decimal value d; 十进制值字符 后面的所有十进制数字都用于该字符
\aalert 警报
\bbackspace 退格符
\eescape [ESC]
\x HHcharacter with hex value HH; 十进制值HH 只允许两个十六进制数字

一个字符不是 Unicode 字符,而是单字节。

原由:为了能够有效地支持 array[char, int]set[char]

Rune 类型可以代表任何 Unicode 字符。 Rune 声明在 unicode 模块中。

如果前面有一个反引号 Token,则不以 ' 结尾的字符字面值被解释为 ' 。在前面的反引号标记和字符字面值之间不能有空格。 这种特殊情况是为了保证像 proc `'customLiteral`(s: string) 这样的声明有效。 proc `'customLiteral`(s: string)proc `'\''customLiteral`(s: string) 相同。

参阅 自定义数值字面量

数值字面值

数值字面量的形式为:

hexdigit = digit | 'A'..'F' | 'a'..'f'
octdigit = '0'..'7'
bindigit = '0'..'1'
unary_minus = '-' # See the section about unary minus
HEX_LIT = unary_minus? '0' ('x' | 'X' ) hexdigit ( ['_'] hexdigit )*
DEC_LIT = unary_minus? digit ( ['_'] digit )*
OCT_LIT = unary_minus? '0' 'o' octdigit ( ['_'] octdigit )*
BIN_LIT = unary_minus? '0' ('b' | 'B' ) bindigit ( ['_'] bindigit )*

INT_LIT = HEX_LIT
        | DEC_LIT
        | OCT_LIT
        | BIN_LIT

INT8_LIT = INT_LIT ['\''] ('i' | 'I') '8'
INT16_LIT = INT_LIT ['\''] ('i' | 'I') '16'
INT32_LIT = INT_LIT ['\''] ('i' | 'I') '32'
INT64_LIT = INT_LIT ['\''] ('i' | 'I') '64'

UINT_LIT = INT_LIT ['\''] ('u' | 'U')
UINT8_LIT = INT_LIT ['\''] ('u' | 'U') '8'
UINT16_LIT = INT_LIT ['\''] ('u' | 'U') '16'
UINT32_LIT = INT_LIT ['\''] ('u' | 'U') '32'
UINT64_LIT = INT_LIT ['\''] ('u' | 'U') '64'

exponent = ('e' | 'E' ) ['+' | '-'] digit ( ['_'] digit )*
FLOAT_LIT = unary_minus? digit (['_'] digit)* (('.' digit (['_'] digit)* [exponent]) |exponent)
FLOAT32_SUFFIX = ('f' | 'F') ['32']
FLOAT32_LIT = HEX_LIT '\'' FLOAT32_SUFFIX
            | (FLOAT_LIT | DEC_LIT | OCT_LIT | BIN_LIT) ['\''] FLOAT32_SUFFIX
FLOAT64_SUFFIX = ( ('f' | 'F') '64' ) | 'd' | 'D'
FLOAT64_LIT = HEX_LIT '\'' FLOAT64_SUFFIX
            | (FLOAT_LIT | DEC_LIT | OCT_LIT | BIN_LIT) ['\''] FLOAT64_SUFFIX

CUSTOM_NUMERIC_LIT = (FLOAT_LIT | INT_LIT) '\'' CUSTOM_NUMERIC_SUFFIX

# CUSTOM_NUMERIC_SUFFIX is any Nim identifier that is not
# a pre-defined type suffix.

从描述中可以看出,数值字面值可以包含下划线,以便于阅读。整数和浮点数可以用十进制(无前缀)、二进制(前缀 0b )、八进制(前缀 0o )和十六进制(前缀 0x )注解表示。

-1 这样的数值字面值中的一元减号 - 是字面值的一部分,为了让 -128'i8 等表达式有效,后来被添加到语言中。如果没有这种例外,则只有 -128 有效, -- 128 将不是有效的 int8 值。

unary_minus "一元减号"规则有一些限制,这在正式语法中没有提到。 - 是数值字面值的一部分时,前面的字符必须在 {' ', '\t', '\n', '\r', ',', ';', '(', '[', '{'} 集合中,这个设计是为了涵盖大多数合理情况。

在下面的例子中, -1 是单独的 Token 标记:

echo -1
echo(-1)
echo [-1]
echo 3,-1

"abc";-1

在下面的例子中, -1 被解析为两个独立的 Token 标记( - 1 ):

echo x-1
echo (int)-1
echo [a]-1
"abc"-1

以撇号 ( \' ) 开始的后缀被称为 type suffix "类型后缀"。没有类型后缀的字面值是整数类型,当包含一个点或 E|e 时是 float 类型。如果字面值的范围在 low(int32)..high(int32) 之间,那么这个整数类型就是 int ,否则就是 int64 。为了记数方便,如果类型后缀明确,那么后缀的撇号是可选的(只有带类型后缀的十六进制浮点数字面值的含义才会不明确)。

预定义的类型后缀有:

类型后缀产生的字面值类型
'i8int8
'i16int16
'i32int32
'i64int64
'uuint
'u8uint8
'u16uint16
'u32uint32
'u64uint64
'ffloat32
'dfloat64
'f32float32
'f64float64

浮点数字面值也可以采用二进制、八进制或十六进制的注解: 0B0_10001110100_0000101001000111101011101111111011000101001101001001'f64 根据 IEEE 浮点标准,约为 1.72826e35 。

字面值必须匹配数据类型,例如, 333'i8 是一个无效的字面值。以非 10 进制表示的字面值主要用于标记和比特位模式, 因此检查是对位宽而不是值范围进行的,所以: 0b10000000'u8 == 0x80'u8 == 128,但是, 0b10000000'i8 == 0x80'i8 == -128 而不是 -1。

自定义数值字面值

如果后缀未预定义,那么后缀会被认为是对 proc 过程、 template 模板、 macro 宏或其他可调用标识符的调用, 包含字面值的字符串被传递给该标识符。可调用标识符需要用特定的 ' 前缀声明。

import strutils
type u4 = distinct uint8 # 一个4位无符号整数,又称 "nibble"
proc `'u4`(n: string): u4 =
  # 这是必需的。
  result = (parseInt(n) and 0x0F).u4

var x = 5'u4

更确切地说,自定义的数值字面值 123'custom 在解析步骤中被转换为 r"123".'custom 。并没有对应于这种转换的 AST 节点种类。通过这种转换,在额外参数传递给被调用者时,处理更合理。

import strutils
type u4 = distinct uint8 # 4 位无符号整数,又称 "nibble"
proc `'u4`(n: string; moreData: int): u4 =
  result = (parseInt(n) and 0x0F).u4

var x = 5'u4(123)

自定义数值字面值由名称为 CUSTOM_NUMERIC_LIT 的语法规则涵盖。自定义的数值字面值是单独的 Token 标记。

运算符

Nim 允许用户定义操作符。操作符是以下字符的任意组合:

=     +     -     *     /     <     >
@     $     ~     &     %     |
!     ?     ^     .     :     \

(语法中使用终结符 OPR 来表示这里定义的运算符标识符。)

这些关键字也是运算符: and or not xor shl shr div mod in notin is isnot of as from

., =, :, :: 不能作为一般运算符使用;,其目的是应用于其他符号。

*: 是特殊情况,会处理为两个 Token 标记 *: ,是为了支持 var v*: T

not 关键字总是一元运算符, a not b 解析为 a(not b) , 而不是 (a) not (b)

Unicode 运算符

这些 Unicode 操作符也被解析为操作符:

∙ ∘ × ★ ⊗ ⊘ ⊙ ⊛ ⊠ ⊡ ∩ ∧ ⊓   # 与 * (乘法) 具有相同的优先级
± ⊕ ⊖ ⊞ ⊟ ∪ ∨ ⊔              # 与 + (加法) 具有相同的优先级

Unicode 操作符可以与非 Unicode 操作符组合。然后应用通常的优先级扩展,例如, = 就像 *= 一样是一个赋值操作符。

不进行 Unicode 规范化步骤。

其他标记

以下字符串表示其他标记:

`   (    )     {    }     [    ]    ,  ;   [.    .]  {.   .}  (.  .)  [:

slice "切片"运算符 .. 优先于其他包含点的标记: {..} 是三个标记 {, .., } 而不是两个标记 {., .}

词法

本节列出了 Nim 的标准语法。语法分析器如何处理缩进问题,在词法分析一节有说明。

Nim 允许用户定义运算符。二元运算符有 11 个不同的优先级。

结合律

第一个字符为 ^ 的二元运算符是右结合,所有其他二元运算符是左结合。

proc `^/`(x, y: float): float =
  # 右结合除法运算符
  result = x / y
echo 12 ^/ 4 ^/ 8 # 24.0 (4 / 8 = 0.5, then 12 / 0.5 = 24.0)
echo 12  / 4  / 8 # 0.375 (12 / 4 = 3.0, then 3 / 8 = 0.375)

优先级

一元运算符总是比任意二元运算符结合性更强: $a + b($a) + b 而不是 $(a + b)

如果一个一元运算符的第一个字符是 @ ,它就是一个 sigil-like 运算符,比 primarySuffix 的结合性更强: @x.abc 被解析为 (@x).abc ,而 $x.abc 被解析为 $(x.abc)

对于不是关键字的二元运算符,优先级由以下规则决定:

->~>=> 结尾的运算符被称为 arrow like "箭头",在所有运算符中优先级最低。

如果运算符以 = 结尾,并且其第一个字符不是 <, >, !, =, ~, ? 中的任意一个,那么它就是一个 赋值运算符 ,具有第二低的优先级。

否则,优先级由第一个字符决定。

优先级运算符第一个字符终结符号
10 (最高)$ ^OP10
9* / div mod shl shr %* % \ /OP9
8+ -+ - ~ |OP8
7&&OP7
6...OP6
5== <= < >= > != in notin is isnot not of as from= < > !OP5
4andOP4
3or xorOP3
2@ : ?OP2
1赋值运算符 (如 +=, *=)OP1
0 (最低)箭头运算符 (like ->, =>)OP0

一个运算符是否被用作前缀,会受到前置空格影响 (这个解析变化是在 0.13.0 版本中引入的) 。

echo $foo
# 解析为
echo($foo)

空格也决定了 (a, b) 是被解析为调用的参数列表,还是被解析为元组构造。

echo(1, 2) # 把 1 和 2 传递给 echo

echo (1, 2) # 把 tuple (1, 2) 传递给 echo

点类运算符

语法中的终结符号: DOTLIKEOP

点类运算符是以 . 开头的运算符,但不是以 .. 开头,例如 .? ,它们的优先级与 . 相同,因此 a.?b.c 被解析为 (a.?b).c ,而不是 a.? (b.c)

语法

语法的起始符号是 module

# This file is generated by compiler/parser.nim.
module = complexOrSimpleStmt ^* (';' / IND{=})
comma = ',' COMMENT?
semicolon = ';' COMMENT?
colon = ':' COMMENT?
colcom = ':' COMMENT?
operator =  OP0 | OP1 | OP2 | OP3 | OP4 | OP5 | OP6 | OP7 | OP8 | OP9
         | 'or' | 'xor' | 'and'
         | 'is' | 'isnot' | 'in' | 'notin' | 'of' | 'as' | 'from'
         | 'div' | 'mod' | 'shl' | 'shr' | 'not' | '..'
prefixOperator = operator
optInd = COMMENT? IND?
optPar = (IND{>} | IND{=})?
simpleExpr = arrowExpr (OP0 optInd arrowExpr)* pragma?
arrowExpr = assignExpr (OP1 optInd assignExpr)*
assignExpr = orExpr (OP2 optInd orExpr)*
orExpr = andExpr (OP3 optInd andExpr)*
andExpr = cmpExpr (OP4 optInd cmpExpr)*
cmpExpr = sliceExpr (OP5 optInd sliceExpr)*
sliceExpr = ampExpr (OP6 optInd ampExpr)*
ampExpr = plusExpr (OP7 optInd plusExpr)*
plusExpr = mulExpr (OP8 optInd mulExpr)*
mulExpr = dollarExpr (OP9 optInd dollarExpr)*
dollarExpr = primary (OP10 optInd primary)*
operatorB = OP0 | OP1 | OP2 | OP3 | OP4 | OP5 | OP6 | OP7 | OP8 | OP9 |
            'div' | 'mod' | 'shl' | 'shr' | 'in' | 'notin' |
            'is' | 'isnot' | 'not' | 'of' | 'as' | 'from' | '..' | 'and' | 'or' | 'xor'
symbol = '`' (KEYW|IDENT|literal|(operator|'('|')'|'['|']'|'{'|'}'|'=')+)+ '`'
       | IDENT | 'addr' | 'type' | 'static'
symbolOrKeyword = symbol | KEYW
exprColonEqExpr = expr ((':'|'=') expr
                       / doBlock extraPostExprBlock*)?
exprEqExpr = expr ('=' expr
                  / doBlock extraPostExprBlock*)?
exprList = expr ^+ comma
optionalExprList = expr ^* comma
exprColonEqExprList = exprColonEqExpr (comma exprColonEqExpr)* (comma)?
qualifiedIdent = symbol ('.' optInd symbolOrKeyword)?
setOrTableConstr = '{' ((exprColonEqExpr comma)* | ':' ) '}'
castExpr = 'cast' ('[' optInd typeDesc optPar ']' '(' optInd expr optPar ')') /
parKeyw = 'discard' | 'include' | 'if' | 'while' | 'case' | 'try'
        | 'finally' | 'except' | 'for' | 'block' | 'const' | 'let'
        | 'when' | 'var' | 'mixin'
par = '(' optInd
          ( &parKeyw (ifExpr / complexOrSimpleStmt) ^+ ';'
          | ';' (ifExpr / complexOrSimpleStmt) ^+ ';'
          | pragmaStmt
          | simpleExpr ( (doBlock extraPostExprBlock*)
                       | ('=' expr (';' (ifExpr / complexOrSimpleStmt) ^+ ';' )? )
                       | (':' expr (',' exprColonEqExpr     ^+ ',' )? ) ) )
          optPar ')'
literal = | INT_LIT | INT8_LIT | INT16_LIT | INT32_LIT | INT64_LIT
          | UINT_LIT | UINT8_LIT | UINT16_LIT | UINT32_LIT | UINT64_LIT
          | FLOAT_LIT | FLOAT32_LIT | FLOAT64_LIT
          | STR_LIT | RSTR_LIT | TRIPLESTR_LIT
          | CHAR_LIT | CUSTOM_NUMERIC_LIT
          | NIL
generalizedLit = GENERALIZED_STR_LIT | GENERALIZED_TRIPLESTR_LIT
identOrLiteral = generalizedLit | symbol | literal
               | par | arrayConstr | setOrTableConstr | tupleConstr
               | castExpr
tupleConstr = '(' optInd (exprColonEqExpr comma?)* optPar ')'
arrayConstr = '[' optInd (exprColonEqExpr comma?)* optPar ']'
primarySuffix = '(' (exprColonEqExpr comma?)* ')'
      | '.' optInd symbolOrKeyword ('[:' exprList ']' ( '(' exprColonEqExpr ')' )?)? generalizedLit?
      | DOTLIKEOP optInd symbolOrKeyword generalizedLit?
      | '[' optInd exprColonEqExprList optPar ']'
      | '{' optInd exprColonEqExprList optPar '}'
pragma = '{.' optInd (exprColonEqExpr comma?)* optPar ('.}' | '}')
identVis = symbol OPR?  # postfix position
identVisDot = symbol '.' optInd symbolOrKeyword OPR?
identWithPragma = identVis pragma?
identWithPragmaDot = identVisDot pragma?
declColonEquals = identWithPragma (comma identWithPragma)* comma?
                  (':' optInd typeDescExpr)? ('=' optInd expr)?
identColonEquals = IDENT (comma IDENT)* comma?
     (':' optInd typeDescExpr)? ('=' optInd expr)?)
tupleTypeBracket = '[' optInd (identColonEquals (comma/semicolon)?)* optPar ']'
tupleType = 'tuple' tupleTypeBracket
tupleDecl = 'tuple' (tupleTypeBracket /
    COMMENT? (IND{>} identColonEquals (IND{=} identColonEquals)*)?)
paramList = '(' declColonEquals ^* (comma/semicolon) ')'
paramListArrow = paramList? ('->' optInd typeDesc)?
paramListColon = paramList? (':' optInd typeDesc)?
doBlock = 'do' paramListArrow pragma? colcom stmt
routineExpr = ('proc' | 'func' | 'iterator') paramListColon pragma? ('=' COMMENT? stmt)?
routineType = ('proc' | 'iterator') paramListColon pragma?
forStmt = 'for' ((varTuple / identWithPragma) ^+ comma) 'in' expr colcom stmt
forExpr = forStmt
expr = (blockExpr
      | ifExpr
      | whenExpr
      | caseStmt
      | forExpr
      | tryExpr)
      / simpleExpr
simplePrimary = SIGILLIKEOP? identOrLiteral primarySuffix*
commandStart = &('`'|IDENT|literal|'cast'|'addr'|'type'|'var'|'out'|
                 'static'|'enum'|'tuple'|'object'|'proc')
primary = simplePrimary (commandStart expr (doBlock extraPostExprBlock*)?)?
        / operatorB primary
        / routineExpr
        / rawTypeDesc
        / prefixOperator primary
rawTypeDesc = (tupleType | routineType | 'enum' | 'object' |
                ('var' | 'out' | 'ref' | 'ptr' | 'distinct') typeDesc?)
                ('not' primary)?
typeDescExpr = (routineType / simpleExpr) ('not' primary)?
typeDesc = rawTypeDesc / typeDescExpr
typeDefValue = ((tupleDecl | enumDecl | objectDecl | conceptDecl |
                 ('ref' | 'ptr' | 'distinct') (tupleDecl | objectDecl))
               / (simpleExpr (exprEqExpr ^+ comma postExprBlocks?)?))
               ('not' primary)?
extraPostExprBlock = ( IND{=} doBlock
                     | IND{=} 'of' exprList ':' stmt
                     | IND{=} 'elif' expr ':' stmt
                     | IND{=} 'except' optionalExprList ':' stmt
                     | IND{=} 'finally' ':' stmt
                     | IND{=} 'else' ':' stmt )
postExprBlocks = (doBlock / ':' (extraPostExprBlock / stmt)) extraPostExprBlock*
exprStmt = simpleExpr postExprBlocks?
         / simplePrimary (exprEqExpr ^+ comma) postExprBlocks?
         / simpleExpr '=' optInd (expr postExprBlocks?)
importStmt = 'import' optInd expr
              ((comma expr)*
              / 'except' optInd (expr ^+ comma))
exportStmt = 'export' optInd expr
              ((comma expr)*
              / 'except' optInd (expr ^+ comma))
includeStmt = 'include' optInd expr ^+ comma
fromStmt = 'from' expr 'import' optInd expr (comma expr)*
returnStmt = 'return' optInd expr?
raiseStmt = 'raise' optInd expr?
yieldStmt = 'yield' optInd expr?
discardStmt = 'discard' optInd expr?
breakStmt = 'break' optInd expr?
continueStmt = 'continue' optInd expr?
condStmt = expr colcom stmt COMMENT?
           (IND{=} 'elif' expr colcom stmt)*
           (IND{=} 'else' colcom stmt)?
ifStmt = 'if' condStmt
whenStmt = 'when' condStmt
condExpr = expr colcom stmt optInd
        ('elif' expr colcom stmt optInd)*
         'else' colcom stmt
ifExpr = 'if' condExpr
whenExpr = 'when' condExpr
whileStmt = 'while' expr colcom stmt
ofBranch = 'of' exprList colcom stmt
ofBranches = ofBranch (IND{=} ofBranch)*
                      (IND{=} 'elif' expr colcom stmt)*
                      (IND{=} 'else' colcom stmt)?
caseStmt = 'case' expr ':'? COMMENT?
            (IND{>} ofBranches DED
            | IND{=} ofBranches)
tryStmt = 'try' colcom stmt &(IND{=}? 'except'|'finally')
           (IND{=}? 'except' optionalExprList colcom stmt)*
           (IND{=}? 'finally' colcom stmt)?
tryExpr = 'try' colcom stmt &(optInd 'except'|'finally')
           (optInd 'except' optionalExprList colcom stmt)*
           (optInd 'finally' colcom stmt)?
blockStmt = 'block' symbol? colcom stmt
blockExpr = 'block' symbol? colcom stmt
staticStmt = 'static' colcom stmt
deferStmt = 'defer' colcom stmt
asmStmt = 'asm' pragma? (STR_LIT | RSTR_LIT | TRIPLESTR_LIT)
genericParam = symbol (comma symbol)* (colon expr)? ('=' optInd expr)?
genericParamList = '[' optInd
  genericParam ^* (comma/semicolon) optPar ']'
pattern = '{' stmt '}'
indAndComment = (IND{>} COMMENT)? | COMMENT?
routine = optInd identVis pattern? genericParamList?
  paramListColon pragma? ('=' COMMENT? stmt)? indAndComment
commentStmt = COMMENT
section(RULE) = COMMENT? RULE / (IND{>} (RULE / COMMENT)^+IND{=} DED)
enumDecl = 'enum' optInd (symbol pragma? optInd ('=' optInd expr COMMENT?)? comma?)+
objectWhen = 'when' expr colcom objectPart COMMENT?
            ('elif' expr colcom objectPart COMMENT?)*
            ('else' colcom objectPart COMMENT?)?
objectBranch = 'of' exprList colcom objectPart
objectBranches = objectBranch (IND{=} objectBranch)*
                      (IND{=} 'elif' expr colcom objectPart)*
                      (IND{=} 'else' colcom objectPart)?
objectCase = 'case' declColonEquals ':'? COMMENT?
            (IND{>} objectBranches DED
            | IND{=} objectBranches)
objectPart = IND{>} objectPart^+IND{=} DED
           / objectWhen / objectCase / 'nil' / 'discard' / declColonEquals
objectDecl = 'object' ('of' typeDesc)? COMMENT? objectPart
conceptParam = ('var' | 'out')? symbol
conceptDecl = 'concept' conceptParam ^* ',' (pragma)? ('of' typeDesc ^* ',')?
              &IND{>} stmt
typeDef = identVisDot genericParamList? pragma '=' optInd typeDefValue
            indAndComment?
varTupleLhs = '(' optInd (identWithPragma / varTupleLhs) ^+ comma optPar ')'
varTuple = varTupleLhs '=' optInd expr
colonBody = colcom stmt postExprBlocks?
variable = (varTuple / identColonEquals) colonBody? indAndComment
constant = (varTuple / identWithPragma) (colon typeDesc)? '=' optInd expr indAndComment
bindStmt = 'bind' optInd qualifiedIdent ^+ comma
mixinStmt = 'mixin' optInd qualifiedIdent ^+ comma
pragmaStmt = pragma (':' COMMENT? stmt)?
simpleStmt = ((returnStmt | raiseStmt | yieldStmt | discardStmt | breakStmt
           | continueStmt | pragmaStmt | importStmt | exportStmt | fromStmt
           | includeStmt | commentStmt) / exprStmt) COMMENT?
complexOrSimpleStmt = (ifStmt | whenStmt | whileStmt
                    | tryStmt | forStmt
                    | blockStmt | staticStmt | deferStmt | asmStmt
                    | 'proc' routine
                    | 'method' routine
                    | 'func' routine
                    | 'iterator' routine
                    | 'macro' routine
                    | 'template' routine
                    | 'converter' routine
                    | 'type' section(typeDef)
                    | 'const' section(constant)
                    | ('let' | 'var' | 'using') section(variable)
                    | bindStmt | mixinStmt)
                    / simpleStmt
stmt = (IND{>} complexOrSimpleStmt^+(IND{=} / ';') DED)
     / simpleStmt ^+ ';'

求值顺序

求值顺序严格从左到右,由内到外,这是大多数其他强类型编程语言的典型做法:

var s = ""

proc p(arg: int): int =
  s.add $arg
  result = arg

discard p(p(1) + p(2))

doAssert s == "123"

赋值也不特殊,左边的表达式在右边的表达式之前被求值:

var v = 0
proc getI(): int =
  result = v
  inc v

var a, b: array[0..2, int]

proc someCopy(a: var int; b: int) = a = b

a[getI()] = getI()

doAssert a == [1, 0, 0]

v = 0
someCopy(b[getI()], getI())

doAssert b == [1, 0, 0]

原由:与重载赋值或类似赋值的运算符保持一致,a = b 可以理解为 performSomeCopy(a, b)

然而,"求值顺序" 的概念只有在代码被规范化之后才适用。规范化涉及到模板的扩展和参数的重新排序,这些参数已经被传递给命名参数。

var s = ""

proc p(): int =
  s.add "p"
  result = 5

proc q(): int =
  s.add "q"
  result = 3

# 由于模板扩展语义,求值顺序是 'b' 在 'a' 之前。
template swapArgs(a, b): untyped =
  b + a

doAssert swapArgs(p() + q(), q() - p()) == 6
doAssert s == "qppq"

# 求值顺序不受命名参数的影响:
proc construct(first, second: int) =
  discard

# 'p' 在 'q' 之前求值!
construct(second = q(), first = p())

doAssert s == "qppqpq"

原由: 这比其他设想的替代方案容易实现。

常量和常量表达式

constant "常量"是一个与常量表达式值绑定的符号。常量表达式有限制,依赖于以下类别的值和运算,这些值和运算要么内置在语言中,要么在对常量表达式进行语义分析之前被声明和求值。

  • 字面值
  • 内置运算符
  • 先前声明的常量和编译期变量
  • 先前声明的宏和模板
  • 先前声明的过程,除了可能修改编译期变量外,没有任何副作用

常量表达式可以包含代码块,代码块可以是在编译期内支持的所有 Nim 特性(详见下面章节)。 在其代码块中,可以声明变量,随后读取和更新,或者声明变量并将其传递过程修改。 其代码块中的代码,仍须遵守上面列出的关于引用该代码块外的值和运算的限制。

访问和修改编译期变量的能力为常量表达式增加了灵活性,这可能会让了解其他静态类型语言的人感到惊讶。 例如,下面的代码在 编译期 返回斐波那契数列的起始部分。(这里只是对定义常量灵活性的演示,不是推荐的风格)。

import std/strformat

var fibN {.compileTime.}: int
var fibPrev {.compileTime.}: int
var fibPrevPrev {.compileTime.}: int

proc nextFib(): int =
  result = if fibN < 2:
    fibN
  else:
    fibPrevPrev + fibPrev
  inc(fibN)
  fibPrevPrev = fibPrev
  fibPrev = result

const f0 = nextFib()
const f1 = nextFib()

const displayFib = block:
  const f2 = nextFib()
  var result = fmt"Fibonacci sequence: {f0}, {f1}, {f2}"
  for i in 3..12:
    add(result, fmt", {nextFib()}")
  result

static:
  echo displayFib

对编译期执行的限制

编译期执行的 Nim 代码不能使用以下语言特性:

  • methods 方法
  • closure iterators 闭包迭代器
  • cast 运算符
  • 引用 (指针) 类型
  • FFI

不允许使用 FFI 和/或 cast 的包装器。请注意,这也包括标准库中的包装器。

随着时间的推移,可能会取消部分或所有限制。

类型

Nim 是静态类型语言。在语义分析期间,所有的表达式都有一个已知类型。可以声明新的类型,其实质上是定义了一个标识符,用来表示这个自定义类型。

这些是主要的类型分类:

  • 序数类型(包括整数、布尔、字符、枚举、枚举子范围)
  • 浮点类型
  • 字符串类型
  • 结构化类型
  • 引用(指针)类型
  • 过程类型
  • 泛型类型

序数类型

序数类型有以下特征:

  • 序数类型是可数的和有序的。因而允许使用如 inc, ord, dec 等函数,来操作已定义的序数类型。
  • 序数类型具有最小可使用值,可以通过 low(type) 获取。尝试从最小值继续减小,会产生 panic 或静态错误。
  • 序数类型具有最大可使用值,可以通过 high(type) 获取。尝试从最大值继续增大,会产生 panic 或静态错误。

整数、bool、字符和枚举类型(以及这些类型的子范围)属于序数类型。

如果 distinct 类型的基类型是序数类型,则 distinct 类型也为序数类型。

预定义整数类型

这些整数类型是预定义的:

int
常规有符号整数类型,其大小与平台有关,并与指针的大小相同。

一般情况下应该使用这种类型。 一个没有类型后缀的整数字面量,如果在 low(int32)..high(int32) 范围内,就属于这种类型,否则该字面量的类型是 int64

intXX
这种命名规则,是有符号整数类型附带 XX 表示位宽(例如: int16 是 16 位宽的整数)。

目前支持 int8 int16 int32 int64 ,这些类型的字面值后缀为 'iXX 。

uint
常规的 unsigned integer "无符号整数"类型的大小与平台有关,与指针的大小相同,整数字面值后缀为 'u
uintXX
这种命名规则,是无符号整数类型附带 XX ,表示位宽(例如: uint16 是 16 位宽的无符号整数),目前支持 uint8 uint16 uint32 uint64 ,字面值后缀为 'uXX' 。无符号运算会环绕,不会导致溢出或下溢的错误。

除了有符号和无符号整数的常用算术运算符( + - * 等)之外, 还有些运算符可以处理 有符号 整数,但将其参数视为 无符号 ,主要用于之后的版本与缺少无符号整数类型的旧版本语言进行兼容。有符号整数的这些无符号运算,约定使用 % 作为后缀:

操作符含义
a +% b无符号整数加
a -% b无符号整数减
a *% b无符号整数乘
a /% b无符号整数除
a %% b无符号整数模
a <% bab 视为无符号数并进行比较
a <=% bab 视为无符号数并进行比较

不同类型整型的表达式中,会执行 Automatic type conversion "自动类型转换" ,较小的类型转换为较大的类型。

Automatic type conversion "自动类型转换" 将较大的类型转换为较小的类型(比如 int32 -> int16 ) , widening type conversion "扩大类型转换" 将较小的类型转换为较大的类型(比如int16 -> int32) ,Nim 中仅有扩大类型转型是 隐式的 :

var myInt16 = 5i16
var myInt: int
myInt16 + 34     # 为 `int16` 类型
myInt16 + myInt  # 为 `int` 类型
myInt16 + 2i32   # 为 `int32` 类型

然而,如果字面值适合这个较小类型,并且这样的转换比其他隐式转换更好,那么 int 字面值可以隐式转换为较小的整数类型,因而 myInt16 + 34 结果是 int16 类型。

关于细节查看转换关系

子范围类型

子范围类型是序数或浮点类型(基类型)的取值范围。要定义子范围类型,必须指定其值的限制,即类型的最低值和最高值。例如:

type
  Subrange = range[0..5]
  PositiveFloat = range[0.0..Inf]
  Positive* = range[1..high(int)] # 正如 `system` 里定义的一样

Subrange 是整数的子范围,只能保存 0 到 5 的值。PositiveFloat 定义了包含所有正浮点数的子范围。 NaN 不属于任何浮点类型的子范围。将任何其他值赋值给 Subrange 类型会产生 panic (如果可以在语义分析期间确认,则为静态错误)。 允许将基类型赋值给它的一个子范围类型(反之亦然)。

子范围类型与其基类型具有相同的大小(子范围示例中的 int )。

预定义浮点类型

以下浮点类型是预定义的:

float
常规的浮点类型,其大小曾与平台有关,但现在总是映射为 float64 。一般情况下应该使用这个类型。
floatXX
这种命名规则,是浮点类型附带 XX 位,表示位宽(例如: float64 是 64 位宽的浮点数)。

目前支持 float32float64 ,字面值后缀为 'fXX 。

在具有不同种类的浮点类型的表达式中,会进行自动类型转换,详情见转换关系。 对于浮点类型进行的算术运算遵循 IEEE 标准。整数类型不会自动转换为浮点类型,反之亦然。

IEEE 标准定义了五种类型的浮点运算异常:

  • 无效: 使用数学上无效的操作数运算, 例如 0.0/0.0, sqrt(-1.0), 和 log(-37.8).
  • 除以零: 除数为零,且被除数是有限的非零数,例如 1.0 / 0.0 。
  • 溢出: 运算产生的结果超出范围,例如,MAXDOUBLE + 0.0000000000001e308。
  • 下溢: 运算产生的结果太小而无法表示为正常数字,例如,MINDOUBLE * MINDOUBLE。
  • 不精确: 运算产生的结果无法用无限精度表示,例如,输入 2.0 / 3.0,log(1.1) 和 0.1。

IEEE 异常在执行期被忽略或映射到 Nim 异常: FloatInvalidOpDefect "浮点数无效缺陷" , FloatDivByZeroDefect "浮点数除零缺陷" , FloatOverflowDefect "浮点数溢出缺陷" , FloatUnderflowDefect "浮点数下溢缺陷" , 和 FloatInexactDefect "浮点数不精确缺陷" 。 这些异常继承自 FloatingPointDefect "浮点数缺陷" 基类。

Nim 提供了编译指示 nanChecksinfChecks 控制是否忽略 IEEE 异常或捕获 Nim 异常:

{.nanChecks: on, infChecks: on.}
var a = 1.0
var b = 0.0
echo b / b # 引发 FloatInvalidOpDefect
echo a / b # 引发 FloatOverflowDefect

在当前的实现中,绝不会引发 FloatDivByZeroErrorFloatInexactErrorFloatOverflowError 取代了 FloatDivByZeroError 。 另有 floatChecks 编译指示为 nanChecksinfChecks 的便捷方式。默认关闭 floatChecks

只有 +, -, *, / 这些运算符会受 floatChecks 编译指示影响。

在语义分析期间,应始终使用最大精度来评估浮点数,这表示在常量展开期间,表达式 0.09'f32 + 0.01'f32 == 0.09'f64 + 0.01'f64 的值为真。

布尔类型

布尔类型在 Nim 中命名为 bool ,值为预定义( truefalse )之一。 while , if , elif , when 中的状态需为 bool 类型.

这个条件成立:

ord(false) == 0 and ord(true) == 1

为布尔类型定义了运算符 not, and, or, xor, <, <=, >, >=, !=, ==andor 运算符进行短路求值。例如:

while p != nil and p.name != "xyz":
  # 如果 p == nil, p.name 不被求值
  p = p.next

bool 类型的大小是一个字节。

字符类型

字符类型在 Nim 中被命名为 char 。它的大小为一个字节。因此,不能表示 UTF-8 字符,而只能是 UTF-8 字符的一部分。

Rune 类型声明在unicode 模块中,可以表示任意 Unicode 字符。

枚举类型

枚举类型定义了一个其值由指定的值组成的新类型,这些值是有序的。例如:

type
  Direction = enum
    north, east, south, west

以下情况成立:

ord(north) == 0
ord(east) == 1
ord(south) == 2
ord(west) == 3

# 也允许:
ord(Direction.west) == 3

由此可知,north < east < south < west。比较运算符可以与枚举类型一起使用。枚举值也可以使用它所在的枚举类型来限定,如 north 可以用 Direction.nort 来限定。

为了更好地与其他编程语言连接,可以显式为枚举类型字段分配序数值,但是,序数值必须升序排列。未明确给出序数值的字段被赋予前一个字段 +1 的值。

显式有序枚举可以有 间隔 :

type
  TokenType = enum
    a = 2, b = 4, c = 89 # 可以有间隔

但是,它不再是序数,因此不可能将这些枚举用作数组类型的索引。 过程 inc , dec, succpred 对于它们不可用。

编译器支持内置的字符串化运算符 $ 用于枚举。字符串化的效果是,显式控制要使用的字符串:

type
  MyEnum = enum
    valueA = (0, "my value A"),
    valueB = "value B",
    valueC = 2,
    valueD = (3, "abc")

从示例中可以看出,可以通过使用元组指定字段的序数值以及字符串值,也可以只指定其中一个。

枚举可以使用 pure 编译指示进行标记,以便将其字段添加到特定模块特定的隐藏作用域,只在最终使用时进行查询。 只有不产生歧义的符号才会添加到此作用域。但总是可以通过 MyEnum.value 类型限定来获取:

type
  MyEnum {.pure.} = enum
    valueA, valueB, valueC, valueD, amb
  
  OtherEnum {.pure.} = enum
    valueX, valueY, valueZ, amb


echo valueA # MyEnum.valueA
echo amb    # 错误: 不确定它是 MyEnum.amb 还是 OtherEnum.amb
echo MyEnum.amb # OK.

枚举值的名称是可重载的,就像例程。如果枚举 TU 都有一个名为 foo 的成员,那么标识符 foo 要在 T.fooU.foo 之间二选一。在重载解析过程中, foo 的最终类型由上下文决定。如果 foo 的类型不明确,将产生静态错误。


type
  E1 = enum
    value1,
    value2
  E2 = enum
    value1,
    value2 = 4

const
  Lookuptable = [
    E1.value1: "1",
    # 不需要再修饰value2,已经知道是E1.value2。
    value2: "2"
  ]

proc p(e: E1) =
  # 在 'case' 语句中消除歧义。
  case e
  of value1: echo "A"
  of value2: echo "B"

p value2

在某些情况下,枚举的歧义取决于当前作用域与枚举定义所在作用域之间的关系。

# a.nim
type Foo* = enum abc

# b.nim
import a
type Bar = enum abc
echo abc is Bar # true

block:
  type Baz = enum abc
  echo abc is Baz # true

对于用枚举实现位域,请查看位域部分。

字符串类型

所有字符串字面值都是 string 类型。Nim 中的字符串与字符序列非常相似。但是,Nim 中的字符串都是以零结尾,并且具有长度字段。 可以用内置的 len 过程检索长度,长度并不计算末尾的零。

除非先将字符串转换为 cstring 类型,否则无法访问末尾的零。末尾零可以保证在 O(1) 完成转换,而无需另行分配。

字符串的赋值运算符始终复制字符串。 & 运算符拼接字符串。

大多数原生 Nim 类型支持使用特殊的 $ 过程转换为字符串。

echo 3 # 为 `int` 调用 `$`

每当用户创建特定的对象时,该过程实现提供了 string 表示。

type
  Person = object
    name: string
    age: int

proc `$`(p: Person): string = # `$` 始终返回字符串
  result = p.name & " 已经 " &
          $p.age & # 需要在 p.age 前添加 `$`,因为它是整数类型,而我们要将其转换成字符串
          "岁了。"

虽然也可以使用 $p.name ,但 $ 操作符不会对字符串做任何事。请注意,不能依赖 intstringecho 过程一样自动转换。

字符串按字典顺序进行比较,所有比较运算符都可用。字符串可以像数组一样索引(下限为 0)。与数组不同的是,字符串可用于 case 语句:

case paramStr(i)
of "-v": incl(options, optVerbose)
of "-h", "-?": incl(options, optHelp)
else: write(stdout, "非法的命令行选项\n")

按照约定,所有字符串都是 UTF-8 格式,但这不是强制的要求。 例如,从二进制文件读取字符串时,得到的将是字节序列。 索引运算 s[i] 表示 s 的第 i 个 char ,而不是第 i 个 unichar 。 在unicode 模块的迭代器 runes 可用来迭代所有 unicode 字符。

cstring类型

cstring 类型意思是 compatible string "兼容字符串",是编译后端字符串的原生表示。对于 C 后端, cstring 类型表示一个指向末尾为零的 char 数组的指针,该数组与 ANSI C 中的 char* 类型兼容。其主要目的是与 C 轻松互通。索引操作 s[i] 表示 s 的第 i 个 char ,但是不检查 cstring 的边界,因而索引操作并不安全。

为方便起见,Nim 中的 string 可以隐式转换为 cstring 。如果将 Nim 字符串传递给 C 风格的可变参数过程,也会隐式转换为 cstring :

proc printf(formatstr: cstring) {.importc: "printf", varargs,
                                  header: "<stdio.h>".}

printf("这会%s工作", "像预期一样")

即使转换是隐式的,它也不是 安全的 : 垃圾收集器不认为 cstring 是根,并且可能收集底层内存。因此,隐式转换将在 Nim 编译器的未来版本中删除。 某些习语,例如将 const 字符串转换为 cstring 是安全的,并且仍将被允许。

为 cstring 定义的 $ 过程能够返回 string 。因此,从 cstring 获得 nim 的 string 可以这样:

var str: string = "Hello!"
var cstr: cstring = str
var newstr: string = $cstr

cstring 字面值不应被修改。

var x = cstring"literals"
x[1] = 'A' # 这是错的!!!

如果 cstring 来自常规内存(而不是只读内存),则可被修改:

var x = "123456"
var s: cstring = x
s[0] = 'u' # 这是可以的

cstring 值像字符串一样,也可用于 case 语句。

结构化类型

结构化类型的变量可以同时保存多个值。结构化类型可以嵌套到无限级别。数组、序列、元组、对象和集合属于结构化类型。

数组和序列类型

数组是同类型的,即数组中的每个元素都类型相同。数组总是具有指定为常量表达式的固定长度(开放数组除外)。它们可以按任意序数类型索引。 若参数 A开放数组 ,那么它的索引为由 0 到 len(A)- 1 的整数。数组表达式可以由数组构造器 [] 构造。 数组表达式的元素类型是从第一个元素的类型推断出来的。所有其他元素都需要隐式转换为此类型。

可以使用 array[size, T] 构造数组类型,也可以使用 array[lo..hi, T] 设置数组的起点,而不是默认的 0。

序列类似于数组,但有动态长度,其长度可能在运行时期间发生变化(如字符串)。序列为可增长的数组实现,在添加项目时分配内存块。 序列 S 的索引为从 0 到 len(S)-1 的整数,并检查其边界。序列可以在序列运算符 @ 的帮助下,结合数组构造器 [] 一起构造。 为序列分配空间的另一种方法是调用内置的 newSeq 过程。

序列可以传递给 开放数组 类型的参数。

例如:

type
  IntArray = array[0..5, int] # 索引为0到5的数组
  IntSeq = seq[int] # 一个整数序列
var
  x: IntArray
  y: IntSeq
x = [1, 2, 3, 4, 5, 6]  # [] 是数组构造器
y = @[1, 2, 3, 4, 5, 6] #  @ 会将数组转换成序列

let z = [1.0, 2, 3, 4] # z 的类型是 array[0..3, float]

数组或序列的下限可以用内置的过程 low() 获取,上限用 high() 获取。长度可以用 len() 获取。 序列或开放数组的 low() 总是返回 0,因为这是第一个有效索引。 可以使用 add() 过程或 & 运算符将元素追加到序列中,并使用 pop() 过程删除(并获取)序列的最后一个元素。

符号 x[i] 可用于访问 x 的第 i 个元素。

数组始终进行边界检查(静态或运行时)。可以通过编译指示禁用这些检查,或调用编译器时,使用 --boundChecks:off 命令行开关。

数组构造器可以具有可读的显式索引:

type
  Values = enum
    valA, valB, valC

const
  lookupTable = [
    valA: "A",
    valB: "B",
    valC: "C"
  ]

如果省略索引,则使用 succ(lastIndex) 作为索引值:

type
  Values = enum
    valA, valB, valC, valD, valE

const
  lookupTable = [
    valA: "A",
    "B",
    valC: "C",
    "D", "e"
  ]

开放数组

通常,固定大小的数组不太灵活,程序应该能够处理不同大小的数组。 openarray "开放数组" 类型只能用于参数。开放数组总是从位置 0 开始用 int 索引。 也可用 lenlowhigh 操作。具有兼容基类型的任何数组都可以传递给开放数组的形参,不关乎索引类型。除了数组之外,还可以将序列传递给开放数组参数。

openarray 类型不能嵌套: 不支持多维开放数组,因为这种需求很少且不高效。

proc testOpenArray(x: openArray[int]) = echo repr(x)

testOpenArray([1,2,3])  # array[]
testOpenArray(@[1,2,3]) # seq[]

可变参数

varargs 参数是一个开放数组参数,它允许将可变数量的参数传递给过程。编译器隐式地将参数列表转换为数组:

proc myWriteln(f: File, a: varargs[string]) =
  for s in items(a):
    write(f, s)
  write(f, "\n")

myWriteln(stdout, "abc", "def", "xyz")
# 转换成:
myWriteln(stdout, ["abc", "def", "xyz"])

仅当 varargs 参数是最后一个参数时,才会执行此转换。在这种情况下也可以执行类型转换::

proc myWriteln(f: File, a: varargs[string, `$`]) =
  for s in items(a):
    write(f, s)
  write(f, "\n")

myWriteln(stdout, 123, "abc", 4.0)
# 转换成:
myWriteln(stdout, [$123, $"abc", $4.0])

在这个例子中, $ 应用于传递给参数 a 的任意参数。 (注意 $ 对字符串是一个空操作。)

请注意,传递给 varargs 形参的显式数组构造器不会隐式地构造另一个隐式数组:

proc takeV[T](a: varargs[T]) = discard

takeV([123, 2, 1]) # takeV 的 T 是 "int" , 不是 "int 数组"

varargs[typed] 被特别对待: 它匹配任意类型参数的变量列表,但 始终 构造一个隐式数组。这是必需的,只有这样,内置的 echo 过程才能够执行预期的操作:

proc echo* (x: varargs[typed, `$`]) {...}

echo @[1, 2, 3]
# 输出 "@[1, 2, 3]" 而不是 "123"

未检查数组

UncheckedArray[T] 类型是一种特殊的 array "数组",编译器不检查它的边界。这对于实现定制灵活大小的数组通常很有用。另外,未检查数组可以转换为不确定大小的 C 数组:

type
  MySeq = object
    len, cap: int
    data: UncheckedArray[int]

生成的 C 代码大致是这样的:

typedef struct {
  NI len;
  NI cap;
  NI data[];
} MySeq;

未检查数组的基本类型可能不包含任何 GC内存,但目前尚未检查。

未来方向: 应该在未检查的数组中允许 GC内存,并且应该有一个关于 GC 如何确定数组的运行时大小的显式注解。

元组和对象类型

元组或对象类型的变量是异构存储容器。元组或对象定义了一个类型的各类 字段 。元组还定义了字段的 顺序 。 元组是有很少抽象可能性的异构存储类型。 () 可用于构造元组。构造函数中字段的顺序必须与元组定义的顺序相匹配。 如果它们以相同的顺序指定相同类型的相同字段,则不同的元组类型 等效 。字段的 名称 也必须相同。

type
  Person = tuple[name: string, age: int] # 表示人的类型:
                                         # 由名字和年龄组成。
var person: Person
person = (name: "Peter", age: 30)
assert person.name == "Peter"
# 一样,但可读性不太好
person = ("Peter", 30)
assert person[0] == "Peter"
assert Person is (string, int)
assert (string, int) is Person
assert Person isnot tuple[other: string, age: int] # `other` 是不同的标识符

可以使用括号和尾随逗号,构造具有一个未命名字段的元组:

proc echoUnaryTuple(a: (int,)) =
  echo a[0]

echoUnaryTuple (1,)

事实上,每个元组结构都允许使用尾随逗号。

字段将会对齐,以此获得最佳性能。对齐与 C 编译器的方式兼容。

为了与 object 声明保持一致, type 部分中的元组也可以用缩进而不是 [] 来定义:

type
  Person = tuple   # 代表人的类型
    name: string   # 人的名字
    age: Natural   # 以及年龄

对象提供了许多元组没有的特性。对象提供继承和隐藏其他模块字段的能力。启用继承的对象在运行时具有相关类型的信息,可以使用 of 运算符来确定对象的类型。 of 运算符类似于 Java 中的 instanceof 运算符。

type
  Person = object of RootObj
    name*: string   # *表示可以从其他模块访问 `name`
    age: int        # 没有 * 表示该字段已隐藏
  
  Student = ref object of Person # 学生是人
    id: int                      # 有个 id 字段

var
  student: Student
  person: Person
assert(student of Student)  # 是真
assert(student of Person)   # 也是真

对模块外部可见的对象字段必须用 * 标记。与元组相反,不同的对象类型永远不会 等价 。没有祖先的对象是隐式的 final ,因此没有隐藏的类型字段。 可以使用 inheritable 编译指示来引入除 system.RootObj 之外的新根对象。

type
  Person = object # final 对象的例子
    name* : string
    age: int
  
  Student = ref object of Person # 错误: 继承只能用于非 final 对象
    id: int

对于元组和对象的赋值操作,将拷贝每个组件。 重写这种拷贝行为的方法描述在这里

对象构造

对象也可以使用 object construction expression "对象构建表达式" 创建, 即以下语法 T(fieldA: valueA, fieldB: valueB, ...) 其中 Tobject 类型或 ref object 类型:

type
  Student = object
    name: string
    age: int
  PStudent = ref Student
var a1 = Student(name: "Anton", age: 5)
var a2 = PStudent(name: "Anton", age: 5)
# 这样也可以直接构造:
var a3 = (ref Student)(name: "Anton", age: 5)
# 不必提到所有字段,而且这些字段可以是乱序的:
var a4 = Student(age: 5)

请注意,与元组不同,对象需要字段名称及其值。对于 ref object 类型,隐式调用 system.new

对象变体

在需要简单变体类型的某些情况下,对象层次结构通常有点臃肿。对象变体是通过枚举类型标记和区分,以便运行时更加灵活,可参照在其他语言中能找到的如 sum类型代数数据类型(ADTs) 的概念。

一个例子:

# 这是一个如何在 Nim 中建模抽象语法树的示例
type
  NodeKind = enum   # 不同的节点类型
    nkInt,          # 带有整数值的叶节点
    nkFloat,        # 带有浮点值的叶节点
    nkString,       # 带有字符串值的叶节点
    nkAdd,          # 加法
    nkSub,          # 减法
    nkIf            # if 语句
  Node = ref NodeObj
  NodeObj = object
    case kind: NodeKind  # `kind` 字段是鉴别字段
    of nkInt: intVal: int
    of nkFloat: floatVal: float
    of nkString: strVal: string
    of nkAdd, nkSub:
      leftOp, rightOp: Node
    of nkIf:
      condition, thenPart, elsePart: Node

# 创建一个新 case 对象:
var n = Node(kind: nkIf, condition: nil)
# 访问 `n.thenPart` 是有效的,因为 `nkIf` 分支是活动的
n.thenPart = Node(kind: nkFloat, floatVal: 2.0)

# 以下语句引发了一个 `FieldError` 异常,因为 n.kind 的值不合适且 `nkString` 分支未激活:
n.strVal = ""

# 无效:会更改活动对象分支:
n.kind = nkInt

var x = Node(kind: nkAdd, leftOp: Node(kind: nkInt, intVal: 4),
                          rightOp: Node(kind: nkInt, intVal: 2))
# 有效:不更改活动对象分支:
x.kind = nkSub

从示例中可以看出,对象层次结构的优点是,不需要在不同对象类型之间进行转换。但是,访问无效对象字段会引发异常。

在对象声明中的 case 语句和标准 case 语句语法一致: case 语句的分支也是如此。

在示例中, kind 字段称为 discriminator "鉴别字段" ,为安全起见,不能对其进行地址限制,并且对其赋值进行限制: 新值不得导致活动对象分支发生变化。 此外,在对象构造期间指定特定分支的字段时,必须将相应的鉴别字段值指定为常量表达式。

与改变活动的对象分支不同,可以将内存中的旧对象换成一个全新的对象。

var x = Node(kind: nkAdd, leftOp: Node(kind: nkInt, intVal: 4),
                          rightOp: Node(kind: nkInt, intVal: 2))
# 改变节点的内容
x[] = NodeObj(kind: nkString, strVal: "abc")

从版本 0.20 开始 system.reset 不能再用于支持对象分支的更改,因为这始终不是完全内存安全的。

作为一项特殊规则,鉴别字段类型也可以使用 case 语句来限制。如果 case 语句分支中的鉴别字段变量的可能值是所选对象分支的鉴别字段值的子集,则认为是有效的初始化。 此分析仅适用于序数类型的不可变判别符,并忽略 elif 分支。对于具有 range 类型的鉴别值,编译器会检查鉴别值的整个可能值范围是否对所选对象分支有效。

一个小例子:

let unknownKind = nkSub

# 无效:不安全的初始化,因为类型字段不是静态已知的:
var y = Node(kind: unknownKind, strVal: "y")

var z = Node()
case unknownKind
of nkAdd, nkSub:
  # 有效:此分支的可能值是 nkAdd / nkSub 对象分支的子集:
  z = Node(kind: unknownKind, leftOp: Node(), rightOp: Node())
else:
  echo "ignoring: ", unknownKind

# 同样有效, 因为 unknownKindBounded 只包含 nkAdd 或 nkSub
let unknownKindBounded = range[nkAdd..nkSub](unknownKind)
z = Node(kind: unknownKindBounded, leftOp: Node(), rightOp: Node())

cast uncheckedAssign

case 对象的一些限制可以通过 {.cast(uncheckedAssign).} 禁用:

type
  TokenKind* = enum
    strLit, intLit
  Token = object
    case kind* : TokenKind
    of strLit:
      s* : string
    of intLit:
      i* : int64

proc passToVar(x: var TokenKind) = discard

var t = Token(kind: strLit, s: "abc")

{.cast(uncheckedAssign).}:
  # 在 'cast' 块中允许将 't.kind' 传递给 'var T' 参数:
  passToVar(t.kind)
  
  # 在 'cast' 块中允许设置字段 's' ,即便构造的 'kind' 字段有未知的值
  t = Token(kind: t.kind, s: "abc")
  
  # 在 'cast' 块中允许直接分配 't.kind' 字段
  t.kind = intLit

对象字段的默认值

对象字段允许具有一个常量默认值。如果提供了默认值,则可以省略字段的类型。

type
  Foo = object
    a: int = 2
    b: float = 3.14
    c = "I can have a default value"
  
  Bar = ref object
    a: int = 2
    b: float = 3.14
    c = "I can have a default value"

显式初始化使用这些默认值,包括使用对象构造表达式或过程 default 创建的 object ;使用对象构造表达式或过程 new 创建的 ref object ;具有子类型的数组或元组,该子类型使用过程 default 创建了默认值。

type
  Foo = object
    a: int = 2
    b = 3.0
  Bar = ref object
    a: int = 2
    b = 3.0

block: # created with an object construction expression
  let x = Foo()
  assert x.a == 2 and x.b == 3.0
  
  let y = Bar()
  assert y.a == 2 and y.b == 3.0

block: # created with an object construction expression
  let x = default(Foo)
  assert x.a == 2 and x.b == 3.0
  
  let y = default(array[1, Foo])
  assert y[0].a == 2 and y[0].b == 3.0
  
  let z = default(tuple[x: Foo])
  assert z.x.a == 2 and z.x.b == 3.0

block: # created with the procedure `new`
  let y = new Bar
  assert y.a == 2 and y.b == 3.0

集合类型

集合类型是数学概念集合的模型。集合的基础类型只能是具有一定大小的序数类型,即:
  • int8-int16
  • uint8/byte-uint16
  • char
  • enum
  • 序数子区间类型,即 range[-10..10] 或等效类型。

当使用带符号的整数文字构造集合时,该集合的基础类型被定义为区间 0 .. DefaultSetElements-1 ,其中 DefaultSetElements 目前始终为 2^8。 集合基础类型的最大区间长度为 MaxSetElements ,目前始终为 2^16。具有更大区间长度的类型将被强制转换为区间 0 .. MaxSetElements-1

原因是集合被实现为高性能的bit vector。 试图用太大类型来声明集合将导致一个错误:

  var s: set[int64] # 错误:集合太大;对于具有超过 2^16 个元素的序数类型,请使用 `std/sets`

注意: Nim 还提供了 hash sets (你需要通过 import std/sets 导入它们),它们没有这样的限制。

集合可以通过集合构造器来构造: {} 是空集。空集的类型与任何具体的集合类型兼容。 构造器也可以用来包含元素(和元素范围)。

type
  CharSet = set[char]
var
  x: CharSet
x = {'a'..'z', '0'..'9'} # 这构建了一个包含从'a' 到 'z' 的字母和从 '0' 到 '9' 的数字的集合。

`std/setutils` 模块提供了一种从可迭代对象初始化集合的方法:

import std/setutils

let uniqueChars = myString.toSet

集合支持以下操作。

运算含义
A + B两个集合的并集
A * B两个集合的交集
A - B两个集合的差集(A不包含B的元素)
A == B集合相等
A <= B子集关系(A是B的子集或等于B)
A < B严格子集关系(A是B的真子集)
e in A集合成员关系(A包含元素e)
e notin AA不包含元素e
contains(A, e)A包含元素e
card(A)A的基数(A中元素的数量)
incl(A, elem)相同于 A = A + {elem}
excl(A, elem)相同于 A = A - {elem}

位域

集合经常被用来为过程的 标记 定义类型。 这是比定义整数常量更利落的解决方案(而且类型安全),因为整数常量必须被 or "或"在一起。

enum、set和cast可以像下面这样一起使用:

type
  MyFlag* {.size: sizeof(cint).} = enum
    A
    B
    C
    D
  MyFlags = set[MyFlag]

proc toNum(f: MyFlags): int = cast[cint](f)
proc toFlags(v: int): MyFlags = cast[MyFlags](v)

assert toNum({}) == 0
assert toNum({A}) == 1
assert toNum({D}) == 8
assert toNum({A, C}) == 5
assert toFlags(0) == {}
assert toFlags(7) == {A, B, C}

要注意set是怎样将枚举值转换为2的幂值。

如果在C中使用枚举和集合,请使用distinct cint。

关于与 C 语言的互操作性,参阅bitsize 编译指示

引用和指针类型

引用(类似于其他编程语言中的指针)是引入多对一关系的一种方式。这意味着不同的引用可以指向并修改内存中的相同位置(也称为 aliasing "别名")。

Nim 区分 traced "追踪"、untraced "未追踪" 引用。未追踪引用也叫 指针 。 追踪引用指向垃圾回收堆中的对象,未追踪引用指向手动分配对象或内存中其它位置的对象。 因此,未追踪引用是 不安全 的。然而对于某些访问硬件的低级操作,未追踪引用是不可避免的。

使用 ref 关键字声明追踪引用,使用 ptr 关键字声明未追踪引用。通常, ptr T 可以隐式转换为 pointer 类型。

空的下标 [] 表示法可以用来取代引用, addr 过程返回一个对象的地址。地址始终是未追踪的引用。因此, addr 的使用是 不安全的 功能。

. (访问元组和对象字段运算符)和 [] (数组/字符串/序列索引运算符)运算符对引用类型执行隐式解引用操作:

type
  Node = ref NodeObj
  NodeObj = object
    le, ri: Node
    data: int

var
  n: Node
new(n)
n.data = 9
# 不必写 n[].data; 非常不推荐 n[].data!

为了简化结构类型检查,递归元组无效:

# 无效递归
type MyTuple = tuple[a: ref MyTuple]

同样, T = ref T 是无效类型。

作为语法扩展,如果在类型部分中通过 ref objectptr object 符号声明,则 object 类型可以是匿名的。当一个对象只能获得引用语义时,这个特性很有用:

type
  Node = ref object
    le, ri: Node
    data: int

要分配一个新的追踪对象,必须使用内置的过程 new 。可以使用过程 allocdeallocrealloc 来处理未追踪的内存。 更多信息,查看系统模块文档。

空(Nil)

如果一个引用什么都不指向,那么它的值为 nilnil 是所有 refptr 类型的默认值。 nil 值也可以像任何其他字面值一样使用。例如,它可以用在像 my Ref = nil 这样的赋值中。

解引用 nil 是一个不可恢复的运行时错误(而不是 panic)。

成功的解引用操作 p[] 意味着 p 不是 nil。可以利用它来优化代码,例如:

p[].field = 3
if p != nil:
  # 如果 p 是 nil , 那么 `p[]` 会导致错误
  # 所以这里的 `p` 永远不会是 nil
  action()

那么上述代码可以变成:

p[].field = 3
action()

注意: 这与 C 用于解引用 NULL 指针的 "未定义行为" 不具有可比性。

混合GC内存和 ptr

要特别注意的是,如果一个未被追踪的对象包含被追踪的对象,例如包含追踪的引用、字符串、序列。为了正确释放所有对象, 在释放未被追踪的内存之前,需要手动调用内置过程 reset :

type
  Data = tuple[x, y: int, s: string]

# 在堆上为 Data 分配内存:
var d = cast[ptr Data](alloc0(sizeof(Data)))

# 在垃圾回收(GC)堆上创建一个新的字符串:
d.s = "abc"

# 告知 GC 不再需要这个字符串:
reset(d.s)

# 释放内存:
dealloc(d)

如果不调用 reset ,就绝不会释放分配给 d.s 字符串的内存。 这个例子对于程序底层来说,表现出两个重要的特性: sizeof 过程返回一个类型或值的字节大小。 cast 操作符可以避开类型系统: 编译器强制将 alloc0 (会返回一个未定义类型的指针)的结果认定为 ptr Data 类型。 只有在不可避免的情况下才应当进行转换,因为这会破坏类型安全,未知的 bug 可能导致崩溃。

注意: 当把垃圾收集的数据和非管理的内存混合使用时,需要了解这些低级细节。 这个例子之所以有效,是因为 alloc0 将内存初始化为零,而 alloc 不会 。 d.s 被初始化为二进制的零,因而可以处理字符串赋值。

过程类型

过程类型是指向过程的内部指针。过程类型变量允许赋值 nil

示例:

proc printItem(x: int) = ...

proc forEach(c: proc (x: int) {.cdecl.}) =
  ...

forEach(printItem)  # 将不会编译这个,因为调用约定不同

type
  OnMouseMove = proc (x, y: int) {.closure.}

proc onMouseMove(mouseX, mouseY: int) =
  # 有默认的调用约定
  echo "x: ", mouseX, " y: ", mouseY

proc setOnMouseMove(mouseMoveEvent: OnMouseMove) = discard

# 'onMouseMove' 有默认的调用约定,可以兼容 'closure':
setOnMouseMove(onMouseMove)

过程类型的一个底层细节问题是,过程的调用约定会影响类型的兼容性: 过程类型只有在调用约定相同的情况下才兼容。 有个延伸的特例,调用约定为 nimcall 的过程可以被传递给期望调用约定为 closure 的过程参数。

Nim 支持下列 calling conventions "调用约定":

nimcall
是Nim proc 使用的默认约定。它与 fastcall 相同,但只适用于支持 fastcall 的 C 编译器。
closure
程序类型 没有任意编译指示注解的默认调用约定,该过程有一个隐藏参数( environment "环境")。

具有 closure 调用约定的过程变体占用两个机器字。一个是过程的指针,另一个是指向隐藏参数环境的指针。

stdcall
这是由微软指定的标准调用惯例,生成的 C 过程将用 __stdcall 关键字声明。
cdecl
其意味着一个过程应使用与 C 编译器相同的约定。在 Windows 中,将用 __cdecl 关键字声明生成的 C 过程。
safecall
这是由微软指定的安全调用约定,将用 __safecall 关键字声明生成的 C 程序。 安全 这个词是指所有的硬件寄存器都应被 push 到硬件堆栈中。
inline
内联约定表示调用者不应该调用过程,而是直接内联其代码。

需注意, Nim 自身并不内联,而是留给 C 编译器,它将生成 __inline 过程,这只是给编译器的提示,编译器则可能忽略,也有可能内联那些没有 inline 的过程。

fastcall
对于不同的 C 编译器其含义不同,有一种是表示 C 语言中的 __fastcall
thiscall
这是微软指定的调用约定,应用于 x86 架构上 C++ 类的成员函数。
syscall
其与 C 语言中的 __syscall 相同,用于中断。
noconv
其生成的 C 代码将不会去明确调用约定,将使用 C 编译器自身的默认调用约定。

这是有必要的,因为 Nim 过程的默认调用约定是 fastcall 以提高速度。

大多数调用约定只存在于 32 位 Windows 平台。

默认的调用约定是 nimcall ,除非它是一个内部过程(一个过程中的过程)。对于一个内部过程,将分析它是否访问其环境,如果它访问了环境,就采用 closure 调用约定,否则就采用 nimcall 调用约定。

Distinct类型

distinct 类型是源于 base type "基类"的新类型,一个重要的特性是,它和其基类型之间 是父子类型关系。 但允许显式将 distinct 类型转换到基类型,反之亦然。请参阅 distinctBase 以获得反向操作的相关信息。

如果 distinct 类型的基类型是序数类型,则 distinct 类型也为序数类型。

模拟货币

distinct 类型可用于模拟不同的物理 units "单位",例如,数字基本类型。以下为模拟货币的示例。

在货币计算中不应混用不同的货币。distinct 类型是一个模拟不同货币的理想工具:

type
  Dollar = distinct int
  Euro = distinct int

var
  d: Dollar
  e: Euro

echo d + 12
# 错误: 数字不可以与 `Dollar` 直接相加

可惜, 不允许 d + 12.Dollar ,因为 + 已被 int (以及其他)所定义。 所以用于 Dollar+ 需要进行这样的定义:

proc `+` (x, y: Dollar): Dollar =
  result = Dollar(int(x) + int(y))

将一美元乘以一美元是没有意义的,但是可以乘以或除法一个无符号数:

proc `*` (x: Dollar, y: int): Dollar =
  result = Dollar(int(x) * y)

proc `*` (x: int, y: Dollar): Dollar =
  result = Dollar(x * int(y))

proc `div` ...

这很快就会变得乏味。这些实现很细微而作用不明显,生成所有这些代码,而可能稍后又优化掉了 —— 美元的 + 应该产生与整数的 + 相同的二进制代码。编译指示 borrow "借用"旨在解决这个问题,理论上,能够简单实现上述所生成内容:

proc `*` (x: Dollar, y: int): Dollar {.borrow.}
proc `*` (x: int, y: Dollar): Dollar {.borrow.}
proc `div` (x: Dollar, y: int): Dollar {.borrow.}

borrow 编译指示会让编译器使用,与处理distinct类型的基类型过程相同的实现,因此不会生成任何代码。

但是,Euro 货币似乎需要重复这些样式的代码,这个可以用模板来解决。

template additive(typ: typedesc) =
  proc `+` * (x, y: typ): typ {.borrow.}
  proc `-` * (x, y: typ): typ {.borrow.}
  
  # 一元操作符:
  proc `+` * (x: typ): typ {.borrow.}
  proc `-` * (x: typ): typ {.borrow.}

template multiplicative(typ, base: typedesc) =
  proc `*` * (x: typ, y: base): typ {.borrow.}
  proc `*` * (x: base, y: typ): typ {.borrow.}
  proc `div` * (x: typ, y: base): typ {.borrow.}
  proc `mod` * (x: typ, y: base): typ {.borrow.}

template comparable(typ: typedesc) =
  proc `<` * (x, y: typ): bool {.borrow.}
  proc `<=` * (x, y: typ): bool {.borrow.}
  proc `==` * (x, y: typ): bool {.borrow.}

template defineCurrency(typ, base: untyped) =
  type
    typ* = distinct base
  additive(typ)
  multiplicative(typ, base)
  comparable(typ)

defineCurrency(Dollar, int)
defineCurrency(Euro, int)

borrow 编译指示也可用于 distinct 类型注解,以提升某些内置操作:

type
  Foo = object
    a, b: int
    s: string
  
  Bar {.borrow: `.`.} = distinct Foo

var bb: ref Bar
new bb
# 字段访问有效
bb.a = 90
bb.s = "abc"

目前仅点访问器可以通过这个方式借用。

避免SQL注入攻击

从 Nim 传递到 SQL 数据库的 SQL 语句可能转化为字符串。 但是,使用字符串模板并填写值很容易受到著名的 SQL injection attack "SQL注入攻击" :

import std/strutils

proc query(db: DbHandle, statement: string) = ...

var
  username: string

db.query("SELECT FROM users WHERE name = '$1'" % username)
# 糟糕的安全漏洞,但是编译器不关心

这可以通过区分包含 SQL 的字符串和不包含 SQL 的字符串来避免。distinct 类型提供了一种引入与 string 不兼容的新字符串类型 SQL 的方法:

type
  SQL = distinct string

proc query(db: DbHandle, statement: SQL) = ...

var
  username: string

db.query("SELECT FROM users WHERE name = '$1'" % username)
# 静态错误: `query` 期望一个 SQL 字符串

抽象类型一个重要的属性是,抽象类型与它们的子类型之间没有父子关系。允许显示将 string 类型转换到 SQL :

import std/[strutils, sequtils]

proc properQuote(s: string): SQL =
  # 正确地为 SQL 语句引用字符串
  return SQL(s)

proc `%` (frmt: SQL, values: openarray[string]): SQL =
  # 引用每个参数:
  let v = values.mapIt(properQuote(it))
  # 需要一个临时类型用到类型转换 :-(
  type StrSeq = seq[string]
  # 调用 strutils.`%`:
  result = SQL(string(frmt) % StrSeq(v))

db.query("SELECT FROM users WHERE name = '$1'".SQL % [username])

现在我们有了针对 SQL 注入攻击的编译期检查。 由于 "".SQL 被转换为 SQL("") ,所以不需要新的语法来实现简洁的 SQL 字符串字面值。 假设 SQL 类型与 db_sqlite 等类似,已经作为 SqlQuery type 实际存在与库中。

Auto类型

auto 类型只能用作返回类型和参数。对于返回类型,它会使编译器从例程主体推断类型:

proc returnsInt(): auto = 1984

对于参数,它当前创建隐式泛型例程:

proc foo(a, b: auto) = discard

和下面一样:

proc foo[T1, T2](a: T1, b: T2) = discard

但是,语言的后续版本可能会将其更改为"从主体推断参数类型"。 从而上面的 foo 将会被拒绝,因为无法从一个空的 discard 语句中推断出参数的类型。

类型关系

以下部分定义描述了编译器完成类型检查所需要的几种类型关系。

类型相等性

Nim 对大多数类型使用结构类型相等。仅对对象、枚举和 distinct 类型以及泛型类型使用名称相等。

Subtype关系

如果对象 a 继承自 ba 将是 b 的子类型。

子类型关系被延伸到类型 var , ref , prt 。如果 AB 的子类型, ABobject 类型那么:

  • var Avar B的子类型
  • ref Aref B的子类型
  • ptr Aptr B的子类型。

注意: 从子类型到父类型的赋值,需要上述指针注解之一,以防止 "对象切割" 。

转换关系

如果以下算法返回 true,则类型 a 隐式 可转换为类型 b :

proc isImplicitlyConvertible(a, b: PType): bool =
  if isSubtype(a, b):
    return true
  if isIntLiteral(a):
    return b in {int8, int16, int32, int64, int, uint, uint8, uint16,
                 uint32, uint64, float32, float64}
  case a.kind
  of int:     result = b in {int32, int64}
  of int8:    result = b in {int16, int32, int64, int}
  of int16:   result = b in {int32, int64, int}
  of int32:   result = b in {int64, int}
  of uint:    result = b in {uint32, uint64}
  of uint8:   result = b in {uint16, uint32, uint64}
  of uint16:  result = b in {uint32, uint64}
  of uint32:  result = b in {uint64}
  of float32: result = b in {float64}
  of float64: result = b in {float32}
  of seq:
    result = b == openArray and typeEquals(a.baseType, b.baseType)
  of array:
    result = b == openArray and typeEquals(a.baseType, b.baseType)
    if a.baseType == char and a.indexType.rangeA == 0:
      result = b == cstring
  of cstring, ptr:
    result = b == pointer
  of string:
    result = b == cstring
  of proc:
    result = typeEquals(a, b) or compatibleParametersAndEffects(a, b)

我们使用判断 typeEquals(a, b) 表示 "类型相等" 属性,使用判断 isSubtype(a, b) 表示 "子类型关系"。compatibleParametersAndEffects(a, b) 当前未指定。

Nim 的 range 类型构造器也执行隐式转换。

a0, b0 为类型 T

A = range[a0..b0] 为实参类型, F 为形参类型。如果 a0 >= low(F) and b0 <= high(F) 并且 TF 都是有符号整数或两者都是无符号整数,则存在从 AF 隐式转换。

如果下列算法返回 true,则类型 a 是显示转换为类型 b :

proc isIntegralType(t: PType): bool =
  result = isOrdinal(t) or t.kind in {float, float32, float64}

proc isExplicitlyConvertible(a, b: PType): bool =
  result = false
  if isImplicitlyConvertible(a, b): return true
  if typeEquals(a, b): return true
  if a == distinct and typeEquals(a.baseType, b): return true
  if b == distinct and typeEquals(b.baseType, a): return true
  if isIntegralType(a) and isIntegralType(b): return true
  if isSubtype(a, b) or isSubtype(b, a): return true

可转换关系可以通过用户定义的类型 converter "转换器"放宽。

converter toInt(x: char): int = result = ord(x)

var
  x: int
  chr: char = 'a'

# 这里产生隐式转换
x = chr
echo x # => 97
# 也可以使用显式形式
x = chr.toInt
echo x # => 97

如果 a 是左值,并且 typeEqualsOrDistinct(T, typeof(a)) 成立,则类型转换 T(a) 是左值。

赋值兼容

一个表达式 b 可以被赋值给一个表达式 a 如果 a 是一个 l-value 并且保持 isImplicitlyConvertible(b.typ, a.typ)

重载解决方案

在调用 p(args) 中,选择最匹配的例程 p 。如果多个例程匹配相同,则在语义分析期间报告歧义。

args 中的每个 arg 都需要匹配。一个实参可以匹配多个不同的类别。假设 f 是形参类型,a 是实参类型。

  1. 完全匹配: af 是同一类型。
  2. 字面值匹配: a 是值 v 的整数字面值, f 是有符号或无符号整数类型, vf 的范围内。 或者: a 是值 v 的浮点字面值, f 是浮点类型, vf 的范围内。
  3. 泛型匹配: f 是泛型类型和 a 匹配,例如 aintf 是泛型(受约束的)参数类型(如在 [T][ T:int|char] )。
  4. 子范围或子类型匹配: arange[T]Tf 完全匹配。 或者: af 的子类型。
  5. 整数转换匹配: a 可以转换为 ffa 是某些整数或浮点类型。
  6. 转换匹配: a 可转换为 f ,可能通过用户定义的 converter

选择最佳匹配候选人主要有两种方法,计数和消歧。计数优先于消歧。 在计数中,每个参数都被赋予一个类别,并计算每个类别中的参数数量。 这些类别按优先级顺序列在上面。例如,如果一个具有一个完全匹配的候选人与一个具有多个通用匹配和零个完全匹配的候选人进行比较,那么具有完全匹配的候选人将获胜。

在以下部分中, count(p, m) 计算了对于程序 p ,匹配类别 m 的匹配数量。

如果以下算法返回 true ,则程序 p 的匹配度优于程序 q

for each matching category m in ["exact match", "literal match",
                                "generic match", "subtype match",
                                "integral match", "conversion match"]:
  if count(p, m) > count(q, m): return true
  elif count(p, m) == count(q, m):
    discard "continue with next category m"
  else:
    return false
return "ambiguous"

当计数产生歧义时,开始进行消歧。参数按位置进行迭代,并比较这些参数对的类型关系。 这种比较的一般目标是确定哪个参数更具体。考虑的类型不是来自调用点的输入,而是竞争候选人的参数类型。

一些例子:

proc takesInt(x: int) = echo "int"
proc takesInt[T](x: T) = echo "T"
proc takesInt(x: int16) = echo "int16"

takesInt(4) # "int"
var x: int32
takesInt(x) # "T"
var y: int16
takesInt(y) # "int16"
var z: range[0..4] = 0
takesInt(z) # "T"

如果这个算法返回 "ambiguous" "歧义",则执行进一步消除歧义: 如果参数 a 通过子类型关系同时匹配 p 的参数类型 fqg,则考虑继承深度:

type
  A = object of RootObj
  B = object of A
  C = object of B

proc p(obj: A) =
  echo "A"

proc p(obj: B) =
  echo "B"

var c = C()
# 没有歧义, 调用 'B' ,而不是 'A' ,因为 B 是 A 的子类型
# 但反之亦然:
p(c)

proc pp(obj: A, obj2: B) = echo "A B"
proc pp(obj: B, obj2: A) = echo "B A"

# 但是这个有歧义:
pp(c, c)

类似,对于泛型匹配,最特化的泛型类型(仍然匹配)是首选:

proc gen[T](x: ref ref T) = echo "ref ref T"
proc gen[T](x: ref T) = echo "ref T"
proc gen[T](x: T) = echo "T"

var ri: ref int
gen(ri) # "ref T"

基于 'var T' 的重载

如果形参 fvar T 类型,并且进行普通类型检查,参数会被检查为 l-value "左值" 。var TT 更好匹配。

proc sayHi(x: int): string =
  # 匹配一个非可变整型
  result = $x
proc sayHi(x: var int): string =
  # 匹配一个整形变量
  result = $(x + 10)

proc sayHello(x: int) =
  var m = x # 一个x的可变版本
  echo sayHi(x) # 匹配 sayHi 的非可变版本
  echo sayHi(m) # 匹配 sayHi 的可变版本

sayHello(3) # 3
            # 13

untyped 惰性类型解析

注意: unresolved "未解析"表达式是没有标识符的表达式,不执行查找和类型检查。

因为没有声明为 immediate 的模板和宏会参与重载解析,因此必须有一种方法将未解析的表达式传递给模板或宏。 这就是元类型 untyped 的任务:

template rem(x: untyped) = discard

rem unresolvedExpression(undeclaredIdentifier)

untyped 类型的参数总是匹配任意参数(只要有任意参数传递给它)。

但须小心,因为其他重载可能会触发参数解析:

template rem(x: untyped) = discard
proc rem[T](x: T) = discard

# 未声明的标识符: 'unresolvedExpression'
rem unresolvedExpression(undeclaredIdentifier)

untypedvarargs[untyped] 是唯一在这个意义上惰性的元类型,其他元类型 typedtypedesc 是非惰性的。

可变参数匹配

参阅 Varargs

迭代器

yielding 类型 T 的迭代器可以通过类型为 untyped (用于未解析的表达式)或类型类 iterableiterable[T] (在类型检查和重载解析之后)的参数传递给模板或宏。

iterator iota(n: int): int =
  for i in 0..<n: yield i

template toSeq2[T](a: iterable[T]): seq[T] =
  var ret: seq[T]
  assert a.typeof is T
  for ai in a: ret.add ai
  ret

assert iota(3).toSeq2 == @[0, 1, 2]
assert toSeq2(5..7) == @[5, 6, 7]
assert not compiles(toSeq2(@[1,2])) # seq[int] is not an iterable
assert toSeq2(items(@[1,2])) == @[1, 2] # but items(@[1,2]) is

重载歧义消除

对于例程调用,会进行 "重载解析" 。有一种较弱的重载解析形式,称为 overload disambiguation 重载歧义消除 ,当重载符号在有额外类型信息的情况下被使用时,会执行。假设 p 是一个重载符号。则上下文是:

  • q 的相应形式参数是 proc 类型时,在函数调用 q(..., p, ...) 中。 如果 q 本身被重载,则必须考虑 qp 的每种解释的笛卡尔积。
  • 在一个对象构造函数中 Obj(..., field: p, ...)fieldproc 类型。类似的规则也适用于 array/set/tuple 的构造器。
  • 有这样的声明 x: T = pTproc 类型。

通常情况下,有歧义的匹配会产生编译错误。

命名参数重载

如果形参的名称不同,则可以分别调用具有相同类型签名的例程。

proc foo(x: int) =
  echo "Using x: ", x
proc foo(y: int) =
  echo "Using y: ", y

foo(x = 2) # Using x: 2
foo(y = 2) # Using y: 2

在这种情况下不提供参数名称会导致歧义错误。

语句和表达式

Nim 使用通用的"语句/表达式"范式: 与表达式相比,语句不产生值。但是,有些表达式是语句。

语句分成 simple statements "简单语句" 和 complex statements "复杂语句" 。 简单语句是不能包含其他语句的语句,如赋值、调用或 return 语句;复杂语句包含其他语句。 为了避免 dangling else problem "不确定性问题",复杂语句必须缩进, 细节可以查看语法一节。

语句列表表达式

语句也可以出现 (stmt1; stmt2; ...; ex) 这样的形式。这称为语句列表表达式或 (;)(stmt1; stmt2; ...; ex) 的类型是 ex 类型。 其他语句必须是 void 类型。(可以使用 discard 来生成 void 类型。) (;) 不会引入新的作用域。

Discard语句

Example:

proc p(x, y: int): int =
  result = x + y

discard p(3, 4) # 丢弃 `p` 的返回值

discard 语句评估其表达式的副作用并将表达式的结果值丢弃,其应在已知忽略此值不会导致问题时使用。

忽略过程的返回值而不使用丢弃语句将是静态错误。

如果调用的 proc/iterator 已使用 discardable "可丢弃"编译指示声明,则可以隐式忽略返回值:

proc p(x, y: int): int {.discardable.} =
  result = x + y

p(3, 4) # 当前有效

但是可丢弃编译指示不适用于模板,因为模板会替换掉 AST。 例如:

{.push discardable .}
template example(): string = "https://nim-lang.org"
{.pop.}

example()

此模板将解析为字符串字面值 "https://nim-lang.org" ,但由于 {.discardable.} 不适用于字面值,编译器会出错。

discard 语句常用于空语句中:

proc classify(s: string) =
  case s[0]
  of SymChars, '_': echo "an identifier"
  of '0'..'9': echo "a number"
  else: discard

Void下上文

在语句列表中,除了最后一个表达式之外,每个表达式类型需要为 void 。 除了这个规则,对内置 result 标识符的赋值也会为后续的表达式触发强制的 void 上下文:

proc invalid* (): string =
  result = "foo"
  "invalid"  # 错误: 类型 `string` 的值必须被抛弃

proc valid*(): string =
  let x = 317
  "valid"

Var语句

Var 语句声明新的局部和全局变量并初始化它们。逗号分隔的变量列表可用于指定相同类型的变量:

var
  a: int = 0
  x, y, z: int

如果给定了初始化器,则可以省略类型: 变量的类型与初始化表达式的类型相同。如果没有初始化表达式,则始终使用默认值初始化变量。默认值取决于类型,并且在二进制中始终为零。

类型默认值
任意整数0
任意浮点数0.0
字符'\0'
布尔false
引用和指针nil
过程nil
序列@[]
字符串""
tuple[x: A, y: B, ...](zeroDefault(A), zeroDefault(B), ...) (analogous for objects)
array[0..., T][zeroDefault(T), ...]
range[T]default(T); 这个有可能超出有效范围
T = enumcast[T](0); 这个有可是无效值

出于优化原因,可以使用 noinit "无初始化"编译指示来避免隐式初始化:

var
  a {.noinit.}: array[0..1023, char]

如果 proc 使用 noinit 编译指示,这指的是其隐式 result 变量:

proc returnUndefinedValue: int {.noinit.} = discard

requiresInit "需初始化"类型编译指示也可以防止隐式初始化。 编译器需要对对象及其所有字段进行显式初始化。 但是,它会进行 control flow analysis "控制流分析" 以验证变量已被初始化并且不依赖于语法属性:

type
  MyObject {.requiresInit.} = object

proc p() =
  # 以下是有效的:
  var x: MyObject
  if someCondition():
    x = a()
  else:
    x = a()
  # 使用 x

requiresInit 编译指示也可以应用于 distinct 类型。

给出以下 distinct 类型定义:

type
  Foo = object
    x: string
  
  DistinctFoo {.requiresInit, borrow: `.`.} = distinct Foo
  DistinctString {.requiresInit.} = distinct string

下列代码块将会编译失败:

var foo: DistinctFoo
foo.x = "test"
doAssert foo.x == "test"

var s: DistinctString
s = "test"
doAssert string(s) == "test"

但这些将会编译成功:

let foo = DistinctFoo(Foo(x: "test"))
doAssert foo.x == "test"

let s = DistinctString("test")
doAssert string(s) == "test"

Let语句

let 语句声明了新的局部和全局 single assignment "唯一赋值"变量并将值绑定到它们。 语法与 var 语句的语法相同,只是关键字 var 被关键字 let 替换。 let 变量不是左值,因此不能传递给 var 参数也不能获取他们的地址。不能为它们分配新值。

对于 let 变量,可以使用与普通变量相同的编译指示。

由于 let 语句在创建后是不可变的,因此它们需要在声明时定义值。 唯一的例外是如果应用了 {.importc.} 编译指示(或任意其他 importX 编译指示),在这种情况下,值应该来自本地代码,通常是 C/C++ const

特殊标识符 _ (下划线) 在声明中,标识符 _具有特殊含义。 任何以 _ 为名称的定义都不会添加到作用域中,这意味着定义会被评估,但无法使用。 因此,名称 _ 可以被无限次重新定义。

let _ = 123
echo _ # error
let _ = 456 # compiles

元组解包

var, letconst 语句中,可以进行元组解包。 特殊标识符 _ 可用于忽略元组中的某些部分:

proc returnsTuple(): (int, int, int) = (4, 2, 3)

let (x, _, z) = returnsTuple()

这大致上被视为以下内容的语法糖:

let
  tmpTuple = returnsTuple()
  x = tmpTuple[0]
  z = tmpTuple[2]

对于 varlet 语句,如果值表达式是一个元组字面量, 则每个表达式将直接扩展为赋值,而不使用临时变量。

let (x, y, z) = (1, 2, 3)
# becomes
let
  x = 1
  y = 2
  z = 3

元组解包也可以嵌套:

proc returnsNestedTuple(): (int, (int, int), int, int) = (4, (5, 7), 2, 3)

let (x, (_, y), _, z) = returnsNestedTuple()

常量域

const部分声明的常量的值是常量表达式:

import std/[strutils]
const
  roundPi = 3.1415
  constEval = contains("abc", 'b') # 在编译时计算

一旦声明,常量的符号就可以用作常量表达式。

详情参阅常量和常量表达式

静态语句/表达式

静态语句/表达式明确要求编译期执行。甚至在静态块中也允许一些具有副作用的代码:

static:
  echo "echo at compile time"

static 也可以像例程一样使用。

proc getNum(a: int): int = a

# 以下,在编译期调用 "echo getNum(123)"
static:
  echo getNum(123)

# 下面的调用在编译期计算 "getNum(123)" ,但其结果在运行时使用。
echo static(getNum(123))

对于哪些 Nim 代码可以在编译期执行,是有限制的,详情参阅编译期执行限制。 如果编译器不能在编译期执行该块,将是一个静态错误。

If语句

示例:

var name = readLine(stdin)

if name == "Andreas":
  echo "What a nice name!"
elif name == "":
  echo "Don't you have a name?"
else:
  echo "Boring name..."

if 语句是在控制流中创建分支的简单方法: 计算关键字 if 后的表达式,如果为真,则执行 : 后的相应语句。 否则,计算 elif 之后的表达式(如果有 elif 分支)。如果所有条件都失败,则执行 else 部分。 如果没有 else 部分,则继续执行下一条语句。

if 语句中,新的作用域在 if/elif/else 关键字之后立即开始,并在相应的 那个 块之后结束。 出于呈现的目的,在以下示例中,作用域被包含在 {| |} 中:

if {| (let m = input =~ re"(\w+)=\w+"; m.isMatch):
  echo "key ", m[0], " value ", m[1]  |}
elif {| (let m = input =~ re""; m.isMatch):
  echo "new m in this scope"  |}
else: {|
  echo "m not declared here"  |}

Case 语句

Example:

let line = readline(stdin)
case line
of "delete-everything", "restart-computer":
  echo "permission denied"
of "go-for-a-walk":     echo "please yourself"
elif line.len == 0:     echo "empty" # optional, must come after `of` branches
else:                   echo "unknown command" # ditto

# 允许分支缩进;
# 在选择表达式之后的冒号是可选:
case readline(stdin):
  of "delete-everything", "restart-computer":
    echo "permission denied"
  of "go-for-a-walk":     echo "please yourself"
  else:                   echo "unknown command"

case 语句类似于 if 语句, 它表示一种多分支选择。 关键字 case 后面的表达式进行求值, 如果其值在 slicelist 列表中, 则执行 of 关键字之后相应语句。 如果其值不在已给定的 slicelist 中, 那么所执行的 elifelse 语句部分与 if 语句相同, elif 的处理就像 else: if 。 如果没有 elseelif 部分,并且 expr 未能持有所有可能的值,则在 slicelist 会发生静态错误。 但这仅适用于序数类型的表达式。 expr 的 "所有可能的值" 由 expr 的类型决定,为了防止静态错误应该使用 else: discard

在 case 语句中,只允许使用序数类型、浮点数、字符串和 cstring 作为值。

对于非序数类型, 不可能列出每个可能的值,所以总是需要 else 部分。 此规则 string 类型是例外,目前,它不需要在后面添加 elseelif 分支, 但在未来版本中不确定。

因为在语义分析期间检查 case 语句的穷尽性,所以每个 of 分支中的值必须是常量表达式。 此限制可以让编译器生成更高性能的代码。

一种特殊的语义扩展是, case 语句 of 分支中的表达式可以为集合或数组构造器, 然后将集合或数组扩展为其元素的列表:

const
  SymChars: set[char] = {'a'..'z', 'A'..'Z', '\x80'..'\xFF'}

proc classify(s: string) =
  case s[0]
  of SymChars, '_': echo "an identifier"
  of '0'..'9': echo "a number"
  else: echo "other"

# 等价于:
proc classify(s: string) =
  case s[0]
  of 'a'..'z', 'A'..'Z', '\x80'..'\xFF', '_': echo "an identifier"
  of '0'..'9': echo "a number"
  else: echo "other"

case 语句不会产生左值, 所以下面的示例无效:

type
  Foo = ref object
    x: seq[string]

proc get_x(x: Foo): var seq[string] =
  # 无效
  case true
  of true:
    x.x
  else:
    x.x

var foo = Foo(x: @[])
foo.get_x().add("asd")

这可以通过显式使用 resultreturn 来修复:

proc get_x(x: Foo): var seq[string] =
  case true
  of true:
    result = x.x
  else:
    result = x.x

When 语句

示例:

when sizeof(int) == 2:
  echo "running on a 16 bit system!"
elif sizeof(int) == 4:
  echo "running on a 32 bit system!"
elif sizeof(int) == 8:
  echo "running on a 64 bit system!"
else:
  echo "cannot happen!"

when 语句几乎与 if 语句相同, 但有一些例外:

  • 每个条件 ( expr ) 必须是一个类型为 bool 的常量表达式。
  • 语句不产生新作用域。
  • 计算为 true 的表达式所属语句将由编译器翻译,而只检查每个条件的语义,不检查其他语句语义!

when 语句启用了条件编译技术。一种特殊的语法扩展是,可以在 object 定义中使用 when 结构。

When nimvm 语句

nimvm 是一个特殊标识符, 可用 when nimvm 语句表达式来判断路径,编译时或可执行文件之间执行。

示例:

proc someProcThatMayRunInCompileTime(): bool =
  when nimvm:
    # 编译时采用此分支.
    result = true
  else:
    # 可执行文件中采用此分支.
    result = false
const ctValue = someProcThatMayRunInCompileTime()
let rtValue = someProcThatMayRunInCompileTime()
assert(ctValue == true)
assert(rtValue == false)

when nimvm 语句必须满足以下要求:

  • 表达式必须是 nimvm ,不允许使用的复杂表达式。
  • 不得含有 elif 分支。
  • 必须含有 else 分支。
  • 分支中的代码不能影响 when nimvm 语句之后代码的语义,比如不能定义后续代码中使用的标识符。

Return 语句

比如:

return 40 + 2

return 语句将结束当前执行的过程,并只允许在过程中使用。如果这里是一个 expr , 将是语法糖:

result = expr
return result

如果 proc 有返回类型,不带表达式的 returnreturn result 的简短表示. 编译器自动声明的变量 result 始终是过程的返回值。与所有变量一样, result 会初始化为(二进制)0:

proc returnZero(): int =
  # 隐式返回0

Yield 语句

示例:

yield (1, 2, 3)

在迭代器中使用 yield 语句代替 return 语句。它只在迭代器中有效。 执行将被返回到调用该迭代器的for循环的主体。Yield并不会结束迭代过程,当下一次迭代开始,执行会被传回迭代器。 更多信息请参阅关于迭代器的章节(迭代器和for语句)。

Block 语句

示例:

var found = false
block myblock:
  for i in 0..3:
    for j in 0..3:
      if a[j][i] == 7:
        found = true
        break myblock # 跳出两个 for 循环块
echo found

block 语句是一种将语句分组到命名的 block 的方法。在 block 语句内,允许用 break 语句立即跳出。 break 语句可以包含围绕的block的名称, 以指定要跳出的层级。

Break 语句

示例:

break

break 语句用于立即跳出 block 块。如果给出 symbol "标识符", 是指定要跳出的闭合的 block 的名称。如果未给出,则跳出最内层的 block 。

While 语句

示例:

echo "Please tell me your password:"
var pw = readLine(stdin)
while pw != "12345":
  echo "Wrong password! Next try:"
  pw = readLine(stdin)

while 语句执行时直到 expr 计算结果为 false。无尽的循环不会报告错误。 while 语句会打开一个 implicit block "隐式块",因而可以用 break 语句跳出。

Continue 语句

continue 语句会使循环结构进行下一次迭代,其只允许在循环中使用。continue 语句是嵌套 block 的语法糖:

while expr1:
  stmt1
  continue
  stmt2

等价于:

while expr1:
  block myBlockName:
    stmt1
    break myBlockName
    stmt2

汇编语句

不安全的 asm 语句支持将汇编代码直接嵌入到 Nim 代码中。 在汇编代码中引用 Nim 的标识符需要包含在特定字符中,该字符可以在语句的编译指示中指定。默认特定字符是 '`' :

{.push stackTrace:off.}
proc addInt(a, b: int): int =
  # a 在 eax 中, b 在 edx 中
  asm """
      mov eax, `a`
      add eax, `b`
      jno theEnd
      call `raiseOverflow`
    theEnd:
  """
{.pop.}

如果使用 GNU 汇编器,则会自动插入引号和换行符:

proc addInt(a, b: int): int =
  asm """
    addl %%ecx, %%eax
    jno 1
    call `raiseOverflow`
    1:
    :"=a"(`result`)
    :"a"(`a`), "c"(`b`)
  """

替代:

proc addInt(a, b: int): int =
  asm """
    "addl %%ecx, %%eax\n"
    "jno 1\n"
    "call `raiseOverflow`\n"
    "1: \n"
    :"=a"(`result`)
    :"a"(`a`), "c"(`b`)
  """

Using语句

在模块中反复使用相同的参数名称和类型时,using 语句提供了语法上的便利,而不必:

proc foo(c: Context; n: Node) = ...
proc bar(c: Context; n: Node, counter: int) = ...
proc baz(c: Context; n: Node) = ...

你可以告知编译器一个名为 c 的参数默认类型为 Context , n的默认类型为 Node :

using
  c: Context
  n: Node
  counter: int

proc foo(c, n) = ...
proc bar(c, n, counter) = ...
proc baz(c, n) = ...

proc mixedMode(c, n; x, y: int) =
  # 'c' 被推断为 'Context' 类型
  # 'n' 被推断为 'Node' 类型
  # 'x' 和 'y' 是 'int' 类型。

using 部分使用缩进的分组语法,与 varlet 部分相同。

注意, usingtemplate 不适用,因为 untyped 模板参数默认是 system.untyped 类型。

使用 using 声明和显式类型的参数混合时,它们之间需要分号。

If 表达式

if 表达式与 i f语句非常相似,但它是一个表达式。这个特性类似于其他语言中的 三元操作符 。 示例:

var y = if x > 8: 9 else: 10

if 表达式总是返回一个值,因此 else 部分是必需的,也允许 elif 部分。

When 表达式

if 表达式相似,与 when 语句对应。

Case表达式

case 表达式与 case 语句非常相似:

var favoriteFood = case animal
  of "dog": "bones"
  of "cat": "mice"
  elif animal.endsWith"whale": "plankton"
  else:
    echo "I'm not sure what to serve, but everybody loves ice cream"
    "ice cream"

如上例所示,case 表达式也可以引入副作用。当分支给出多个语句时,Nim 将使用最后一个表达式作为结果值。

Block 表达式

block 表达式几乎和 block 语句相同,但它是一个表达式,它使用 block 的最后一个表达式作为值。 它类似于语句列表表达式,但语句列表表达式不会创建新的 block 作用域。

let a = block:
  var fib = @[0, 1]
  for i in 0..10:
    fib.add fib[^1] + fib[^2]
  fib

表构造器

表构造器是数组构造器的语法糖:

{"key1": "value1", "key2", "key3": "value2"}

# 等同于:
[("key1", "value1"), ("key2", "value2"), ("key3", "value2")]

空表可以写成 {:} (对比 {} 空集合),这是另一种写为空数组构造器 [] 的方法。这种略微不同寻常的书写表的方式有很多优点:

  • 保留了(键, 值)对的顺序, 因此更容易支持有序的字典,例如 {key: val}.newOrderedTable
  • 表字面值可以放入 const 部分,编译器可以更容易地将它放入可执行文件的数据部分,就像数组一样,生成的数据部分占用更少的内存。
  • 每个表的实现在语法上一样。
  • 除了这个最低限度的语法糖, 语言核心不需要关心表。

类型转换

从语法上来说, 类型转换 类似于过程调用,只是用一个类型名替换了过程名。类型转换总是安全的,将类型转换失败会导致异常(如果不能静态确定)。

普通的 proc 通常比 Nim 中的类型转换更友好: 例如, $toString 运算符, 而 toFloattoInt 可从浮点数转换为整数,反之亦然。

类型转换也可用于消除重载例程的歧义:

proc p(x: int) = echo "int"
proc p(x: string) = echo "string"

let procVar = (proc(x: string))(p)
procVar("a")

由于对无符号数的操作会环绕,且不会检查,因而到无符号整数的类型转换以及无符号整数之间的类型转换也会这样。 这样做的原因是,当算法从 C 移植到 Nim 时,可以更好地与 C 语言进行互操作。

例外: 将检查在编译时转换为无符号类型的值, 以使 byte(-1) 之类代码无法编译。

注意: 历史版本中不检查运算,有时会检查转换,但从 1.0.4 语言版本实现开始,转换 总是未检查

类型强转

类型强转 是一种粗暴的机制,对于表达式按位模式解释,就好像它就是另一种类型。类型强转仅用于低层编程,并且本质上是不安全的。

cast[int](x)

强制转换的目标类型必须是具体类型,例如,非具体的类型类目标将是无效的:

type Foo = int or float
var x = cast[Foo](1) # Error: 不能转换为非具体类型: 'Foo'

类型强转不应与 类型转换 混淆, 如前所述,与类型转换不同,类型强转不能更改被转换数据的底层位模式(除了目标类型的大小可能与源类型不同之外)。 强制转换类似于其他语言中的 类型双关 或 c++ 的 reinterpret_castbit_cast 特性。

如果目标类型的大小大于源类型的大小,则剩余的内存将被清零。

addr 操作符

addr 运算符返回左值的地址。如果地址的类型是 T, 则 addr 运算符结果的类型为 ptr T 。 地址总是一个未追踪引用的值。获取驻留在堆栈上的对象的地址是 不安全的 , 因为指针可能比堆栈中的对象存在更久, 因此可以引用不存在的对象。 我们得到变量的地址,是为了更容易与其他编译语言互操作(如C),也可以做到检索 let 变量、参数或 for 循环变量的地址:

let t1 = "Hello"
var
  t2 = t1
  t3 : pointer = addr(t2)
echo repr(addr(t2))
# --> ref 0x7fff6b71b670 --> 0x10bb81050"Hello"
echo cast[ptr string](t3)[]
# --> Hello
# 下面这行代码也可以使用
echo repr(addr(t1))

unsafeAddr操作符

unsafeAddr 操作符是 addr 操作符已弃用的别名:

let myArray = [1, 2, 3]
foreignProcThatTakesAnAddr(unsafeAddr myArray)

过程

大多数编程语言中称之为 methods:idx "方法"或 functions:idx "函数",在 Nim 中则称为 procedures:idx "过程"。 过程声明由标识符、零个或多个形参、返回值类型和代码块组成,形参声明为由逗号或分号分隔的标识符列表。形参由 : typename 给出一个类型。 该类型适用于紧接其之前的所有参数,直到参数列表的开头的分号分隔符或已经键入的参数。 分号可使类型和后续标识符的分隔更加清晰。

# 只使用逗号
proc foo(a, b: int, c, d: bool): int

# 使用分号进行显式的区分
proc foo(a, b: int; c, d: bool): int

# 会失败: a是无类型的, 因为 ';' 为停止类型传播
proc foo(a; b: int; c, d: bool): int

可以使用默认值声明参数,如果调用者没有为参数提供值,则使用该默认值,每次调用函数时,都会重新计算该值。

# b是可选的, 默认值为 47 。
proc foo(a: int, b: int = 47): int

参数可以声明为可变的,过程允许通过类型修饰符 var 来修饰参数。

# 通过第二个参数 "返回" 一个值给调用者
# 请注意, 该函数实际没有使用真实的返回值(即 void)
proc foo(inp: int, outp: var int) =
  outp = inp + 47

如果 proc 声明没有过程体, 则是 forward "前置"声明。 如果 proc 返回一个值,那么过程体可以访问一个名为 result 的隐式变量。 过程可能会重载,重载解析算法会确定哪个 proc 是参数的最佳匹配。 示例:

proc toLower(c: char): char = # toLower 字符
  if c in {'A'..'Z'}:
    result = chr(ord(c) + (ord('a') - ord('A')))
  else:
    result = c

proc toLower(s: string): string = # 字符串 toLower
  result = newString(len(s))
  for i in 0..len(s) - 1:
    result[i] = toLower(s[i]) # 为字符调用 toLower ,不递归!

调用过程可以通过多种方式完成:

proc callme(x, y: int, s: string = "", c: char, b: bool = false) = ...

# 带位置参数的调用                      # 参数绑定:
callme(0, 1, "abc", '\t', true)       # (x=0, y=1, s="abc", c='\t', b=true)
# 使用命名参数和位置参数调用:
callme(y=1, x=0, "abd", '\t')         # (x=0, y=1, s="abd", c='\t', b=false)
# 带命名参数的调用(顺序无关):
callme(c='\t', y=1, x=0)              # (x=0, y=1, s="", c='\t', b=false)
# 作为命令语句调用:不需要():
callme 0, 1, "abc", '\t'              # (x=0, y=1, s="abc", c='\t', b=false)

过程可以递归地调用自身。

Operators "操作符"是将特定运算符作为标识符的过程:

proc `$` (x: int): string =
  # 将整数转换为字符串;这是一个前缀操作符。
  result = intToStr(x)

具有一个参数的操作符是前缀操作符,有两个参数的运算符是中缀操作符。(但是, 解析器将这些与操作符在表达式中的位置区分开来。) 无法声明后缀运算符,所有后缀运算符都是内置的,由语法明确指出。

任何操作符都可以像普通的 proc 一样用 `opr` 表示法调用。(因此操作符可以有两个以上的参数):

proc `*+` (a, b, c: int): int =
  # 乘 和 加
  result = a * b + c

assert `*+`(3, 4, 6) == `+`(`*`(a, b), c)

导出标记

如果声明的标识符有 asterisk "星号"标记,表示从当前模块导出:

proc exportedEcho*(s: string) = echo s
proc `*`*(a: string; b: int): string =
  result = newStringOfCap(a.len * b)
  for i in 1..b: result.add a

var exportedVar*: int
const exportedConst* = 78
type
  ExportedType* = object
    exportedField*: int

方法调用语法

对于面向对象的编程,可以用 obj.methodName(args) 语法,取代 methodName(obj, args) 。 如果没有多余的参数,则可以省略括号: obj.len (取代 len(obj) )。

此方法调用语法不限于对象,可用于为过程提供任意类型的第一个参数:

echo "abc".len # 等同于 echo len "abc"
echo "abc".toUpper()
echo {'a', 'b', 'c'}.card
stdout.writeLine("Hallo") # 等同于 writeLine(stdout, "Hallo")

另一种看待方法调用语法的方式是,它是提供缺失的后缀表示法。

方法调用语法与显式泛型实例化冲突: p[T](x) 不能写为 x.p[T] 因为 x.p[T] 总是被解析为 (x.p)[T]

See also: 方法调用语法限制

[: ] 符号是为了缓解这个问题: x.p[:T] 由解析器重写为 p[T](x) , x.p[:T](y) 被重写为 p[T](x, y) 。 注意 [: ] 没有 AST 表示, 直接在解析步骤中进行重写。

属性

Nim 不需要 get-properties : 使用 方法调用语法 调用的普通 get-procedure 达到相同目的。但 set 值是不同的; 因而需要一个特殊的 setter 语法:

#  asocket 模块
type
  Socket* = ref object of RootObj
    host: int # cannot be accessed from the outside of the module

proc `host=`*(s: var Socket, value: int) {.inline.} =
  ## hostAddr的setter.
  ## 它访问 'host' 字段并且不是对 `host =` 的递归调用, 如果内置的点访问方法可用, 则首选点访问:
  s.host = value

proc host*(s: Socket): int {.inline.} =
  ## hostAddr 的 getter
  ## 它访问 'host' 字段并且不是对 `host` 的递归调用, 如果内置的点访问方法可用, 则首选点访问:
  s.host

# 模块 B
import asocket
var s: Socket
new s
s.host = 34  # same as `host=`(s, 34)

定义为 f= 的 proc(后面跟 = )被称为 setter 。 可以通过常见的反引号表示法显式调用 setter:

proc `f=`(x: MyObject; value: string) =
  discard

`f=`(myObject, "value")

f= 可以在 x.f = value 模式中隐式调用,当且仅当 x 的类型没有名为 f 的字段或 f 在当前模块中不可见时。 此规则确保对象字段和访问器可以有相同的名字。在模块内 x.f 总是被解释为字段访问,在模块外则被解释为访问器过程调用。

命令调用语法

如果调用在语法上是一个语句,则可以在没有 () 的情况下调用例程。此命令调用语法也适用于表达式。但之后只能有一个参数。这种限制意味着 echo f 1, f 2 被解析为 echo(f(1), f(2)) 而不是 echo(f(1, f(2))) 。 在这种情况下, 方法调用语法可以用来提供更多的参数。

proc optarg(x: int, y: int = 0): int = x + y
proc singlearg(x: int): int = 20*x

echo optarg 1, " ", singlearg 2  # 打印 "1 40"

let fail = optarg 1, optarg 8   # 错误。命令调用的参数太多
let x = optarg(1, optarg 8)     # 传统过程调用 2 个参数
let y = 1.optarg optarg 8       # 与上面相同, 没有括号
assert x == y

命令调用的语法也不能有复杂的表达式作为参数。例如:匿名过程ifcasetry。 没有参数的函数调用仍然需要 () 来区分调用和函数本身优先类的值。

闭包

过程可以出现在模块的顶层,也可以出现在其他作用域中,在这种情况下,称为嵌套过程。 嵌套过程可以从其封闭的作用域访问局部变量,这就变成了一个闭包。 任何捕获的变量都存储在闭包(它的环境)隐藏附加参数中,并且通过闭包及其封闭作用域的引用来访问它们(即, 对它们进行的任意修改在两个地方都是可见的)。 如果编译器确定这是安全的,则会在堆或栈上分配闭包环境。

在循环中创建闭包

由于闭包通过引用来捕获局部变量,这种行为往往在循环体内部并不友好。 参阅 closureScopecapture 来了解如何改变这种行为。

匿名过程

未命名过程可以用 lambda 表达式传递给其他过程:

var cities = @["Frankfurt", "Tokyo", "New York", "Kyiv"]

cities.sort(proc (x, y: string): int =
  cmp(x.len, y.len))

过程表达式既可以嵌套在过程中,也可以在上层可执行代码中。sugar 模块包含 => 宏,它为匿名过程提供了更简洁的语法,类似于 JavaScript 、 c# 等语言中的 lambda 。

Do 标记

作为一种特殊的简洁表示法, do 关键字可以用来将匿名过程传递给过程:

var cities = @["Frankfurt", "Tokyo", "New York", "Kyiv"]

sort(cities) do (x, y: string) -> int:
  cmp(x.len, y.len)

# 使用方法加命令语法减少括号:
cities = cities.map do (x: string) -> string:
  "City of " & x

do 写在包含常规过程参数的圆括号之后。 由 do 块表示的过程表达式,将作为最后一个参数附加到例程调用。 在使用命令语法的调用中, do 块将绑定到前面紧靠的表达式,而不是命令调用。

带参数列表或编译指示列表的 do 对应于匿名的 proc ,但是不带参数或编译指示中的 do 被视为常规语句列表。 这允许宏接收缩进语句列表作为内联调用的参数,以及 Nim 例程语法的直接镜像。

# 将语句列表传递给内联宏:
macroResults.add quote do:
  if not `ex`:
    echo `info`, ": Check failed: ", `expString`

# 处理宏中的例程定义:
rpc(router, "add") do (a, b: int) -> int:
  result = a + b

函数

func 关键字是引入 noSideEffect 过程的快捷方式。

func binarySearch[T](a: openArray[T]; elem: T): int

是它的简写:

proc binarySearch[T](a: openArray[T]; elem: T): int {.noSideEffect.}

例程

例程是一类标识符: proc, func, method, iterator, macro, template, converter

类型绑定操作符

类型绑定操作符是名称以 = 开头的 procfunc ,但不是运算符(即只是包含符号而矣,如 ==)。它们与以 = 结尾的 setter无关(参阅属性)。为类型声明的类型绑定操作符,不论是否在作用域中(包括是否私有),都将应用于该类型。

# foo.nim:
var witness* = 0
type Foo[T] = object
proc initFoo*(T: typedesc): Foo[T] = discard
proc `=destroy`[T](x: var Foo[T]) = witness.inc # type bound operator

# main.nim:
import foo
block:
  var a = initFoo(int)
  doAssert witness == 0
doAssert witness == 1
block:
  var a = initFoo(int)
  doAssert witness == 1
  `=destroy`(a) # can be called explicitly, even without being in scope
  doAssert witness == 2
# 在退出作用域时仍然会被调用
doAssert witness == 3

类型限定运算符有: =destroy, =copy, =sink, =trace, =deepcopy, =wasMoved, =dup.

这些操作被 overridden "重写", 而不是 overloaded "重载"。这意味着实现会自动提升为结构化类型。 例如,如果类型 T 有一个重写的赋值运算符 = ,这个操作符也可用于类型 seq[T] 的赋值。

由于这些操作被绑定到一个类型,为了实现的简单性,它们必须绑定到一个名义上的类型; 这意味着一个被重写的 deepCopyref T 是真正绑定到 T 而不是 ref T 。 这也意味着,不能同时重写 deepCopyptr Tref T ,相反,必须为一种指针类型使用 distinct 或 object 辅助类型。

想了解关于这些过程的更多细节,参阅生命期追踪钩子

Nonoverloadable 内置命令

出于实现简单性的原因,以下内置过程不能被重载(它们需要专门的语义检查):

declared, defined, definedInScope, compiles, sizeof,
is, shallowCopy, getAst, astToStr, spawn, procCall

因此,它们的行为更像关键字而不是普通的标识符;然而,与关键字不同的是,重定义可能会 shadow system模块中的定义。 从列表中,以下过程不应以点表示法 x.f 来编写,因为 x 在传递给 f 之前无法进行类型检查:

declared, defined, definedInScope, compiles, getAst, astToStr

Var 参数

参数的类型可以使用 var 关键字作为前缀:

proc divmod(a, b: int; res, remainder: var int) =
  res = a div b
  remainder = a mod b

var
  x, y: int

divmod(8, 5, x, y) # modifies x and y
assert x == 1
assert y == 3

在示例中, resremaindervar parameters 。可以通过过程修改 Var 形参,且调用者可以拿到更改。 传递给 var 形参的实参必须是左值。 Var 形参的实现为隐藏指针。上面的例子相当于:

proc divmod(a, b: int; res, remainder: ptr int) =
  res[] = a div b
  remainder[] = a mod b

var
  x, y: int
divmod(8, 5, addr(x), addr(y))
assert x == 1
assert y == 3

在示例中,var 形参或指针用来提供两个返回值。这可以通过返回一个元组这种更简洁的方式来完成:

proc divmod(a, b: int): tuple[res, remainder: int] =
  (a div b, a mod b)

var t = divmod(8, 5)

assert t.res == 1
assert t.remainder == 3

可以使用 tuple unpacking 来访问元组的字段:

var (x, y) = divmod(8, 5) # 元组解包
assert x == 1
assert y == 3

注意: 对于高效的参数传递来说, var 形参不是必需的。 因为非 var 形参不能修改,所以编译器在认为可以加快执行速度的情况下,会更自由地通过引用传递参数。

Var 返回类型

过程、转换器或者迭代器可以返回 var 类型,表示返回的是一个左值,调用者可以修改它:

var g = 0

proc writeAccessToG(): var int =
  result = g

writeAccessToG() = 6
assert g == 6

如果隐式创建的指向某地址的指针,有可能在其生命周期之外继续访问它,那么编译器会报告静态错误:

proc writeAccessToG(): var int =
  var g = 0
  result = g # 错误!

当迭代器返回元组时,元组的元素也可以是 var 类型:

iterator mpairs(a: var seq[string]): tuple[key: int, val: var string] =
  for i in 0..a.high:
    yield (i, a[i])

在标准库中,所有返回 var 类型的例程,都遵循以 m 为前缀的命名规范。

通过 var T 返回的内存是安全的,这由简单的借用规则来保证: 如果 result 未指向堆的地址(即在 result = X 中, X 涉及到 ptrref 访问),那么它必须来自例程的第一个参数。

proc forward[T](x: var T): var T =
  result = x # 可以, 来自第一个参数。

proc p(param: var int): var int =
  var x: int
  # 我们知道 'forward' 提供了一个从其第一个参数 'x' 得出的地址的视图
  result = forward(x) # 错误: 地址来自 `x` ,
                      # 其不是p的第一个参数,
                      # 并且存活在栈上。

换句话说, result 所指向的生命周期与第一个参数的生命周期相关联,这就足以验证调用位置的内存安全。

未来的方向

新版本的 Nim 借用规则将更加准确,比如使用这样的语法:

proc foo(other: Y; container: var X): var T from container

这里的 var T from contaner 显式指定了返回值的地址必须源自第二个参数(本例的 'container')。 var T from p 语句指明了类型 varTy[T, 2]varTy[T, 1] 类型不兼容。

具名返回值优化 (NRVO)

注意: 本节文档仅描述当前的实现。这部分语言规范将会有变动。 详情查看链接 https://github.com/nim-lang/RFCs/issues/230

在例程内部返回值以特殊的 result 变量出现。这为实现与 C++ 的 "具名返回值优化" (NRVO) 类似的机制创造了条件。 NRVO 指的是 p 内对 result 的操作会直接影响 let/var dest = p(args) (定义 dest) 或 dest = p(args) (给 dest 赋值) 中的目标 dest 。 这是通过将 dest = p(args) 重写为 p'(args, dest) 来实现的,其中 p'p 的变体,它返回 void 并且接收一个与 result 对应的可变参数。

不太正式的示例:

proc p(): BigT = ...

var x = p()
x = p()

# 上面这段代码大致上会被解释为以下代码

proc p(result: var BigT) = ...

var x; p(x)
p(x)

假设 p 的返回值类型为 T。 当 sizeof(T) >= N (N 依赖于具体实现) 时,编译器就会使用 NRVO。 换句话说,NRVO 适用于 "较大" 的结构体。

即使 p 可能抛出异常,依然会使用 NRVO。这会带来显著的不同行为:

type
  BigT = array[16, int]

proc p(raiseAt: int): BigT =
  for i in 0..high(result):
    if i == raiseAt: raise newException(ValueError, "interception")
    result[i] = i

proc main =
  var x: BigT
  try:
    x = p(8)
  except ValueError:
    doAssert x == [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0]

main()

编译器能够检测这些情况并发出警告,但是这个行为默认是关闭的。通过 warning[ObservableStores] 以及 push/pop 编译指示可以为一段代码打开这个警告。以上面的代码为例:

{.push warning[ObservableStores]: on.}
main()
{.pop.}

重载下标运算符

数组/开放数组/序列的 [] 下标运算符可以被重载。

方法

过程总是静态派发,而方法则使用动态派发。为了将动态派发应用在对象上,对象必须是引用类型。

type
  Expression = ref object of RootObj ## 表达式的抽象基类
  Literal = ref object of Expression
    x: int
  PlusExpr = ref object of Expression
    a, b: Expression

method eval(e: Expression): int {.base.} =
  # 一定要重写这个基方法
  raise newException(CatchableError, "未重写基方法")

method eval(e: Literal): int = return e.x

method eval(e: PlusExpr): int =
  # 注意: 这里依赖于动态绑定
  result = eval(e.a) + eval(e.b)

proc newLit(x: int): Literal =
  new(result)
  result.x = x

proc newPlus(a, b: Expression): PlusExpr =
  new(result)
  result.a = a
  result.b = b

echo eval(newPlus(newPlus(newLit(1), newLit(2)), newLit(4)))

在这个例子中,构造函数 newLitnewPlus 都是过程,因为它们都使用静态绑定,但是 eval 是方法因为需要动态绑定。

正如这个例子所示,基方法必须使用 base 编译指示修饰。base 编译指示对于开发者来说也是一种提醒: 这个基方法 m 是推断方法 m 所能产生的所有效果的一个基础。

注意: 目前还不支持方法的编译期执行。

注意: 从 Nim 0.20 开始,泛型方法已被弃用。

多重方法 (Multi-methods)

Note 从 Nim 0.20 开始,要启用多重方法,开发者必须在编译时显式添加 --multimethods:on 参数。

在多重方法中,所有对象类型的参数都会用于方法派发:

type
  Thing = ref object of RootObj
  Unit = ref object of Thing
    x: int

method collide(a, b: Thing) {.base, 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

通过 proCall 防止动态方法解析

通过调用内置的 system.procCall 可以防止动态方法解析。 某种程度上它与传统面向对象语言提供的 super 关键字类似。

type
  Thing = ref object of RootObj
  Unit = ref object of Thing
    x: int

method m(a: Thing) {.base.} =
  echo "base"

method m(a: Unit) =
  # 调用基方法:
  procCall m(Thing(a))
  echo "1"

迭代器与 for 循环语句

for:idx 语句是一种迭代容器中元素的抽象机制。它依赖于 iterator "迭代器"来实现。 与 while 语句类似,for 语句也开启了一个 implicit block "隐式代码块",也就可以使用 break 语句。

for 循环声明了迭代变量 - 它们的生命周期持续到循环体的结束。迭代变量的类型根据迭代器的返回值类型推断。

迭代器与过程类似,不过迭代器只能在 for 循环的上下文中调用。迭代器提供了一种遍历抽象类型的方法。 迭代器里的 yield 语句对于 for 循环的执行至关重要。当程序执行到 yield 语句时,数据会绑定 到 for 循环变量,同时控制权也移交到循环体并继续执行。迭代器的局部变量和执行状态在多次调用期间会自动保存。 例如:

# system 模块中存在如下定义
iterator items*(a: string): char {.inline.} =
  var i = 0
  while i < len(a):
    yield a[i]
    inc(i)

for ch in items("hello world"): # `ch` 是迭代器变量
  echo ch

编译器会生成如下代码,就像是开发者写的一样:

var i = 0
while i < len(a):
  var ch = a[i]
  echo ch
  inc(i)

如果迭代器的 yield 语句产生的是元组,那么可以有多个循环变量,个数等于元组的元素数。 第 i 次循环变量的类型就是元组第 i 个元素的类型。换句话说,循环上下文支持隐式元组拆包。

隐式 items/pairs 调用

如果循环表达式 e 不是迭代器并且 for 循环只有一个循环变量,则循环表达式会被重写为 items(e); 即隐式调用 items 迭代器:

for x in [1,2,3]: echo x

如果循环恰好有两个循环变量,则隐式调用 pairs 迭代器。

items/pairs 标识符的符号查找在编译器重写之后执行,所以 items/pairs 的所有重载都能生效。

一等迭代器

Nim 中有两种迭代器: inline (内联)和 closure (闭包)迭代器。 inline iterator "内联迭代器" 总是被编译器内联优化, 这种抽象也就不会带来任何额外开销(零成本抽象),但代码体积可能大大增加。

请警惕: 在使用内联迭代器时,循环体会被内联进循环中所有的 yield 语句里,所以理想情况是合理地重构迭代器代码使它只包含一条 yield 语句,以免代码体积膨胀。

内联迭代器是二等公民;它们只能作为参数传递给其他内联代码工具,如模板、宏和其他内联迭代器。

相反,closure iterator "闭包迭代器" 可以更自由地传递:

iterator count0(): int {.closure.} =
  yield 0

iterator count2(): int {.closure.} =
  var x = 1
  yield x
  inc x
  yield x

proc invoke(iter: iterator(): int {.closure.}) =
  for x in iter(): echo x

invoke(count0)
invoke(count2)

闭包迭代器和内联迭代器都有一些限制:

  1. 目前,闭包迭代器不能在编译期执行。
  2. 闭包迭代器可使用 return 语句结束循环,但内联迭代器(虽然基本没什么用)不允许使用。
  3. 内联迭代器不能递归。
  4. 内联迭代器与闭包迭代器都没有特殊的 result 变量。
  5. JS 后端不支持闭包迭代器。

如果既不用 {.closure.} 也不用 {.inline.} 显式标记迭代器,则默认为内联迭代器。但是将来的版本可能会改动。

iterator 类型总是约定隐式使用 closure 调用规范;下面的例子展示了如何使用迭代器实现一个 collaborative tasking "协作任务"系统:

# simple tasking:
type
  Task = iterator (ticker: int)

iterator a1(ticker: int) {.closure.} =
  echo "a1: A"
  yield
  echo "a1: B"
  yield
  echo "a1: C"
  yield
  echo "a1: D"

iterator a2(ticker: int) {.closure.} =
  echo "a2: A"
  yield
  echo "a2: B"
  yield
  echo "a2: C"

proc runTasks(t: varargs[Task]) =
  var ticker = 0
  while true:
    let x = t[ticker mod t.len]
    if finished(x): break
    x(ticker)
    inc ticker

runTasks(a1, a2)

可以使用内置的 system.finished 判断迭代器是否结束;如果迭代器已经结束,再次调用也不会抛出异常。

请注意 system.finished 容易用错,因为它只在迭代器最后一次循环完成后的下一次迭代才返回 true:

iterator mycount(a, b: int): int {.closure.} =
  var x = a
  while x <= b:
    yield x
    inc x

var c = mycount # 实例化迭代器
while not finished(c):
  echo c(1, 3)

# 输出
1
2
3
0

所以这段代码应该这么写:

var c = mycount # 实现化迭代器
while true:
  let value = c(1, 3)
  if finished(c): break # 丢弃这次的返回值!
  echo value

为了便于理解,可以这样认为,迭代器实际上返回了键值对 (value, done),而 finished 的作用就是访问隐藏的 done 字段。

闭包迭代器是 可恢复函数 ,因此每次调用必须提供参数。如果需要绕过这个限制,可以通过工厂过程构造闭包迭代器,并在构造的时候捕获参数:

proc mycount(a, b: int): iterator (): int =
  result = iterator (): int =
    var x = a
    while x <= b:
      yield x
      inc x

let foo = mycount(1, 4)

for f in foo():
  echo f

借助 for 循环宏可以把这个函数调用变得像是在使用内联迭代器:

import std/macros
macro toItr(x: ForLoopStmt): untyped =
  let expr = x[0]
  let call = x[1][1] # 把 foo 拿从 toItr(foo) 里出来
  let body = x[2]
  result = quote do:
    block:
      let itr = `call`
      for `expr` in itr():
          `body`

for f in toItr(mycount(1, 4)): # 使用上文的 `proc mycount`
  echo f

因为闭包迭代器需要以完整的函数调用机制作为支撑,所以代价比调用内联迭代器更高。 像这样在使用闭包迭代器的地方用宏装饰一下,或许是一种有益的提醒。

工厂过程 proc 同普通的过程一样也可以递归。利用上面的宏可让这种过程的递归看起来像是递归迭代器在递归。比如:

proc recCountDown(n: int): iterator(): int =
  result = iterator(): int =
    if n > 0:
      yield n
      for e in toItr(recCountDown(n - 1)):
        yield e

for i in toItr(recCountDown(6)): # 输出: 6 5 4 3 2 1
  echo i

另请参阅iterable将迭代器传递给模板和宏。

转换器

转换器和普通过程相似,但它增强了"隐式转换"类型的关系,参阅转换关系:

# 前方代码风格不好: Nim 不是 C。
converter toBool(x: int): bool = x != 0

if 4:
  echo "compiles"

开发者可以显式调用转换器以提高代码的可读性。 请注意编译不支持隐式转换器的链式调用: 假设存在 A 类型到 B 类型和 B 类型到 C 类型的转换器,Nim 不提供从 A 转换为 C 类型的隐式转换。

Type 段

例子:

type # 展示相互递归类型的例子
  Node = ref object  # 由垃圾收集器管理的对象(ref)
    le, ri: Node     # 左子树和右子树
    sym: ref Sym     # 叶子包含对 Sym 的引用
  
  Sym = object       # 符号
    name: string     # 符号的名称
    line: int        # 符号声明的行数
    code: Node       # 符号的抽象语法树

类型段由 type 关键字开启。它包含多个类型定义。类型定义是给类型绑定一个名称。 类型定义可以是递归的甚至是相互递归的。相互递归类型只能在同一个 type 段中出现。 像 objects 或者 enums 这样的名义类型仅能在 type 段中定义。

异常处理

Try 语句

例如:

# 读取包含数字的文本文件的前两行,并尝试将它们相加
var
  f: File
if open(f, "numbers.txt"):
  try:
    var a = readLine(f)
    var b = readLine(f)
    echo "sum: " & $(parseInt(a) + parseInt(b))
  except OverflowDefect:
    echo "overflow!"
  except ValueError, IOError:
    echo "catch multiple exceptions!"
  except CatchableError:
    echo "Catchable exception!"
  finally:
    close(f)

try 之后的语句顺序执行,直到有异常 e 抛出。如果 e 的异常类型能够匹配 except 子句列出的异常类型,则执行对应的代码。 except 子句之后的代码被称为 exception handlers "异常处理程序"。

如果存在 finally 子句,那么 finally:idx 子句总会在异常处理程序之后得以执行。

异常处理程序会 吃掉 异常。然而异常处理程序也可能抛出新的异常。如果没有处理这个异常,则会通过调用栈传递出去。 这种情况往往意味着,所在过程剩下的那些不属于 finally 子句的代码不被执行。

Try 表达式

try 也可以用作表达式;try 分支的类型与 except 分支相兼容,而 finally 分支的类型必须是 void:

from std/strutils import parseInt

let x = try: parseInt("133a")
        except ValueError: -1
        finally: echo "hi"

为了防止写出令人迷惑的代码,解析时做了限制: 如果 try 语句在 ( 之后,则必须写成一行:

from std/strutils import parseInt
let x = (try: parseInt("133a") except ValueError: -1)

Except 子句

except 子句中,可使用下面的语法访问当前抛出的异常:

try:
  # ...
except IOError as e:
  # 现在可以使用 "e"
  echo "I/O error: " & e.msg

或者使用 getCurrentException 获取当前抛出的异常。

try:
  # ...
except IOError:
  let e = getCurrentException()
  # 现在可以使用 "e"

注意,getCurrentException 总是返回 ref Exception 类型。如果需要使用具体类型(比如上面例子中的 IOError)的变量,则需要显式转换:

try:
  # ...
except IOError:
  let e = (ref IOError)(getCurrentException())
  # 现在 "e" 是具体的异常类型了

但是这种需求很少见。最常见的使用场景是从 e 中提取错误信息,使用 getCurrentExceptionMsg 已经足够了:

try:
  # ...
except CatchableError:
  echo getCurrentExceptionMsg()

自定义异常

可以创建自定义异常。自定义异常是一种自定义类型:

type
  LoadError* = object of Exception

自定义异常的名称建议以 Error 结尾。

自定义异常可以像其他异常一样抛出,例如:

raise newException(LoadError, "Failed to load data")

Defer 语句

使用 defer 语句代替 try finally 语句可以避免代码的复杂嵌套,从作用域的角度看也更加灵活。下面给了例子。

defer 之后的任意语句,都认为处在当前块的隐式 try 块中:

proc main =
  var f = open("numbers.txt", fmWrite)
  defer: close(f)
  f.write "abc"
  f.write "def"

重写为:

proc main =
  var f = open("numbers.txt")
  try:
    f.write "abc"
    f.write "def"
  finally:
    close(f)

defer 位于模板/宏的最外层作用域时,它的作用域将延伸到调用模板/宏的那个代码块中:

template safeOpenDefer(f, path) =
  var f = open(path, fmWrite)
  defer: close(f)

template safeOpenFinally(f, path, body) =
  var f = open(path, fmWrite)
  try: body # 若不使用 `defer` ,`body` 必须指定为参数
  finally: close(f)

block:
  safeOpenDefer(f, "/tmp/z01.txt")
  f.write "abc"
block:
  safeOpenFinally(f, "/tmp/z01.txt"):
    f.write "abc" # 增加一级词法作用域
block:
  var f = open("/tmp/z01.txt", fmWrite)
  try:
    f.write "abc" # 增加一级词法作用域
  finally: close(f)

Nim 不允许在最顶层使用 defer 语句,因为不确定这样的语句涉及哪些内容。

Raise 语句

例子:

raise newException(IOError, "IO 失败")

除了数组索引,内存分配等内置操作之外, raise 语句是抛出异常的唯一方法。

如果没有给出异常的名称,则 re-raised "重新抛出" 当前异常。 如果当前没有异常可以重新抛出,则会抛出 ReraiseDefect 异常。这遵循 raise 语句 总是 抛出异常的规则。

异常的层级

异常树被定义在system模块中。每个异常都继承自 system.Exception 。 表示程序错误的异常继承自 system.Defect (它是Exception的子类型),因为它们可以被映射到终止整个进程的操作中,因此将不能捕捉。 如果恐慌变为异常,则这些异常继承自 Defect

表示可捕获的其它运行时错误的异常从 system.CatchableError(它是 Exception 的子类) 继承。

Exception
|-- CatchableError
|   |-- IOError
|   |   `-- EOFError
|   |-- OSError
|   |-- ResourceExhaustedError
|   `-- ValueError
|       `-- KeyError
`-- Defect
    |-- AccessViolationDefect
    |-- ArithmeticDefect
    |   |-- DivByZeroDefect
    |   `-- OverflowDefect
    |-- AssertionDefect
    |-- DeadThreadDefect
    |-- FieldDefect
    |-- FloatingPointDefect
    |   |-- FloatDivByZeroDefect
    |   |-- FloatInvalidOpDefect
    |   |-- FloatOverflowDefect
    |   |-- FloatUnderflowDefect
    |   `-- InexactDefect
    |-- IndexDefect
    |-- NilAccessDefect
    |-- ObjectAssignmentDefect
    |-- ObjectConversionDefect
    |-- OutOfMemoryDefect
    |-- RangeDefect
    |-- ReraiseDefect
    `-- StackOverflowDefect

导入的异常

导入的 C++ 异常也可以抛出和捕获。使用 importcpp 导入的类型可以抛出和捕获。异常通过值抛出,通过引用捕获。 例子如下:

type
  CStdException {.importcpp: "std::exception", header: "<exception>", inheritable.} = object
    ## 异常不继承自 `RootObj`, 所以我们使用 `inheritable` 关键字
  CRuntimeError {.requiresInit, importcpp: "std::runtime_error", header: "<stdexcept>".} = object of CStdException
    ## `CRuntimeError` 没有默认构造器 => `requiresInit`
proc what(s: CStdException): cstring {.importcpp: "((char *)#.what())".}
proc initRuntimeError(a: cstring): CRuntimeError {.importcpp: "std::runtime_error(@)", constructor.}
proc initStdException(): CStdException {.importcpp: "std::exception()", constructor.}

proc fn() =
  let a = initRuntimeError("foo")
  doAssert $a.what == "foo"
  var b: cstring
  try: raise initRuntimeError("foo2")
  except CStdException as e:
    doAssert e is CStdException
    b = e.what()
  doAssert $b == "foo2"
  
  try: raise initStdException()
  except CStdException: discard
  
  try: raise initRuntimeError("foo3")
  except CRuntimeError as e:
    b = e.what()
  except CStdException:
    doAssert false
  doAssert $b == "foo3"

fn()

注意 getCurrentException()getCurrentExceptionMsg() 不能用于从 C++ 导入的异常。 开发者需要使用 except ImportedException as x: 语句并且依靠对象 x 本身的功能获取异常的具体信息。

Effect 系统

注意: Nim编译器 1.6 版本的发布改变了效果追踪的规则。

异常追踪

Nim 支持异常追踪。 raises 编译指示可以显式定义过程/迭代器/方法/转换器所允许抛出的异常。编译期会加以验证:

proc p(what: bool) {.raises: [IOError, OSError].} =
  if what: raise newException(IOError, "IO")
  else: raise newException(OSError, "OS")

空的 raises 列表(raises: [])表示不允许抛出异常:

proc p(): bool {.raises: [].} =
  try:
    unsafeCall()
    result = true
  except CatchableError:
    result = false

raises 列表也可以附加到过程类型上。这会影响类型兼容性:

type
  Callback = proc (s: string) {.raises: [IOError].}
var
  c: Callback

proc p(x: string) =
  raise newException(OSError, "OS")

c = p # type error

对于例程 p 来说,编译器使用推断规则来判断可能引发的异常的集合; 算法在 p 的调用图上运行:

  1. 对过程类型 T 的每个间接调用都假定产生 system.Exception (所有异常的基类),即任意异常都有可能,除非 T 拥有显式的 raises 列表。 不过,如果是以 f(...) 的形式调用并且 f 是当前分析的例程的参数,而且并被标记 .effectsOf: f,那么忽略它。 乐观地假定这类调用没有 effect。 第二条规则对这种情况有所补充。
  2. 当某过程类型的表达式 e 是作为过程 p 的标记为 .effectsOf 的参数传入的,对 e 的调用会被视为间接调用,它的 raises 列表会加入到 praises 列表。
  3. 所有对方法体未知(因为声明前置)的过程 q 的调用都会被看作抛出 system.Exception 除非 q 显式定义了 raises 列表。 importc 导入的过程,若没有显式声明 raises 列表,则默认视为 .raises: []
  4. 方法 m 每一次调用都假定会抛出 system.Exception,除非显式声明了 raises 列表。
  5. 对于其他的调用,Nim 可以分析推断出确切的 raises 列表。
  6. 推断 praises 列表时,Nim 会考虑它里面的 raisetry 语句。

.raises: [] 异常追踪机制不追踪继承自 system.Defect 的异常。这样更能跟内置运算符保持一致。 下面的代码是合法的:

proc mydiv(a, b): int {.raises: [].} =
  a div b # 会抛出 DivByZeroDefect 异常

同理,下面的代码也是合法的:

proc mydiv(a, b): int {.raises: [].} =
  if b == 0: raise newException(DivByZeroDefect, "除数为 0")
  else: result = a div b

这是因为 DivByZeroDefect 继承自 Defect,再加上 --panics:on 选项 Defect 异常就变成了不可修复性错误。(自从 Nim 1.4 开始)

EffectsOf 编译指示

异常追踪推断规则(见之前的小节)的第一条与第二条确保可以获得下面的预期效果:

proc 我们不抛异常但是回调可能抛(callback: proc()) {.raises: [], effectsOf: callback.} =
  callback()

proc 抛异常() {.raises: [IOError].} =
  raise newException(IOError, "IO")

proc use() {.raises: [].} =
  # 编译失败! 会抛出 IOError 错误!
  我们不抛异常但是回调可能抛(抛异常)

如这个例子所示, proc (...) 类型的参数可以标记为 .effectsOf 。这样的参数带来了 effect 多态: 过程 我们不抛异常但是回调可能抛 可以抛出 callback 所抛出的异常。

所以在很多情况下,回调并不会导致编译器在 effect 分析中过于保守:

{.push warningAsError[Effect]: on.}

import algorithm

type
  MyInt = distinct int

var toSort = @[MyInt 1, MyInt 2, MyInt 3]

proc cmpN(a, b: MyInt): int =
  cmp(a.int, b.int)

proc harmless {.raises: [].} =
  toSort.sort cmpN

proc cmpE(a, b: MyInt): int {.raises: [Exception].} =
  cmp(a.int, b.int)

proc harmful {.raises: [].} =
  # 无法编译, `sort` 当前将引发异常
  toSort.sort cmpE

标签追踪

异常追踪是 effect system "Effect 系统"的一部分。抛出异常是一个 effect 。当然可以定义其他 effect 。自定义 effect 是一种给例程打 标签 并做检查的方法:

type IO = object ## 输入/输出 effect
proc readLine(): string {.tags: [IO].} = discard

proc no_effects_please() {.tags: [].} =
  # 编译器禁止这么做:
  let x = readLine()

标签必须是类型名称。同 raises 列表一样,tags 列表也可以附加到过程类型上。这会影响类型的兼容性。

标签追踪的推断规则与异常追踪的推断规则类型类似。

有一种禁止某些 effect 出现的方法:

type IO = object ## input/output effect
proc readLine(): string {.tags: [IO].} = discard
proc echoLine(): void = discard

proc no_IO_please() {.forbids: [IO].} =
  # 这是可以的,因为它没有定义任何标签:
  echoLine()
  # 编译器会阻止这种情况:
  let y = readLine()

forbids 编译指示定义了一个被禁止的 effect 的列表 —— 如果任何语句具有这些 effect,则编译会失败。 带有 effect 禁止列表的过程类型是不带这种列表的过程类型的子类型:

type MyEffect = object
type ProcType1 = proc (i: int): void {.forbids: [MyEffect].}
type ProcType2 = proc (i: int): void

proc caller1(p: ProcType1): void = p(1)
proc caller2(p: ProcType2): void = p(1)

proc effectful(i: int): void {.tags: [MyEffect].} = echo $i
proc effectless(i: int): void {.forbids: [MyEffect].} = echo $i

proc toBeCalled1(i: int): void = effectful(i)
proc toBeCalled2(i: int): void = effectless(i)

## 这将会失败,因为toBeCalled1使用了ProcType1所禁止的MyEffect:
caller1(toBeCalled1)
## 这是可以的,因为toBeCalled2和ProcType1有相同的限制:
caller1(toBeCalled2)
## 这些都是可以的,因为ProcType2没有副作用限制:
caller2(toBeCalled1)
caller2(toBeCalled2)

ProcType2ProcType1 的子类型。与 tags 编译指示所不同的是,父上下文将:调用具有禁用副作用的其他函数的函数;不继承禁用副作用列表。

副作用

noSideEffect 编译指示用于标记过程和迭代器,说明它们只能通过参数产生副作用。这意味着这个过程或迭代器只能修改参数所涉及的地址,而且返回值只依赖于参数。假如该过程或迭代器的参数中都不是 varrefptrcstringproc 类型,则不会修改外部内容。

换句话说,如果一个例程既不访问本地线程变量或全局变量,也不调用其他带副作用的例程,则该例程是无副作用的。

如果给予一个过程或迭代器无副作用标记,而编译器却无法验证,将引发静态错误。

作为一个特殊的语义规则,内置的debugEcho忽略副作用,这样它就可以用于调试标记为 noSideEffect 的例程。

func 是无副作用过程的语法糖:

func `+` (x, y: int): int

{.cast(noSideEffect).} 编译指示可覆盖编译器的副作用分析:

func f() =
  {.cast(noSideEffect).}:
    echo "test"

副作用通常可被推断出来,与异常追踪的推断类似。

GC 安全的作用

当过程 p 不访问任何使用了 GC 内存的全局变量( string seq ref 或一个闭包)时 —— 无论是直接访问还是通过调用不是 GC 安全的过程进行间接访问 —— 我们就称 pGC safe "GC 安全" 的。

是否 GC 安全通常可被推断出来,与异常追踪的推断类似。

gcsafe 注解可把过程标记为 GC 安全的,否则将由编译器推断是否是 GC 安全的。值得注意的是, noSideEffect 暗含着 gcsafe

从 C 语言库导入的例程将总是被看作 gcsafe

{.cast(gcsafe).} 编译指示块可覆盖编译器的 GC 安全分析:

var
  someGlobal: string = "some string here"
  perThread {.threadvar.}: string

proc setPerThread() =
  {.cast(gcsafe).}:
    deepCopy(perThread, someGlobal)

参阅:

Effects 编译指示

effects 编译指示用于协助程序员进行作用分析。这条语句可以使编译器输出直到 effects 处所有推断出的作用:

proc p(what: bool) =
  if what:
    raise newException(IOError, "IO")
    {.effects.}
  else:
    raise newException(OSError, "OS")

编译器输出一条消息,提示可能抛出 IOErrorOSError 不会出现在提示里,因为 effects 编译指示所在的分支不会抛出这个异常。

泛型

泛型是 Nim 通过 type parameters "类型参数" 把过程、迭代器或类型参数化的方法。在不同的上下文里,用方括号引入类型参数,或者实例化泛型过程、迭代器及类型。

以下例子展示了如何构建一个泛型二叉树:

type
  BinaryTree*[T] = ref object # 二叉树是具有
                              # 泛型参数 `T` 的泛型类型。
    le, ri: BinaryTree[T]     # 左右子树;可能是nil
    data: T                   # 存储在节点中的数据

proc newNode*[T](data: T): BinaryTree[T] =
  # 节点的构造函数
  result = BinaryTree[T](le: nil, ri: nil, data: data)

proc add*[T](root: var BinaryTree[T], n: BinaryTree[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](root: var BinaryTree[T], data: T) =
  # 便捷过程:
  add(root, newNode(data))

iterator preorder*[T](root: BinaryTree[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)  # 将右子树push到堆栈上
      n = n.le          # 并跟踪左子树

var
  root: BinaryTree[string]  # 用 `string` 实例化二叉树
add(root, newNode("hello")) # 实例化 `newNode` 和 `add`
add(root, "world")          # 实例化 `add` 过程
for str in preorder(root):
  stdout.writeLine(str)

这里的 T 称为 generic type parameter "泛型类型参数",或者 type variable "类型变量"。

泛型过程

让我们考虑泛型 proc 的构造,以就定义的术语达成一致。

p[T: t](arg1: f): y

  • p: 被调用方符号
  • [...]: 泛型参数
  • T: t: 泛型约束
  • T: 类型变量
  • [T: t](arg1: f): y: 正式签名
  • arg1: f: 正式参数
  • f: 正式参数类型
  • y: 正式返回类型

这里使用"正式"一词是为了表示程序员定义的符号,而不是编译时上下文中的符号。 由于泛型可以被实例化并且类型绑定,当涉及泛型时,我们需要考虑多个实体。

泛型的使用将正式定义的表达式解析为仅绑定到具体类型的该表达式的实例。这个过程被称为"实例化"。

在泛型的正式定义位置使用的括号指定了"约束",如:

type Foo[T] = object
proc p[H;T: Foo[H]](param: T): H

约束定义可以通过使用 ; 分隔每个定义来定义多个符号。 请注意, T 是由 H 组成的,而 p 的返回类型被定义为 H 。 当此泛型过程被实例化时, H 将绑定到一个具体类型,从而使 T 具体化,并且 p 的返回类型将绑定到用于定义 H 的相同具体类型。

在使用位置的括号中,可以按照约束中定义符号的顺序提供具体类型来实例化泛型。 另外,在某些情况下,编译器可以推断类型绑定,从而使代码更加简洁。

Is 运算符

is 运算符用来在语义分析期间检查类型的等价性。在泛型代码中利用这个运算符编写类型相关的代码:

type
  Table[Key, Value] = object
    keys: seq[Key]
    values: seq[Value]
    when not (Key is string): # 对于字符串类型做优化: 用空值代表已删除
      deletedKeys: seq[bool]

类型类

类型类是特殊的伪类型,可在重载解析或使用 is 运算符时针对性地匹配某些类型。Nim 支持以下内置类型类:

类型匹配
object任意 object 类型
tuple任意 tuple 类型
enum任意 enumeration
proc任意 proc 类型
iterator任意 iterator 类型
ref任意 ref 类型
ptr任意 ptr 类型
var任意 var 类型
distinct任意 distinct 类型
array任意 array 类型
set任意 set 类型
seq任意 seq 类型
auto任意 类型

此外,任何泛型类型都会自动创建一个同名的类型类,可匹配该泛型类的任意实例。

类型类通过标准的布尔运算符可组合成更复杂的类型类。

# 创建一个类型类,可以匹配所有元组和对象类型
type RecordType = (tuple or object)

proc printFields[T: RecordType](rec: T) =
  for key, value in fieldPairs(rec):
    echo key, " = ", value

泛型参数列表中的参数类型约束可以通过 , 进行分组,并以 ; 结束,就像宏和模板中的参数列表那样:

proc fn1[T; U, V: SomeFloat]() = discard    # T 没有类型约束
template fn2(t; u, v: SomeFloat) = discard  # t 没有类型约束

虽然类型类在语法上接近于类 ML 语言中的代数数据类型 (ADT),但应该知道,类型类只是实例化时所必须遵守的静态约束。 类型类本身并非真的类型,只是一种检查系统,检查泛型是否最终被 解析 成某种单一类型。 与对象、变量和方法不同,类型类不允许运行时的类型动态特性。

例如,以下代码无法通过编译:

type TypeClass = int | string
var foo: TypeClass = 2 # foo 的类型在这里被解释为 int 类型
foo = "this will fail" # 这里发生错误,因为 foo 是 int

Nim 允许将类型类和常规类型用作泛型类型参数的 type constraints "类型约束":

proc onlyIntOrString[T: int|string](x, y: T) = discard

onlyIntOrString(450, 616) # 可以
onlyIntOrString(5.0, 0.0) # 类型不匹配
onlyIntOrString("xy", 50) # 不行,因为同一个 T 不能同时是两种不同的类型

prociterator 类型类也接受一个调用约定指示,以限制匹配的 prociterator 类型的调用约定。

proc onlyClosure[T: proc {.closure.}](x: T) = discard

onlyClosure(proc() = echo "hello") # valid
proc foo() {.nimcall.} = discard
onlyClosure(foo) # type mismatch

隐式泛型

一个类型类可以直接作为参数的类型使用。

# 创建一个类型类,可以匹配所有元组和对象类型
type RecordType = (tuple or object)

proc printFields(rec: RecordType) =
  for key, value in fieldPairs(rec):
    echo key, " = ", value

以这种方式使用类型类的过程,被当成是 implicitly generic "隐式泛型"。 在程序中,对于每个特定参数类型组合时被实例化一次。

通常,重载解析期间,每一个被命名的类型类都将被绑定到单一的具体类型。我们称这些类型类为 bind once "单一绑定" 类型。以下是从 system 模块里直接拿来的例子:

proc `==`*(x, y: tuple): bool =
  ## 需要 `x` 和 `y` 都是相同的元组类型
  ## 针对元组的泛型运算符 `==` 建立于 `x` 和 `y` 各字段的相等性之上
  result = true
  for a, b in fields(x, y):
    if a != b: result = false

另一种情况是用 distinct 修饰类型类,这将允许每一参数绑定到匹配类型类的不同类型。这样的类型类被称为 bind many "多绑定" 类型。

使用了隐式泛型的过程,常常需要引用匹配的泛型类型内的类型参数。使用 . 语法能便捷地实现此功能:

type Matrix[T, Rows, Columns] = object
  ...

proc `[]`(m: Matrix, row, col: int): Matrix.T =
  m.data[col * high(Matrix.Columns) + row]

下面是关于隐式泛型更多的例子:

proc p(t: Table; k: Table.Key): Table.Value

# 大致等同于:

proc p[Key, Value](t: Table[Key, Value]; k: Key): Value

proc p(a: Table, b: Table)

# 大致等同于:

proc p[Key, Value](a, b: Table[Key, Value])

proc p(a: Table, b: distinct Table)

# 大致等同于:

proc p[Key, Value, KeyB, ValueB](a: Table[Key, Value], b: Table[KeyB, ValueB])

typedesc 作为参数类型使用时,也会产生隐式泛型,typedesc 有其独有的规则:

proc p(a: typedesc)

# 等同于以下写法:

proc p[T](a: typedesc[T])

typedesc 是一个 "多绑定" 类型类:

proc p(a, b: typedesc)

# 大致等同于:

proc p[T, T2](a: typedesc[T], b: typedesc[T2])

typedesc 类型的参数本身可以作为一个类型使用。如果其作为类型使用,就是底层类型。换言来说,"typedesc" 剥离了一层。

proc p(a: typedesc; b: a) = discard

# 大致等同于:
proc p[T](a: typedesc[T]; b: T) = discard

# 所以这是合法的:
p(int, 4)
# 这里参数 'a' 需要的是一个类型, 而 'b' 需要的则是一个值。

泛型推断的局限

泛型实例化时不会推断出 var Ttypedesc[T]。下面的例子是不允许的:

proc g[T](f: proc(x: T); x: T) =
  f(x)

proc c(y: int) = echo y
proc v(y: var int) =
  y += 100
var i: int

# 允许: 'T' 被推断为 'int' 类型
g(c, 42)

# 不允许: 'T' 不会被推断为 'var int'
g(v, i)

# 也不允许: 明确地通过 'var int' 实例化
g[var int](v, i)

泛型中的符号查找

开放和封闭符号

泛型中的符号绑定规则略显微妙: 存在开放和封闭两种符号。 封闭的符号在实例化的上下文中无法被重新绑定,而开放的符号则可以。 默认情况下,重载符号都是开放的,所有其他符号都是封闭的。

会在两种不同的上下文中查找开放的符号: 一是其定义所处的上下文,二是实例化时的上下文:

type
  Index = distinct int

proc `==` (a, b: Index): bool {.borrow.}

var a = (0, 0.Index)
var b = (0, 0.Index)

echo a == b # 可以!

在这个例子中,针对元组的泛型 == (定义于 system 模块) 建立在元组各字段的 == 运算之上。 然而,针对 Index 类型的 == 定义发生泛型 == 定义 之后; 这个例子可以编译,因为实例化关于元组的 == 时,当前定义的关于 Index== 也会考虑进来。

Mixin 语句

符号通过 mixin 关键字可以声明为开放的:

proc create*[T](): ref T =
  # 这里没有 'init' 的重载,我们需要显式的将其声明为一个开放的符号:
  mixin init
  new result
  init result

mixin 语句只在模板和泛型中才有意义。

Bind 语句

bind 语句是 mixin 语句的反面。可用于显式地声明标识符需要更早绑定(也就是说应在模板/泛型的定义作用域中查找这些标识符)。

# 模块 A
var
  lastId = 0

template genId* : untyped =
  bind lastId
  inc(lastId)
  lastId

# 模块 B
import A

echo genId()

但是 bind 用处不大,因为默认就是从定义作用域绑定符号。

bind 语句只在模板和泛型中有意义。

委托绑定语句

下面的示例概述了当泛型的实例化跨越多个不同模块时会出现的一个问题:

# 模块 A
proc genericA* [T](x: T) =
  mixin init
  init(x)

import C

# 模块 B
proc genericB*[T](x: T) =
      # 实例化 `genericB` 时,如果没有 `bind init` 语句,来自模块 C 的 init 过程就是不可用的:
  bind init
  genericA(x)

# 模块 C
type O = object
proc init* (x: var O) = discard

# 主模块
import B, C

genericB O()

当由实例化 genericB 引发实例化 genericA 时,模块 B 的作用域中那个来自模块 C 的 init 过程未在考虑之中。 解决方案是在 genericB 中通过 bind 语句 forward "转发" 这个符号。

模板

模板是简单形式的宏: 它是运行于 Nim 的抽象语法树的简单替换机制。编译器在语义分析阶段处理它。

调用 模板的语法和调用过程的语法是相同的。

Example:

template `!=` (a, b: untyped): untyped =
  # 这个定义存在于系统模块中
  not (a == b)

assert(5 != 6) # 编译器将其重写为: assert(not (5 == 6))

!=, >, >=, in, notin, isnot 等运算符实际上都是模板:

a > b 转换为 b < a
a in b 转换为 contains(b, a)
notinisnot 的转换也显而易见。

模板中的 "类型" 可以使用 untypedtypedtypedesc 等三个符号。 这些都是 "元类型" ,它们仅用于特定上下文中。常规类型也可使用;这意味着会得到一个 typed 表达式。

Typed 参数和 untyped 参数的比较

untyped 参数表示表达式传递给模板前不执行符号的查找和类型的解析。这意味着,比如,未声明 的标识符也能传递给模板:

template declareInt(x: untyped) =
  var x: int

declareInt(x) # 可以
x = 3

template declareInt(x: typed) =
  var x: int

declareInt(x) # 不正确,因为此处 x 没有被声明,所以它没有类型

如果一个模板的每个参数都是 untyped 的,则称它为 immediate 模板。 由于历史原因,模板可以用 immediate 编译指示显式地标记,这类模板不参与重载解析,参数的类型也将被编译器 忽略。显式声明的即时模板现在已经弃用。

注意: 由于历史原因, stmttyped 的别名, expruntyped 的别名,但它们都被移除了。

传递代码块到模板

通过专门的 : 语法,可以将一个语句块传递给模板的最后一个参数:

template withFile(f, fn, mode, actions: untyped): untyped =
  var f: File
  if open(f, fn, mode):
    try:
      actions
    finally:
      close(f)
  else:
    quit("cannot open: " & fn)

withFile(txt, "ttempl3.txt", fmWrite):  # 专门的冒号
  txt.writeLine("line 1")
  txt.writeLine("line 2")

在这个例子中,那两行 writeLine 语句被绑定到了模板的 actions 参数。

通常,当传递一个代码块到模板时,接受代码块的参数需要被声明为 untyped 类型。因为这样,符号查找会被推迟到模板实例化期间:

template t(body: typed) =
  proc p = echo "hey"
  block:
    body

t:
  p()  # 因 p 未声明而失败

以上代码错误信息为 p 未被声明。其原因是 p() 语句在传递到 body 参数前执行类型检查和符号查找。 修改模板参数类型为 untyped 使得传递语句体时不做类型检查,同样的代码便可以通过:

template t(body: untyped) =
  proc p = echo "hey"
  block:
    body

t:
  p()  # 编译通过

untyped 可变参数

除了 untyped 元类型可以阻止类型检查之外,用了 varargs[untyped] 连参数的个数也不检查的了:

template hideIdentifiers(x: varargs[untyped]) = discard

hideIdentifiers(undeclared1, undeclared2)

然而,因为模板不能遍历可变参数,一般而言这个功能在宏中更有用。

模板中的符号绑定

模板是 hygienic "洁净"宏,会新开作用域。大部分符号会在宏的定义作用域中绑定:

# 模块 A
var
  lastId = 0

template genId* : untyped =
  inc(lastId)
  lastId

# 模块 B
import A

echo genId() # 可以,因为 'lastId' 在 'genId' 的定义作用域中完成绑定

像在泛型中一样,模板中的符号绑定也受 mixinbind 语句影响。

标识符的构建

在模板中,标识符可以通过反引号标注构建:

template typedef(name: untyped, typ: typedesc) =
  type
    `T name`* {.inject.} = typ
    `P name`* {.inject.} = ref `T name`

typedef(myint, int)
var x: PMyInt

在这个例子中, name 参数实例化为 myint,所以 `T name` 就变为 Tmyint

模板参数的查找规则

模板中的参数 p 总是会被替换,即使是像 x.p 这样的表达式。因此,模板参数可当作字段名称使用,而且一个全局符号会被同名参数所覆盖,即便使用了完全限定也会覆盖:

# 模块 'm'

type
  Lev = enum
    levA, levB

var abclev = levB

template tstLev(abclev: Lev) =
  echo abclev, " ", m.abclev

tstLev(levA)
# 输出: 'levA levA'

但是全局符号可以通过 bind 语句适时捕获:

# 模块 'm'

type
  Lev = enum
    levA, levB

var abclev = levB

template tstLev(abclev: Lev) =
  bind m.abclev
  echo abclev, " ", m.abclev

tstLev(levA)
# 输出: 'levA levB'

模板的洁净性

默认情况下,模板是 hygienic "洁净"的: 模板内局部声明的标识符,不能在实例化上下文中访问:

template newException* (exceptn: typedesc, message: string): untyped =
  var
    e: ref exceptn  # e 在这里被隐式地 gensym
  new(e)
  e.msg = message
  e

# 允许这样写:
let e = "message"
raise newException(IoError, e)

模板中声明的符号是否向实例所处作用域公开取决于 injectgensym 编译指示。被 gensym 编译指示标记的符号不会公开,而 inject 编译指示则反之。

type , var, letconst 等实体符号默认是 gensymprociteratorconvertertemplatemacro 等默认是 inject。 然而,如果实体的名称是由模板参数传入的,那么会标记为 inject

template withFile(f, fn, mode: untyped, actions: untyped): untyped =
  block:
    var f: File  # 由于 'f' 是一个模板参数,其被隐式注入
    ...

withFile(txt, "ttempl3.txt", fmWrite):
  txt.writeLine("line 1")
  txt.writeLine("line 2")

injectgensym 编译指示是二类注解;它们在模板定义之外没有语义,也不能被再次封装。

{.pragma myInject: inject.}

template t() =
  var x {.myInject.}: int # 无法工作

如果不想保持模板的洁净性,我们可以在模板中使用 dirty 编译指示。injectgensymdirty 模板中没有作用。

标记为 gensym 的符号既不能作为 field 用在 x.field 语义中,也不能用于 ObjectConstruction(field: value)namedParameterCall(field = value) 等语义构造。

其原因在于要让以下代码:

type
  T = object
    f: int

template tmp(x: T) =
  let f = 34
  echo x.f, T(f: 4)

按预期执行。

但是这意味着 gensym 生成的符号无法用于方法调用语法:

template tmp(x) =
  type
    T {.gensym.} = int
  
  echo x.T # 不可以,应该使用: 'echo T(x)' 。

tmp(12)

方法调用语法的局限

x.f 里的表达式 x 需要先经过语义检查(意味着符号查找和类型检查),然后才能决定是否需要重写成 f(x) 的形式。因此,当用于调用模板或宏时,. 语法有一些局限:

template declareVar(name: untyped) =
  const name {.inject.} = 45

# 无法编译:
unknownIdentifier.declareVar

在方法调用语义中,无法使用带有模块符号的完全限定标识符,这是 . 运算符的绑定顺序决定的。

import std/sequtils

var myItems = @[1,3,3,7]
let N1 = count(myItems, 3) # 可行
let N2 = sequtils.count(myItems, 3) # 完全限定, 此处可行
let N3 = myItems.count(3) # 可行
let N4 = myItems.sequtils.count(3) # 非法的, `myItems.sequtils` 无法解析

这就是说,当由于某种原因,一个过程需要借助模块名消除歧义时,这个调用就需要使用函数调用的语法来书写。

宏是一种在编译时运行的特殊函数。通常,宏的输入是所传入的代码的抽象语法树(AST)。 宏然后可以对其执行转换,并将转换后的 AST 结果返回。 可以用来添加自定义的语言功能,实现 domain-specific languages "领域特定语言"。

宏的调用是一种特殊情况,语义分析并完全是自顶向下、从左到右的。相反,语义分析至少发生两次:

  • 语义分析识别并解析宏调用。
  • 编译器执行宏正文(可能会调用其他过程)。
  • 将宏调用的 AST 替换为宏返回的 AST。
  • 再次对该区域的代码进行语义分析。
  • 如果宏返回的 AST 包含其他宏调用,则迭代执行。

虽然宏支持高级的编译时代码转换,但无法更改 Nim 的语法。

风格说明: 为了代码的可读性,最好选用最弱的但又能满足需要的编程结构。建议如下:

  1. 尽可能使用常规的过程和迭代器。
  2. 其次尽可能使用泛型过程和迭代器。
  3. 再次尽可能使用模板。
  4. 最后才考虑使用宏。

debug 示例

下面的例子实现了一个接受可变参数的 debug 命令,功能强大:

# 导入 `macros` 模块以获得操作 Nim 语法树所需要的 API
import std/macros

macro debug(args: varargs[untyped]): untyped =
  # `args` 是一个 `NimNode` 值列表,每个值对应一个传入参数的 AST
  # 宏总是需要返回一个 `NimNode`,本例子返回的是 `nnkStmtList` 节点
  result = nnkStmtList.newTree()
  # 遍历传入这个宏传递的所有参数:
  for n in args:
    # 为语句列表添加 write 调用;
    # `toStrLit` 将 AST 转换为字符串形式:
    result.add newCall("write", newIdentNode("stdout"), newLit(n.repr))
    # 为语句列表添加 write 调用,输出 ": "
    result.add newCall("write", newIdentNode("stdout"), newLit(": "))
    # 为语句列表添加 writeLine 调用,输出值并换行:
    result.add newCall("writeLine", newIdentNode("stdout"), n)

var
  a: array[0..10, int]
  x = "some string"
a[0] = 42
a[1] = 45

debug(a[0], a[1], x)

这个宏展开为以下代码:

write(stdout, "a[0]")
write(stdout, ": ")
writeLine(stdout, a[0])

write(stdout, "a[1]")
write(stdout, ": ")
writeLine(stdout, a[1])

write(stdout, "x")
write(stdout, ": ")
writeLine(stdout, x)

传递给 varargs 的各参数被包装到数组构造函数表达式中。这就是 debug 能遍历所有 args 子节点的原因。

bindSym

上面的 debug 宏依赖于这样一个事实,writewriteLinestdout 是在 system 模块中声明的,所以在实例化时的上下文里是可见的。有一种使用绑定标识符 (即 symbols) 代替未绑定的标识符的方法,这用到了内置的 bindSym:

import std/macros

macro debug(n: varargs[typed]): untyped =
  result = newNimNode(nnkStmtList, n)
  for x in n:
    # 我们通过 'bindSym' 在作用域中绑定符号:
    add(result, newCall(bindSym"write", bindSym"stdout", toStrLit(x)))
    add(result, newCall(bindSym"write", bindSym"stdout", newStrLitNode(": ")))
    add(result, newCall(bindSym"writeLine", bindSym"stdout", x))

var
  a: array[0..10, int]
  x = "some string"
a[0] = 42
a[1] = 45

debug(a[0], a[1], x)

这个宏展开为以下代码:

write(stdout, "a[0]")
write(stdout, ": ")
writeLine(stdout, a[0])

write(stdout, "a[1]")
write(stdout, ": ")
writeLine(stdout, a[1])

write(stdout, "x")
write(stdout, ": ")
writeLine(stdout, x)

在这个版本的 debug 中,标识符 write ` `writeLinestdout 已经绑定,不会重复查找。 如示例所示, bindSym 确切可以隐式处理重载标识符。

注意,传递给 bindSym 的标识符名称必须是常量。实验性功能 dynamicBindSym (实验手册)允许动态地计算这个值。

语句后的代码块

当以语句形式调用宏时,宏可以接受 ofelifelseexceptfinallydo 代码块 (包括诸如带有例程参数的 do 等其它形式)。

macro performWithUndo(task, undo: untyped) = ...

performWithUndo do:
  # 若干行用来执行
  # 任务的代码
do:
  # 用来撤消操作的代码

let num = 12
# 如果没有初始代码块,可只使用一个冒号
match (num mod 3, num mod 5):
of (0, 0):
  echo "FizzBuzz"
of (0, _):
  echo "Fizz"
of (_, 0):
  echo "Buzz"
else:
  echo num

For 循环宏

当宏只有一个输入参数,而且这个参数的类型是特殊的 system.ForLoopStmt 时, 这个宏可以重写整个 for 循环:

import std/macros

macro example(loop: ForLoopStmt) =
  result = newTree(nnkForStmt)    # 创建一个新的 For 循环。
  result.add loop[^3]             # 这是 "item" 。
  result.add loop[^2][^1]         # 这是 "[1, 2, 3]" 。
  result.add newCall(bindSym"echo", loop[0])

for item in example([1, 2, 3]): discard

展开为:

for item in items([1, 2, 3]):
  echo item

再举一个例子:

import std/macros

macro enumerate(x: ForLoopStmt): untyped =
  expectKind x, nnkForStmt
  # 检查是否指定了计数的起始值
  var countStart = if x[^2].len == 2: newLit(0) else: x[^2][1]
  result = newStmtList()
  # 我们把第一个 for 循环变量修改为整数计数器:
  result.add newVarStmt(x[0], countStart)
  var body = x[^1]
  if body.kind != nnkStmtList:
    body = newTree(nnkStmtList, body)
  body.add newCall(bindSym"inc", x[0])
  var newFor = newTree(nnkForStmt)
  for i in 1..x.len-3:
    newFor.add x[i]
  # 将 enumerate(X) 转换为 'X'
  newFor.add x[^2][^1]
  newFor.add body
  result.add newFor
  # 现在将整个宏包装到代码块里从而创建一个新的作用域
  result = quote do:
    block: `result`

for a, b in enumerate(items([1, 2, 3])):
  echo a, " ", b

# 如果不将宏包装到代码块里,我们就需要为这里的 `a` 和 `b` 选择不同的名称
# 以免犯重复定义的错误。
for a, b in enumerate(10, [1, 2, 3, 5]):
  echo a, " ", b

Case 语句宏

名为 `` case `` 的宏能够为特定类型实现 case 语句。 下面的例子借助元组已有的相等运算符(由 system.== 提供)为它们实现了 case 语句。

import std/macros

macro `case`(n: tuple): untyped =
  result = newTree(nnkIfStmt)
  let selector = n[0]
  for i in 1 ..< n.len:
    let it = n[i]
    case it.kind
    of nnkElse, nnkElifBranch, nnkElifExpr, nnkElseExpr:
      result.add it
    of nnkOfBranch:
      for j in 0..it.len-2:
        let cond = newCall("==", selector, it[j])
        result.add newTree(nnkElifBranch, cond, it[^1])
    else:
      error "自定义的元组 'case' 无法处理这个节点", it

case ("foo", 78)
of ("foo", 78): echo "yes"
of ("bar", 88): echo "no"
else: discard

重载解析会处理 case 宏: case 宏的第一个参数的类型用来匹配 case 语句选择器表达式的类型。 然后整个 case 语句被填入这个参数并对宏求值。

换句话说,这种宏需要转换整个 case 语句,但是决定调用哪个宏的仅是语句的选择器表达式。

特殊类型

static[T]

如名称所示,静态参数必须是常数表达式:

proc precompiledRegex(pattern: static string): RegEx =
  var res {.global.} = re(pattern)
  return res

precompiledRegex("/d+") # 这个调用被替换成一个预编译的、
                        # 存储在全局变量里的正则表达式

precompiledRegex(paramStr(1)) # 错误,命令行选项不是常数表达式

出于代码生成的目的,所有静态参数都被视为泛型参数,即过程将为每个特定值提供(或值的组合)单独编译。

静态参数也可以出现在泛型类型签名中:

type
  Matrix[M,N: static int; T: Number] = array[0..(M*N - 1), T]
    # 注意这里的 `Number` 只是一个类型约束,而
    # `static int` 则要求我们提供一个整数值
  
  AffineTransform2D[T] = Matrix[3, 3, T]
  AffineTransform3D[T] = Matrix[4, 4, T]

var m1: AffineTransform3D[float]  # OK
var m2: AffineTransform2D[string] # 错误,`string` 不是一种 `Number`

请注意, static T 只是底层泛型 static[T] 的语法便利。 类型参数可以被省略,以获得所有常量表达式的类型类。通过将 static 与另一个类型类实例化,来创建更具体的类型类。

把表达式强制转换成对应的 static 类型可以强制其像常数表达式一样在编译期就进行求值。

import std/math

echo static(fac(5)), " ", static[bool](16.isPowerOfTwo)

编译器将报告表达式求值失败或可能的类型不匹配错误。

typedesc[T]

在一些上下文中,Nim 把类型名当作常规的值处理。这些值只存在于编译阶段,由于所有的值都必须有类型, 就用 typedesc 来表示它们的这种特殊类型。

typedesc 作为泛型类型。例如,标识符 int 的类型是 typedesc[int] 。 和普通的泛型一样,当省略泛型参数时,typedesc 就表示所有的类型类。 作为一种语法上的便利,我们也可以使用 typedesc 作为修饰语。

具有 typedesc 参数的过程,被当成是隐式泛型的。 它们按提供类型的每个特定组合来实例化,并在过程主体中,每个参数名称将指代为绑定的具体类型。

proc new(T: typedesc): ref T =
  echo "allocating ", T.name
  new(result)

var n = Node.new
var tree = new(BinaryTree[int])

当出现多个类型参数时,它们将自由绑定到不同的类型。可以使用明确的泛型参数,来强制执行一次性绑定。

proc acceptOnlyTypePairs[T, U](A, B: typedesc[T]; C, D: typedesc[U])

一旦绑定,类型参数就可以出现在过程签名的其它部分:

template declareVariableWithType(T: typedesc, value: T) =
  var x: T = value

declareVariableWithType int, 42

通过限制与类型参数相匹配的类型集,可以进一步影响重载解析。在实践中,通过模板将属性附加到类型上。该约束可以是一个具体的类型或一个类型类。

template maxval(T: typedesc[int]): int = high(int)
template maxval(T: typedesc[float]): float = Inf

var i = int.maxval
var f = float.maxval
when false:
  var s = string.maxval # error, maxval is not implemented for string

template isNumber(t: typedesc[object]): string = "不这么看。"
template isNumber(t: typedesc[SomeInteger]): string = "是的!"
template isNumber(t: typedesc[SomeFloat]): string = "有可能,也可能是 NaN。"

echo "int 是数字吗? ", isNumber(int)
echo "float 是数字吗? ", isNumber(float)
echo "RootObj 是数字吗? ", isNumber(RootObj)

给宏传入 typedesc 与传入其它参数几乎是一样的,区别仅在于宏一般不会被实例化。类型表达式简单地作为 NimNode 传给宏,就像其它任何东西一样。

import std/macros

macro forwardType(arg: typedesc): typedesc =
  # `arg` 的类型是 `NimNode`
  let tmp: NimNode = arg
  result = tmp

var tmp: forwardType(int)

typeof 运算符

注意: 由于历史原因 typeof(x) 也可写作 type(x) ,但是不鼓励这种写法。

取给定的表达式的 typeof 值就能得到这个表达式的类型(在其它的很多语言里这被称为 typeof 运算符):

var x = 0
var y: typeof(x) # y 的类型是 int

如果 typeof 被用来判断函数(或迭代器、变换器)调用 c(X) 的结果的类型(这里,X 代表可能为空的参数列表), 解释代码时,与其它方式相比,优先考虑把 c 视作迭代器。通过给 typeof 传入第二个参数 typeOfProc 可以改变这种行为。

iterator split(s: string): string = discard
proc split(s: string): seq[string] = discard

# 因为迭代器是首选的解释,所以它的类型是 `string` :
assert typeof("a b c".split) is string

assert typeof("a b c".split, typeOfProc) is seq[string]

模块

依靠模块概念 Nim 支持将程序拆分成小块。每个模块单独一个文件,有其独立的 namespace "命名空间"。 模块为 information hiding "信息隐藏"和 separate compilation "独立编译"提供了可能。一个模块可以通过 import 语句访问另一个模块里的符号。允许 Recursive module dependencies "递归模块依赖",但是略微复杂。只会导出带了星号( * )标记的顶层符号。 只有合法的 Nim 标识符才能作为模块名(所以对应的文件名是 identifier.nim )。

编译模块的算法如下:

  • 递归地追随导入语句正常编译整个模块。
  • 如果发现成环,只导入已经完成语法分析的(且被导出的)符号;如果遇到未知标识符就中止。

最好用一个例子来演示(译者注:代码里的注释描述了编译模块 A 时编译器的行为):

# 模块 A
type
  T1* = int  # 模块 A 导出了类型 `T1`
import B     # 编译器开始分析模块 B

proc main() =
  var i = p(3) # 由于此处模块 B 已经完成语法分析,所以没有问题

main()

# 模块 B
import A  # 此时模块 A 未完成语法分析,只会导入模块 A 中目前已知的符号

proc p* (x: A.T1): A.T1 =
  # 编译器已把 T1 添加到 A 的接口符号表,所以这么写没问题
  result = x + 1

Import 语句

import 关键字之后,可以有一个模块名称的列表,或者在单独的模块名称之后有一个 except 列表,以防止某些标识符被导入。

import std/strutils except `%`, toUpperAscii

# 这行代码无法工作:
echo "$1" % "abc".toUpperAscii

不检查 except 列表是否真的从模块中导出。这个特点使我们可以针对不同版本的模块进行编译,即使某个版本没有导出其中的一些标识符。

import 只允许在顶层出现。

字符串字面量可用于 import/include 语句。

当使用时,编译器执行路径替换

Include 语句

include 语句所干的事情与导入模块截然不同: 它只是把文件的内容包含进来而已。 include 语句可用来把一个大模块切分成几个文件:

include fileA, fileB, fileC

include 语句可以在顶层之外使用,比如:

# 模块 A
echo "Hello World!"

# 模块 B
proc main() =
  include A

main() # => Hello World!

导入语句里的模块名

可以通过 as 关键字引入一个模块的别名,之后将无法访问原始的模块名称。

import std/strutils as su, std/sequtils as qu

echo su.format("$1", "lalelu")

path/to/module"path/to/module" 标注,可以用来描述子目录中的模块。

import lib/pure/os, "lib/pure/times"

注意模块名仍然是 strutils 而不是 lib/pure/strutils,所以 不能 这么干:

import lib/pure/strutils
echo lib/pure/strutils.toUpperAscii("abc")

与之类似,因为模块名已经就是 strutils 了,所以下面的代码是不合理的:

import lib/pure/strutils as strutils

从目录里集体导入

使用语法 import dir / [moduleA, moduleB] 能够从同一个路径里导入多个模块。

在语法上,路径名可以是 Nim 标识符或者字符串字面量。如果路径名不是一个合法的 Nim 标识符, 那么就需要写成字符串字面量的形式:

import "gfx/3d/somemodule" # '3d' 不是合法的 Nim 标识符,要用引号

用于 import/include 的伪路径

路径也可以是所谓的 "pseudo directory" "伪路径"。它们用来解决存在同名模块时的多义问题。

有两个伪路径:

  1. std:std 这个伪路径代表了 Nim 标准库的抽象位置。例如,import std / strutils 可用来明确地导入标准库里的 stutils 模块。
  2. pkg:pkg 这个伪路径用来明确地指向 Nim 软件包。不过,其技术细节不在本文档的范围以内。

它的语义是: 使用搜索路径去查找模块名,但是忽略标准库所在位置 。换句话说,它是 std 的反面。

对于所有导入标准库(stdlib)里的模块的情况,建议、优选(但是目前并不强制)把 std/ 这个伪路径写到导入语句里。

From import 语句

from 关键字之后,是一个模块名称,后面是一个 import ,用来列出一个偏好使用的标识符,而不需要完全明确的限定。

from std/strutils import `%`

echo "$1" % "abc"
# 总是允许全限定形式:
echo strutils.replace("abc", "a", "z")

如果要导入模块 module ,又要强制以全限定的形式访问它的每一个符号,那么可以 from module import nil

Export 语句

export 语句用来转发符号,这样客户模块就不需要再导入本模块的依赖了:

# 模块 B
type MyObject* = object

# 模块 A
import B
export B.MyObject

proc `$`*(x: MyObject): string = "my object"

# 模块 C
import A

# 这里 B.MyObject 被隐式导入:
var x: MyObject
echo $x

当被导出的符号是另一个模块时,这个模块里的所有定义都会被导出。通过使用 except 列表可以将其中的某些符号排除。

注意当导出时,只需要指定模块名:

import foo/bar/baz
export baz

作用域规则

标识符从它的声明处开始生效,并持续到到其声明所在的那个块结束。标识符为已知状态的那段代码范围称为标识符的作用域。标识符的准确的作用域与其声明方式有关。

块作用域

对于在块(block)的声明部分里声明的变量,其作用域从其声明处开始,直到块的末尾结束。 如果一个块里包含另一个块,在这个块里又再次声明了这个标识符,那么,在这个内部的块里,第二个声明有效。 当离开这个内部的块时,第一个声明又一次有效。在同一个块里,同一个标识符不能被重复定义, 除非是为了过程或者迭代器重载之目的。

元组或对象作用域

在元组或者对象定义里的字段标识符在下列地方有效:

  • 直到元组/对象的定义结束
  • 所给的元组/对象类型的变量的字段指示器(designators)
  • 对象类型的所有派生类型内

模块作用域

模块里的所有标识符从声明开始直到模块结束一直有效。间接依赖的模块里的标识符在本模块里 不可用 。 每个模块都自动导入了 system "系统"模块。

如果一个模块从两个不同模块里导入了相同的标识符,那么每次使用它时都必须加上限定,除非它是一个重载的过程或者迭代器, 这时重载解析会进来解决多义性:

# 模块 A
var x* : string

# 模块 B
var x* : int

# 模块 C
import A, B
write(stdout, x) # 错误:x 指代不明
write(stdout, A.x) # 正确:加上限定后 x 的指代明确

var x = 4
write(stdout, x) # 没有多义性: 这是模块 C 自己的 x

模块可以共享它们的名称,但是,当尝试使用模块名称来限定标识符时,编译器将因标识符不明确而失败。 可以通过为模块设置别名来限定标识符。

# Module A/C
proc fb* = echo "fizz"

# Module B/C
proc fb* = echo "buzz"

import A/C
import B/C

C.fb() # Error: ambiguous identifier: 'fb'

import A/C as fizz
import B/C

fizz.fb() # Works

对于根目录里有一个 identifier.nimble 文件的目录树,里面的那些模块被合称为一个 Nimble 包。 identifier.nimble 这个文件名里的 identifier 就是包的名称,必须是合法的 Nim 标识符。 对于没有与之关联的 .nimble 文件的模块,给它这么一个包名: unknown

包与包之间有了区分,就可以限制编译器输出的诊断信息的范围: 仅限当前项目里的包,或者仅限项目外部的包。

编译器消息

Nim 编译器会输出不同类型的消息: hint "提示",warning "警告"和 error "错误"。 编译器遇到静态错误时会输出 错误 消息。

编译指示

编译指示(pragmas)是 Nim 语言在不引入大量新关键字的前提下给编译器提供额外信息、命令的方法。 编译指示在语法检查时随即就处理了。编译指示由一对特殊的花括号 {..} 包围。 当语言有了新特性但是还没设计出与之匹配的漂亮语法时,常常通过编译指示提供尝鲜体验。

deprecated 编译指示

deprecated 编译指示用来标记某符号已废弃:

proc p() {.deprecated.}
var x {.deprecated.}: char

可选地,这个编译指示还能接受一个包含警告信息的字符串,编译器会把它呈现给开发者。

proc thing(x: bool) {.deprecated: "请改用 thong".}

compileTime 编译指示

compileTime 编译指示用来指示一个过程或者变量只能用于编译期的执行。不会为它生成代码。 编译期过程可作为宏的辅助。从语言的 0.12.0 版本开始,包含 system.NimNode 类型的参数的过程隐式地声明为 compileTime:

proc astHelper(n: NimNode): NimNode =
  result = n

与下面的代码一致:

proc astHelper(n: NimNode): NimNode {.compileTime.} =
  result = n

加了 compileTime 编译指示的变量在运行时也存在。很多时候希望某些变量(例如查找表)在编译时填充数据、 在运行时访问——这轻而易举:

import std/macros

var nameToProc {.compileTime.}: seq[(string, proc (): string {.nimcall.})]

macro registerProc(p: untyped): untyped =
  result = newTree(nnkStmtList, p)
  
  let procName = p[0]
  let procNameAsStr = $p[0]
  result.add quote do:
    nameToProc.add((`procNameAsStr`, `procName`))

proc foo: string {.registerProc.} = "foo"
proc bar: string {.registerProc.} = "bar"
proc baz: string {.registerProc.} = "baz"

doAssert nameToProc[2][1]() == "baz"

noreturn 编译指示

noreturn 编译指示用来指示过程永远不会返回。

acyclic 编译指示

acyclic 编译指示用来指示对象类型是无环的,即使看起来像是有环的。 这个信息是一种 优化 ,有了这个信息垃圾回收器不再需要考虑这个类的对象构成环的情况:

type
  Node = ref NodeObj
  NodeObj {.acyclic.} = object
    left, right: Node
    data: string

我们也可以直接使用引用对象类型:

type
  Node {.acyclic.} = ref object
    left, right: Node
    data: string

这个例子里通过 Node 类型声明了一个树形结构。注意到这个类型的定义是递归的,GC 不得不考虑各对象可能构成一个有环图的情况。 acyclic 编译指示告知 GC 这不可能发生。如果程序员把 acyclic 编译指示赋予了实际上有环的数据类型,那么将导致内存泄露,但是不会破坏内存安全。

final 编译指示

final 编译指示用来指示一个对象类型不能被继承。注意只能继承那些继承自已有对象类型的类型(通过 object of SuperType 语法) 或者标注了 inheritable 的类型。

shallow 编译指示

shallow 编译指示影响类型的语义: 允许编译器进行浅拷贝。这会导致严重的语义问题,破坏内存安全! 但是,它也可以大幅度提高赋值的速度,因为 Nim 的语义要求对序列和字符串做深拷贝。深拷贝代价高昂, 尤其是用序列来构造树形结构的时候:

type
  NodeKind = enum nkLeaf, nkInner
  Node {.shallow.} = object
    case kind: NodeKind
    of nkLeaf:
      strVal: string
    of nkInner:
      children: seq[Node]

pure 编译指示

给对象类型加上 pure 编译指示后,编译器就不再为它生成用于运行时类型识别的类型字段。 这曾是为了实现与其它编译型语言的二进制兼容。

枚举类型可以标记为 pure 。这样一来,访问其成员时总是需要使用全限定。

asmNoStackFrame 编译指示

可以给过程加上 asmNoStackFrame 编译指示以告知编译器不要为它生成栈帧。编译器同样也不会生成类似return result; 的退出语句。 根据所用的 C 编译器,生成的 C 函数会被声明成 __declspec(naked) 或者 __attribute__((naked))

注意: 这个编译指示应该只用于完全由汇编语句构成的过程。

error 编译指示

error 编译指示可使编译器输出一条包含指定内容的错误消息。但是输出了这个错误消息后,编译过程并不一定会中止。

可以给符号(比如迭代器或者过程)附加 error 编译指示。 使用 这个符号将触发静态错误。 当需要排除某些由于重载和类型转换导致的合法操作时,这个 error 就派上用场了:

## 检查所比较的是整形数值,而不是指针:
proc `==`(x, y: ptr int): bool {.error.}

fatal 编译指示

fatal 编译指示可使编译器输出一条包含指定内容的错误消息。与 error 编译指示不同, 输出了这个错误消息后,编译过程必然中止。例子:

when not defined(objc):
  {.fatal: "编译这个程序时带上 objc 命令!".}

warning 编译指示

warning 编译指示可使编译器输出一条包含指定内容的警告消息,然后继续编译。

hint 编译指示

hint 编译指示可使编译器输出一条包含指定内容的提示消息,然后继续编译。

line 编译指示

line 编译指示可以修改所在语句的代码行信息。这个行信息可在栈回溯信息里看到:

template myassert*(cond: untyped, msg = "") =
  if not cond:
    # 修改 `raise` 语句运行时的行信息
    {.line: instantiationInfo().}:
      raise newException(AssertionDefect, msg)

如果 line 带了参数,那么参数需要是 tuple[filename: string, line: int] 的形式; 如果不带参数,那么相当于以 system.instantiationInfo() 为参数。

linearScanEnd 编译指示

linearScanEnd 编译指示用来告知编译器如何处理 Nim case 语句。这个编译指示在语法上必须是一个语句:

case myInt
of 0:
  echo "最常见的情况"
of 1:
  {.linearScanEnd.}
  echo "第二常见的情况"
of 2: echo "不常见:使用分支表"
else: echo "也不常见:使用了分支表,数值为 ", myInt

在这个例子里, 01 分支比其它情况更加常见。所以,生成的汇编代码应该首先测试这两个值以使 CPU的分支预测器有更大的几率预测成功(避免出现开销高昂的 CPU 流水线停滞)。 其它的情况则可以放到跳转表里,其开销为 O(1),但代价是一次(很可能出现的)流水线停滞。

linearScanEnd 编译指示应该被到最后一个需要进行线性扫描的分支里。如果放到整个 case 语句最后那个分支里,那么整个 case 语句都会使用线性扫描。

computedGoto 编译指示

computedGoto 编译指令告知编译器如何编译嵌在 while true 语句里的 Nim case 语句。 这个编译指示在语法上必须是这个循环体里的一条语句:

type
  MyEnum = enum
    enumA, enumB, enumC, enumD, enumE

proc vm() =
  var instructions: array[0..100, MyEnum]
  instructions[2] = enumC
  instructions[3] = enumD
  instructions[4] = enumA
  instructions[5] = enumD
  instructions[6] = enumC
  instructions[7] = enumA
  instructions[8] = enumB
  
  instructions[12] = enumE
  var pc = 0
  while true:
    {.computedGoto.}
    let instr = instructions[pc]
    case instr
    of enumA:
      echo "yeah A"
    of enumC, enumD:
      echo "yeah CD"
    of enumB:
      echo "yeah B"
    of enumE:
      break
    inc(pc)

vm()

如例子所示,computedGoto 对于实现解释器非常有用。如果所使用的后端(C 编译器)不支持计算跳转这个扩展功能,那么该编译指示被直接忽略。

immediate 编译指示

即时编译指示已经过时。参阅类型化参数与非类型化参数

redefine 编译指示

允许对具有相同签名的模板标识符进行重新定义。这可以通过 redefine 编译指示来明确。

template foo: int = 1
echo foo() # 1
template foo: int {.redefine.} = 2
echo foo() # 2
# 警告:模板隐式重定义
template foo: int = 3

这主要是针对宏生成的代码。

与编译选项相关的编译指示

下面列出的编译指示用来改写过程、方法、转换器的代码生成选项。

当前,编译器提供以下可能的选项(以后可能会增加)。

编译指示允许的值描述
checkson|off是否为所有的运行时检查生成代码。
boundCheckson|off是否为数组边界检查生成代码。
overflowCheckson|off是否为上、下溢出检查生成代码。
nilCheckson|off是否为空指针检查生成代码。
assertionson|off是否为断言生成代码。
warningson|off打开或关闭编译器的警告消息。
hintson|off打开或关闭编译器的提示消息。
optimizationnone|speed|size 设置优化目标为执行速度(speed)、文件大小(size), 或者关闭优化(none)
patternson|off打开或关闭项重写模块、宏。
callconvcdecl|...为所有过程(及过程类型)设置默认的调用规范。

例如:

{.checks: off, optimization: speed.}
# 关闭运行时检查,优化执行速度

push 和 pop 编译指示

push/pop 编译指示也是用来控制编译选项的,不过是用于临时性地修改设置然后还原。例子:

{.push checks: off.}
# 由于这一段代码对于执行速度非常关键,所以不做运行时检查
# ... 一些代码 ...
{.pop.} # 恢复原来旧的编译设置

push/pop 能够开关一些来自标准库的编译指示,例如:

{.push inline.}
proc thisIsInlined(): int = 42
func willBeInlined(): float = 42.0
{.pop.}
proc notInlined(): int = 9

{.push discardable, boundChecks: off, compileTime, noSideEffect, experimental.}
template example(): string = "https://nim-lang.org"
{.pop.}

{.push deprecated, used, stackTrace: off.}
proc sample(): bool = true
{.pop.}

对于来自第三方的编译指示,push/pop 是否有效与第三方的实现有关,但是无论如何使用的语法是相同的。

register 编译指示

register 编译指示仅用于变量。这个编译指示将变量声明为 register, 提示编译器应该将这个变量放到硬件寄存器里以提高访问速度。C 编译器经常忽略这个提示,理由充分: 没有这个提示它们往往能把活干得更漂亮。

然而,特定的情况下(例如一个字节码解释器的调度循环)这个编译指示可能会有所帮助。

global 编译提示

可以给过程里的变量加上 global 编译提示,命令编译器把这个变量存储在全局位置,并且在程序启动时初始化一次。

proc isHexNumber(s: string): bool =
  var pattern {.global.} = re"[0-9a-fA-F]+"
  result = s.match(pattern)

在泛型过程里使用时,编译器会为泛型过程的每个实例创建独立的全局变量。编译器为某个模块创建的这些全局变量, 其初始化时的先后顺序不做规定;但是,整体上是先初始化这个模块的顶层变量,再初始化这些全局变量; 如果其它模块导入了这个模块,那么这些全局变量的初始化将早于其它模块里的变量。

禁用某些信息

Nim 会生成一些可能让用户感到烦恼的警告和提示。 Nim 提供了一种禁用某些消息的机制:每个提示和警告消息都与一个符号相关联。 这是消息的标识符,可以通过在指示后面的括号中放置它来启用或禁用该消息:

{.hint[XDeclaredButNotUsed]: off.} # 关闭有关已声明但未使用的符号的提示。

对于警告消息而言,这种办法往往比一股脑地禁用所有警告更好。

used 编译提示

当一个符号既未导出也未被使用时,Nim 会输出一条警告消息。给这个符号加上 used 编译提示可以抑制这条消息。 当通过宏生成符号时,这个编译提示非常有用:

template implementArithOps(T) =
  proc echoAdd(a, b: T) {.used.} =
    echo a + b
  proc echoSub(a, b: T) {.used.} =
    echo a - b

# 'echoSub' 虽然未被使用,但是不会触发警告
implementArithOps(int)
echoAdd 3, 5

used 也可用作顶层语句,把模块标记为"已使用"。这样就可以抑制针对这个模块的"未使用的导入"这条警告:

# 模块:debughelper.nim
when defined(nimHasUsed):
  # 'import debughelper' 对于调试来说非常有用,
  # 即使这个模块未被使用,也不需要 Nim 输出警告:
  {.used.}

expermimental 编译指示

experimental 编译指示用于启用实验性的语言特性。也就是说,具体到每个特性,有的过于不稳定,无法发布;有的前景不明朗(可能随时被删除)。详情参阅实验手册

示例:

import std/threadpool
{.experimental: "parallel".}

proc threadedEcho(s: string, i: int) =
  echo(s, " ", $i)

proc useParallel() =
  parallel:
    for i in 0..4:
      spawn threadedEcho("并行地使用 echo ", i)

useParallel()

expermimental 编译指示是顶层语句,模块里出现了这个编译指示之后,它所启用的特性就一直有效。这会给宏和泛型实例的使用带来问题,因为它们可以跨越模块作用域。目前,必须在 .push/pop 环境中使用以避免问题:

# client.nim
proc useParallel*[T](unused: T) =
  # 这里使用泛型 T 演示问题.
  {.push experimental: "parallel".}
  parallel:
    for i in 0..4:
      echo "并行输出"
  
  {.pop.}

import client
useParallel(1)

与实现紧密相关的编译指示

本节介绍当前 Nim 实现所支持的额外的编译指示。不要把它们视为语言规范的一部分。

Bitsize 编译指示

bitsize 是对象字段成员的编译指示。表明该字段为 C/C++ 中的位域。

type
  mybitfield = object
    flag {.bitsize:1.}: cuint

生成:

struct mybitfield {
  unsigned int flag:1;
};

大小指示

Nim 会自动确定枚举的大小。但是,当包装 C 枚举类型时,它必须具有特定的大小。 size pragma 允许指定枚举类型的大小。

type
  EventType* {.size: sizeof(uint32).} = enum
    QuitEvent,
    AppTerminating,
    AppLowMemory

doAssert sizeof(EventType) == sizeof(uint32)

size pragma 还可以指定 importc 不完整对象类型的大小,这样即使在没有声明字段的情况下,也可以在编译时获取其大小。

  type
    AtomicFlag* {.importc: "atomic_flag", header: "<stdatomic.h>", size: 1.} = object
  
  static:
    # 如果 AtomicFlag 没有使用 size 指示,则此代码将导致编译时错误。
    echo sizeof(AtomicFlag)

size pragma 只接受 1、2、4 或 8 的值。

Align 编译指示

align 编译指示是针对变量和对象字段成员的,用于修改所声明的实体的字节对齐要求。其参数必须是常数,是 2 的整数次幂。同一个声明存在多个有效的非 0 对齐的编译指示时,较弱的编译指示会被忽略。与类型的对齐要求相比,较弱的对齐编译指示的声明也会被忽略。

type
  sseType = object
    sseData {.align(16).}: array[4, float32]
  
  # 每个对象都按 128 字节边界对齐
  Data = object
    x: char
    cacheline {.align(128).}: array[128, char] # 超量对齐的字符数组

proc main() =
  echo "sizeof(Data) = ", sizeof(Data), " (1 byte + 127 bytes padding + 128-byte array)"
  # 输出: sizeof(Data) = 256 (1 byte + 127 bytes padding + 128-byte array)
  echo "sseType 的对齐长度是 ", alignof(sseType)
  # 输出: sseType 的对齐长度是 16
  var d {.align(2048).}: Data # Data 的这个实例的对齐要求更加严格

main()

这种编译指示对 JS 后端没有任何影响。

Noalias 编译指示

从 Nim 编译器版本 1.4 开始,有一个用于变量和参数的 .noalias 注解。它被直接映射到 C/C++ 的 restrict 关键字,表示底层指向内存中的一个独占地址,此地址不存在其他别名。编译器 不检查 代码是否遵守了此别名限制。如果违反了限制,后端优化器就完全有可能错误地编译代码。这是一个 不安全的 语言功能。

理想情况下,在 Nim 之后的版本中,该限制将在编译时得以检查确认。(这也是为什么选择了 noalias 做名称,而不是描述更详细的名称,如 unsafeAssumeNoAlias 。)

Volatile 编译指示

volatile 编译指示仅用于变量。它声明变量为 volatile:c:,不论 C/C++ 中 volatile 代表什么含义 (其语义在 C/C++中没有明确定义)。

注意: LLVM 后端不存在这种编译指示。

nodecl 编译指示

nodecl 编译指示可以应用于几乎任何标识符(变量、过程、类型等),在与 C 的互操作时往往很有用: 它告诉 Nim,不要在 C 代码中声明这个标识符。例如:

var
  EACCES {.importc, nodecl.}: cint # 把 EACCES 假装成变量,
                                   # Nim 不知道它的值

然而, header 编译指示通常是更好的选择。

注意: 这在 LLVM 后端无法使用。

Header 编译指示

header 编译指示和 nodecl 编译指示非常相似: 可以应用于几乎所有的标识符,并指定它不应该被声明,与之相反,生成的代码应该包含一个 #include:

type
  PFile {.importc: "FILE*", header: "<stdio.h>".} = distinct pointer
    # 引入 C 的 FILE* 类型;Nim 把它视为一个新的指针类型

header 编译指示总是需要一个字符串常量。这个字符串常量包含头文件: 像 C 语言里经常发生的那样,系统头文件被括在尖括号中: <> 。如果没有给出尖括号,Nim 生成 C 代码时就把头文件括在 "" 中。

注意: LLVM 后端不存在这种编译指示。

IncompleteStruct 编译指示

incompleteStruct 编译指示告知编译器不要在 sizeof 表达式中使用底层的 C struct

type
  DIR* {.importc: "DIR", header: "<dirent.h>",
         pure, incompleteStruct.} = object

Compile 编译指示

compile 编译指示用来把 C/C++ 源文件与项目一同编译和链接:

这个 pragma 可以有三种形式。第一种是简单的文件输入:

{.compile: "myfile.cpp".}

第二种形式是元组,其中第二个参数是输出名称的 strutils 格式化程序:

{.compile: ("file.c", "$1.o").}

注意: Nim 会计算 SHA1 校验和,只在文件变化时才重新编译。使用 -f 命令行选项可以强制重新编译文件。

从 1.4 开始, compile 编译指示也可以使用这种语法:

{.compile("myfile.cpp", "--custom flags here").}

从例子中可以看出,这个新写法允许在文件重新编译时将自定义标志传递给 C 编译器。

Link 编译指示

link 编译指示用来将附加文件与项目链接:

{.link: "myfile.o".}

passc 编译指示

passc 编译指示可以用来传递额外参数到 C 编译器,就像命令行使用的 --passc:

{.passc: "-Wall -Werror".}

请注意,可以使用系统模块中的 gorge 来嵌入外部命令中的参数,这些参数将在语义分析期间执行:

{.passc: gorge("pkg-config --cflags sdl").}

localPassC 编译指示

localPassC 编译指示可以向 C 编译器传递额外的参数,但只适用于由编译指示所在的 Nim 模块生成的 C/C++ 文件:

# 模块 A.nim
# 生成: A.nim.cpp
{.localPassC: "-Wall -Werror".} # 当编译 A.nim.cpp 时传递

passl 编译指示

passl 编译指示可以把额外参数传递到 C 链接器,就像在命令行使用的 --passl:

{.passl: "-lSDLmain -lSDL".}

请注意,可以使用系统模块中的 gorge 来嵌入外部命令中的参数,这些参数将在语义分析期间执行:

{.passl: gorge("pkg-config --libs sdl").}

Emit 编译指示

emit 编译指示可以直接影响编译器代码生成器的输出。这样一来,代码将无法移植到其他代码生成器/后端,非常不鼓励使用这种方法。然而,它对于实现与 C++Objective C 代码的接口非常有用。

示例:

{.emit: """
static int cvariable = 420;
""".}

{.push stackTrace:off.}
proc embedsC() =
  var nimVar = 89
  # 在 emit 内、字符串字面值以外访问 Nim 符号
  {.emit: ["""fprintf(stdout, "%d\n", cvariable + (int)""", nimVar, ");"].}
{.pop.}

embedsC()

nimbase.h 定义了 NIM_EXTERNC C 宏,用于 extern "C" 代码,与 nim cnim cpp 兼容,例如:

proc foobar() {.importc:"$1".}
{.emit: """
#include <stdio.h>
NIM_EXTERNC
void fun(){}
""".}

Note: 为了向后兼容,如果 emit 语句的参数是单一的字符串字面值,可以通过反引号引用 Nim 标识符。但这种用法已经废弃。

顶层 emit 语句所输出的代码混杂在所生成的 C/C++ 文件中,其位置可通过前缀 /*TYPESECTION*/:c:、/*VARSECTION*//*INCLUDESECTION*/ 加以影响:

{.emit: """/*TYPESECTION*/
struct Vector3 {
public:
  Vector3(): x(5) {}
  Vector3(float x_): x(x_) {}
  float x;
};
""".}

type Vector3 {.importcpp: "Vector3", nodecl} = object
  x: cfloat

proc constructVector3(a: cfloat): Vector3 {.importcpp: "Vector3(@)", nodecl}

ImportCpp 编译指示

注意: c2nim可以解析大量的 C++ 子集, 关于 importcpp 编译指示模式语言,没有必要知道这里描述的所有细节。

与 C 语言的importc 编译指示类似,importcpp 编译指示可以用来导入 C++ 方法或一般的 C++ 标识符。 生成的代码使用 C++ 的方法调用语法: obj->method(arg) 。 与 headeremit 编译指示相结合,可与用 C++ 编写的库 宽松 对接。

# 关于如何与 C++ 引擎对接的可怕示例 ... ;-)

{.link: "/usr/lib/libIrrlicht.so".}

{.emit: """
using namespace irr;
using namespace core;
using namespace scene;
using namespace video;
using namespace io;
using namespace gui;
""".}

const
  irr = "<irrlicht/irrlicht.h>"

type
  IrrlichtDeviceObj {.header: irr,
                      importcpp: "IrrlichtDevice".} = object
  IrrlichtDevice = ptr IrrlichtDeviceObj

proc createDevice(): IrrlichtDevice {.
  header: irr, importcpp: "createDevice(@)".}
proc run(device: IrrlichtDevice): bool {.
  header: irr, importcpp: "#.run(@)".}

这个例子需要告知编译器生成 C++ (命令 cpp ) 才能工作。编译器生成 C++ 代码时会定义条件标识符 cpp

命名空间

这个 宽松对接 的例子使用了 .emit 来生成 using namespace 声明。通过 namespace::identifier 标识符来引用导入的名称往往会更好:

type
  IrrlichtDeviceObj {.header: irr,
                      importcpp: "irr::IrrlichtDevice".} = object

Importcpp 应用于枚举

importcpp 应用于枚举类型时,数字枚举值都会标注 C++ 枚举类型,就像这样: ((TheCppEnum)(3)) 。(事实上这已是最简单的实现方式。)

Importcpp 应用于过程

请注意,用于过程的 importcpp 使用了一种有些隐晦的模式语言,以获得最大的灵活性:

  • 井号 # 会被第一个或下一个参数所取代。
  • 井号加个点 #. 表示调用应该使用 C++ 的点或箭头符号。
  • 符号 @ 被剩余参数替换,通过逗号分隔。

例如:

proc cppMethod(this: CppObj, a, b, c: cint) {.importcpp: "#.CppMethod(@)".}
var x: ptr CppObj
cppMethod(x[], 1, 2, 3)

生成:

x->CppMethod(1, 2, 3)

有一项特殊规则: 为了保持与旧版本的 importcpp 编译指示的向后兼容性,如果没有任何特殊的模式字符 ( # ' @ 中的任意一个 ),就会假定使用 C++ 的点或箭头符号。所以上述例子也可以写成:

proc cppMethod(this: CppObj, a, b, c: cint) {.importcpp: "CppMethod".}

请注意,模式语言当然也具有 C++ 操作符重载的能力:

proc vectorAddition(a, b: Vec3): Vec3 {.importcpp: "# + #".}
proc dictLookup(a: Dict, k: Key): Value {.importcpp: "#[#]".}

  • 撇号 ' 后面跟着 0..9 范围内的整数 i ,被第 i 个参数的 类型 替换。第 0 个位置是返回值类型。这可以用来向 C++ 函数模板传递类型。

' 和数字之间,用星号来获得该类型的基本类型。(也就是说,它从类型中“拿走星号”,如 T* 变成 T 。)两个星号可以用来获取元素类型的元素类型,等等。

例如:

type Input {.importcpp: "System::Input".} = object
proc getSubsystem*[T](): ptr T {.importcpp: "SystemManager::getSubsystem<'*0>()", nodecl.}

let x: ptr Input = getSubsystem[Input]()

生成:

x = SystemManager::getSubsystem<System::Input>()

  • #@ 用来支持 cnew 操作这一特殊情况。它使调用表达式直接被内联,而不需要经过一个临时地址。这只是为了规避当前代码生成器的限制。

例如,C++中 new 运算符可以像这样“导入”:

proc cnew*[T](x: T): ptr T {.importcpp: "(new '*0#@)", nodecl.}

# 'Foo' 的构造函数:
proc constructFoo(a, b: cint): Foo {.importcpp: "Foo(@)".}

let x = cnew constructFoo(3, 4)

生成:

x = new Foo(3, 4)

然而,根据使用情况 new Foo 也可以像这样包装:

proc newFoo(a, b: cint): ptr Foo {.importcpp: "new Foo(@)".}

let x = newFoo(3, 4)

包装构造函数

有时候 C++ 类的拷贝构造函数是私有的,所以不能生成 Class c = Class(1,2);:cpp:,而应该是 Class c(1,2); 。 要达到这个目的,需要给包装 C++ 构造函数的 Nim 过程加上 constructor 编译指示。这个编译指示也有助于生成更快的 C++ 代码,因为这样一来构造时就不会再调用拷贝构造函数:

# 'Foo' 的更好的构造函数:
proc constructFoo(a, b: cint): Foo {.importcpp: "Foo(@)", constructor.}

包装析构函数

由于 Nim 直接生成C++,任何析构函数都会在作用域退出时被 C++ 编译器隐式调用。这意味着,通常我们可以不包装析构函数! 但是,当需要显式调用它时,就需要包装。模式语言提供了所需一切:

proc destroyFoo(this: var Foo) {.importcpp: "#.~Foo()".}

Importcpp 应用于对象

C++ 模板被映射成 importcpp 泛型对象。这意味着可以很容易地导入 C++ 模板,不需要再为对象类型设计模式语言:

type
  StdMap[K, V] {.importcpp: "std::map", header: "<map>".} = object
proc `[]=`[K, V](this: var StdMap[K, V]; key: K; val: V) {.
  importcpp: "#[#] = #", header: "<map>".}

var x: StdMap[cint, cdouble]
x[6] = 91.4

生成:

std::map<int, double> x;
x[6] = 91.4;

  • 如果需要更精确的控制,可以在提供的模式中使用撇号 ' 来表示泛型的具体类型参数。更多细节请参见过程模式中的撇号操作符的用法。

    type
      VectorIterator[T] {.importcpp: "std::vector<'0>::iterator".} = object
    
    var x: VectorIterator[cint]

    生成:

    
    std::vector<int>::iterator x;

ImportJs 编译指示

与 C++ 的importcpp 编译指示类似,importjs 编译指示可以用来导入 Javascript 方法或一般的标识符。 生成的代码使用 Javascript 方法的调用语法: obj.method(arg)

ImportObjC 编译指示

类似于 C 语言的importc 编译指示importobjc 编译指示可以用来导入 Objective C 方法。 生成的代码使用 Objective C 的方法调用语法。 [obj method param1: arg] 。 除了 headeremit 编译指示,允许宽松地与用 Objective C 编写的库对接。

# 关于如何与 GNUStep 对接的可怕示例...

{.passl: "-lobjc".}
{.emit: """
#include <objc/Object.h>
@interface Greeter:Object
{
}

- (void)greet:(long)x y:(long)dummy;
@end

#include <stdio.h>
@implementation Greeter

- (void)greet:(long)x y:(long)dummy
{
  printf("Hello, World!\n");
}
@end

#include <stdlib.h>
""".}

type
  Id {.importc: "id", header: "<objc/Object.h>", final.} = distinct int

proc newGreeter: Id {.importobjc: "Greeter new", nodecl.}
proc greet(self: Id, x, y: int) {.importobjc: "greet", nodecl.}
proc free(self: Id) {.importobjc: "free", nodecl.}

var g = newGreeter()
g.greet(12, 34)
g.free()

这个例子需要告知编译器生成 Objective C (命令 objc ) 才能工作。当编译器输出 Objective C 代码时会定义条件标识符 objc

CodegenDecl 编译指示

codegenDecl 指示可以用于直接影响 Nim 的代码生成器。 它接收一个格式字符串,该字符串决定了在生成的代码中如何声明变量、过程或对象类型。

对于变量,格式字符串中的 $1 表示变量的类型,$2 表示变量的名称,$# 按出现的先后顺序依次表示 $1、$2。

以下 Nim 代码:

var
  a {.codegenDecl: "$# progmem $#".}: int

将生成此 C 代码:

int progmem a

对过程而言,$1是过程的返回值类型,$2是过程的名字,$3是参数列表,$# 按出现的先后顺序依次表示 $1、$2、$3。

以下 Nim 代码:

proc myinterrupt() {.codegenDecl: "__interrupt $# $#$#".} =
  echo "realistic interrupt handler"

将生成此代码:

__interrupt void myinterrupt()

对于对象类型,$1代表对象类型的名称,$2 是字段列表,$3 是基类型。


const strTemplate = """
  struct $1 {
    $2
  };
"""
type Foo {.codegenDecl:strTemplate.} = object
  a, b: int

将生成此代码:

struct Foo {
  NI a;
  NI b;
};

cppNonPod 编译指示

importcpp 非 POD 类型时应该加上 cppNonPod 编译指示,这样用作 threadvar 变量时才可能正常工作(尤其是对构造函数和析构函数而言)。这需要 --tlsEmulation:off

type Foo {.cppNonPod, importcpp, header: "funs.h".} = object
  x: cint
proc main()=
  var a {.threadvar.}: Foo

编译期的 define 编译指示

这里列出的编译指示可以用来在编译时接收来自 -d/-define 命令行参数的值。

当前提供了以下编译指示 (以后可能增加)。

编译指示描述
intdefine在编译时将 define 读取为整数类型
strdefine在编译时将 define 读取为字符串类型
booldefine在编译时将 define 读取为布尔类型

const FooBar {.intdefine.}: int = 5
echo FooBar

nim c -d:FooBar=42 foobar.nim

在上述例子中,-d 标志在编译时覆盖 FooBar 的默认值,打印出 42。如果删除 -d:FooBar=42:option:,则使用默认值5。可以使用 defined(FooBar) 判断是否为它提供了值。

语法 -d:flag 实际上是 -d:flag=true 的简写。

这些指示还接受一个可选的字符串参数,用于限定定义名称。

const FooBar {.intdefine: "package.FooBar".}: int = 5
echo FooBar

nim c -d:package.FooBar=42 foobar.nim

这有助于消除不同包中定义名称的歧义。

还请参阅 generic `define` pragma,该版本的指示可以基于常量值检测定义的类型。

用户定义的编译指示

pragma 编译指示

pragma 编译指示可以用来声明用户定义的编译指示。这是有用的,因为 Nim 的模板和宏不会影响编译指示。用户定义的编译指示处于与所有其他符号都不同的模块作用域。它们不能从模块中导入。

示例:

when appType == "lib":
  {.pragma: rtl, exportc, dynlib, cdecl.}
else:
  {.pragma: rtl, importc, dynlib: "client.dll", cdecl.}

proc p*(a, b: int): int {.rtl.} =
  result = a + b

在这个例子中,引入了一个名为 rtl 的新编译指示,它表示要么从动态库中导入符号,要么为生成动态库而导出符号。

定制注解

可以定制带类型的编译指示。定制的编译指示不会直接影响代码生成,但宏可以检测到它们的存在。给模板加上 pragma 编译指示就能定义定制的编译指示:

template dbTable(name: string, table_space: string = "") {.pragma.}
template dbKey(name: string = "", primary_key: bool = false) {.pragma.}
template dbForeignKey(t: typedesc) {.pragma.}
template dbIgnore {.pragma.}

考查这个有风格的例子,它是关于对象关系映射 (ORM) 的一个合理实现:

const tblspace {.strdefine.} = "dev" # 控制开发、测试、生成环境的开关

type
  User {.dbTable("users", tblspace).} = object
    id {.dbKey(primary_key = true).}: int
    name {.dbKey"full_name".}: string
    is_cached {.dbIgnore.}: bool
    age: int
  
  UserProfile {.dbTable("profiles", tblspace).} = object
    id {.dbKey(primary_key = true).}: int
    user_id {.dbForeignKey: User.}: int
    read_access: bool
    write_access: bool
    admin_access: bool

在本例中,通过定制的编译指示来描述 Nim 对象如何被映射到关系数据库的模式中。定制的编译指示可以有零个或多个参数。请使用模板调用语法来传递多个参数。 所有的参数都有类型,并且遵循模板的标准重载解析规则。因此,可以为参数设置默认值,可以通过名称传递,可以使用可变参数,等等。

所有可以使用普通编译指示的地方,都可以使用定制的编译指示,为过程、模板、类型和变量定义、语句等添加注解。

宏模块包含工具,可以用来简化自定义编译指示的访问 hasCustomPragma , getCustomPragmaVal 。 详情参阅模块文档。这些宏并不神奇,它们也可以通过逐步的对象表示的 AST 来实现。

带有自定义指示的更多示例:

  • 更好的序列化/反序列化控制:

    type MyObj = object
      a {.dontSerialize.}: int
      b {.defaultDeserialize: 5.}: int
      c {.serializationKey: "_c".}: string

  • 在游戏引擎中为 gui 查看器添加类型:

    type MyComponent = object
      position {.editable, animatable.}: Vector3
      alpha {.editRange: [0.0..1.0], animatable.}: float32

宏编译指示

有时可以用编译指示语法来调用宏和模板,比如用在例程(过程、迭代器等)声明或例程类型表达式上。编译器执行以下简单的语法转换:

template command(name: string, def: untyped) = discard

proc p() {.command("print").} = discard

转换为:

command("print"):
  proc p() = discard


type
  AsyncEventHandler = proc (x: Event) {.async.}

转换为:

type
  AsyncEventHandler = async(proc (x: Event))


当多个宏编译指示应用于同一个定义时,从左到右的第一个将先被求值。 然后,这个宏可以选择是否在其输出中保留其余的宏编译指示。保留下的那些宏编译指示的求值方式依此类推。

宏指示的应用还有更多,例如在类型、变量和常量声明中, 但这种行为被认为是实验性的,并在 experimental manual 中进行了记录。

外部函数接口

Nim 的 FFI (外部函数接口) 很广博,这里只讲述将来会推广到其他后端(如 LLVM/JavaScript 后端) 的部分。

Importc 编译指示

importc 编译指示提供了一种从 C 语言导入程序或变量的方法。可选参数是一个包含 C 标识符的字符串。如果没有这个参数,C 名称就和 Nim 的标识符 一字不差:

proc printf(formatstr: cstring) {.header: "<stdio.h>", importc: "printf", varargs.}

importc 应用于 let 语句时不需要提供值,因为这时期望从 C 取得值。这个用法能够导入 C const:

{.emit: "const int cconst = 42;".}

let cconst {.importc, nodecl.}: cint

assert cconst == 42

注意,这个编译指示曾在 JS 后端滥用,用来导入 JS 对象和函数。其他后端也支持这个编译指示,功能相同。另外,当目标语言不是 C 时,还有其他的编译指示:

传递给 importc 的字符串字面量可以是一个格式化字符串:

proc p(s: cstring) {.importc: "prefix$1".}

在示例中, p 的外部名称被设置为 prefixp 。只有 $1 可用,并且必须将字面美元符号写为 $$

Exportc 编译指示

exportc 编译指示提供了一种将类型、变量或过程导出到 C 的手段。枚举和常量不能导出。可选参数是包含 C 标识符的字符串。如果参数缺失,C 名字就和 Nim 标识符 一字不差 :

proc callme(formatstr: cstring) {.exportc: "callMe", varargs.}

请注意这个编译指示的名称有点用词不当: 因为其他后端也通过这个名称提供了相同功能。

传递给 exportc 的字符串字面量可以是一个格式化字符串:

proc p(s: string) {.exportc: "prefix$1".} =
  echo s

在示例中, p 的外部名称被设置为 prefixp 。只有 $1 可用,并且必须将字面美元符号写为 $$

如果该标识符也应被导出到一个动态库中,除了使用 exportc 编译指示外, 还应该使用 dynlib 编译指示。参阅Dynlib 编译指示应用于导出

Extern 编译指示

exportcimportc一样, extern 编译指示也能影响名称混淆。传递给 extern 的字符串字面量可以是一个格式化字符串:

proc p(s: string) {.extern: "prefix$1".} =
  echo s

在示例中, p 的外部名称被设置为 prefixp 。只有 $1 可用,并且必须将字面美元符号写为 $$

Bycopy 编译指示

bycopy 指示可以应用于对象或元组类型或过程参数。它指示编译器按值将类型传递给过程:

type
  Vector {.bycopy.} = object
    x, y, z: float

Nim 编译器会根据参数类型的大小自动确定参数是按值传递还是按引用传递。 如果参数必须按值传递或按引用传递(例如,当与 C 库接口时),请使用 bycopybyref 指示。 请注意,标记为 byref 的参数会优先于标记为 bycopy 的类型。

Byref 编译指示

byref 指示可以应用于对象或元组类型或过程参数。 当应用于类型时,它会指示编译器按引用(隐藏指针)将类型传递给过程。 当应用于参数时,它将具有优先权,即使类型被标记为 bycopy 。 当使用 cpp 后端时,标记为 byref 的参数将转换为 cpp 引用 &

Varargs 编译指示

varargs 编译指示只能用于过程(和过程类型)。它告知 Nim 在最后一个指定的参数之后,过程还可以接受数目不定的若干参数。Nim 字符串值将会自动转换为 C 字符串:

proc printf(formatstr: cstring) {.nodecl, varargs.}

printf("%s 内侯!", "世界") # "世界" 作为 C 字符串传递

Union 编译指示

union 编译指示可以应用于任意 object 类型,表示这个对象的每个字段在内存中都重叠在一起。生成 C/C++ 代码时将产生联合体(union)而不是结构体(struct)。声明这个对象时禁止使用继承、禁止使用带 GC 的内存,但目前编译器不做这个检查。

未来的方向: 应该允许联合体使用带 GC 的内存,而 GC 应该保守地扫描联合体。

Packed 编译指示

packed 编译指示可以用于任意 object 类型,确保对象里的字段一个接一个地紧密排放。 当需要访问网络、硬件驱动,或者与 C 语言进行互操作时,这个编译指示非常有用。将 packed 编译指示与继承相结合是未定义的。它也不应该用于带 GC 的内存(使用引用)。

未来方向: 在 packed 编译指示中使用带 GC 的内存将导致静态错误。继承用法应加以定义并写进文档。

用于导入的 dynlib 编译指示

使用 dynlib 编译指示从动态库(Windows 上的 .dll 文件, UNIX 上的 lib*.so 文件)中导入过程或变量。 必须把动态库的名称写在参数里:

proc gtk_image_new(): PGtkWidget
  {.cdecl, dynlib: "libgtk-x11-2.0.so", importc.}

一般来说,导入动态库不需要任何特殊链接选项或与导入库链接。这也意味着不需要安装 devel 软件包。

dynlib 导入机制支持版本化:

proc Tcl_Eval(interp: pTcl_Interp, script: cstring): int {.cdecl,
  importc, dynlib: "libtcl(|8.5|8.4|8.3).so.(1|0)".}

在运行时,将按以下顺序搜索动态库:

libtcl.so.1
libtcl.so.0
libtcl8.5.so.1
libtcl8.5.so.0
libtcl8.4.so.1
libtcl8.4.so.0
libtcl8.3.so.1
libtcl8.3.so.0

dynlib 指示不仅支持常量字符串作为参数,还支持一般的字符串表达式:

import std/os

proc getDllName: string =
  result = "mylib.dll"
  if fileExists(result): return
  result = "mylib2.dll"
  if fileExists(result): return
  quit("无法加载动态库")

proc myImport(s: cstring) {.cdecl, importc, dynlib: getDllName().}

注意: 类似 libtcl(|8.5|8.4).so 的形式只支持常量字符串,因为它们是预编译的。

注意: 由于初始化顺序的问题,向 dynlib 编译指示传递变量将在运行时出错。

注意: dynlib 导入,可以通过 --dynlibOverride:name 命令行选项进行覆盖,参阅 编译器用户指南]。

Dynlib 编译指示应用于导出

一个使用了 dynlib 编译指示的过程,也能被导出为动态库。这时,它不需要参数,但必须结合 exportc 编译指示来使用:

proc exportme(): int {.cdecl, exportc, dynlib.}

这只有在程序通过 --app:lib 命令行选项被编译为动态库时才有用。

线程

--threads:on 命令行选项默认是启用的。然后, typedthreads 模块 包含几个线程原始类型。 有关详细信息,请参阅 spawn

创建线程的唯一方法是通过 spawncreateThread

Thread 编译指示

出于可读性的考虑,作为新线程执行的程序应该用 thread 编译指示进行标记。 编译器会检查是否违反了 no heap sharing restriction "无堆共享限制": 这个限制的意思是,由来自不同的(线程本地)堆上的内存所组成的数据结构是无效的。

线程过程可以被传递给 createThreadspawn

Threadvar 编译指示

变量可以用 threadvar 编译指示来标记,这会使它成为 thread-local "线程本地"变量; 此外,这意味着 global 编译指示的所有作用。

var checkpoints* {.threadvar.}: seq[string]

由于实现的限制,本地线程变量不能在 var 块中初始化。(每个线程本地变量都需要在线程创建时复制。)

线程和异常

线程和异常之间的交互很简单: 一个线程中, 被捕获 了的异常,无法影响其他的线程。 然而,某个线程中 未捕获 的异常,会终止整个 进程

守卫和锁

Nim 提供了诸如锁、原子性内部函数或条件变量这样的常见底层并发机制。

Nim 通过附带编译指示,显著地提高了这些功能的安全性:

  1. 引入 guard 注解,以防止数据竞争。
  2. 每次访问受保护的内存位置,都需要在适当的 locks 语句中进行。

守卫和锁块

受保护的全局变量

对象字段和全局变量都可以使用 guard 编译指令进行标注:

import std/locks

var glock: Lock
var gdata {.guard: glock.}: int

然后,编译器会确保每次访问 gdata 都在 locks 块中:

proc invalid =
  # invalid: unguarded access:
  echo gdata

proc valid =
  # valid access:
  {.locks: [glock].}:
    echo gdata

为了能够方便地初始化,始终允许在顶层访问 gdata假定 (但不强制)所有顶层语句都在发生并发操作之前执行。

我们故意让 locks 块看起来很丑,因为它没有运行时的语意,也不应该被直接使用! 它应该只在模板里出现,再由模板实现运行时的加锁操作:

template lock(a: Lock; body: untyped) =
  pthread_mutex_lock(a)
  {.locks: [a].}:
    try:
      body
    finally:
      pthread_mutex_unlock(a)

守卫不需要属于某种特定类型。它足够灵活,可以对低级无锁机制建模:

var dummyLock {.compileTime.}: int
var atomicCounter {.guard: dummyLock.}: int

template atomicRead(x): untyped =
  {.locks: [dummyLock].}:
    memoryReadBarrier()
    x

echo atomicRead(atomicCounter)

locks 指示接受一个锁表达式列表 locks: [a, b, ...] ,以支持 多锁 语句。

保护常规地址

guard 注解也可以用于保护对象中的字段。这时,需要用同一个对象的另一个字段或者一个全局变量作为守卫。

由于对象可以驻留在堆上或栈上,这就大大地增强了语言的表达能力:

import std/locks

type
  ProtectedCounter = object
    v {.guard: L.}: int
    L: Lock

proc incCounters(counters: var openArray[ProtectedCounter]) =
  for i in 0..counters.high:
    lock counters[i].L:
      inc counters[i].v

x.L 的守卫就可以访问字段 x.v。模板展开后得到:

proc incCounters(counters: var openArray[ProtectedCounter]) =
  for i in 0..counters.high:
    pthread_mutex_lock(counters[i].L)
    {.locks: [counters[i].L].}:
      try:
        inc counters[i].v
      finally:
        pthread_mutex_unlock(counters[i].L)

编译器会分析检查 counters[i].L 是否是用于受保护位置 counters[i].v 的那个锁。 因为这个分析能够处理像 obj.field[i].fieldB[j] 这样的路径,所以我们叫它 path analysis "路径分析"。

路径分析 目前不健全 ,但也不是一点儿用都没有。如果两条路径在语法上相同,则认为是等价的。

这意味着下面的代码(目前)可以编译通过,虽然真不应该如此:

{.locks: [a[i].L].}:
  inc i
  access a[i].v