転職したらスマレジだった件

スマレジのエンジニアやまてのテックブログです。マジレス大歓迎です。

ミノ駆動本を読んで、保守しやすい成長し続けるコードを書きたい。 - 第3章 クラス設計 後編

スマレジで Web 系エンジニアとして働いている やまて(@r_yamate) と申します。

テックファームという SES 部署に所属していますが、2023 年 4 月からは、スマレジの関連アプリの開発業務を担当しています。

Flutter(Dart、Kotlin)でのアプリ開発をメインで担当しており、React の一部機能の実装をすることもあります。

開発しているアプリがついにリリースされ、利用していただく日を楽しみにしています。

はじめに

今回は、 #ミノ駆動本 こと 『良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方』の「第3章クラス設計 ―すべてにつながる設計の基盤―」 を読んで学んだ内容について、後編の記事として投稿します。

gihyo.jp

記事の書き進め方

以前、プログラミング実務未経験のときに取り組んでいたブラックジャックソースコードを、ミノ駆動本で学んだ内容をアウトプットする舞台として、悪いコードから良いコードにリファクタリングしていけたらと思っています。

qiita.com

記事の中では、ブラックジャックPHP で書いたソースコードと、それを改善したものを例文にします。

また、できていなかったところや主観的に特に気をつけたいと思ったところに重点を置き、書き進めたいと思います。

目次

ちなみにミノ駆動本の「第3章 クラス設計 ―すべてにつながる設計の基盤―」の目次は、以下の通りです。

3.1 クラス単体で正常に動作するよう設計する

3.2 成熟したクラスへ成長させる設計術

3.3 悪魔退治の効果を検証する

3.4 プログラム構造の問題解決に役立つ設計パターン

前回の第 3 章前編は、以下の内容で投稿しました。

  1. クラス単体で正常に動作するよう設計する

 悪魔に負けない、頑強なクラスの構成要素

 全てのクラスに備わる自己防衛責務

  1. 成熟したクラスへ成長させる設計術

 2-1. コンストラクタで確実に正常値を設定する

 2-2. ロジックをデータ保持側に寄せる

 2-3. 不変で思わぬ動作を防ぐ

ryamate.hatenablog.com

2. 成熟したクラスへ成長させる設計術

2-4. 変更したい場合は新しいインスタンスを作成する

一つ前の「2-3. 不変で思わぬ動作を防ぐ」で、Card クラスのコンストラクタのインスタンス変数を不変にしました。

ミノ駆動本では、

「おいおい、不変にしたら変更ができなくなってしまうじゃないか」と思った読者もいるかもしれません。

と、前置きがあり、私もまさにそう思っていました。

インスタンス変数を変更しない代わりに、新しいインスタンスを作成することで変更する、とのことでした。

createDeck メソッドで、新しいインスタンスを作成するように変更してみます。

<?php

// 省略

/**
 * デッキクラス
 */
class Deck
{
    /**
     * コンストラクタ
     *
     * @param array<Card> $deck デッキ
     */
    public function __construct(
        private readonly array $deck = []
    ) {
    }

    /**
     * デッキを作成する
     *
     * @return Deck
     */
    public function createDeck(): Deck
    {
        $newDeck = [];
        $suits = ['♠', '♥', '♦', '♣'];
        $numbers = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];

        foreach ($suits as $suit) {
            foreach ($numbers as $number) {
                $score = match($number) {
                    'A' => 11,
                    'K', 'Q', 'J' => 10,
                    default => (int)$number,
                };
                $newDeck[] = new Card($suit, $number, $score);
            }
        }

        return new self($newDeck);
    }

    // 省略
}

新しいインスタンスを作成する目的としては、インスタンス変数に不正値を直接代入することができなくなり、ガード節付きコンストラクタで不正値を常にバリデーションでき、不正に強い構造にするためです。

ただ結局、リファクタリングしきれず、readonlyDeck クラスのインスタンス変数を不変にして、インスタンス変数を返す実装に全て置き換えるには修正の影響範囲が大きく実装しきれなかったため、リファクタリングは断念し、一旦 readonly は削除しました。

<?php

// 省略

/**
 * デッキクラス
 */
class Deck
{
    public function __construct(
        private array $deck = []
    ) {
    }
    // 省略
}

2-5. メソッド引数やローカル変数にも final を付け不変にする

メソッド引数やローカル変数にも final を付け不変にする目的は、途中で値を変更してコードを追うのが難しくなるのを防ぐためです。

ただ PHP では、final として宣言できるのはクラスとメソッド、 定数のみであり、「メソッド引数」や「ローカル変数」には final を付けられないため、リファクタリングは飛ばします。

ちなみに私は普段業務では Dart 言語を用いていますが、使用しているパッケージ(pedantic_mono)の lint のルールに従って、「ローカル変数」には常に final を付けて不変にしていたため、だいぶ習慣化されてきています。

pub.dev

また、Dart では、「メソッド引数」に final を付けると、不必要に冗長なコードが生成される可能性がある、として避けた方がいいものとされています。

dart.dev

不変にしない(できない)にしても、途中で値を変更するとコードを追うのが難しくなるため、引数は変更するものではないと考え、引数に再代入しないようにします。

2-6. 「値の渡し間違い」を型で防止する

試しに、CardSuit クラスを定義して、型安全性を強化することで「値の渡し間違い」(文字列のタイプミスなど)を防止してみます。

<?php

namespace Blackjack;

/**
 * カードのスートを表すクラス
 */
class CardSuit
{
    /** 有効なスート */
    private const VALID_SUITS = ['♠', '♥', '♦', '♣'];

    /**
     * コンストラクタ
     *
     * @param string $suit スート
     */
    public function __construct(
        private readonly string $suit
    ) {
        if (!in_array($suit, self::VALID_SUITS)) {
            throw new \InvalidArgumentException("Invalid suit: {$suit}");
        }
    }

    /**
     * スートを取得する
     *
     * @return string
     */
    public function getValue(): string
    {
        return $this->suit;
    }
}

基本データ型(プリミティブ型)である string 型であった $suit について、CardSuit クラスに置き換えます。

<?php

namespace Blackjack;

/**
 * カードクラス
 *
 * ブラックジャックのカードを表現する。
 */
class Card
{
    /**
     * コンストラクタ
     *
     * @param CardSuit $suit カードのスート
     * @param string $number カードの数字('2', '3', ..., 'A')
     * @param int $score カードの点数
     */
    public function __construct(
        private readonly CardSuit $suit,
        private readonly string $number,
        private readonly int $score,
    ) {
        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}");
        }
    }
        // 省略

これで値の渡し間違いが防止されます。

あとは、Deck クラスで CardSuit クラスを利用することで、無効なスートでカードを生成することがないようにしておきます。

<?php

// 省略

class Deck
{
        // 省略

    /**
     * デッキを作成する
     *
     * @return Deck
     */
    public function createDeck(): Deck
    {
        $newDeck = [];
        $suits = ['♠', '♥', '♦', '♣'];
        $numbers = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];

        foreach ($suits as $suitStr) {
            $suit = new CardSuit($suitStr);
            foreach ($numbers as $number) {
                $score = match($number) {
                    'A' => 11,
                    'K', 'Q', 'J' => 10,
                    default => (int)$number,
                };
                $newDeck[] = new Card($suit, $number, $score);
            }
        }

        return new self($newDeck);
    }

    // 省略
}

3. 悪魔退治の効果を検証する

1章で説明のあったデータクラスが招く悪魔は、以下の 5 つでした。

  • 重複コード … 別のクラスに同じようなロジックが複数書いてある
  • 修正漏れ … 重複したコードがあると発生しやすい
  • 可読性低下 … 関連するコードが離れていて、重複コードが多いと生じる
  • 未初期化状態(生焼けオブジェクト) … 初期化しないと利用できないことを利用する側が知らずにバグが生じてしまう状態。データクラスであることで、初期化が必要となっていると生じる
  • 不正値の混入 … 仕様として正しくない値が入ってしまうこと。データクラスは不正値を簡単に受け取ってしまい、生じる

ryamate.hatenablog.com

データクラスやメソッドだけのクラスではなく、「インスタンス変数」と「インスタンス変数を不正状態から防御し、正常に操作するメソッド」をクラスの要素にできたら、上記の悪魔が退治されたことになります。

ブラックジャックのコードを一部(Deck クラスと Card クラス)だけでも悪魔退治(リファクタリング)してみましたが、どこにロジックが書いてあるか予測しやすく修正前よりもとても見やすくまとまっていると感じています。

変更が容易か、という観点については、正直まだ実感はなく、実感として得られるのは実際に変更したときかと思います。

ミノ駆動本ではクラス設計について、以下のように表現していました。

クラス設計とは、インスタンス変数を不正状態に陥らせないためのしくみづくりと言っても過言ではありません。

この点に関しては、今回リファクタリングしただけでも感じられたと思っています。

4. プログラム構造の問題解決に役立つ設計パターン

本章で作成、編集してきたクラスは、完全コンストラクタと値オブジェクト、この2つの設計パターンを適用したものです。

  • 完全コンストラクタ … 不正状態から防護する
  • 値オブジェクト   … 特定の値に関するロジックを高凝集にする

「値オブジェクト + 完全コンストラクタ」は、オブジェクト指向設計の最も基本形を体現している構造のひとつといっても過言ではありません。

とミノ駆動本では書かれており、この先の章でも基本として用いられる設計パターンとされています。

4-1. 完全コンストラク

完全コンストラクタは、不正状態から防護するための設計パターンです。

Deck クラスと Card クラスは、以下のようにしました。

  • 編集後:src/lib/Deck.php
<?php

namespace Blackjack;

require_once('Card.php');
require_once('CardSuit.php');
require_once('CardNumber.php');

use Blackjack\Card;
use Blackjack\CardSuit;
use Blackjack\CardNumber;

/**
 * デッキクラス
 */
class Deck
{
    /**
     * コンストラクタ
     *
     * @param array<Card> $deck デッキ
     */
    public function __construct(
        private array $deck = []
    ) {
    }

    /**
     * デッキを作成する
     *
     * @return Deck
     */
    public function createDeck(): Deck
    {
        $newDeck = [];
        $suits = ['♠', '♥', '♦', '♣'];
        $numbers = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];

        foreach ($suits as $suitStr) {
            $suit = new CardSuit($suitStr);
            foreach ($numbers as $numberStr) {
                $number = new CardNumber($numberStr);
                $newDeck[] = new Card($suit, $number);
            }
        }

        return new self($newDeck);
    }

    /**
     * デッキをシャッフルする
     */
    public function shuffleDeck(): void
    {
        shuffle($this->deck);
    }

    /**
     * デッキからカードを1枚取る
     *
     * @return Card カード
     */
    public function takeCard(): Card
    {
        return array_shift($this->deck);
    }

    /**
     * deckプロパティを返す
     *
     * @return array<Card> デッキ
     */
    public function getDeck(): array
    {
        return $this->deck;
    }
}

Deck クラスの不完全な点としては、インスタンス変数を不変にできておらず、shuffleDeck メソッドと takeCard メソッドはインスタンス変数を上書きしている点です。

  • 編集後:src/lib/Card.php
<?php

namespace Blackjack;

/**
 * カードクラス
 *
 * ブラックジャックのカードを表現する。
 */
class Card
{
    /**
     * コンストラクタ
     *
     * @param CardSuit $suit カードのスート
     * @param CardNumber $number カードの数字('2', '3', ..., 'A')
     */
    public function __construct(
        private readonly CardSuit $suit,
        private readonly CardNumber $number,
    ) {
    }

    /**
     * カードのスートを取得する
     *
     * @return CardSuit カードのスート
     */
    public function getSuit(): CardSuit
    {
        return $this->suit;
    }

    /**
     * カードの数字を取得する
     *
     * @return CardNumber カードの数字
     */
    public function getNumber(): CardNumber
    {
        return $this->number;
    }

    /**
     * カードの点数を取得する
     *
     * @return int カードの点数
     */
    public function getScore(): int
    {
        return $this->calculateScore($this->number);
    }

    /**
     * カードの得点を計算する
     *
     * @param CardNumber $number カードの数字
     * @return int カードの得点
     */
    private function calculateScore(CardNumber $number): int
    {
        $numberValue = $number->getValue();
        return match ($numberValue) {
            'A' => 11,
            'K', 'Q', 'J' => 10,
            default => (int)$numberValue,
        };
    }
}

4-2. 値オブジェクト

値オブジェクトとは、値をクラスとして表現する設計パターンです。

完全コンストラクタで設計した Card クラスのインスタンス変数を、値オブジェクトにすることで、値に関するロジックを高凝集にして、不正な値の代入なども防げるようになります。

  • 作成:src/lib/CardSuit.php
<?php

namespace Blackjack;

/**
 * カードのスートを表すクラス
 */
class CardSuit
{
    /** 有効なスート */
    private const VALID_SUITS = ['♠', '♥', '♦', '♣'];

    /**
     * コンストラクタ
     *
     * @param string $suit スート
     */
    public function __construct(
        private readonly string $suit
    ) {
        if (!in_array($suit, self::VALID_SUITS)) {
            throw new \InvalidArgumentException("Invalid suit: {$suit}");
        }
    }

    /**
     * スートを取得する
     *
     * @return string
     */
    public function getValue(): string
    {
        return $this->suit;
    }
}
  • 作成:src/lib/CardNumber.php
<?php

namespace Blackjack;

/**
 * カードの数字を表すクラス
 */
class CardNumber
{
    /** 有効なカードの数字 */
    private const VALID_NUMBERS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];

    /**
     * コンストラクタ
     *
     * @param string $number カードの数字
     */
    public function __construct(
        private readonly string $number
    ) {
        if (!in_array($number, self::VALID_NUMBERS)) {
            throw new \InvalidArgumentException("Invalid card number: {$number}");
        }
    }

    /**
     * カードの数字を取得する
     *
     * @return string
     */
    public function getValue(): string
    {
        return $this->number;
    }
}

おわりに

今回は、 #ミノ駆動本 こと 『良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方』の「第3章クラス設計 ―すべてにつながる設計の基盤―」 を読んで学んだ内容について、後編の記事として投稿しました。

結局、リファクタリングは不完全になってしまっているものの、手を動かしながら本の内容をどのように反映させればいいかを考えることで、本の3章の理解は十分深まったと感じています。

ありがとうございました。