为什么我应该避免在 Scala 中使用局部可修改变量?

Why should I avoid using local modifiable variables in Scala?

我是 Scala 的新手,而且大多数时候我还没有使用过 Java。现在我的代码中到处都有警告说我应该 "Avoid mutable local variables" 我有一个简单的问题 - 为什么?

假设我有一个小问题 - 从四个中确定最大整数。我的第一个方法是:

def max4(a: Int, b: Int,c: Int, d: Int): Int = {
  var subMax1 = a
  if (b > a) subMax1 = b

  var subMax2 = c
  if (d > c) subMax2 = d

  if (subMax1 > subMax2) subMax1
  else subMax2
}

考虑到这条警告消息后,我找到了另一个解决方案:

def max4(a: Int, b: Int,c: Int, d: Int): Int = {
  max(max(a, b), max(c, d))
}

def max(a: Int, b: Int): Int = {
  if (a > b) a
  else b
}

看起来更漂亮了,但这背后的意识形态是什么?

每当我处理一个问题时,我都会这样想:"Ok, we start from this and then we incrementally change things and get the answer"。我知道问题是我尝试 更改 一些初始状态以获得答案,但不明白为什么至少在本地更改事物是不好的?如何在像 Scala 这样的函数式语言中迭代集合?

举个例子:假设我们有一个整数列表,如何编写一个函数,使 returns 个可被 6 整除的整数子列表?想不出没有局部可变变量的解决方案。

在您的特定情况下,还有另一种解决方案:

def max4(a: Int, b: Int,c: Int, d: Int): Int = {
  val submax1 = if (a > b) a else b
  val submax2 = if (c > d) c else d

  if (submax1 > submax2) submax1 else submax2
}

是不是更容易理解?当然我有点偏见,但我倾向于认为是,但是不要盲目地遵循这个规则。如果您发现某些代码可能以可变风格编写得更具可读性和简洁性,那么就这样做——scala 的强大之处在于您不需要承诺既不可变也不可变的方法,您可以在它们之间摇摆(顺便说一句,同样适用于 return 关键字用法)。

Like an example: Suppose we have a list of ints, how to write a function that returns the sublist of ints which are divisible by 6? Can't think of solution without local mutable variable.

当然可以使用递归编写这样的函数,但是,如果可变解决方案看起来和工作良好,为什么不呢?

它与 Scala 的关系不如与一般函数式编程方法的关系那么大。这个想法如下:如果你有常量变量(Java 中的 final),你可以使用它们而不用担心它们会改变。同样,您可以并行化您的代码,而不必担心竞争条件或线程不安全代码。

在你的例子中并不那么重要,但是想象一下下面的例子:

val variable = ...
new Future { function1(variable) }
new Future { function2(variable) }

使用final变量你可以肯定不会有任何问题。否则,您将不得不检查主线程以及 function1 和 function2。

当然,如果您不更改可变变量,也有可能获得相同的结果。但是使用不可变的,你可以确定会是这种情况。

编辑以回答您的编辑

本地可变变量不错,这就是您可以使用它们的原因。但是,如果您尝试考虑没有它们的方法,您可以得出与您发布的解决方案一样的解决方案,它更清晰并且可以很容易地并行化。

How to iterate over collection then in functional languages like Scala?

您始终可以在不更改任何内容的情况下迭代不可变集合。例如:

val list = Seq(1,2,3)
for (n <- list)
  println n

关于你说的第二点:你要停止传统的思维方式。在函数式编程中,Map、Filter、Reduce 等的使用很正常;以及模式匹配和其他在 OOP 中不常见的概念。对于您给出的示例:

Like an example: Suppose we have a list of ints, how to write a function that returns sublist of ints which are divisible by 6?

val list = Seq(1,6,10,12,18,20)
val result = list.filter(_ % 6 == 0)

首先你可以这样重写你的例子:

def max(first: Int, others: Int*): Int = {
    val curMax = Math.max(first, others(0))
    if (others.size == 1) curMax else max(curMax, others.tail : _*)
}

这使用可变参数和尾递归来查找最大数。当然还有很多其他方法可以做同样的事情。

回答你的问题 - 这是一个很好的问题,也是我第一次开始使用 Scala 时想到的问题。我个人认为整个 immutable/functional 编程方法有点夸张了。但对于它的价值,支持它的主要论点是:

不可变代码更易于阅读(主观)

不可变代码更健壮 - 改变可变状态确实会导致错误。以此为例:

for (int i=0; i<100; i++) {
  for (int j=0; j<100; i++) {
     System.out.println("i is " + i = " and j is " + j);
  }
}

这是一个过于简化的示例,但仍然很容易遗漏错误,编译器也不会帮助您

可变代码通常不是线程安全的。即使是微不足道的、看似原子的操作也不安全。以 i++ 为例,这看起来像一个原子操作,但它实际上等同于:

int i = 0;
int tempI = i + 0;
i = tempI;

不可变数据结构不允许您执行此类操作,因此您需要明确考虑如何处理它。当然,正如您指出的那样,局部变量通常是线程安全的,但不能保证。可以将 ListBuffer 实例变量作为参数传递给方法,例如

然而,不可变和函数式编程风格也有缺点:

性能。它通常在编译和运行时都较慢。编译器必须强制执行不变性,并且 JVM 必须分配比可变数据结构所需的更多 objects。 collections 尤其如此。

大多数 scala 示例显示类似 val numbers = List(1,2,3) 的内容,但在现实世界中,硬编码值很少见。我们通常动态构建 collections(从数据库查询等)。虽然 scala 可以重新分配集合中的值,但每次修改它时它仍然必须创建一个新的 collection object 。如果您想将 1000 个元素添加到 Scala List(不可变)中,JVM 将需要分配(然后是 GC)1000 objects

难以维护。功能代码可能很难阅读,看到这样的代码并不少见:

val data = numbers.foreach(_.map(a => doStuff(a).flatMap(somethingElse)).foldleft("", (a : Int,b: Int) => a + b))

我不了解你,但我发现这种代码真的很难理解!

难以调试。功能代码也很难调试。尝试在我上面的(糟糕的)示例中途放置一个断点

我的建议是使用 functional/immutable 真正有意义并且你和你的同事觉得这样做很舒服的风格。不要使用不可变结构,因为它们很酷或者 "clever"。复杂而具有挑战性的解决方案会让你在 Uni 获得加分,但在商业世界中,我们需要简单的解决方案来解决复杂的问题! :)

你的两个主要问题:

  1. 为什么要针对本地状态更改发出警告?
  2. 如何迭代没有可变状态的集合?

两个我都会回答。

警告

编译器警告不要使用可变局部变量,因为它们通常 是错误的原因。这并不意味着情况总是如此。但是,您的示例代码几乎是一个完全不必要地使用可变本地状态的经典示例,其使用方式不仅更容易出错,更不清晰,而且效率更低。

您的第一个代码示例比第二个实用解决方案效率低。当您只需要分配一个时,为什么可能要对 submax1 进行两次分配?你问这两个输入中哪一个更大,那么为什么不先问 然后再做一个赋值呢?为什么您的 第一个 临时存储部分状态的方法仅在提出如此简单问题的过程中进行了一半?

您的第一个代码示例也因为不必要的代码重复而效率低下。你反复问 "which is the biggest of two values?" 为什么要独立写 3 次那个代码?不必要地重复代码是 OOPFP 中众所周知的坏习惯,原因完全相同。每次您不必要地重复代码时,都会打开一个潜在的错误源。添加可变的本地状态(尤其是在不必要的情况下)只会增加脆弱性和难以发现错误的可能性,即使是在短代码中也是如此。您只需在一处键入 submax1 而不是 submax2,您可能暂时不会注意到该错误。

你的第二个 FP 解决方案删除了​​代码重复,大大减少了出错的机会,并表明根本不需要可变的本地状态。正如您自己所说,它也比 om-nom-nom 答案中的替代解决方案更清晰、更清晰。

(顺便说一下,编写这种简单函数的惯用 Scala 方法是

def max(a: Int, b: Int) = if (a > b) a else b

哪种简洁风格强调其简单性并使代码不那么冗长)

您的第一个解决方案效率低下且脆弱,但这是您的第一直觉。该警告使您找到了更好的解决方案。这个警告证明了它的价值。 Scala 被设计为可供 Java 开发人员使用,并且被许多具有长期命令式风格经验但对 FP 知之甚少或一无所知的人所采用。他们的第一直觉几乎总是与您相同。您已经展示了该警告如何帮助改进代码。

种情况,使用可变本地状态可以更快,但 Scala 专家一般的建议(不仅仅是纯粹的 FP真正的信徒)是 prefer immutability 并且只有在有明确的使用案例的情况下才达到可变性。这与许多开发人员的直觉背道而驰,即使对经验丰富的 Scala 开发人员来说很烦人,警告也是有用的。

有趣的是 max 函数经常出现在 "new to FP/Scala" 问题中。发问者经常被他们的 use of local state...引起的错误绊倒,这 link 既表明一些开发人员经常沉迷于可变状态,同时也让我想到了你的另一个问题。

集合的函数迭代

在 Scala 中迭代集合的函数式方法有 3 种

  1. 为了理解
  2. 显式递归
  3. 折叠和其他高阶函数

为了理解

您的问题:

Suppose we have a list of ints, how to write a function that returns sublist of ints which are divisible by 6? Can't think of solution without local mutable variable

答案:假设xs是一个整数列表(或其他一些序列),那么

for (x <- xs; if x % 6 == 0) yield x

将为您提供一个序列(与 xs 的类型相同),其中仅包含可被 6 整除的那些项(如果有的话)。不需要可变状态。 Scala 只是为您和 returns 匹配您的条件的任何内容迭代序列。

如果您还没有了解 for comprehensions(也称为 sequence comprehensions) you really should. Its a very expressive and powerful part of Scala syntax. You can even use them with side effects and mutable state if you want (look at the final example on the tutorial I just linked to). That said, there can be unexpected performance penalties 并且它们被一些开发人员过度使用。

显式递归

在第一节末尾的问题 I linked to 中,我在答案中给出了一个非常简单、显式递归的解决方案,用于从列表中返回最大的 Int。

def max(xs: List[Int]): Option[Int] = xs match {
  case Nil => None
  case List(x: Int) => Some(x)
  case x :: y :: rest => max( (if (x > y) x else y) :: rest )
} 

我不打算解释模式匹配和显式递归是如何工作的(阅读我的其他答案或 this one)。我只是向你展示技术。大多数 Scala 集合都可以递归迭代,不需要任何可变状态。如果您需要跟踪您一路上所做的事情,您可以传递一个累加器。 (在我的示例代码中,我将累加器放在列表的前面以保持代码更小,但查看这些问题的其他答案以更常规地使用累加器)。

但是这里有一个(天真的)显式递归方法来找到那些可被 6 整除的整数

def divisibleByN(n: Int, xs: List[Int]): List[Int] = xs match {
  case Nil => Nil
  case x :: rest if x % n == 0 => x :: divisibleByN(n, rest)
  case _ :: rest => divisibleByN(n, rest)
}

我称之为天真,因为它不是 tail recursive,因此可能会破坏您的筹码。可以使用累加器列表和内部辅助函数编写一个更安全的版本,但我把这个练习留给你了。无论您如何尝试,结果都不会像原始版本那样漂亮,但努力是有教育意义的。

递归是一项非常重要的学习技术。也就是说,一旦你学会了这样做,接下来要学习的重要事情是你通常可以避免自己明确地使用它......

折叠和其他高阶函数

您是否注意到我的两个显式递归示例有多么相似?那是因为列表上的大多数递归都具有相同的基本结构。如果你写了很多这样的函数,你会多次重复这个结构。这使它成为样板;浪费你的时间和潜在的错误来源。

现在,有许多复杂的方法可以解释 folds,但一个简单的概念是它们将样板从递归中移除。他们为您负责累加器值的递归和管理。他们只要求您为累加器提供一个种子值,并在每次迭代中应用该函数。

例如,这里是一种使用 fold 从列表中提取最高 Int 的方法 xs

xs.tail.foldRight(xs.head) {(a, b) => if (a > b) a else b}

我知道你不熟悉折叠,所以这对你来说可能看起来很乱,但你肯定认得我在右边传递的 lambda(匿名函数)。我在那里所做的是获取列表中的第一项 (xs.head) 并将其用作累加器的种子值。然后我告诉列表的其余部分 (xs.tail) 遍历自身,依次将每个项目与累加器值进行比较。

这种事情很常见,所以合集api设计师提供了一个shorthand版本:

xs.reduce {(a, b) => if (a > b) a else b}

(如果你查看源代码,你会发现他们已经使用折叠实现了它)。

您可能想对 Scala 集合进行迭代的任何事情 都可以使用折叠来完成。通常,api 设计者会提供一个更简单的 higher-order function,它是在引擎盖下使用折叠实现的。想再次找到那些可被 6 整除的 Int 吗?

xs.foldRight(Nil: List[Int]) {(x, acc) => if (x % 6 == 0) x :: acc else acc}

从一个空列表作为累加器开始,遍历每一项,只将能被 6 整除的添加到累加器。同样,为您提供了一个更简单的基于折叠的 HoF

xs filter { _ % 6 == 0 }

折叠和相关的高阶函数比推导式或显式递归更难理解,但非常强大且富有表现力(对任何理解它们的人来说)。它们消除了样板代码,消除了潜在的错误来源。因为它们是由核心语言开发人员实现的,所以它们可以更高效(并且该实现可以随着语言的进步而改变,而不会破坏您的代码)。有经验的 Scala 开发人员优先使用它们来理解或显式递归。

tl;博士

  1. 为了理解而学习
  2. 学习显式递归
  3. 如果高阶函数可以完成这项工作,请不要使用它们。

使用不可变变量总是更好,因为它们使您的代码更易于阅读。编写递归代码可以帮助您解决问题。

def max(x: List[Int]): Int = {
  if (x.isEmpty == true) {
    0
  }
  else {
    Math.max(x.head, max(x.tail))
  }
}
val a_list = List(a,b,c,d)
max_value = max(a_list)