我是 Scala、FP 和一般编程的初学者。我试图理解什么时候某些东西可以被称为正确的 FP。
如果我们说函数式编程是将函数链接在一起,以便 f(x) = x(对于一个输入和一个输出),那么如果您构建依赖于局部突变的函数,您是否可以说您仍在使用 fp,但其中相同的输入总是有相同的输出?
比较下面的 Scala 代码,它总是为 x <= 10, and x for all x > 10 生成 10。也就是说,该函数总是有一个可预测的结果,除非我构建它们的逻辑有缺陷:
def imp(x: Int): Int = {
var y = x // in case x is an immutable value
while (y < 10) {
y += 1
}
return y
}
def tailRec(x: Int): Int = {
if (x >= 10) return x
else tailRec(x + 1)
}
val x = 2;
imp(x); //10
tailRec(x); //10
我想说我的命令函数没有副作用(它不会改变函数外部的状态)。我想说它在本地破坏了引用透明度,因为 y 不仅仅是值的名称,而且会随着时间的推移而变化。然而,如果我们从全局设置考虑命令式函数,这似乎并不重要,因为该函数总是给出与正确的纯尾递归函数相同的结果。如果你把函数看成一个黑盒子并且只关注 f(x)=x,那么这还是 FP 吗?
这些函数从定义上来说绝对是纯粹的。我将使用 Wikipedia 提供的一个,它说纯函数具有以下属性:
- 对于相同的参数,函数返回值是相同的(局部静态变量、非局部变量、可变引用参数或输入流没有变化,即引用透明度),并且
- 该函数没有副作用(局部静态变量、非局部变量、可变引用参数或输入/输出流不会发生变化)。
函数的内部状态相关的唯一情况是,如果所述状态是static,则比每个单独的函数调用寿命更长,并可能影响后续调用中的返回值(在 Scala 中,只有当方法发生变化时才应该发生这种情况)其类别的状态)。
关于将纯代码和非纯代码混合在一起,肯定很难做得这么好,但通过一些理解我认为这是可行的。我想说最好的方法是 Scala Collection API 本身,例如您可以在
List.map
方法的定义中看到的那样:公开一个纯 API 并在一个小的、受限范围,不会破坏引用透明度。这允许在没有任何干扰的情况下对纯代码和不纯代码进行分层。我想说,选择不纯粹方法的最佳理由是性能(但这应该始终由测量驱动——在大多数应用程序中,可以公平地说,专注于这方面是不经济的)并允许表达算法以更直接和可读的方式进行(这是选择不纯粹方法的一个很好的理由,只要你的抽象正确——这是相当困难的,但无论如何可能值得做)。
为了更深入,我认为看看编译器为这两种方法输出的内容很有趣:Scala 编译器能够检测尾递归并将其优化为
while
循环的结果,确保它也是堆栈-safe(您还可以确保编译器使用 @tailrec
注释强制函数是尾递归的)。我稍微调整了您的代码以使条件相同,这是编译器输出上 javap -v
的(已清理的)输出:
public int imp(int);
...
Code:
stack=2, locals=3, args_size=2
0: iload_1
1: istore_2
2: iload_2
3: bipush 10
5: if_icmpge 14
8: iinc 2, 1
11: goto 2
14: iload_2
15: ireturn
...
public int tailRec(int);
...
Code:
stack=2, locals=2, args_size=2
0: iload_1
1: bipush 10
3: if_icmpge 12
6: iinc 1, 1
9: goto 0
12: iload_1
13: ireturn
...
基本上,您可以观察到的唯一区别是,在递归版本中,您必须分配一个可变变量(因为它是 Scala,所以不可能改变参数)。如果在一个温暖的 JVM 上这个被 JIT 消除了,我不会感到惊讶。 ;-)