函数式-基础

方法

创建函数最简单的方式就是作为对象的成员,即方法

本地函数

函数式编程风格的一个主要原则就是:程序应该被解构为若干小的函数块,每块实现一个完备的任务,每块都很小。

但是大量的小块函数会污染程序的命名空间,这时可以私有函数,或者另一种方式,本地函数,即嵌套函数:

import scala.io.Source
object LongLines {
  def processFile(filename:String, width:Int){
    def processLines(line:String){    // 打印超出宽度的行, 引用外层的 width
      if (line.length > width) println(filename + ": " + line)
  }
  val source = Source.fromFile(filename)
  for (line <- source.getLines)
    processLine(line)
  }
}

头等函数

Scala 的函数是头等函数(first-class-function),不仅可以定义或调用函数,还可以把函数写成匿名字面量,当做值来传递。函数字面量被编译进类,并在运行期实例化为函数值。因此函数字面量与值的区别在于:函数字面量存在于源代码,而函数值作为对象存在于运行期。类似于类(源代码)和对象(运行期)之间的关系。

(x: Int) => x + 1

符号=>指:把左边的东西转换为右边的东西。

函数值是对象,因此可以赋给变量。同时也是函数,可以按照通常的函数调用方式,使用一个括号来调用:

var increase = (x: Int) => x + 1
increase(2)   // 3

函数可以由多条语句构成,由大括号包围,组成一个代码块。当函数执行时,所有的语句都会执行,最后一行作为返回值。

函数字面量的短格式

可以省略函数字面量中的参数类型来进行简化:

someNumber.filter((x) => x > 0)

因为使用这个函数来过滤整数列表someNumber,因此 Scala 可以推断出 x 肯定为整数。这种方式称为目标类型化(target typing)

某些参数的类型是推断的,可以省略参数的括号:

someNumber.filter(x => x > 0)

占位符语法

只要每个参数在函数字面量中仅出现一次,可以使用占位符_来代替该参数名:

someNumber.filter(_ > 0)

但是有时候把下划线当做参数的占位符,编译器可能无法推断缺失的参数类型:

val f = _ + _

因为上面的filter调用是一个整数列表,编译器可以推断,但这里,编译器无从推断,会编译失败。这时,我们可以为参数提供类型:

val f = (_:Int) + (_:Int)

这里需要注意的是,这里的两个占位符表示的是需要两个参数,而不是一个参数使用两次。

_ + _   (x, y) => x + y
_ * 2     x => x * 2
_.head    xs => xs.head
_ drop _  (xs, n) => xs.drop(n)

在参数较少时,使用这种方式可以使程序更清晰,但是当参数增多,比如foo(_, g(List(_ + 1), _)),则会让程序变得难以理解。

偏应用函数

或称为部分应用函数(Partially applied functions)。

可以使用占位符代替一个参数,也可以代替整个参数列表。比如println(_),或者println _

这中下划线方式的使用,实际上定义的是一个部分应用函数。它是一种表达式,不需要提供函数需要的所有参数,可以只提供部分,或者不提供。

比如一个普通的sum函数:

def sum(a:Int, b:Int, c:Int) = a + b + c

当调用函数时,传入任何需要的参数,实际是把函数应用到参数上。

如果通过sum来创建一个部分应用表达式,不需要提供所需要的参数,只需要在sum之后添加一个下划线,使用空格隔开,然后把得到的函数存入变量:

val a = sum _
a : (Int, Int, Int) => Int = <function3>  // function3 类型的实例
a(1,3,4)  // 6

可以看到,a为一个函数。变量a指向一个函数值对象。这个函数值是由 Scala 编译器依照部分应用函数表达式sum _自动产生的类的一个实例。编译器产生的类有一个apply方法,该方法接收 3 个参数。在通过a进行a(1,2,3)调用时,会被翻译成对函数值的apply方法的调用,即apply(1,2,3)

可以看到function3的源码定义:

/** A function of 3 parameters.
 *
 */
trait Function3[-T1, -T2, -T3, +R] extends AnyRef { self =>
  /** Apply the body of this function to the arguments.
   *  @return   the result of function application.
   */
  def apply(v1: T1, v2: T2, v3: T3): R
  ...
}

偏应用函数实际上是指该函数未被应用到所有的参数,sum的例子是没有应用到任何参数,也可以只应用到部分参数。

val b = sum(1, _:Int, 3)
b: Int => Int = <function1>

这时会发现b为一个function1类型的实例,它接收一个参数。

同时,如果定义的是一个省略所有参数的偏应用函数,比如这里的sum,二者在代码的某个位置需要一个相同签名的函数,这时可以省略掉占位符进行简化:

someNumbers.foreach(println _)
someNumbers.foreach(println)

需要注意的是,只有在需要一个函数的地方才可以省略占位符。比如这里,编译器知道foreach需要一个函数。否则会编译错误。比如:val s = sum,必须是:val s = sum _

闭包

重复参数

可以指定一个函数的最后一个参数是重复的,然后就可以传入可变长度的参数列表:

def echo(args:String*) = 
  for(arg <- args) println(arg)

echo("a")
echo("a", "b")
val seq = Seq("a","b","c")
echo(seq:_*)

尾递归

高阶函数

函数值作为参数的函数称为高阶函数

减少代码重复

比如我们要实现一个 根据文件名查找文件的程序,首先是文件名以指定字符串结尾的文件名

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

filesHere这里作为一个工具函数来获取所有文件名列表。

现在如果需要的不只是以指定字符串结尾的方式查找,只要是文件名中包含指定字符串,或者以指定的方式能够匹配指定的字符串,因此我们需要查找的方式是这样的:

def filesMatching(query: String, method ) = 
  for (file <- filesHere; if file.getName.method (query)) yield file

method表示匹配方式,但是 Scala 中不支持传入函数名的方式,因此我们可以传递一个函数值:

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

mathcer接收两个字符串,一个是文件名,一个是需要匹配的字符串,返回一个布尔值表示该文件名与指定的字符串是否匹配。因此,我们可以实现我们的不同匹配方式,而对filesMatching函数进行复用:

def filesEnding(query: String) = filesMatching(query, _.endsWith(_))
def filesContaining(query: String) = filesMatching(query, _.contains(_))
def filesRegex(query: String) = filesMatching(query, _.matches(_))

类似_.endsWith(_)的部分使用了占位符语法,前面已经提到,函数的参数只被使用一次,且参数顺序与使用顺序一致,则可以使用占位符语法简化。其完整的写法实际是:

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

简化后会发现,函数filesMatching的参数中,query已经不再需要了,因为该参数只用于matcher函数,并且已经通过匹配方法传入,因此再次进行简化:

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 filesContains(query:String) = filesMatching(_.contains(query))
  def filesRegex(query:String) = filesMatching(_.matches(query))
}

简化客户端代码

集合 API 提供一些列常用的方法,其中应用了大量的高阶函数,通过将高阶函数作为参数来定义 API,从而使客户端代码更加易于使用。

比如常用的existsfind, 在scala.collection.TraversableLike包中:

def exists(p: A => Boolean): Boolean = {
  var result = false
    breakable {
      for (x <- this)
        if (p(x)) { result = true; break }
    }
    result
}

def find(p: A => Boolean): Option[A] = {
    var result: Option[A] = None
    breakable {
      for (x <- this)
        if (p(x)) { result = Some(x); break }
    }
    result
}

参数p是一个A => Boolean类型的函数,它接收一个参数并放回一个布尔值,用于判断集合中的元素是否满足条件,比如在应用时:

List(1,2,3,-1).exists(_ < 0)

这里同样应用了占位符语法,使整个操作更加简便。

柯里化

柯里化是将函数应用到多个参数列表上

def plainOldSum(x: Int, y: Int) = x + y   // 普通函数
plainOldSum(1, 2)
def curriedSum(x: Int)(y: Int) = x + y    // 柯里化函数
curriedSum(1)(2)

在调用柯里化函数时,如果没有一次给出所有的参数列表,比如上面的curriedSum,第一次只提供一个参数列表进行调用curriedSum(1),这时会返回一个函数值(y: Int) => x + y,调用该函数值并提供另一个参数列表,即(y: Int),得出最后的求和值。

其过程类似于以下面的方式定义函数:

def first(x: Int) = (y: Int) => x + y
val second = first(1) // Int => Int = <function1>
second(2)       // 3

柯里化函数也可以以下面的方式,使用一个占位符语法,来获取中间的函数值,即上面的second函数值:

val onePlus = curriedSum(1)_    // Int => Int = <function1>

调用时提供的占位符_代表第二个参数列表。

同样,可以定义更多个参数列表的柯里化函数,比如:

def multiSum(x: Int)(y: Int)(z: Int) = x + y + z
val second = multiSum(1)_   // Int => (Int => Int) = <function1>
val third = second(2)     // Int => Int = <function1>
third(3)            // 6

自定义控制结构

因为函数可以作为参数值来传递,因此可以使用该特性来定义自己的控制结构,只需要定义接收函数值的方法即可。

比如:

def twice(op: Double => Double, x: Double) = op(op(x))
twice(_ + 1, 5)   // 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) 
)

我们可以利用这种模式来实现不同的控制,比如将withPrintWriter实现为一个日志打印过程或缓存更新过程,而操作部分是一次数据库查询。

这种模式成为借贷模式。这个例子中,控制抽象函数withPrintWriter打开一个资源,即writer,借贷给op函数,当op函数不再需要时又将其关闭。

但是这种模式的使用方式扛起来并不像是一个控制结构,它就是一个函数调用,然而可以通过使用大括号的方式使其更像是真正的控制结构。

但是在使用大括号进行函数调用时只能接收一个参数,比如:

println { "Hello, world!" }
"Hello, world!".substring { 7, 9 }    // error,多个参数必须使用小括号包围参数列表

因此,我们可以将上面的withPrintWriter函数改写为柯里化的方式,每次只接收一个函数,来满足只有一个参数才能使用大括号的要求:

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

然后,下面的调用使withPrintWriter 看起来更像一个控制结构:

withPrintWriter(file) { writer =>
  writer.println(new java.util.Date) 
}

传名参数

但是上面的例子与内建的控制接口,比如ifwhile并不相似,因为在大括号中需要传入一个参数,这可以通过传名参数实现。

比如定义一个断言函数:

var assertionsEnabled = true

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

然后以下面的方式调用:

myAssert(() => 5 > 3)

或许你更希望使用myAssert(5 > 3)的方式来调用,在创建传名参数时可以使用=>来代替完整的() =>,括号部分实际是函数的参数列表,只不过该函数不接受任何参数,因此省略。

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

myAssert(5 > 3)

一个传名类型,即其参数列表为空(),并进行省略,这样的用法仅仅在作为参数时可行。

但是根据上面的定义方式,其实与def byNameAssert(predicate:Boolean)没有什么差别了。

真正的差别在于,如果是传入的值,这个值必须在调用byNameAssert之前完成计算,如果是传入的函数,则会在调用之后进行计算。