使用Scala高价函数简化代码

2015.06.18 | Comments

在Scala里,带有其他函数做参数的函数叫做高阶函数,使用高阶函数可以简化代码。

减少重复代码

有这样一段代码,查找当前目录样以某一个字符串结尾的文件:

object FileMatcher {
  private def filesHere = (new java.io.File(".")).listFiles
  def filesEnding(query: String) =
    for (file <- filesHere; if file.getName.endsWith(query))
      yield file
}

如果,我们想查找包含某一个字符串的文件,则代码需要修改为:

def filesContaining(query: String) =
  for (file <- filesHere; if file.getName.contains(query))
    yield file

上面的改动只是使用了 contains 替代 endsWith,但是随着需求越来越复杂,我们要不停地去修改这段代码。例如,我想实现正则匹配的查找,则代码会是下面这个样子:

def filesRegex(query: String) =
  for (file <- filesHere; if file.getName.matches(query))
    yield file

为了应变复杂的需求,我们可以进行重构代码,抽象出变化的代码部分,将其声明为一个方法:

def filesMatching(query: String,matcher: (String, String) => Boolean) = {
  for (file <- filesHere; if matcher(file.getName, query))
    yield file
}

这样,针对不同的需求,我们可以编写不同的matcher方法实现,该方法返回一个布尔值。

有了这个新的 filesMatching 帮助方法,你可以通过让三个搜索方法调用它,并传入合适的函数 来简化它们:

def filesEnding(query: String) = filesMatching(query, _.endsWith(_))

def filesContaining(query: String) = filesMatching(query, _.contains(_))

def filesRegex(query: String) = filesMatching(query, _.matches(_))

上面的例子使用了占位符,例如, filesEnding 方法里的函数文本 _.endsWith(_) 其实就是:

(fileName: String, query: String) => fileName.endsWith(query)

因为,已经确定了参数类型为字符串,故上面可以省略参数类型。由于第一个参数 fileName 在方法体中被第一个使用,第二个参数 query 第二个使用,你也可以使用占位符语法:_.endsWith(_)。第一个下划线是第一个参数文件名的占位符,第二个下划线是第二个参数查询字串的占位符。

因为query参数是从外部传过来的,其可以直接传递给matcher函数,故filesMatching可以只需要一个参数:

object FileMatcher {
  private def filesHere = (new java.io.File(".")).listFiles

  private def filesMatching(matcher: String => Boolean) =
    for (file <- filesHere; if matcher(file.getName))
      yield file

  def filesEnding(query: String) = filesMatching(_.endsWith(query))

  def filesContaining(query: String) = filesMatching(_.contains(query))

  def filesRegex(query: String) = filesMatching(_.matches(query))
}

上面的例子使用了函数作为第一类值帮助你减少代码重复的方式,另外还演示了闭包是如何能帮助你减少代码重复的。前面一个例子里用到的函数文本,如 _.endsWith(_)_.contains(_)都是在运行期实例化成函数值而不是闭包,因为它们没有捕 获任何自由变量。

举例来说,表达式_.endsWith(_)里用的两个变量都是用下划线代表的,也就是说它们都是从传递给函数的参数获得的。因此,_.endsWith(_)使用了两个绑定变量,而不是自由变量。

相对的,最近的例子里面用到的函数文本_.endsWith(query)包含一个绑定变量,下划线代表的参数和一个名为 query 的自由变量。仅仅因为 Scala 支持闭包才使得你可以在最近的这个例子里从 filesMatching 中去掉 query 参数,从而更进一步简化了代码。

另外一个例子,是循环集合时可以使用exists方法来简化代码。以下是使用了这种方式的方法去判断是否传入的 List 包含了负数的例子:

def  containsNeg(nums: List[Int]): Boolean = {
    var exists = false
    for (num <- nums)
        if (num < 0)
            exists = true
    exists
}

采用和上面例子同样的方法,我们可以抽象代码,将重要的逻辑抽离到一个独立的方法中去实现。对于上面的查找判断是否存在的逻辑,Scala中提供了高阶函数 exists 来实现,代码如下:

def containsNeg(nums: List[Int]) = nums.exists(_ < 0)

同样,如果你要查找集合中是否存在偶数,则可以使用下面的代码:

def containsOdd(nums: List[Int]) = nums.exists(_ % 2 == 1)

柯里化

当函数有多个参数列表时,可以使用柯里化函数来简化代码调用。例如,对下面的函数,它实现两个 Int 型参数,x 和 y 的加法:

scala> def plainOldSum(x: Int, y: Int) = x + y
plainOldSum: (Int,Int)Int

scala> plainOldSum(1, 2)
res4: Int = 3

我们可以将其柯里化,代之以一个列表的两个Int参数,实现如下:

scala> def curriedSum(x: Int)(y: Int) = x + y
curriedSum: (Int)(Int)Int

scala> curriedSum(1)(2)
res5: Int = 3

当你调用 curriedSum,你实际上背靠背地调用了两个传统函数。第一个函数调 用带单个的名为 x 的 Int 参数,并返回第二个函数的函数值,第二个函数带 Int 参数 y。

你可以使用偏函数,填上第一个参数并且部分应用第二个参数。

scala> val onePlus = curriedSum(1)_
onePlus: (Int) => Int = <function>

curriedSum(1)_里的下划线是第二个参数列表的占位符。结果就是指向一个函数的参考,这个函数在被调用的时候,对它唯一的Int参数加1并返回结果:

scala> onePlus(2)
res7: Int = 3

可变长度参数

类似柯里化函数,对于同类型的多参数列表,我们还可以使用可变长度参数,这部分内容,请参考《Scala基本语法和概念》中的可变长度参数

贷出模式

前面的例子提到了使用函数作为参数,我们可以将这个函数的执行结果再次作为参数传入函数,即双倍控制结构:能够重复一个操作两次并返回结果。

下面是一个例子:

scala> def twice(op: Double => Double, x: Double) = op(op(x))
twice: ((Double) => Double,Double)Double

scala> twice(_ + 1, 5)
res9: Double = 7.0

上面例子中 op 的类型是 Double => Double,就是说它是带一个 Double 做参数并返回另一个 Double 的函数。这里,op函数等同于:

def add(x:Int)=x+1

op函数会执行两次,第一次是执行add(5)=6,第二次是执行add(add(5))=add(6)=6+1=7

任何时候,你发现你的代码中多个地方有重复的代码块,你就应该考虑把它实现为这种双重控制结构。

考虑这样一种需求:打开一个资源,对它进行操作,然后关闭资源,你可以这样实现:

def withPrintWriter(file: File, op: PrintWriter => Unit) {
  val writer = new PrintWriter(file)
  try {
    op(writer)
  } finally {
    writer.close()
  }
}

有了这个方法,你就可以这样使用:

withPrintWriter(new File("date.txt"), writer => writer.println(new java.util.Date) )

注意: 这里和上面的例子一样,使用了=> 来映射式定义函数,其可以看成是没有参数的函数,返回一个匿名函数;调用的时候是调用这个返回的匿名函数。

使用这个方法的好处是,调用这个方法只需要关注如何操作资源,而不用去关心资源的打开和关闭。这个技巧被称为贷出模式:loan pattern,因为该函数要个模板方法一样,实现了资源的打开和关闭,而将使用 PrintWriter 操作资源贷出给函数,交由调用者来实现。

例子里的 withPrintWriter 把 PrintWriter 借给函数 op。当函数完成的时候,它发出信号说明它不再需要“借”的资源。于是资源被关闭在 finally 块中,以确信其确实被关闭,而忽略函数是正常结束返回还是抛出了异常。

因为,这个函数有两个参数,所以你可以将该函数柯里化:

def withPrintWriter(file: File)(op: PrintWriter => Unit) {
  val writer = new PrintWriter(file)
  try {
    op(writer)
  } finally {
    writer.close()
  } 
}

这样的话,你可以如下方式调用:

val file = new File("date.txt")
withPrintWriter(file) {
    writer => writer.println(new java.util.Date)
}

这个例子里,第一个参数列表,包含了一个 File 参数,被写成包围在小括号中。第二个参数列表,包含了一个函数参数,被包围在大括号中。

当一个函数只有一个参数时,可以使用大括号代替小括号。

传名参数 by-name parameter

《Programming in Scala》的第九章提到了传名参数这个概念。其中举的例子是:实现一个称为myAssert的断言函数,该函数将带一个函数值做输入并参考一个标志位来决定该做什么。

如果没有传名参数,你可以这样写myAssert:

var assertionsEnabled = true 
def myAssert(predicate: () => Boolean) =  
    if (assertionsEnabled && !predicate())  
        throw new AssertionError

这个定义是正确的,但使用它会有点儿难看:

myAssert(() => 5 > 3) 

你或许很想省略函数文本里的空参数列表和=>符号,写成如下形式:

myAssert(5 > 3) // 不会有效,因为缺少() => 

传名函数恰好为了实现你的愿望而出现。要实现一个传名函数,要定义参数的类型开始于=>而不是() =>。例如,你可以通过改变其类型() => Boolean=> Boolean,把myAssert的predicate参数改为传名参数。

def byNameAssert(predicate: => Boolean) =  
    if (assertionsEnabled && !predicate)  
        throw new AssertionError  

现在你可以在需要断言的属性里省略空的参数了。使用byNameAssert的结果看上去就好象使用了内建控制结构:

byNameAssert(5 > 3)  

传名类型中,空的参数列表()被省略,它仅在参数中被允许。没有什么传名变量或传名字段这样的东西。

现在,你或许想知道为什么你不能简化myAssert的编写,使用陈旧的Boolean作为它参数的类型,如:

def boolAssert(predicate: Boolean) =  
    if (assertionsEnabled && !predicate)  
        throw new AssertionError         

当然这种格式同样合法,并且使用这个版本boolAssert的代码看上去仍然与前面的一样:

boolAssert(5 > 3)  

虽然如此,这两种方式之间存在一个非常重要的差别须指出。因为boolAssert的参数类型是Boolean,在boolAssert(5 > 3)里括号中的表达式先于boolAssert的调用被评估。表达式5 > 3产生true,被传给boolAssert。相对的,因为byNameAssert的predicate参数的类型是=> BooleanbyNameAssert(5 > 3)里括号中的表达式不是先于byNameAssert的调用被评估的。而是代之以先创建一个函数值,其apply方法将评估5 > 3,而这个函数值将被传递给byNameAssert。

因此这两种方式之间的差别,在于如果断言被禁用,你会看到boolAssert括号里的表达式的某些副作用,而byNameAssert却没有。例如,如果断言被禁用,boolAssert的例子里尝试对x / 0 == 0的断言将产生一个异常:

scala> var assertionsEnabled = false 
assertionsEnabled: Boolean = false 
scala> boolAssert(x / 0 == 0)  
java.lang.ArithmeticException: / by zero  
 at .< init>(< console>:8)  
 at .< clinit>(< console>)  
 at RequestResult$.< init>(< console>:3)  
 at RequestResult$.< clinit>(< console>)...  

但在byNameAssert的例子里尝试同样代码的断言将不产生异常:

scala> byNameAssert(x / 0 == 0) 

总结

本文主要总结了几种使用Scala高阶函数简化代码的方法,涉及到的知识点有:柯里化、偏函数、函数映射式定义、可变长度参数、贷出模式以及传名参数。需要意识到的是,灵活使用高阶函数可以简化代码,但也可能会增加代码阅读的复杂度。


原创文章,转载请注明: 转载自JavaChen Blog,作者:JavaChen
本文链接地址:http://blog.javachen.com/2015/06/18/simplify-code-using-scala-higher-order-function.html
本文基于署名2.5中国大陆许可协议发布,欢迎转载、演绎或用于商业目的,但是必须保留本文署名和文章链接。 如您有任何疑问或者授权方面的协商,请邮件联系我。