【问题标题】:Why should I avoid using local modifiable variables in Scala?为什么我应该避免在 Scala 中使用局部可修改变量?
【发布时间】:2015-10-24 21:21:21
【问题描述】:

我对 Scala 还是很陌生,而且大部分时间在我使用 Java 之前。现在我的代码中到处都有警告说我应该“避免可变局部变量”,我有一个简单的问题 - 为什么?

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

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
}

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

每当我遇到一个问题时,我都会这样想:“好吧,我们从这个开始,然后我们逐渐改变事情并得到答案”。我知道问题在于我尝试更改一些初始状态以获得答案,但不明白为什么至少在本地更改内容是不好的?如何在 Scala 等函数式语言中迭代集合?

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

【问题讨论】:

  • @om-nom-nom 对您的最后一个示例提出了问题。您想从列表中提取 all 可被 6 整除的整数,还是多个整数的任何子序列,其中每个整数都可被 6 整除?我应该说,第一种更简单的解释对我来说似乎更有可能。

标签: scala functional-programming immutability


【解决方案1】:

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

在您的示例中并不那么重要,但是请想象以下示例:

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

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

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

编辑以回答您的编辑

本地变量还不错,这就是您可以使用它们的原因。但是,如果您尝试在没有它们的情况下思考方法,则可以得出您发布的解决方案,这样更简洁并且可以非常容易地并行化。

如何在 Scala 等函数式语言中迭代集合?

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

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

关于你所说的第二件事:你必须停止以传统方式思考。在函数式编程中,Map、Filter、Reduce等的使用是正常的;以及模式匹配和其他在 OOP 中不典型的概念。对于您给出的示例:

举个例子:假设我们有一个整数列表,如何编写一个返回可被 6 整除的整数子列表的函数?

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

【讨论】:

  • 我知道在不同线程中使用可变变量时可能会出现并发问题,但是为什么我还要尽量避免使用局部可变变量呢?
  • list.filter(_ % 6 == 0) 我不是 OP,但可能是他的意思是返回连续整数的子列表(或者他没有)
【解决方案2】:

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

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 关键字用法同样适用)。

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

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

【讨论】:

  • 我只是尽量不使用 Scala 语法编写 Java 代码,并尝试理解其背后的哲学
  • @OleksiiDuzhyi 哲学相当简单——从两个世界中挑选最好的(命令式/功能性)。至于 IDE 的警告——我猜这是非黑即白的谬误——中间有很多案例。
  • 这并不能真正回答问题。它与它有关,但似乎回答了一个不同的问题“我如何用不可变变量编写这个函数?”。该功能仅作为示例提出问题。
  • @itsbruce 我不敢苟同!最初的发布者对 IDE 向他显示的警告感到困惑,如果有避免局部可变状态的深层原因,就会徘徊。我的立场是,一般来说没有这样的原因,而且 IDE 还不够聪明,无法区分可变局部变量的合法使用和有害变量的使用——它只是遵循其开发人员预定义的规则。该规则通常会导致更好的代码(如针对特定案例所示),但应谨慎对待,这是此答案的关键信息。问题的哪一部分没有解决?
【解决方案3】:

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

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 时想到的一个问题。就我个人而言,我认为整个不可变/函数式编程方法有些被夸大了。但这里的价值在于支持它的主要论据:

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

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

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 必须分配比可变数据结构所需的更多的对象。收藏品尤其如此。

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

难以维护。函数式代码可能很难阅读,这样的代码并不少见:

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

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

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

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

【讨论】:

  • 提醒一下,原始问题强调本地状态(如方法范围),而不是一般状态
  • 性能...编译器必须强制执行不变性...你能详细说明一下吗?
  • 会 +1 代码示例,但对大多数所谓的缺点提出质疑,主要是因为它们与问题无关。 OP 可以避免本地可变状态而不会犯任何此类罪行。 “难以阅读”是愚蠢的。任何风格的代码都可能写得不好。将我的代码与 OP 的 here 进行比较。调试?同样,差代码不好,好代码好。但是纯函数更容易测试。表现?要看。此外,持久数据结构。
【解决方案4】:

您的两个主要问题:

  1. 为什么要针对本地状态变化​​发出警告?
  2. 如何在没有可变状态的情况下迭代集合?

我都会回答。

警告

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

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

由于不必要的代码重复,您的第一个代码示例也效率低下。您反复问“两个值中哪个是最大的?”为什么要独立写出那 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 开发人员来说,警告也是有用的。

有趣的是,某种 ma​​x 函数在“FP/Scala 新手”问题中出现的频率很高。提问者经常因他们的use of local state... 引起的错误而绊倒,该链接既表明了一些开发人员对可变状态的经常迟钝上瘾,同时也将我引向了您的其他问题。

集合的函数迭代

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

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

供理解

你的问题:

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

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

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

会给你一个序列(与xs 的类型相同),其中只包含那些可以被 6 整除的项目,如果有的话。不需要可变状态。 Scala 只是为您遍历序列并返回符合您条件的任何内容。

如果您还没有学习 for comprehensions(也称为 sequence comprehensions)的强大功能,那么您真的应该学习。它是 Scala 语法中非常有表现力和强大的部分。如果需要,您甚至可以将它们与副作用和可变状态一起使用(查看我刚刚链接到的教程的最后一个示例)。也就是说,可能有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) 对其自身进行迭代,依次将每个项目与累加器值进行比较。

这种事情很常见,所以Collections api设计者提供了一个简写版本:

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

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

您可能想要对 Scala 集合迭代地执行的任何操作都可以使用折叠来完成。通常,api 设计者会提供一个更简单的higher-order function,它在后台使用折叠来实现。想再次找到那些可被 6 整除的整数吗?

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. 如果高阶函数可以完成这项工作,请不要使用它们。

【讨论】:

    【解决方案5】:

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

    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)
    

    【讨论】:

    • 你确定这会编译吗? max 期待 List[Int]a_list 的类型为 List[String]
    • 是的,它运行成功。这是代码的sn-p。 a,b,c,dInt 变量,类似于问题中的max4 函数参数。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2021-11-06
    • 1970-01-01
    • 2011-07-10
    • 2012-02-25
    • 2018-06-08
    • 2014-12-08
    • 1970-01-01
    相关资源
    最近更新 更多