モデリングとは、対象を開発者にとって都合よく切り取ることだと思った
こんにちは!miyamyia(←これはtypoですが、みやみやと読みます。typoが正。) です。
この記事では、研修中にモデリングの核心を掴んだかもしれないという話をします。
思い出せ あの時掴んだ 呪力の核心を!!! - 『呪術廻戦』(芥見下々)
やっていること
現在、研修課題としてPHPでCLIベースのブラックジャックアプリケーションを作成しています。
今回は、その中で スコア計算ロジック を考えていた時の話です。
ブラックジャックのスコア計算はこのようなルールに基づいています。
- 2~10 はカードの数字がそのままスコアになる
- J、Q、K はスコアを10にする
- エースは1または11となり、他の手札を参照して、バストしない範囲で最大になる値を採用する
これがなかなか強敵でした。
最初は、カードの点数計算なので、カードに責務を持たせるべきだと考えました。そのため、このような構成を考えていました。
class Card {
private string $suit;
private int $number;
public function score(): int {
return $this->number >= 10 ? 10 : $this->number;
}
}
しかし、これでは ルール3 を表現することができません。
ここで、他のカードを受け取ってエースのスコアを計算するようにするのも一つですが、他のカードのリストを参照して自身の点数を計算するという処理は、このオブジェクトにとって自然な責務ではないと感じたため、却下しました。(なぜ不自然だと感じるかはうまく言語化できていません。。)
次に、カードを集約しているクラスが、すべてのカードを参照してスコアを決定するという方針を考えました。結果的にこのようなコードになっています。
class Card {
private string $suit;
public int $number;
}
class Player {
private array $cards;
public function score():int {
// エースを除いたカードのリストを取得
$withoutAce = array_filter($this->cards, fn(Card $card) => $card->number !== 1);
$score = 0;
// エースを除いたカードのスコアを計算
foreach ($withoutAce as $card) {
$score += $card->number >= 10 ? 10 : $card->number;
}
// エースのみのカードリストを取得
$aces = array_filter($this->cards, fn(Card $card) => $card->number === 1);
foreach($aces as $ace) {
// スコアが10以下であれば、11を加算する
if($score <= 10) {
$score += 11;
// スコアが11以上であれば、1を加算する
}else {
$score += 1;
}
}
}
}
なんといっても長いですね。さらに、これにはバグがあります。
例えば「10,A,A,A」のような組み合わせの場合、本来は13になるはずですが、このロジックだと23になってバストとなります。
さらに、カードが単体で判断できるはずのスコア計算もプレイヤーの責務になってしまっており、カードクラスにはメソッドがなく、いわゆるドメインモデル貧血症のような状態になっています。(カードをドメインモデルとして扱うのであればの話です。)
「Aは1か11である。Aは、他のカードの合計と足し合わせてバストしない範囲で最大になる」
このように、自然なルールの描写に従ってロジックを考えると、意外と実現が難しいことに気づきました。
別の側面から問題を捉える
ここで、この問題を改めて見つめ直して、ルールの本質を探りました。
例えば、「エースが、他の手札によって1か11になる」ではなく、「プレイヤーが、すべてのカードを合計した後に10を加算するかを判断する」と捉えてみるとどうでしょうか。
そうすると、このようなシンプルな手順になります。
- Aは常に1として考え、すべてのカードのスコアを足し合わせる
- Aの数が1枚以上かつ、すべてのカードのスコア合計が11以下であれば、10を加算する
public function score(): int
{
$score = 0;
// すべてのカードのスコアを足し合わせる
foreach ($this->cards as $card) {
$score += $card->score();
}
// Aの枚数をカウントする
$acesCount = count(array_filter($this->cards, fn(Card $card) => $card->score() === 1));
// Aが1枚以上かつ、合計スコアが11以下の場合、10を加算する
if ($acesCount >= 1 && $score <= 11) $score += 10;
return $score;
}
こうすることにより、コードはシンプルになり、バグもなくなりました。
例えば、「2,2,2,A,A,A,A」の場合は、このように計算されます。
- 2 + 2 + 2 + 1 + 1 + 1 + 1 を行う
- Aが1枚以上あり、合計値が10で11を超えていないため、10を加算する。
- 合計スコアは20となる
前述した、バグが発生していた「10,A,A,A」の場合はこのように計算されます。
- 10 + 1 + 1 + 1 を行う
- Aは1枚以上あるものの、合計値が13で、すでに11を超えているため、10を加算しない。
- 合計スコアは13となる
ループ内での処理が単純になったのも良いですね。デバッグするときに、頭の中に保持しておくべき変数や状態が減って読み解きやすくなったように思います。
カードの順番に依存しない計算方法になったのも非常に良いです。
モデリングとは
実践ドメイン駆動設計 にはこのように書かれています。
これは決して、「現実世界」をそのままモデリングするということではない。DDDでは、現状をそのままモデリングするのではなく、業務により役立つモデルを作る。
時には、使いやすいモデルと現状を反映したモデルとが食い違いかもしれない。しかし、そんな場合は使いやすいモデルを選択するのがDDDだ。
今回私が書いた処理は、ブラックジャックで一般に言われる計算のルールとは少し違う処理の流れでした。
私は、モデリングというのは対象を様々な側面から観察し、目的を達成するのに便利な側面を切り取ることだと考えます。そのため、今回の例では現実をそのままモデルに反映はしていないものの、バグの入り込む余地が少なく、認知負荷が下がり、可読性が向上したと思います。
そのほかにも、「Aと10点札のみの場合にブラックジャック」→「手札が2枚かつ合計が21点であればブラックジャック」のように捉え直すことで、処理の流れを追いやすく、読みやすいロジックを実現しました。
このように、対象を別の側面から切り取ることで、より良いコードを書くことができる場合があるかもしれません。
ただし、あまりに現実と乖離しているモデルは、可読性を下げる原因にもなります。モデルとは人間にとって都合の良い、使いやすいツールであるべきです。使いやすさ(ここにはもちろん可読性が含まれます)を優先して考え、バランスの取れた、より良いモデルを、より良い側面を、探していきましょう。
Discussion