本章介绍了在 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
类来描述 Pizza
由 crustSize
、crustType
和可能有多个 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
之类的函数,这些函数同样可以很好地放在CrustSize
或CrustType
的伴生对象中。
模块
组织行为的第二种方法是使用“模块化”方法。编程 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
类对复合数据进行建模。然后,要对行为进行建模,请定义对数据模型值进行操作的函数。我们已经看到了组织函数的不同方法
- 您可以将您的方法放入伴随对象中
- 您可以使用模块化编程风格,分离接口和实现
- 您可以使用“函数对象”方法,并将方法存储在已定义的数据类型上
- 您可以使用扩展方法为您的数据模型配备功能