Java 中的面向資料編程
1. 概述
在本教程中,我們將了解軟體開發的不同範例:面向資料的程式設計。我們首先將其與更傳統的物件導向程式設計進行比較,並強調它們的差異。
之後,我們將進行實踐練習,應用以資料為導向的程式設計來實現 Yahtzee 遊戲。在整個練習中,我們將專注於 DOP。原則,並利用現代 Java 功能,如記錄、密封介面和模式匹配。
2. 原則
以資料為導向的程式設計是一種範式,我們圍繞著資料結構和流程而不是物件或函數來設計應用程式。 這種軟體設計方法圍繞著三個關鍵原則:
- 數據與對其進行操作的邏輯是分離的,
- 資料儲存在通用且透明的資料結構中,
- 數據是不可變的,並且始終處於有效狀態;
透過僅允許創建有效實例並防止更改,我們確保我們的應用程式始終具有有效資料。因此,遵守這些規則將導致非法國家無法代表。
3. 面向資料與物件導向編程
如果我們遵循這些原則,我們最終會得到與更傳統的物件導向程式設計 (OOP) 截然不同的設計。一個關鍵的區別是 OOP 使用介面來實現依賴倒置和多態性,這是將我們的邏輯與跨邊界依賴解耦的寶貴工具。
相反,當我們使用 DOP 時,我們不允許混合資料和邏輯。因此,我們無法多態地呼叫資料類別上的行為。此外,OOP 使用封裝來隱藏數據,而 DOP 則偏好通用且透明的資料結構,例如映射、元組和記錄。
總而言之,以資料為導向的程式設計適用於資料所有權明確且針對外部依賴性的保護較不重要的小型應用程式。另一方面,OOP 仍然是定義清晰的模組邊界或允許客戶透過外掛程式擴展軟體功能的可靠選擇。
4. 資料模型
在本文的程式碼範例中,我們將實作 Yahtzee 遊戲的規則。首先我們回顧一下遊戲的主要規則:
- 在每一輪中,玩家透過擲五個六面骰子開始回合,
- 玩家可以選擇重新擲部分或全部骰子,最多三次,
- 然後玩家選擇計分策略,例如「一」、「二」、「對」、「兩對」、「三同」…等。
- 最後,玩家根據計分策略和骰子得到分數;
現在我們有了遊戲規則,我們可以應用以資料為導向的原則來建模我們的領域。
4.1.將資料與邏輯分離
我們討論的第一個原則是分離資料和行為,我們將應用它來創建各種評分策略。
我們可以將Strategy視為具有多個實作的介面。此時,我們不需要支援所有可能的策略;我們可以關注其中的一些並密封接口以指示我們允許的:
sealed interface Strategy permits Ones, Twos, OnePair, TwoPairs, ThreeOfaKind {
}
正如我們所觀察到的, Strategy介面沒有定義任何方法。從物件導向程式設計的背景來看,這可能看起來很奇怪,但將資料與對其進行操作的行為分開是至關重要的。因此,特定策略也不會暴露任何行為:
class Strategies {
record Ones() implements Strategy {
}
record Twos() implements Strategy {
}
record OnePair() implements Strategy {
}
// other strategies...
}
4.2.資料不變性和驗證
眾所周知,以資料為導向的程式設計促進了儲存在通用資料結構中的不可變資料的使用。 Java 記錄非常適合這種方法,因為它們為不可變資料建立透明的載體。讓我們用一筆record來表示骰子Roll :
record Roll(List<Integer> dice, int rollCount) {
}
儘管記錄本質上是不可變的,但它們的組件也必須是不可變的。例如,從可變列表建立Roll可以讓我們稍後修改骰子的值。為了防止這種情況,我們可以使用緊湊的建構函式來用unmodifiableList()包裝清單:
record Roll(List<Integer> dice, int rollCount) {
public Roll {
dice = Collections.unmodifiableList(dice);
}
}
此外,我們可以使用此建構函數來驗證資料:
record Roll(List<Integer> dice, int rollCount) {
public Roll {
if (dice.size() != 5) {
throw new IllegalArgumentException("A Roll needs to have exactly 5 dice.");
}
if (dice.stream().anyMatch(die -> die < 1 || die > 6)) {
throw new IllegalArgumentException("Dice values should be between 1 and 6.");
}
dice = Collections.unmodifiableList(dice);
}
}
4.3.資料構成
這種方法有助於使用資料類別擷取域模型。使用沒有特定行為或封裝的通用資料結構使我們能夠從較小的資料模型建立更大的資料模型。
例如,我們可以將Turn表示為Roll和Strategy之間的並集:
record Turn(Roll roll, Strategy strategy) {
}
正如我們所看到的,我們僅透過資料建模就捕獲了業務規則的很大一部分。儘管我們尚未實現任何行為,但檢查數據表明玩家透過執行Roll並選擇策略來完成Turn Strategy.此外,我們還可以觀察到支援的Strategies有: Ones, Twos, OnePair,和ThreeOfaKind.
5. 實施行為
現在我們有了資料模型,下一步是實現對其進行操作的邏輯。為了保持資料和邏輯之間的清晰分離,我們將利用靜態函數並確保類別保持無狀態。
讓我們先建立一個roll()函數,該函數傳回帶有五個骰子的Roll :
class Yahtzee {
// private default constructor
static Roll roll() {
List<Integer> dice = IntStream.rangeClosed(1, 5)
.mapToObj(__ -> randomDieValue())
.toList();
return new Roll(dice, 1);
}
static int randomDieValue() { /* ... */ }
}
然後,我們需要允許玩家重新擲特定的骰子值。
static Roll rerollValues(Roll roll, Integer... values) {
List<Integer> valuesToReroll = new ArrayList<>(List.of(values));
// arguments validation
List<Integer> newDice = roll.dice()
.stream()
.map(it -> {
if (!valuesToReroll.contains(it)) {
return it;
}
valuesToReroll.remove(it);
return randomDieValue();
}).toList();
return new Roll(newDice, roll.rollCount() + 1);
}
正如我們所看到的,我們替換了重新擲骰子的值並增加了rollCount,傳回了Roll記錄的新實例。
接下來,我們讓玩家能夠透過接受String來選擇評分策略,我們將使用靜態工廠方法從中建立適當的實作。由於玩家完成了他們的回合,我們返回一個包含他們的Roll和所選Strategy Turn實例:
static Turn chooseStrategy(Roll roll, String strategyStr) {
Strategy strategy = Strategies.fromString(strategyStr);
return new Turn(roll, strategy);
}
最後,我們將編寫一個函數來根據所選的Strategy計算給定Turn中玩家的得分。讓我們使用 switch 表達式和 Java 的模式匹配功能。
static int score(Turn turn) {
var dice = turn.roll().dice();
return switch (turn.strategy()) {
case Ones __ -> specificValue(dice, 1);
case Twos __ -> specificValue(dice, 2);
case OnePair __ -> pairs(dice, 1);
case TwoPairs __ -> pairs(dice, 2);
case ThreeOfaKind __ -> moreOfSameKind(dice, 3);
};
}
static int specificValue(List<Integer> dice, int value) { /* ... */ }
static int pairs(List<Integer> dice, int nrOfPairs) { /* ... */ }
static int moreOfSameKind(List<Integer> dice, int nrOfDicesOfSameKind) { /* ... */ }
使用不含default分支的模式比對可確保詳盡性,確保明確處理所有可能的情況。換句話說,如果我們決定支援新的Strategy ,則在我們更新此 switch 表達式以包含新實現的評分規則之前,程式碼將不會編譯。
正如我們所觀察到的,我們的函數是無狀態且無副作用的,僅執行對不可變資料結構的轉換。此管道中的每個步驟都會傳回後續邏輯步驟所需的資料類型,從而定義正確的轉換順序和順序:
@Test
void whenThePlayerRerollsAndChoosesTwoPairs_thenCalculateCorrectScore() {
enqueueFakeDiceValues(1, 1, 2, 2, 3, 5, 5);
Roll roll = roll(); // => { dice: [1,1,2,2,3] }
roll = rerollValues(roll, 1, 1); // => { dice: [5,5,2,2,3] }
Turn turn = chooseStrategy(roll, "TWO_PAIRS");
int score = score(turn);
assertEquals(14, score);
}
六、結論
在本文中,我們介紹了面向資料程式設計的關鍵原則以及它與 OOP 的不同之處。之後,我們發現了 Java 語言中的新功能如何為開發以資料為導向的軟體提供堅實的基礎。
像往常一樣,完整的源代碼可以在 GitHub 上找到。