Scala 3 — 書籍

FP 建模

語言

本章節提供使用 Scala 3 中的函式程式設計 (FP) 進行領域建模的簡介。使用 FP 建模我們周遭的世界時,您通常會使用這些 Scala 建構

  • 列舉
  • 案例類別
  • 特質

如果您不熟悉代數資料類型 (ADT) 及其廣義版本 (GADT),您可能想在閱讀本節之前閱讀 代數資料類型 一節。

簡介

在 FP 中,資料資料上的運算是兩件分開的事情;您不必像在 OOP 中那樣將它們封裝在一起。

這個概念類似於數字代數。當您想到大於或等於零的整數時,您會有一個看起來像這樣的集合

0, 1, 2 ... Int.MaxValue

忽略整數的除法,這些值可能的運算

+, -, *

在 FP 中,業務領域以類似的方式建模

  • 您描述您的值集(您的資料)
  • 您描述對這些值運作的運算(您的函數)

正如我們將看到的,以這種風格對程式進行推理與物件導向程式設計截然不同。FP 中的資料就是:將功能性與資料分開,讓您可以在不擔心行為的情況下檢查資料。

在本章中,我們將對披薩店中的「披薩」建模資料和運算。您將看到如何實作 Scala/FP 模型的「資料」部分,然後您將看到幾種不同的方式來組織對該資料的運算。

資料建模

在 Scala 中,描述程式設計問題的資料模型很簡單

  • 如果您想使用不同的替代方案對資料建模,請使用 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 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的伴侶物件中。

模組

組織行為的第二種方法是使用「模組化」方法。程式設計 in 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 類別對資料進行建模。
  • 在與資料相同的類型中定義行為(方法)。
  • 將行為實作為純函式:它們不會變異任何內部狀態,而是會傳回一個副本。

這實際上是一種混合方法:就像在物件導向設計中,方法會封裝在類別中,但就像函式程式設計一樣,方法會實作為不會變異資料的純函式

範例

使用此方法,你可以直接在 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

擴充方法

最後,我們展示一個介於第一種方法(在伴隨物件中定義函式)和最後一種方法(將函式定義為類型本身的方法)之間的方法。

擴充方法讓我們可以建立類似於函式物件的 API,而不需要將函式定義為類型本身的方法。這可能有多種優點

  • 我們的資料模型再次非常簡潔,而且沒有提到任何行為。
  • 我們可以追溯為類型裝備額外的函式,而不需要變更原始定義。
  • 除了伴隨物件或類型上的直接方法之外,擴充方法也可以在另一個檔案中外部定義。

讓我們再次回顧我們的範例。

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

使用我們的擴充方法,我們可以取得與先前相同的 API

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

同時可以在任何其他模組中定義擴充。通常,如果你設計資料模型,你會在伴隨物件中定義擴充方法。這樣,所有使用者都可以使用它們。否則,需要明確匯入擴充方法才能使用。

此方法的摘要

在 Scala/FP 中定義資料模型通常很簡單:只要使用列舉對資料的變異進行建模,並使用 case 類別對複合資料進行建模。然後,為了對行為進行建模,定義在資料模型的值上運作的函式。我們已經看到組織函式的不同方式

  • 你可以將方法放入伴隨物件中
  • 你可以使用模組化程式設計風格,將介面和實作分開
  • 你可以使用「函數物件」方法,將方法儲存在已定義的資料類型上
  • 你可以使用擴充方法,為資料模型裝備功能

此頁面的貢獻者