Skip to content

Latest commit

 

History

History
340 lines (252 loc) · 16.5 KB

README.md

File metadata and controls

340 lines (252 loc) · 16.5 KB

PHPStan型付けチュートリアル 入門編

入門編のねらい

この入門編は、いままでPHPStanを使ったことがない方でも、「PHPStanがどのようにコードを分析しているか」の感覚を捉えるようになることが目的です。むしろ、「PHPStanにある程度馴染みはじめた」という方こそ「初めて聞いた」という内容があるかもしれません。

全ての機能を直ちに使いこなせるようになることはこの記事の目的ではありません。あなたが書いたコードに期待通りの型が付いているのか、付いていないのかを判別できるようになることが重要です。

1. 値の型を確認してみよう

PHPStanは値についた型を \PHPStan\dumpType() 関数で出力できます。

Note

この節のコードは以下で確認できます

$a = 'foo';
$b = 'bar';
$c = $a . $b;

\PHPStan\dumpType($a);
\PHPStan\dumpType($b);
\PHPStan\dumpType($c);

複数の値をまとめてチェックしたいときはcompact()で配列にまとめることでわかりやすくなることもあります。

$n = 5;
$m = 2;
$l = $n / $m;
\PHPStan\dumpType(compact('n', 'm', 'l'));

「これってPHPを実行した結果を画面に表示してるだけじゃないの?」と思われるかもしれません。禅問答のようですが、「そうであって、そうではない」のです。

何を言っているかわからないと思うので、次のようなコードを考えてみましょう。

$n = 5;
if (rand() === 1) {
    $m = 2;
} else {
    $m = 5;
}

$l = $n / $m;
\PHPStan\dumpType(compact('n', 'm', 'l'));

rand() === 1 という条件が成り立つ確率は大雑把に「21億分の1」です。PHPStanはrand() === 1という確率的な処理は行なっていません。どちらでも僅かにでも可能性があるならば、PHPStanはどちらの可能性もあると判断して2|5という型をつけます。さらに$l = $n / $mという式はどうでしょうか。$nには5という型がついていますが、$m25の可能性があるので、$l = 5 / 2 (= 2.5) と $l = 5 / 5 (= 1) という2パターンが考えられます。ここでPHPStanは$l1|2.5という型をつけます。これはPHPStanが行なう型付けの特殊な例などではなく、PHPStanが常に行なっていることです。

Caution

\PHPStan\dumpType() は静的解析時に用いられる擬似的な関数ですが、実行時に定義されません。
実アプリケーションでは実行する前、あるいはユニットテスト実行前に取り除いてください。

Important

🔜 コードを好きに書き換えてみて、納得できたら次に進んでください

2. 型宣言で関数に型をつける

Note

この節のコードは以下で確認できます

PHPの関数に型を付けてみましょう。

function label($title)
{
    return "label:{$title}";
}

\PHPStan\Testing\assertType('string', label('foo'));

Tip

\PHPStan\Testing\assertType(expected, actual) は値が期待する型とPHPStanが認識している型の 文字列表現の一致 をチェックする関数です。expectedactualが同じ文字列なら何も出力されなくなります。

PHPではパラメータ(仮引数リスト)や戻り値に型宣言を追加できます。関数やメソッドで型宣言された型は、実行時に必ず保証されます。

保証されるということは次のことを意味します:

  • パラメータで宣言された型は、実装内で必ず制約を満たします
    • 制約を満たさない値が渡されることを心配する必要はありません
  • 呼び出した結果、必ず宣言された型の制約を満たす戻り値が返されます
    • 制約を満たさない値が返されることを心配する必要はありません

それぞれの箇所では、型宣言されたものが静的型付きであることが必ず保証されます。

Important

🔜 型を追加してエラーが出なくなったら次に進んでください

Tip

PHPの型宣言についてどのように振る舞うかチェックできます

3. 型を絞り込む

Note

この節のコードは以下で確認できます

ユーザーがフォームから検索して、結果の書籍一覧を表示する画面を考えてみましょう。

search()関数の実装は次のようになっています。

/**
 * @param non-empty-string $word
 * @param 'asc'|'desc' $order
 * @param positive-int $page
 * @return list<Book>
 */
function search(string $word, string $order, int $page): array
{
    // 本来は検索エンジンからデータを取得する
    return match ($page) {
        1 => [new Book('', [])],
        default => [],
    };
}

/** … */Doc commentといい、関数やクラスの説明を記述できます。 @param@returnのような記述はPHPDocタグと呼びます。@paramはパラメータの詳細な型を、@returnは関数・メソッドの戻り値の型を記述します。

  • @param non-empty-string $word
    • 空文字列での検索は不正なので、関数呼び出し側の責任でチェックする
    • non-empty-stringとは、''(空文字列)以外の文字列のことです
  • @param 'asc'|'desc' $order
    • 検索結果を昇順と降順のどちらに並び変えるか
  • @param positive-int $page
    • 検索のページ数:最小値は1
  • @return list<Book>

Warning

Doc commentは、必ず /** ... */ (*が二つ!)から始まります。
範囲コメントの /* ... */ とは区別されるので十分に気をつけてください。

エディタによってはPHPDocタグが色付けされるかによって区別できます。 Emacs PHP ModeでPHPDocタグが色付けされている画像

続いて、外部からの入力を値として取得します。

$word = filter_var($_GET['word'] ?? '');
$order = filter_var($_GET['order'] ?? 'asc');
$page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT);

$books = search($word, $order, $page);
// 初期状態では以下のエラーが発生する
// Parameter #1 $word of function search expects non-empty-string, string|false given.
// Parameter #2 $order of function search expects 'asc'|'desc', string|false given.
// Parameter #3 $page of function search expects int<1, max>, int|false given.
  • filter_var()
    • 値をフィルタリングする関数です (名前に反して変数以外もフィルタできます)
    • PHPStanは検証フィルタとオプションによって型が変わります

PHPStanは比較により型を絞り込む(type narrowing)ことができます。 コードに以下のようなコードを追加して型を確認してみてください。

$word = filter_var($_GET['word'] ?? '');
\PHPStan\dumpType($word); // DumpedType: string|false

if ($word === '' || $word === false) {
    \PHPStan\dumpType($word); // DumpedType: ''|false
} else {
    \PHPStan\dumpType($word); // DumpedType: non-empty-string
}

\PHPStan\dumpType($word); // DumpedType: string|false

PHPStanは制御フロー解析を実装しており、ifforeachといった制御構造に従った変数スコープを保持しています。上記のコードでは初期状態でstring|falseだった変数がifelseでそれぞれ''|falsenon-empty-stringに分岐し、合流後はstring|false戻っていることが確認できます。

型が絞り込まれた状態で制御フローを中断することで、その型を絞り込めます。中断とは、return throw continue break exit あるいは never 型の関数を読み込むなどです。

$word = filter_var($_GET['word'] ?? '');
\PHPStan\dumpType($word); // DumpedType: string|false

if ($word === '' || $word === false) {
    throw new RangeException('$word を入力してください');
} else {
    \PHPStan\dumpType($word); // DumpedType: non-empty-string
}

\PHPStan\dumpType($word); // DumpedType: non-empty-string

これで型が絞り込まれたelseの状態で固定できました。elseのコードはまるごと削除しても構いません。さらに、型の絞り込みは式の内部でも起こります。

// strlen() に false を渡してしまう可能性があるのでエラー
if (strlen($word) === 0 || $word === false) {
// Parameter #1 $string of function strlen expects string, string|false given.
    throw new RangeException('$word を入力してください');
}

これは || の右辺と左辺を入れ替えることで解決します。

// false のときに左辺で処理が打ち切られるので strlen() の呼び出しを防げる
if ($word === false || strlen($word) === 0) {

もっとも、このパターンはin_array()関数を用いて簡潔に絞り込めます。

if (in_array($word, [false, ''], true)) {
    throw new RangeException('$word を入力してください');
}

このようにin_array($var, ['foo', 'bar', 'buz'], true)と書くことで、$var === 'foo' || $var === 'bar' || $var === 'buz'と等価になり、PHPStanも型の絞り込みを適切に認識します。

同じように、ほかの変数$order$pageの型も絞り込んでみてください。

Important

🔜 実装を修正してエラーが出なくなったら次に進んでください

  • search()を呼び出す際に渡す値の型を適切に絞り込みます
  • search()の実装内部でエラーが出ないように適当な値を埋めてください

4. 型宣言で安全に型をつける

一方で、ファイル単位declare(strict_types=1); の有無によって、スカラー型について「関数(メソッド)を呼び出す際の引数(argument, 実引数)」および「関数(メソッド)が返す戻り値の型」の振る舞いが変わります。

  • strict_types=1あり
    • 値と型が一致しないとTypeErrorを送出します
  • strict_types=1なし (または0)

以下のようなコードを考えてみましょう。

Note

この節のコードは以下で確認できます

<?php declare(strict_types = 0);

/**
 * $s が数値文字列だったら int に変換して返す
 */
function to_int(string $s): ?int
{
    try {
        return $s;
    } catch (TypeError) {
        return null;
    }
}

\PHPStan\Testing\assertType('int', to_int('1'));
\PHPStan\Testing\assertType('int', to_int('1.1'));
\PHPStan\Testing\assertType('null', to_int('php'));
\PHPStan\Testing\assertType('int|null', to_int(random_bytes(1)));

このコードは「現実には動作するのにPHPStanが警告する」代表的な例だと言えます。ただ、このコードはPHPStanで警告するだけでなく、declare(strict_types=0)に依存しているため安定しているとは言いがたいでしょう。

単に以下のようにすれば問題は解決するでしょうか。

function to_int(string $s): ?int
{
    return (int)$s;
}

(int)キャストは入力値が数値文字列でなかったときにエラーも出さないため、基本的には適切ではありません。一方でfilter_var($var, FILTER_VALIDATE_INT)filter_var($var, FILTER_VALIDATE_FLOAT)は数値文字列として適切ではない文字列が返されたときにfalseを返します。

これらの検証をうまく組み合わせることで、適切な値を返す関数が実装できます。一方で、assertType()で表明したような型は実現できていません。

\PHPStan\Testing\assertType('int', to_int('1'));
\PHPStan\Testing\assertType('int', to_int('1.1'));
\PHPStan\Testing\assertType('null', to_int('php'));
\PHPStan\Testing\assertType('int|null', to_int(random_bytes(1)));

PHPStanは条件付き戻り値型をサポートしているのでPHPDocタグに以下のように記述できます。

 * @return ($s is numeric-string ? int : null)

条件付き戻り値型はPHPStan 2.1現在、($param is T ? X : Y)または($param is not T ? Y : X)のように記述できます。外側の()は省略できません。また、条件付き戻り値型のXYの部分はどんな型もネストして書けます。もちろん()で括る必要はありますが、条件付き戻り値型をネストすることもできます。

引数で渡した値が「確実にTである」というときはX、「確実にTではない」という場合はYが戻り値になります。$param: ?T$param: T|Uのように型が絞り込まれていないければ、自動でX|Yになります。

'1''1.1'は定数で確実に数値文字列ですので、intという型を返してよいということになります。一方、'php'という文字列は間違っても数値ではないので、確実にnullが返るということができます。「$s :stringだがnumericかどうかはわからない」という時は自動で?intになります。

Tip

今回はとても大雑把に型をつけていますが、「数値文字列」ではなく「整数を表す文字列」だけをサポートしたい場合などはPHPStan 2.1時点ではPHPDocだけでは判定できず、PHPStan拡張を実装する必要があります。 この章ではこのような使い方ができるということだけを認識できれば目的達成です。

Important

🔜 実装と型宣言を修正してエラーが出なくなれば、この章は修了です🎉

入門編の修了

🎉 修了おめでとうございます!

ここまで学んだことを整理しましょう。

  • \PHPStan\dumpType()でPHPStanが認識している型を確認できる
    • \PHPStan\Testing\assertType()で期待する型との比較もできる
  • PHPの基本機能で関数・メソッドに型を付けることができる
  • PHPStanは制御フロー解析により型を絞り込める
  • PHPでは実行できるがPHPStanが受け付けないコードも存在することを認識できる
  • PHPDocタグでより詳細な型を付けることができる
  • declare(strict_types=1)の有無での振る舞いの差異がわかる
  • filter_var()を使った型の検査方法がわかる
  • 条件付き戻り値型の存在を認識している

ここまでできれば、細かい理窟は抜きにして「PHPStanを使える」と言って差し支えないと思います。

もちろん全ての機能を直ちに使いこなせるようになっている必要はありません。とはいえ、コードを書いて期待通りの型がついていない原因をチェックできるようになったといえるでしょう。

より詳細なPHPStanの使い方を身に付けるために次のステップに進みましょう!