超PHPerになろう

Enjoy PHP Programming

条件付き戻り値型とPHPStan 1.6.0の新機能

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

phpstan.org

条件付き戻り値型 (Conditional return types)

この機能の大部分はRichard van Velzenが開発しました。

PHPStanは初リリース以来、関数呼び出しで渡された引数によって様々な型を返す方法を提供してきました。いわゆる動的戻り値型拡張(dynamic return type extensions)は非常に柔軟です。実装できる任意のロジックによって型を解決できます。しかし、PHPStan拡張の核心となるコンセプトには学習コストがかかります。

PHPStan 0.12ではジェネリクスが導入されました。これはPHPDocの特別な記法によって動的戻り値型拡張が必要だったケースの一部をカバーしています。

www.phper.ninja

そして今日、PHPStanはこれらの高度な機能へのアクセシビリティについて新たな一歩を踏み出しました。この機能を使うために、もはやPHPStan拡張の達人になる必要はありません。それはもはや、「ノーコード」ソリューションとも呼べるかもしれません 🤣

条件付き戻り値型(Conditional Return Types)はPHPDocの@returnタグに if-else ロジックを記述できるものです。

<?php

/**
 * @return ($as_float is true ? float : string)
 */
function microtime(bool $as_float): string|float
{
    ...
}

条件付き戻り値型はジェネリクスと組み合わせることもできます。

<?php

/**
 * @template T of object
 * @param class-string<T> $class
 * @return ($throw is true ? T : T|null)
 */
function getService(string $class, bool $throw = true): ?object
{
    ...
}

ジェネリックテンプレート型は条件の中にも記述できます。

<?php

/**
 * @template T of int
 * @template U
 * @param T $size
 * @param U $value
 * @return (T is positive-int ? non-empty-array<U> : array<U>)
 */
function fillArray(int $size, $value): array
{
    ...
}

より複雑な条件は、条件型をネストすることで記述できます。

<?php

/**
 * @param int|float $a
 * @param int|float $b
 * @return ($a is int ? ($b is int ? int : float) : float)
 */
function add($a, $b) {
    return $a + $b;
}

整数マスク型

この機能はRichard van Velzenによって実装されました。

関数の振る舞いを設定するための一般的な方法のひとつとして、さまざまなビットフラグを| 演算子で結合した整数値として受け取ることがあります。

<?php

echo json_encode($a, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE);

このパターンを成立させるためには、1, 2, 4, 8, ... といった2の累乗の値である必要があります。

これをPHPStanで利用できるようになりました。

<?php

const FOO = 1;
const BAR = 2;
const BAZ = 4;

/** @param int-mask<FOO, BAR, BAZ> $flag */
function test(int $flag): void
{
    $isFoo = ($flag & FOO) !== 0;
    $isBar = ($flag & BAR) !== 0;
    $isBaz = ($flag & BAZ) !== 0;
}

test(FOO); // OK
test(FOO | BAR); // OK
test(FOO | 8); // Error: Parameter #1 $flag of function test expects int<0, 7>, 9 given.

There’s also the int-mask-of<...> variant which accepts a union type of integer values instead of comma-separated values:

<?php

class HelloWorld
{
    const FOO_BAR = 1;
    const FOO_BAZ = 2;

    /** @param int-mask-of<self::FOO_*> $flags */
    public static function sayHello(int $flags): void
    {
        ...
    }
}

メモリ消費量の削減

これまでのPHPStanは飢えた獣でした。php.iniでmemory_limitに設定された上限のメモリを使用してしまうばかりでなく、ハードウェアに割り当てられたメモリまでもを食い潰し、CIランナーにkillされてしまうまでになってしまいました。

そんな獣も、もはや前ほどに飢えることはなくなりました。何が獣を変えたのでしょうか。実行中のプログラムの内部で中で何が起こっているのかを知ることは役に立ちます。有意義なことを成し遂げるのにメモリを使うのは問題ありませんが、トータルでのメモリ使用量を削減するためには次のファイルを解析するために必要なデータを再利用し、開放する必要があります。

メモリリークデバッグするには、php-meminfo拡張を利用してメモリに保持されている全てのスカラー値とオブジェクトをJSONファイルに書き出します。また、さまざまな統計を生成するアナライザーが付属しているので、どこから最適化に着手すべきかわかります。

そうすると、私はすぐにメモリがASTのノードに占有されていることに気がつきました。PHP参照カウントという方式でメモリを管理していて、基本的にオブジェクトが参照されなくなると占有していたメモリを解放します。ところがASTノードはオブジェクトを相互に参照するので、期待通りには機能しません。

もちろん、このような循環参照を回収するガベージコレクタもあります。しかしPHPStanは時間的な性能が向上したのでオフにしました。循環が多いとgc_disable()は多くのCPU時間を消費します。

ノードのparent/previous/next属性を削除するとそれらを読み取るカスタムルールの下位互換性を損ねてしまうので、いまのところはBleeding edge設定が有効の場合のみ有効化します。このようなメモリを浪費する属性なしでこれらのルールを機能させる方法についての記事を書きました。

このような工夫によって gc_disable() を使わなくてもパフォーマンスが低下することはなくなったので、使わないようにしました。(PHP 7.3でガベージコレクタが大幅に改善されたので、PHP 7.2では引き続き利用しています)

私の検証ではBleeding Edgeを有効化することで、PHPStanのメモリ使用量は50〜70%程度減少しました。

Bleeding Edgeを有効化するには phpstan.neon を以下のように変更します。

includes:
    - vendor/phpstan/phpstan/conf/bleedingEdge.neon

完全に静的なリフレクション

2020年6月に部分的に静的なリフレクションを備えたPHPStan 0.12.26をリリースしたことで多くのユーザーにメリットがありました。

訳注: リフレクションはクラスや関数の定義情報を取得する機能のことで、PHPStanはこれまで型情報の取得のためにPHPファイルの一部を実際に読み込むことで情報を取得していました

完全なリフレクションに置き換えると多くのリソースを消費してしまったので、この時点では部分的なものに留まりました。メモリ消費の最適化の一環としてBetterReflectionの一部を書き直したことで必要のないASTノードは保持しないようになりました。これについてはこちらのissueで説明しています

検証では実行時リフレクションと静的リフレクションのパフォーマンスの差はごくわずかしかなかったので、これは有望そうです。次のステップとして100%静的リフレクションに切り替える時期が来たようです。この切り替えによって、既にこれこれのようなエッジケースが解消されています。

現時点では静的リフレクションへの切り替えはBleeding Edgeに含まれていますが、私の計画ではPHPStan 1.xのリリースサイクル中に次のメジャーバージョンを待たずに有効化するつもりです。リリースは段階的に展開されるので、アーリーアダプターからのフィードバックを処理してからほかの全てのユーザーにも解放されます。

未知のプロパティについて isset()とnull合体演算子(??) を一貫させる

この機能は木村洋太さんによって開発されました。

数年間、PHPStanはこのような明白な矛盾に苦しめられていました。

<?php

class Foo
{

}

function (Foo $f): void {
    // エラーなし
    echo isset($f->prop) ? $f->prop : 'bar';

    // Error: Access to an undefined property Foo::$prop.
    // 未定義プロパティ Foo::$prop へのアクセス
    echo $f->prop ?? 'bar';
};

問題の2行は機能として等価ですが、それがPHPStanの振る舞いには反映されていませんでした。PHPStanはプロパティ名のタイプミスからユーザーを保護することを目的としていましたが、ほとんどの場合は役立つよりもユーザーをいらいらさせていたことがわかりました。

新しい振る舞いはこうなります。

<?php

function (Foo $f): void {
    // エラーなし
    echo isset($f->prop) ? $f->prop : 'bar';

    // エラーなし
    echo $f->prop ?? 'bar';
};

もし、さらに厳密な振る舞いを求めていて両方の行でエラーが報告されるようにするには phpstan.neon で以下のように有効化します。

parameters:
    checkDynamicProperties: true

これはBleeding Edgeを組み合わせると、phpsta-strict-rules 1.2.0で有効になります。

PHP 8.1の完全サポート

以前のPHPStan 1.xリリースにおいてもPHP 8.1のさまざまな変更や新機能がサポートされていました。しかし、いくつかの組み込み関数のシグネチャが変更されていたことにはずっと気がついていませんでした。もっとも注目すべきはfputcsv()に新しいオプションパラメータが追加されていたことと、いくつもの関数がリソースからオブジェクトに移行されたことです。

PHP 8.0からPHPStanはphp-srcから直接抽出した自家製のスタブリポジトリに移行しました。この情報は正しいと信頼できますが、リポジトリサイズを2倍にせずにPHP 8.1での変更を表わす必要がありました。そこで、私は専用の#[Until]#[Since]というアトリビュートシグネチャの変更を記録することにしました。

Do you like PHPStan and use it every day? Consider supporting further development of PHPStan on GitHub Sponsors. I’d really appreciate it!

PHPStanが好きで毎日使っていますか? GitHubスポンサーでさらなる開発のサポートを検討してください! 本当に感謝しています

訳者によるまとめ

今回のPHPStan 1.6は型の機能と内部のパフォーマンス改善の両面において発展を遂げており、PHPStanを導入してから滅多に更新していないという人にもパフォーマンス改善の恩恵が大きいので、この期にBleeding Edge有効化とセットで是非導入してもらいたいバージョンです。

PHPStan作者のOndřej(@OndrejMirtes)は最近Twitterで日本のユーザーとも積極的にコミュニケーションをとっています。このバージョンにおいても木村洋太さん id:rajyan が非常に大きな役割と果たしたほか、PHPerKaigi 2022を契機にid:muno_92さんも初めてPRを提出するなど日本からの貢献も加速していて非常におもしろいフェイズです。

muno-92.hatenablog.com

条件付き戻り値型は野放図に使ってしまうと収拾がつかないことになってしまいかねませんが、ユースケースをしっかり考えて使うことで柔軟で使いやすい関数と静的な型安全性を両立できるポテンシャルを持った非常に重要な機能です。見方によっては静的解析の普及で膠着してしまった関数定義の定石を変えるポテンシャルを持っているとすら言えるかもしれません。