跳至主要內容

Nix 语言快速入门

NixOS-CN大约 14 分钟

Nix 语言快速入门

提示

Nix 语言的主要工作是描述打包过程。同时 Nix 语言也是一门强类型和动态类型的语言。

交互模式

以下交互式教程需要使用 nix repl 命令调出交互命令模式:

$ nix repl
Welcome to Nix 2.5.1. Type :? for help.

它有点像用于调试 JavaScript 的控制台或 Python 的交互模式?

nix-repl> 1 + 2  # 输入表达式
3  # 输出结果

惰性求值

Nix语言的求值是惰性的,这意味着表达式不会在被绑定到变量后立即求值,而是在该值被使用时才求值。

即时计算被直接依赖的值

nix-repl> { a.b.c = 1; }
{ a = { ... }; }

在上面的例子中,我们输入了一个匿名集合,而这个匿名集合包含 a 集合。

匿名集合

匿名集合即没有分配名称的集合,与之对立的是命名集合,例如 foo = { bar };

a 集合中的值并没有被这个匿名集合直接依赖,自然顶级以下的集合不会被立刻求值。占位的变成了 ...

在下面这个例子,我们将显式声明 qux 的直接依赖:

let
  foo = { bar.qux = 1; };
  lax = foo.bar.qux;
in
  lax  # 我们需要 lax,lax 需要 foo.bar.qux

我们可以输入 :p 启用详尽求值,所有表达式都将被立刻求值:

nix-repl> :p { a.b.c = 1; }
{ a = { b = { c = 1; }; }; }

注意

:p 参数只能在交互模式使用,输入 :q 可以退出交互模式。

文件求值

使用nix-instantiate --evalopen in new window*.nix 文件中存在的表达式进行求值:

$ echo 1 + 2 > file.nix  # 该命令会往 file.nix 中写入 1 + 2
$ nix-instantiate --eval file.nix  # 文件求值
3  # 输出结果

立即求值

在文件求值的情景下可以通过在命令行添加 --strict 参数来启用立即求值。

$ echo "{ a.b.c = 1; }" > file.nix
$ nix-instantiate --eval --strict file.nix
{ a = { b = { c = 1; }; }; }

echo 命令

echo 是 Linux 中最常见的命令之一,主要作用是输出文本,追加文本,返回输出。

你可以键入 help echo 来获取该命令的使用帮助。

代码风格

好的代码风格会让程序员身心愉悦,同时也增加了代码可维护性。

格式化

Alejandraopen in new window 是一个新兴的 Nix 代码格式化工具,使用 Rust 编写。你可以在线尝试open in new window它。

当心空格

空格用于分隔词法标记(Lexical tokens),在一些场景是必要的,不然会无法区分关键字。

在许多中文资料中,混淆了 Lexical,Syntax 和 Grammar 三者的概念:

  • Lexical(词法):是指语言中单词的意义、形态和用法等方面的规则。词法规则定义了单词的基本形态和语法功能,例如名词、动词、形容词等。同时,它还规定了一些特殊单词的用法,例如冠词、介词、连词等。
  • Syntax(句法):是指语言中标记(Token)之间的组合方式,以及这种组合方式所遵循的规则。通俗点说,语法规定了单词应该如何排列、组合成句子,以及这些句子之间的联系方式。
  • Grammar(语法):是指语言中的规则体系,包括了语法规则、语义规则和语用规则等。它涉及到语言的整个结构和组成方式,而不仅仅是句子的构成。

下面的两种示例是等价的:

let
  x = 1;
  y = 2;
in
  x + y

显然,下面的可读性比上面的差很多:

let x=1;y=2;in x+y

名称和值

原始数据类型,列表,属性集与函数都可以被当作值。我们可以使用 = 为名称绑定值,然后用分号分隔赋值语句:

let
  foo = "I am a fool";
  bar = "I am at the bar";
in
  foo + bar

名称不等同常见编程语言中的变量,因为它一旦定义就无法修改。在概念上,它们更多地是形成了一种绑定关系,一个值可以被多个名称绑定,一个名称只能绑定一个值。这种赋值没有副作用(传统的赋值会改变变量的状态,Nix 语言中的变量一旦赋值无法改变)。

属性集

集合

还记得我们在上面提到的集合吗?其实它真正的名字是属性集,没有过早引入属性集的概念是为了方便读者渐进式地理解。

属性集就是装载若干对名称与值的集合,集合内的名称被称为这个集合的属性,集合内中由名称和值组成的对则被称为该属性的元素。示例如下:

{
  string = "hello";
  integer = 1;
  float = 3.141;
  bool = true;
  null = null;
  list = [ 1 "two" false ];
  attribute-set = {
    a = "hello";
    b = 2;
    c = 2.718;
    d = false;
  };  # 标准 json 不支持注释
}

json 样式

你可能觉得莫名的像 json,下面是 json 的示例:

{
  "string": "hello",
  "integer": 1,
  "float": 3.141,
  "bool": true,
  "null": null,
  "list": [1, "two", false],
  "object": {
    "a": "hello",
    "b": 1,
    "c": 2.718,
    "d": false
  }
}

注意到了吗?

  • 属性不需要添加引号
  • 列表是用空格分隔的

递归属性集

当属性集内的属性需要访问该集合的另一个属性时,应当使用递归属性集:

rec {
  one = 1;
  two = one + 1;  # 直接依赖于 one
  three = two + 1; # 直接依赖于 two,间接依赖于 one
}

输出如下:

{ one = 1; three = 3; two = 2; }

元素的声明顺序并不决定元素在属性集中的排布顺序,属性集中的元素排布顺序是由求值顺序决定的,优先被求值的被放在了前面。

let 绑定

一个完整的 let 绑定有两个部分: let 绑定名称与值, in 使用名称。在 letin 之间的语句中,你可以声明需要被复用的名称,并将其与值绑定。它们可以在 in 之后的表达式中发挥作用:

let
  b = a + 1;
  a = 1;
in
  a + b

引用到 a 的地方有两处,它们都会将 a "替换"成值来计算或赋值,类似于常量。

提示

你不需要关心名称的声明顺序,不会出现名称未定义的情况。

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;
}

x 未定义:

error: undefined variable 'x'

       at «string»:3:7:

            2|   a = let x = 1; in x;
            3|   b = x;
             |       ^
            4| }

属性访问

使用 . 访问属性:

let
  attrset = { x = 1; };
in
  attrset.x

访问嵌套的属性也是同样的方式:

let
  attrset = { a = { b = { c = 1; }; }; };
in
  attrset.a.b.c

当然,就像如何访问属性一样,也可以用 . 直接赋值它:

let
  a.b.c = 1;
in
  a.b.c

with 表达式

with 表达式可以让你少写几次属性集的名称,是个语法糖:

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  };
in
  with a; [ x y z ]  # 等价 [ a.x a.y a.z ]

作用域被限制到了分号后面的第一个表达式内:

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  };
in
  {
    b = with a; [ x y z ];
    c = x;  # a.x
  }

x 未定义:

error: undefined variable 'x'

       at «string»:10:7:

            9|   b = with a; [ x y z ];
           10|   c = x;
             |       ^
           11| }

inherit 表达式

inherit 本意就是继承,我们可以使用它完成一对命名相同的名称和属性之间的赋值:

let
  x = 1;
  y = 2;
in
  {
    inherit x y;
  }

没有这个语法糖,我们可能得这样写:

let
  x = 1;
  y = 2;
in
  {
    x = x;
    y = y;
  }

加上括号,就直接从属性集继承名称:

let
  a = { x = 1; y = 2; };
in
  {
    inherit (a) x y;
  }

inherit 同样可以在 let 表达式中使用:

let
  inherit ({ x = 1; y = 2; }) x y;
in
  [ x y ]

等价于:

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

我们变相的将特定属性带到了全局作用域,实现了更方便的解构出名称的方法。

字符串插值

各大流行语言均已支持,使用 "${ ... }" 可以插入名称的值:

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

输出为:

"hello Nix"

字符串插值语法只支持字符串类型,所以引入的名称的值必须是字符串,或是可以转换为字符串的类型:

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

因为是数字类型,所以报错:

error: cannot coerce an integer to a string

       at «string»:4:2:

            3| in
            4| "${x} + ${x} = ${x + x}"
             |  ^
            5|

字符串插值是可以被嵌套的:

let
  a = "no";
in
  "${a + " ${a + " ${a}"}"}"

输出为:

"no no no"

路径类型

路径在 Nix 语言中不是字符串类型,而是一种独立的类型,以下是一些路径的示例:

./relative  # 当前文件夹下 relative 文件(夹)的相对路径
/current/directory/absolute  # 绝对路径,从根目录开始指定
../  # 当前目录的上级目录
../../  # 当前目录的上级的上级目录
./  # 当前目录

检索路径

又名"尖括号语法"。

检索路径是通过系统变量来获取路径的语法,由一对尖括号组成:

<nixpkgs>

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

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

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

<nixpkgs/lib>

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

多行字符串

Nix 中被两对单引号 '' 引用的内容即为多行字符串。

''
multi
line
string
''

等价于:

"multi\nline\nstring"

Nix 的多行字符串存在特殊行为,其一是,Nix 会智能地去除掉开头的缩进,这在其他语言中是不常见的。

举个例子:

''
  one
   two
    three
''

等价于:

"one\n two\n  three\n"

字符串中的字符转义

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

"this is a \"string\" \\"  # 结果是: this is a "string" \

但在多行字符串中,情况会有点特殊。Nix 规定在多行字符串中需要使用两个单引号 '' 来转义。

比如如下 Nix 代码会输出原始字符 ${a},而不是做字符串插值:

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

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

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

但如果我们希望在字符串中使用原始字符 '',因为会与多行字符串原有的语义冲突,不能直接写 '',而必须改用 ''' 三个单引号。也就是说,在多行字符串中的 ''' 三个单引号这样的组合,实际输出的是原始字符串 ''.

举个例子:

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

函数

函数在 Nix 语言中是人上人,我们先来声明一个匿名函数(Lambda):

x: x + 1

引号左边是函数参数,引号右边跟随一个空格,随即是函数体。

这是个嵌套的函数,支持多重参数(柯里化函数):

x: y: x + y

参数当然可以是属性集类型:

{ a, b }: a + b

为函数指定默认参数,在缺省该参数赋值的情况下,它就是默认值:

{ a, b ? 0 }: a + b

允许传入额外的属性:

{ a, b, ...}: a + b  # 明确传入的属性有 a 和 b,传入额外的属性将被忽略
{ a, b, ...}: a + b + c  # 即使传入的属性有 c,一样不会参与计算,这里会报错

为额外的参数绑定到参数集,然后调用:

args@{ a, b, ... }: a + b + args.c
{ a, b, ... }@args: a + b + args.c  # 也可以是这样

为函数命名:

let
  f = x: x + 1;
in
  f

调用函数,并使用函数构建新属性集:

concat = { a, b }: a + b  # 等价于 concat = x: x.a + x.b
concat { a = "Hello "; b = "NixOS"; }

输出:

Hello NixOS

由于函数与参数使用空格分隔,所以我们可以使用括号将函数体与参数分开:

(x: x + 1) 1  # 向该 Lambda 函数传入参数 1

柯里化函数

我们将 f (a,b,c) 转换为 f (a)(b)(c) 的过程就是柯里化。为什么需要柯里化?因为它很灵活,可以避免重复传入参数,当你传入第一个参数的时候,该函数就已经具有了第一个参数的状态(闭包)。

尝试声明一个柯里化函数:

x: y: x + y

为了更好的可读性,我们推荐你这样写:

x: (y: x + y)

这个例子中的柯里化函数,虽然接收两个参数,但不是"迫切"需要:

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

输出为:

<LAMBDA>

f 1 的值依然是函数,这个函数大概是:

y: 1 + y;

我们可以保存这个状态的函数,稍后再来使用:

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

也可以一次性赋值:

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

属性集参数

当我们被要求必须传入多个参数时,使用这种函数声明方法:

{a, b}: a + b

调用该函数:

let
  f = {a, b}: a + b;
in
  f { a = 1; b = 2; }

如果我们额外传入参数,会怎么样?

let
  f = {a, b}: a + b;
in
  f { a = 1; b = 2; c = 3; }

意外参数 c

error: 'f' at (string):2:7 called with unexpected argument 'c'

       at «string»:4:1:

            3| in
            4| f { a = 1; b = 2; c = 3; }
             | ^
            5|

默认参数

前面稍微提到过一点,没有什么需要过多讲解的地方:

let
  f = {a, b ? 0}: a + b;
in
  f { a = 1; }

赋值是可选的,根据你的需要来:

let
  f = {a, b ? 0}: a + b;
in
  f { a = 1; b = 2; }

额外参数

有的时候,我们设计的函数不得不接收一些我们不需要的额外参数,我们可以使用 ... 允许接收额外参数:

{a, b, ...}: a + b

不比上个例子,这次不会报错:

let
  f = {a, b, ...}: a + b;
in
  f { a = 1; b = 2; c = 3; }

命名参数集

又名 "@ 模式"。在上文中,我们已经可以接收到额外的参数了,假如我们需要使用某个额外参数,我们可以使用命名属性集将其接收到一个另外的集合:

{a, b, ...}@args: a + b + args.c  # 这样声明函数
args@{a, b, ...}: a + b + args.c  # 或是这样

具体示例如下:

let
  f = {a, b, ...}@args: a + b + args.c;
in
  f { a = 1; b = 2; c = 3; }

函数库

除了一些内建操作符open in new window+, ==, &&, 等),我们还要学习一些被视为事实标准的库。

内建函数

它们在 Nix 语言中并不是 <LAMBDA> 类型,而是 <PRIMOP> 元操作类型(primitive operations)。这些函数是内置在 Nix 解释器中,由 C++ 实现。查询内建函数open in new window 以了解其使用方法。

builtins.toString()  # 通过 builtins 使用函数

导入

import 表达式以其他 Nix 文件的路径为参数,返回该 Nix 文件的求值结果。

import 的参数如果为文件夹路径,那么会返回该文件夹下的 default.nix 文件的执行结果。

如下示例中,import 会导入 ./file.nix 文件,并返回该文件的求值结果:

$ echo 1 + 2 > file.nix
import ./file.nix
3

被导入的 Nix 文件可以返回任何内容,返回值可以向上面的例子一样是数值,也可以是属性集(attribute set)、函数、列表,等等。

如下示例导入了 file.nix 文件中定义的一个函数,并使用参数调用了该函数:

$ echo "x: x + 1" > file.nix
import ./file.nix 1
2