entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

夏インターンでPHPStanのバグを直してコントリビュートした話

こんにちは。先日PIXIV SUMMER BOOT CAMP 2023にpixivウェブエンジニアリングコースで参加した、zer0-starです。

インターン期間中に、メンターのtadsanによりPHPStanのバグが発見され、僕がそれを直しました。

せっかくなので、直したバグについて話していこうと思います。

なお、今回出したPRは、以下になります。

https://github.com/phpstan/phpstan-src/pull/2591

バグの概要

僕が直したバグの概要ですが、端的に言うと「array_sum()に渡される配列の要素の型が定数型を含んでいたときなどに、推論される返り値がありえない型になる」というものです。

array_sum()とは、その名のとおり、引数として渡された配列の和を返す関数です。

www.php.net

定数型とは、1 2.3 'foo' のように、定数を表す型のことです。TypeScriptでお馴染みかもしれませんね。

例えば、"整数の配列"を表すarray<int>という型の値がarray_sum()に渡されたときは、返り値がintと推論されていました。これは正しい結果です。

しかし、例えば"要素が全て1であるような配列"を表すarray<1>という型の値が渡されたときには、期待される返り値の型は"0以上の整数"を表すint<0, max>か、少なくともそのスーパータイプであって欲しいでしょう。にもかかわらず、実際に推論された型は1となっていました。この結果は明らかに正しくありません。

他にも、例えば[1, 2, 3] が渡されたときには返り値が6と推論されて欲しいですが、実際には1 または2 または3 を表わす 1 | 2 | 3 と推論されてしまっていました。困ります!

では、なぜこのようなバグが発生してしまったのでしょうか。それは、array_sum() の型を推論するためのPHPStan拡張の実装に問題があったからです。

元の実装では、要素の型をほとんどそのまま返すようになっていました。これは、定数型が渡されなければ、問題なく動作する実装でした。つまり array_sum() の入力が array<int> なら intarray<float> なら floatarray<int|float> ならば int|float を返すということです。

これは一見よさそうですが、array<1|2>array_sum() に入力した結果は 1|2 ではありません。要素が1と2しかなかったとしても、空のリストならば結果は 0 、長さが2以上ならば結果はそれに応じて増加していきます。

この拡張の実装時には int[]non-empty-array<float> のような型のテストしかなく [1, 2, 3] のような定数を直接渡していなかったため、この問題はこれまで見過ごされていたようです。

PHPStan拡張とは

ここで登場したPHPStan拡張というものについて説明します。(ここでは、型推論に絞って話します)

実は、PHPStanは素の状態では型の表現力が(TypeScript等と比べて)あまり高くありません。

しかし、それを補う仕組みとして、ユーザーやライブラリの作成者が、PHPStanの静的解析をアドホックに拡張する拡張機能を作成することができます。拡張機能では、例えば関数やメソッド単位で型推論をオーバーライドして、通常では不可能な精密な型付けをすることができます。

型の表現力を強くする代わりに拡張機能という形式をとっていることのメリットとして、型付けのルールをPHPのコードとして書くことができるので、複雑な型付けも読み書きしやすく、型を操作するための専用の構文などに習熟する必要がないというものが挙げられます。

array_sum() の返り値の型推論では、PHPStan本体に同梱されているarray_sum() 専用の拡張機能が使用されています。先程のバグは、この拡張機能が原因で発生していたのでした。

PHPStan拡張ってどうやって実装するの

関数の型推論をする拡張機能は、具体的には関数の定義、関数適用の構文木、そして型環境をとって型を返すようなメソッドとして実装できます。例えば、「ある関数について返り値の型を引数全ての型のunionに推論する」ような拡張機能を作りたいなら、次のようなメソッドを書くことになるでしょう。

public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type
{
    $resultTypes = [];

    foreach ($functionCall->getArgs() as $arg) {
        $resultTypes[] = $scope->getType($arg->value);
    }

    return TypeCombinator::union(...$resultTypes);
}

コードの詳細については説明しません。型をデータとして操作していることが分かればOKです。

Scope::getType() を使うことで、自分の関心がない箇所についてはPHPStanの側に型推論を任せ、拡張側では型を組み合わせて最終的な型を作ることに専念できます。

実際にバグを直してみる

ここまで分かれば、あとは拡張を正しい動作をするように書き換えるだけです。

APIリファレンスとにらめっこして……よし、書けた!大量に追加したテストケースも通ったし、PRを出すぞ!

そうしてできたのが、こちらのコードです。

https://github.com/phpstan/phpstan-src/pull/2591/commits/2bef891930282fa1bb347590f9ab2a0404fb1cd6

配列の形やunionを全て場合分けで対応しているので、複雑なコードになってしまいました……泣

無限の場合分けと無限のネストを兼ね備えたコード

これに対してPHPStanの作者のOndřej Mirtes氏からのフィードバックは、以下の通りでした。

Hi, I don't really love that this PR is working with specific scalar values and calls array_sum internally. It misses out on various properties of the type system, like working with IntegerRangeType.

I'd much rather be if for ConstantArrayType objects, like [$one, $two, $three], it tried to do $scope->getType(new Plus($one, new Plus($two, $three))). Because Plus expr handling in MutatingScope already knows how to work with al possible Types, we can just call it here like that.

Because ConstantArrayType contains Type and not Expr, you can take advantage of TypeExpr virtual node to build an Expr out of Type instances.

つまり、要素の型がunionか定数かintかfloatかといった情報をいちいち手動で場合分けするのではなく、PHPStanの型推論に任せようということです。

そのために使える機能として、TypeExprというクラスを使うといいというアドバイスをもらいました。

ReturnType拡張を作成する際に頻出するPHPStan\Analyser\Scope::getType()というメソッドは、ソースコードをパースしたPHP Parserの構文木のノードを受け取って型を解決できます。1 + 1と書かれた式のノードを渡すと、定数型のint<2>rand() + rand() なら int が得られるということです。ここまでは普通の使い方なのですが、PHPStanはVirtualNodeというPHP Parserを拡張した独自のノードを提供しています。TypeExprはVirtualNodeのひとつで、PHPStanのTypeオブジェクトをPHP Parserのノードとして扱えるようにするためのアダプターです。

つまり、array_sum([1, 2]) というコードの結果は $scope->getType(new Add(new LNumber(1), new LNumber(2))) のようなノードを組み立てることで得られますが、new LNumber(1)のような部分を別の部分から $scope->getType() で得られたTypeオブジェクトをTypeExprでラップしたものに置き換えても型を解決できるということです。これを活用することで、要素が変数か定数かといった細かな場合分けを完全に廃することができました。このTypeExpr については、PHPStanを"完全に理解した"というメンターのtadsanも初めて聞いた機能だったとのことです。

その後も修正とフィードバックを繰り返し、コードの質だけでなく型推論の性能も高くすることができました。

そして、インターン最終日の最終課題発表をしている最中に、PRがマージされました!ありがとう……

おわりに

PHPStanでは容易に拡張機能を書いて静的解析の効果を上げることができます。みんなも、お気に入りの拡張を育てて自分だけのPHPStanを作りあげよう!

zer0-star
2023年10月からウェブエンジニアリングチームでアルバイトをしています。プログラミング言語とその理論に興味アリ。星宮ととのファンです。