😋

PHPでコレクションオブジェクト

2024/04/26に公開

今回のお話

コレクションオブジェクト、もしくはファーストクラスオブジェクトと呼ばれるものについてお話をします。

配列やコレクションはコードを複雑にする

同じ型のオブジェクトを複数持つ配列やコレクションを扱うコードは複雑になりがちです。
こうした配列、コレクションを扱うロジックがコードに多くなり始めると、コードの可読性が下がることに繋がります。

例えば架空のパーティの予約システムを考えてみましょう。
このシステムはパーティの参加者(attendee)の情報に応じて様々なドメインロジックがあります。
そのロジックたちの中には1予約における参加者(attendee)の集まりに対するロジックが複数あります。
また、参加者の状況によってパーティの中で予約できるコンテンツ(PartyContent)が変わってきます。
予約システムのコード例を以下に提示します。
このコードの中で注目すべきことは

  • 配列操作に対して似たようなロジックが存在してること
  • 配列の現在の状況が把握しづらくなること

です。

配列の要素になるクラス

Attendeeクラス
class Attendee
{
    private string $name;
    private int $age;
    private string $rate;
    public function __construct(string $name, int $age, string $rate)
    {
        $this->name = $name;
        $this->age = $age;
        $this->rate = $rate;
    }

    public function listAttendeeInfo(): string
    {
        return "名前: {$this->name}, 年齢: {$this->age}, レート: {$this->rate}";
    }

    public function isUnder20(): bool
    {
        return $this->age < 20;
    }
    public function isOver20(): bool
    {
        return $this->age >= 20;
    }

    public function isOver65(): bool
    {
        return $this->age >= 65;
    }

    public function isPremium(): bool
    {
        return $this->rate === 'premium';
    }
}

PartyContentクラス
class PartyContent
{
    private string $name;
    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function hasName(string $name): bool
    {
        return $this->name === $name;
    }

    public function isExtreme(): bool
    {
        return $this->name === 'extreme';
    }
}

attendeeを要素に持った配列への操作例

予約操作で参加者の年齢によっては大人の同伴者が必要な場合

コード例

 $atendees = [];

// $newAttendeesDataはフロントからの予約リクエスト
foreach($newAttendeesData as $attendeeDatum)
{
    $newAttendee = new Attendee(
        $attendeeDatum["name"],
        $attendeeDatum["age"],
        $attendeeDatum["rate"]
    );
    $attendees[] = $newAttendee;
}

// 20歳未満の参加者がいるかどうか
$existsUnder20 = false;
foreach($attendees as $attendee)
{
    if(!$attendee->isUnder20()) {
        $existsUnder20 = true;
        break;
    }
}
// 20歳未満の参加者がいる場合は20歳以上の同伴者が必要
if ($existsUnder20) {
    foreach($attendees as $attendee) {
        if (!$attendee->isOver20()) {
            // 予約の不許可
        }
    }
}
会場で用意している複数のパーティコンテンツを用意していて、予約者たちの情報によって選択可能なコンテンツは変わる場合

コード例

// $attendeesの変数は用意済みとして

$existsOver65 = false;
foreach($attendees as $attendee) {
    if ($attendee->isOver65()) {
        $existsOver65 = true;
        break;
    }
}

$existsUnder20 = false;
foreach($attendees as $attendee) {
    if ($attendee->isUnder20) {
        $existsUnder20 = true;
        break;
    }
}

$areAllPremium = true;
foreach($attendees as $attendee) {
    if (!$attendee->isPremium()) {
        $areAllPremium = false;
        break;
    }
}

$defaultPartyContents = [
    new PartyContent("Basic"),
    new PartyContent("Extended"),
    new PartyContent("Educational"),
    new PartyContent("Entertainment"),
    new PartyContent("Extreme"),
];

$availablePartyContents = $defaultPartyContents;

// 上の3つの変数を使い分岐処理を行う
// 20歳未満の参加者は一部のコンテンツを選択不可にする
if ($existsUnder20) {
    $availablePartyContents =
        array_filter(
            $availablePartyContents,
            fn($partyContent) => !$partyContent->isExtreme()
        );
}
    
// 65歳以上の参加者は追加のコンテンツを選択可能にする
if ($existsOver65) {
    $availablePartyContents[] = new PartyContent("Senior");
}
    
// 全員がプレミアムメンバーなら追加のコンテンツを選択可能にする
if ($areAllPremium) {
    $availablePartyContents[] = "new PartyContent("VIP")";
}

他にも長くなってしまうので例示しませんが、割引処理などで20歳以下の参加者が2人以上ならば10%割引するなど様々なロジックが存在しています。

さて、色々とコード例を例示してしまいましたが、どうでしたでしょうか。
(以下に私の思う問題を書いていきますが、他にもご意見があったらぜひコメントして下さい。)

複雑性を挙げている問題

配列への操作という2つの問題で上記コードの複雑性を上げて、可読性が下がっている思います。

問題1

上記のコードではそれぞれの処理毎に似たようなオブジェクトを要素に持つ配列が登場します。そして、その配列に対して重複したロジックが存在します。
このような重複ロジックが氾濫するようになってくると、業務上の変更による修正が必要な時に散り散りとなったロジックを見直す作業が発生します。
提示したコード例ではまだ見る箇所が少ないですが、プロダクトの成長に伴って重複箇所は増えて修正に必要とする時間が増加してしまうことになるでしょう。
(ちなみにコード例に存在する重複したロジックは、20歳未満が存在するかと20歳以上が存在するかという2つのロジックでした。見つけられましたか?)

問題2

配列を使う側のコードの処理で何をしたかったのかという意図が把握しづらいです。
そのため、配列の現在の状態も即座に把握しづらいです。

解決策

これらの2つの問題はメソッドとして処理を括り出すことで解決できます。
まず問題2について、 ロジックの意図が見づらい場合はメソッドとして処理を括り出し、その処理に適切なメソッド名をつけてあげる事が良いでしょう。
そうすることで、ロジックの複雑さをメソッド内に留めて、使う側は処理の内容を一見して把握できる事が理想です。
このメソッドに括り出す対処は問題1に対しても有効です。
散り散りにコードの各所に置かれた重複コードは1つのメソッドの形にして纏めて、それを使いましょう。
これで2つの問題は解決できました。

さて、今回のお話はこれからが本題です。
処理をまとめたメソッドは何処に置くべきなのかという話です。
ここでコレクションオブジェクト(ファーストクラスコレクション)という言葉が出てきます。

コレクションオブジェクト

定義

リスト、マップなどのコレクションをプリミティブとみなして、それをラップしたクラスをファーストクラスコレクションと呼びます。

以上の定義はThe ThoughtWorks Authrogyという本で紹介されているデザインパターンで、ファーストクラスコレクションはコレクションオブジェクトと同義です。
コレクションオブジェクトでは配列など複数要素のデータとデータに関するロジックを持っている専用クラスに閉じ込めます。
それぞれのコレクションオブジェクトは

  • 配列などのデータをプロパティとして1つだけ持ち、
  • 特定条件の要素を返したり、ループ処理を行うなどを行います。

このような専用クラスを用意すると使う側のコードは記述が簡単になります。

少し先ほどの話に戻りますが、解決策の項でロジックを括り出したメソッドはこのコレクションオブジェクトの中で管理することが良いでしょう。
オブジェクトなどを要素に持つ配列に対する操作はドメインでの重要なロジックである事があります。
先ほどの例で言うと、attendeeの配列が複数回出てきましたが、 それはattendeeの配列がこの予約システムにおける重要な関心事であって、業務の中に関連するロジックが存在するからです。
こうしたロジックをコレクションオブジェクトの中で配置することで、attendeeなどの1つの業務の単位としてのクラスでは収まらないロジックを1箇所で集中的に管理できます。

いざ、実装

コレクションオブジェクトの実装例

Attendeesクラス
class Attendees
{
    private iterable $attendees = [];
    public function __construct(iterable $attendees)
    {
        // ここで型チェックを行う
        $setAttendee =
            fn(Attendee ...$attendees) => $this->attendees = $attendees;
        $setAttendee(...$attendees);
    }

    public function existsUnder20(): bool
    {
        /** @var Attendee $attendee */
        foreach ($this->attendees as $attendee) {
            if (!$attendee->isOver20()) {
                return true;
            }
        }
        return false;
    }

    public function existsOver20(): bool
    {
        /** @var Attendee $attendee */
        foreach ($this->attendees as $attendee) {
            if ($attendee->isOver20()) {
                return true;
            }
        }
        return false;
    }

    public function existsOver65(): bool
    {
        /** @var Attendee $attendee */
        foreach ($this->attendees as $attendee) {
            if ($attendee->isOver65()) {
                return true;
            }
        }
        return false;
    }

    public function areAllPremium(): bool
    {
        /** @var Attendee $attendee */
        foreach ($this->attendees as $attendee) {
            if (!$attendee->isPremium()) {
                return false;
            }
        }
        return true;
    }
}
PartyContentsクラス
class PartyContents
{
    private iterable $partyContents = [];
    public function __construct(iterable $partyContents)
    {
        // ここで型チェックを行う
        $setPartyContent =
            fn(PartyContent ...$partyContents) => $this->partyContents = $partyContents;
        $setPartyContent(...$partyContents);
    }

    public function remove(string $name): PartyContents
    {
        $filtered =
            array_filter(
                $this->partyContents,
                fn(PartyContent $partyContent) => !$partyContent->hasName($name)
            );
        return new PartyContents($filtered);
    }

    public function add(string $name): PartyContents
    {
        $arr = [];
        foreach ($this->partyContents as $partyContent) {
            $arr[] = $partyContent;
        }
        $arr[] = new PartyContent($name);
        return new PartyContents($arr);
    }
}

コレクションオブジェクトの操作例

予約操作で参加者の年齢によっては大人の同伴者が必要な場合
$attendeeModelArr = [];
$newAttedeeData = [
    ["name" => "hoge", "age" => 20, "rate" => "premium"],
    ["name" => "fuga", "age" => 19, "rate" => "normal"],
];
foreach ($newAttedeeData as $attedeeDatum) {
    $attendeeModelArr[] = new Attendee(
        $attedeeDatum["name"],
        $attedeeDatum["age"],
        $attedeeDatum["rate"]
    );
}
$attendees = new Attendees($attendeeModelArr);

if ($attendees->existsUnder20() && !$attendees->existsOver20()) {
    // 予約の不許可
}
会場で用意している複数のパーティコンテンツを用意していて、予約者たちの情報によって選択可能なコンテンツは変わる場合
$defaultPartyContents = new PartyContents([
    new PartyContent("Basic"),
    new PartyContent("Extended"),
    new PartyContent("Educational"),
    new PartyContent("Entertainment"),
    new PartyContent("Extreme"),
]);
$availablePartyContents = $defaultPartyContents;
if ($attendees->existsUnder20()) {
    $availablePartyContents = $availablePartyContents->remove("Extreme");
}
if ($attendees->existsOver65()) {
    $availablePartyContents = $availablePartyContents->add("Senior");
}
if ($attendees->areAllPremium()) {
    $availablePartyContents = $availablePartyContents->add("VIP");
}

まとめ

コレクションオブジェクト(ファーストクラスコレクション)はオブジェクト指向の考えに基づいています。
こうした配列(および他言語におけるコレクション)及び、ロジックを1つのクラスに閉じ込め整理する事で、修正による影響範囲をコントロールできて変更容易性が向上します。
また、コレクションオブジェクトの要素になるオブジェクト(ドメインモデル)に関することも今後書いて行けたらと思っています。
私が前回書きました バリューオブジェクト にも関連しますので、併せて一読できるように記事を整理していこう思います。

ソーシャルデータバンク テックブログ

Discussion