Scala 3 — 书籍

FP 建模

语言

本章介绍了在 Scala 3 中使用函数式编程 (FP) 进行领域建模。使用 FP 对我们周围的世界进行建模时,你通常会使用以下 Scala 构造

  • 枚举
  • 案例类
  • 特质

如果你不熟悉代数数据类型 (ADT) 及其广义版本 (GADT),你可能需要在阅读本节之前阅读 代数数据类型 部分。

简介

在 FP 中,数据对该数据的操作是两件独立的事情;你无需像在 OOP 中那样强制将它们封装在一起。

这个概念类似于数值代数。当你考虑大于或等于零的整数时,你有一个可能的集,如下所示

0, 1, 2 ... Int.MaxValue

忽略整数的除法,对这些值可能的操作

+, -, *

在 FP 中,业务域以类似的方式进行建模

  • 描述你的值集(你的数据)
  • 描述对这些值起作用的操作(你的函数)

正如我们所见,以这种风格对程序进行推理与面向对象编程完全不同。FP 中的数据只是存在:将功能从数据中分离出来,让你可以检查数据,而不用担心行为。

在本章中,我们将对一家披萨店中的“披萨”的数据和操作进行建模。你将看到如何实现 Scala/FP 模型的“数据”部分,然后你将看到几种组织该数据上操作的不同方法。

对数据建模

在 Scala 中,描述编程问题的 data 模型很简单

  • 如果你想使用不同的备选方案对数据建模,请使用 enum 构造(或 Scala 2 中的 case object)。
  • 如果你只想对事物进行分组(或需要更精细的控制),请使用 case

描述备选方案

仅由不同备选方案组成的数据(如地壳大小、地壳类型和浇头)在 Scala 中通过枚举进行精确建模。

在 Scala 2 中,枚举通过 sealed class 和几个扩展该类的 case object 的组合来表示

sealed abstract class CrustSize
object CrustSize {
  case object Small extends CrustSize
  case object Medium extends CrustSize
  case object Large extends CrustSize
}

sealed abstract class CrustType
object CrustType {
  case object Thin extends CrustType
  case object Thick extends CrustType
  case object Regular extends CrustType
}

sealed abstract class Topping
object Topping {
  case object Cheese extends Topping
  case object Pepperoni extends Topping
  case object BlackOlives extends Topping
  case object GreenOlives extends Topping
  case object Onions extends Topping
}

在 Scala 3 中,枚举通过 enum 构造简洁地表示

enum CrustSize:
  case Small, Medium, Large

enum CrustType:
  case Thin, Thick, Regular

enum Topping:
  case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions

描述不同备选方案的数据类型(如 CrustSize)有时也称为和类型

描述复合数据

披萨可以被认为是上述不同属性的复合容器。我们可以使用 case 类来描述 PizzacrustSizecrustType 和可能有多个 toppings 组成

import CrustSize._
import CrustType._
import Topping._

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

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

聚合多个组件的数据类型(如 Pizza)有时也称为积类型

就是这样。这是 FP 风格披萨系统的数据模型。此解决方案非常简洁,因为它不需要将披萨上的操作与数据模型结合起来。数据模型易于阅读,就像声明关系数据库的设计一样。创建我们的数据模型的值并检查它们也非常容易

val myFavPizza = Pizza(Small, Regular, Seq(Cheese, Pepperoni))
println(myFavPizza.crustType) // prints Regular

更多数据模型

我们可能以相同的方式继续对整个披萨订购系统进行建模。以下是一些其他 case 类,用于对这样的系统进行建模

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

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

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

“精简域对象”

在 Debasish Ghosh 的著作《函数式和响应式域建模》中,他指出,OOP 从业者将其类描述为封装数据和行为的“富域模型”,而 FP 数据模型可以被认为是“精简域对象”。这是因为——正如本课所展示的——数据模型被定义为带有属性但没有行为的 case 类,从而产生简短且简洁的数据结构。

对操作进行建模

这引发了一个有趣的问题:由于 FP 将数据与其操作分开,您如何在 Scala 中实现这些操作?

答案实际上非常简单:您只需编写对我们刚刚建模的数据值进行操作的函数(或方法)。例如,我们可以定义一个计算披萨价格的函数。

def pizzaPrice(p: Pizza): Double = p match {
  case Pizza(crustSize, crustType, toppings) => {
    val base  = 6.00
    val crust = crustPrice(crustSize, crustType)
    val tops  = toppings.map(toppingPrice).sum
    base + crust + tops
  }
}
def pizzaPrice(p: Pizza): Double = p match
  case Pizza(crustSize, crustType, toppings) =>
    val base  = 6.00
    val crust = crustPrice(crustSize, crustType)
    val tops  = toppings.map(toppingPrice).sum
    base + crust + tops

您会注意到,该函数的实现只是遵循数据的形状:由于 Pizza 是一个 case 类,我们使用模式匹配来提取组件并调用辅助函数来计算各个价格。

def toppingPrice(t: Topping): Double = t match {
  case Cheese | Onions => 0.5
  case Pepperoni | BlackOlives | GreenOlives => 0.75
}
def toppingPrice(t: Topping): Double = t match
  case Cheese | Onions => 0.5
  case Pepperoni | BlackOlives | GreenOlives => 0.75

类似地,由于 Topping 是一个枚举,我们使用模式匹配来区分不同的变体。奶酪和洋葱的价格为 50 美分,而其他每种的价格为 75 美分。

def crustPrice(s: CrustSize, t: CrustType): Double =
  (s, t) match {
    // if the crust size is small or medium,
    // the type is not important
    case (Small | Medium, _) => 0.25
    case (Large, Thin) => 0.50
    case (Large, Regular) => 0.75
    case (Large, Thick) => 1.00
  }
def crustPrice(s: CrustSize, t: CrustType): Double =
  (s, t) match
    // if the crust size is small or medium,
    // the type is not important
    case (Small | Medium, _) => 0.25
    case (Large, Thin) => 0.50
    case (Large, Regular) => 0.75
    case (Large, Thick) => 1.00

要计算地壳的价格,我们同时对地壳的大小和类型进行模式匹配。

上面显示的所有函数的一个重要特点是它们是纯函数:它们不会改变任何数据或产生其他副作用(如抛出异常或写入文件)。它们所做的只是接收值并计算结果。

如何组织功能

在实现上面的 pizzaPrice 函数时,我们没有说明将在何处定义它。Scala 为您提供了许多出色的工具,用于在不同的命名空间和模块中组织您的逻辑。

有几种不同的方法来实现和组织行为

  • 在伴生对象中定义函数
  • 使用模块化编程风格
  • 使用“函数对象”方法
  • 在扩展方法中定义功能

本节的其余部分展示了这些不同的解决方案。

伴生对象

第一种方法是在伴生对象中定义行为(函数)。

如在 域建模工具 部分中所讨论的,伴生对象 是一个与类同名的 object,并且在与类相同的文件中声明。

使用这种方法,除了枚举或 case 类之外,还可以定义一个同名的伴生对象,其中包含行为。

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

// the companion object of case class Pizza
object Pizza {
  // the implementation of `pizzaPrice` from above
  def price(p: Pizza): Double = ...
}

sealed abstract class Topping

// the companion object of enumeration Topping
object Topping {
  case object Cheese extends Topping
  case object Pepperoni extends Topping
  case object BlackOlives extends Topping
  case object GreenOlives extends Topping
  case object Onions extends Topping

  // the implementation of `toppingPrice` above
  def price(t: Topping): Double = ...
}
case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

// the companion object of case class Pizza
object Pizza:
  // the implementation of `pizzaPrice` from above
  def price(p: Pizza): Double = ...

enum Topping:
  case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions

// the companion object of enumeration Topping
object Topping:
  // the implementation of `toppingPrice` above
  def price(t: Topping): Double = ...

使用这种方法,可以创建一个 Pizza 并像这样计算其价格

val pizza1 = Pizza(Small, Thin, Seq(Cheese, Onions))
Pizza.price(pizza1)

以这种方式对功能进行分组有几个优点

  • 它将功能与数据关联起来,使程序员(和编译器)更容易找到。
  • 它创建了一个命名空间,例如,它允许我们使用 price 作为方法名,而无需依赖于重载。
  • Topping.price 的实现可以访问枚举值,例如 Cheese,而无需导入它们。

但是,还有一些需要考虑的权衡

  • 它将功能与数据模型紧密耦合。特别是,伴生对象需要在与 case 类相同的文件中定义。
  • 可能不清楚在何处定义诸如 crustPrice 之类的函数,这些函数同样可以很好地放在 CrustSizeCrustType 的伴生对象中。

模块

组织行为的第二种方法是使用“模块化”方法。编程 Scala 一书将模块定义为“具有明确定义的接口和隐藏实现的‘较小的程序部分’”。让我们看看这意味着什么。

创建 PizzaService 接口

首先要考虑的是 Pizza 的“行为”。为此,您可以绘制一个 PizzaServiceInterface 特征,如下所示

trait PizzaServiceInterface {

  def price(p: Pizza): Double

  def addTopping(p: Pizza, t: Topping): Pizza
  def removeAllToppings(p: Pizza): Pizza

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

  def price(p: Pizza): Double

  def addTopping(p: Pizza, t: Topping): Pizza
  def removeAllToppings(p: Pizza): Pizza

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

如所示,每个方法都将 Pizza 作为输入参数(以及其他参数),然后返回 Pizza 实例作为结果

当您编写这样的纯接口时,您可以将其视为一份合同,其中规定,“所有扩展此特征的非抽象类必须提供这些服务的实现。”

此时您还可以做的一件事是想象自己是此 API 的使用者。当您这样做时,有助于勾勒出一些示例“使用者”代码,以确保 API 看起来像您想要的样子

val p = Pizza(Small, Thin, Seq(Cheese))

// how you want to use the methods in PizzaServiceInterface
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

如果该代码看起来不错,您通常会开始勾勒另一个 API(例如订单 API),但由于我们现在只关注披萨,所以我们将停止考虑接口,并创建此接口的具体实现。

请注意,这通常是一个两步过程。在第一步中,您将 API 的合同勾勒为接口。在第二步中,您创建该接口的具体实现。在某些情况下,您最终会创建基本接口的多个具体实现。

创建具体实现

现在您已经了解了 PizzaServiceInterface 的样子,您可以通过编写接口中定义的所有方法的主体来创建它的具体实现

object PizzaService extends PizzaServiceInterface {

  def price(p: Pizza): Double =
    ... // implementation from above

  def addTopping(p: Pizza, t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings(p: Pizza): Pizza =
    p.copy(toppings = Seq.empty)

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

  def updateCrustType(p: Pizza, ct: CrustType): Pizza =
    p.copy(crustType = ct)
}
object PizzaService extends PizzaServiceInterface:

  def price(p: Pizza): Double =
    ... // implementation from above

  def addTopping(p: Pizza, t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings(p: Pizza): Pizza =
    p.copy(toppings = Seq.empty)

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

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

end PizzaService

虽然创建接口后跟实现的这个两步过程并不总是必要的,但明确考虑 API 及其用途是一种好方法。

一切就绪后,您可以使用 Pizza 类和 PizzaService

import PizzaService._

val p = Pizza(Small, Thin, Seq(Cheese))

// use the PizzaService methods
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

println(price(p4)) // prints 8.75
import PizzaService.*

val p = Pizza(Small, Thin, Seq(Cheese))

// use the PizzaService methods
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

println(price(p4)) // prints 8.75

函数对象

在《Scala 编程》一书中,作者将术语“函数对象”定义为“没有任何可变状态的对象”。scala.collection.immutable 中的类型也是如此。例如,List 上的方法不会改变内部状态,而是创建 List 的副本作为结果。

您可以将这种方法视为“混合 FP/OOP 设计”,因为您

  • 使用不可变 case 类对数据建模。
  • 相同类型中定义行为(方法)作为数据。
  • 将行为实现为纯函数:它们不会改变任何内部状态;相反,它们会返回一个副本。

这实际上是一种混合方法:就像在OOP 设计中,方法封装在类中,包含数据,但作为FP 设计的典型,方法实现为不会改变数据的纯函数

示例

使用此方法,您可以在 case 类中直接实现披萨的功能

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

  // the operations on the data model
  def price: Double =
    pizzaPrice(this) // implementation from above

  def addTopping(t: Topping): Pizza =
    this.copy(toppings = this.toppings :+ t)

  def removeAllToppings: Pizza =
    this.copy(toppings = Seq.empty)

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

  def updateCrustType(ct: CrustType): Pizza =
    this.copy(crustType = ct)
}
case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
):

  // the operations on the data model
  def price: Double =
    pizzaPrice(this) // implementation from above

  def addTopping(t: Topping): Pizza =
    this.copy(toppings = this.toppings :+ t)

  def removeAllToppings: Pizza =
    this.copy(toppings = Seq.empty)

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

  def updateCrustType(ct: CrustType): Pizza =
    this.copy(crustType = ct)

请注意,与之前的方法不同,因为这些方法是 Pizza 类的成员方法,所以它们不会将 Pizza 引用作为输入参数。相反,它们将自己的引用作为 this,引用当前的披萨实例。

现在,您可以像这样使用此新设计

Pizza(Small, Thin, Seq(Cheese))
  .addTopping(Pepperoni)
  .updateCrustType(Thick)
  .price

扩展方法

最后,我们展示一种介于第一种方法(在伴生对象中定义函数)和最后一种方法(将函数定义为类型本身上的方法)之间的方法。

扩展方法使我们能够创建类似于函数对象的方法,而无需将函数定义为类型本身上的方法。这可能有多种优势

  • 我们的数据模型再次非常简洁,并且没有提及任何行为。
  • 我们可以追溯性地为类型配备附加方法,而无需更改原始定义。
  • 除了伴生对象或类型上的直接方法之外,还可以在外部的另一个文件中定义扩展方法。

让我们再次回顾我们的示例。

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

implicit class PizzaOps(p: Pizza) {
  def price: Double =
    pizzaPrice(p) // implementation from above

  def addTopping(t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings: Pizza =
    p.copy(toppings = Seq.empty)

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

  def updateCrustType(ct: CrustType): Pizza =
    p.copy(crustType = ct)
}

在上面的代码中,我们将在隐式类中将披萨的不同方法定义为方法。使用 implicit class PizzaOps(p: Pizza),则无论在何处导入 PizzaOps,其方法都将在 Pizza 的实例上可用。在这种情况下,接收者是 p

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

extension (p: Pizza)
  def price: Double =
    pizzaPrice(p) // implementation from above

  def addTopping(t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings: Pizza =
    p.copy(toppings = Seq.empty)

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

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

在上面的代码中,我们将披萨的不同方法定义为扩展方法。使用 extension (p: Pizza),我们表示希望在 Pizza 的实例上提供这些方法。在这种情况下,接收者是 p

使用我们的扩展方法,我们可以获得与之前相同的方法

Pizza(Small, Thin, Seq(Cheese))
  .addTopping(Pepperoni)
  .updateCrustType(Thick)
  .price

同时能够在任何其他模块中定义扩展。通常,如果您是数据模型的设计者,您将在伴随对象中定义您的扩展方法。这样,它们已经对所有用户可用。否则,需要明确导入扩展方法才能使用。

此方法的总结

在 Scala/FP 中定义数据模型往往很简单:只需使用枚举对数据进行建模变体,并使用 case 类对复合数据进行建模。然后,要对行为进行建模,请定义对数据模型值进行操作的函数。我们已经看到了组织函数的不同方法

  • 您可以将您的方法放入伴随对象中
  • 您可以使用模块化编程风格,分离接口和实现
  • 您可以使用“函数对象”方法,并将方法存储在已定义的数据类型上
  • 您可以使用扩展方法为您的数据模型配备功能

此页面的贡献者