控制结构

优质
小牛编辑
125浏览
2023-12-01

控制结构

函数式风格的程序倾向于需要更少的传统的控制结构,并且使用声明式风格写的程序读起来更好。这通常意味着打破你的逻辑,拆分到若干个小的方法或函数,用匹配表达式(match expression)把他们粘在一起。函数式程序也倾向于更多面向表达式(expression-oriented):条件分支是同一类型的值计算,for(..) yield 表达式,以及递归都是司空见惯的。

递归

用递归术语来表达你的问题常常会使问题简化,如果应用了尾递归优化(可以通过@tailrec注释检测),编译器甚至会将你的代码转换为正常的循环。对比一个标准的命令式版本的堆排序(fix-down):

  1. def fixDown(heap: Array[T], m: Int, n: Int): Unit = {
  2. var k: Int = m
  3. while (n >= 2*k) {
  4. var j = 2*k
  5. if (j < n && heap(j) < heap(j + 1))
  6. j += 1
  7. if (heap(k) >= heap(j))
  8. return
  9. else {
  10. swap(heap, k, j)
  11. k = j
  12. }
  13. }
  14. }

每次进入while循环,我们工作在前一次迭代时污染过的状态。每个变量的值是那一分支所进入函数,当找到正确的位置时会在循环中返回。
(敏锐的读者会在Dijkstra的“Go To声明是有害的”一文找到相似的观点)

考虑尾递归的实现[2]:

  1. @tailrec
  2. final def fixDown(heap: Array[T], i: Int, j: Int) {
  3. if (j < i*2) return
  4. val m = if (j == i*2 || heap(2*i) < heap(2*i+1)) 2*i else 2*i + 1
  5. if (heap(m) < heap(i)) {
  6. swap(heap, i, m)
  7. fixDown(heap, m, j)
  8. }
  9. }

每次迭代都是一个明确定义的历史清白的变量,并且没有引用单元:到处都是不变的(invariants)。更容易实现,也容易阅读。也没有性能方面的惩罚:因为方法是尾递归的,编译器会转换为标准的命令式的循环。

返回(Return)

并不是说命令式结构没有价值。在很多例子中它们很适合于提前终止计算而非对每个可能终止的点存在一个条件分支:的确在上面的fixDown函数,如果我们已经在堆的结尾,一个return用于提前终止。

Returns可以用于切断分支和建立不变量(establish invariants)。这减少了嵌套,并且容易推断后续的代码的正确性,从而帮助了读者。这尤其适用于卫语句(guard clauses):

  1. def compare(a: AnyRef, b: AnyRef): Int = {
  2. if (a eq b)
  3. return 0
  4. val d = System.identityHashCode(a) compare System.identityHashCode(b)
  5. if (d != 0)
  6. return d
  7. // slow path..
  8. }

使用return增加了可读性

  1. def suffix(i: Int) = {
  2. if (i == 1) return "st"
  3. else if (i == 2) return "nd"
  4. else if (i == 3) return "rd"
  5. else return "th"
  6. }

上面是针对命令式语言的,在Scala中鼓励省略return

  1. def suffix(i: Int) =
  2. if (i == 1) "st"
  3. else if (i == 2) "nd"
  4. else if (i == 3) "rd"
  5. else "th"

但使用模式匹配更好:

  1. def suffix(i: Int) = i match {
  2. case 1 => "st"
  3. case 2 => "nd"
  4. case 3 => "rd"
  5. case _ => "th"
  6. }

注意,return会有隐性开销:当在闭包内部使用时。

  1. seq foreach { elem =>
  2. if (elem.isLast)
  3. return
  4. // process...
  5. }

在字节码层实现为一个异常的捕获/声明(catching/throwing)对,用在频繁的执行的代码中,会有性能影响。

for循环和for推导

for对循环和聚集提供了简洁和自然的表达。 它在扁平化(flattening)很多序列时特别有用。for语法通过分配和派发闭包隐藏了底层的机制。这会导致意外的开销和语义;例如:

  1. for (item <- container) {
  2. if (item != 2) return
  3. }

如果容器延迟计算(delays computation)会引起运行时错误,使返回不在本地上下文 (making the return nonlocal)

因为这些原因,常常更可取的是直接调用foreach, flatMap, map和filter —— 但在其意义清楚的时候使用for。

要求require和断言(assert)

要求(require)和断言(assert)都起到可执行文档的作用。两者都在类型系统不能表达所要求的不变量(invariants)的场景里有用。
assert用于代码假设的不变量(invariants) (内部或外部的) 例如:(译注,不变量 invariant 是指类型不可变,即不支持协变或逆变的类型变量)

  1. val stream = getClass.getResourceAsStream("someclassdata")
  2. assert(stream != null)

相反,require用于表达API契约:

  1. def fib(n: Int) = {
  2. require(n > 0)
  3. ...
  4. }