Skip to content

Latest commit

 

History

History
1507 lines (1039 loc) · 70 KB

24.最佳实践.md

File metadata and controls

1507 lines (1039 loc) · 70 KB

24. 最佳实践

2010年时,当我第一次从Java来到Scala时,我对这些小事很开心,比如删除了很多 ;(){} 字符,使用不啰嗦的语言让我想起了Ruby。我不太了解编程语言的历史,我认为Scala是“具有类型的Ruby”,所有的这些小进步,从而创造了“更好的Java”。

随着时间的推移,我在清单中添加了不少新的内容,并使用了更地道的Scala风格。正如Ward Cunningham在Robert C. Martin(Prentice Hall)的 Clean Code 一书中所引用的那样,我想写出 “让它看起来像是为解决问题而生的语言”的代码,所以我学习了集合类及其方法、for表达式、match表达式和模块化开发。这就是本章的内容:尝试分享Scala编程的一些最佳实践,以便你能以 “Scala方式” 编写代码。

在学习本章每个小节之前,先对Scala最佳实践做些简短总结:

在应用层:

  • 正如Martin Odersky所说,函数用于(编写)逻辑,对象用于模块化。
  • 当你写函数时,尽量把它们写成纯函数。按照80/20法则,把80% 的应用程序写成纯函数,在这些函数之上再加一层薄薄的其他代码,如I/O。就像有人说的那样,这就像在纯FP蛋糕上放了一层薄薄的不纯的糖衣(尽管也有处理这些 ”不纯“代码的方法)。
  • 将行为从类中移到粒度(granular)特质中。我在第六章中描述了这种方法。
  • 使用Scala Future类和Akkahttps://akka.io/ )来实现并发。
  • 当你更加深入FP时,使用像Catshttps://oreil.ly/fg5pO )、Monixhttps://monix.io/ )和ZIOhttps://oreil.ly/TawQ6 )这样的库(它们也支持并发)。

在日常编程层:

  • 学习如何编写纯函数(24.1小节)。至少它们简化了要思考的问题。
  • 与第一点有关,不要写抛出异常的函数。用OptionTryEither等类型来替代返回值。
  • 同样地,不要使用像headtaillast这类抛出异常的方法。
  • 学习如何将函数作为变量来传递(见第10章)。
  • 学习如何使用Scala集合API。了解最常见的类和方法。了解这些方法将使你不至于编写冗长的自定义for循环。
  • 优先考虑不可变代码。首先使用val字段和不可变集合(24.2小节)。
  • 学习面向表达式编程(24.3小节)
  • 函数式编程语言是模式匹配语言,所以要成为match表达式的专家(24.4小节)。
  • 从你的词汇表中删除null关键字(24.5小节)。使用OptionTryEither类型替代(24.6小节)。
  • 模块化组织代码(24.7小节)。
  • 使用测试驱动开发或行为驱动开发的测试工具,如ScalaTesthttps://www.scalatest.org )和MUnithttps://scalameta.org/munit/ ),以及ScalaCheckhttps://scalacheck.org/ )这种基于属性的测试工具。
  • 随着你对Scala越来越熟练,在处理OptionTryEither类型时,学会使用高阶函数来取代match表达式(24.8小节)。

代码之外:

  • 学习如何使用sbt(如第17章所述)或其他自动构建工具。
  • 在编码时可以把REPL打开,以便根据需要运行小型测试(1.1小节“Scala REPL入门”),或者使用Scastiehttps://scastie.scala-lang.org/ )或ScalaFiddlehttps://scalafiddle.io/ )等在线工具。

其他资源

除了本章分享的实践之外,Twitter的“Effective Scala”( https://oreil.ly/fCBTt )是一个很棒的资源。Twitter团队一直是Scala的忠实用户和支持者,这份文档总结了其使用经验。

Scala编程风格指南( https://docs.scala-lang.org/style )也是一个很棒的资源,它包含了使用Scala风格的例子。

24.1 编写纯函数

问题

你想编写纯函数,同时也想了解纯函数的好处。

解决方案

令人惊讶的客观事实是,很难找到一个关于纯函数的明确定义,所以我将给你一个我在Functional Programming, Simplified一书中使用的总纲:

    纯函数是一个只依赖其声明的输入参数和其算法来产生输出的函数。它不从”外部世界“ —— 即函数范围之外的世界 —— 读取任何其他值,也不修改外部世界的任何值。一个纯函数是完全的,这意味着它的结果对每一个可能的输入都有定义,而且它是确定的,这意味着它对一个给定的输入总是返回相同的值。

为了说明如何编写纯函数及其好处,在这个小节中,我将把一个面向对象编程风格的类中的方法转换成纯函数。

面向对象方式

为了简化这个解决方案,下面这个OOP风格的类故意有一些缺陷。它不仅能够存储股票的信息,而且能够访问互联网以获得当前的股票价格,并进一步维护股票的历史价格列表:

    // a poorly written OOP-style class
    class Stock (
        var symbol: String,
        var company: String,
        var price: BigDecimal,
        var volume: Long
    ):
        var html: String = _ // null
        // create a url based on the stock symbol
        def buildUrl(stockSymbol: String): String = ...
        // this method calls out to the internet to get the url content,
        // such as getting a page from yahoo finance or a similar site
        def getUrlContent(url: String): String = ...
        def setPriceFromHtml(html: String): Unit =
        this.price = ...
        def setVolumeFromHtml(html: String): Unit =
        this.volume = ...
        def setHighFromHtml(html: String): Unit =
        this.high = ...
        def setLowFromHtml(html: String): Unit =
        this.low = ...

        // some DAO-like functionality
        private val _history: ArrayBuffer[Stock] = ...
        def getHistory = _history

除了试图在一个地方做太多不同的事,从FP的角度来看,这段代码还有以下这些问题:

  • 所有字段都是可变的。
  • 所有set* 方法都会修改类字段。
  • getHistory方法返回一个可变的数据结构。

getHistory方法很容易解决,在共享ArrayBuffer之前将其转换为像Vector那样的不可变序列,但这个类有更深的问题。让我们来修复它们。

修复问题

第一个解决方法是把耦合在类中的两个概念分开。首先,应该有一个股票的概念,一个股票只包括一个符号和公司名称。你可以把它变成一个样例类:

    case class Stock(symbol: String, company: String)

这方面的例子有 Stock("AAPL", "Apple")Stock("GOOG", "Google")

其次,在任何时刻都有与股票在股市上的表现有关的信息。你可以把这个数据结构称为StockInstance,也可以把它定义为一个样例类:

    case class StockInstance(
        symbol: String,
        datetime: String,
        price: BigDecimal,
        volume: Long
    )

一个StockInstance的例子看起来像这样:

    StockInstance("AAPL", "Mar. 1, 2021 5:00pm", 127.79, 107_183_333)

这涵盖了解决方案的数据部分。现在让我们来看看如何处理这些行为:

行为

回到原来的类,getUrlContent方法并不是专门针对股票的,应该移到一个不同的对象中,比如一个通用的NetworkUtils对象:

    object NetworkUtils:
        def getUrlContent(url: String): String = ???

这个方法接受一个URL作为参数,并返回该URL的HTML内容。

同样地,从股票符号建立一个URL的能力应该被移到一个对象中。因为这种行为是针对股票的,所以把它放在一个名为StockUtils的对象中:

    object StockUtils:
        def buildUrl(stockSymbol: String): String = ???

从HTML中提取股票价格的能力(即屏幕抓取)也可以写成一个纯函数,应该移到同一个对象中去:

    object StockUtils:
        def buildUrl(stockSymbol: String): String = ???
        def getPrice(html: String): String = ???

事实上,前一个类中所有名为 set* 的方法都应该是StockUtils中的 get* 方法:

    object StockUtils:
        def buildUrl(stockSymbol: String): String = ???
        def getPrice(symbol: String, html: String): String = ???
        def getVolume(symbol: String, html: String): String = ???
        def getHigh(symbol: String, html: String): String = ???
        def getLow(symbol: String, html: String): String = ???

getPricegetVolumegetHighgetLow等方法都是纯函数:给定相同的HTML字符串和股票符号,它们总是返回相同的值,而且它们没有副作用。

按照这个思考过程,代码将需要日期和时间函数,所以我把它们放在一个DateUtils对象中:

    object DateUtils:
        def currentDate: String = ???
        def currentTime: String = ???

这些并不是纯函数,但至少它们在一个合理的位置。

通过这个新的设计,你为当前日期和时间创建一个股票的实例,作为一系列简单的表达式。首先,从一个网页上检索描述股票的HTML:

    val stock = Stock("AAPL", "Apple")
    val url = StockUtils.buildUrl(stock.symbol)
    val html = NetworkUtils.getUrlContent(url)

一旦你有了HTML,便可以提取所需的股票信息、日期,并创建 Stock 实例:

    val price = StockUtils.getPrice(html)
    val volume = StockUtils.getVolume(html)
    val high = StockUtils.getHigh(html)
    val low = StockUtils.getLow(html)
    val date = DateUtils.currentDate
    val stockInstance = StockInstance(symbol, date, price, volume, high, low)

注意,所有的变量都是不可变的,每一行都是一个表达式。事实上,现在的代码非常简单,如果需要的话,你可以取消所有的中间变量:

    val html = NetworkUtils.getUrlContent(url)
    val stockInstance = StockInstance(
        symbol,
        DateUtils.currentDate,
        StockUtils.getPrice(html),
        StockUtils.getVolume(html),
        StockUtils.getHigh(html),
        StockUtils.getLow(html)
    )

这种简单性是使用纯函数的一大好处。“输出只取决于输入,没有副作用”是纯函数的一个简单咒语。

如前所述,getPricegetVolumegetHighgetLow等方法都是纯函数。但是像getDate这样的方法呢?它不是一个纯函数,但事实是,你需要日期和时间来解决这个问题。这就是对纯函数有一个健康、平衡的态度的部分含义。

其他处理不纯函数的方法(TODO:鸽子栏)

有一些更强大的方法来处理不纯函数,这些方法将返回OptionTryEither,或者使用外部库,如 Catshttps://oreil.ly/fg5pO ) 或ZIOhttps://oreil.ly/TawQ6 )。然而,这些方法需要很长的时间来介绍。请参考Functional Programming, Simplified了解其中一些方法的细节。

作为对这个例子的最后说明,股票类不需要维护一个可变的股票实例的列表。假设股票信息存储在一个数据库中,你可以创建一个StockDao(“数据访问对象”)来查询数据:

    object StockDao:
        def getStockInstances(symbol: String): Seq[StockInstance] = ???
        // other code ...

虽然getStockInstances不是一个纯函数,但产生的Seq类是不可改变的,所以你可以放心地分享它,而不必担心它可能在你的应用程序中被修改。

鸽子栏TODO get和set不是必须的

虽然我在许多方法的名称中使用了get前缀,但没有必要(也不常见)遵循类似JavaBeans的命名惯例。事实上,部分原因是你在Scala中写setter方法时不以set开头,部分原因是为了遵循统一访问原则( https://oreil.ly/THte4 ),许多Scala API根本不使用getset

比如说,想想Scala类。他们的accessor和mutator不使用getset

    class Person(var name: String)
    val p = Person("William")
    p.name // accessor
    p.name = "Bill" // mutator

StockUtils或者Stock对象?

在前面的例子中被移到StockUtils类中的方法也可以放在Stock类的伴生对象中。也就是说,你可以把股票类和对象放在一个名为Stock.scala的文件中:

    case class Stock(symbol: String, company: String)

    object Stock:
        def buildUrl(stockSymbol: String): String = ???
        def getPrice(symbol: String, html: String): String = ???
        def getVolume(symbol: String, html: String): String = ???
        def getHigh(symbol: String, html: String): String = ???
        def getLow(symbol: String, html: String): String = ???

在这个例子中,我把这些方法放在StockUtils类中,以便清楚地把Stock类和对象的关注点分开。在你自己的实践中,使用你喜欢的任何方法。参见7.5小节了解更多关于伴生对象的细节。

讨论

如果你是从纯OOP背景来到Scala的,那么编写纯函数可能会出乎意料的困难。就我自己而言,在2010年之前,我的代码一直遵循着将数据和行为封装在类中的OOP范式,因此,我的方法几乎总是会改变对象的内部状态。转而使用纯函数和不可变值需要一段时间来适应。

但编写纯函数的一个主要好处是,它自然而然地引导你进入函数式编程风格,你把代码写成代数表达式,然后组合这些表达式来解决更大的问题。这种编码风格的一个好处是,纯函数更容易测试。

例如,试图测试原始代码中的set方法会很难。对于每个字段(price, volume, highlow),你必须遵循这些步骤:

  1. 设置对象中的html字段。
  2. 调用当前的set方法,如setPriceFromHtml
  3. 在内部,该方法读取私有的html类字段。
  4. 当该方法执行时,它改变了类中的一个字段(price)。
  5. 你必须得到那个字段来验证它是否被改变。
  6. 在更复杂的类中,htmlprice字段有可能被类中的其他方法所修改。

原始类的测试代码看起来是这样的:

    val stock = Stock("AAPL", "Apple", 0, 0)
    stock.buildUrl
    val html = stock.getUrlContent
    stock.getPriceFromHtml(html)
    assert(stock.getPrice == 200.0)

这是一个测试有副作用的方法的简单例子,当然,在一个大型的应用程序中,这可能会变得更加复杂。

相比之下,测试一个纯函数更容易:

  1. 调用函数,传入一个已知值。
  2. 从函数中获取一个结果。
  3. 验证结果是否符合你的预期。

测试函数式方法的结果的代码是这样的:

    val url = NetworkUtils.buildUrl("AAPL")
    val html = NetworkUtils.getUrlContent(url)
    val price = StockUtils.getPrice(html)
    assert(price == 200.0)

虽然所显示的代码并没有短多少,但却简单多了。而且正如本书其他例子所示,由于纯函数是表达式,如果你愿意,它们也可以被简化为这种形式:

    val price = getPrice(getUrlContent(buildUrl("AAPL")))
    assert(price == 200.0)

在许多其他情况下,如果你有一系列不依赖于以特定顺序运行的纯表达式,你和编译器都可以自由地并行运行它们。

24.2 使用不可变量和集合

问题

为了使代码更容易编写、阅读、测试和在并行/并发情况下使用,你希望在代码中减少对可变对象和数据结构的使用。

解决方案

Programming in Scala书中所说的这个简单理念开始。“优先选择val字段、不可变对象和没有副作用的方法,先去找它们”。然后在有充分理由的情况下使用其他方法。

“优先不变性”有两个组成部分:

  • 优先选择不可变集合。例如,在使用可变的ArrayBuffer之前,使用像ListVector这样的不可变序列。
  • 优先选择不可变量。也就是说,选择val而不是var

在Java中,可变性是默认的,它可能导致不必要的危险代码和隐藏的bug。在下面的例子中,尽管trustMeMuHaHa方法的List参数被标记为final,但该方法仍然可以改变集合:

    // java
    class EvilMutator {

        // trust me ... mu ha ha (evil laughter)
        public static void trustMeMuHaHa(final List<Person> people) {
            people.clear();
        }
    }

虽然Scala将方法参数视为val字段,但通过传入一个可变集合,如ArrayBuffer,也会面临同样的问题:

    def evilMutator(people: ArrayBuffer[Person]) =
        people.clear()

就像Java代码一样,evilMutator方法可以调用clear,因为ArrayBuffer的内容是可变的。

虽然没有人会故意写这样的恶意代码,但意外确实发生了。为了使你的代码远离这个问题,假如没有理由改变一个序列,就不要使用可变序列类。通过将序列改为SeqListVector,你就可以消除这个问题的可能性。事实上,下面的代码甚至不会被编译:

    def evilMutator(people: Seq[Person]) =
        // ERROR - won’t compile
        people.clear()

因为SeqListVector是不可变序列,任何试图添加或删除元素的行为都会失败。

讨论

使用不可变量(val)和不可变集合,至少有两个主要好处:

  • 它们代表了一种防御性编码的方法,因此你不必担心数据被意外地改变。
  • 它们更容易推理。

解决方案中的例子展示了第一个好处:如果没有必要让其他代码改变你的引用或集合内容,就不要让它们这样做。Scala让这一切变得简单。

第二个好处可以从很多方面来考虑,但我喜欢在使用actor和并发时考虑它:如果使用不可变集合,我可以自由地传递它们,而不需要担心另一个线程会修改集合。

使用val+mutable和var+immutable

当你编写纯FP代码时,所有的东西都是不可变的,但如果不是在写纯FP代码,可以结合这些工具。例如,有些开发者喜欢使用这些组合:

  • 可变集合的字段被声明为val
  • 不可变集合的字段被声明为var

这些方法一般是按下面方式使用:

  • 可变集合字段被声明为val,通常是使类(或方法)变私有。
  • 不可变集合字段在类中被声明为var,更多的时候是公开可见的,也就是说它可以被其他类使用。

作为第一种方式的例子,目前的Akka FSM类(scala.akka.actor.FSM)将几个可变集合字段定义为私有val字段,像这样:

    private val timers = mutable.Map[String, Timer]()

    // some time later ...
    timers -= name
    timers.clear()

这样做是安全的,因为timers字段对这个类来说是私有的,所以它的可变集合不会被共享。

我在一个教学项目中使用的方法是该主题的一个变形:

    enum Topping { case Cheese, Pepperoni, Mushrooms }

    class Pizza:
        private val _toppings = collection.mutable.ArrayBuffer[Topping]()
        def toppings = _toppings.toSeq
        def addTopping(t: Topping): Unit = _toppings += t
        def removeTopping(t: Topping): Unit = _toppings -= t

这段代码将 _toppings定义为一个可变的ArrayBuffer,然后将其作为Pizza类的私有val值。下面是我采用这种方法的理由:

  • _toppings成为一个ArrayBuffer,因为我知道元素(toppings)会经常被添加和删除。
  • _toppings成为一个val,因为它没有必要被重新分配。
  • 让它变成private的,这样访问器就不会在类之外可见。
  • 我创建了toppingsaddToppingremoveTopping方法,让其他代码操作这个集合。
  • 当其他代码调用toppings方法时,给它个toppings的不可变副本。

你对函数式编程了解得越多,就会发现像这样可变的代码是没有必要的。但在你熟练掌握CatsMonixZIO等库之前,这可能是一个有用的折衷办法。

总之,总是以”优先不变性“的方式开始,只有在对当前情况有意义的时候,也就是说,当你的决定是合理的时候,才放宽这一理念。你在函数式编程方面探索越多,你就越不需要接触可变的工具。

24.3 编写表达式(而不是语句)

问题

你习惯于使用另一种编程语言编写语句,并想学习如何在Scala中编写表达式,以及面向表达式编程理念的好处。

解决方案

要理解EOP(Expression-oriented programming language),你必须理解语句和表达式之间的区别。维基百科的EOP页面( https://oreil.ly/vhfVX )对这两者进行了简洁的区分:

     语句不返回结果,执行语句只是为了其副作用,而表达式总是返回一个结果,而且往往根本没有副作用。

所以语句像下面这样:

    order.calculateTaxes()
    order.updatePrices()
    println(s"The product $name costs $price.")

表达式像下面这样:

    val tax = calculateTax(order)
    val price = calculatePrice(order)
    val s = s"The product $name costs $price."

维基百科的EOP页面还指出:

     面向表达式的编程语言是一种编程语言,其中每个(或几乎每个)构造都是一个表达式,因此会产生一个值。

因为纯函数式程序完全是用表达式编写的,所以它进一步指出,所有纯FP语言都是面向表达式的。

一个例子

下面的例子有助于展示EOP。本小节与24.1小节类似,所以它重用了那个小节中的类,用来展示一个初始的OOP风格的设计:

    // an intentionally poor design
    class Stock(
        var symbol: String,
        var company: String,
        var price: String,
        var volume: String,
        var high: String,
        var low: String
    ):
        var html: String = _
        def buildUrl(stockSymbol: String): String = ???
        def getUrlContent(url: String): String = ???
        def setPriceUsingHtml(): Unit = this.price = ???
        def setVolumeUsingHtml(): Unit = this.volume = ???
        def setHighUsingHtml(): Unit = this.high = ???
        def setLowUsingHtml(): Unit = this.low = ???

使用这个类的结果是这样的代码:

    val stock = Stock("GOOG", "Google", "", "", "", "")
    val url = stock.buildUrl(stock.symbol)
    stock.html = stock.getUrlContent(url)

    // a series of calls on an object (i.e, “statements”)
    stock.setPriceUsingHtml()
    stock.setVolumeUsingHtml()
    stock.setHighUsingHtml()
    stock.setLowUsingHtml()

虽然没有实现代码,但所有这些set方法都是从雅虎财经页面下载某只股票,并从HTML中提取数据,然后更新当前对象中的字段。

在前两行之后,这段代码完全不是面向表达式的,它是对一个对象的一系列调用,根据其他内部数据填充(可变)类的字段。这些是语句,而不是表达式,它们不产生值,但它们确实会改变状态。

在24.1小节中,通过将这个类重构为几个不同的组件,其大部分为纯函数,最终会得到下面代码:

    // a series of expressions
    val url = StockUtils.buildUrl(symbol)
    val html = NetworkUtils.getUrlContent(url)
    val price = StockUtils.getPrice(html)
    val volume = StockUtils.getVolume(html)
    val high = StockUtils.getHigh(html)
    val low = StockUtils.getLow(html)
    val date = DateUtils.getDate()
    val stockInstance = StockInstance(symbol, date, price, volume, high, low)

这段代码是面向表达式的。它由一系列简单的表达式组成,这些表达式将值传递给纯函数(除了getDate),每个函数返回一个值,并将其分配给一个变量。这些函数不改变给定的数据,也没有副作用,所以它们很容易阅读,容易推理,也容易测试。因为它们是简单的表达式,它们中的大多数可以按任何顺序运行,甚至可以并行运行。

讨论

在Scala中,大多数表达式都很明显。例如,下面两个表达式都会返回结果,正是你所期望的:

    val x = 2 + 2 // 4
    val xs = List(1,2,3,4,5).filter(_ > 2) // List(3, 4, 5)

然而,对于来自OOP风格语言的人来说,if/else表达式返回一个值可能更让人惊讶:

    val max = if a > b then a else b

match表达式也会返回一个结果:

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

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

    val result = try
        "1".toInt
    catch
        case _ => 0

编写这样的表达式是函数式编程语言的一个特点,Scala让使用这些表达式的感觉很自然和直观,而且它们还能产生简洁和富有表现力的代码。

好处

因为表达式总是返回一个结果,一般也没有副作用,所以EOP有几个好处:

  • 纯函数更容易推理。无论输入,还是返回结果,而且没有副作用。你不必担心刚刚在应用程序的其他地方改变了状态。
  • 我在24.1小节展示了纯函数更容易测试。
  • 结合Scala的语法,EOP可以产生简洁的、富有表现力的代码。
  • 虽然在这些例子中提了一下,但表达式通常可以按任何顺序执行。这个巧妙的特质可以并行地执行表达式,当你试图利用多核CPU的优势时,这会非常有用。

另见

24.4 使用match表达式和模式匹配

问题

模式匹配是Scala编程语言的一个主要特性,你想看看在不同情况下如何使用它的例子。

解决方案

match表达式 —— match/case —— 以及模式匹配经常出现在Scala代码中。如果你是从Java来到Scala的,那么match表达式最明显的用途是:

  • 取代Java switch语句。
  • 取代笨重的if/then语句。

模式匹配非常普遍,以至于你会在更多的情况看到match表达式的使用:

  • 作为函数体。
  • 处理Option变量。

case也使用在:

  • try/catch表达式。
  • Akka Classic actor的receive方法。

下面的例子展示了这些技术。

取代Java的switch语句和笨重的if/then语句

在4.7小节”用一个case语句匹配多个条件“中,展示了match表达式可以像Java switch语句一样使用:

    val month = i match
        case 1 => "January"
        // the rest of the months here ...
        case 12 => "December"
        case _ => "Invalid month" // the default, catch-all case

它可以用同样的方式来取代笨重的if/then/else语句:

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

这些虽然是match表达式的简单用法,但却是一个好的开始。

作为函数体或者方法体

随着你对匹配表达式的熟悉,你将使用它们作为函数体,例如这个函数使用Perl对“true”的定义,判定它所给的值是否为真:

    def isTrue(a: Matchable): Boolean = a match
        case false | 0 | "" => false
        case _ => true

见4.8小节“将match表达式的结果分配给变量”,可以找到更多像这样的例子。

和Option变量一起使用

match表达式对Option/Some/None类型有很好的效果。例如给定这个方法makeInt,它返回Option

    import scala.util.control.Exception.allCatch
    def makeInt(s: String): Option[Int] = allCatch.opt(s.trim.toInt)

你可以用match表达式来处理makeInt的结果:

    makeInt(aString) match
        case Some(i) => println(i)
        case None => println("Error: Could not convert String to Int.")

类似的方式,match表达式是用Play框架( https://www.playframework.com/ )处理表单验证的一种流行方式:

    verifying("If age is given, it must be greater than zero",
        model =>
            model.age match {
                case Some(age) => age > 0
                case None => false
            }
    )

你也可以对Option使用reduce方法来处理这样的情况。例如,这两个例子可以这样写:

    // first example
    makeInt(aString).fold(println("Error..."))(println)

    // second example
    makeInt(aString).fold(false)(_ > 0)

更多细节和处理这些情况的其他方法见24.6小节。

在try/catch表达式中

Case语句也被用于 try/catch 表达式中。下面的例子展示了如何编写一个 try/catch 表达式,当从文件中成功读出几行时返回一个Option,如果在读文件过程中出现异常则返回None

    def readTextFile(filename: String): Option[List[String]] =
        try
            Some(Source.fromFile(filename).getLines.toList)
        catch
            case ioe: IOException =>
                None
            case fnf: FileNotFoundException =>
                None

如果在这种情况下具体的错误很重要,请使用Either/Left/RightTry/Success/Failure类,这样你就可以把失败信息返回给调用者。更多细节参考24.6小节。

在Akka actor中

如果你使用原始的Akka Classic "untyped"库,那么使用case语句是有帮助的,因为它们被Akka actors当作处理传入消息的主要方式:

    class SarahsBrain extends Actor {
        def receive = {
            case StartMessage => handleStartMessage()
            case StopMessage => handleStopMessage()
            case _ => log.info("Got something unexpected.")
        }

        // other code here ...
    }

另见

  • match表达式在第四章有许多例子。
  • 正如24.8小节所描述的,可以使用高阶函数来处理OptionEitherTry值。

24.5 消除代码中的null值

问题

为了与现代最佳实践保持一致,你想从代码中消除null值。

解决方案

Beginning Scala第一版的作者、Lift框架( https://liftweb.net/ )的创建者David Pollak,提出了一个关于null值的简单规则:

    任何时候,禁止任何代码中使用null

尽管我在本书中使用了null值,是为了让一些例子更容易理解,但我在实践中不再使用了。我想象没有null值这回事,并以其他方式编码。

有些常见的情况,你可能会忍不住使用null值,所以本节展示了,如何在这些情况下不使用null值:

  • 当类或方法中的var字段没有初始默认值时,将其声明为一个Option
  • 当函数没有产生预期的结果时,你可能会想返回null。使用OptionTryEither来代替。
  • 如果你正在使用一个返回null的Java库,将其转换为一个Option或其他。

让我们来看看这些技术。

用Option来初始化var字段,而不是null

使用null值最诱人的时候是当一个类或方法中的一个字段不会被立即初始化时。例如,想象一下,你正在为下一个伟大的社交网络应用编写代码。为了鼓励人们注册,在注册过程中,你要求的唯一信息是电子邮件地址和密码。因此其他的东西都是可选的,当你用OOP风格写代码时,你可能会想写一些这样的代码:

    case class Address(city: String, state: String, zip: String)

    class User(var email: String, var password: String):
        var firstName: String = _
        var lastName: String = _
        var address: Address = _

User类不好,因为firstNamelastNameaddress都被声明为null,如果在访问它们之前不对它们进行赋值,会给应用程序带来问题。更好的方法是将每个可选字段定义为Option

    class User(var email: String, var password: String):
        var firstName = None: Option[String]
        var lastName = None: Option[String]
        var address = None: Option[Address]

现在你可以这样创建User

    val u = User("al@example.com", "secret")

然后在未来的某个时间点,可以像这样分配其他的值:

    u.firstName = Some("Al")
    u.lastName = Some("Alexander")
    u.address = Some(Address("Talkeetna", "AK", "99676"))

然后在代码中你可以这样访问字段:

    u.address.foreach { a =>
        println(a.city)
        println(a.state)
        println(a.zip)
    }

或这样:

    println(firstName.getOrElse("<not assigned>"))

在这两种情况下,如果值被分配了,它们就会被打印出来。在第一个例子中,如果地址是 None,那么 foreach 循环就不会被执行,所以打印语句永远不会到达。这是因为一个 Option 可以被认为是拥有零个或一个元素的集合。如果值是 None,它就有零个元素,如果是 Some,它就有一个元素。在 getOrElse 例子中,如果值没有被分配,就会打印出字符串

与此相关的是,当一个字段是可选的时候,你也应该在构造函数中使用Option

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

不要从方法中返回null

因为你不应该在代码中使用null,从函数中返回null值的规则很简单:不要这样做。

如果你不能返回null,你能返回什么?返回Option。或者如果你需要知道方法中可能发生的错误,使用TryEither而不是Option

有了Option,你的函数签名应该是这样的:

    def doSomething: Option[String] = ???
    def makeInt(s: String): Option[Int] = ???
    def lookupPerson(name: String): Option[Person] = ???        

例如,当读取一个文件时,如果过程失败,函数可以返回null,但这段代码显示了如何读取一个文件并返回一个Option

    def readTextFile(filename: String): Option[List[String]] =
        try
            Some(io.Source.fromFile(filename).getLines.toList)
        catch
            case e: Exception => None

如果能找到并读取文件,该方法返回了一个包装在Some中的 List[String] 。如果发生异常,返回None。如果你想要错误信息,请使用Try/Success/Failure类(或Either/Right/Left)而不是Option/Some/None

    import scala.util.{Try, Success, Failure}

    def readTextFile(filename: String): Try[List[String]] =
        Try(io.Source.fromFile(filename).getLines.toList)

如果文件可以被读取,这段代码会以Success包装的 List[String] 的形式返回文件的行数,如果出现问题,则会以Failure的形式返回异常:

    java.io.FileNotFoundException: Foo.bar (No such file or directory)

作为警告(和平衡),Twitter的Effective Scala页面建议不要过度使用Option,并在有意义的地方使用 null 对象模式( https://oreil.ly/FceWj )。通常你需要自己判断,但要尝试用这些方法来消除所有的null值。

乌鸦图 -- TODO null对象

null 对象是一个继承了基类型的对象,其行为为空或中性。下面是维基百科关于空对象的Java例子的Scala实现( https://oreil.ly/FceWj ):

    trait Animal:
        def makeSound(): Unit

    class Dog extends Animal:
        def makeSound(): Unit = println("woof")

    class NullAnimal extends Animal:
        def makeSound(): Unit = () // just returns Unit

NullAnimal类中的makeSound方法有一个中立的、“什么都不做”的行为。使用这种方法,一个定义为返回动物的方法可以返回NullAnimal而不是null

关于为什么永远不要从函数中返回null的更多细节,参考我的博客“Pure Function Signatures Tell All”( https://oreil.ly/MurlR )。

将null转换成Option或者其他

你会遇到null值的第三个主要地方是在处理遗留的Java代码时。这里没有什么神奇的公式,除了捕获null值并从你的代码中返回其他东西。这可能是一个Option,一个空对象,一个空列表,或者其他任何适合这个问题的东西。

例如,下面的getName方法转换了一个结果可能返回null的Java方法,并返回一个 Option[String] 来代替:

    def getName(): Option[String] =
        val name = javaPerson.getName()
        if name == null then None else Some(name)

讨论

早在1965年,ALGOL W编程语言的null reference的发明者Tony Hoare将null值的产生称为他的“十亿美元的错误”( https://oreil.ly/aBTrA )。像Java这样的语言最初是通过使用try/catch结构来处理null引用和NullPointerException,但是像Scala这样的现代语言使用其他技术来完全消除它们。

代码中消除null值会带来这些好处:

  • 完全消除了一类的错误:空指针异常。
  • 你永远不用担心,“如果出了问题,这个方法会不会返回null值?”。
  • 你不必写if语句来检查null值。
  • 在方法中添加一个 Option[T] 的返回类型声明是一个很好的方式,可以表明在这个方法中发生了一些事情,这样调用者可能会收到一个None而不是一个Some[T] 。使用OptionTryEither是一种更好的方式,而不是从返回对象的方法中返回null
  • 你会更加习惯使用OptionTryEither,因此你将能够利用它们在集合库和其他框架中的使用方式。

Scala 3中的显式空值 -- TODO 乌鸦图

在写这篇文章的时候,Scala 3.0.0包括一个名为Explicit Nulls的编译器功能。当你启用这个功能时,它会改变Scala的类型层次,使Null只是Any的一个子类型,而不是每个引用类型的一个子类型。因此,所有的引用类型——即任何扩展AnyRef的类型,如StringList,以及你的自定义类型(如User)——都是非空的。

可以用scalac这个选项启用这个功能:

    -Yexplicit-nulls

当启用这个选项时,下面的代码就无法编译了:

    val s: String = null

关于如何工作的更多细节,参考Explicit Nulls( https://oreil.ly/ZWgZw )。

另见

24.6 使用Scala的错误处理类型(Option、Try和Either)

问题

出于各种原因,包括从你的代码中移除null值,你想有效地使用Option/Some/NoneTry/Success/FailureEither/Left/Right类。

解决方案

本小节与24.5小节有一些重叠。本小节展示了在以下情况下,使用Option而不是null

  • 在函数和构造函数参数中使用Option
  • 使用Option来初始化类的字段(而不是使用null)。
  • 将其他代码(如Java代码)中的null结果转换为Option
  • 从方法中返回Option

24.5小节展示了在特定场景下如何使用Option。本节新加了这些解决方案:

  • Option中提取值。
  • 在集合中使用Option
  • 在框架中使用Option
  • 需要处理异常消息时,使用Try/Success/Failure
  • 需要处理异常消息时,使用Either/Left/Right

从Option中提取值

这里有两种方法来定义makeInt函数,它可以捕获到toInt可能抛出的异常并返回一个Option

    import scala.util.control.Exception.allCatch
    def makeInt(s: String): Option[Int] = allCatch.opt(s.trim.toInt)

    import scala.util.{Try, Success, Failure}
    def makeInt(s: String): Option[Int] = Try(s.trim.toInt).toOption

作为一个返回 Option 的方法的消费者,有几种很好的方法来调用它并访问其结果:

  • 使用match表达式。
  • 使用forEach
  • 使用getOrElse
  • 使用高阶函数(HOF)。

根据你的需要,一个好的方式访问makeInt结果是使用match表达式。可以从match表达式中返回一个值:

    makeInt(aString) match
        case Some(i) => println(i)
        case None => println(0)

因为你可以把Option看成是有零个或一个元素的集合,所以foreach方法在这种情况下使用,把结果作为副作用来处理:

    makeInt(aString).foreach{ i =>
        println(s"Got an int: $i")
    }

如果makeInt返回的是Some,这个例子就会打印这个值,但如果makeInt返回的是None,就会绕过println语句。

要(a)在方法成功时提取值,或(b)在方法失败时使用一个默认值,使用getOrElse

    val x = makeInt("1").getOrElse(0) // 1
    val y = makeInt("A").getOrElse(0) // 0

你也可以使用Option中提供的HOF,如24.8小节所示。

在Scala集合中使用Option

Option的另一大特点是它在Scala集合中经常被使用。例如,从一个字符串列表开始:

    val possibleNums = List("1", "2", "foo", "3", "bar")

试想你要从该字符串列表中转换所有整数的列表。通过将makeInt方法传递给map方法,你可以把集合中的每个元素转换为一个SomeNone值:

    scala> possibleNums.map(makeInt)
    res0: List[Option[Int]] = List(Some(1), Some(2), None, Some(3), None)

这是个不错的开始。正如“flatten Seq[Option] ”所示,由于 Option 可以被认为包含零个或一个元素的集合,你可以通过在 map 后面添加 flatten 来将这个 List[Option[Int]] 值转换为 List[Int]

    val a = possibleNums.map(makeInt).flatten // a: List[Int] = List(1, 2, 3)

如13.6小节“用flatten对列表进行扁平化处理”所示,这与使用flatMap类似:

    val a = possibleNums.flatMap(makeInt)     // a: List[Int] = List(1, 2, 3)

collect方法提供了另一种方法来实现同样的结果:

    scala> possibleNums.map(makeInt).collect{case Some(i) => i}
    res0: List[Int] = List(1, 2, 3)

这个例子是有效的,因为collect方法需要一个偏函数,在这种情况下,我传入的匿名函数只为Some值定义,它忽略了None值。(参见10.7小节,“创建偏函数”,了解更多关于collect方法的细节)。

这些例子都是有效的,有几个原因:

  • makeInt被定义为返回一个Option,尤其是一个Option[Int]
  • flattenflatMapcollect这些集合方法都是为了处理Option值而建立的。
  • 你可以将方法、函数和匿名函数传入到集合类的方法中。

在框架中使用Option

当您使用第三方Scala库时,会发现 Option 常用于处理可选变量的情况。例如,它们被用到PlayFramework( https://www.playframework.com )的Anorm数据库库中,你可以为可能为空的数据库表字段使用 Option 值。下面的例子展示了如何使用Anorm编写SQL SELECT语句。这里,数据库中的第三个字段可能为空,因此我在collect方法的match中使用SomeNone值:

    def getAll() : List[Stock] =
        DB.withConnection { implicit connection =>
        sqlQuery().collect {
            // use Some when the 'company' field has a value
            case Row(id: Int, symbol: String, Some(company: String)) =>
                    Stock(id, symbol, Some(company))
            // use None when the 'company' field does not have a value
            case Row(id: Int, symbol: String, None) =>
                    Stock(id, symbol, None)
        }.toList
    }

Option还广泛应用于Play Framework的验证方法中。在下面例子中,model.age 是一个 Option[Int]

    verifying("If age is given, it must be greater than zero",
        model => model.age match
            case Some(age) => age < 0
            case None => true
    )

scala.util.control.Exception.allCatch (TODO:鸽子图)

scala.util.control.Exception对象为你提供了另一种使用OptionTryEither的方法。例如,你可以从方法中删除try/catch块,并将其替换为allCatch

    import scala.util.control.Exception.allCatch
    import scala.io.Source

    def readTextFile(f: String): Option[List[String]] =
        allCatch.opt(Source.fromFile(f).getLines.toList)

allCatch是一个能捕获所有内容的Catch对象。如果捕获到异常(如FileNotFoundException),opt方法将返回None,如果代码块成功,则返回Some。其他allCatch方法也支持TryEither的方式。

使用Try或Either获取错误信息

当你想使用Option/Some/None方法,但又想写个在失败情况下返回错误信息的方法,有两套类似的错误处理类:

  • TrySuccessFailure
  • EitherLeftRight

在本节中,我将展示Try/Success/Failure类。

TryOption类似,但它在一个Failure对象中返回异常信息,而None不会给你这些信息。用Try包装的计算结果将是下面一个类的子类:

  • Success (类似于Some)。
  • Failure(类似于None)。

如果计算成功,将返回Success实例,并包含想要的结果。如果抛出了异常,将返回Failure,并包含关于失败的信息。

为了说明这点,首先导入这些类然后创建一个测试函数:

    import scala.util.{Try,Success,Failure}
    def divideXByY(x: Int, y: Int): Try[Int] = Try(x/y)

只要y不为零,这个函数就会返回成功的结果。当y为零时,会抛出一个ArithmeticException异常。然而这个异常并没有在方法上抛出,而是被Try捕获,Try从方法中返回一个Failure对象。REPL展示了成功和失败案例是如何运行的:

    scala> divideXByY(1,1)
    res0: scala.util.Try[Int] = Success(1)

    scala> divideXByY(1,0)
    res1: scala.util.Try[Int] = Failure(java.lang.ArithmeticException: / by zero)

Option一样,你可以使用match表达式、foreachgetOrElse或者24.8小节中的HOF来访问Try结果。例如,获取Failure中信息的一种方法是使用match表达式:

    divideXByY(1, 1) match
        case Success(i) => println(s"Success, value is: $i")
        case Failure(s) => println(s"Failed, message is: $s")

Option一样,foreach可以很好地处理打印等副作用:

    divideXByY(1, 1).foreach(println) // prints 1
    divideXByY(1, 0).foreach(println) // no output is printed

如果你不关心错误信息,只想得到结果,可以使用getOrElse

    val x = divideXByY(1, 1).getOrElse(0) // x: 1
    val y = divideXByY(1, 0).getOrElse(0) // y: 0

有了Try类,你可以把各种操作连在一起,在操作过程中捕捉异常。例如,下面的代码不会抛出异常,无论xy的实际值是什么:

    // 'x' and 'y' are String values
    val z = for
        a <- Try(x.toInt)
        b <- Try(y.toInt)
    yield
        a * b

    val answer = z.getOrElse(0) * 2

如果xy"1""2" 这样的String值,这段代码按预期工作,answer是一个Int值。如果xy是一个不能转换为Int的字符串,z将会是一个Failure值:

    z: scala.util.Try[Int] =
        Failure(java.lang.NumberFormatException: For input string: "one")

如果xynullz将是这个值:

    z: scala.util.Try[Int] = Failure(java.lang.NumberFormatException: null)

无论是哪种Failure的情况,代码都会优雅地处理这些情况。

讨论

你也可以使用EitherLeftRight类来代替OptionTry。这段代码显示了两种编写divideXByY的方法,同时返回一个Either类型:

    // 1st approach
    import scala.util.control.Exception.allCatch
    def divideXByY(x: Int, y: Int): Either[Throwable, Int] = allCatch.either(x/y)

    // 2nd approach
    import scala.util.{Try,Success,Failure}
    def divideXByY(x: Int, y: Int): Either[Throwable, Int] = Try(x/y).toEither

Either的类型签名所示,声明了方法返回的两种类型。按照惯例,Left类型包含你想返回的失败信息,而Right类型包含成功值。

EitherTry更灵活,因为它更通用。它实际上只是包含两种可能类型之一的值(技术上称为disjoint union)。你可以从它的类型签名中看到,它是一个包含两种可能类型的容器,AB

    Either[+A, +B]

当用于错误处理时,Left通常包含一个表示错误的ThrowableString,但由于Either实际上只是两个类型的容器,它可以用于任何东西。这段代码显示了使用Either编写makeInt方法的一种方式,其中左边的值现在是一个String

    def makeInt(s: String): Either[String, Int] =
        try
            Right(s.trim.toInt)
        catch
            case e: Exception => Left(e.getMessage)

这些例子展示了调用makeInt可能产生的两种结果:

    makeInt("1") // Right(1)
    makeInt("a") // Left(For input string: "a")

Eitherright-biased,这意味着Right是默认情况下的操作,所以像map这样的方法在默认情况下工作:

    makeInt("1").map(_ * 2) // Right(2)
    makeInt("a").map(_ * 2) // Left(For input string: "a")

正因为如此,Eitherfor表达式中工作地很好:

    val x =
        for
            a <- makeInt("1")
            b <- makeInt("2")
        yield
            a + b
    // result: x == Right(3)

match表达式的使用方式就像在OptionTry一样:

    makeInt(aString) match
        case Right(x) => println(s"Success, x = $x")
        case Left(s) => println(s"Failure, message = $s")

使用Either是为使用FP库做准备 -- TODO 耗子栏

在审阅本章时,Hermann Hueck指出,使用Either的两个好处是:(a)它比Try更灵活,因为可以控制错误类型,(b)它可以让你提前准备好使用Cats( https://typelevel.org/cats/ )和ZIO( https://zio.dev/ )等FP库,它们大量使用了Either和类似的方法。

不要使用get方法

当你第一次接触Scala时,你可能会用get方法来获取结果:

    val x = makeInt("5").get  // x: 5

但不要这样做。这并不比NullPointerException好:

    val x = makeInt("foo").get // java.util.NoSuchElementException: None.get

最好的做法是不要对一个Option调用get,而是使用match表达式、foreachgetOrElse 或者24.8小节中的HOF。和null值一样,我发现最好是想象get方法不存在。

其他方法

这些类型的另一个明显特点是它们所支持的方法。例如OptionTryEither有这些共同的方法:

  • 类似集合的方法,如flatMapflattenfoldforeachmap
  • 访问封闭值的方法,如getOrElseorElse

OptionTry有这些额外的共同方法:

  • collectfilter

Option有这些额外的类似集合的方法:

  • containsemptyexistsforallisDefinedisEmptynonEmptyreducetaketakeWhile

Try有额外的方法来帮助从错误中恢复:

  • recoverrecoverWithtransform,它们让你优雅地处理SuccessFailure的结果。

Either有这些额外方法:

  • containsfilterOrElseforall以及更普遍的,额外的left/right操作方法,如joinLeftjoinRightleftswap

另见

24.7 构建模块化系统

问题

你熟悉Martin Odersky的观点,即Scala开发者应该使用“逻辑用函数,模块化用对象”,所以你想知道如何在Scala中构建模块。

解决方案

要理解这个解决方案,必须要理解模块的概念。Programming in Scala一书将模块描述为“‘一小段程序’,有一个定义明确的接口和一个隐藏的实现”。更重要的是,它增加了这样的讨论:

     促进这种模块化的技术需要提供一些基本要素。首先,应该有一个模块结构,能够很好地分离接口和实现。其次,应该有一种方法可以用另一个具有相同接口的模块替换一个模块,而不需要改变或重新编译依赖于被替换模块的那些模块。最后,应该有一种方法可以将模块连接在一起。这项组装任务由configuring the system完成。

关于这三点,Scala提供了这些解决方案:

  • 继承与特质、类和对象的混合,提供了良好的接口和实现分离。
  • 继承还提供了一种机制,使一个模块可以替换另一个模块。
  • 从特质中创建对象(具体化它们)提供了一种将模块连接起来的方法。

采用模块化方式,可以这样写代码:

    trait Database { ... }
    object MockDatabase extends Database { ... }
    object TestDatabase extends Database { ... }
    object ProductionDatabase extends Database { ... }

使用这种方法,你在基本的Database特质中定义了所需的方法签名——接口,并实现了一些行为。然后为你的DevTestProduction环境创建三个对象。实际的实现可能比这更复杂一些,但这是基本的想法。

在Scala中使用模块化编程是这样的:

  • 思考你的问题,并创建一个或多个基本特质来建模问题边界。
  • 在更多的特质中用纯函数实现接口,这些特质继承了基本特质。
  • 根据需要将这些特质组合在一起,形成其他特质。
  • 在必要时,从这些特质中创建对象。

一个例子

下面是这个技术的一个例子。想象一下,你想为一只狗定义行为,比方说一只爱尔兰猎犬。做到这样的方法是直接创建一个IrishSetter类:

    class IrishSetter { ... }

这通常是个坏想法。一个更好的想法是思考不同类型狗的行为接口,然后在你准备好创建一个爱尔兰猎犬时进行具体的实现。

例如,最初的想法狗是一种动物:

    trait Animal

更具体地说,狗是一种有尾巴的动物,而且尾巴有颜色:

    import java.awt.Color
    abstract class AnimalWithTail(tailColor: Color) extends Animal

接下来,你可能会想,“既然狗有尾巴,那么尾巴可以有什么样的行为?“ 有了这种想法,你就会勾勒出这样的特质:

    trait DogTailServices:
        def wagTail = ???
        def lowerTail = ???
        def raiseTail = ???

接下来,因为你只想让这个特质混入类中,并继承AnimalWithTail,此时需要给这个特质添加一个自我类型(self-type):

    trait DogTailServices:
        // implementers must be a sub-type of AnimalWithTail
        this: AnimalWithTail =>

        def wagTail = ???
        def lowerTail = ???
        def raiseTail = ???

正如6.6小节“限定特质只可用于指定类型的子类“所说的,这一行看起来很特别,它声明了一个自我类型:

    this: AnimalWithTail =>

这个自我类型意味着,“这个特质只能混入其他继承AnimalWithTail的特质、类和对象中”。试图将其混入其他类型,会导致编译器错误。

为了保持这个例子的简单性,我将继续在DogTailServices特质中实现这些功能(服务),如下:

    trait DogTailServices:
        this: AnimalWithTail =>
        def wagTail() = println("wagging tail")
        def lowerTail() = println("lowering tail")
        def raiseTail() = println("raising tail")

接下来,当我对狗有更多的思考时,我知道它有嘴,所以我又勾勒出这样一个特质:

    trait DogMouthServices:
        this: AnimalWithTail =>
        def bark() = println("bark!")
        def lick() = println("licking")

我可以一直这样下去,但希望你能明白这个想法:要思考与一个领域对象(如狗)相关的服务——行为或功能,然后将这些服务勾勒成具有逻辑组织特质的纯函数。

不要陷入困境 -- TODO 耗子图

当涉及到设计特质时,仅从你最好的想法开始,然后随着你的思路会变得更加清晰,从而重新组织它们。举个例子,随着设计者对问题的深入理解,Scala的集合类已经被重新设计了好几次。

现在我将停止定义新的和狗有关的行为,并创建一个模块,作为迄今为止所定义爱尔兰猎犬服务的实现:

    object IrishSetter extends
        AnimalWithTail(Color.red),
        DogTailServices,
        DogMouthServices

打开REPL并导入必要的Color类:

    scala> import java.awt.Color
    import java.awt.Color

然后将这些特质导入REPL(这里没有显示),会看到可以通过IrishSetter调用这些函数/服务:

    scala> IrishSetter.wagTail()
    wagging tail

    scala> IrishSetter.bark()
    bark!

虽然这是一个相对简单的例子,但它展示了Scala中使用模块化编程的常见过程。

关于服务

service这个名字来自于这样一个事实,即这些函数提供了一系列的公共服务,这些服务对程序员来说基本上是可用的。尽管可以是任何名字,但我发现,当把这些函数想象为实现了一系列网络服务的调用会比较有意义。例如,当你使用 Twitter的REST API来编写一个Twitter客户端时,API中提供给你的功能被认为是一系列的网络服务。

讨论

采用模块化编程方法的原因在Programming in Scala中有描述:

     随着程序规模的增长,以模块化的方式组织程序变得越来越重要。首先,能够分别编译组成系统的不同模块有助于不同的团队独立工作。此外,能够插拔一个模块的实现并插入另一个模块是很有用的,因为它允许系统的不同配置在不同的情况下使用,例如开发人员可以在桌面上进行单元测试,集成测试,预发和部署。

关于第一点,在函数式编程中能够说:"嘿,A组,你负责Order函数,B组负责Pizza函数,怎么样?"

关于第二点,一个很好的例子是,你可能在开发环境中使用一个mock数据库,然后在测试和生产环境中使用真实的数据库。在这种情况下,你会创建像这样的特质:

    trait Database { ... }
    object MockDatabase extends Database { ... }
    object TestDatabase extends Database { ... }
    object ProductionDatabase extends Database { ... }

这个例子的详细演变在Programming in Scalahttps://oreil.ly/cMeZQ )的第27章中有展示。

24.8 用高阶函数处理Option值

问题

当使用match表达式时处理Option值很不错,但有些冗长。你可以使用高阶函数处理Option值,这是更高级和简洁的方式。

解决方案

这个小节展示了在不同情况下处理Option值的高级方法,特别是如何使用高阶函数来取代match表达式,后者非常易读,但可能很冗长。其中的一些例子也适用于TryEither类型。

样本数据

表24-1中展示了高级的HOF(Higher Order Function)技术。这个表格表达的是:与其使用第二列中match表达式,不如使用第三列中的更简洁的HOF

该表按照每个match表达式的结果类型进行排序,这就是你在试图解决问题时的想法,例如,“我需要打印Some中的值,我知道这是一个带副作用返回的Unit,我该怎么做?“ 在这种情况下,可以查看表格,并在第一行找到解决方案。所以你可以使用第二列中的match表达式,或者第三列中的选项。同样,当你想从Some中提取值时,使用第二行和第三行所示的解决方案,或者使用default值;在这两种解决方案中,结果的类型均为A

作为这些解决方案的开始,这里有一些将在表格中使用的函数和值:

    // functions
    def p(i: Int): Boolean = i == 1           // type: A => Boolean (a predicate)
    def f(i: Int): Int = i * 2                // type: A => A
    def fo(i: Int): Option[Int] = Some(i * 2) // type: A => Option[A]

    // values
    val option: Option[Int] = Some(1)
    val none: Option[Int] = None

    val default = 0
    val defaultSome = Some(0)
    val stringOption = Option("foo")

在这个代码中:

  • pInt => Boolean 类型的谓词(或者更一般地说,A => Boolean)。
  • fInt => Int 类型的函数(或者更一般地说,A => A)。
  • fo是返回一个Option的函数(所以fo的类型签名是A => Option[A])。

关于表24-1中的例子:

  • 因为你知道在特定情况下使用Option时需要的结果类型,所以表格是按照表达式的返回类型进行排序的,在第一列有所展示。
  • 虽然我的代码使用的是Int值,但除了一个例子之外,你可以把表达式看成是使用了一个通用类型A

考虑到这些背景,表24-1展示了一些例子,其中有相对较长的match表达和其对应的HOFs

表24-1 Match表达式和它们等价的HOFs

结果类型 匹配表达式 HOF
Unit // use match for a side effect
option match
    case Some(i) => println(i)
    case None => ()
option.foreach(println)

// or this:
for o <- option do println(o)
A option match
    case Some(i) => i
    case None => default
option.getOrElse(default)

option.fold(default)(x => x)
A // apply a function to the
// option value
option match
    case Some(i) => f(i)
    case None => default
option.map(f)
.getOrElse(default)

option.fold(default)(f)
Option[A] option match
    case Some(i) => fo(i)
    case None => None
option.map(f)

option.flatMap(i => fo(i))

for i <- option yield f(i)
Option[A] option match
    case Some(x) => Some(x)
    case None => defaultSome
option.orElse(defaultSome)
Option[A] option match
    case Some(x) if p(x) =>
            Some(x)
    case _ => None
option.filter(p)
option.find(p)
Option[A] option match
    case Some(x) if !p(x) =>
            Some(x)
    case None => None
option.filterNot(p)
Boolean option match
    case Some(x) => p(x)
    case None => true
option.forall(p)
Boolean option match
    case Some(x) => p(x)
    case None => false
option.exists(p)
Boolean option match
    case Some(a) => false
    case None => true
option.isEmpty
Boolean option match
    case Some(x) => true
    case None => false
option.isDefined
option.nonEmpty
结果类型 匹配表达式 HOF
Boolean // the example uses 'x == 1'
// because I use Option[Int]
option match
    case Some(x) => x == 1
    case None => false
option.contains(1)
Int option match
    case Some(x) => 1
    case None => 0
option.size
Int option match
    case Some(x) if p(x) => 1
    case _ => 0
option.count(p)
Seq, List, etc. option match
    case Some(x) => Seq(x)
    case None => Nil
option.toSeq
option.toList
(also toVector, toArray,
toSet, etc.)
Either[Int,Int] = Right option match
    case Some(x) => Right(x)
    case None => Left(default)
option.toRight(default)
Either[Int,Int] = Left option match
    case Some(x) => Left(x)
    case None => Right(default)
option.toLeft(default)
A or null stringOption match
    case Some(x) => x
    case None => null
// only use this for Java APIs
// that need it
stringOption.orNull

注意,我把null的例子放在最后一行,因为你永远不该使用它。除非与一个需要它的Java API进行交互。

讨论

下面是一些例子,说明表24-1第三列的HOF是如何工作的:

    option.fold(default)(f)             // 2
    none.fold(default)(f)               // 0

    option.map(f).getOrElse(default)    // 2
    none.map(f).getOrElse(default)      // 0

    option.flatMap(i => fo(i))           // Some(2)
    none.flatMap(i => fo(i))             // None

    option.orElse(defaultSome)          // Some(1)
    none.orElse(defaultSome)            // Some(0)

    option.forall(p)                    // true

    option.find(p)                       // Some(1)
    option.filter(p)                     // Some(1)

    option.toSeq                        // Seq[Int] = List(1)
    none.toSeq                          // Seq[Int] = List()

有些例子如fold,乍一看可能有点难以理解,但是如果你仔细想想,它的使用与序列是一样的,fold需要一个初始种子值和一个fold函数。因为一个 Option 可以被认为是零个元素或一个元素的集合,种子值在 OptionNone 的情况下作为默认值,而你提供的函数在 OptionSome 的情况下被使用。我认为代码在以后也能读懂。代码的可维护性——是非常重要的,所以我建议只使用你认为合适的代码。

另见

  • 关于这些技术的更多例子,请观看Marconi Lanna在LambdaConf 2015视频( https://oreil.ly/AScOC ),它有助于更好理解表格中的最后几个例子。