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 encountered | rec / fix 中真正环状自引用 | 检查访问路径是否能在有限步终止 |
参数缺少报 argument not passed | attrset 解构缺字段(无默认值) | 给默认值或调用时补齐 |
attribute 'x' missing | with 压平后访问不存在属性 | 改用显式 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 的模块系统。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。