FP 中的不可变状态
Immutable state in FP
在学习有关 Scala 的一些教程时,我想到了这个问题,但我认为在涉及函数式编程时,这个问题通常很有趣。
我不确定不变性在 FP 中的重要性。我可以想到两种不同的情况:
1) class 方法不是 return 实际字段而是它们的副本。例如,如果我们有一个 class Dog,我们需要保持不可变,那么它的函数:
getToys() { return new ArrayList(this.toys); }
而不是:
getToys() { return this.toys; }
这种情况对我来说很有意义,在第二种情况下,客户端代码实际上可能会破坏对象。我的怀疑在于第二种情况:
2) 在 Scala 和大多数其他 FP 语言中,我们更喜欢递归调用:
sum(x: List[Int], acc: Int) {
if(x.isEmpty) return acc;
sum(x.tail, acc + x.head);
}
反对传统的 for 循环递增累加器。原因是这个累加器是一个可变变量。
该变量永远不会暴露在函数之外,所以为什么要让它不可变?
编辑:
似乎最重要的最佳实践是引用透明度
而不是严格的不变性,大致意思是我们不关心可变状态,只要它不能被客户端代码发现。然而,人们仍然声称,即使可变状态影响局部变量(如我的第一个示例),代码也更具可读性或更易于推理。
就我个人而言,我认为循环比递归更具可读性。
所以真正的问题是:为什么使用不可变变量的代码被认为更易于阅读/推理?
In Scala and most other FP languages we prefer a recursive call ... against a traditional for loop incrementing an accumulator. The reasoning is that this accumulator is a mutable variable.
您假设递归比递增累加器的传统循环更受欢迎。我同意函数式程序员喜欢递归,但不是出于你想的原因。在很多情况下,递归只是表达问题的一种自然而简洁的方式。请注意,由于尾调用优化消除了分配新堆栈帧的开销,因此性能不是反对递归的理由。
您将递归比作递增累加器的 for 循环。大多数程序员更喜欢在没有索引的情况下循环集合(例如 Scala 中的 for (i <- 0 to 10) { .. }
或 Java 中的 for (T x : collection) { .. }
。for
构造抽象而不是遍历集合,这使得它成为可能a) 更清楚地传达您的意图和 b) 减少错误的风险(例如一次性错误)。进一步抽象,我们可以表达您使用高阶折叠对整数列表求和的示例:
scala> (1 to 10).foldLeft(0)(_+_)
res0: Int = 55
现在回到你最初的问题:为什么不可变变量更容易阅读和推理?这可能令人惊讶,但程序员也只是人。人类无法对具有许多活动部件的系统进行推理。不变性减少了移动部件的数量,使程序更容易推理。
你说得对,如果引用不对函数进行转义,那么不可变性并不是最重要的问题,但还有其他考虑因素。命令式循环具有非常通用和不精确的语义,因此大多数人在学习替代方案后会发现它们太草率了。
例如,命令式循环没有 return 类型。不检查其参数的输入类型。许多类型的命令式循环迫使程序员不必要地处理与问题陈述无关的索引。只有少数几种命令式循环旨在涵盖所有可能的情况,因此它们过于通用。您无法创建自己的更适合您特定情况的新型命令式循环。它们几乎总是必须内置到语言本身中。
出于某种奇怪的原因,每个人似乎都在递归和命令式循环之间做出错误的选择,但递归确实是函数式程序员腰带中最糟糕的工具。除非绝对必要,或者除非算法自然地递归表达,否则我们会避免使用它。有许多高阶函数可用于比递归更清晰地更精确地表达大多数日常代码,同时保留所有优点,例如 Martjin 的 foldLeft
示例。
仅类型检查的好处就值得使用 foldLeft
而不是 for
循环。空列表可以毫不费力地处理。没有必须用循环外的作用域初始化的怪异累加器。您不必为所有那些您根本不关心的临时变量想出奇怪的名称。简直就是清洁工。
你可能会说这是一个特殊的简单案例,但要小心,避免命令式循环可以在绝大多数情况下为你提供这种体验,而在递归最终变得复杂的情况下,命令式解决方案通常会一样复杂。当您对所有替代方案进行真正的比较时,命令式循环之所以有利,是因为人们熟悉它们。
在学习有关 Scala 的一些教程时,我想到了这个问题,但我认为在涉及函数式编程时,这个问题通常很有趣。
我不确定不变性在 FP 中的重要性。我可以想到两种不同的情况:
1) class 方法不是 return 实际字段而是它们的副本。例如,如果我们有一个 class Dog,我们需要保持不可变,那么它的函数:
getToys() { return new ArrayList(this.toys); }
而不是:
getToys() { return this.toys; }
这种情况对我来说很有意义,在第二种情况下,客户端代码实际上可能会破坏对象。我的怀疑在于第二种情况:
2) 在 Scala 和大多数其他 FP 语言中,我们更喜欢递归调用:
sum(x: List[Int], acc: Int) {
if(x.isEmpty) return acc;
sum(x.tail, acc + x.head);
}
反对传统的 for 循环递增累加器。原因是这个累加器是一个可变变量。
该变量永远不会暴露在函数之外,所以为什么要让它不可变?
编辑:
似乎最重要的最佳实践是引用透明度 而不是严格的不变性,大致意思是我们不关心可变状态,只要它不能被客户端代码发现。然而,人们仍然声称,即使可变状态影响局部变量(如我的第一个示例),代码也更具可读性或更易于推理。
就我个人而言,我认为循环比递归更具可读性。
所以真正的问题是:为什么使用不可变变量的代码被认为更易于阅读/推理?
In Scala and most other FP languages we prefer a recursive call ... against a traditional for loop incrementing an accumulator. The reasoning is that this accumulator is a mutable variable.
您假设递归比递增累加器的传统循环更受欢迎。我同意函数式程序员喜欢递归,但不是出于你想的原因。在很多情况下,递归只是表达问题的一种自然而简洁的方式。请注意,由于尾调用优化消除了分配新堆栈帧的开销,因此性能不是反对递归的理由。
您将递归比作递增累加器的 for 循环。大多数程序员更喜欢在没有索引的情况下循环集合(例如 Scala 中的 for (i <- 0 to 10) { .. }
或 Java 中的 for (T x : collection) { .. }
。for
构造抽象而不是遍历集合,这使得它成为可能a) 更清楚地传达您的意图和 b) 减少错误的风险(例如一次性错误)。进一步抽象,我们可以表达您使用高阶折叠对整数列表求和的示例:
scala> (1 to 10).foldLeft(0)(_+_)
res0: Int = 55
现在回到你最初的问题:为什么不可变变量更容易阅读和推理?这可能令人惊讶,但程序员也只是人。人类无法对具有许多活动部件的系统进行推理。不变性减少了移动部件的数量,使程序更容易推理。
你说得对,如果引用不对函数进行转义,那么不可变性并不是最重要的问题,但还有其他考虑因素。命令式循环具有非常通用和不精确的语义,因此大多数人在学习替代方案后会发现它们太草率了。
例如,命令式循环没有 return 类型。不检查其参数的输入类型。许多类型的命令式循环迫使程序员不必要地处理与问题陈述无关的索引。只有少数几种命令式循环旨在涵盖所有可能的情况,因此它们过于通用。您无法创建自己的更适合您特定情况的新型命令式循环。它们几乎总是必须内置到语言本身中。
出于某种奇怪的原因,每个人似乎都在递归和命令式循环之间做出错误的选择,但递归确实是函数式程序员腰带中最糟糕的工具。除非绝对必要,或者除非算法自然地递归表达,否则我们会避免使用它。有许多高阶函数可用于比递归更清晰地更精确地表达大多数日常代码,同时保留所有优点,例如 Martjin 的 foldLeft
示例。
仅类型检查的好处就值得使用 foldLeft
而不是 for
循环。空列表可以毫不费力地处理。没有必须用循环外的作用域初始化的怪异累加器。您不必为所有那些您根本不关心的临时变量想出奇怪的名称。简直就是清洁工。
你可能会说这是一个特殊的简单案例,但要小心,避免命令式循环可以在绝大多数情况下为你提供这种体验,而在递归最终变得复杂的情况下,命令式解决方案通常会一样复杂。当您对所有替代方案进行真正的比较时,命令式循环之所以有利,是因为人们熟悉它们。