Nix lnaugage basicsを読んだメモ

最近、パッケージ管理やOSの設定を宣言的にやりたいという気持ちが湧いてきてNixに入門しドキュメントを読んでいたが、やはりサンプルコードに何が書いてあるのかわからないとあまりモチベーションがわかない。 まだNixに登場する概念をほとんどカバーできていないが、先にNix言語から勉強することにした。 多くのドキュメントでNix language basicsが勧められていたのでこれを読んでみることにした。

introduction

Nix言語は、既存のファイルの内容が新しいファイルを導出するのにどのように使われるかを簡単に記述できるよう設計されている。

Nix言語で書かれたファイルは非常に難解に見えるが、それはNix言語はソフトウェアの構築という複雑な問題を解決するのに使われているためであり、Nix言語自体には自在に組み合わせられるわずかなコンセプトが存在するのみだ。 Nix言語のファイルに見られる複雑さはNix言語から来るのではなく、その用途から来るものである。

How to run the examples?

Nix言語のコードの一部分をNix式(Nix expression)と呼ぶ Nix式を評価すると得られる値をNix値(Nix value)と呼ぶ .nixファイルの中身はNix式である

Nix式を評価するというのは、Nix式を言語のルールに則ってNix値に変化させることを言う。

Interactive evaluation

REPLはnix replで起動できる。 Nix言語は遅延評価を採用しているが、このREPLもデフォルトでは必要になるまで式を評価しない。 式を最後まで評価して得られる値が見たい場合、:p expressionとすればよい。

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

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

:qでREPLを終了する。

Evaluating Nix files

.nixファイルに書き込んだ式を評価するにはnix-instantiate --eval example.nixとする。 nix-instantiateは本来はNix式からstore derivationsと呼ばれるものを生成するコマンド。 store derivationsは簡単に言うと、引数などが厳密に指定されたビルド手順のレシピのようだが、今回はNix言語の理解に集中したいため後回しにする。

--evalオプションをつけるとただファイルの中のNix式を評価して得られるNix値を標準出力に出すだけで、store derivationsのinstantiation(これも現時点では不明の概念)まではやらない。

nix-instantiateにファイル名を渡さない場合はdefault.nixというファイルを読み込もうとする。

nix-instantiateも必要になるまで式を評価してくれないので、完全に評価されたNix値を見たい場合は--strictオプションも付ける。

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

Names and values

Nix言語における値にはプリミティブなデータ型、リスト、attribute sets、関数が存在する。

attribute setsは名前と値のペアを集めたもので、JSONと似ている。 ただし、JSONと違って関数を値として持つことができる。 たとえば、以下はプリミティブ型、リスト、attribute setsを値として持つattribute sets。

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

name = value;という形で値に名前が割り当てられる。 他の言語で言うところの変数の束縛だと思われるが、ドキュメントではVariableという言葉が登場せずNameと書いてある。

また、見てわかるとおりリストの要素はスペース区切り。

Recursive attribute set rec { ... }

先頭にrecを付けて宣言されたattribute setsの中では、式の中でそれまでに値に付けた名前を使うことができる。

rec {
  one = 1;
  two = one + 1;
  three = two + 1;
}
nix-repl> :p rec { one = 1; two = one + 1; three = two + 1; }
{
  one = 1;
  three = 3;
  two = 2;
}

attribute setの要素は好きな順に宣言して問題なく、評価時に順序が決まる。

わざわざrecを付けないとこうできないようにした理由が何かあってこの先明らかになるのだろうか。 気になるところ。

let ... in ...

letとinの間で値に名前を付け、inの後にその名前を使って組み立てた式を評価した値が返される。

let
  a = 1;
in
  a + a
nix-repl> let a = 1; in a + a
2

Haskellのlet式と同じっぽい...と思ったら値に名前を付ける順番は好きにやっていいらしい。

nix-repl> let b = a + 1; a = 1; in a + b
3

let式と再帰的なattribute setsはどのような順番で値に名前を割り当ててもよく、他の名前を=の右側の式に使ってもよいという点で似ている。 一方で再帰的なattribute setsはattribute setとして評価されるが、let ... in ...からは(attribute setも含めて)どんな値でも返せる。

nix-repl> let a = c + 1; b = a + 1; c = b + 1; in a + b + c
error: infinite recursion encountered
       at «string»:1:20:
            1| let a = c + 1; b = a + 1; c = b + 1; in a + b + c
             |                    ^

循環参照みたいなことをやってみたら当然怒られた。

let式の中で付けた名前にアクセスできるのはそのlet式の中のみ。

with ...; ...

1つの式の中で特定のattribute setの要素に繰り返しアクセスする際に使える構文。

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

これは

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

と同値。

複数withを並べれば複数のattribute setの中の属性を同時に参照することもできるらしい。

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  };
  b = {
    v = 4;
    w = 5;
  };
in with a; with b; [x y z v w]
[ 1 2 3 4 5 ]

attribute setの中の名前が被っているときは後にwithで指定した方の値が優先される?

let
  a = {
    x = 1;
    y = 2;
    z = 3;
  };
  b = {
    x = 4;
    y = 5;
  };
in with a; with b; [ x y z ]
[ 4 5 3 ]

便利だと思う反面使いすぎると保守性が悪化しそうな気がする。

inherit ...

JavaScriptの{ x, y }のように、既に名前がついてる値をattribute setに加えるためのキーワード。

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

これは

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

と全く同じ。

withとは違ってinheritで指定した名前と同じ名前をもう一度束縛しようとするとエラーになる。

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

これはwithと違ってこんなことを許容しても役にたつケースがおよそ存在しないからinheritの機能としてエラーにされているのか、あるいはx = 1; y = 2; x = 3;と展開されて、1つのattribute setの中で同じ名前は2度使えないというルールによってエラーになっているのだろうか?

withと同じく、既存のattribute setから特定の名前だけを取り出して新しいattribute setに加えることもできる。

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

2つ以上のattribute setをinheritで同じ名前の値を取り出そうとするとやはりエラーになる。

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

String interpolation ${ ... }

大体Scalaと同じらしい。 恐らくScalaのようにカスタムした補完子は作れないのか先頭にsを付ける必要が無いのと、名前1つから成る式を埋め込む時でも{}が必須になったくらいか。

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

当然、string値や文字列に変換できる型の値しか埋め込めないのだが、なんとinteger型すら文字列に変換できる型として扱われていないらしい。 一体string以外にどの型が埋め込めるんだろう。

文字列補完子のネストもできるが可読性は悪そう。

ちなみに、Nix言語の用途の都合上、Nix言語のコードの中にインラインでシェルスクリプト書いた場合にそのスクリプトの中で$varのように"$"が文字列に登場することは普通にあるようだ。 名前1つから成る式を埋め込む場合も{}が必須なのはそういう事情もあるのかもしれない。

File system paths

stringとは別にパスを表記する型があるらしい。

絶対パスを記述する方法と相対パスを記述する方法があり、/から始めると絶対パスを記述できる。

nix-repl> /foo/bar
/foo/bar

/以外から始めると相対パスとして評価される。

相対パスは必ず/を含まなければならないというルールがある。 .はそこまでのパスのディレクトリを指し、例としてfoo/.と書くと(仮にそう書かれたファイルが/current/directoryというパスに置いてある場合は)/current/directory/fooというパスになる。 これらの2つのルールからNix言語ではファイルが置かれているディレクトリを指す際に./.という記述を使う。

..で親のディレクトリを指せる。

また、パスは文字列補完で埋め込むことができる。

Lookup paths

このパートは書いてある内容があまり理解できなかったので、一度飛ばし、後でこのLookup pathsが再登場した時に戻ってきた。 それでも分からないのでNix Reference Manualを読みにいったがそれでも理解しきるに至らなかったので、わかったところまでを書いておく。

Lookup pathsとは<nixpkgs>や<nixpkgs/lib>のような<>で囲まれたパスであり、<nixpkgs>を脱糖するとbuiltins.findFile builtins.nixPath "nixpkgs"になる。 builtinsというのは、これも後に出てくるのだがNix言語のインタプリタに実装されたライブラリで、コード中で参照すると様々な関数を要素として持つattribute setになっている。

nix-repl>  builtins
{
  abort = «primop abort»;
  add = «primop add»;
  addDrvOutputDependencies = «primop addDrvOutputDependencies»;
  addErrorContext = «primop addErrorContext»;
  all = «primop all»;
  any = «primop any»;
  appendContext = «primop appendContext»;
  # ... (とても要素数が多いので省略)
}

builtins.findFileはそんな関数の内の1つだ。 builtins.findFile builtins.nixPath "nixpkgs"という式ではbuiltins.nixPathと"nixpkgs"という2つの引数が与えられている。

builtins.findFileの挙動を説明するにあたってbuiltins.nixPathが何なのかを先に知っておいた方がわかりやすいのでこちらを先に見る。 私の環境ではこのようなattribute setになっている。

nix-repl> :p builtins.nixPath
[
  {
    path = "/home/pkino/.nix-defexpr/channels";
    prefix = "";
  }
  {
    path = "flake:nixpkgs";
    prefix = "nixpkgs";
  }
]

このようにbuiltins.findFileの第1引数にはpathとprefixという属性を持つattribute setのリストが渡される。 builtins.findFileはこの中でprefixが第2引数にマッチするものを見つけると、そのattribute setのpath属性を返す。 なので、builtins.findFile builtins.nixPath "nixpkgs"とすると"flake:nixpkgs"が返ってくる。

しかし実際にREPLで<nixpkgs>を評価させると以下のように展開される。

nix-repl> <nixpkgs>
/nix/store/g9bw874w67bfsvg93szwnxxjxp4lphby-source

どうもattributeのpath属性がパッケージ名だと、そのパッケージをダウンロードしてそのディレクトリの絶対パスを返しているようなのだが、このあたりがリファレンスマニュアルでは詳しくは説明されておらず、後は実際にbuiltins.findFileのコードを読みに行くしかなさそうだが現状でそこまでのモチベーションが無いので先に進むことにした。

なお、builtins.findFile builtins.nixPath "nixpkgs/lib/network"のように第2引数に/を含む文字列を渡すと最初の/までの文字列がprefix属性とのマッチングに使われ、返り値はpathの末尾に残りのパスをくっつけたものになる。

nix-repl> builtins.findFile builtins.nixPath "nixpkgs/lib/network"
/nix/store/g9bw874w67bfsvg93szwnxxjxp4lphby-source/lib/network

"nixpkgs"がマッチングに使われ、返り値の最後に"/lib/network"が追加されているのがわかるだろう。

ここまで長く説明したが、どうやらLookup pathsは再現性がなく不純であるため(使われてしまってはいるのだが)あまり使わない方がいいらしい。

Indented strings

''で文字列を囲むと複数行の文字列が書ける。

nix-repl> ''
          foo
          bar
          baz
          ''
"foo\nbar\nbaz\n"

一番スペースが少ない行がインデントの基準にされる。

nix-repl> ''
             foo
              bar
               baz
          ''
"foo\n bar\n  baz\n"

nix-repl> ''
             foo
            bar
          baz
          ''
"   foo\n  bar\nbaz\n"

Functions

関数はarg: expressionという形で表記される。 実際のドキュメントでは次のパートで説明されているが、関数の適用はHaskellと同様functionName argという形で行う。

nix-repl> f = x: x + 1

nix-repl> f 1
2

Nixでは1引数関数のみが存在するが、attribute setを引数にとることで複数の引数を扱える。

nix-repl> f = { x, y }: x + y

nix-repl> a = { x = 1; y = 2; }

nix-repl> f a
3

なお、この関数を余分な名前を含むattribute setに対し適用しようとするとエラーになる。

nix-repl> a = { x = 1; y = 2; z = 3; }

nix-repl> f a
error:
       … from call site
         at «string»:1:1:
            1| f a
             | ^

       error: function 'anonymous lambda' called with unexpected argument 'z'
       at «string»:1:2:
            1|  { x, y }: x + y
             |  ^
       Did you mean one of x or y?

余分な名前を含むattribute setを受け入れるには、以下のようにする。

nix-repl> f = { x, y, ... }: x + y

nix-repl> a = { x = 1; y = 2; z = 3; }

nix-repl> f a
3

HaskellやScalaのパターンマッチにおけるasパターンのように、引数に受け取るattribute set自体に名前を付けることもできる。

nix-repl> f = args@{ x, y, ... }: x + y + args.z

nix-repl> a = { x = 1; y = 2; z = 3; }

nix-repl> f a
6

どの引数が求められているのかわかりづらく保守性が悪化しそうだが、パッケージの設定のために非常に多くのパラメータを受け取ることになったらこうせざるを得ないんだろうなという気がする。 後置でもよい。

nix-repl> f = { x, y, ... }@args: x + y + args.z

デフォルト引数も使える。

nix-repl> f = { x, y ? 0 }: x + y

nix-repl> a = { x = 1; }

nix-repl> f a
1

匿名の関数はlambdaと呼ばれる。

Calling functions

以下のように、関数の適用を正しく行うためにかっこで囲まなければならないケースもある。

nix-repl> let
            f = x: x + 1;
            a = 1;
          in [ (f a) ]
[
  2
]

nix-repl> let
            f = x: x + 1;
            a = 1;
          in [ f a ]
[
  «lambda f @ «string»:2:7»
  1
]

このケースではリストの要素はスペース区切りで記述されるため、上はf aの返り値を要素に持つリストが作成されるが、下はfとaを要素に持つリストになってしまう。

Multiple arguments

関数の返り値を関数にすることによりカリー化もできる。

nix-repl> f = x: y: x + y

nix-repl> f 1 2
3

nix-repl> f2 = f 1

nix-repl> f2 2
3

x: y: x + yはx: (y: x + y)と評価されていることがわかる。

Function libraries

Nix言語で広く使われるライブラリとしてbuiltinsとpkgs.libが挙げられる。

builtinsはその名の通りNix言語のインタプリタにC++で実装されている関数で、ここにリファレンスマニュアルがある。

pkgs.libはnixpkgsリポジトリにlibと呼ばれるattribute setがあり、便利な関数を提供している。 こちらはNix言語で実装されている。 恐らくこれのことだと思われる。

特定のNixファイルに定義されている関数を呼び出したいなら、まずそのnixファイルに書かれた式を評価し値を得る必要がある。 たとえば、./test/lib.nixに以下の内容を書き込む。

{
  a = 1;
}

importというキーワードはパス型の値を受け取る関数のようになっており(あるいは本当に関数なのかもしれない)、importにnixファイルのパスを渡すことでそのファイルに書かれた式を評価できる。

nix-repl> import ./test/lib.nix
{ a = 1; }

他のnixファイルが返すattribute setの要素である値を扱いたいなら、以下のようにimportが返したattribute setの要素をそのまま参照するか、importが返したattribute setに名前を付けることになる。 大抵は後者でやることになると思われる。

nix-repl> (import ./test/lib.nix).a
1

nix-repl> test-lib = import ./test/lib.nix

nix-repl> test-lib.a
1

また、importにディレクトリの名前を渡すと、そのディレクトリにあるdefault.nixという名前のファイルが探される。

./test/default.nixというファイルに以下の内容を書き込む。

{
  add = x: y: x + y;
}

以下はこのファイルをディレクトリを指定し利用している例。

nix-repl> test = import ./test

nix-repl> test.add 1 2
3

なお<nixpkgs>を評価して得られるディレクトリのdefault.nixが返す値は関数になっており、慣例的に空のattribute setを渡して利用されている。

nix-repl> pkgs = import <nixpkgs> {}

nix-repl> pkgs.lib.strings.toUpper "foo"
"FOO"

pkgsやpkgs.libは慣例として関数の引数として渡されることもよくある。

{ pkgs, lib, ... }:
# ...

この場合は呼び出し側でnixpkgsをimportし渡す、依存性注入のような操作を行うことになる。

Impurities

Nix言語では、ビルドの中で特定のファイルを参照することがある。 このようなファイルをビルド時の入力として渡す方法は、ファイルパスを記述するか、Fetcherと呼ばれる専用の関数を使うかのどちらかになる。

Paths

「ファイルパスを記述する」方の方法。 ファイルシステムパスが文字列補完子の中で使われると、そのファイルはNix storeというNixがパッケージ管理に使っているディレクトリにコピーされ、そのパスが返される。

nix-repl> "${./test/lib.nix}"
"/nix/store/41yix9s3lf3vsrmxk59n6j0i1c63ymj9-lib.nix"

文字列補完子に埋め込んだ値は文字列として表現できるものに評価されなければならない。 ファイルシステムパスの場合は、対応するNixストアのファイルのパスとして評価される。 Nixストアのファイルパスは/nix/store/<hash>-<name>という形式になっており、<hash>の部分にはファイル内容のハッシュが、<name>には元のファイル名が入る。 Nix言語自体からは脱線するが、Nixはビルドに使うファイルの中身をハッシュとして記録することで、ビルドの再現性を担保しているようだ。

ディレクトリのパスを埋め込んだ場合も、副作用としてディレクトリ全体がNix storeにコピーされ、そのディレクトリのNix storeパスが返ってくる。

Fetchers

ビルド時の入力に使われるファイルはファイルシステムからだけでなく、ネットワークから取得してもよい。 そのための関数がbuiltinsパッケージに用意されている。

  • builtins.fetchurl
  • builtins.fetchTarball
  • builtins.fetchGit
  • builtins.fetchClosure

これらの関数により取得されたファイルはやはりNix storeに保存され、そのパスが関数の返り値として返される。

Derivations

(先に書いたことの繰り返しになるが、Derivationとはパッケージのビルド手順のレシピ)

Nix言語はDerivationsを記述するのに使われ、 NixはDerivationsから成果物を生成する。 ビルドされた成果物は他のDerivationsの入力に使われることもある。

感想

実はNix language basicsを読んでいる間にだんだん手を動かしたくなってきてしまい、VirtualBoxにNixOSをインストールしてちょっとした設定をいじるところから始めていた。 まだ記述している内容により何が起こるのかまでは理解できていないが、どういう構文が使われて、Nix式がどういう構成になっているのかくらいはわかるようになって楽しくなってきた。 ただ、まだNixに登場する概念を全然理解できていないので、次はNixOS & Flakes Bookを読んでみようと思う。 NixOSは難易度が高いと聞いており常用していくかは未定だが、パッケージの管理をNixに移行するところまではやってみたい。