何时使用命令式编程风格更合适?

15
从第二版《Scala编程》第98页底部:
平衡态度的Scala程序员
更喜欢`val`、不可变对象和没有副作用的方法。首选它们。当您有特定需求和理由时,请使用`var`、可变对象和具有副作用的方法。
在前面的页面上已经解释了为什么更喜欢`val`、不可变对象和没有副作用的方法,因此这句话很容易理解。但是第二个句子:"当您有特定需求和理由时,请使用`var`、可变对象和具有副作用的方法"没有很好地解释清楚。因此,我的问题是:
何时需要使用`var`、可变对象和具有副作用的方法?
答:只有在以下情况下才需要使用`var`、可变对象和具有副作用的方法:
1. 状态更新:当需要修改某些状态时,例如计数器或缓存,最好使用可变对象或具有副作用的方法来实现。
例如:
```scala var count = 0 // 可变变量 def incrementCount(): Unit = { count += 1 // 具有副作用方法 } ```
2. 性能优化:在一些特殊情况下,使用可变对象或具有副作用的方法可以提高代码的运行效率。
例如:
```scala val xs = (1 to 1000000).toList // 不可变列表 def sum(xs: List[Int]): Int = xs.foldLeft(0)(_ + _) // 没有副作用的方法 val sumOfXs = sum(xs) // 计算sum(xs)的值
// 使用可变变量和具有副作用的方法重写sum函数,可以提高代码的性能。 def sum(xs: List[Int]): Int = { var acc = 0 for (x <- xs) { acc += x } acc } ```
总之,只要确实需要修改状态或提高性能,才应该使用`var`、可变对象和具有副作用的方法。否则,最好使用`val`、不可变对象和没有副作用的方法来编写Scala程序。

2
一个可能太明显的例子:你的程序所做的任何输入/输出都是副作用,而且不能没有具有副作用的方法来完成。 - Alexey Romanov
这是个很好的例子!有时候我们会被细节迷惑而忽略了大局...我认为你的评论和@HeikoSeeberger的回答结合起来就解释清楚了一切。 - PrimosK
我的经验法则是:尽可能使用不可变和纯函数的方法,如果你有疑问或性能问题,请在IRC和StackOverflow上提问。 :) - Dan Burton
4个回答

16
在很多情况下,函数式编程提高了抽象层次,从而使您的代码更加简明、易于编写和理解。但是,在某些情况下,生成的字节码可能无法像命令式解决方案一样被优化(快速)。
当前(Scala 2.9.1)一个很好的例子是求和范围。
(1 to 1000000).foldLeft(0)(_ + _)

对比:

var x = 1
var sum = 0
while (x <= 1000000) {
  sum += x
  x += 1
}

如果对这些进行性能分析,你会注意到执行速度有显著的差异。因此,有时候性能是一个非常好的理由。


1
当然,可以使用sum。但我想指出“纯粹”方法的差异。 - Heiko Seeberger
def seriesSum(a1: Int, an: Int, n: Int) = n / 2 * (a1 + an) ? - Vlad Gudim
1
@PrimosK我觉得(1到1000000).sum慢得很可疑,因为它以恒定时间运行,而不是while循环。 - Daniel C. Sobral
@DanielC.Sobral - 是哪个版本?默认为2.9.1的IndexedSeq实现(但文档没有突出这一事实)。在我的看来,它慢了约25倍。 - Rex Kerr
@PrimosK 哦,好的,它只是在去年12月份被更改了。我可能把它和 size/length 搞混了。 - Daniel C. Sobral

8

简便的小更新

使用可变性的一个原因是如果你正在跟踪某个持续进行的过程。例如,假设我正在编辑一个大型文档,并有一组复杂的类来跟踪文本的各种元素、编辑历史、光标位置等。现在假设用户点击了文本的不同部分。我应该重新创建文档对象,复制许多字段但不包括EditState字段;重新创建具有新ViewBoundsdocumentCursorPositionEditState吗?还是只在一个地方更改可变变量?只要线程安全不是问题,那么更新一个或两个变量比复制所有内容要简单得多,也更不容易出错。如果线程安全一个问题,那么保护并发访问可能比使用不可变方法并处理过时请求更费力。

计算效率

使用可变性的另一个原因是为了速度。对象创建是廉价的,但简单的方法调用更加廉价,对基本类型的操作则更加廉价。

例如,假设我们有一个映射,我们想要对值和值的平方求和。

val xs = List.range(1,10000).map(x => x.toString -> x).toMap
val sum = xs.values.sum
val sumsq = xs.values.map(x => x*x).sum

如果只是偶尔这样做,那没什么大不了的。但如果你关注正在发生的事情,对于每个列表元素,你需要首先重新创建它(values),然后对它求和(boxed),再次重新创建它(values),然后以带装箱的形式再次重新创建它(map),最后将它们相加。这至少需要六个对象的创建和五个完整的遍历,只为了每个项执行两个加法和一个乘法。非常低效。
你可以尝试通过避免多次递归并仅通过一次地传递映射来改进它,使用fold:
val (sum,sumsq) = ((0,0) /: xs){ case ((sum,sumsq),(_,v)) => (sum + v, sumsq + v*v) }

这个改进已经好多了,在我的机器上性能提高了约15倍。但是每次迭代仍然要创建三个对象。如果你

case class SSq(var sum: Int = 0, var sumsq: Int = 0) {
  def +=(i: Int) { sum += i; sumsq += i*i }
}
val ssq = SSq()
xs.foreach(x => ssq += x._2)

由于您缩小了拳击比赛,因此您的速度大约快了两倍。如果您的数据在数组中,并使用while循环,则可以避免所有对象创建和拆箱,并将速度提高20倍。

现在,话虽如此,您也可以为数组选择递归函数:

val ar = Array.range(0,10000)
def suma(xs: Array[Int], start: Int = 0, sum: Int = 0, sumsq: Int = 0): (Int,Int) = {
  if (start >= xs.length) (sum, sumsq)
  else suma(xs, start+1, sum+xs(start), sumsq + xs(start)*xs(start))
}

这样编写的代码与可变 SSq 一样快。但是,如果我们改为这样编写:

def sumb(xs: Array[Int], start: Int = 0, ssq: (Int,Int) = (0,0)): (Int,Int) = {
  if (start >= xs.length) ssq
  else sumb(xs, start+1, (ssq._1+xs(start), ssq._2 + xs(start)*xs(start)))
}

我们现在又慢了10倍,因为我们每一步都需要创建一个对象。
所以归根结底,只有当您无法方便地将更新的结构作为独立参数传递给方法时,才真正重要的是具有不可变性。一旦您超越了这个方法可行的复杂度,可变性可能会带来巨大的收益。
累积对象创建
如果您需要从可能存在故障的数据中构建具有n个字段的复杂对象,则可以使用以下生成器模式:
abstract class Built {
  def x: Int
  def y: String
  def z: Boolean
}
private class Building extends Built {
  var x: Int = _
  var y: String = _
  var z: Boolean = _
}

def buildFromWhatever: Option[Built] = {
  val b = new Building
  b.x = something
  if (thereIsAProblem) return None
  b.y = somethingElse
  // check
  ...
  Some(b)
}

这仅适用于可变数据。当然还有其他选择:

class Built(val x: Int = 0, val y: String = "", val z: Boolean = false) {}
def buildFromWhatever: Option[Built] = {
  val b0 = new Built
  val b1 = b0.copy(x = something)
  if (thereIsAProblem) return None
  ...
  Some(b)
}

在许多方面,这种方法甚至更加简洁,但每次更改时都需要复制对象,这可能非常缓慢。而且这两种方法都不是特别可靠的;为了做到这一点,您可能需要

class Built(val x: Int, val y: String, val z: Boolean) {}
class Building(
  val x: Option[Int] = None, val y: Option[String] = None, val z: Option[Boolean] = None
) {
  def build: Option[Built] = for (x0 <- x; y0 <- y; z0 <- z) yield new Built(x,y,z)
}

def buildFromWhatever: Option[Build] = {
  val b0 = new Building
  val b1 = b0.copy(x = somethingIfNotProblem)
  ...
  bN.build
}

但是,再次强调,这会有很多开销。


5
我发现命令式/可变风格更适合动态编程算法。如果坚持使用不可变性,对于大多数人来说编程会更加困难,并且可能导致使用大量内存和/或溢出堆栈。例如:函数式范式中的动态规划

3

以下是一些例子:

  1. (原来是一个注释) 任何程序都必须进行一些输入和输出(否则,它就没有用处)。但根据定义,输入/输出是副作用,不能在不调用具有副作用方法的情况下完成。

  2. Scala 的一个主要优点是能够使用 Java 库。其中许多依赖于可变对象和带有副作用的方法。

  3. 有时您需要一个 var,因为它在作用域中。请参见此博客文章中的 Temperature4 示例。

  4. 并发编程。如果您使用 actor,则发送和接收消息是副作用;如果您使用线程,则在锁上同步是副作用,并且锁是可变的;事件驱动的并发都是关于副作用的;futures、并发集合等都是可变的。


此外,数据流/FRP 可以处理并发/事件驱动编程而不产生副作用。STM 处理锁定时也不会产生副作用。第三种情况也可以通过数据流/FRP 来处理。可以使用延续来代替 futures。因此,我认为这些示例都没有证明副作用或可变性的必要性。 - Daniel C. Sobral
然后,在main中,只需像处理简单的Unix管道一样进行输入/输出即可。但在Scala中,仍需要调用具有副作用的方法,并使main本身成为具有副作用的方法。 - Alexey Romanov
STM在实现中是否处理没有副作用的锁?ScalaSTM肯定不会,我找不到readTVar#等在GHC中的定义位置。 - Alexey Romanov
@Daniel:如果你的程序与其他系统(或用户)交互,那么你不能推迟所有的I/O。因为你的计算将取决于输入,而输入又会受到程序先前输出的影响。当然,你可以以纯粹的方式计算每个输入的响应,但有时处理I/O的外层是程序中更复杂的部分。你不能仅通过推迟I/O并使main成为代码中唯一的不纯函数来解决这个问题。 - Ben
@Daniel:我在上一条评论中差点提到FRP,但最终决定不去复杂化事情。:) 然而,它仍然远未成为主流(尽管我对此感到非常兴奋)。即使在Haskell中,处理复杂的I/O依赖代码的通常方法也是在IO单子中有一个外层,这本质上是一个命令式程序。 - Ben

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接