スマレジで Web 系エンジニアとして働いている やまて(@r_yamate) と申します。
テックファームという SES 部署に所属していますが、2023 年 4 月からは、スマレジの関連アプリの開発業務を担当しています。
Flutter(Dart、Kotlin)でのアプリ開発をメインで担当しており、React の一部機能の実装をすることもあります。
開発しているアプリがついにリリースされ、利用していただく日を楽しみにしています。
はじめに
今回は、 #ミノ駆動本 こと 『良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方』の「第3章クラス設計 ―すべてにつながる設計の基盤―」 を読んで学んだ内容について記事にします。
前回の第 2 章は設計の初歩についてで、変数は省略しない、メソッド化しよう、というような内容でした。
第 3 章は、クラス設計、すべてにつながる設計の基盤について、ということで、クラスが単体で正しく動作するための設計方法など、クラス設計の基本についてです。
前編、後編に分けて投稿します。
記事の書き進め方
以前、プログラミング実務未経験のときに取り組んでいたブラックジャックのソースコードを、ミノ駆動本で学んだ内容をアウトプットする舞台として、悪いコードから良いコードにリファクタリングしていけたらと思っています。
記事の中では、ブラックジャックの PHP で書いたソースコードと、それを改善したものを例文にします。
また、できていなかったところや主観的に特に気をつけたいと思ったところに重点を置き、書き進めたいと思います。
目次
ちなみにミノ駆動本の「第3章 クラス設計 ―すべてにつながる設計の基盤―」の目次は、以下のとおりです。
3.1 クラス単体で正常に動作するよう設計する
3.2 成熟したクラスへ成長させる設計術
3.3 悪魔退治の効果を検証する
3.4 プログラム構造の問題解決に役立つ設計パターン
1. クラス単体で正常に動作するよう設計する
ミノ駆動本では、ドライヤーなどの家電製品は、それ自体が単体で動作するように設計されている、クラス設計の考え方はそれと同じ、と、身の回りのモノでの例え話から始まります。単体でちゃんと動くことが大事だと、ドライヤーを使うたびに思い出すようにします。
悪魔に負けない、頑強なクラスの構成要素
単体で動作するように設計するための良いクラスの構成要素は、以下の2つとしています。
データだけのデータクラスはダメ、メソッドだけのクラスもダメ、どちらかが欠けてはダメ、ということですね。
データクラスがあると、他のクラスから操作してもらう必要があり、関連するコードが離れてしまうことが理由の一つとして挙げられています。
全てのクラスに備わる自己防衛責務
自己防衛責務 とは、データの入力チェックや初期化など自分のクラスのことは自分のクラスで行って、他のクラスに任せないことをいいます。
2. 成熟したクラスへ成長させる設計術
ブラックジャックのコードには良いクラスの構成要素である「インスタンス変数」がない、以下の Card
クラスが存在していました。
<?php namespace Blackjack; /** * カードクラス * * このクラスはブラックジャックのカードを表現します。 */ class Card { /** * @var array<string,int> 各カードの点数 * * 2から9までは、書かれている数の通りの点数 * 10,J,Q,Kは10点 * Aは1点あるいは11点として、手の点数が最大となる方で数える(初期値 11 にする) */ private const CARD_SCORE = [ '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9, '10' => 10, 'J' => 10, 'Q' => 10, 'K' => 10, 'A' => 11, ]; /** * @var array<int,string> 各カードのマーク * '♠', '♥', '♦', '♣'の4種類のマークが存在します。 */ private const SUITS = [ '♠', '♥', '♦', '♣', ]; /** * 新しくデッキを作成する * * @return array<int,array<string,int|string>> デッキ * * 各カードは'suit'(マーク), 'num'(数字), 'score'(点数)の3つの属性を持つ配列として表現されます。 */ public function createNewDeck(): array { $deck = []; foreach (self::SUITS as $suit) { foreach (self::CARD_SCORE as $num => $score) { $deck[] = [ 'suit' => $suit, 'num' => (string)$num, 'score' => $score, ]; } } return $deck; } }
トランプのカードを表す Card
クラスは、メソッドしか持っておらず、「インスタンス変数」を持っていません。
当然、インスタンス変数が無いため、唯一のメソッドである createNewDeck
メソッドは「インスタンス変数を不正状態から防御し、正常に操作するメソッド」ではありません。
このメソッドの処理をみてみると、トランプのカードを生成してはいるのですが、別で Deck
クラスがあり、カード 52 枚のデッキを作成しているメソッドであるため、そもそも Deck
クラスに関連するメソッドであると考えます。
現状の Deck
クラスは以下のとおりです。
<?php namespace Blackjack; require_once('Card.php'); use Blackjack\Card; /** * デッキクラス */ class Deck { /** * コンストラクタ * * @param array<int,array<string,int|string>> $deck デッキ */ public function __construct( private array $deck = [] ) { } /** * deck プロパティを返す * * @return array<int,array<string,int|string>> $deck デッキ */ public function getDeck(): array { return $this->deck; } /** * デッキを初期化する * * @return array<int,array<string,int|string>> $deck デッキ */ public function initDeck(): array { $card = new Card(); $this->deck = $card->createNewDeck(); shuffle($this->deck); return $this->deck; } /** * デッキから、プレイヤーが引いたカードを1枚除く * * @return void */ public function takeACard(): void { $this->deck = array_slice($this->deck, 1); } }
2-1. コンストラクタで確実に正常値を設定する
まずは Card クラスにコンストラクタを追加し、インスタンス変数を持たせます。
<?php namespace Blackjack; /** * カードクラス * * ブラックジャックのカードを表現する。 */ class Card { /** * コンストラクタ * * @param string $suit カードのスート('♠', '♥', '♦', '♣'の4種類のマーク) * @param string $number カードの数字('2', '3', ..., 'A') * @param int $score カードの点数 */ public function __construct( private string $suit, private string $number, private int $score, ) { } /** * カードのスートを取得する * * @return string カードのスート */ public function getSuit(): string { return $this->suit; } /** * カードの数字を取得する * * @return string カードの数字 */ public function getNumber(): string { return $this->number; } /** * カードの点数を取得する * * @return int カードの点数 */ public function getScore(): int { return $this->score; } }
「インスタンス変数」を持たせ、コンストラクタで初期化するようにしました。しかし、このままでは引数に不正値を渡せてしまいます。
$this->deck[] = new Card('★', 'X', 500000000000);
トランプのカードの種類には無い不正値の入力によるバグの発生を防止するため、バリデーションをコンストラクタ内に定義します。
<?php namespace Blackjack; /** * カードクラス * * ブラックジャックのカードを表現する。 */ class Card { /** * コンストラクタ * * @param string $suit カードのスート('♠', '♥', '♦', '♣'の4種類のマーク) * @param string $number カードの数字('2', '3', ..., 'A') * @param int $score カードの点数 */ public function __construct( private string $suit, private string $number, private int $score, ) { if (!in_array($suit, ['♠', '♥', '♦', '♣'])) { throw new \InvalidArgumentException("Invalid suit: {$suit}"); } if (!in_array($number, ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'])) { throw new \InvalidArgumentException("Invalid number: {$number}"); } $validScores = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; if (!in_array($score, $validScores)) { throw new \InvalidArgumentException("Invalid score: {$score}"); } } // 省略 }
ミノ駆動本では、このような処理の対象外となる条件をメソッドの始めに定義する方法を ガード節 と言う、と説明されています。その後の章に登場する、早期return や 早期continue などもガード節と同じ性質のものかと思います。
2-2. ロジックをデータ保持側に寄せる
元々 Card
クラスにあった唯一のメソッドである createNewDeck
メソッドは、Deck
クラスに関連するロジックなので、Deck
クラスに移動し、createDeck
メソッドとします。
<?php namespace Blackjack; require_once('Card.php'); use Blackjack\Card; /** * デッキクラス */ class Deck { /** * コンストラクタ * * デッキを初期化する。 * * @param array<Card> $deck デッキ */ public function __construct( private array $deck = [] ) { $this->createDeck(); $this->shuffleDeck(); } /** * deck プロパティを返す * * @return array<Card> $deck デッキ */ public function getDeck(): array { return $this->deck; } /** * デッキを初期化する * * 各カードはCardクラスのインスタンス。 */ public function createDeck(): void { $this->deck = []; $suits = ['♠', '♥', '♦', '♣']; $numbers = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; $values = ['2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9, '10' => 10, 'J' => 10, 'Q' => 10, 'K' => 10, 'A' => 11]; foreach ($suits as $suit) { foreach ($numbers as $number) { $this->deck[] = new Card($suit, $number, $values[$number]); } } } /** * デッキをシャッフルする */ public function shuffleDeck(): void { shuffle($this->deck); } /** * デッキからカードを1枚取る * * @return Card 取り除かれたカード */ public function takeCard(): Card { return array_shift($this->deck); } }
2-3. 不変で思わぬ動作を防ぐ
インスタンス変数の上書きは、仕様変更で処理が変わったときに意図しない値に書き換わるなどの思わぬ副作用を発生させる、とのことなので不変にします。
<?php namespace Blackjack; /** * カードクラス * * ブラックジャックのカードを表現する。 */ class Card { /** * コンストラクタ * * @param string $suit カードのスート('♠', '♥', '♦', '♣'の4種類のマーク) * @param string $number カードの数字('2', '3', ..., 'A') * @param int $score カードの点数 */ public function __construct( private readonly string $suit, private readonly string $number, private readonly int $score, ) { if (!in_array($suit, ['♠', '♥', '♦', '♣'])) { throw new \InvalidArgumentException("Invalid suit: {$suit}"); } if (!in_array($number, ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'])) { throw new \InvalidArgumentException("Invalid number: {$number}"); } $validScores = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; if (!in_array($score, $validScores)) { throw new \InvalidArgumentException("Invalid score: {$score}"); } } // 省略
final 修飾子は PHP には無いようであったため、readonly
をインスタンス変数に付けました。
読み取り専用プロパティ
PHP 8.1.0 以降では、
readonly
を付けてプロパティを宣言できます。 これによって、プロパティを初期化した後に値が変更されることを防止できます。
注意: プロパティを final として宣言することはできません。 final として宣言できるのはクラスとメソッド、 および定数(PHP 8.1.0以降)だけです。
読み取り専用プロパティに再代入しようとしても、再代入できないようにすることができました。
$this->suit = $suit;
PHP Fatal error: Uncaught Error: Cannot modify readonly property Blackjack\Card::$suit in /Users/r_yamate/development/php-oop-cli-blackjack/src/lib/Card.php:34
再代入しようとすると、このようにエラーになります。
おわりに
今回は、 #ミノ駆動本 こと 『良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方』の「第3章クラス設計 ―すべてにつながる設計の基盤―」 を読んで学んだ内容について書きました。
クラスが単体で正しく動作するための設計方法の3つのポイントについて、ブラックジャックのコードで実践してみました。
次回も、リファクタリングの続きをして、第3章後編として投稿します。
ありがとうございました。
自分の映画館での視聴履歴の直近5回はドラえもん、ドラえもん、スラムダンク、ドラえもん、ドラえもん、という感じだけど、毎回ちゃんと作品に満足して帰ってる。作ってる大人たちすごい。 pic.twitter.com/GbpaBL7uHl
— やまて|Web系エンジニア3年目 (@r_yamate) 2024年3月20日
ドラえもんを観ると汚れた心が洗われる感覚すらある。