WHITEPLUS TechBlog

株式会社ホワイトプラスのエンジニアによる開発ブログです。

PHP で null安全

前提

PHP >= 8.0
PHPStan: 1.2.0

null安全とは

  • 簡単にいうと、null が原因で実行時エラーにならない仕組みのこと。
  • コンパイラや静的解析ツールによって nullable 型とnon-nullable型を区別し、必要な null チェックが機械的に強制されることで、null を安全に扱うことができる。

  • PHP では null に対してメソッド呼び出しを行うとエラーになるので null 安全ではない。

  • null に対するプロパティアクセスはエラーにならないものの、想定外の挙動であることが大半だと思うのでバグを引き起こす可能性がある。
<?php

$user = null;
echo $user->getAddress()->getCountry(); // Error: Call to a member function address() on null
var_dump($user->address->country);  // null

PHPStan で null安全にする

  • PHPの静的解析ツールである PHPStan を使うと、nullable 型に対して直接プロパティアクセスやメソッド呼び出しを行なっている箇所をエラーとして検出する事ができます(解析レベル 8 以上)。
<?php

class User
{
    public function __construct(private ?Order $order)
    {
    }

    public function echoOrder(): void
    {
        echo $this->order->id;          // Cannot access property $id on Order|null.
        echo $this->order->createdAt(); // Cannot call method createdAt() on Order|null.
    }
}

class Order
{
    public function __construct(public string $id, public DateTimeImmutable $createdAt)
    {
    }

    public function createdAt(): string
    {
        return $this->createdAt->format('Y-m-d');
    }
}

https://phpstan.org/r/f2b5ee35-b230-478e-9eb5-fd519df760d4

  • これで null ハンドリングが漏れていることに気づけるようになりました。

nullハンドリング(Null Object パターンから null safe演算子へ)

  • null をハンドリングする際にnull チェックが重なると辛いため、これを回避するためのパターンに Null Object パターンがありますが、使うのが適切でないケースもあります。

null object と not-null object で型を区別したい

  • 注文した商品を生産する工場を例に挙げます。
  • 工場(=Factory クラス)の取得処理では、工場が存在しない場合を Null Object パターンを使って NullFactory として表現してみます。
<?php

class Factory
{
    public function __construct(private string $code)
    {
    }

    public function isNull(): bool
    {
        return false;
    }

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

class NullFactory extends Factory
{
    public function isNull(): bool
    {
        return true;
    }

    public function code(): string
    {
        return '';
    }
}

interface FactoryRepository
{
    public function find(string $code): Factory;  // 存在しない場合は NullFactory を返す
}

class SomeUseCase
{
    public function __construct(private FactoryRepository $factoryRepository)
    {
    }

    public function factoryCode(): string
    {
        return $this->factoryRepository->find('9000')->code();
    }
}
  • すると、注文クラス(=Order)では存在する工場を割り当てたいので型としては Factory ですが、中身が Null Object かどうかをチェックしなければなりません。
<?php

class Order
{
    private Factory $factory;

    public function __construct(Factory $factory)
    {
        $this->setFactoryCode($factory);
    }

    private function setFactoryCode(Factory $factory)
    {
        if ($factory->isNull()) {  // Null Object かどうかチェック !?
            throw new \InvalidArgumentException('工場が存在しない');
        }
        $this->factory = $factory;
    }
}
  • Factory 型で受け取っているにも関わらず Null Object かどうかをチェックしなければならない分、型としての強制力が弱くなってしまっています。
  • 他のクラスでもFactory 型を使う度に、存在しない工場も受け入れるのか、それとも存在する工場だけ受け入れるのかを考えて Null Object かどうかのチェックを入れるかどうか、人間が判断し続けなければなりません。
  • Null Object パターンは null かどうかを判別せずに共通したインターフェースで実行可能にするものでしたが、ここではそれが裏目に出ている感があります。
  • この状態では、Null Object パターンを導入するデメリットが大きいため、存在する工場だけを Factory で表現し、存在しない工場は null で表現した方がいいと考えます。

上記以外では、「Null クラスで存在しない場合の振る舞いを定義するものの、使うコンテキストによって異なる振る舞いにしたい」場合は Null Object パターンが不敵なケースとして一般的に言われています。

null safe 演算子を使う

  • PHP8 未満で存在しないものを null で表現する時、ネストになり読みづらくなってしまう場合があります。
<?php

$country =  null;
if ($session !== null) {
    $user = $session->user;
    if ($user !== null) {
        $address = $user->getAddress();
        if ($address !== null) {
            $country = $address->country;
        }
    }
}
  • PHP8.0 ではそれを解決するために null safe 演算子が導入され、スッキリ書くことができるようになりました。
<?php

$country = $session?->user?->getAddress()?->country;
  • null safe 演算子を使って先ほど Null Object パターンで実装したコードを書き換えてみましょう。
<?php

interface FactoryRepository
{
    public function find(string $code): ?Factory;  // 存在しない場合は null を返す
}

class SomeUseCase
{
    public function __construct(private FactoryRepository $factoryRepository)
    {
    }

    public function factoryCode(): string
    {
        return $this->factoryRepository->find('9000')?->code() ?? '';
    }
}
  • これで null Object パターンを使わなくても、null をハンドリングする辛さがだいぶ軽減されますね。

誤ってnon-nullable 型をnullハンドリングさせない

  • PHPStan は nullable 型において null ハンドリングを強制するだけでなく、non-nullable 型を誤って null ハンドリングしている所を除外するよう強制することがほぼほぼできています。
  • ただ、non-nullable 型のプロパティに対して null合体演算子を使用している所ではエラーを起こせておらず、これは PHPStan のバグだと思われます。
  • これが見過ごされても実行時エラーにはなりませんが、null を考慮している部分がデッドコードになることで可読性が落ちるので直ると嬉しいですね。
<?php

class User
{
    public function __construct(private Order $order2)
    {
    }

    public function echoOrder(): void
    {
        echo $this->order2?->id;    // PHPStan Error: Using nullsafe property access on non-nullable type Order
        echo $this->order2->createdAt()?->format('Y-m-d');    // PHPStan Error: Using nullsafe method call on non-nullable type DateTimeImmutable
        echo $this->order2->id ?? '';  // string 型に対して null を考慮した書き方になっているが、PHPStan エラーにはならない
        $this->order2->createdAt() ?? new DateTimeImmutable();    // PHPStan Error: Expression on left side of ?? is not nullable
    }
}

class Order
{
    public function __construct(public string $id, public DateTimeImmutable $createdAt)
    {
    }

    public function createdAt(): DateTimeImmutable
    {
        return $this->createdAt;
    }
}

https://phpstan.org/r/79764540-d0b9-44ce-aed9-6bd887cbbfe7

まとめ

  • PHPStan で null安全にしつつ、null safe 演算子を使うことで nullハンドリングをやり易くすることができました。
  • PHP も周辺ツールも日々進化しているので、新機能を上手く取り込みながら効率的に開発していきたいですね。

さいごに

ホワイトプラスでは、ビジョンバリューに共感していただけるエンジニアを募集しています!

ネットクリーニングの「リネット」など「生活領域×テクノロジー」で事業を展開している会社です。どんな会社か気になった方はオウンドメディア「ホワプラSTYLE」をぜひご覧ください。

オンラインでカジュアル面談もできますので、ぜひお気軽にお問い合わせください。

open.talentio.com open.talentio.com