超PHPerになろう

Enjoy PHP Programming

戻り値の記憶と忘却

この記事はPHPStan開発者のOndřej Mirtesによって2021年4月3日にPHPStan Blogに書かれた記事を翻訳したものです。

phpstan.org

PHPは記憶されるべき関数呼び出しの戻り値について、これまで一貫性がありませんでした。

<?php

$person = new Person();

if ($person->getName()) {
    \PHPStan\dumpType($person->getName()); // string|null
}

if ($person->getName() !== null) {
    \PHPStan\dumpType($person->getName()); // string
}

この挙動にはあまり意味がなく、多かれ少なかれ実装の事故の原因となっていました。

なぜこれらの呼び出しをまったく覚えていないのでしょうか? 関数(主にゲッターメソッド)を呼び出したあと、次に呼び出しをしたときに違う値が返ってくるリスクがあるからです。

最新のPHPStanリリースではbleeding edge*1を有効にすると、一貫性があるようにこの挙動を制御できるようになります。

<?php

// With PHPStan 0.12.83 + bleeding edge

$person = new Person();

if ($person->getName()) {
    \PHPStan\dumpType($person->getName()); // string
}

if ($person->getName() !== null) {
    \PHPStan\dumpType($person->getName()); // string
}

関数宣言とメソッド宣言に記述する新しいアノテーションとして@phpstan-pure@phpstan-impureがあります。さらに、関数およびメソッドの戻り値宣言が void の場合はimpureだと判断します。

純粋関数(pure function)は入力(オブジェクトの状態および引数)に対して、常に同じ値を返し、副作用を持ちません。非純粋関数(impure function)は副作用を持ち、返り値は入力に関わらず変わるかもしれません。

関数の戻り値は @phpstan-impureアノテーションされていない関数に対してのみ記憶されます。

<?php

/** @phpstan-impure */
function impureFunction(): bool
{
    return rand(0, 1) === 0 ? true : false;
}

if (impureFunction()) {
    \PHPStan\dumpType(impureFunction()); // bool
}

既に戻り値型が記憶されたオブジェクトを非純粋メソッド呼び出しの引数として渡すと、記憶された型を忘却します。

<?php

if ($person->getName() !== null) {
    \PHPStan\dumpType($person->getName()); // string
    $person->setName('John Doe');
    \PHPStan\dumpType($person->getName()); // string|null
}

オブジェクトを非純粋なメソッドに渡したとき、状態を忘却せず記録し続けてほしい状況に陥ることがあります。説明を簡単にするため、Rectorのコードベースからコードを抜粋します。

<?php

/**
 * @param ClassMethod $node
 */
public function refactor(Node $node): ?Node
{
    if ($node->stmts === null) {
        return null;
    }
    
    // ここでは $node->stmts === null ではありえない

    $classMethodStatementCount = count($node->stmts);

    for ($i = $classMethodStatementCount - 1; $i >= 0; --$i) {
        // PHPStan reports:
        // Offset int does not exist on array<PhpParser\Node\Stmt>|null.
        $stmt = $node->stmts[$i];
        $prevStmt = $node->stmts[$i - 1];
        if (! $this->isBothMethodCallMatch($stmt, $prevStmt)) {
            if (count($this->collectedMethodCalls) >= 2) {
                // this is an impure method
                // it will reset that $node->stmts isn't null
                $this->fluentizeCollectedMethodCalls($node);
            }

            continue;
        }
    }

    return $node;
}

その場合の解決策はシンプルで、変数に代入することです!

<?php

/**
 * @param ClassMethod $node
 */
public function refactor(Node $node): ?Node
{
    // save $node->stmts to a variable so it does not reset
    // after impure method call
    $stmts = $node->stmts;
    if ($stmts === null) {
        return null;
    }

    $classMethodStatementCount = count($stmts);

    for ($i = $classMethodStatementCount - 1; $i >= 0; --$i) {
        // No errors!
        $stmt = $stmts[$i];
        $prevStmt = $stmts[$i - 1];
        if (! $this->isBothMethodCallMatch($stmt, $prevStmt)) {
            if (count($this->collectedMethodCalls) >= 2) {
                $this->fluentizeCollectedMethodCalls($node);
            }

            continue;
        }
    }

    return $node;
}

新しい挙動は同じ質問を二度したあとのデッドコードを検出することにも役立ちます。

<?php

if ($product->isGiftCard()) {
    // do a thing...
    return;
}

// PHPStan reports:
// If condition is always false.
if ($product->isGiftCard()) {
    // do a different thing...
    return;
}

さらには深刻なバグを検出することにも役立ちます。is_dir($x)のような関数は clearstatcache() 関数を呼ぶまで結果がキャッシュされており、ファイルシステムを参照しないことを知っていましたか?

<?php

if (is_dir($dir)) {
    return;
}

\PHPStan\dumpType(is_dir($dir)); // false

clearstatcache();

\PHPStan\dumpType(is_dir($dir)); // bool

訳者による補足

この実装はメソッドに絞って説明されていますが、実際にはプロパティに対しても十全に機能します。筆者が一年前に公開したarray shapes記法(Object-like arrays)と旧PSR-5記法で型をつける - Qiitaでは、プロパティの型が記憶されないため以下のように一時変数に代入することで対応していました。

<?php

$begin = $options->begin;
if ($begin !== null) {
    $timestamp['begin'] = $options->begin->getTimestamp();
}

このテクニックは本文中にあるようにプロパティやメソッドの結果の型を忘却させないためにも使い続けることができます。

<?php declare(strict_types = 1);

$book = new Book();

if ($book->name !== null) {
    \PHPStan\dumpType($book->name); // string
    mydump($book);
    \PHPStan\dumpType($book->name); // string|null
}

class Book {
    public function __construct(
        public ?string $name = null,
    ) {}
}

/**
 * @param mixed $v
 */
function mydump($v): void
{
    var_dump($v);
}

動作確認: Playground | PHPStan

型を忘却するのは以下のような経験則的な条件によることは気をつけてください

  • オブジェクトが非純粋な関数/メソッド呼び出しの引数に渡される
  • オブジェクトの非純粋なメソッドが呼ばれる

上記の mydump() のように外部出力するだけの関数であっても型が忘却されることには気をつけてください。

たとえば、下記の setName() のように状態が更新されるにも拘らず、$thisを返すメソッド、あるいはその成否をboolで返すようなメソッドは @phpstan-impure タグを付けない限り、忘却する対象とはならないことには十分に気をつけてください。

<?php declare(strict_types = 1);

$book = new Book();

if ($book->name !== null && $book->getName() !== null) {
    \PHPStan\dumpType($book->name);
    $book->setName(null);
    \PHPStan\dumpType($book->name);
}

class Book {
    public function __construct(
        public ?string $name = null,
    ) {}
    
    /**
    * @phpstan-impure
    */
    public function setName(?string $name): self
    {
        $this->name = $name;
        
        return $this;
    }
}

動作確認: Playground | PHPStan

本記事を書いている段階ではPHPStanはイミュータブルクラスの概念をサポートしていないのですが、将来的に実装されたときには型を絶対に忘却しないというように発展することも期待できますね。

*1:この型推論の変更は多くのプロジェクトの検査を失敗させるおそれがあるため後方互換性維持のポリシーに基き、デフォルトでは古い挙動が維持されます