引用透明性
在计算机科学中,引用透明性和引用不透明性是部分计算机程序具有的属性。若一个表达式在被其相应的值替换时能不改变程序的行为(反之亦然),则该表达式是引用透明的。 [1]因此,该表达式必须是纯函数——给予相同的输入,其返回值必须相同;对其求值时不能含有副作用。反之,一个不具有应用透明性的表达式是引用不透明的。
在数学中,根据构成数学函数的定义,所有函数应用都是引用透明的。然而,在编程中情况并非总是如此;为避免歧义,函数经常被归类为过程或方法。函数式编程的一个定义特征是它只允许引用透明的函数。一些其他编程语言有提供方法用以选择地保证引用透明性。一些函数式编程语言要求所有函数必须引用透明。
引用透明性的重要性在于允许程序员和编译器将程序行为作为重写逻辑进行推理。这有助于证明正确性、简化算法、在不破坏代码的情况下协助修改代码,以及通过记忆、公共子表达式消除、惰性求值或并行计算来优化代码。
历史
应用透明性的概念据称起源于阿尔弗雷德·诺斯·怀特海和伯特兰·罗素的《数学原理》(1910–1913年)。 [2]它被威拉德·范·奥曼·蒯因引入分析哲学。在《词与物》(1960年)第30章中,蒯因给出了如下定义:
如果一个包含模式 φ 是引用透明的,那么当特指项 t 在一个术语或句子 ψ(t) 中是纯粹引用的时候,其在包含术语或句子 φ(ψ(t)) 中也是纯粹引用的。
该术语出现在其当代计算机科学用法中,在编程语言中对变量的讨论中,在Christopher Strachey的开创性讲义集《编程语言中的基本概念》(1967 年)中。讲义引用了蒯因的《词与物》。
例子和反例
如果表达式中涉及的所有函数都是纯函数,则该表达式是引用透明的。
假设有函数可以从某个来源接受输入并将其返回。在伪代码中,该函数可能以GetInput(Source)
调用,其中Source
为特定的磁盘文件、键盘等的标识。在多次应用GetInput
的情况下,即使Source
一致,连续的返回值也会不同。因此,函数GetInput
既不是确定性的,也不是引用透明的。
一个更为微妙的例子是使函数可以具有自由变量,即某些未被明确作为参数传递的输入;然后根据名称绑定规则将其解析为非局部变量,例如全局变量、当前执行环境中的变量(用于动态绑定)或闭包中的变量(用于静态绑定)。由于可以在不更改作为参数传递的原值的情况下更改此变量,因此即便参数相同,后续调用该函数的结果也可能不同。然而,在纯函数式编程中,破坏性赋值是不允许的,因此如果自由变量被静态绑定到一个值,该函数仍是引用透明的,因为非局部变量及其值都分别由于静态绑定和不可变性而不可改变。
算术运算是引用透明的:5 * 5
可以替换为25
。事实上,数学意义上的所有函数都是引用透明的:sin(x)
是透明的,因为它总是对每个特定的x
给出相同的结果。
重新赋值是不透明的。例如,C语言表达式x = x + 1
更改了赋给变量x
的值。在x
最初为10
的情况下,对该表达式连续求值两次会分别得出11
和12
。显然,将x = x + 1
替换为11
或12
时程序会具有不同含义,因此该表达式并不引用透明。但是,函数如int plusone(int x) { return x + 1; }
是透明的,因为输入x
不会被隐式变更 ,因此不含有如此副作用。
today()
不是透明的,当对它求值并以结果(比如, "Jan 1, 2001"
)进行替换时,你不会得到与明天运行它得到的相同的结果。这是因为它取决于状态(日期)。
在无副作用的语言,例如Haskell中,我们可以“用等于代替等于”:若 x == y
,则有f(x) == f(y)
。这反映了不可分者同一性原理。对于副作用语言,该属性一般不需要成立。即便如此,重要的是将此类断言限制为所谓的判断相等性,即系统测试的术语相等性,不包括用户定义的类型等价性。例如,如果B f(A x)
和类型A
已经覆盖了相等性的概念,例如使所有项相等,那么在x == y
时仍有f(x) != f(y)
。这是因为像Haskell这样的系统不会验证在具有用户定义的等价关系的类型上定义的函数是否在该等价方面得到了良好的定义。因此,引用透明性仅限于没有等价关系的类型。将引用透明性扩展到用户定义的等价关系可以通过例如 Martin-Löf 身份类型来完成,但需要依赖类型的系统,例如Agda、Coq或Idris。
与命令式编程对比
如果用其值替换表达式仅在程序执行的某个时刻有效,则该表达式不是引用透明的。这些序列点的定义和排序是命令式编程的理论基础,也是命令式编程语言语义的一部分。
然而,因为引用透明的表达式可以在任何时候求值,所以不需要定义序列点,也不需要任何求值顺序的保证。不考虑这些因素而完成的编程称为纯函数式编程。
以引用透明的方式编写代码的一个优点是静态代码分析对一个智能编译器会更容易,并且可以自动进行更好的代码改进转换。例如,在使用 C 语言编程时,如果在循环内包含对昂贵函数的调用,则会导致性能下降,即使可以在不更改程序结果的情况下将函数调用移出循环。程序员此时将被迫执行调用的手动代码移动,但这可能会以牺牲源代码的可读性为代价。但是,如果编译器能够确定函数调用是引用透明的,它可以自动执行此转换。
强制引用透明性的语言的主要缺点是它们使自然适合步骤序列命令式编程风格的操作的表达更加笨拙和不够简洁。这些语言通常包含使这些任务更容易的机制,同时保留语言的纯功能质量,例如定字句语法和单子。
又例
有两个函数,一个是引用透明的(rt
),另一个是引用不透明的(ro
):
int g = 0;
int rt(int x) {
return x + 1;
}
int ro(int x) {
g++;
return x + g;
}
函数rt
是引用透明的,这意味着若x == y
则有rt(x) == rt(y)
。例如, rt(6) = 7
。但是,我们不能确定ro
同样引用透明,因为它使用了由它自己修改的全局变量。
ro
的引用不透明性使得对程序的推理更加困难。假设我们希望对以下陈述进行推理:
int i = ro(x) + ro(y) * (ro(x) - ro(x));
人们可能会想将这一陈述简化为:
int i = ro(x) + ro(y) * 0;
int i = ro(x) + 0;
int i = ro(x);
然而这并不正确,因为ro(x)
每次都会计算出不同的值。需要注意的是, ro
的返回值基于未传入的全局值g
,并且每次调用ro
时g
都会被修改。这意味着数学恒等式x − x = 0在此不有效,而是适用于像rt
这样引用透明的函数。
但是,可以使用更复杂的分析来将语句简化为:
int tmp = g; int i = x + tmp + 1 + (y + tmp + 2) * (x + tmp + 3 - (x + tmp + 4)); g = g + 4;
int tmp = g; int i = x + tmp + 1 + (y + tmp + 2) * (x + tmp + 3 - x - tmp - 4)); g = g + 4;
int tmp = g; int i = x + tmp + 1 + (y + tmp + 2) * (-1); g = g + 4;
int tmp = g; int i = x + tmp + 1 - y - tmp - 2; g = g + 4;
int i = x - y - 1; g = g + 4;
这需要更多步骤,并且要求程序员对编译器优化不可行的代码有一定程度的洞察力。
因此,引用透明性使我们能够推理我们的代码,这将导致更健壮的程序,找到我们不希望通过测试找到的错误的可能性,以及看到优化机会的可能性。
另见
参考
- John C. Mitchell. . Cambridge University Press. 2002: 78.
- Alfred North Whitehead; Bertrand Russell. 1 2nd. Cambridge University Press. 1927. Here: p.665. According to Quine, the term originates from there.
- Søndergaard, Harald; Sestoft, Peter. (PDF). Acta Informatica. 1990, 27 (6): 505–517 [2023-05-24]. S2CID 15806063. doi:10.1007/bf00277387. (原始内容存档 (PDF)于2020-08-19).
- Davie, Antony. . New York: Cambridge University Press. 1992: 290. ISBN 0-521-27724-8.