控制抽象和传名参数
控制抽象是这样的函数,满足如下条件
- 参数是函数
- 函数参数没有输入值也没有返回值 ()=>Unit 简化处理:fun(funName:=>Unit)
第7章指出,Scala并没有很多内建的控制抽象,因为它提供了让用户自己创建控制抽象的功能。第8章介绍了函数值。本章将向你展示如何应用函数值来创建新的控制抽象。在这个过程中,你还将学习到柯里化和传名参数。
减少代码重复
所有的函数都能被分解成在每次函数调用时都一样的公共部分和在每次调用时都不一样的非公共部分。公共部分是函数体,而非公共部分必须通过实参传入。当你把函数值当作入参时,这段算法的非公共部分本身又是另一个算法。每当这样的函数被调用时,你都可以传入不同的函数值作为实参,被调用的函数会(在由它选择的时机)调用传入的函数值。这些高阶函数(higher-order function),即那些接收函数作为参数的函数,让你有额外的机会来进一步压缩和简化代码。
高阶函数的好处之一是可以用来创建减少代码重复的控制抽象。例如,假设你在编写一个文件浏览器,并且你打算提供API给用户来查找匹配某个条件的文件。首先,添加一个机制用来查找文件名是以指定字符串结尾的文件。比如,这将允许用户查找所有扩展名为“.scala”的文件。你可以通过在单例对象中定义一个公共的filesEnding方法的方式来提供这样的API,就像这样:
object FileMatcher:
private def filesHere = (new java.io.File(".")).listFiles
def filesEnding(query: String) = for file <- filesHere if file.getName.endsWith(query) yield file
这个filesEnding方法用私有的助手方法filesHere来获取当前目录下的所有文件,然后基于文件名是否以用户给定的查询条件结尾来过滤这些文件。由于filesHere方法是私有的,filesEnding方法是FileMatcher(也就是你提供给用户的API)中定义的唯一一个能被访问到的方法。
到目前为止,一切都很完美,暂时还没有重复的代码。不过到了后来,你决定让人们可以基于文件名的任意部分进行搜索。因为有时候用户记不住他们到底是将文件命名成了phb-important.doc、stupid-phb-report.doc、may2003salesdoc.phb,还是其他完全不一样的名字,他们只知道名字中某个地方出现了“phb”,这时这样的功能就很有用。于是,你回去给FileMatcher API添加了这个
- def filesContaining(query: String) = for file <- filesHere if file.getName.contains(query) yield file
这个函数与filesEnding的运行机制没什么两样:搜索filesHere,检查文件名,如果名字匹配,则返回文件。唯一的区别是,这个函数用的是contains而不是endsWith。
几个月过去了,这个程序变得更成功了。终于,面对某些高级用户提出的想要基于正则表达式搜索文件的需求,你屈服了。这些喜欢“偷懒”的用户有着大量拥有上千个文件的巨大目录,他们想实现类似找出所有标题中带有“oopsla”字样的PDF文件的操作。为了支持他们,你编写了下面这个函数:
def filesRegex(query: String) =for file <- filesHere if file.getName.matches(query) yield file
有经验的程序员会注意到这些函数中不断重复的代码,那么,有没有办法将它们重构成公共的助手函数呢?按显而易见的方式来实现并不可行。你会想要做到这样的效果:
def filesMatching(query: String, method) =for file <- filesHere if file.getName.method(query) yield file
这种方式在某些动态语言中可以做到,但Scala并不允许像这样在运行时将代码黏在一起的操作。那怎么办呢?
函数值提供了一种答案。虽然不能将方法名像值一样传来传去,但是可以通过传递某个帮你调用方法的函数值来达到同样的效果。在本例中,可以给方法添加一个matcher参数,该参数的唯一目的就是检查文件名是否满足某个查询条件:
- def filesMatching(query: String,matcher: (String, String) =>Boolean) = for file <- filesHere if matcher(file.getName, query) yield file
在这个版本的方法中,if子句用matcher来检查文件名是否满足查询条件。但这个检查具体做什么,取决于给定的matcher。现在,我们来看matcher这个类型本身。它首先是一个函数,因此在类型声明中有一个=>符号。这个函数接收两个字符串类型的参数(分别是文件名和查询条件),返回一个布尔值,因此这个函数的完整类型是(String, String) => Boolean。
有了这个新的filesMatching助手方法,就可以对前面3个搜索方法进行简化,调用助手方法,传入合适的函数:
- 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)
由于filesMatching方法接收一个要求两个String入参的函数,并不需要显式地给出入参的类型,因此可以直接写(fileName, query) => fileName.endsWith(query)。因为这两个参数在函数体内分别只被用到一次(第一个参数fileName先被用到,然后是第二个参数query),所以可以用占位符语法来写:_.endsWith(_)。第一个下画线是第一个参数(即文件名)的占位符,而第二个下画线是第二个参数(即查询字符串)的占位符。
这段代码已经很简化了,不过实际上还能更短。注意,这里的查询字符串被传入filesMatching方法后,filesMatching方法并不对它做任何处理,只是将它传入matcher函数。这样的来回传递是不必要的,因为调用者已经知道这个查询字符串了,所以完全可以将query参数从filesMatching方法和matcher函数中移除,
这样就可以得到示例9.1的代码。
- 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(_)使用了两个绑定变量,并没有使用任何自由变量。相反地,在示例9.1中,函数字面量_.endsWith(query)包含了一个绑定变量,即用下画线表示的那一个,以及一个名称为query的自由变量。正因为Scala支持闭包,我们才能在最新的这个例子中将query参数从filesMatching方法中移除,从而进一步简化代码。
编写新的控制结构 [ ()>{}, ()(),(){} ]
在拥有一等函数的语言中,可以有效地制作出新的控制接口,尽管语言的语法是固定的。你需要做的就是创建接收函数作为入参的方法。
例如,下面这个twice控制结构,它重复某个操作两次,并返回结果:
def twice(op: Double => Double, x: Double) = op(op(x))
twice(_ + 1, 5) // 7.0
本例中的op类型为Double => Double,意思是这是一个接收一个Double参数作为入参,返回另一个Double参数的函数。
每当你发现某个控制模式在代码中多处出现时,就应该考虑将这个模式实现为新的控制结构。在本章前面的部分,你看到了filesMatching这个非常特殊的控制模式,现在来看一个更加常用的编码模式:打开某个资源,对它进行操作,然后关闭这个资源。可以用类似如下的方法,将这个模式捕获成一个控制抽象:
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))
使用这个方法的好处是,确保文件在最后被关闭的是withPrintWriter而不是用户代码。因此,不可能出现使用者忘记关闭文件的情况。这个技巧被称作贷出模式,因为是某个控制抽象(如withPrintWriter)打开某个资源并将这个资源“贷出”给函数的。例如,前一例中的withPrintWriter将一个PrintWriter“贷出”给op函数。当函数完成时,它会表明自己不再需要这个“贷入”的资源。这时这个资源就在finally代码块中被关闭了,这样能确保无论函数是正常返回还是抛出异常,资源都会被正常关闭。
可以用花括号而不是圆括号来表示参数列表,这样调用方的代码看上去就更像在使用内建的控制结构一样。在Scala中,只要有那种只传入一个参数的方法调用,就都可以选择使用花括号来将入参括起来,而不是圆括号。
例如,可以不这样写:
val s = "Hello, world!"
s.charAt(1) // 'e'
而是写成:
s.charAt { 1 } // 'e'
在第二个例子中,用了花括号而不是圆括号来将println的入参括起来。不过,这个花括号技巧仅对传入单个入参的场景适用。参考下面这个尝试打破上述规则的例子:
由于你尝试传入两个入参给substring,因此当你试着将这些入参用花括号括起来时,会得到一个错误提示。这时需要使用圆括号:
s.substring(7, 9) // "wo"
Scala允许用花括号替代圆括号来传入单个入参的目的是让调用方程序员在花括号中编写函数字面量。这能让方法用起来更像是控制抽象。以前面的withPrintWriter为例,在最新的版本中,withPrintWriter接收两个入参,因此不能用花括号。尽管如此,由于传入withPrintWriter的函数是参数列表中的最后一个,因此可以用柯里化将第一个File参数单独拉到一个参数列表中,这样剩下的函数就独占了第二个参数列表。示例9.4展示了如何重新定义withPrintWriter。
def withPrintWriter(file: File)(op: PrintWriter => Unit) = {val writer = new PrintWriter(file) try op(writer) finally writer.close()}
新版本与旧版本的唯一区别在于,现在有两个各包含一个参数的参数列表,而不是一个包含两个参数的参数列表了。仔细看两个参数之间的部分,在旧版本的withPrintWriter中(173页),你看到的是...File, op...,而在新版本中,你看到的是...File)(op...。有了这样的定义,就可以用更舒服的语法来调用这个方法了:
val file = new File("date.txt")
withPrintWriter(file) { writer=> writer.println(new java.util.Date)}
在本例中,第一个参数列表,也就是那个包含了一个File入参的参数列表,用的是圆括号。而第二个参数列表,即包含函数入参的那个,用的是花括号。
传名参数( funName :=>return type )
前一节的withPrintWriter与语言内建的控制结构(如if和while)不同,花括号中间的代码接收一个入参。传入withPrintWriter的函数需要一个类型为PrintWriter的入参。这个入参就是下面代码中的“writer =>”:
withPrintWriter(file) { writer =>writer.println(new java.util.Date)}
不过如果你想要实现那种更像是if或while的控制结构,但没有值需要传入花括号中间的代码,该怎么办呢?为了帮助我们应对这样的场景,Scala提供了传名参数。
我们来看一个具体的例子,假设你想要实现一个名称为myAssert的断言结构。这个myAssert将接收一个函数值作为输入,然后通过一个标记来决定如何处理。如果标记位打开,则myAssert将调用传入的函数,验证这个函数返回了true;而如果标记位关闭,则myAssert将什么也不做。
如果你不使用传名参数,则可能会这样来实现myAssert:
var assertionsEnabled = true
def myAssert(predicate: () =>Boolean) = if assertionsEnabled && !predicate() then throw new AssertionError
这个定义没有问题,不过用起来有些别扭:myAssert(() => 5 > 3)
你大概更希望能够不在函数字面量里写空的圆括号和=>符号,而是直接这样写:myAssert(5 > 3) // 不能工作,因为缺少了 () =>
传名参数就是为了解决这个问题产生的。要让参数成为传名参数,需要给参数一个以=>开头的类型声明,而不是() =>。例如,可以像这样将myAssert的predicate参数转换成传名参数:把类型“() => Boolean”改成“=> Boolean”。示例9.5给出了具体的样子:def byNameAssert(predicate: => Boolean) =if assertionsEnabled && !predicate then throw new AssertionError
示例9.5 使用传名参数
现在已经可以对要做断言的属性去掉空的参数列表了。这样做的结果就是byNameAssert用起来与使用内建的控制结构完全一样:byNameAssert(5 > 3)
对传名(by-name)类型而言,空的参数列表,即(),是需要去掉的,这样的类型只能用于参数声明,并不存在传名变量或传名字段。
你可能会好奇为什么不能简单地用旧版的Boolean作为其参数的类型声明,就像这样:def boolAssert(predicate: Boolean) =if assertionsEnabled && !predicate then throw new AssertionError
这种组织方式当然也是合法的,boolAssert用起来也与之前看上去完全一样:boolAssert(5 > 3)
不过,这两种方式有一个显著的区别需要注意。由于boolAssert的参数类型为Boolean,在boolAssert(5 > 3)圆括号中的表达式将“先于”对boolAssert的调用被求值。但是由于byNameAssert的predicate参数类型是=> Boolean,在byNameAssert(5 > 3)的圆括号中的表达式在调用byNameAssert之前并不会被求值,而是会有一个函数值被创建出来。这个函数值的apply方法将会对5 > 3求值,传入byNameAssert的是这个函数值。
因此,两种方式的区别在于如果断言被禁用,你将能够观察到boolAssert的圆括号中的表达式的副作用,而使用byNameAssert则不会。例如,如果断言被禁用,那么当我们断言“x / 0 == 0”时,boolAssert会抛出异常:
- val x = 5
- assertionsEnabled = false
- boolAssert(x / 0 == 0)
- java.lang.ArithmeticException: /by zero ... 27 elided
而对同样的代码使用byNameAssert来做断言,则不会抛出异常:
byNameAssert(x / 0 == 0) // 正常返