Skip to content

引用透明性:表达式可替换为值的前提

——为什么 f(x) === f(x) 成立时,代码才真正“可推理”?

“如果你不能把 f(x) 替换成它的结果而不改变程序行为,那你就不理解 f(x)。”

一、什么是引用透明性?(Referential Transparency)

定义
一个表达式是引用透明的,当且仅当它可以在程序中被其求值结果所替换,而不改变程序的行为

这听起来像一句废话,但它蕴含着编程中最深刻的洞察:

引用透明性 = 可替换性 = 可推理性

二、f(x) === f(x) 的深意:不只是相等,而是“可内联”

我们常看到:

js
const a = f(x);
const b = f(x);
console.log(a === b); // true?

但这只是值相等,不是引用透明

真正的测试是:

你能否将 f(x) 替换为 a,而不影响程序?

示例 1:纯函数 —— 引用透明

js
function square(n) { return n * n; }

const result = square(3) + square(3);
// 可安全替换为:
const result = 9 + 9; // 程序行为不变
// 或:
const sq = square(3);
const result = sq + sq; // 公共子表达式提取(CSE)优化成立

这里 square(3) 是引用透明的:你可以把它“内联”或“缓存”,系统不变。

示例 2:不纯函数 —— 引用不透明

js
let counter = 0;
function impureSquare(n) {
  counter++; // 副作用
  return n * n;
}

const result = impureSquare(3) + impureSquare(3);
// 如果你尝试替换:
const sq = impureSquare(3); // counter = 1
const result = sq + sq;     // counter 仍为 1,但原程序中 counter = 2

程序行为变了!
impureSquare(3) 不能被替换为值,因为它携带了副作用

三、为什么 f(x) === f(x) 是“可推理”的前提?

因为推理的本质是代数化简

在数学中,你可以这样推导:

y = (x + 1) + (x + 1)
  = 2*(x + 1)

因为你知道 (x + 1) 可以被复用。

但在不透明的代码中,这种推理失效:

js
// 不透明
const a = getTime(); // 14:00
const b = getTime(); // 14:01
const diff = b - a; // 60000ms

你不能说 getTime() === getTime(),也不能替换。

没有引用透明性,你就失去了“静态推理”的能力。

四、JS 中破坏引用透明性的“四大元凶”

1. 外部状态依赖(自由变量)

js
let taxRate = 0.1;
function priceWithTax(price) {
  return price * (1 + taxRate); // 依赖外部可变状态
}

如果 taxRate 被修改,priceWithTax(100) 的结果就变了。

修复:参数化依赖

js
const priceWithTax = (taxRate) => (price) =>
  price * (1 + taxRate);

现在 priceWithTax(0.1)(100) 是引用透明的。

2. 对象/数组突变

js
function addTodo(todos, text) {
  todos.push({ text, done: false }); // 修改输入
  return todos;
}

const list = [];
addTodo(list, 'Learn FP');
addTodo(list, 'Write Article');
// 现在 list 被改变了!后续所有使用 list 的地方都受影响

addTodo(list, 'Learn FP') 不能被替换为结果数组,因为原数组也被改了

修复:返回新数组

js
const addTodo = (todos, text) =>
  [...todos, { text, done: false }];

现在你可以安全替换调用为结果。

3. 时间与随机性

js
function log(msg) {
  return `${new Date()}: ${msg}`; // 时间不可控
}

function rollDice() {
  return Math.floor(Math.random() * 6) + 1; // 随机性不可控
}

log('hi') 每次调用都不同,无法替换。

修复:将时间/随机源作为输入

js
const logAt = (time, msg) => `${time}: ${msg}`;
const rollWithSeed = (random, sides) => Math.floor(random() * sides) + 1;

现在 logAt("2025-01-01", "hi") 是引用透明的。

4. 异常抛出(控制流中断)

js
function safeDivide(a, b) {
  if (b === 0) throw new Error('Divide by zero');
  return a / b;
}

try {
  const x = safeDivide(1, 0); // 抛出异常
} catch (e) {
  console.log('Error!');
}

你不能把 safeDivide(1, 0) 替换为任何值,因为它会中断执行流

修复:用数据表示失败

js
const safeDivide = (a, b) =>
  b === 0 ? { ok: false, error: 'Divide by zero' }
          : { ok: true, value: a / b };

// 现在可以安全替换
const result = safeDivide(1, 0);
// 等价于:
const result = { ok: false, error: 'Divide by zero' };

五、引用透明性的工程价值

场景引用透明的优势
重构可安全重命名、移动、内联函数,不影响行为
缓存(Memoization)相同输入必有相同输出 → 可缓存结果
并行计算无共享状态 → 可多线程安全执行
惰性求值可延迟计算,直到需要时才执行(如 Ramda)
测试无需 mock,直接传参断言
调试错误可复现,调用栈清晰

六、高级话题:如何在不纯的世界中模拟引用透明?

现实世界充满副作用,但我们可以通过封装来恢复局部透明性。

模式:IO Monad —— 将“动作”数据化

js
const IO = (effect) => ({
  effect,
  map: (f) => IO(() => f(effect())),
  flatMap: (f) => IO(() => f(effect()).effect())
});

// 使用
const readLine = IO(() => prompt("Enter name: "));
const greet = str => `Hello, ${str}!`;
const main = readLine.map(greet);

// `main` 是一个描述“读取并问候”的数据结构
// 它本身是引用透明的!

// 最后在最外层执行
main.effect(); // 此时才产生副作用

在这个模型中:

  • readLine.map(greet) 可以被替换为 main
  • 组合过程是纯的
  • 副作用被推迟到最后一刻

这就是 Haskell 的 IO 类型在 JS 中的投影。

结语:引用透明性是“可推理代码”的基石

当你能说 f(x)f(x) 是同一个东西时,你的代码才真正属于你。

否则,它只是一个黑盒,你只能通过运行它来猜测它的行为。

函数式编程的伟大之处,就在于它通过纯函数、不可变性、高阶抽象,在 JavaScript 这样的命令式语言中,重建了一片可数学化推理的净土