20.2 函数

Scala中的函数可以看成为了完成某个任务而组合在一起的指令序列。它们对于抽象行为非常有帮助,是函数式编程的基石。

对于Java语言中的方法,你已经非常熟悉了:它们是与类相关的函数。你也已经了解了Lambda表达式,它可以看成一种匿名函数。跟Java比较起来,Scala为函数提供的特性要丰富得多,本节会逐一讲解。Scala提供了下面这些特性。

  • 函数类型,它是一种语法糖,体现了Java语言中函数描述符的思想,即,它是一种符号,表示了在函数接口中声明的抽象方法的签名。这些内容第3章中都介绍过。
  • 能够读写非本地变量的匿名函数,而Java中的Lambda表达式无法对非本地变量进行写操作。
  • 对柯里化的支持,这意味着你可以将一个接受多个参数的函数拆分成一系列接受部分参数的函数。

20.2.1 Scala中的一等函数

函数在Scala语言中是一等值。这意味着它们可以像其他的值,比如Integer或者String那样,作为参数传递,可以作为结果值返回。正如前面章节所介绍的那样,Java中的方法引用和Lambda表达式也可以看成一等函数。

让我们看一个例子,看看Scala中的一等函数是如何工作的。假设你现在有一个字符串列表,列表中的值是朋友们发送给你的消息(tweet)。你希望依据不同的筛选条件对该列表进行过滤,比如,你可能想要找出所有提及Java这个词或者短于某个长度的消息。你可以使用谓词(返回一个布尔型结果的函数)定义这两个筛选条件,代码如下:

  1. def isJavaMentioned(tweet: String) : Boolean = tweet.contains("Java")
  2. def isShortTweet(tweet: String) : Boolean = tweet.length() < 20

在Scala语言中,你可以直接传递这两个方法给内嵌的filter,如下所示(这和你在Java中使用方法引用将它们传递给某个函数大同小异):

  1. val tweets = List(
  2. "I love the new features in Java",
  3. "How's it going?",
  4. "An SQL query walks into a bar, sees two tables and says 'Can I join you?'"
  5. )
  6. tweets.filter(isJavaMentioned).foreach(println)
  7. tweets.filter(isShortTweet).foreach(println)

现在,让我们一起审视下内嵌方法filter的函数签名:

  1. def filter[T]($text-p: (T) => Boolean): List[T]

你可能会疑惑参数p到底代表的是什么类型(即(T) => Boolean),因为在Java语言中你期望看到的是一个函数接口!这其实是一种新的语法,Java中暂时还不支持。它描述的是一个函数类型。这里它表示的是这样一个函数,它接受类型为T的对象,返回一个布尔类型的值。在Java语言中,它被编码为Predicate或者Function。它实际上与isJavaMentionedisShortTweet具有类似的函数签名,所以你可以将它们作为参数传递给filter方法。Java语言的设计者们为了保持语言与之前版本的一致性,决定不引入类似的语法。对于一门语言的新版本,引入太多的新语法会增加它的学习成本,带来额外学习负担。

20.2.2 匿名函数和闭包

Scala也支持匿名函数。匿名函数和Lambda表达式的语法非常类似。下面的这个例子中,你将一个匿名函数赋值给了名为isLongTweet的变量,该匿名函数的功能是检查给定的消息长度,判断它是否超长:

  1. val isLongTweet : String => Boolean ←---- 这是一个函数类型的变量,它接受一个String参数,返回一个布尔类型的值
  2. = (tweet : String) => tweet.length() > 60 ←---- 一个匿名函数

在新版的Java中,你可以使用Lambda表达式创建函数式接口的实例。Scala也提供了类似的机制。前面的这段代码是Scala中声明匿名类的语法糖。Function1(只带一个参数的函数)提供了apply方法的实现:

  1. val isLongTweet : String => Boolean
  2. = new Function1[String, Boolean] {
  3. def apply(tweet: String): Boolean = tweet.length() > 60
  4. }

由于变量isLongTweet中保存了类型为Function1的对象,因此你可以调用它的apply方法,这看起来就像下面的方法调用:

  1. isLongTweet.apply("A very short tweet") ←---- 返回false

如果用Java,你可以采用下面的方式:

  1. Function<String, Boolean> isLongTweet = (String s) -> s.length() > 60;
  2. boolean long = isLongTweet.apply("A very short tweet");

为了使用Lambda表达式,Java提供了几种内置的函数式接口,比如PredicateFunctionConsumer。Scala提供了trait(你可以暂时将trait想象成接口)来实现同样的功能: 从Function0(一个函数不接受任何参数,并返回一个结果)到Function22(一个函数接受22个参数),它们都定义了apply方法。

Scala还提供了另一个非常酷炫的特性,你可以使用语法糖调用apply方法,效果就像一次函数调用:

  1. isLongTweet("A very short tweet") ←---- 返回false

编译器会自动地将方法调用f(a)转换为f.apply(a)。更一般地说,如果f是一个支持apply方法的对象(注:apply可以有任意数目的参数),那么对方法f(a1, …, an)的调用就会被转换为f.apply(a1, …, an)

闭包

第3章中我们曾经抛给大家一个问题:Java中的Lambda表达式是否是借由闭包组成的。温习一下,那么什么是闭包呢?闭包是一个函数实例,它可以不受限制地访问该函数的非本地变量。不过Java中的Lambda表达式自身带有一定的限制:它们不能修改定义Lambda表达式的函数中的本地变量值。这些变量必须隐式地声明为final。这些背景知识有助于我们理解“Lambda避免了对变量值的修改,而不是对变量的访问”。

与此相反,Scala中的匿名函数可以取得自身的变量,但并非变量当前指向的变量值。比如,下面这段代码在Scala中是可能的:

  1. def main(args: Array[String]) {
  2. var count = 0
  3. val inc = () => count+=1 ←---- 这是一个闭包,它捕获并递增count
  4. inc()
  5. println(count) ←---- 打印输出1
  6. inc()
  7. println(count) ←---- 打印输出2
  8. }

不过在Java中,下面的这段代码会遭遇编译错误,因为count隐式地被强制定义为final

  1. public static void main(String[] args) {
  2. int count = 0;
  3. Runnable inc = () -> count+=1; ←---- 错误:count必须为final或者在效果上为final
  4. inc.run();
  5. System.out.println(count);
  6. inc.run();
  7. }

第7、18以及19章曾多次提到你应该尽量避免修改,这样你的代码更加易于维护和并发运行,所以请在绝对必要时才使用这一特性。

20.2.3 柯里化

在第19章中,我们描述了一种名为柯里化的技术:带有两个参数(比如xy)的函数f可以看成一个仅接受一个参数的函数g,函数g的返回值也是一个仅带一个参数的函数。这一定义可以归纳为接受多个参数的函数可以转换为多个接受一个参数的函数。换句话说,你可以将一个接受多个参数的函数切分为一系列接受该参数列表子集的函数。Scala为此特别提供了一个构造器,帮助你更加轻松地柯里化一个现存的方法。

为了理解Scala到底带来了哪些变化,先回顾一个Java的示例。你定义了一个简单的函数对两个正整数做乘法运算:

  1. static int multiply(int x, int y) {
  2. return x * y;
  3. }
  4. int r = multiply(2, 10);

不过这种定义方式要求向其传递所有的参数才能开始工作。你可以人工地对multiple方法进行切分,让其返回另一个函数:

  1. static Function<Integer, Integer> multiplyCurry(int x) {
  2. return (Integer y) -> x * y;
  3. }

multiplyCurry返回的函数会捕获x的值,并将其与它的参数y相乘,然后返回一个整型结果。这意味着你可以像下面这样在一个map中使用multiplyCurry,对每一个元素值乘以2:

  1. Stream.of(1, 3, 5, 7)
  2. .map(multiplyCurry(2))
  3. .forEach(System.out::println);

这样就能得到计算的结果2、6、10、14。这种方式工作的原因是map期望的参数为一个函数,而multiplyCurry的返回结果就是一个函数。

现在的Java语言中,为了构造柯里化的形式需要你手工地切分函数(尤其是函数有非常多的参数时),这是极其枯燥的事情。Scala提供了一种特殊的语法可以自动完成这部分工作。比如,正常情况下,你定义的multiply方法如下所示:

  1. def multiply(x : Int, y: Int) = x * y
  2. val r = multiply(2, 10)

该函数的柯里化版本如下:

  1. def multiplyCurry(x :Int)(y : Int) = x * y ←---- 定义一个柯里化函数
  2. val r = multiplyCurry(2)(10) ←---- 调用该柯里化函数

使用语法(x: Int)(y: Int),方法multiplyCurry接受两个由一个Int参数构成的参数列表。与此相反,multiply接受一个由两个Int参数构成的参数列表。当你调用multiplyCurry时会发生什么呢?multiplyCurry的第一次调用使用了单一整型参数(参数x),即multiplyCurry(2),返回另一个函数,该函数接受参数y,并将其与它捕获的变量x(这里的值为2)相乘。正如19.1.2节介绍的,我们称这个函数是部分应用的,因为它并未提供所有的参数。第二次调用对xy进行了乘法运算。这意味着你可以将对multiplyCurry的第一次调用保存到一个变量中,进行复用:

  1. val multiplyByTwo : Int => Int = multiplyCurry(2)
  2. val r = multiplyByTwo(10) ←---- 20

和Java比较起来,在Scala中你不再需要像这里这样手工地提供函数的柯里化形式。Scala提供了一种方便的函数定义语法,能轻松地表示函数使用了多个柯里化的参数列表。