NullObjectパターンを覚えた

出品者のデータにフィルタをかけて該当する出品者のSellerオブジェクトを返すメソッドがあるとします。

サンプルコード

<?php

class Seller
{
    private $data;

    /**
     * Seller constructor.
     * @param $data
     */
    public function __construct(array $data)
    {
        // 出品者の情報が色々入ったデータ
        $this->data = $data;
    }

    /**
     * 出品者の価格
     * @return int
     */
    public function price(): int
    {
        return $this->data['price'];
    }

    /**
     * 出品者の評価率
     * @return int
     */
    public function rate(): int
    {
        return $this->data['rate'];
    }
}
<?php

class Filter
{
    /**
     * @var Seller[]
     */
    private $sellers;

    /**
     * Filter constructor.
     * @param Seller[]
     */
    public function __construct(array $sellers)
    {
        // 販売者のクラスが入った配列
        $this->sellers = $sellers;
    }

    /**
     * 評価率が80%以上で最安値の出品者情報を取得する
     * @return Seller
     */
    public function featuredSeller(): Seller
    {
        $lowestPrice = null;
        $featuredSellerObject = null;
        foreach ($this->sellers as $seller) {
            // 評価率80%以上かつ最安値の出品者に絞る
            if (80 <= $seller->rate() && $this->isLowestPrice($seller, $lowestPrice)) {
                $lowestPrice = $seller->price();
                $featuredSellerObject = $seller;
            }
        }
        return $featuredSellerObject;
    }

    /**
     * @param Seller $seller
     * @param $lowestPrice
     * @return bool
     */
    private function isLowestPrice(Seller $seller, $lowestPrice): bool
    {
        return ($seller->price() < $lowestPrice || !$lowestPrice);
    }
}
<?php

// 出品者データ
$sellers[] = new Seller(['rate' => 79, 'price' => 500]);
$sellers[] = new Seller(['rate' => 60, 'price' => 700]);

// 出品者にフィルターをかけて結果を取り出す
$filter = new Filter($sellers);
// 評価率80%以上で最安値の出品者を抽出する
print_r($filter->featuredSeller());

featuredSellerメソッドは評価率が80%以上で最安値の出品者を返しますが、出品者のデータの中に評価率80%以上の出品者がいない場合、nullが返りエラーになります。

<?php

PHP Fatal error:  Uncaught TypeError: Return value of Filter::featuredSeller() must be an instance of Seller, null returned in /PhpstormProjects/nullobject/filter.php:36
Stack trace:
#0 /PhpstormProjects/nullobject/main.php(10): Filter->featuredSeller()

エラーが出ないようにするためには返り値の型をfeaturedSeller(): ?Sellerに書き換えてnullも許容できるようにしてあげないといけません。

<?php

    /**
     * 評価率が80%以上で最安値の出品者情報を取得する
     * @return Seller
     */
    public function featuredSeller(): ?Seller // 返り値の型をnull or Sellerにする
    {
        $lowestPrice = null;
        $featuredSellerObject = null;
        foreach ($this->sellers as $seller) {
            // 評価率80%以上かつ最安値の出品者に絞る
            if (80 <= $seller->rate() && $this->isLowestPrice($seller, $lowestPrice)) {
                $lowestPrice = $seller->price();
                $featuredSellerObject = $seller;
            }
        }
        return $featuredSellerObject;
    }

しかし、?Sellerとしてしまうと利用する側でnullが返ってきているのか、Sellerオブジェクトが返ってきているのかいちいち確認しないといけません。 もしnullチェックを忘れてSellerクラスのメソッドを使用するとエラーになります。

<?php

$filter = new Filter($sellers);
// $filter->featuredSeller()の返り値がnullかどうか確認する必要がある
if ($seller = $filter->featuredSeller()) {
    echo $seller->price();
}
// nullチェックを忘れてSellerクラスのメソッドを使用するとエラーになる
echo $filter->featuredSeller()->price();

NullObjectパターンを導入する

<?php

interface SellerInterface
{
    public function rate();
    public function price();
}
<?php

// SellerInterfaceを実装したメソッドは使えるが何も返さないクラス
class NullSeller implements SellerInterface
{
    public function price()
    {
        return null;
    }

    public function rate()
    {
        return null;
    }
}
<?php

class Seller implements SellerInterface //SellerInterfaceを実装
{
    private $data;

    /**
     * Seller constructor.
     * @param $data
     */
    public function __construct(array $data)
    {
        // 出品者の情報が色々入ったデータ
        $this->data = $data;
    }

    /**
     * 出品者の価格
     * @return int
     */
    public function price(): int
    {
        return $this->data['price'];
    }

    /**
     * 出品者の評価率
     * @return int
     */
    public function rate(): int
    {
        return $this->data['rate'];
    }
}
<?php

    /**
     * 評価率が80%以上で最安値の出品者情報を取得する
     * @return Seller
     */
    public function featuredSeller(): SellerInterface // SellerInterfaceを返り値にする
    {
        $lowestPrice = null;
        $featuredSellerObject = new NullSeller(); // NullSellerを初期値として入れておく
        foreach ($this->sellers as $seller) {
            // 評価率80%以上かつ最安値の出品者に絞る
            if (80 <= $seller->rate() && $this->isLowestPrice($seller, $lowestPrice)) {
                $lowestPrice = $seller->price();
                $featuredSellerObject = $seller;
            }
        }
        return $featuredSellerObject;
    }

条件に当てはまる出品者がいない場合、nullを返すのではなくNullSellerオブジェクトを返すようにし、またSellerInterfacefeaturedSellerメソッドの返り値とすることで、呼び出した側でnullが返ってくるのか確認する必要がなくなります。

<?php

// 出品者
$sellers[] = new Seller(['rate' => 79, 'price' => 500]);
$sellers[] = new Seller(['rate' => 60, 'price' => 700]);

$filter = new Filter($sellers);
// エラーにならない
echo $filter->featuredSeller()->price();

NullObjectパターンを導入することで、

  1. returnの型を明確にできる
    返り値の型を?Sellerとする必要がなく、nullもしくはSellerが返るという曖昧な書き方をしなくて済む。

  2. 返ってきた値に対してif文でnullかどうかいちいち確認しなくても良い

というメリットが得られます。

ただ昨日初めて使ったのでそもそも本来のNullObjectパターンとずれてるかもしれないし、後々にやっぱこれアンチパターンだわって気づくかもしれない。

github.com