0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【PHP】ファーストクラスコレクションを自分なりに書いてみた

Posted at

はじめに

以前読んだミノ駆動先生の『良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方』の中でこれは業務の中で使えそうだなと思ってファーストクラスコレクションのサンプルコードを書いていたので、デザインパターンを適用することによる改善点などを備忘録として残そうと思います。

ファーストクラスコレクションとは?

配列やリストなどのコレクションを直接操作するのではなく、コレクション操作専用のクラスでカプセル化することで凝集度を高めるデザインパターン

コレクションの要素となるクラス

今回はサンプルとして以下のような利用者情報を扱うためのクラスを使用します。
※前提としてDBの利用者マスタから取得したレコードに対応するエンティティクラスを想定しているため利用者IDは被らないものとします。

User.php
/** 利用者クラス */
final class User
{
    private int $id;      // 利用者ID
    private string $name; // 利用者名
    private int $age;     // 利用者年齢

    public function __construct(int $id, string $name, int $age) {
        $this->id   = $id;
        $this->name = $name;
        $this->age  = $age;
    }

    public function __toString(): string {
        return "ID:" . $this->Id() . " , 名前:" . $this->Name() . " , 年齢:" . $this->Age();
    }

    public function Id(): int {
        return $this->id;
    }

    public function Name(): string {
        return $this->name;
    }

    public function Age(): int {
        return $this->age;
    }
}

今回は以下の操作を例にコレクションを直接操作する場合とファーストクラスコレクションを使用した場合とを比較していきます。

【お題】
1.利用者IDが一致する利用者を取得する
2.コレクションに要素を追加する

コレクションを直接操作する場合

// Userクラスのインスタンス化と配列作成
$user1 = new User(1, "太郎", 25);
$user2 = new User(2, "次郎", 23);
$user3 = new User(3, "花子", 20);
$userArray = [$user1, $user2, $user3];

// ********************************
// 1.利用者IDが一致する利用者を取得
// ********************************
// (1)filterパターン
$filteredArray = array_filter($userArray, fn($x) => $x->Id() === 2);
$targetUser1   = array_shift($filteredArray);
echo $targetUser1; // -> ID:2 , 名前:次郎 , 年齢:23

// (2)foreachパターン
$targetUser2 = null;
foreach ($userArray as $user) {
    if ($user->Id() === 2) {
        $targetUser2 = $user;
        break;
    }
}
echo $targetUser2; // -> ID:2 , 名前:次郎 , 年齢:23

// ********************************
// 2.コレクションに要素を追加
// ********************************
// (1)Userクラスのオブジェクトを追加
$user4 = new User(4, "桃子", 18);
$userArray[] = $user4;
foreach ($userArray as $user) {
    echo $user . "\n";
}

// -> ID:1 , 名前:太郎 , 年齢:25
// -> ID:2 , 名前:次郎 , 年齢:23
// -> ID:3 , 名前:花子 , 年齢:20
// -> ID:4 , 名前:桃子 , 年齢:18 ・・・OK!

// (2)Stringを追加
$userArray[] = "三郎";
foreach ($userArray as $user) {
    echo $user . "\n";
}

// -> ID:1 , 名前:太郎 , 年齢:25
// -> ID:2 , 名前:次郎 , 年齢:23
// -> ID:3 , 名前:花子 , 年齢:20
// -> ID:4 , 名前:桃子 , 年齢:18
// -> 三郎・・・NG(Userクラスのオブジェクトではないが追加されてしまう)

上記のコードについての問題点

1.利用者IDが一致する利用者を取得する

  • 利用者IDが一致する利用者の取得方法が複数考えられるため、実装者によって実装方法が異なる恐れがある(array_filter + array_shiftで取得する方法、foreachで取得する方法など)
  • そもそも何をやっているかわかりづらい
  • 取得処理をメソッド化することで実装方法のばらつきは回避できるが、コレクションに対する操作は色々な箇所から実行されがちなため至る所にコレクション操作用のメソッドが乱造されたり、ユーティリティクラスが作られて段々と肥大していく(経験談)

、、、、、つまり、コレクション側に操作がまとまっていればいいのでは?💡

2.コレクションに要素を追加する

  • $userArrayの要素にはUserクラスのオブジェクトだけ入っていてほしいが、何でも入ってしまう(PHPにlist<T>があれば...)

、、、、、要素の追加時に型チェックを強制できればいいのでは?💡

ファーストクラスコレクションで操作する場合

というわけで解決編です。

まずは以下のソースをご確認ください。ファーストクラスコレクションのテンプレートとなる抽象クラスと、具体的な処理を実装した具象クラスという構成になっています。

FirstClassCollectionAbstract.php
/** ファーストクラスコレクション用抽象クラス */
abstract class FirstClassCollectionAbstract implements \IteratorAggregate
{
    private array $_array;

    protected function __construct(array $array) {
        $this->_array = $array;
    }

    public function getIterator(): \Traversable {
        return new \ArrayIterator($this->_array);
    }

    /** 要素を追加する */
    public function add($val): static {
        // 追加する要素のみバリデーションを行う(型チェックなど)
        // ※既存の要素はadd()、createFromArray()でチェック済みであるため
        if (!static::isValid($val)) {
            throw new \InvalidArgumentException("引数が正しくありません");
        }
        return new static([...($this->_array), $val]);
    }

    /** 要素数を返す */
    public function count(): int {
        return count($this->_array);
    }

    /** 条件に一致する要素のみに絞った新しいコレクションを返す */
    public function filter(?callable $callback): static {
        return new static(array_values(array_filter($this->_array, $callback)));
    }

    /** 条件に一致する最初の要素を返す */
    public function first(?callable $callback) {
        $filteredArray = array_filter($this->_array, $callback);
        return array_shift($filteredArray);
    }

    public static function create(): static {
        return new static([]);
    }

    public static function createFromArray(array $array): static {
        // 全ての要素でバリデーションを行う(型チェックなど)
        foreach ($array as $val) {
            if (!static::isValid($val)) {
                throw new \InvalidArgumentException("引数が正しくありません");
            }
        }
        return new static($array);
    }

    /** バリデーション
     *  継承先で型チェックなどを行う
     *  @param mixed $obj
     *  @return bool true: OK, false: NG
     */
    abstract protected static function isValid(mixed $obj): bool;
}
UserList.php
/** 利用者クラス用ファーストクラスコレクション */
final class UserList extends FirstClassCollectionAbstract
{
    /** IDが一致する要素を返す
     *  @param int $id 利用者ID
     *  @return ?User
     */
    public function getById(int $id): ?User {
        return $this->first(fn(User $user) => $user->Id() === $id); 
    }

    /** バリデーション
     *  Userかをチェック
     *  @param mixed $obj
     *  @return bool true: OK, false: NG
     */
    protected static function isValid(mixed $obj): bool {
        return ($obj instanceof User);
    }
}

今回のお題である以下の操作についてはそれぞれ以下のメソッドが対応しています。

1.利用者IDが一致する利用者を取得する
→UserListクラスのgetByIdメソッド

2.コレクションに要素を追加する
→FirstClassCollectionAbstract抽象クラスのaddメソッド

// Userクラスのインスタンス化と配列作成
$user1 = new User(1, "太郎", 25);
$user2 = new User(2, "次郎", 23);
$user3 = new User(3, "花子", 20);
$userList = UserList::createFromArray([$user1, $user2, $user3]);

// ********************************
// 1.利用者IDが一致する利用者を取得
// ********************************
// (1)filterパターン
$targetUser1 = $userList->getById(2);
echo $targetUser1; // -> ID:2 , 名前:次郎 , 年齢:23

// (2)foreachパターン ※今まで通りの書き方もできます
$targetUser2 = null;
foreach ($userList as $user) {
    if ($user->Id() === 2) {
        $targetUser2 = $user;
        break;
    }
}
echo $targetUser2; // -> ID:2 , 名前:次郎 , 年齢:23

// ********************************
// 2.コレクションに要素を追加
// ********************************
// (1)Userクラスのオブジェクトを追加
$user4 = new User(4, "桃子", 18);
$userList1 = $userList->add($user4);
foreach ($userList1 as $user) {
    echo $user . "\n";
}

// -> ID:1 , 名前:太郎 , 年齢:25
// -> ID:2 , 名前:次郎 , 年齢:23
// -> ID:3 , 名前:花子 , 年齢:20
// -> ID:4 , 名前:桃子 , 年齢:18 ・・・OK!

// (2)Stringを追加
$userList2 = $userList->add("三郎"); // addメソッドのバリデーションでエラーとなる

// -> PHP Fatal error:  Uncaught InvalidArgumentException: 引数が正しくありません

上記のコードで何が改善したか

1.利用者IDが一致する利用者を取得する

  • 取得処理がファーストクラスコレクションであるUserListクラスに実装されたことによりデータと操作が1つのクラスにまとまったことで凝集度が高まった
  • 処理がカプセル化されたことでgetByIdメソッドに利用者IDを渡すだけでよくなった(フィルターしたりforeachで回したり※、取得方法を実装者が気にしなくてよくなった)
    ※とはいえforeachで回して何かしたいときもあるのでforeachも今まで通りできます
  • その他の処理(例 要素数のカウント)などもUserListクラスに実装し、戻り値をUserListクラスのオブジェクトにすることでメソッドチェーンとして記述できるようになった
要素数のカウント
$user1 = new User(1, "太郎", 25);
$user2 = new User(2, "次郎", 23);
$user3 = new User(3, "花子", 20);
$userArray = [$user1, $user2, $user3];
$userList = UserList::createFromArray([$user1, $user2, $user3]);

// これだとあまり違いがわからないですが...
echo "userArray:" . count($userArray);
// -> userArray:3
echo "userList:" . $userList->count();
// -> userList:3

// フィルターとかすると可読性に差が出るかも?
echo "userArray:" . count(array_filter($userArray, fn($x) => $x->Age() > 20));
// -> userArray:2
echo "userList:" . $userList->filter(fn($x) => $x->Age() > 20)->count();
// -> userList:2

、、、、、つまり、コレクション側に操作がまとまっていればいいのでは?💡

どうでしょう、コレクション側に操作がまとまり無事に改善したんじゃないでしょうか?
メソッドチェーンで記述できるようになったことで個人的には可読性が上がったように感じます。
C#でLINQを使っていた方や(私は書いたこと無いので想像ですが)JavaでStream APIを使っていた方には何となく見やすいんじゃないでしょうか?


2.コレクションに要素を追加する

  • カプセル化により要素の追加はaddメソッドを使用するようになったため、addメソッド内にバリデーションを実装することで型チェックを強制できるようになりました!

、、、、、要素の追加時に型チェックを強制できればいいのでは?💡

上記の通り型チェックを強制できるようになったためこちらも無事に改善したんじゃないでしょうか?

今回バリデーションで行っているのは型チェックのみですが、例えば新たに「20歳未満は登録できないようにしてほしい」という要件が出てきた場合などにもすぐ対応できるんじゃないかと思います。

FirstClassCollectionAbstract.php (addはisValidを呼び出しているだけで特に変更無し)
/** ファーストクラスコレクション用抽象クラス */
abstract class FirstClassCollectionAbstract implements \IteratorAggregate
{
    private array $_array;

    protected function __construct(array $array) {
        $this->_array = $array;
    }

    // コード一部省略

    /** 要素を追加する */
    public function add($val): static {
        // 追加する要素のみバリデーションを行う(型チェックなど)
        // ※既存の要素はadd()、createFromArray()でチェック済みであるため
        if (!static::isValid($val)) {
            throw new \InvalidArgumentException("引数が正しくありません");
        }
        return new static([...($this->_array), $val]);
    }

    // コード一部省略
}
UserList.php (20歳以上チェック追加版)
/** 利用者クラス用ファーストクラスコレクション */
final class UserList extends FirstClassCollectionAbstract
{
    // コード一部省略

    /** バリデーション
     *  Userかをチェック + 20歳以上か?
     *  @param mixed $obj
     *  @return bool true: OK, false: NG
     */
    protected static function isValid(mixed $obj): bool {
        return ($obj instanceof User) && $obj->Age() >= 20; // 20歳未満はNG
    }
}

おわりに

Qiitaへの投稿が久しぶりなのと、Qiitaへ投稿するにあたり手直ししましたが元となるサンプルコードを書いたのが2年ほど前だったため忘れていたことを色々振り返ることができ復習の良い機会になりました。

コードの記述量自体は増えるものの、コレクションに対する操作が一箇所にまとまっているため変更が容易であり、利用者側もメソッドを呼び出すだけで操作できるのでトータルで見たら負担が減るのではないかと思います。
(低凝集なコードにありがちな肥大化するユーティリティクラスの恐怖から解放されそうです☺️)

また、今までは本を読んでサンプルコードを少し書いて終わりにしていましたが、こうやって投稿することで上級者の方からアドバイスを貰えたり、最近プログラミングを始めたけどソースコードが散らかって困ってるという初心者の方の力になれる機会があるかもしれないなと感じました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?