Nix 语言深度:函数、惰性求值与模块化抽象

Nix 是一门纯函数式、惰性求值的领域语言,理解其核心语义是从「会用」走向「精通」的分水岭。本文深入函数与柯里化、惰性求值、attrset 操作、let/inherit/rec/with 绑定、override 与 fix 定点组合,用大量可运行表达式讲透 Nix 语言的底层机制。

1. 为什么还要深挖语言本身?

Nix 不是「一门附属于包管理器的配置格式」,而是一门图灵完备的纯函数式、惰性求值语言。https://plumephp.com/nix-language-basics/ 覆盖了语法入门;但当你开始编写复杂的 overlay、lib.fix、override 时,很快会撞上几个令人困惑的现象:

  • 为什么 pkgs.foo.override { ... } 能「继承」未改动的参数?
  • 为什么 nixpkgs 里的 callPackage 能自动补齐函数参数?
  • 为什么「相互递归」的 attrset(如 { a = b; b = a; })在惰性求值下可以正常工作?
  • 为什么 let x = x; in x 会死循环,而 rec { x = x; } 却能求值?

这些问题的答案都藏在语言语义里。本文假设你已经能读懂基础表达式,聚焦五个核心主题:函数与柯里化、惰性求值、attrset 操作、绑定与递归、以及 override/fix 这类「高阶抽象」。

📌 相关专题:函数式编程的工程实践可参考 https://plumephp.com/posts/devops/;Flakes 层的组织方式见 https://plumephp.com/nix-flakes/。


2. 函数与柯里化:Nix 只有一元函数

2.1 一个参数,仅此而已

Nix 的语法里函数只有一种形态:λ 参数名: 表达式,即只接收一个参数。所谓「多参数函数」只是返回一个接收下一个参数的函数——这就是柯里化(currying):

# 表面上的「二元函数」
add = a: b: a + b;

# 实际求值过程:先返回闭包,再应用
# add 1     -> b: 1 + b   (一个待应用的函数)
# add 1 2   -> 3

# 部分应用(partial application)是柯里化的直接红利
inc = add 1;         # b: 1 + b
inc 10               # => 11

这解释了 nixpkgs 中大量的「先给配置、再给输入」的模式:

# callPackage 的实质就是依次传入两个参数
# pkgs.callPackage drv { extra = 1; }
#   -> import drv 得到的函数,先注入 derivation 依赖,再注入覆盖参数

2.2 命名参数与默认值:函数定义只是「模式匹配」

当一个函数的参数写成 attrset 集合形式时,Nix 会做 attrset 解构(destructuring):

greet = { name, greeting ? "Hello" }: "${greeting}, ${name}!";

greet { name = "Alice"; }          # => "Hello, Alice!"
greet { name = "Bob"; greeting = "Hi"; }  # => "Hi, Bob!"

# ? 表示默认值;不给默认值且调用时缺少该属性会直接报错
# greet {}                         # error: 'name' argument not passed

形如 { name, greeting ? "Hello" }: ... 的函数,等价于一个柯里化函数的「语法糖」——它把 attrset 视为单一参数,并在求值前按模式拆包。这也是 nixpkgs 里 mkDerivation、mkShell 等函数接收大量命名参数的底层机制。

2.3 变长参数:... 与 @

当你不关心 attrset 里的全部属性时,用 ... 吸收多余字段;当你既想拆包又想要整个集合时,用 name @ { ... }:

# 只声明关心的字段,多余字段被静默忽略
f = { a, ... }: a;
f { a = 1; b = 2; c = 3; }     # => 1

# @ 语法同时绑定整个 attrset
g = args @ { a, ... }: a + args.b;
g { a = 1; b = 2; }            # => 3

# 多参数函数同样遵循柯里化 + 模式拆包
h = { a, ... }: { b, ... }: a + b;
h { a = 1; } { b = 2; }        # => 3

2.4 builtins.map 与柯里化的配合

nixpkgs 函数库大量利用部分应用构建「数据管道」:

pkgs = import <nixpkgs> {};

# map 本身只接收一个函数 + 一个列表
map (x: x * 2) [ 1 2 3 ];      # => [ 2 4 6 ]

# 柯里化让组合更自然
map (pkg: pkg.name) [ pkgs.hello pkgs.git ];

实战:如果你发现某个函数「参数顺序别扭」,试试柯里化风格 config: input: ...,把「配置」放前面、「数据」放后面,方便部分应用复用。


3. 惰性求值:Nix 一切皆可「先声明,后触发」

3.1 按需计算

Nix 采用惰性求值(lazy evaluation):表达式只在值真正被需要时才计算。这对一个包管理系统至关重要——nixpkgs 有超过 10 万个 derivation,但一次 nix build 只实例化你需要的几个,其余全部停留在「未求值的 thunk」状态。

# 下面的表达式完全合法,虽然 /nonexistent 不存在
{
  a = builtins.readFile /nonexistent;   # 只有访问 a 才会报错
  b = 1;
}

# 访问 b 不会触发 readFile
# 访问 a 才会:error: path '/nonexistent' does not exist

3.2 惰性带来的三个工程能力

① 无限结构 / 自引用(lazy self-reference)

# 无限列表:除非访问足够深,否则不会死循环
evens = let go = n: [ n ] ++ go (n + 2); in go 0;
builtins.head evens            # => 0

# attrset 自引用:rec 的底层就是惰性
lib = rec {
  x = y + 1;
  y = 10;
};
lib.x                        # => 11

② 大配置树只构建需要的部分

NixOS 的 configuration.nix 声明了整个系统(几百个服务的 options),但激活时只评估被启用服务的部分。这正是 mkIf 能「条件式地让某些求值不触发」的原因(详见 https://plumephp.com/nixos-module-system/)。

③ 表达式就是「构建计划」

mkDerivation { ... } 返回的不是「构建好的包」,而是一个尚未构建的 derivation 描述。只有当它被 nix build 消费、被复制进 store、或在 builtins.toString 下强制时才真正执行构建。

3.3 惰性的代价:难以调试与顺序陷阱

惰性求值最大的坑是求值顺序不可预期,副作用语言的习惯(「先执行这行再执行那行」)在这里失效:

# 错误示范:试图用 builtins.trace 观察「顺序」
# 输出顺序不一定是书写顺序,因为父表达式决定子表达式何时求值
x = builtins.trace "eval x" 1;
y = builtins.trace "eval y" 2;
[ x y ]                     # 取决于访问顺序

调试工具 nix-instantiate --eval -E --strict 可以强制求值完整表达式:

# --strict 强制完全求值,暴露深处的错误
nix-instantiate --eval -E --strict '{ a = builtins.readFile /missing; b = 1; }'

最佳实践:把「定义」与「触发」分离。模块只负责定义 options,求值交给 nixos-rebuild;包定义只描述 derivation,构建交给 nix build。不要在你的表达式顶层写有副作用的代码。


4. attrset:Nix 的「对象系统」

4.1 合并、覆盖与检查

attrset(属性集 / 记录)是 Nix 里唯一的内置复合数据结构(列表之外的),nixpkgs 把它当对象系统用:

# 合并
builtins.unionOf
  { a = 1; b = 2; }
  { b = 3; c = 4; }            # => { a = 1; b = 3; c = 4; }  (后合并覆盖前)

# 更新单个属性(返回新集合,原集合不变——纯函数式)
builtins.update { a = 1; b = 2; } { a = 9; }   # => { a = 9; b = 2; }

# 属性存在性检查
builtins.hasAttr "a" { a = 1; }            # => true
{ a = 1; } ? a                            # 等价语法糖

# 取属性(? 防止报错)
lib.attrByPath [ "a" "b" ] "default" { a = { b = 2; }; }   # => 2

4.2 mapAttrs 与递归遍历

nixpkgs 的 lib.mapAttrs 是最常用的 attrset 遍历函数,overlay、callPackage、nixosSystem 都构建在它之上:

lib.mapAttrs (name: value: "${name}-${value}")
  { x = "a"; y = "b"; }          # => { x = "a-x"; y = "b-y"; }

# 递归展开嵌套 attrset 的关键是自引用闭包
lib.mapAttrsRecursive
  (path: value: "PATH:${lib.concatStringsSep "." path}:${toString value}")
  { a = { b = 1; }; c = 2; }
  # => { a = { b = "PATH:a.b:1"; }; c = "PATH:c:2"; }

4.3 attrset 的继承语义://

// 是最常见的覆盖运算符,也是 override 实现的基础:

a = { x = 1; y = 2; };
b = a // { y = 3; z = 4; };
# b => { x = 1; y = 3; z = 4; }
# a 不变 => { x = 1; y = 2; }

注意:// 是浅覆盖,嵌套 attrset 会被整体替换而不是递归合并。这正是 override 只替换顶层参数、而 overrideAttrs 需要显式 final: prev: { ... } 的原因。


5. let / inherit / rec / with:绑定机制的语法糖

5.1 let ... in 与惰性绑定

let 引入的是惰性绑定:所有绑定相互可见,且可以引用后面定义的绑定(不求值时不会因「未定义」报错,但最终访问到未定义项会失败):

let
  a = b + 1;    # 引用后面才定义的 b —— 惰性求值下合法
  b = 2;
in a            # => 3

5.2 inherit:从作用域拉取

inherit 是「从当前作用域复制同名变量到 attrset」的语法糖:

let
  name = "nix";
  version = "2.18";
in {
  inherit name version;          # => { name = "nix"; version = "2.18"; }
  name = "overridden";           # 之后可以再覆盖
}

# inherit 还可以带括号改变取值来源:
# inherit (pkgs) git hello;   从 pkgs 中取 git、hello 属性
lib = { inherit (pkgs) jq jdk; };   # => { jq = <derivation>; jdk = <derivation>; }

5.3 rec:attrset 内部自引用

rec { ... } 让 attrset 内部可以引用自己——本质上就是把 let 的作用域并入 attrset:

rec {
  a = b;
  b = 1;
}
# => { a = 1; b = 1; }

实现细节:rec { a = b; b = 1; } 大致等价于 let res = { a = res.b; b = 1; }; in res,因此惰性求值下 a 与 b 的循环引用是安全的——只要访问路径不构成严格循环依赖。

# 危险:真正的循环,会无限递归
# rec { x = x; }   —— 访问 x 会死循环

5.4 with:压平命名空间

with attrset; expr 把 attrset 的属性压进作用域,注意它是惰性的、且作用域优先级低于 let 绑定:

pkgs = import <nixpkgs> {};

with pkgs; [ git curl jq ]
# 等价于 [ pkgs.git pkgs.curl pkgs.jq ]

# 坑:with 会遮蔽外层同名变量
let x = 1; in with { x = 2; }; x     # => 2

# 坑:with 是「部分惰性」的——若属性不存在不会立刻报错,访问才报
with { a = 1; }; b                     # error: attribute 'b' missing

最佳实践:with pkgs; 让配置简洁,但不要在库代码(被 import 的 .nix)里滥用 with——它会悄悄引入不可见的依赖,破坏可读性。nixpkgs 风格指南推荐在 mkDerivation 的参数里用 with pkgs; [ ... ],其余地方尽量显式。

5.5 四种绑定机制对比

机制作用域惰性典型场景
let ... in局部、可后向引用✅局部计算与中间变量
inherit从外层作用域复制✅把变量搬进 attrset
rec { }attrset 内部自引用✅相互依赖的包组
with ...压平命名空间部分批量使用 pkgs 属性

6. fix 定点组合:Nix 的递归发动机

6.1 什么是 fix

fix(即 nixpkgs 的 lib.fix,也称 Y 组合子 的近似)把一个「接收自身最终结果的函数」转化为一个自引用结构:

fix = f: let x = f x; in x;

# 用法:f = 最终结果: { ... }
lib.fix (self: { a = 1; b = self.a + 1; })
# => { a = 1; b = 2; }   —— self 就是「求值中的自己」

它和 rec 的关系:fix (self: { ... }) 与 rec { ... } 在简单场景等价,但 fix 的函数形式让它可以被组合、被复用、被注入参数——这是 rec 做不到的。

6.2 pkgs 本身就是 fix 的结果

整个 nixpkgs 包集就是 fix 生成的巨型自引用 attrset:包 A 可以引用包 B、包 C 引用包 A。正因为是「函数接受自身」,override 和 overlay 才能工作——覆盖不是在最终结果上做,而是在生成过程中注入修改:

# pkgs 定义简化示意
pkgs = lib.fix (self: {
  hello = import ./hello { inherit self; };
  git = import ./git { inherit self; };
  # hello 内部可以引用 self.git
});

# overlay 实质:在 fix 的每一步生成后套一层 final: prev
pkgs = lib.fix (self:
  lib.extends oldOverlay (prev: { ... })
);

6.3 extends 与 composeExtensions

lib.extends 把两个函数「串联」成 final: prev: ... 形式的叠加,这正是 overlay 的合并算法:

extends = overlay: baseFunc: final: prev:
  let prev' = baseFunc final prev;
      in prev' // overlay final prev';

所以写多个 overlay 时,nixpkgs 用 composeManyExtensions 把它们按顺序叠加——后一个 overlay 能看到前一个修改后的 prev。

实战:lib.fix 还常见于「相互递归的模块树」——例如一个 NixOS 模块要同时获得 config 和 options,本质就是 fix 的两个面(见 https://plumephp.com/nixos-module-system/)。


7. override 与 overrideAttrs:参数化派生的机制

7.1 两层覆盖

每个 nixpkgs 包自带两个覆盖函数,作用层级不同:

# ① override:替换传给「包函数」的顶层命名参数(通常即依赖项)
pkgs.postgresql_15.override {
  openssl = pkgs.openssl_3;          # 换依赖
  enableICU = false;                 # 换布尔选项
}

# ② overrideAttrs:直接修改 mkDerivation 的参数
pkgs.nginx.overrideAttrs (final: prev: {
  configureFlags = prev.configureFlags ++ [ "--with-http_v2_module" ];
  buildInputs = prev.buildInputs ++ [ pkgs.libpcre ];
})

override 依赖「包是一个柯里化函数」这一语言特性:callPackage 把 { lib, stdenv, ... } 参数注入后,包内保存了一个带参数的闭包,override 就是重新调用该闭包并替换参数——这就是上一节柯里化、attrset 解构、fix 三者合力的结果。

7.2 包函数的常见签名

# pkgs/development/.../foo.nix 的典型结构
{ lib, stdenv, fetchFromGitHub, cmake, openssl }:

stdenv.mkDerivation {
  pname = "foo";
  version = "1.0";
  buildInputs = [ cmake openssl ];
  src = fetchFromGitHub { ... };
}

override { openssl = ...; } 能命中,是因为 callPackage 在调用时保留了对原始函数签名的引用——这也是为什么包的函数参数名必须唯一且不能省略 { ... }。

7.3 override 的局限与 callPackage 补偿

override 只能替换函数签名里出现过的参数。若依赖在 mkDerivation 内部硬编码,就得用 overrideAttrs。nixpkgs 还提供 lib.callPackageWith 让你用新参数集重新调用任意包函数:

pkgs.lib.callPackageWith (pkgs // { newDep = pkgs.hello; }) ./mypkg.nix {}

8. 综合实战:把语言特性拧成一把工具

8.1 用柯里化 + attrset 解构写「可覆盖配置」

# 一个参数化构建函数的推荐写法
# config.nix
{ lib, stdenv, fetchurl, python3, extraBuildInputs ? [] }:

let
  configure = { withPython, withTests, additionalFlags ? [] }:
    stdenv.mkDerivation {
      pname = "myapp";
      version = "1.2.0";
      src = fetchurl { url = "..."; sha256 = "..."; };
      buildInputs = lib.optionals withPython [ python3 ] ++ extraBuildInputs;
      configureFlags = lib.optionals withTests [ "--enable-tests" ] ++ additionalFlags;
      passthru = { inherit configure; };   # 暴露可继续覆盖的入口
    };
in
configure   # 默认配置

8.2 用 fix + extends 实现「可叠加的配置层」

# 配置层的叠加:后层覆盖前层
layers = [
  (self: super: { version = "1.2.0"; })
  (self: super: { enableFeatureX = true; })
  (self: super: super // { finalMark = "layered"; })  # 显式合并
];

final = lib.fix (self:
  lib.foldl' lib.extends (prev: {}) layers self);

# final.version => "1.2.0"; final.enableFeatureX => true

8.3 用惰性求值实现「条件不构建」

# 只有真正被需要的包才会被求值(触发构建)
packagesForSystem = system: {
  default = lib.attrByPath [ system ] (throw "unsupported system")
    { x86_64-linux = pkgs.buildx86; aarch64-linux = pkgs.buildArm; };
};

# 在 aarch64 机器上,pkgs.buildx86 永远不会被强制求值

9. 常见坑与调试工具

现象根因解法
infinite recursion encounteredrec / fix 中真正环状自引用检查访问路径是否能在有限步终止
参数缺少报 argument not passedattrset 解构缺字段(无默认值)给默认值或调用时补齐
attribute 'x' missingwith 压平后访问不存在属性改用显式 pkgs.x
// 覆盖后嵌套属性「消失」浅合并用 lib.recursiveUpdate 或 overrideAttrs
求值顺序与预期不符惰性求值按需计算用 --strict 强制求值定位
重复的 name 冲突inherit 与外层变量重名括号形式 inherit (src) name;

调试命令:

# 计算并打印表达式
nix-instantiate --eval -E '1 + 2'

# 强制完整求值(暴露惰性隐藏的错误)
nix-instantiate --eval --strict -E 'let x = builtins.readFile /missing; y = 1; in y'

# 查看推导式结构
nix derivation show .#default

# 交互式探索(repl)
nix repl

10. 总结

Nix 语言的五个核心抽象构成了整个 nixpkgs/NixOS 生态的地基:

  • 柯里化:一切函数本质上是一元的,callPackage、override、mkDerivation 都建立在这条铁律上。
  • 惰性求值:十万级 derivation 只实例化需要的一小撮,mkIf、条件模块、optionals 都依赖「不求值就不触发」。
  • attrset 即对象://、mapAttrs、hasAttr 构成对象操作原语,nixpkgs 的对象系统就是 attrset + 函数。
  • 绑定语法糖:let/inherit/rec/with 各有作用域语义,正确选择能让配置既简洁又无歧义。
  • fix 与 override:包集合是定点组合的结果,覆盖是在生成中注入而非事后修补——这就是「可复现但可定制」的哲学。

掌握这五块拼图后,你会发现自己能读懂 nixpkgs 中任何一段「吓人」的代码,并能写出真正模块化、可组合、可覆盖的 Nix 库。下一步建议沿着 https://plumephp.com/nix-flakes-best-practices/ 学习如何在 Flakes 层组织这些语言原语,或用 https://plumephp.com/nixos-module-system/ 看它们如何支撑 NixOS 的模块系统。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「DevOps」更多文章

  1. 备份与容灾自动化:RPO/RTO、Velero、PITR 与恢复演练
  2. 配置漂移与安全基线:IaC漂移检测、CIS合规、供应链安全与密钥轮换
  3. 内部开发者平台(IDP)工程化:Backstage、Golden Path 与自服务能力