Skip to content

Latest commit

 

History

History
2023 lines (1404 loc) · 83.1 KB

10.函数式编程.md

File metadata and controls

2023 lines (1404 loc) · 83.1 KB

10. 函数式编程

Scala同时支持面向对象编程和函数式编程风格。事实上,正如我在网站上记录的那样( https://oreil.ly/MOunk ),在2018年的一次演讲中,Scala语言的创造者Martin Odersky表示,Scala的本质是“在类型化环境中融合了函数式编程和面向对象编程”,“函数用于逻辑,对象用于模块化”。本书中的许多示例都展示了这种融合,本章只关注Scala中的函数式编程技术 —— 我在本章中将其称为 Scala/FP

FP是个大话题,我在 Functional Programming, Simplified 一书中写了七百多页。虽然我不能在这一章中涵盖所有的内容,但我将尝试涵盖一些主要的概念。初始的示例将展示如何:

  • 编写和理解函数字面量
  • 将函数字面量(也称为匿名函数)传递给方法
  • 编写一个接受函数作为参数的方法

之后,你会看到一些非常具体的函数式编程技术:

  • 部分应用的函数
  • 编写函数作为返回值的方法
  • 偏函数

本章最后有两个例子,有助于展示这些技术。

如果你对FP不熟悉,一开始可能会感到困惑,所以了解它的目标和动机肯定会有帮助。因此,在接下来的几页中,我将尝试提供我所能提供的关于函数式编程的最佳介绍。Functional Programming, Simplified 由130个简短的章节组成,这篇介绍是该书前21章的一个极其浓缩的版本。

什么是函数式编程?

找到一个一致的FP定义是出乎意料的难,但在写那本书的过程中,我想到了这个:

    函数式编程是一种只使用纯函数和不可变值来编写软件应用程序的方式。

正如你在本章中看到的,纯函数是数学函数,就像写代数等式一样。

另一个不错的定义来自于Mary Rose Cook,她说:

    函数式代码有一个特点:没有副作用。它(纯函数)不依赖当前函数之外的数据,也不改变存在于当前函数之外的数据。所有其他“函数式”事物都可以从这个属性派生出来。

我在 Functional Programming, Simplified 一书中对这些定义进行了详细的阐述,但就本章的目的而言,这些定义给了我们一个坚实的起点。

纯函数

要理解这些定义,你还必须理解什么是纯函数。在我的世界里,纯函数 就是一个函数:

  • 其算法和输出 取决于 (a) 该函数的输入参数 和(b)调用其他纯函数
  • 不改变它所给的参数
  • 不改变应用程序中其他地方的任何东西(即,任何种类的全局状态)
  • 不与外部世界交互,例如与文件、数据库、网络或用户交互

因为这个标准,你也可以对纯函数做这些陈述:

  • 它们的内部算法不会调用其他返回随时间变化的函数,如日期、时间和随机数(随机的任何东西 )函数。
  • 当以相同的输入调用任何次数时,纯函数总是返回相同的值。

数学函数是纯函数的好例子,包括像min、max、sum、sin、cosine、tangent等算法。与列表有关的函数,如filter、map,以及从现有列表中返回一个排序的列表,也是很好的例子。在输入相同的情况下调用任何次数,它们总是返回相同的结果。

纯函数的 反例 是:

  • 任何种类的输入/输出(I/O)功能(包括来自用户的输入,向用户的输出,以及从文件、数据库和网络中读出和写入)
  • 在不同时间返回不同结果的函数(日期、时间和随机函数)
  • 修改应用程序中其他地方的可变状态(如一个类中的可变字段)的函数
  • 一个接受可变类型(如 ArrayArrayBuffer )的函数,并修改了它的元素

纯函数让你感到舒适,因为当你用一组给定的输入来调用它时,你总是会得到完全相同的答案,例如:

    "zeus".length // will always be `4`
    sum(2,2) // will always be `4`
    List(4,5,6).max // will always be `6`

副作用

有人说,一个纯粹的函数式程序是没有副作用的。那么,什么是 副作用呢?

一个有副作用的函数会修改状态,改变变量,和/或 与外部世界互动。这包括:

  • 向文件、数据库或网络服务中写入(或读取)数据
  • 变更作为输入的变量的状态,改变数据结构中的数据,或修改对象中的可变字段的值
  • 抛出一个异常,或在发生错误时停止应用程序
  • 调用有副作用的其他函数

纯函数更容易测试。想象一下,写一个加法函数,如 +。给定两个数字 12 ,其结果总是 3。像这样的纯函数只是一个简单的问题:(a)不可改变的数据进来,(b)结果出来;其他的什么都没有发生。因为像这样的函数没有副作用,也不依赖于其范围之外的某处可变的状态,所以测试起来很简单。

参阅24.1小节,了解“编写纯函数”的更多细节。

在FP中进行思考

编写纯函数相对简单,事实上,编写纯函数往往是一种乐趣,因为在编写纯函数时,你不必考虑应用程序的整个状态。你所要考虑的只是什么东西进来了,什么东西出去了。

FP中较难的部分,其实是(a)处理I/O,和(b)如何将你的纯函数粘在一起有关。我发现如果要在FP中取得成功,你必须强烈渴望把你的代码看作是数学,把每个函数看作是一个代数等式,数据进去,数据出来,没有副作用,没有变更,也不会出错。

所发生的是,你写了一个纯函数,然后又写了一个,然后又写了一个。当它们完成后,你通过组合你的纯函数 —— 代数等式 —— 来创建你的应用程序,就像你是一个数学家在黑板上写一系列的等式。我再怎么强调这个渴望的重要性也不为过。你一定想像代数一样写这样的代码。

例如,在数学中,你可能有一个这样的函数:

    f(x) = x^2 + 2x + 1

在Scala中,这个函数是这样写的:

    def f(x: Int): Int = x*x + 2*x + 1

请注意关于这个函数的几件事:

  • 函数的结果只取决于 x 的值和函数的算法。
  • 该函数只依赖于 * 和 + 运算符,可以认为是调用其他纯函数。
  • 该函数不会更改 x

此外:

  • 这个函数并没有在世界任何地方改变任何其他东西。
  • 它的范围只涉及将算法应用于输入参数 x,并且不改变该范围之外的任何变量。
  • 它不从世界上任何其他地方读取或写入任何东西:没有用户输入,没有文件,没有数据库,没有网络,等等。
  • 如果你用同样的输入无限次地调用这个函数(比如说 2 ),它将总是返回相同的值(如 9 )。

该函数是一个纯函数,其输出只取决于其输入。FP就是要把你所有的函数都写成这样,然后把它们组合在一起,形成一个完整的应用程序。

引用透明性和替代性

FP中的另一个重要概念是引用透明性( referential transparency ,RT),这是一种属性,即一个表达式可以被其结果值取代而不改变程序的行为(反之亦然)。同样,你可以通过执行代数运算来考察这一点。例如,如果所有这些符号都代表不可变的值:

    a = b + c
    d = e + f + b
    x = a + d

你可以执行 替换规则 来确定 x 的值:

    x = a + d
    x = (b + c) + d // 取代'a'的作用
    x = (b + c) + (e + f + b) // 取代'd'的作用
    x = b + c + e + f + b // 去除不需要的括号
    x = 2b + c + e + f // 不能再减少表达了

当函数式程序员说一个程序“执行出一个结果”,他们的意思是你能通过执行替换规则来运行一个程序。你和编译器都可以执行这些替换。相反,如果像 b 这样的值在每次调用时都会返回一个随机值或用户输入值,那么你就不能还原等式。

虽然这个例子使用了代数符号,但你也可以用Scala代码做同样的事情。例如,在Scala/FP中,你写的代码看起来像这样:

    val a = f(x)
    val b = g(a)
    val c = h(y)
    val d = i(b, c)

假设 fghi 是纯函数 —— 假设所有字段都是 val 字段 —— 当你写这样的简单表达式时,你和编译器都可以自由地重新编排代码。例如,第一个和第三个表达式可以以任何顺序发生 —— 甚至可以并行运行。唯一的要求是前三个表达式在调用 i 之前被执行。

另外,由于 a 的值总是与 f(x) 完全 相同,f(x) 总是可以被 a 取代,反之亦然。对于 bcd 也是如此。

例如,这个等式:

    val b = g(a)

与此等式完全相同:

    val b = g(f(x))

因为所有的字段都是不可变的,而且这些函数是纯函数,所以你和编译器都可以继续移动等式并进行替换,以至于所有的这些表达式都是等价的:

    val d = i(b, c)
    val d = i(g(a), h(y))
    val d = i(g(f(x)), h(y))

如前所述,函数式编程的一大好处是纯函数比有副作用的函数更容易测试,现在你可以看到第二个好处:用这样的引用透明性的代码,g(a)h(y) 可以在不同的线程(或更随机的 fiber )上运行,以利用多核的优势。因为所有的字段都是不可变的,而且函数是纯函数,所以你可以安全地进行这些代数式的替换。但是如果字段是可变的( var 字段)或不是纯函数的,那么这些代码块就不能安全地被移动。

Lisp —— 原名 LISP,是LISt Processor的缩写 —— 它是一种编程语言,最初于1958年被创造出来,开创了高级编程语言的包括高阶函数在内的许多重要概念。当你以代数/函数风格写代码时,自然会产生一种思维方式,这在Conrad Barski的 Land of Lisp(No Starch Press)一书中有所描述:

    当有人说一个函数“返回一个值”时,一些高级Lispers会感到害怕。在lambda计算中,你通过对起始程序执行替换规则来“运行”一个程序,以确定一个函数的结果。因此,一组函数的结果只是通过执行替换而神奇地出现;一个函数从未有意识地“决定”返回一个值。正因为如此,Lisp纯粹主义者更愿意说,一个函数“执行出一个结果”。

前面的例子说明了这句话的含义。

FP是面向表达式编程的一个超集

要使一种语言支持FP,它必须首先支持 面向表达式的编程(EOP)。在EOP中,每一行代码都是一个表达式,而不是一个语句。表达式 是一行返回一个结果的代码,没有副作用。相反,语句 就像调用 println 一样:它们不返回结果,只是为了其副作用而被调用。(技术上讲,语句返回一个结果,但它是一个 Unit 结果)。

Scala之所以能成为一门伟大的FP语言,一个特点是你所有的代码都可以写成表达式,包括 if 表达式:

    val a = 1
    val b = 2
    val max = if a > b then a else b

match 表达式:

    val evenOrOdd: String = i match
        case 1 | 3 | 5 | 7 | 9 => "odd"
        case 2 | 4 | 6 | 8 | 10 => "even"

for 表达式:

    val xs = List(1, 2, 3, 4, 5)
    val ys = for
        x <- xs
        if x > 2
    yield
        x * 10

甚至 try/catch 块也会返回一个值:

    def makeInt(s: String): Int =
        try
            s.toInt
        catch
            case _ : Throwable => 0

我的Scala函数式编程规则

为了帮助读者采用正确的FP思维方式,我在 Functional Programming, Simplified 一书中制定了这些编写Scala/FP代码的规则:

  • 永远不要使用 null。甚至可以忘记Scala有一个 null 关键字。
  • 只写纯函数。
  • 所有字段只使用不可变的值( val )。
  • 每一行代码都必须是一个代数表达式。每当你使用 if 的时候,你必须同时使用 else
  • 纯函数不应该抛出异常;相反,它们产生的值是 OptionTryEither
  • 不要创建封装数据和行为的OOP“classes”。相反,应该使用样例类创建不可变的数据结构,然后编写纯函数来操作这些数据结构。

如果你采用了这些简单的规则,你会发现:

  • 你的大脑将放弃寻求捷径来对抗这个系统。(偶尔加入 var 字段或不纯函数只会减慢你的学习过程。)
  • 你的代码会变得像代数一样。
  • 随着时间的推移,你会逐渐理解Scala/FP的思维过程;你会发现一个概念在逻辑上可以引出另一个概念。

作为最后一点的例子,你会发现只使用不可变的字段自然会导致递归算法的出现。然后你会发现,并不会经常需要递归,因为不可变的Scala集合类中都提供了内置相关的函数式方法。

是的,使用FP的I/O代码

虽然有不同的方法来处理输入/输出(I/O),但FP代码当然会使用I/O。这包括处理用户的I/O,以及从文件、数据库和网络中读写。没有I/O,任何应用程序都不会有用,所以Scala/FP(以及所有其他函数式语言)都有以“函数式”方式处理I/O的设施。

例如,以函数式方式处理命令行I/O的Scala代码往往是这样的:

    def mainLoop: IO[Unit] =
        for
           _ <- putStr(prompt)
           cmd <- getLine.map(Command.parse _)
           _ <- if cmd == Quit then
                    IO.unit
                else
                    processCommand(cmd) >> mainLoop
        yield
            ()

    mainLoop.unsafeRunSync()

在这段代码中,putStrprintln 的函数式替换,而 getLine 是一个让你读取用户输入的函数式方法。另外,注意到 mainLoop 是以递归方式调用自己。这就是如何用不可变值创建一个循环。

不幸的是,要解释这些I/O函数背后的技术和理念需要花费一些时间 —— 这可能需要一百页甚至更多篇幅,这取决于你的背景 —— 但我在 Functional Programming, Simplified 一书中对它们进行了详细解释。

函数式蛋糕和必要的糖衣 -- TODO 鸽子栏

    正如我在本书的第一版中所写的那样,在你使用一个FP库,如Cats( https://oreil.ly/L9gn7 )、ZIO( https://oreil.ly/TawQ6 ),或Monix( https://monix.io ),我所能提供给初学函数式编程的人的最好建议是用纯函数来编写你的应用程序的核心。这个纯函数式核心可以被认为是“蛋糕”,然后与外界交互的I/O函数可以被认为是围绕这个核心的“糖衣”。根据不同的应用,你可能最终拥有80%的蛋糕(纯函数)和20%的糖衣(I/O函数),或者也可能是相反的情况。一些开发者将这种技术描述为“函数式核心和命令式外壳”。

10.1 使用函数字面量(匿名函数)

你想使用一个匿名函数 —— 也被称为 函数字面量 —— 所以你可以把它传给一个接受函数为参数的方法,或者把它赋给一个变量。

解决方案

给定这个 List

    scala> val x = List.range(1, 10)
    val x: List[Int] = List(1, 2, 3, 4, 5, 6, 7, 8, 9)

你可以将一个匿名函数传递给list的 filter 方法,以创建一个只包含偶数的新 List

    val evens = x.filter((i: Int) => i % 2 == 0)
                        ----------------------

在这个例子中,匿名函数是下划线的。REPL展示的这个表达式产生了一个新的偶数 List

    scala> val evens = x.filter((i: Int) => i % 2 == 0)
    evens: List[Int] = List(2, 4, 6, 8)

在这个解决方案中,下面的代码是一个函数字面量,当它被传递到这样的方法中时,它也被称为 匿名函数,有些编程语言也将其称为 lambda

    (i: Int) => i % 2 == 0

虽然这段代码是有效的,但它显示了定义函数字面量的最明确的形式。多亏了几个Scala的快捷方式,这个表达式才可以简化成这样:

    val evens = x.filter(_ % 2 == 0)

REPL显示,这样做返回的结果是一样的:

    scala> val evens = x.filter(_ % 2 == 0)
    evens: List[Int] = List(2, 4, 6, 8)

讨论

本小节中的第一个例子使用了这个函数字面量:

    (i: Int) => i % 2 == 0

当你看这段代码时,把 => 符号看作是一个转化器是有帮助的,因为这个表达式把符号左边的参数列表(一个名为 iInt )用符号右边的算法(在这个例子中,算法是一个模数的函数,结果是一个 Boolean )转化为一个新的结果。

如前所述,这个例子展示了定义匿名函数的详细形式,它可以通过几种方式简化。第一个例子显示了最明确的形式:

    val evens = x.filter((i: Int) => i % 2 == 0)

因为Scala可以从列表中确定它包含整数值,所以 i 的类型声明就没有必要了:

    val evens = x.filter((i) => i % 2 == 0)

当一个匿名函数只有一个参数时,就不需要括号了:

    val evens = x.filter(i => i % 2 == 0)

因为Scala允许你在参数只在函数中出现一次时使用 _ 符号而不是变量名,所以这段代码可以被进一步简化:

    val evens = x.filter(_ % 2 == 0)

在其他情况下,你可以进一步简化你的匿名函数。例如,从最明确的形式开始,你可以用这个匿名函数和 foreach 方法打印列表中的每个元素:

    x.foreach((i: Int) => println(i))

和以前一样,不需要声明为 Int

    x.foreach((i) => println(i))

因为只有一个参数,所以不需要 i 输入参数周围的圆括号:

    x.foreach(i => println(i))

因为 i 在函数体中只使用了一次,表达式可以用 _ 通配符进一步简化:

    x.foreach(println(_))

最后,如果一个函数字面量由一个接受单一参数的语句组成,你不需要明确地命名和指定参数,所以该语句可以简化为这样:

    x.foreach(println)

拥有多个参数的匿名函数

Map 提供了一个有关匿名函数的好例子,它可以接受多个参数。例如,给定这个 Map

    val map = Map(1 -> 10, 2 -> 20, 3 -> 30)

这个例子展示了在一个不可变的 Map 实例上使用匿名函数和 transform方法的语法,其中每个元素的key和value被传递给匿名函数:

    val newMap = map.transform((k,v) => k + v)

下面REPL中展示了这是如何工作:

    scala> val map = Map(1 -> 10, 2 -> 20, 3 -> 30)
    val map: Map[Int, Int] = Map(1 -> 10, 2 -> 20, 3 -> 30)
    scala> val newMap = map.transform((k,v) => k + v)
    val newMap: Map[Int, Int] = Map(1 -> 11, 2 -> 22, 3 -> 33)

虽然这不是一个特别有用的算法,重要的是,它展示了如何以键值对的语法处理匿名函数所接受到的条目:

    (k,v) => k + v

也可以把Map元素当作一个元组来处理 -- TODO 鸽子栏

    根据需要,另一种可能的方法是将每个 Map 元素作为一个含有两个元素的二元组:

    scala> map.foreach(x => println(s"${x._1} --> ${x._2}"))
    1 --> 10
    2 --> 20
    3 --> 30

另见

  • 有关这个话题的更多细节,请看我的文章“Explaining Scala’s val Function Syntax”( https://oreil.ly/nP3ZU )。

10.2 将函数作为变量传递

问题

你想把一个函数创建为一个变量,并把它传递出去,就像你在面向对象的编程语言中传递 StringInt 和其他变量一样。

解决方案

使用10.1小节中的语法来定义一个函数字面量,然后将该字面量分配给一个变量。例如,下面的代码定义了一个函数字面量,它接受一个 Int 参数,并返回一个传入 Int 的值的两倍:

    (i: Int) => i * 2

正如10.1小节中提到的,你可以把 => 符号看成是一个 转换器。在这种情况下,本例中该函数将 Inti 转换为一个新的 Int 值,新值是 i 的两倍。

现在你可以把这个函数的字面量分配给一个变量:

    val double = (i: Int) => i * 2

当你把这段代码粘贴到REPL中时,如下面代码的划线部分所示,你会看到它将 double 识别为一个将 Int 转化为另一个 Int 的函数:

    scala> val double = (i: Int) => i * 2
    val double: Int => Int = Lambda ...
                ----------

此时,变量 double 是一个变量实例,就像 StringInt 或其他类型的实例一样,但在这里它是一个函数的实例,它被称为 函数值。现在你可以像调用一个方法一样调用 double

    double(2) // 4
    double(3) // 6

除了像这样调用 double 之外,你还可以把它传递给任何一个所需参数符合这个签名的函数。例如,像 List 这样的集合类上的 map 方法需要一个函数参数,用于将类型 A 转换为类型 B ,正如其签名所示:

    def map[B](f: (A) => B): List[B]
               -----------

正因为如此,当你在处理 List[Int] 时,你可以给 map 一个 double 函数,它可以将 Int 转化为 Int

    scala> val list = List.range(1, 5)
    list: List[Int] = List(1, 2, 3, 4)
    scala> list.map(double)
    res0: List[Int] = List(2, 4, 6, 8)

在这个例子中,泛型类型 A 是一个 Int ,泛型类型 B 也恰好是一个 Int ,但在更复杂的例子中,它们可以是其他类型。例如,你可以创建一个函数,将一个 String 转换为一个 Int

    val length = (s: String) => s.length

然后,你可以在一个字符串列表上使用 String-to-Int 函数和 map 方法:

    scala> val lengths = List("Mercedes", "Hannah", "Emily").map(length)
    val lengths: List[Int] = List(8, 6, 5)

欢迎来到函数式编程世界。

函数和方法通常是可以互换的 -- TODO 鸽子栏

    虽然第一个例子展示了一个作为 val 变量创建的 double 函数,但你也可以使用 def 定义方法,且通常可以以相同的方式使用它们。详细信息请参阅讨论部分。

讨论

你至少可以用两种不同的方式声明一个函数字面量。下面这个模数函数的值 —— 如果 i 是偶数就返回 true —— 这意味着函数字面量的返回类型是 Boolean

    val f = (i: Int) => { i % 2 == 0 } // 带括号的函数有时更容易阅读
    val f = (i: Int) => i % 2 == 0 // 不带括号的函数

在这种情况下的Scala编译器足够聪明,它可以查看函数的主体,并确定它返回一个 Boolean 值。

然而,如果你喜欢明确地声明一个函数字面量的返回类型,或者因为你的函数比较复杂而想这样做。下面的例子展示了你可以使用不同的形式来明确声明这个 isEven 函数返回一个 Boolean

    val isEven: (Int) => Boolean = i => { i % 2 == 0 }
    val isEven: Int => Boolean = i => { i % 2 == 0 }
    val isEven: Int => Boolean = i => i % 2 == 0
    val isEven: Int => Boolean = _ % 2 == 0

第二个例子有助于证明这些方法的不同。这些函数都接受两个 Int 参数,并返回一个 Int 值,即两个输入值之和:

    // implicit approach
    val add = (x: Int, y: Int) => { x + y }
    val add = (x: Int, y: Int) => x + y

    // explicit approach
    val add: (Int, Int) => Int = (x,y) => { x + y }
    val add: (Int, Int) => Int = (x,y) => x + y

在这些例子中,我在方法主体周围提供了大括号,因为我发现这样的代码更容易阅读,尤其是当你第一次接触到函数语法时。说到这里,有一个多行函数的例子,它没有大括号:

    val addThenDouble: (Int, Int) => Int = (x,y) =>
        val a = x + y
        2 * a

使用像val函数一样的def方法

Scala非常灵活,这得益于一种叫做 Eta Expansion 的技术,就像你可以定义一个函数并把它赋值给一个 val 一样,你也可以用 def 定义一个方法,然后把它作为一个实例变量来传递。同样使用模数算法,你可以用任何一种方式定义 def 方法:

    def isEvenMethod(i: Int) = i % 2 == 0
    def isEvenMethod(i: Int) = { i % 2 == 0 }
    def isEvenMethod(i: Int): Boolean = i % 2 == 0
    def isEvenMethod(i: Int): Boolean = { i % 2 == 0 }

当一个方法被传入另一个期望有函数参数的方法时,Eta Expansion会透明地将该方法转化为一个函数。因此,任何一个 isEven 方法都可以被传递到另一个接受函数参数的方法中,该方法只需一个 接受 Int 并返回一个 Boolean 值的函数作为参数。例如,集合类 Listfilter 方法被定义为接受一个将泛型类型 A 转换为 Boolean 的函数:

    def filter(p: (A) => Boolean): List[A]
              -----------------

因为在下个例子中,你有一个 List[Int] ,你可以通过 isEvenMethod 方法来 filter,该方法将 Int 转化为 Boolean 值:

    val list = List.range(1, 10)
    list.filter(isEvenMethod)

下面是在REPL中的样子:

    scala> def isEvenMethod(i: Int) = i % 2 == 0
    def isEvenMethod(i: Int): Boolean

    scala> val list = List.range(1, 10)
    val list: List[Int] = List(1, 2, 3, 4, 5, 6, 7, 8, 9)

    scala> list.filter(isEvenMethod)
    val res0: List[Int] = List(2, 4, 6, 8)

如前所述,这与定义一个函数字面量并将其分配给一个变量的过程类似。例如,在这里你可以看到 isEvenFunction 的工作原理与 isEvenMethod 一样:

    val isEvenFunction = (i: Int) => i % 2 == 0
    list.filter(isEvenFunction) // List(2, 4, 6, 8)

从编程的角度来看,明显的区别是 isEvenMethod 是一个 方法,而 isEvenFunction 是一个被分配给变量的 函数。但作为一个程序员,两种方法都能工作真是太好了。

将一个现有的函数/方法分配给一个函数型变量

  1. 继续这个探索,你也可以把现有的方法或函数赋值给一个函数变量。例如,你可以从 scala.math.cos 方法中创建一个名为 c 的新函数,像这样:to function variables

     scala> val c = scala.math.cos
     val c: Double => Double = Lambda ...
    

产生的函数值 c 被称为 部分应用函数。它是部分应用的,因为 cos 方法需要一个参数,而你还没有提供这个参数。

现在你有了 c 这个函数值,你可以像使用 cos 一样使用它:

    scala> c(0)
    res0: Double = 1.0

在Scala 3中改进的Eta Expansion -- TODO 耗子栏

    在Scala 2中,这个例子中需要使用下划线:

    val c = scala.math.cos _

    但由于Scala 3中改进的Eta Expansion技术,已经不再需要这样做了。

接下来的例子展示了如何使用这一技术,使用 scala.math 中的 pow 方法来创建一个 square 函数。注意,pow 需要两个参数,其中第二个参数作为第一个参数的幂次:

    scala> val square = scala.math.pow(_, 2)
    val square: Double => Double = Lambda ...

同样,square 是一个部分应用的函数。我提供了power参数(第二个),但没有提供value参数(第一个),所以现在 square 在等待接受一个额外的参数,即要被平方的值:

    scala> square(3)
    val res0: Double = 9.0

    scala> square(4)
    val res1: Double = 16.0

这个例子展示了使用这种技术的典型方法:你从一个更一般的方法( pow )中创建一个更具体的函数( square )。请参阅10.5小节以了解更多信息。

读取square在REPL的输出 -- TODO 鸽子栏

    注意,当你创建 square 时,你可以知道它仍然需要一个参数,因为REPL的输出显示其类型签名为:

    val square: Double => Double ...
                ----------------

    这意味着它是一个接受一个 Double 参数并返回一个 Double 值。

综上所述,这里有一些关于函数变量的说明:

  • => 符号看作是一个转换器。它使用右边的算法将其左边的输入数据转换为一些新的输出数据。
  • 使用 def 来定义一个方法,val 来创建一个函数。
  • 当把一个函数分配给一个变量时,函数字面量 是表达式右边的代码。

在Map中储存函数

当我说函数是变量时,我的意思是它们可以像 StringInt 变量一样在所有方面被使用。正如本章中的几个示例所示,它们也可以作为函数参数使用。如下面例子所示,你还可以将函数(或方法)存储在 Map 中:

    def add(i: Int, j: Int) = i + j
    def multiply(i: Int, j: Int) = i * j

    // 在Map中存储函数
    val functions = Map(
        "add" -> add,
        "multiply" -> multiply
    )
    // 从Map中获得一个函数并使用它
    val f = functions("add")
    f(2, 2) // 4

另见

  • 有关Eta Expansion的更多信息,请参阅我的文章“Using Scala Methods as If They Were Functions (Eta Expansion)”( https://oreil.ly/7v4ch )。
  • 有关 val 函数和 def 方法的更多详细信息,可以参阅我的博文“Scala:The Differences Between val and def When Creating Functions” ( https://oreil.ly/DuK5N )。
  • 关于部分应用函数的更多例子和细节,请参阅10.5小节。

10.3 定义一个接受简单函数作为参数的方法

问题

你想创建一个方法,将一个简单的函数作为方法参数。

解决方案

此解决方案遵循三步流程:

  1. 定义你的方法,方法参数是你想传入的函数的签名。
  2. 定义一个或多个与此签名相匹配的函数。
  3. 稍后,将这些函数作为参数传递给你的方法。

为了证明这一点,定义一个名为 executeFunction 的方法,该方法以一个名为 callback 的函数作为参数。该函数必须没有输入参数,而且必须不返回任何东西:

    def executeFunction(callback:() => Unit) =
        callback()

关于这个代码,有两点说明:

  • callback:() 语法指定了一个没有输入参数的函数。如果该函数有参数,其类型将被列在括号内。
  • 代码中的 => Unit 部分表示这个 callback 函数不返回任何东西。

我很快就会讨论这种语法。

接下来,定义一个符合这个签名的函数或方法。例如,sayHelloFsayHelloM 都不接受任何输入参数,也不返回任何东西:

    val sayHelloF = () => println("Hello") // 函数
    def sayHelloM(): Unit = println("Hello") // 方法

在示例的最后一步,由于 sayHelloFsayHelloM 都符合 callback 的类型签名,它们都可以被传递给 executeFunction 方法:

    executeFunction(sayHelloF)
    executeFunction(sayHelloM)

REPL展示了它是如何工作的:

    scala> val sayHelloF = () => println("Hello")
    val sayHelloF: () => Unit = Lambda ...

    scala> def sayHelloM(): Unit = println("Hello")
    def sayHelloM(): Unit

    scala> executeFunction(sayHelloF)
    Hello

    scala> executeFunction(sayHelloM)
    Hello

讨论

在上面的示例中,我创建了一个接受简单函数的方法,这样你就可以看到接受函数的方法如何与一个不接受参数且不返回任何东西的函数( Unit )一起工作的。在接下来的内容中,你将看到更复杂的函数签名的例子。

在这个例子中使用的 callback 名称并没有什么特别之处。当我第一次学习如何将函数传递给方法时,我更喜欢 callback 这个名字,因为它使意思很清楚,但这只是一个方法参数的名字。如今,就像我给一个 Int 参数 i 命名一样,我给一个函数参数命名为 f

    def runAFunction(f:() => Unit) = f()

特别的部分是,你传入方法的任何函数必须与你定义的函数参数签名相匹配。在这个例子中,我声明传入的函数必须不接受任何参数,而且必须不返回任何东西:

    f:() => Unit

更一般地说,将一个函数定义为方法参数的语法是:

    parameterName: (parameterType(s)) => returnType

runAFunction 的例子中,parameterNamefparameterType 区域是空的,因为该函数不接受任何参数,而返回类型是 Unit,因为该函数不返回任何东西:

    runAFunction(f:() => Unit)

再比如,要定义一个接受 String 并返回一个 Int 的函数参数,可以使用这两个签名中的一个:

    executeFunction(f: String => Int)
    executeFunction(f: (String) => Int)

关于更复杂的函数签名示例,请参阅下一节内容。

关于Unit的注意事项 -- TODO 鸽子栏

    这些例子中显示的Scala Unit 类似于Java中的 Void 以及Python中的 None。在这种情况下,它被用来表示一个函数不返回任何东西。

另见

函数参数的语法与我们曾讨论的“命名参数”的语法相似。

10.4 声明更复杂的高阶函数

问题

你想定义一个以函数为参数的方法,该函数可能有一个或多个输入参数,并可能返回一个除 Unit 以外的值。你的方法也可以有额外的参数。

解决方案

按照10.3小节中描述的方法,定义一个方法,将一个函数作为参数。指定你期望收到的函数签名,然后在方法的主体中执行该函数。

下面的例子定义了一个名为 exec 的方法,以一个函数作为输入参数。该函数必须接受一个 Int 作为输入参数,并且不返回任何东西:

    def exec(callback: Int => Unit) =
        // invoke the function we were given, giving it an Int parameter
        callback(1)

接下来,定义一个符合预期签名的函数。此函数和方法都符合 callback 的签名,因为它们接受一个 Int 参数,并且不返回任何东西:

    val plusOne = (i: Int) => println(i+1)
    def plusOne(i: Int) = println(i+1)

现在你可以把任何一个版本的 plusOne 传入 exec 函数:

    exec(plusOne)

因为 plusOne 是在方法内部调用的,所以这段代码打印的是数字 2

任何符合这个签名的函数都可以被传入 exec 方法。例如,定义一个名为 plusTen 的新函数(或方法),该函数也接受一个 Int 参数 ,并且不返回任何东西:

    val plusTen = (i: Int) => println(i+10)
    def plusTen(i: Int) = println(i+10)

现在你可以把它传到你的执行函数中,并看到它也能工作:

    exec(plusTen) // prints 11

虽然这些例子很简单,但你可以看到这个技术的威力:你可以轻松地交换可互换的算法。只要传入的函数或方法的签名与你的方法所期望的一致,你的算法就可以做任何你想做的事情。这与OOP策略设计模式中的算法互换相类似。

讨论

不包括像 givens 这样的其他功能(见23.8小节,“用given和using进行术语推断”),描述一个函数作为方法参数的一般语法是这样的:

    parameterName: (parameterType(s)) => returnType

因此,要定义一个接受 String 并返回 Int 的函数,请使用这两个签名中的一个:

    exec(f: (String) => Int)
    exec(f: String => Int)

第二个例子是可行的,因为当一个函数被声明为只有一个输入参数时,圆括号是可选的。作为一个更复杂的例子,这里有一个函数的签名,它接受两个 Int 值并返回一个 Boolean

    exec(f: (Int, Int) => Boolean)

最后,这个 exec 方法期望有一个接受 StringIntDouble 这三个参数,并返回一个 Seq[String] 的函数:

    exec(f: (String, Int, Double) => Seq[String])

在方法参数中同时使用函数和其他参数

一个函数参数就像其他方法参数一样,所以一个方法除了接受一个函数之外,还可以接受其他参数,事实上,这种情况经常发生。 下面的代码展示了这一点。首先,定义一个名为 executeXTimes 的方法,它接受两个参数,一个函数和一个 Int

    def executeXTimes(callback:() => Unit, numTimes: Int): Unit =
        for i <- 1 to numTimes do callback()

顾名思义,executeXTimes 会调用 callback 函数 numTimes 次,所以如果你传入一个 3callback 就会被调用三次。

接下来,定义一个符合 callback 签名的函数或方法:

    val sayHello = () => println("Hello")
    def sayHello() = println("Hello")

现在把任意一个 sayHello 和一个 Int 同时传给 executeXTimes

    scala> executeXTimes(sayHello, 3)
    Hello
    Hello
    Hello

这表明你可以使用这种技术将变量传入方法中,然后这些变量就可以被方法主体中的函数使用。

作为另一个例子,创建这个名为 executeAndPrint 的方法,它接受一个函数和两个 Int 参数:

    def executeAndPrint(f:(Int, Int) => Int, x: Int, y: Int): Unit =
        val result = f(x, y)
        println(result)

在这种情况下,函数 f 接受两个 Int 参数并返回一个 Int 。这个 executeAndPrint 方法比前面的例子更有趣,因为它接受了两个 Int 参数,并将它们传递给这行代码中所给的 f 函数:

    val result = f(x, y)

为了证明这一点,创建两个与 executeAndPrint 所期望的函数签名相匹配的函数,一个是 sum 函数,一个是 multiply 函数:

    val sum = (x: Int, y: Int) => x + y
    def sum(x: Int, y: Int) = x + y
    val multiply = (x: Int, y: Int) => x * y
    def multiply(x: Int, y: Int) = x * y

现在你可以按如下方式调用 executeAndPrint ,传入不同的函数以及两个 Int 参数:

    executeAndPrint(sum, 2, 9) // prints 11
    executeAndPrint(multiply, 3, 9) // prints 27

这很酷,因为 executeAndPrint 方法不知道实际运行的是什么算法。它只知道它把参数 xy 传给它所获得的第一个函数参数,然后打印出该函数的结果。这有点像在OOP中定义一个接口,然后提供该接口的具体实现。

下面是这个三步骤过程的另一个例子:

    // 1 - 定义方法
    def exec(callback: (Any, Any) => Unit, x: Any, y: Any): Unit =
        callback(x, y)

    // 2 - 定义一个函数
    def printTwoThings(a: Any, b: Any): Unit =
        println(a)
        println(b)

    // 3 - 将函数和其他参数传递给exec方法
    case class Person(name: String)
        exec(printTwoThings, "Hello", Person("Dave"))

最后一行代码的输出在REPL中看起来像这样:

    scala> exec(printTwoThings, "Hello", Person("Dave"))
    Hello
    Person(Dave)

另见

函数参数的语法与我们曾讨论的“命名参数”的语法相似。

10.5 使用部分应用函数

问题

你想通过以下方式消除重复向函数传递变量的现象:通过(a)将公共变量传递到函数中,(b)创建一个预加载了这些值的新函数,然后(c)使用这个新函数时,只向它传递它所需要的唯一变量。

解决方案

部分应用函数的经典例子始于一个 sum 函数:

    val sum = (a: Int, b: Int, c: Int) => a + b + c

sum 没有什么特别之处,它只是一个求三个 Int 值之和的函数。但是当你在调用 sum 时提供了两个参数而没有提供第三个参数时,事情就变得有趣了:

    val addTo3 = sum(1, 2, _)

因为你没有为第三个参数提供一个值,结果变量 addTo3 是一个部分应用函数。你可以在REPL中看到这一点。首先,粘贴 sum 函数:

    scala> val sum = (a: Int, b: Int, c: Int) => a + b + c
    val sum: (Int, Int, Int) => Int = Lambda ...

REPL结果显示这样的输出:

    val sum: (Int, Int, Int) => Int = Lambda ...
              -------------     ---

这个输出验证了 sum 是一个接受三个 Int 输入参数并返回一个 Int 的函数。接下来,只给 sum 它想要的三个输入参数中的两个,同时将结果分配给 addTo3

    scala> val addTo3 = sum(1, 2, _)
    val addTo3: Int => Int = Lambda ...
                ----------

REPL结果的下划线部分显示 addTo3 是一个将单个 Int 输入参数转化为输出一个 Int 参数的函数。addTo3 是通过给 sum 输入参数 12 创建的,现在 addTo3 是一个可以再接受一个输入参数的函数。所以现在当你给 addTo3 传一个Int,比如数字 10 ,你会神奇地得到被传入这两个函数的三个数字的总和:

    scala> addTo3(10)
    res0: Int = 13

以下是刚刚发生的事情的总结:

  • 前两个数字( 12 )被传入原来的 sum 函数。
  • 这个过程创建了名为 addTo3 的新函数,它是一个部分应用函数。
  • 在代码的某一时刻,第三个数字( 10 )被传入 addTo3

请注意,在这个例子中,我把 sum 创建为一个 val 函数,但它也可以定义为一个 def 方法,它的工作原理完全一样:

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

    scala> val addTo3 = sum(1, 2, _)
    val addTo3: Int => Int = Lambda ...

    scala> addTo3(10)
    val res0: Int = 13

讨论

在函数式编程语言中,当你调用一个有参数的函数时,你可以说是 将该函数应用于参数 。当所有的参数都传递给函数时 —— 这是你在使用Java等语言时经常做的事。 —— 你已经将函数 完全应用 于所有的参数。但是当你只给函数一个参数的子集时,表达式的结果是一个部分应用函数。

正如例子中所展示的那样,所产生的部分应用函数是一个变量,你可以在周围传递。例如,这段代码显示了部分应用函数 addTo3 如何被传入wormhole,通过wormhole,并在执行之前从另一端传递出去:

    def sum(a: Int, b: Int, c: Int) = a + b + c
    val addTo3 = sum(1, 2, _)

    def intoTheWormhole(f: Int => Int) = throughTheWormhole(f)
    def throughTheWormhole(f: Int => Int) = otherSideOfWormhole(f)

    // supply 10 to whatever function you receive:
    def otherSideOfWormhole(f: Int => Int) = f(10)
    intoTheWormhole(addTo3) // 13

正如最后一行的注释所示,当 addTo3 最终被 otherSideOfWormhole 执行时,调用 intoTheWormhole 的结果将是 13

函数变量也被称为 函数值 ,如示例所示,当你后来提供 完全应用 函数值所需的所有参数时,会产生一个结果。

在实际应用中的使用

这种技术的一个用途是为一个一般的函数创建一个更具体的版本。例如,在处理HTML时,你可能有一个方法可以为一个HTML片段添加前缀和后缀:

    def wrap(prefix: String, html: String, suffix: String) =
        prefix + html + suffix

如果在你的代码中的某一地方,你知道你总是想给不同的HTML字符串添加相同的前缀和后缀,你可以将这两个参数应用到方法中,而不应用 html 参数:

    val wrapWithDiv = wrap("<div>", _, "</div>")

现在你可以调用产生的 wrapWithDiv 函数,只需将你要包裹的HTML传给它:

    scala> wrapWithDiv("<p>Hello, world</p>")
    res0: String = <div><p>Hello, world</p></div>

    scala> wrapWithDiv("""<img src="/images/foo.png" />""")
    res1: String = <div><img src="/images/foo.png" /></div>

wrapWithDiv 函数预先加载了你应用的 <div></div> 标签,所以只需一个参数,即你想包裹的HTML,就可以调用它。

这是一个很吸引人的优势,如果你想的话,你仍然可以调用原来的 wrap 函数:

    wrap("<pre>", "val x = 1", "</pre>")

一般来说,你可以使用部分应用函数,通过将一些参数绑定到一个现有的方法(或函数)上,而将其他参数留待填入,从而使编程更容易。

Scala 3中改进的类型推断 -- TODO 鸽子栏

    在Scala 2中,经常需要指定一个被省略的参数的类型,例如为前面的例子指定 String 类型:

    val wrapWithDiv = wrap("<div>", _: String, "</div>")
                                    ---------

    但到目前为止,在Scala 3中还不需要这样做。只需要用 _ 字符来声明这个缺失的字段,就像例子中所展示那样:

    val wrapWithDiv = wrap("<div>", _, "</div>")

10.6 创建返回函数的方法

问题

你想从一个函数或方法中返回一个函数(算法)。

解决方案

定义一个匿名函数,并从你的方法中返回该函数。然后将其分配给一个函数变量,以后根据需要调用该函数变量。

例如,假设在当前范围内存在一个名为 prefix 的变量,这段代码声明了一个匿名函数,它接收一个名为 strString 参数,并返回带有 prefix 的字符串:

    (str: String) => s"$prefix $str"

现在你可以从另一个函数的主体中返回该匿名函数,如下所示:

    // 单行形式
    def saySomething(prefix: String) = (str: String) => s"$prefix $str"

    // 多行形式
    def saySomething(prefix: String) = (str: String) =>
        s"$prefix $str"

那个例子没有显示 saySomething 的返回类型,但是如果你愿意,你可以把它声明为 (String => String)

    def saySomething(prefix: String): (String => String) = (str: String) =>
        s"$prefix $str"

因为 saySomething 返回一个将一个 String 转换为另一个 String 的函数,你可以将该函数分配给一个变量。saySomething 也需要一个名为 prefixString 参数,所以在你创建一个名为 sayHello 的新函数时给它这个参数:

    val sayHello = saySomething("Hello")

当你把这段代码粘贴到REPL中时,你可以看到 sayHello 是一个将 String 转换为 String 的函数:

    scala> val sayHello = saySomething("Hello")
    val sayHello: String => String = Lambda ...
                  ----------------

sayHello 本质上与 saySomething 相同,但 prefix 预加载了值 "Hello" 。回头看看这个匿名函数,你会发现它接受一个 String 参数 s 并返回一个 String,所以你需要传递给它一个字符串:

    sayHello("Al")

下面是这些步骤在REPL中的情况:

    scala> def saySomething(prefix: String) = (str: String) =>
         |     s"$prefix $str"
    def saySomething(prefix: String): String => String

    // assign "Hello" to prefix
    scala> val sayHello = saySomething("Hello")
    val sayHello: String => String = Lambda ...

    // assign "Al" to str
    scala> sayHello("Al")
    res0: String = Hello Al

讨论

你可以在任何时候使用这种方法来封装一个方法内的算法。有点像面向对象的工厂或策略模式,你的方法返回的函数可以基于它收到的输入参数。例如,创建一个 greeting 方法,根据指定的语言返回一个合适的问候语:

    def greeting(language: String) = (name: String) =>
        language match
            case "english" => s"Hello, $name"
            case "spanish" => s"Buenos dias, $name"

如果你不清楚 greeting 是返回一个 String => String 的函数,你可以通过以下方式使代码更加明确:(a)指定方法的返回类型和(b) 在方法内部创建函数值:

    // [a] declare the 'String => String' return type
    def greeting(language: String): (String => String) = (name: String) =>
        // [b] create the function values here, then return them from the
        // match expression
        val englishFunc = () => s"Hello, $name"
        val spanishFunc = () => s"Buenos dias, $name"
        language match
            case "english" => println("returning the english function")
                              englishFunc()
            case "spanish" => println("returning the spanish function")
                              spanishFunc()

下面是第二个方法在REPL中被调用时的样子:

    scala> val hello = greeting("english")
    val hello: String => String = Lambda ...

    scala> val buenosDias = greeting("spanish")
    val buenosDias: String => String = Lambda ...

    scala> hello("Al")
    returning english function
    val res0: String = Hello, Al

    scala> buenosDias("Lorenzo")
    returning spanish function
    val res1: String = Buenos dias, Lorenzo

你可以在任何时候使用这个技巧,将一个或多个函数封装在一个方法后面。

从一个方法中返回方法

另外,如果你愿意,由于Scala的Eta Expansion技术,你也可以从方法内部声明和返回 方法

    def greeting(language: String): (String => String) = (name: String) =>
        def englishMethod = s"Hello, $name"
        def spanishMethod = s"Buenos dias, $name"
        language match
            case "english" => println("returning the english method")
                              englishMethod
            case "spanish" => println("returning the spanish method")
                              spanishMethod

greeting 的唯一变化是,这个版本定义并返回 englishMethodspanishMethod。我发现方法的语法更容易阅读,所以我更喜欢这种方式,对于 greeting 的调用者来说,其他的东西看起来完全一样。

相同的技术,不同的用途 -- TODO 鸽子栏

    在第一个例子中,prefix 被用来在返回结果的函数中预装一个值。在第二个例子中,language 参数被用来选择要返回的算法,这一点类似于面向对象编程的策略或模板模式。

10.7 创建偏函数

问题

你想定义一个只对输入值的可能子集有效的函数,或者你想定义一系列只对输入值的子集而有效的函数,然后结合这些函数以完全解决一个问题。

解决方案

偏函数 是一个函数,它不会为每个可能的输入值都提供答案。它只为可能的数据子集提供答案,并定义了它可以处理的数据。在Scala中,偏函数也可以被询问,以确定它是否可以处理某个特定的值。

例如,想象这里一个普通函数,用一个数字除以另一个数字:

    val divide = (x: Int) => 42 / x

按照定义,当输入参数为零时,这个函数就会抛出异常:

    scala> divide(0)
    java.lang.ArithmeticException: / by zero

虽然你可以通过捕捉和抛出一个异常来处理这种特殊情况,但Scala让你把 divide 函数定义为 PartialFunction 。即使这样,还需要明确声明,当输入参数不为零时,该函数才会被定义:

    val divide = new PartialFunction[Int, Int] {
        def apply(x: Int) = 42 / x
        def isDefinedAt(x: Int) = x != 0
    }

在这种方法中,apply 方法定义了函数签名和方法体。现在你可以做几件好事了。一件事是在你试图使用该函数之前测试它:

    scala> divide.isDefinedAt(0)
    res0: Boolean = false

    scala> divide.isDefinedAt(1)
    res1: Boolean = true

    scala> val x = if divide.isDefinedAt(1) then Some(divide(1)) else None
    val x: Option[Int] = Some(42)

除此以外,你很快就会看到其他代码可以利用偏函数来提供优雅而简洁的解决方案。

虽然 divide 函数明确地说明了它处理哪些数据,但偏函数也可以用 case 语句来编写:

    val divide2: PartialFunction[Int, Int] =
        case d if d != 0 => 42 / d

通过这种方法,Scala可以根据这部分代码推断出 divide2 需要一个 Int 输入参数:

    PartialFunction[Int, Int]
                    ---

虽然这段代码没有明确实现 isDefinedAt 方法,但它与之前的 divide 函数具有相同的行为:

    scala> divide2.isDefinedAt(0)
    res0: Boolean = false

    scala> divide2.isDefinedAt(1)
    res1: Boolean = true

讨论

PartialFunction 的Scaladoc( https://oreil.ly/hpqyJ )是这样描述偏函数的:

    一个 PartialFunction[A, B] 类型的偏函数是一个一元函数,其域不一定包括 A 类型的所有值。函数 isDefinedAt 允许[你]动态地测试一个值是否在函数的域中。

这有助于解释为什么最后一个带有 case 语句的例子能起作用:isDefinedAt 方法动态地测试给定的值是否在函数的域中,也就是说,是否被处理或占位。PartialFunction 特质的签名看起来是这样的:

    trait PartialFunction[-A, +B] extends (A) => B

正如在其他章节中所讨论的,=> 符号可以被认为是一个转化器,在这种情况下,(A) => B 可以被解释为一个函数,将一个类型 A 转化为结果类型 B

divide2 方法将一个输入 Int 转化为一个输出 Int ,所以它的签名看起来像这样:

    val divide2: PartialFunction[Int, Int] = ...
                                 --------

但是,如果它返回的是一个 String,它将被这样声明:

    val divide2: PartialFunction[Int, String] = ...
                                 -----------

作为一个例子,下面的方法就使用了这个签名:

    // converts 1 to "one", etc., up to 5
    val convertLowNumToString = new PartialFunction[Int, String] {
        val nums = Array("one", "two", "three", "four", "five")
        def apply(i: Int) = nums(i-1)
        def isDefinedAt(i: Int) = i > 0 && i < 6
    }

用orElse和andThen来串联偏函数

偏函数的一个了不起的特点是,你可以把它们连在一起。例如,一种方法可能只对偶数起作用,另一种方法可能只对奇数起作用,而它们一起可以解决所有的整数问题。

为了展示这种方法,下面的例子显示了两个函数,它们分别可以处理少数的 Int 输入并将其转换为 String 结果:

    // converts 1 to "one", etc., up to 5
    val convert1to5 = new PartialFunction[Int, String] {
        val nums = Array("one", "two", "three", "four", "five")
        def apply(i: Int) = nums(i-1)
        def isDefinedAt(i: Int) = i > 0 && i < 6
    }

    // converts 6 to "six", etc., up to 10
    val convert6to10 = new PartialFunction[Int, String] {
        val nums = Array("six", "seven", "eight", "nine", "ten")
        def apply(i: Int) = nums(i-6)
        def isDefinedAt(i: Int) = i > 5 && i < 11
    }        

单独来看,它们各自只能处理五个数字。但与 orElse结合在一起,所产生的函数可以处理10个数字:

    scala> val handle1to10 = convert1to5 orElse convert6to10
    handle1to10: PartialFunction[Int,String] = <function1>

    scala> handle1to10(3)
    res0: String = three

    scala> handle1to10(8)
    res1: String = eight

orElse 方法来自 PartialFunction 特质,它还包括 andThen 方法,以进一步帮助你将偏函数链接起来。

集合类中的偏函数

了解偏函数很重要,这不仅仅是为了在你的工具箱中拥有另一个工具,还因为它们被用于一些库的API中,包括Scala集合库。

你会遇到偏函数的一个例子是集合类的 collect 方法。collect 方法接受一个偏函数作为输入,正如它的Scaladoc所描述的,collect “通过对定义函数的列表中的所有元素应用偏函数来建立一个新的集合。”

例如,前面显示的 divide 函数是一个偏函数,它在 Int 值为0时是未定义的。下面再看一下这个函数:

    val divide: PartialFunction[Int, Int] =
        case d: Int if d != 0 => 42 / d

如果你试图用 map 方法和一个包含 0 的列表来使用这个偏函数,它会抛出一个 MatchError

    scala> List(0,1,2).map(divide)
    scala.MatchError: 0 (of class java.lang.Integer)
    stack trace continues ...

但是,如果你用 collect 方法使用同一个函数,就不会抛出异常:

    scala> List(0,1,2).collect(divide)
    res0: List[Int] = List(42, 21)

这是因为 collect 方法的编写是为了测试它所给的每个元素的 isDefinedAt 方法而编写的。从概念上讲,它类似于这样:

    List(0,1,2).filter(divide.isDefinedAt(_))
               .map(divide)

因此,当输入值为 0 时,collect 不会运行 divide 算法,但会对其他每个元素运行 divide 算法。

你可以看到 collect 方法在其他情况下的工作,比如传递给它一个包含混合数据类型的 List,以及一个只对 Int 值工作的函数:

    scala> List(42, "cat").collect { case i: Int => i + 1 }
    res0: List[Int] = List(43)

因为它检查了 isDefinedAt 方法,collect 可以处理你的匿名函数不能使用 String 作为输入的事实。

collect 的另一个用途是当一个列表包含一系列的 SomeNone 值,而你想提取所有的 Some 值时:

    scala> val possibleNums = List(Some(1), None, Some(3), None)
    val possibleNums: List[Option[Int]] = List(Some(1), None, Some(3), None)

    scala> possibleNums.collect{case Some(i) => i}
    val res1: List[Int] = List(1, 3)

或者使用扁平化(flatten) -- TODO 耗子栏

    另一种减少 Seq[Option] 的方法是在它的 Some 元素中调用 flatten

    scala> possibleNums.flatten
    val res0: List[Int] = List(1, 3)

    能这样做的原因是 Option 就像一个包含0或1个值的列表。而 flatten 的目的是将一个“列表的列表”转换为一个单一的列表。参见 13.6小节“用 flatten 扁平化一个列表”,以了解更多细节。

另见

10.8 实现函数式的错误处理

问题

你已经开始用函数式编程风格写代码,但你不确定在写纯函数时如何处理异常和其他错误。

解决方案

因为编写函数式代码就像编写代数等式一样,而代数等式总是返回一个值,从不会抛出一个异常 —— 所以你的纯函数不会抛出异常。相反,你用Scala的 错误处理类型 来处理错误:

  • Option/Some/None
  • Try/Success/Failure
  • Either/Left/Right

有个典型例子是写一个 makeInt 方法。想象一下,Scala String 并不包含 makeInt 方法,所以你想自己写一个方法。一个正确的解决方案是这样的:

    def makeInt(s: String): Option[Int] =
        try
            Some(Integer.parseInt(s))
        catch
            case e: NumberFormatException => None

如果 makeInt 能够将 String 转换为 Int,那么这段代码将返回一个 Some[Int],否则将返回一个 None。这个方法的调用者这样使用它:

    makeInt("1") // Option[Int] = Some(1)
    makeInt("a") // Option[Int] = None

    makeInt(aString) match
        case Some(i) => println(s"i = $i")
        case None => println("Could not create an Int")

给出一个可能转换为整数也可能不能转换为整数的字符串列表 listOfStrings,你也可以像这样使用 makeInt

    val optionalListOfInts: Seq[Option[Int]] = 
        for s <- listOfStrings yield makeInt(s)

这很好,因为 makeInt 不会抛出一个异常并破坏 for 表达式。相反,for 表达式会返回一个包含 Option[Int] 值的 Seq。例如,如果 listOfStrings 包含这些值:

    val listOfStrings = List("a", "1", "b", "2")

然后 optionalListOfInts 将包含这些值:

    List(None, Some(1), None, Some(2))

要创建一个只包含成功转换为整数值的列表,只需像这样将该列表扁平化:

    val ints = optionalListOfInts.flatten // List(1, 2)

除了使用 Option 类型来解决这个问题外,你还可以使用 TryEither 类型。使用这三种错误处理类型的 makeInt 方法的更短版本看起来像这样:

    import scala.util.control.Exception.*
    import scala.util.{Try, Success, Failure}

    def makeInt(s: String): Option[Int] = allCatch.opt(Integer.parseInt(s))
    def makeInt(s: String): Try[Int] = Try(Integer.parseInt(s))
    def makeInt(s: String): Either[Throwable, Int] =
        allCatch.either(Integer.parseInt(s))

这些例子显示了这三种方法对成功和错误的处理示例:

    // Option
    makeInt("1") // Some(1)
    makeInt("a") // None

    // Try
    makeInt("1") // util.Try[Int] = Success(1)
    makeInt("a") // util.Try[Int] = Failure(java.lang.NumberFormatException:
                 //                 For input string: "a")
    // Either
    makeInt("1") // Either[Throwable, Int] = Right(1)
    makeInt("a") // Either[Throwable, Int] = Left(java.lang.NumberFormatException:
                 //                          For input string: "a")

所有这些方法的关键是,你不抛出异常;相反,你要返回这些错误处理类型。

使用这两种方法可以让你为ZIO等FP库做好准备 -- TODO 耗子栏

    本书的审稿人之一Hermann Hueck提出了一个观点,使用 Either 的两个好处是:(a)它比 Try 更灵活,因为你可以控制错误类型,以及(b)它让你准备好使用ZIO( https://zio.dev )这样的函数式编程库,它广泛地使用 Either 和类似的方法。

讨论

解决这个问题的一个糟糕的(非FP)方法是像这样写一个抛出一个异常的方法:

    // don’t write code like this!
    @throws(classOf[NumberFormatException])
    def makeInt(s: String): Int =
        try
            Integer.parseInt(s)
        catch
            case e: NumberFormatException => throw e

你不要在FP中写这样的代码,因为当其他人使用你的方法时并引发异常时,这会破坏他们的等式。例如,想象一下,有人为使用这个版本的 makeInt 的表达式写了这个:

    val possibleListOfInts: Seq[Int] =
        for s <- listOfStrings yield makeInt(s)

如果 listOfStrings 包含与解决方案中显示的相同的值:

    val listOfStrings = List("a", "1", "b", "2")

它们的表达式 —— 它们想成为一个代数等式 —— 将在第一个元素上被破坏,即列表中的“a”。

同样,由于代数等式不抛出异常,纯函数也不抛出它们。

另见

  • 参阅24.6小节,“使用Scala的错误处理类型( OptionTryEither )”,了解更多关于使用 OptionTryEither 错误处理类型的细节。

10.9 实际应用中的例子:在算法中传递函数

作为一个实际应用中的例子,在这一章中,我将展示如何将方法和函数作为我在航空航天工程时期使用的算法的一部分进行传递。

Newton’s Method( https://oreil.ly/YUapD )是一种数学方法,可以用来解决等式的解。例如,这个例子将为这个等式找到一个可能的 x值:

    3x + sin(x) - Ex = 0

正如你在下面的代码中看到的,名为 newtonsMethod 的方法将函数作为其前两个参数。它还接受另外两个 Double 参数并返回一个 Double

    /**
     * Newton’s Method for solving equations.
     * @param fx The equation to solve.
     * @param fxPrime The derivative of `fx`.
     * @param x An initial “guess” for the value of `x`.
     * @param tolerance Stop iterating when the iteration values are within this tolerance.
     * @todo Check that `f(xNext)` is greater than a second tolerance value.
     * @todo Check that `f'(x) != 0`
     */
    def newtonsMethod(
        fx: Double => Double,
        fxPrime: Double => Double,
        x: Double,
        tolerance: Double
    ): Double =
        /**
         * 大多数FP方法都不使用`var`字段。但有些人认为,当`var`字段包含在方法/函数的范围内时,是可以接受的。
         */
        var x1 = x
        var xNext = newtonsMethodHelper(fx, fxPrime, x1)
        while math.abs(xNext - x1) > tolerance do
            x1 = xNext
            println(xNext) // 调试(中间值)
            xNext = newtonsMethodHelper(fx, fxPrime, x1)
        end while
        // 返回 xNext:
        xNext

    end newtonsMethod

    /**
     * 计算 `x2 = x1 - f(x1)/f'(x1)` .
     */
    def newtonsMethodHelper(
        fx: Double => Double,
        fxPrime: Double => Double,
        x: Double
    ): Double =
        x - fx(x) / fxPrime(x)

传入 newtonsMethod 的两个函数应该是原始等式( fx )和等式的导数( fxPrime )。不要太在意这两个方法 里面 的细节。我只想关注在这样一个真实世界的算法中,函数是如何传递的。

newtonsMethodHelper 方法也需要两个函数作为参数,所以你可以看到函数是如何从 newtonsMethod 传递到 newtonsMethodHelper 的。

这里有一个 @main 驱动方法,展示了如何使用 Newton’s Method 来寻找 fx 等式的解:

    /**
     * A “driver” function to test Newton’s method. Start with:
     * - the desired `f(x)` and `f'(x)` equations
     * - an initial guess, and
     * - a tolerance value
     */
    @main def driver =
        // `f(x)`和`f'(x)`这两个函数都接受一个`Double`参数,并返回一个`Double`值。
        def fx(x: Double): Double = 3*x + math.sin(x) - math.pow(math.E, x)
        def fxPrime(x: Double): Double = 3 + math.cos(x) - math.pow(math.E, x)

        val initialGuess = 0.0
        val tolerance = 0.00005

        // 将 `f(x)`和 `f'(x)`与初始猜测和公差一起传递给newtonsMethod函数。
        val answer = newtonsMethod(fx, fxPrime, initialGuess, tolerance)

        // 注意:这不是一种打印输出的FP方法。
        println(answer)

这个例子的输出结果是:

    0.3333333333333333
    0.3601707135776337
    0.36042168047601975
    0.3604217029603242

正如你所看到的,这段代码的大部分涉及到定义方法和函数,将函数传入方法,然后在方法中调用函数。这让你了解了FP的工作原理,特别是在为这样的算法编写代码时。

名为 newtonsMethod 的方法适用于任何两个函数 fxfxPrime 。其中 fxPrimefx 的导数,在没有实现的 @todo 条目的范围内,可以对任何两个函数实现。要对这个例子进行实验,可以尝试改变函数 fxfxPrime,或者实现 @todo 条目。

算法的来源 -- TODO 鸽子栏

    所示的算法来自于20世纪80年代的大学教材 Applied Numerical Analysis,作者是Curtis Gerald和Patrick Wheatley(Pearson)。该书以伪代码形式展示了该方法。

10.10 实际应用中的例子:函数式领域建模

作为一个实际应用中的例子,让我们看看如何为一家披萨店组建一个FP风格的订单应用程序。这个例子中的代码将只关注披萨 —— 没有面包、奶酪、软饮或沙拉,但它将对客户、地址和订单进行建模,并对这些数据类型进行操作(纯函数)。

数据模型

开始时,这里有一些披萨类需要的枚举。为了清楚我们在做什么,把这些代码放在一个名为 Nouns.scala 的文件中:

    enum Topping:
        case Cheese, Pepperoni, Sausage, Mushrooms, Onions

    enum CrustSize:
        case Small, Medium, Large

    enum CrustType:
        case Regular, Thin, Thick

接下来,这些枚举被用来定义一个 Pizza 类。将这个样例类添加到 Nouns.scala 中:

    case class Pizza(
        crustSize: CrustSize,
        crustType: CrustType,
        toppings: Seq[Topping]
    )

最后,这些类被用来模拟客户和订单的概念:

    case class Customer(
        name: String,
        phone: String,
        address: Address
    )

    case class Address(
        street1: String,
        street2: Option[String],
        city: String,
        state: String,
        postalCode: String
    )

    case class Order(
        pizzas: Seq[Pizza],
        customer: Customer
    )

将这段代码也添加到 Nouns.scala 中。

这就是数据模型的全部内容。请注意,这些类是用枚举和样例类来定义的简单的、不可变的数据结构。与OOP类不同的是,这里并没有将行为(方法)封装在类里面。因此,这种方法感觉很像定义一个数据库模式。

贫血模型的领域对象 -- TODO 鸽子栏

    Debasish Ghosh在他的 Functional and Reactive Domain Modeling (Manning) 一书中指出,当OOP从业者将他们的类描述为封装数据和行为的“充血领域模型”时,FP数据模型可以被认为是“贫血模型的领域对象”。这是因为,如本节所示,数据模型是用枚举和带有属性的样例类来定义的,但它们没有行为。

在该模型上操作的函数

现在你要做的就是创建一系列的纯函数来操作这些不可变的数据结构。做到这点的一个好方法是首先用一个或多个特质勾勒出所需的接口。对于在 Pizza 上操作的函数,我将定义一个特质。把这些代码放在一个名为 Verbs.scala 的文件中:

    trait PizzaServiceInterface:
        def addTopping(p: Pizza, t: Topping): Pizza
        def removeTopping(p: Pizza, t: Topping): Pizza
        def removeAllToppings(p: Pizza): Pizza

        def updateCrustSize(p: Pizza, cs: CrustSize): Pizza
        def updateCrustType(p: Pizza, ct: CrustType): Pizza

一旦你创建了该特质的具体实现 —— 你一会儿就会做到 —— 你就可以写出这样的代码:

    import Topping.*, CrustSize.*, CrustType.*

    @main def pizzaServiceMain =
        // PizzaService是一个继承PizzaServiceInterface的特质。
        import PizzaService.*
        object PizzaService extends PizzaService

        // 一个初始化的pizza
        val p = Pizza(Medium, Regular, Seq(Cheese))

        // 演示PizzaService的功能
        val p1 = addTopping(p, Pepperoni)
        val p2 = addTopping(p1, Mushrooms)
        val p3 = updateCrustType(p2, Thick)
        val p4 = updateCrustSize(p3, Large)

        // 这不是一种打印输出的函数式方法。
        // result:
        // Pizza(LargeCrustSize,ThickCrustType,List(Cheese, Pepperoni, Mushrooms))
        println(p4)        

把这些代码放在一个名为 Driver.scala 的文件中。

因为我对这个API的外观很满意,所以我创建了 PizzaServiceInterface 特质的具体实现。为此,我将这段代码添加到 Verbs.scala 文件中:

    import ListUtils.dropFirstMatch
    trait PizzaService extends PizzaServiceInterface:
        def addTopping(p: Pizza, t: Topping): Pizza =
            val newToppings = p.toppings :+ t
            p.copy(toppings = newToppings)

        def removeTopping(p: Pizza, t: Topping): Pizza =
            val newToppings = dropFirstMatch(p.toppings, t)
            p.copy(toppings = newToppings)

        def removeAllToppings(p: Pizza): Pizza =
            val newToppings = Seq[Topping]()
            p.copy(toppings = newToppings)

        def updateCrustSize(p: Pizza, cs: CrustSize): Pizza =
            p.copy(crustSize = cs)

        def updateCrustType(p: Pizza, ct: CrustType): Pizza =
            p.copy(crustType = ct)
    end PizzaService

这段代码需要一个名为 dropFirstMatch 的方法,用来丢弃列表中的第一个匹配元素,我把它放在 ListUtils 对象中:

    object ListUtils:
        /**
         * Drops the first matching element in a list, as in this example:
         * {{{
         * val xs = List(1,2,3,1)
         * dropFirstMatch(xs, 1) == List(2,3,1)
         * }}}
         */
        def dropFirstMatch[A](xs: Seq[A], value: A): Seq[A] =
            val idx = xs.indexOf(value)
            for
                (x, i) <- xs.zipWithIndex
                if i != idx
            yield
                x

就我们的目的而言,这个方法适用于放弃在配料列表中第一次发现的 Topping

    val a = List(Pepperoni, Mushrooms, Pepperoni)
    val b = dropFirstMatch(a, Pepperoni)
    // result: b == List(Mushrooms, Pepperoni)   // 第一个Pepperoni被移除

正如 PizzaServiceInterfacePizzaService 所示,函数(动词)的实现通常是一个两步的过程。在第一步中,你把API的契约勾画成一个 接口。在第二步中,你创建一个该接口的具体 实现。这使你能够灵活地创建基础接口的多种具体实现。

在这一点上,你有一个完整的、可以工作的小应用程序。用这个应用程序进行实验,看看你是否喜欢现在的API,或者你是否想修改它。

名词和动词 -- TODO 鸽子栏

    我特别使用 Nouns.scalaVerbs.scala 这两个文件名来强调这种编写代码的FP方法。如上所示,你的数据模型只是由你的应用程序中的名词组成,而函数是处理操作的地方,也就是动词。

使用特质创建依赖注入框架

将一个特质作为一个接口,然后在另一个特质(或对象)中实现它的一个好处是,你可以用这个设计来创建一个 依赖注入框架。例如,想象一下,你想写一些代码来计算披萨的价格,而这些代码需要访问一个数据库。解决这个问题的一种方法是:

  1. 创建一个名为 PizzaDaoInterface 的数据访问对象(DAO)接口。
  2. 为你的开发、测试和生产环境创建该DAO接口的不同实现。
  3. 创建一个引用 PizzaDaoInterface 的 “pizza pricer”特质。
  4. 为开发、测试和生产环境创建特定的披萨定价器实现。
  5. 创建单元测试来测试你的代码(或者在我们的例子中,使用一个 @main 注解来驱动应用程序,展示这个解决方案)。

这些步骤在下面的例子中有展示。

1. 创建一个DAO接口

首先,创建一个名为 Dao.scala 的文件,然后把这个接口放在该文件中,并像这样声明DAO:

    trait PizzaDaoInterface:
        def getToppingPrices(): Map[Topping, BigDecimal]
        def getCrustSizePrices(): Map[CrustSize, BigDecimal]
        def getCrustTypePrices(): Map[CrustType, BigDecimal]

在实际应用中,由于访问数据库可能会失败,这些方法将返回一个像 OptionTryEither 这样的类型来包裹着每一个Map,但我省略了这些,使代码更简单。

2. 创建该DAO的多个实现

接下来,为你的开发、测试和生产环境创建该DAO接口的具体实现。通常你会为每个环境创建一个具体的实现,但为了更具针对性,我只为开发环境创建一个“mock” DAO,并命名为 DevPizzaDao。这样做的目的是为了在本地开发(和测试)过程中拥有一个快速、模拟的数据库。要做到这一点,把这段代码也放在 Dao.scala 中:

    object DevPizzaDao extends PizzaDaoInterface:
        def getToppingPrices(): Map[Topping, BigDecimal] =
            Map(
                Cheese    -> BigDecimal(1),   // simulating $1 each
                Pepperoni -> BigDecimal(1),
                Sausage   -> BigDecimal(1),
                Mushrooms -> BigDecimal(1)
            )

        def getCrustSizePrices(): Map[CrustSize, BigDecimal] =
            Map(
                Small  -> BigDecimal(0),
                Medium -> BigDecimal(1),
                Large  -> BigDecimal(2)
            )

        def getCrustTypePrices(): Map[CrustType, BigDecimal] =
            Map(
                Regular -> BigDecimal(0),
                Thick   -> BigDecimal(1),
                Thin    -> BigDecimal(1)
            )
    end DevPizzaDao

在实际应用中,你可能使用这个DAO进行开发和测试,并在生产环境中使用 ProductionPizzaDao。或者你可以为测试环境使用一个单独的DAO,或者在任何地方使用测试数据库测试代码。

3. 创建一个“pizza pricer”特质

接下来,回到 Verbs.scala 文件,创建一个引用 PizzaDaoInterface 的“pizza pricer”特质:

    trait PizzaPricerTrait:

        // this base trait references the DAO interface
        def pizzaDao: PizzaDaoInterface

        def calculatePizzaPrice(p: Pizza): BigDecimal =
            // the key thing here is the use of `pizzaDao`
            val crustSizePrice: BigDecimal =
                pizzaDao.getCrustSizePrices()(p.crustSize)
            val crustTypePrice: BigDecimal =
                pizzaDao.getCrustTypePrices()(p.crustType)
            val toppingPrices: Seq[BigDecimal] =
                for
                    topping <- p.toppings
                    toppingPrice = pizzaDao.getToppingPrices()(topping)
                yield
                    toppingPrice
            val totalToppingPrice: BigDecimal = toppingPrices.reduce(_ + _) //sum
            val totalPrice: BigDecimal =
                crustSizePrice + crustTypePrice + totalToppingPrice
            totalPrice

        // other price-related functions ...

    end PizzaPricerTrait

解决方案这部分的两个关键是:

  • pizzaDao 的引用声明为 PizzaDaoInterface 类型的 def 。它将在你接下来要开发的具体对象中被 val 字段所取代。
  • 如示例所示,calculatePizzaPrice 可以在这个基础特质中实现。这样,你只需要在一个地方实现它,并且它将在继承 PizzaPricerTrait 的具体对象中可用。

4. 为你的环境创建特定的定价器

接下来,在例子的最后一步,为你的开发、测试和生产环境创建具体的披萨定价器对象:

    object DevPizzaPricerService extends PizzaPricerTrait:
        val pizzaDao = DevPizzaDao         // dev environment

    object TestPizzaPricerService extends PizzaPricerTrait:
        val pizzaDao = TestPizzaDao        // test environment

    object ProductionPizzaPricerService extends PizzaPricerTrait:
        val pizzaDao = ProductionPizzaDao  // production environment

如上所述,因为 PizzaPricerTrait 完全实现了它的 calculatePizzaPrice 方法,所有这些对象需要做的就是连接到它们各自的数据访问对象。

在这个项目的源代码中,你会看到我在 Verbs.scala 文件中创建了一个 DevPizzaPricer。(注意,在这个例子中,DevPizzaPricerService 能编译并运行,因为我实现了 DevPizzaDao,但是 TestPizzaPricerServiceProductionPizzaPricerService 不能编译,因为我没有实现 TestPizzaDaoProductionPizzaDao )。

统一访问原则 -- TODO 鸽子栏

    在 PizzaPricerTrait 中,pizzaDao 被定义为一个 def

    def pizzaDao: PizzaDaoInterface

    但在具体的 DevPizzaPricerService 对象中,我将该字段定义为一个 val

    val pizzaDao = DevPizzaDao

    这是因为Scala中的统一访问原则(UAP)( https:// oreil.ly/G7Uce )实现。该链接来自Scala词汇表,其中提到“统一访问原则指出,变量和无参数函数应该使用相同的语法进行访问。Scala支持这一原则,不允许在无参数函数的调用点上放置圆括号。因此,无参数函数的定义可以改为 val,反之亦然,且不会影响客户端代码。”

5. 创建单元测试或驱动该方案的应用程序

在实际应用中,你会创建单元测试来测试代码,但为了更具针对性,在这里我将仅创建一个 @main 应用程序来演示该解决方案。这个 pizzaPricingMain 应用程序展示了如何通过使用 DevPizzaPricerService (它反过来使用 DevPizzaDao )在开发环境中访问和使用pizza pricer算法:

  @main def pizzaPricingMain =
        object PizzaService extends PizzaService
        import PizzaService.*
        import DevPizzaPricerService.*

        // create a pizza
        val p = Pizza(
            Medium,
            Regular,
            Seq(Cheese, Pepperoni, Mushrooms)
        )

        // 确定披萨的价格
        val pizzaPrice = calculatePizzaPrice(p)

        // 打印披萨和它的价格(以非函数式的方式)
        println(s"Pizza: $p")
        println(s"Price: $pizzaPrice")

当你运行该代码时,你将会看到这样的输出:

    Pizza: Pizza(Medium,Regular,List(Cheese, Pepperoni, Mushrooms))
    Price: 11.0

如6.11小节“使用特质创建模块”中所述,这种“接线”技术使用了Scala中的 模块(modules) 概念。这种方法的一个重要好处是,你可以在开发环境中使用 DevPizzaPricerService,在测试中使用 TestPizzaPricerService,在生产中使用 ProductionPizzaPricerService。这种技术是利用Scala语言的特性来创建自己的依赖注入框架。


用不透明类型改进此代码

这段代码至少可以通过在两个地方使用不透明类型而得到改进:

  • 使用字符串来表示邮政编码的地方
  • 在一个字段中使用BigDecimal来表示货币的地方

如果你使用不透明类型,则可以这样引用这些字段:

    case class Address(
        street1: String,
        street2: Option[String],
        city: String,
        state: String,
        postalCode: PostalCode // use PostalCode
                               // instead of String
    )

    // in the PizzaDaoInterface:
    def getToppingPrices(): Map[Topping, Money]

像这样创建自定义类型的好处是:(a)每个人都可以更容易地看到这些字段是什么,(b)你可以添加自定义方法和扩展方法来处理这些类型,如验证邮政编码字段。

为了使这个示例相对简单,我没有做这些改动,但如果你想使用这个技术来创建更有意义的类型,请参阅23.7小节,“用不透明类型创建有意义的类型名称”。


另见

  • 关于本示例中显示的模块技术的更多例子,请参阅6.11小节,“使用特质创建模块”,以及7.7小节,“将特质重新整合为对象”。
  • 这个例子的源代码在本书的源代码库中,地址为 github.com/alvinj/ScalaCookbook2Examples