本章節提供使用 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
類別來描述 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 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
的伴侶物件中。
模組
組織行為的第二種方法是使用「模組化」方法。程式設計 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
類別對複合資料進行建模。然後,為了對行為進行建模,定義在資料模型的值上運作的函式。我們已經看到組織函式的不同方式
- 你可以將方法放入伴隨物件中
- 你可以使用模組化程式設計風格,將介面和實作分開
- 你可以使用「函數物件」方法,將方法儲存在已定義的資料類型上
- 你可以使用擴充方法,為資料模型裝備功能