跳至主要內容

Nix 语言快速入门

NixOS-CN大约 35 分钟

Nix 语言快速入门

基础要求

以下教程需要你具有一定基础。具体来说,如果你已经知道在编程领域什么是变量(variable)、字符串(string)、函数(function)及参数(argument),那么你的知识水平就差不多足够了。

仅面向本文维护者的说明,单击以切换折叠/展开 本文在设计上是线性的,也即只需要读者具备一点点基础,就可以通过按顺序从头读到尾的方式完成本文的学习。因此,请留心说明顺序,例如讲 let 绑定时如果举了一个列表的例子,你需要确保前面已经正式介绍过列表。再如,讲 with 语法糖的时候同时用到 let 绑定和列表,那么这两个概念都需要在前面已经正式介绍过。否则,读者很可能会面对初次接触的语法或者概念而被卡住,这会严重影响学习效率甚至是完成率。若出于顺序安排的其他合理性原因,实在无法避开在说明中涉及陌生概念,可以提示读者相关部分不需要理解,后面会讲到。另外,常用的 callout 块中,info 显示为蓝色,适用于普通知识点;而 note 显示为灰色,适用于“就算不理解也没关系”的高阶或补充知识。至于 tip 和 warning,它们有可能涉及到知识,也可能不涉及,重点区别在于 tip 偏向于“实用建议/能帮助理解或加强记忆的提示”,而 warning 偏向于“能够避免出现问题或损失的提示”(包括时间精力方面的)以及“注意避开误区的说明”。

Nix 作为语言,是一门简单的函数式语言,它被专门设计并用于 Nix 包管理器及相关生态(NixOS、Home-Manager 等)。

实践环境

在学习 Nix 语言时,虽然不是必须,但若动手实践,效率往往会高得多。

以下给出两种实践方法。这不是必须的,你也可以跳过本节。

注意

本节需要你已经安装了 Nix 或正在使用 NixOS。

另外,本教程中的示例代码并不全是为了供直接运行而写的。对于 Nix 来说,运行代码被称为求值(evaluate),而只有表达式(expression)能被求值;但是,示例的代码未必是表达式,此时若仍想进行测试,则需要你基于此示例,自己编写测试代码。

交互模式

你可以通过在命令行运行

nix repl

进入交互模式,其界面类似下面的样子:

Nix 2.31.1
Type :? for help.
nix-repl>

此时输入表达式,例如

1 + 2

回车即可求值,得到结果如下:

3

提示

输入 :q 可以退出交互模式。

文件求值

交互模式简单快捷,但我们平时使用 Nix 语言进行编辑配置、打包等操作时,大多数情况下不会直接使用交互模式,而是对 *.nix 纯文本文件进行编辑。

因此,如果你习惯于使用编辑器,这里更推荐利用文件求值进行实践。每个 nix 文件的内容都是一个表达式,这是 nix 文件能被求值的前提。

例如,新建文件 foo.nix,将其内容编辑如下:

1 + 2

保存后,在命令行运行

nix-instantiate --eval foo.nix

结果如下:

3

拓展说明:求值的惰性与嵌套迭代

此部分内容较长,仅供有兴趣的人阅读。

单击以切换折叠/展开

Nix 的求值具有惰性(laziness),只会在有必要时进行。例如,下述代码(看不懂没关系)将名称 a 分配给值 20\dfrac{2}{0} ,这是一种典型的数学错误:

let a = builtins.div 2 0; b = 3; in b

运行它所得结果为 3,竟然不会报错?实际上,这正是因为 a 的值不被需要(只需要输出 b 的值),所以也未被求值。


与惰性类似的是另一种行为是,嵌套属性集的求值,在交互模式和文件求值模式下,除非必要,默认不会迭代,而是以占位符替代,例如

{ a.b.c = 1; }

使用交互模式,结果如下

{
  a = { ... };
}

使用文件求值的结果如下

{ a = <CODE>; }

不过,与惰性不同,迭代求值的行为可以直接控制。

  • 交互模式:在开头添加 :p,例如 :p { a.b.c = 1; }
  • 文件求值:添加 --strict 参数,例如nix-instantiate --eval --strict foo.nix

结果如下:

{
  a = {
    b = { c = 1; };
  };
}

好了,下面正式介绍 Nix 语法。

注释、缩进与换行

注释、缩进与换行的语法与机制,对编程语言的风格有重要影响。本节将介绍 Nix 语言中的注释、缩进与换行。

  • 注释:在 Nix 语言中,用 # 表示注释,在它之后直到行末的部分都会被忽略。
  • 缩进与换行:与 Python 这种对缩进有要求的语言不同,在 Nix 语言中,大多数情况下,换行与缩进只是为了更好的可读性,并不影响代码的本质。

例如,下面的两段示例代码(你目前还不需要理解它们的含义),它们在本质上(也即在 Nix 解释器看来)并没有区别。

第一例:

{ a = 1; b = 2; }

第二例:

{ # 这是一句注释,放在代码所在行的末尾。
# 这也是一句注释,单独占了一行。
  a = 1;    # 这一行即使不缩进,也不影响代码本质。
  b = 2;
  # c = 3;  # 这里的代码被注释掉了,相当于不存在。
}

注意

换行与空格一样具有分隔作用,请勿在不可分隔的地方胡乱断行。

名称与属性集

变量是大多数编程语言中最基础的概念,而与之类似的名称则是 Nix 语言中最基础的概念。本节将会介绍 Nix 中如何将名称分配给值,以及最常用的数据类型——属性集,继而引出递归属性集列表的概念。

名称和值

我们可以使用 = 将名称(name)分配给值(value),形成“名称—值”对。

例如将名称 foo 分配给值 123

foo = 123

如何测试此示例

上面的示例不属于表达式(但可以作为表达式的一部分),所以你无法将它直接写入 nix 文件进行文件求值;不过 nix repl 有一些灵活的处理,允许你输入这样的结构。

函数式语言与命令式语言中“变量”的区别

太长不看版:在 Nix 里,“名称”就是“变量”,只是这个变量一旦绑定便成永恒;它保留了数学“可取不同值”的语义,却丢掉了命令式“可重新赋值”的含义。

维度命令式语言Nix(函数式)
底层模型存储格(内存单元)无存储格,只有「名称—值」映射
操作赋值:随时把新值写回同一单元绑定:一次性把名称贴到值,不可重写
所谓“变量”存储格的别名 → 之后可反复擦写数学意义上的变量 → 同一作用域内值固定
文档用词variable = 可重写的存储格variable = 一次性绑定的名称(不会变)

名称的值并不仅限于 123 这种整数。一些常见的数据类型如下(不需要完全理解,留下印象即可)

  • 字符串(string),例如 "Hello world"
  • 整数(integer),例如 1
  • 浮点数(float),例如 3.141
  • 布尔(bool),只有 truefalse 两种
  • null,只有 null 一种
  • 列表(list),例如 [ 1 "tux" false ]
  • 属性集(attribute set),例如 { a = 1; b = "tux"; c = false; }
  • 函数(function),例如 x: x + 1

属性集

在 Nix 语法中,属性集是最常见的数据类型之一,基本示例如下:

{
  a = 1;
  b = 2;
}

概念说明:

  • 属性集(attribute set)就是装载若干对名称与值的集合。
  • 属性集内的名称被称为这个属性集的属性(attribute);
  • 属性集内由名称和值组成的对被称为该属性的元素(element);

语法说明:

  • 属性集以 { } 为边界,其内部为多个“名称—值”对,且它们末尾必须添加 ;

属性集 { a = 1; b = 2; } 中有两个属性:

  • 属性 a,其值为 1
  • 属性 b,其值为 2

属性的值除了可以是 1 2 这样的数值外,也可以是一个属性集(也即支持嵌套),例如将 b 的值改为属性集 { c = 2; d = 3; }

{
  a = 1;
  b = {
    c = 2;
    d = 3;
  };
}

嵌套属性集中的属性也可以利用 . 表示,例如上面这段的一种等价写法如下:

{
  a = 1;
  b.c = 2;
  b.d = 3;
}

提示

上面的写法被称为“属性访问”,后面会再次介绍。

递归属性集

普通的属性集不支持递归引用,举个例子:

{ 
  a = 1;
  b = a + 1;
}

对上面的表达式求值,会报错:

error: undefined variable 'a'

可见,当属性集内的属性 b 需要访问该属性集的另一个属性 a 时,即使 a 是“先”定义的,也无法访问到。

此时就需要我们改用递归(recursive)属性集,它相比普通的属性集,在前面多加了 rec

rec {
  a = 1;
  b = a * 2 + 1;
}

对上面的表达式求值,结果如下:

{ a = 1; b = 3; }

求值结果的排序依据

可以看到,结果中的 a = 1 在前面,b = 3 在后面。这种顺序实际上与任何其它因素(包括声明顺序、求值依赖关系)都无关,而只与属性名称本身的排序有关。

例如,对 rec { a = 1; b = 2; }rec { b = 2; a = 1; } 的求值,都会把 a = 1 放在前面,归因到底,只是 a 在字母表中位于 b 之前罢了。

(直接原因则与 Nix 解释器对名称排序所用到的算法或者调用的库有关,这里不再深入。)

拓展说明:求值过程的顺序机制

既然求值结果的排序与求值顺序等因素无关,那么求值顺序由什么决定呢?

将刚才例子中属性集里的两个元素位置对调:

rec {
  b = a * 2 + 1;
  a = 1;
}

你会发现,Nix 解释器似乎能自动处理求值顺序,并不会因为 a 的声明被调整到后面而影响求值结果(与之前的完全一致,从略)。

这看起来相当“智能”,你甚至可以写得更复杂一些,比如 Nix 解释器也能自动处理下面的例子(结果略):

rec {
  c = a * 2 - b + d - 35;
  a = 12;
  b = d * 2 + 64;
  d = a - 15;
}

不过,这并不代表你可以直接用它来解方程。例如我们再写一个在数学上有唯一解的方程组:

rec {
  b = a * 2 + 1;
  a = b + 1;
}

此表达式求值的输出如下:

{
  a = «error: infinite recursion encountered»;
  b = «error: infinite recursion encountered»;
}

由此可见,递归属性集内部处理求值顺序的机制,确实是递归的,而如果递归陷入死循环就会报错。

列表

之前我们学习了属性集,它含有多个元素,例如:

{
  a = "apple";
  b = "orange";
  c = "banana";
}

上面的名称 a b c 或许可以有明确的含义,但有些场景不需要这些名称,而只关心后面的值,这种情况下就可以使用列表,例如:

[ "apple" "orange" "banana" ]

需要注意语法细节:

  • 列表以 [ ] 为边界,其内部为多个元素,每个元素都是值。
  • 元素之间使用空格(或换行)分隔,各元素; 结尾。

let 绑定与属性访问

前面关于名称的使用是非常基本的,本节要介绍的let 绑定属性访问 则提供了更灵活的处理方法。

let 绑定

有时我们希望在指定的范围内为值分配名称,此时就可以使用 let 绑定,示例如下:

let
  a = 1;
  b = 2;
in
  a + b  # 结果是 3

注意语法细节:

  • letin 之间的“名称—值”对以 ; 结尾;
  • in 之后只有一个表达式。注意,这只是语法形式上的要求,并不代表 let 绑定的用处很有限,因为表达式本身可以很复杂,常见的是嵌套属性集。作为基本示例,下面演示刚刚学到的列表:
let
  b = a + 1;
  c = a + b;
  a = 1;
in
  [ a b c ]

求值的结果如下:

[ 1 2 3 ]

作用域

let 绑定是有作用域的,绑定的名称只能在作用域使用,或者说每个 let 绑定的名称只能在该表达式内使用。

例如下面的例子:

{
  a = let x = 1; in x;
  b = x;
}

由于 b = x; 不在作用域之内,会有报错如下:

error: undefined variable 'x'

拓展说明:局部变量(?)

Nix 中不存在“全局变量”,因而“局部变量”的说法可能引起误会,应当尽量避免使用。

不过,Nix manualopen in new window 中对 let 绑定的介绍提到了局部变量(local variable)。

A let-expression allows you to define local variables for an expression.

这种说法可能不合适,但既然官方文档也有用到,其他地方自然也可能会出现,留心即可。

属性访问

前面提到,嵌套属性集中的属性可以利用 . 表示,这被称为属性访问(attribute access)。

在下面这个例子中,我们定义了一个嵌套属性集 a,并使用 a.b.c 访问值 123

let
  a = { b = { c = 123; }; };
in
  a.b.c

求值,结果如下:

123

利用访问属性的写法,可以更加方便地为值分配名称。比如,上面的例子也可以这样写(返回结果不变):

let
  a.b.c = 123;
  # 还可以写成下面这样
  # a = { b.c = 123; };
  # 再或者这样
  # a.b = { c = 123; };
in
  a.b.c

小结

本小节给出了属性访问的两种应用场景,第一种是获取属性的值,第二种是为值分配属性名称。

显然,第二种场景不是必须使用属性访问的写法,它只是更方便。仅就这个场景来看,这是一种语法糖

我们将在下一节介绍另外两种常用的语法糖。

语法糖 withinherit

语法糖(syntactic sugar)是对语言功能没有影响,但更方便使用的一种语法。

本节将介绍两种常用的语法糖 withinherit

with 表达式

with 能简化特定形式的列表。

  • 举个例子,列表 [ a.x a.y ] 中出现了两次 a
  • with 表达式就可以帮助你简化这种列表,将其写作 with a; [ x y ]
  • 注意语法细节:a 后面有一个分号 ;,而 with 表达式的作用域为分号后的第一个列表。

在下面的例子中,由于 [ a.x a.y ] 等价于 with a; [ x y ];R1R2 的值一致。

let
  a = {
    x = 1;
    y = 2;
  };
in
  {
    R1 = [ a.x a.y ];
    R2 = with a; [ x y ];
  }

进行严格求值,返回结果:

{ R1 = [ 1 2 ]; R2 = [ 1 2 ]; }

就近性

不过,这种等价并不是恒定的,比如下面的例子,我们在 let 后面直接加一行 x = 0;

let
  x = 0;
  a = {
    x = 1;
    y = 2;
  };
in
  {
    R1 = [ a.x a.y ];
    R2 = with a; [ x y ];
  }

进行严格求值,返回结果:

{ R1 = [ 1 2 ]; R2 = [ 0 2 ]; }

可见,with 表达式具有一种“就近性”,当 x 的值可以不经嵌套地直接访问时,它会直接返回这个值,而不会使用 a 来嵌套地访问。

inherit 语法

首先说明什么是“继承”。

例如下面的表达式:

let
  a = 1;
  b = 2;
  x = 3;
  y = 4;
in
  {
    m = a;
    n = b;
    x = x;
    y = y;
  }

求值结果:

{ m = 1; n = 2; x = 3; y = 4; }

在此例中,

  • m 获取了 a 的值,n 获取了 b 的值。
  • x y 则直接从同名变量获取值,这被称为“继承”(inherit)。

下面将要介绍的 inherit 语法,则简化了这种继承所需的“名称—值”对。比如,刚才的例子可以这样写(求值结果不变):

let
  a = 1;
  b = 2;
  x = 3;
  y = 4;
in
  {
    m = a;
    n = b;
    inherit x y;
    # 也可以分开写
    # inherit x;
    # inherit y;
  }

注意

inherit 的语法结构,例如上面的 inherit x;,本质上仍然属于“名称—值”对,不属于表达式。

inherit 还支持前置一对括号 () 包裹属性集,实现属性访问的效果。例如下面的例子:

let
  a = { x = 1; y = 2; };
in
  {
    inherit (a) x y;
    # 等价于
    # x = a.x;
    # y = a.y
    # 注:这里没有列表,不要和 with 混淆。
  }

严格求值,结果如下:

{ x = 1; y = 2; }

利用 inherit 提升“嵌套级别”

由于 inherit 可实现这种属性访问的效果,它的用法还可以更灵活。比如下面的例子:

let
  a = { x = 1; y = 2; };
in
  with a; [ x y ]

严格求值,结果如下:

[ 1 2 ]

而如果利用 inherit 我们还可以这样写:

let
  inherit ({ x = 1; y = 2; }) x y;
  # 等价于
  # x = { x = 1; y = 2; }.x;
  # y = { x = 1; y = 2; }.y;
in
  [ x y ]

对其严格求值的结果不变。可见,利用 inherit,我们变相地提升了 { x = 1; y = 2; } 中属性的“嵌套级别”,在后续代码中得以省去属性访问。

文件系统路径

在 Nix 语言中,文件系统路径(file system paths;简称路径)是一种数据类型,它不同于后面要介绍的字符串类型。

路径的基本语法

在 Nix 语言中,路径的基本语法与 POSIX 的路径虽有共通之处,但有细节上的差异,不注意的话很容易导致问题。

路径有绝对路径(absolute path)和相对路径(relative path)两种,它们都必须满足:

  • 路径至少包含一个 /
    • 对于相对路径,若目标已经在当前目录,可前置 ./
  • 路径不能以 / 结尾。
    • 若有需要,可在 / 后加 .

注意

因路径的语法不正确导致的报错,看起来可能会很奇怪,留心即可。

例如把当前路径 ./. 错写成 .

.

求值报错:

error: syntax error, unexpected '.'

绝对路径/ 开头。

例一:/etc/os-release 文件

/etc/os-release
  • 不能写成 /etc/os-release/
    • 注意,这不是因为 os-release 属于文件而非目录,而是因为路径不能以 / 结尾。
  • 可以写成 /etc/os-release/.

求值结果:

/etc/os-release

例二:根目录

/.
  • 不能写成 /(路径不能以 / 结尾)

求值结果:

/

相对路径不以 / 开头,且求值结果与当前所在目录(以下假设 /home/user)有关。

例一:当前目录(用 . 表示):

./.
  • (虽然一般不合适)还可以写成 ././././.
  • 但是不能写成 .(缺少 /,不构成路径)

求值结果:

/home/user

例二:当前目录下的 Downloads

./Downloads
  • 也可以写成 Downloads/.
  • 但是不能写成 Downloads(缺少 /,不构成路径)
  • 也不能写成 Downloads/(路径不能以 / 结尾)

求值结果:

/home/user/Downloads

例三:用 .. 指定上级目录

../../etc
  • 也可以写成 ./../../etc

求值结果:

/etc

检索路径

检索路径(lookup paths)又名“尖括号语法”(angle bracket syntax),是通过系统变量来获取路径的语法。其最简单的形式是以一对尖引号 < > 包裹所需内容。

例如:

注意

请不要急着运行下面的示例,因为它实际包含更多内容。

<nixpkgs>

这个时候 <nixpkgs> 实际上依赖了系统变量中一个名为$NIX_PATHopen in new window 的路径值:

/nix/var/nix/profiles/per-user/root/channels/nixpkgs

注意

我们建议你避免使用检索路径来指定其它相对路径,比如下面的例子:

<nixpkgs/lib>

这是一种污染,因为这样指定相对路径会让配置与环境产生联系。我们的配置文件应该尽量保留纯函数式的特性,即输出只与输入有关,纯函数不应该与外界产生任何联系。

字符串

字符串(string)是一种常见的数据类型,其最简单的形式是以一对双引号 " " 包裹所需内容。

例如:

"hello world!"

字符串插值

字符串插值,这个功能是各大流行语言的标配。

在 Nix 中,使用 "${ ... }" 可以插入名称的值:

let
  name = "Nix";
in
  "hello ${name}"

输出为:

"hello Nix"

名称的值的数据类型

字符串插值语法支持的值必须为字符串类型,例如:

let
  x = 1;
in
  "${x} + ${x} = ${x + x}"

由于 x 的值为数字类型,对此求值的报错如下:

# ... 前面略
error: cannot coerce an integer to a string

如果确实需要将数字作为插值参数,应该怎么办呢?

虽然有点早,提前告诉你—— 对于非字符串类型,可以显式使用内置函数 toString,将其转换为字符串类型:

let
  x = 1;
in
  "${toString x} + ${toString x} = ${toString (x + x)}"

求值的结果如下:

"1 + 1 = 2"

字符串插值也支持嵌套,例如:

let
  a = "pen";
  b = "apple";
  c = "pineapple";
in
  {
    # 请注意 plus 和 equals 两侧留出的空格
    # 对比下面两行,它们的值完全一致
    L1="${a + " plus ${b + " equals ${c}"}"}.";
    L2="${a+" plus ${b+" equals ${c}"}"}.";
  }

求值结果如下:

{
  L1 = "pen plus apple equals pineapple.";
  L2 = "pen plus apple equals pineapple.";
}

多行字符串

有时我们需要用字符串表示多行内容,此时可利用转义,将 \n 作为换行符。

比如对于以下内容:

Please run
  cat /etc/os-release
to get distro info.

可用字符串表示为

"Please run\n  cat /etc/os-release\nto get distro info.\n"

求值结果如下:

"Please run\n  cat /etc/os-release\nto get distro info.\n"

提示

上面的求值结果看起来仍然不是多行的,但其实从数据本身内容来说是没有问题的。

如果想要渲染出多行的样子,文件求值时可以加 --raw 参数,比如 nix-instantiate --eval --raw foo.nix,结果如下:

Please run
  cat /etc/os-release
to get distro info.

而若使用 nix repl 则需要其它方法来达成目的,这里不再展开。

但是,这样做的可读性较差,可维护性也不好。

一个更合适的方法是使用缩进字符串(indented strings),也称为多行字符串(multi-line strings)。

其基本形式为,用两组 '' 作为开头和结尾,中间包裹所需内容。

比如刚才的例子等价于:(求值结果不变)

''
Please run
  cat /etc/os-release
to get distro info.
''

智能去除缩进

Nix 的多行字符串会统一去除开头的缩进,这在其他语言中是不常见的。

比如刚才的例子还等价于:(求值结果不变)

''
    Please run
      cat /etc/os-release
    to get distro info.
''

字符串中的字符转义

在单行字符串中, Nix 的转义语法与许多其他语言相同, " \ ${ 以及其他 \n \t 等特殊字符,都可直接使用 \ 进行转义。

比如,内容 this is a "string" \ 可用下面的代码表示:

"this is a \"string\" \\"

但在多行字符串中,不是使用 \,而是使用 '' 来转义。

比如,下面的例子会输出原始字符 ${a},而不是做字符串插值:

let
  a = "1";
in
''the value of a is:
  ''${a}
''

求值结果如下:

"the value of a is:\n  \${a}\n"

其他 \n \t 等特殊字符的转义也类似,必须使用两个单引号来转义,如

''
  this is a
  multi-line
  string
  ''\n
''

拓展说明:连续多个单引号

对于多个单引号来说,因为 '' 本身被用来转义,输出它们的方法有些特殊:

  • 若要在字符串中使用原始字符 ''(2 个),可以用 '''(3 个)。
  • 若要在字符串中使用原始字符 '''(3 个),可以用 ''''(4 个)。

例如:

let
  a = "1";
in
''the value of a is:
  '''${a}'''
''

求值结果如下:

"the value of a is:\n  ''1''\n"

而对于更多的单引号,转义机制较为复杂。

  • 一般地,当存在连续 nn 个单引号时(n3,nZn\ge 3,n\in \mathbb Z),令 N=nmod3N=n\bmod 3
    • N2N\neq 2 则会转义出 nN3×2+N=2n+N3\frac{n-N}{3}\times 2+N=\frac{2n+N}{3} 个单引号。
    • N=2N=2 则会报错:
      error: syntax error, unexpected end of file, expecting IND_STR or DOLLAR_CURLY or IND_STRING_CLOSE
      
  • 反过来说,若需要在字符串中使用连续 mm 个单引号作为原始字符(m2,mZm\ge 2,m\in \mathbb Z),令 M=mmod2M=m\bmod 2,则需要 M+mM2×3=3mM2M+\frac{m-M}{2}\times 3=\frac{3m-M}{2} 个单引号来进行转义。

函数

作为一门函数式编程语言(functional programming language), Nix 中函数的地位非常重要。

函数的基本构成

函数由参数和函数体组合而成,它们之间由 : 分隔。

例如,对于数学上的函数 f(x)=x+1f(x)=x+1 ,用 Nix 的函数表达如下:

x: x + 1

在此例中,冒号左边的 x 是参数,右边的 x+1 是函数体。

匿名函数与 λ

机智的你可能会发现,此示例实现的 f(x)=x+1f(x)=x+1 并不完整——

毕竟,f(x)=x+1f(x)=x+1 的函数名 ff 去哪里了?

确实,上面的例子少了函数名,它没有和名称绑定,被称为匿名函数

我们对它进行求值,结果如下:

<LAMBDA>

这里的 LAMBDA(即希腊字母 λ)就是函数的代表符号。

在一些语言中,λ 特指匿名函数,不过,在 Nix 语言中,<LAMBDA> 只是一种数据类型,指代一般的函数。

至于为什么 λ 被用来代表函数,请自行搜索“lambda 演算”以及“函数式编程”,这里不再展开。

直接调用匿名函数

利用 ( ) 将匿名函数的整体包裹起来,就可以直接调用了,例如

(x: x + 1) 2

求值结果为 3

函数是一种数据类型,自然可以将函数与名称绑定。

沿续前一个例子,我们将函数 x: x + 1 绑定到名称 f,并且将 2 作为其参数来调用:

let
  f = x: x + 1;
in
  f 2

求值,结果如下:

3

这相当于先定义函数 f(x)=x+1f(x)=x+1 ,再求 f(2)f(2) 的值,结果为 3。

作为参数的属性集:基本形式

在前面的例子中,我们只实现了一个简单的一元函数 f(x)=x+1f(x)=x+1

那么对于多元函数,比如 f(x,y)=3x+y2f(x,y)=3x+\frac{y}{2},在 Nix 中应该怎么实现呢?

  • 坏消息是,根据 Nix 语法规范,每个函数在形式上有且仅有一个参数
  • 好消息是,这个参数可以是属性集,并且在函数体中可以将属性集中的各个属性单独拿出来使用

提示

“每个函数在形式上有且仅有一个参数”,这个特性其实不算缺点,比如它为函数的柯里化(之后会介绍)提供了方便。

例如

{ x, y }: ( 3 * x ) + ( y / 2 )

上面的函数虽然仅接受一个参数(属性集 { x, y }),实际功能却相当于数学上的二元函数 f(x,y)=3x+y2f(x,y)=3x+\frac{y}{2}

属性集的语法细节

函数定义中作为参数出现的属性集,只包含属性名称,并且用 , 分隔。

这与之前介绍的属性集和列表都不同。

作为对比,下面是一个标准的属性集的示例:

# 注意分号不是分隔而是后缀,这里出现了两次
{ a = 1; b = 2; }

再来一个列表的示例:

# 用空格分隔
[ a b ]

我们为前面例子中的匿名函数绑定名称 f,并且以参数 { x = 1; y = 4; } 来调用它:

let
  f = { x, y }: ( 3 * x ) + ( y / 2 );
in
  f { x = 1; y = 4; }

求值,结果如下:

5

这相当于定义了函数 f(x,y)=3x+y2f(x,y)=3x+\frac{y}{2} 之后求值 f(1,4)f(1,4),结果为 5。

更多示例

Nix 的函数也能处理其它数据类型。

例如,定义一个函数 concat3 并调用它来拼接 "Hello" " ""world"

let
  concat3 = { a, b, c }: a + b + c;
in
  concat3 { a = "Hello"; b = " "; c = "world"; }

求值结果如下:

"Hello world"

调用函数进行的求值,自然也可以嵌套使用。例如,定义一个函数 concat2,并两次调用它来拼接 "Hello" " ""world"

let
  concat2 = { a, b }: a + b;
in
  concat2 {
    a = concat2 { a = "Hello"; b = " "; };
    b = "world";
  }

由于 concat2 接受的属性集仅含两个属性,此例先拼接了 "Hello"" ",再将此结果与 "world" 拼接。求值结果仍然为 "Hello world"

注意

函数被调用时所接受的属性集,必须符合定义中作为参数的属性集的要求,否则就会报错。

例如前面的 concat2 函数,我们多给一个 c 的值:

let
  concat2 = { a, b }: a + b;
in
  concat2 { a = "Hello"; b = "world"; c = "!"; }

求值,报错:

error: function 'concat2' called with unexpected argument 'c'

或者,这次我们只给出 b 的值:

let
  concat2 = { a, b }: a + b;
in
  concat2 { b = "world"; }

求值,报错:

error: function 'concat2' called without required argument 'a'

但是,前述要求可以设置得更加灵活,下面的若干节将会对此进行介绍。

作为参数的属性集:属性默认值

在属性后面加 ? <value> ,会将此属性的默认值设为 <value>

在下面的例子中,我们来定义一个“问候”函数 greet。其功能是:

  • 使用作为问候语的参数 greeting
  • 对作为问候对象的参数 object 进行“问候”。

我们可以将最常用的问候语(例如 "Hello, ")作为默认值,这样就可以选择不传入此参数,而直接采用默认值。

实例如下,注意在函数定义中,我们在参数 greeting 后面附加了 ? "Hello, "

let
  greet = { greeting ? "Hello, ", object }: greeting + object + "!";
in
  {
    # 对 world 进行问候(默认问候语)
    R1 = greet { object = "world"; } ;
    # 对 my friend 进行问候(默认问候语)
    R2 = greet { object = "my friend"; } ;
    # 对 my friend 进行问候(自定义问候语)
    R3 = greet { greeting = "Welcome, "; object = "my friend"; } ;
  }

严格求值,结果如下:

{
  R1 = "Hello, world!";
  R2 = "Hello, my friend!";
  R3 = "Welcome, my friend!";
}

作为参数的属性集:额外属性

前面已经提到,在调用函数时如果传入额外属性,会引发报错。但有时我们需要传入额外属性,此时就需要在属性集中添加一个占位符 ...

例如:

let
  concat2 = { a, b, ... }: a + b;
in
{
  R1 = concat2 { a = "Hello "; b = "world"; };
  # 传入额外属性 c,这次不会引发报错
  R2 = concat2 { a = "Hello "; b = "world"; c = "!"; };
}

严格求值结果如下:

{ 
  R1 = "Hello world";
  R2 = "Hello world";
}

注意这里的 R1R2 的值相同,因为 c 作为额外属性,不能出现在函数定义中,自然也不会参与计算。

注意

在函数定义中,若函数体使用了参数中未定义的属性,不论参数是否含 ... 都会报错。例如:

let
  # 参数中没有 c,但函数体里有 c
  concat2 = { a, b, ... }: a + b + c;
in
  concat2 { a = "Hello "; b = "world"; c = "!"; }

求值,报错(注意这个报错发生在函数的定义部分):

error: undefined variable 'c'

作为参数的属性集:命名属性集

这里再次展示前面举过的例子,定义函数 f(x,y)=3x+y2f(x,y)=3x+\frac{y}{2},求值 f(1,4)=5f(1,4)=5,用 Nix 实现如下:

let
  f = { x, y }: ( 3 * x ) + ( y / 2 );
in
  f { x = 1; y = 4; }

求值,结果如下:

5

命名属性集

与匿名函数的概念类似,若一个属性集没有与名称绑定,则称其为匿名属性集。反之,则称为命名属性集。

此例的函数定义中,匿名属性集 { x, y } 作为了参数。

而命名属性集也可以作为参数,此时往往需要结合属性访问。

例如,上面的例子等价于:(求值结果不变)

let
  # 用命名属性集 A 代替了匿名属性集 { x, y }
  # 同时 x、y 也要改用属性访问的写法 A.x、A.y
  f = A: ( 3 * A.x ) + ( A.y / 2 );
in
  f { x = 1; y = 4; }

此外,函数的参数可以是一个命名属性集与一个匿名属性集的结合,两者以 @ 连接(先后顺序不限),并且匿名属性集必须包含 ... 以允许额外属性。

例如,上面的例子还等价于:(求值结果不变)

let
  f = { x, ... }@A: ( 3 * x ) + ( A.y / 2 );
  # 也可以写成
  # f = A@{ x, ... }: ( 3 * x ) + ( A.y / 2 );
in
  f { x = 1; y = 4; }

柯里化函数

前面已经提到如何直接调用匿名函数,现在考虑下面的表达式:

( y : 1 * y ) 10
# 结果为 10

再考虑表达式:

( y : 2 * y ) 10
# 结果为 20

再考虑表达式:

( y : 3 * y ) 10
# 结果为 30

可以看到,尽管参数始终为 10,表达式的结果会随着匿名函数的函数体内部这个乘数的变化(从 1、2 变到 3)而变化(从 10、20 变到 30)。本质上,这是函数本身在随着这个乘数的变化而变化。

这种变化关系自然也是一种函数关系。换句话说,设这个乘数为 x,这形成了由 x y : x * y 的函数关系。

这个函数关系本身,也可以用 Nix 的函数来实现:

x : ( y : x * y )
# 虽然会降低可读性,也可以这样写:
# x : y : x * y

这个函数接受一个参数,我们将其命名为 f 并传入 4 来测试:

let
  f = x : ( y : x * y );
in
  f 4

求值,结果如下:

<LAMBDA>

不出我们预料,f 4 是一个函数。实际上这个函数正是

y : 4 * y

f 4 接受一个参数,我们传入 10 来测试:

let
  f = x : ( y : x * y );
in
  f 4 10

求值结果为 40。

以上,通过两个一元函数的嵌套,我们得以先传入一个参数,再传入另一个参数。

就最终的实际功能来说,这个函数与普通的二元函数都能接受两个参数;只不过,之前我们实现二元函数的方法是利用属性集,比如

let
  g = { x, y }: x * y;
in
  g { x = 4; y = 10; }

求值结果仍然为 40。

观察前面例子中 gf 的函数定义,它们的函数体中都含有 x * y,只是接受参数的方式不同。

gf,相当于把一个二元函数 g 改写为了函数 f 这样的两个一元函数的嵌套序列

一般地,将一个 nn 元函数改写为 nn 个一元函数的嵌套序列,这个过程就被称为柯里化(currying)。

柯里的由来

“柯里”是 curry 的音译(也可译作“卡瑞”“加里”等),它得名自数理逻辑学家 Haskell Brooks Curry。

curry 还有其它音译,但它们可能代表完全不同的其他含义,例如咖喱、库里,等等。

柯里化:闭包与嵌套

在前面的例子中,函数 f 4 保存了 x = 4 的这种状态,这种函数被称为闭包(closure)。

(支持闭包机制的语言很多,并且尤其在 Javascript 等采用动态变量的语言中,闭包的一个重要作用就是将捕捉闭包时外部变量的状态保存下来;但本文的例子中,闭包则是保存传入参数的值,不涉及外部变量。受篇幅限制且为了避免理解困难,这里不介绍闭包的完整概念,感兴趣可自行了解。)

实际上,柯里化就是通过闭包与嵌套来实现的。

闭包,有时可以避免重复传入参数。

之前,为了演示默认值,我们自定义了一个问候函数 greet

let
  greet = { greeting ? "Hello, ", object }: greeting + object + "!";
in
  {
    R1 = greet { object = "world"; } ;
    R2 = greet { object = "my friend"; } ;
    R3 = greet { greeting = "Welcome, "; object = "my friend"; } ;
  }

这里我们将 greet 函数柯里化,利用闭包来实现,甚至可以更加简洁(求值结果与之前例子相同):

let
  greet = greeting : ( object : greeting + object + "!" );
  # greet_Hello 就是一个闭包,调用它可以避免重复传入 "Hello, "
  greet_Hello = greet "Hello, ";
in
  {
    # 对 world 进行问候(用 greet_Hello)
    R1 = greet_Hello "world";
    # 对 my friend 进行问候(用 greet_Hello)
    R2 = greet_Hello "my friend";
    # 对 my friend 进行问候(自定义问候语)
    R3 = greet "Welcome, " "my friend";
  }

拓展说明:数学上的柯里化

柯里化中的“一元函数”并不是数学意义上的函数,在数学上对应的概念实际上是映射。

例如,三元函数 F(x,y,z)=x+y+zF(x,y,z)=x+y+z 也即映射 F:x,y,zx+y+zF: x,y,z\mapsto x+y+z 可以转换为三个一元映射的嵌套,分别是:

  1. F1:x(y(zx+y+z))F_1: x \mapsto ( y \mapsto ( z \mapsto x + y + z ))
  2. F2:y(zx+y+z)F_2: y \mapsto ( z \mapsto x + y + z )
  3. F3:zx+y+zF_3: z \mapsto x + y + z

函数库

前面我们已经接触到了 +-*/ 等运算符号,实际上它们都属于 Nix 语言中的内建操作符(built-in operator)。

常用的内建操作符还有 == && 等。建议至少浏览一遍内建操作符的文档页面open in new window,以熟悉可用的功能。

除了内建操作符之外,还有两个被广泛使用的函数库(function library),它们加在一起被视为 Nix 语言的事实标准。

builtins

builtins 即内建函数,也称为“原始操作” (primitive operations,简写为 primops)。

Nix 附带许多内建函数,均在 Nix 手册open in new window 列出。

这些函数可以通过常量 builtins 访问,例如前面提到过的 toString

builtins.toString

求值,结果如下:

<PRIMOP>

拓展说明:primop 类型

注意这里返回的结果不是 <LAMBDA>,说明内建函数与普通的函数是有差别的。

实际上,普通的函数由 Nix 语言实现,而这些内建函数则作为 Nix 语言解释器的一部分,由 C++ 实现。

import 函数

有些内置函数只能通过 builtins 访问,但另有一些内置函数,可直接在顶层使用,比如 import, toString, map

import 接受的参数是 Nix 文件的路径,会对其进行文件求值并返回结果。此路径也可以是目录,这种情况下则会使用该目录下的 default.nix 文件。

例如,令 foo.nix 的文件内容为 1 + 2,有如下示例

import ./foo.nix

求值,结果为 3

被导入的 Nix 文件必须是 Nix 表达式,这个表达式自然也可以是函数本身,而函数是可以接受参数的。

例如,令 foo.nix 的文件内容为 x: x + 1,有如下示例

import ./foo.nix 4

求值,结果为 5

pkgs.lib

nixpkgsopen in new window 仓库包含一个名为 libopen in new window 的属性集,它提供了大量有用的函数,详见 Nixpkgs 手册open in new window

这些函数是基于 Nix 语言实现的,而不是像 builtins 那样本身作为语言的一部分而存在。

由于 Nixpkgs 的属性集通常约定命名为 pkgs,因此往往可以通过 pkgs.lib 使用这些函数。

(其实,当下直接使用 lib 而不是 pkgs.lib 的情况更常见,后面会提到。)

例如能够将小写转大写的 pkgs.lib.strings.toUpper 函数,示例:

let
  pkgs = import <nixpkgs> {};
in
pkgs.lib.strings.toUpper "Have a good day!"

求值,结果如下:

"HAVE A GOOD DAY!"

详细说明

上面的例子较为复杂,不过到现在你应该熟悉它的各个组成部分了。

名称 pkgs 被声明为从路径为 <nixpkgs> 的文件 import 出来的表达式。至于 <nixpkgs> 的具体值则由环境变量 $NIX_PATH 决定。由于该表达式是一个函数,需要一个参数才能求值,在这个例子中传入空的属性集 {} 就足够了。

现在 pkgslet ... in ... 的作用域内,其下的属性可以被访问。据 Nixpkgs 手册可知,其下存在一个函数 lib.strings.toUpperopen in new window,作用是小写转大写:

Converts an ASCII string s to upper-case.

Nix 生态中 pkgs、pkgs.lib 和 lib 的约定俗成

pkgs 常被作为参数传递给函数。按约定,可以假设它指的是 Nixpkgs 的属性集,该属性集有一个 lib 属性。

例如,将下面的例子写入 foo.nix

{ pkgs, ... }:
pkgs.lib.strings.removePrefix "I " "I see you!"

在命令行将 { pkgs = import <nixpkgs> {}; } 作为参数,进行文件求值:

nix-instantiate --eval foo.nix --arg pkgs 'import <nixpkgs> {}'

运行结果如下:

"see you!"

而在 NixOS 配置中以及 Nixpkgs 内部,你还经常会看到直接传入 lib 的情况。此时可以假设它指的是 Nixpkgs 的属性集下的 lib,也即前面那种情况下的 pkgs.lib

例如,将下面的例子写入 foo.nix

{ lib, ... }:
lib.strings.removePrefix "I " "I see you!"

在命令行将 { lib = (import <nixpkgs> {}).lib; } 作为参数,进行文件求值:

nix-instantiate --eval foo.nix --arg lib '(import <nixpkgs> {}).lib'

运行结果与前面一例相同。

有时还会同时传入 pkgslib,此时可以假设 pkgs.liblib 是等价的。这样做则是为了通过避免重复使用 pkgs.lib 来提高可读性。

示例:

{ pkgs, lib, ... }:
# ... 多次使用 `pkgs`
# ... 多次使用 `lib`

出于历史原因,pkgs.lib 中的一些函数与同名的 builtins 等价。