1. 概述
本教程将介绍一种不同的软件开发范式——数据导向编程(Data-Oriented Programming, DOP)。首先我们将它与传统的面向对象编程(OOP)进行对比,并突出两者的核心差异。
随后,我们将通过实现 Yahtzee 游戏的实践案例,深入应用数据导向编程。整个实践过程将聚焦 DOP 核心原则,并充分利用现代 Java 特性,如 records、sealed interfaces 和 pattern matching。
2. 核心原则
数据导向编程是一种围绕数据结构和数据流(而非对象或函数)设计应用的范式。该设计方法基于三大核心原则:
- 数据与操作数据的逻辑分离
- 数据存储在通用且透明的数据结构中
- 数据不可变,且始终处于有效状态
通过仅允许创建有效实例并禁止修改,我们确保应用始终拥有有效数据。遵循这些规则将使非法状态无法表示(making illegal states unrepresentable)。
3. 数据导向 vs 面向对象编程
遵循这些原则后,最终设计将与传统的面向对象编程(OOP)截然不同。关键差异在于:OOP 使用接口实现依赖倒置和多态,这是解耦逻辑与跨边界依赖的利器。
相比之下,DOP 禁止混合数据与逻辑。因此,我们无法在数据类上多态调用行为。此外,OOP 通过封装隐藏数据,而 DOP 偏好通用透明数据结构(如 map、tuple 和 record)。
总结:数据导向编程适用于数据所有权明确、外部依赖防护需求较低的小型应用。 而面向对象编程在定义清晰模块边界或允许客户端通过插件扩展功能方面仍是更优选择。
4. 数据模型
在本文代码示例中,我们将实现 Yahtzee 游戏规则。首先回顾游戏核心规则:
- 每回合玩家开始时掷五个六面骰子
- 玩家可选择重掷部分或全部骰子,最多三次
- 玩家随后选择计分策略(如"ones"、"twos"、"pairs"、"two pairs"、"three of a kind"等)
- 最终根据策略和骰子点数计算得分
明确游戏规则后,我们即可应用数据导向原则构建领域模型。
4.1. 数据与逻辑分离
首要原则是分离数据与行为,我们将据此实现各种计分策略。
可将 Strategy 视为具有多个实现的接口。目前无需支持所有策略,先聚焦核心策略并密封接口以限定允许的实现:
sealed interface Strategy permits Ones, Twos, OnePair, TwoPairs, ThreeOfaKind {
}
如上所示,Strategy 接口未定义任何方法。从 OOP 背景看这很反常,但这是保持数据与操作逻辑分离的关键。 因此具体策略也不暴露任何行为:
class Strategies {
record Ones() implements Strategy {
}
record Twos() implements Strategy {
}
record OnePair() implements Strategy {
}
// 其他策略...
}
4.2. 数据不可变性与验证
数据导向编程倡导使用存储在通用数据结构中的不可变数据。Java records 完美契合此需求,它们能创建不可变数据的透明载体。 我们用 record 表示骰子 Roll:
record Roll(List<Integer> dice, int rollCount) {
}
尽管 record 本质不可变,但其组件也必须不可变。例如,用可变列表创建 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 并选择 Strategy 完成 Turn。同时,支持的 Strategies 包括:Ones, Twos, OnePair, ThreeOfaKind。
5. 实现行为
拥有数据模型后,下一步是实现操作数据的逻辑。为保持数据与逻辑的清晰分离,我们将使用静态函数并确保类无状态。
首先创建 roll() 函数,返回包含五个骰子的 Roll:
class Yahtzee {
// 私有默认构造函数
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));
// 参数验证
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 分支的模式匹配确保穷尽性(exhaustiveness),保证所有情况被显式处理。 换言之,若新增 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);
}
6. 结论
本文介绍了数据导向编程的核心原则及其与 OOP 的差异。随后展示了 Java 新特性如何为开发数据导向软件提供坚实基础。
完整源代码可在 GitHub 获取。