17
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

PHPStanの型チェック機能をまとめる

Posted at

PHPStan の型チェック機能

https://phpstan.org/writing-php-code/phpdocs-basics
こちらをベースに独断と偏見で重要そうなところをまとめる。(翻訳ではない)

バージョンは 0.12.25。

PHPDocと型宣言

メソッドや関数で型を指定する

<?php declare(strict_types = 1);

class Foo{
    public function f():void{}
}
class Bar{}
class Baz{}

/**
 * @param Foo $foo
 * @return Bar
 */
function foo($foo){
    $foo->g();
    return new Baz();
}
 ------ --------------------------------------------------- 
  14     Call to an undefined method Foo::g().              
  15     Function foo() should return Bar but returns Baz.  
 ------ --------------------------------------------------- 

存在しないメソッドや型の不一致はエラー。

<?php declare(strict_types = 1);

class Foo{
    public function f():void{}
}
class Bar{}
class Baz{}

function foo(Foo $foo): Bar{
    $foo->g();
    return new Baz();
}
 ------ --------------------------------------------------- 
  10     Call to an undefined method Foo::g().              
  11     Function foo() should return Bar but returns Baz.  
 ------ --------------------------------------------------- 

PHPDocなし、型宣言のみの場合でも解析される。

プロパティの型を指定する

<?php declare(strict_types = 1);

class Foo {
    public function f():void {}
}
class Bar {
    /** @var Bar */
    public $bar;
}

function test(): Bar{
    $bar = new Bar();
    $bar->foo = 1;
    $bar->foo = new Foo();
    $bar->foo->g();
    return $bar;
}
 ------ -------------------------------------------- 
  13     Access to an undefined property Bar::$foo.  
  15     Call to an undefined method Foo::g().       
 ------ -------------------------------------------- 

クラスプロパティが型チェックされる。

型宣言が使えるならPHPDocでなくて型宣言でも良い。

<?php declare(strict_types = 1);

class Foo {}
class Bar {
    public Bar $bar;
}

function test(): Bar{
    $bar = new Bar();
    $bar->foo = 1;
    return $bar;
}
 ------ -------------------------------------------- 
  10     Access to an undefined property Bar::$foo.  
 ------ -------------------------------------------- 

クラスのマジックメソッドによる動的な型指定

マジックメソッドを利用しているなら @property@method を使う。

<?php declare(strict_types = 1);

/**
 * @property int $dynamicProperty
 * @property-read int $readProperty
 * @property-write int $writeProperty
 * @method string dynamicMethod(int $a)
 */
class Foo {
}

function test(): void{
    $foo = new Foo();
    $foo->dynamicProperty++;
    $foo->dynamicMethod($foo->readProperty);

    $foo->readProperty = 2;
    $a = $foo->writeProperty;
    $foo->dynamicMethod('foo');
}
 ------ --------------------------------------------------------------------------- 
  17     Property Foo::$readProperty is not writable.                               
  18     Property Foo::$writeProperty is not readable.                              
  19     Parameter #1 $a of method Foo::dynamicMethod() expects int, string given.  
 ------ --------------------------------------------------------------------------- 

読み取り専用、書き込み専用にも対応している。動的なメソッドの引数や戻り値の型もチェックされる。

@mixin でマジックメソッド__callからの委譲クラスメソッド呼び出しに対応できる。

<?php declare(strict_types = 1);

class Foo {
    function f(int $a, string $b): void{}
}

/**
 * @mixin Foo
 */
class Bridge {
    /** 
     * @param array<int,mixed> $args
     * @return mixed
     */
    public function __call(string $name, $args)
    {
        return (new Foo())->$name(...$args);
    }
}

function test(): void {
    $bridge = new Bridge();
    $bridge->f(1, 3);
}
 ------ --------------------------------------------------------------- 
  23     Parameter #2 $b of method Foo::f() expects string, int given.  
 ------ --------------------------------------------------------------- 

Bridge__call から呼び出される Foo::f が型チェックされている。

Genericsの@templateと同時に使えば非常に強力である。

<?php declare(strict_types = 1);

class Foo {
    function f(int $a, string $b): void{}
}

class Bar {
    function b(int $a, string $b): void{}
}

/**
 * @template T
 * @mixin T
 */
class Delegate {

    /** @var T $a */
    private $a;

    /** @param T $a */
    public function __construct($a){
        $this->a = $a;
    }

    /** 
     * @param array<int,mixed> $args
     * @return mixed
     */
    public function __call(string $name, $args)
    {
        return $this->a->$name(...$args);
    }
}

function test(): void {
    $bridge = new Delegate(new Foo());
    $bridge->f(1, 'foo');

    $bridge = new Delegate(new Bar());
    $bridge->f(1, 'foo');
}
 ------ --------------------------------------------------------------- 
  23     Parameter #2 $b of method Foo::f() expects string, int given.  
 ------ --------------------------------------------------------------- 

$bridge = new Delegate(new Foo()); のときに$bridge->fは存在するが、$bridge = new Delegate(new Bar()); のときには $bridge->fは存在しないのでエラーになる。

対応している型の書き方

int, integer, string, array 等のスカラー型やstdClass, PDOのようなクラス、iterablecallableなど仮想的な型もある。

iterableとジェネリクス

iterableはジェネリクスを指定できる。

/**
 * @param iterable<stdClass> $iterator
 */
function test(iterable $iterator): void {
    foreach ($iterator as $key => $value){
    }
}

iterable系のジェネリクスの型は<TValue>または<TKey,TValue>なのだが、キーも同時に指定した方が良い。

<?php declare(strict_types = 1);

function f(?int $a): void{}

/**
 * @param array<stdClass> $iterator
 */
function test(array $iterator): void {
    $key = null;
    foreach ($iterator as $key => $value){
    }
    f($key);
}
 ------ ------------------------------------------------------------------------ 
  12     Parameter #1 $a of function f expects int|null, int|string|null given.  
 ------ ------------------------------------------------------------------------ 

intのつもりで書いていてもintではない可能性があるので。

class-string疑似型

特殊な型としてstdClass::class のような文字列を受け取る class-string という型もある。

<?php declare(strict_types = 1);

class Container {
    /** @var array<class-string<mixed>,mixed> */
    private $components;

    public static function getInstance(): self
    {
        static $self;
        if ($self !== null)
            $self = new self();
        return $self;
    }

    /**
     * @template T
     * @param class-string<T> $name
     * @return T
     */
    public function getComponent(string $name){
        return $this->components[$name];
    }
}


interface Foo {}
class FooImpl implements Foo{}

function test(): Foo
{
    $container = Container::getInstance();
    return $container->getComponent(Foo::class);
}

Javaのようなタイプセーフなコンテナが書ける。

callable型

<?php declare(strict_types = 1);

class A{}

function test(callable $f, int $a, string $b): A
{
    return $f($a, $b);
}

callableは標準では厳しくチェックされないが、細かく型を指定することもできる。

<?php declare(strict_types = 1);

class A{}

/**
 * @param callable (int,int):A $f
 */
function test(callable $f, int $a, string $b): A
{
    return $f($a, $b);
}
 ------ ----------------------------------------------------------------------------- 
  10     Parameter #2 $ of callable callable(int, int): A expects int, string given.  
 ------ ----------------------------------------------------------------------------- 

型を指定することで間違いに気付くことが出来るので、指定した方が良い。

<?php declare(strict_types = 1);

class A{}
class B{}
class C{}

/**
 * @template T
 * @template U
 * @template V
 * @param callable (T):U $f
 * @param callable (U):V $g
 * @param T $a
 * @return V
 */
function combine(callable $f, callable $g, $a)
{
    $ret = $f($a);
    return $g($ret);
}

function test(A $a): C
{
    return combine(function(A $a){
        return new B();
    }, function(B $b){
        return new C();
    }, $a);
}

こういうことが出来れば夢が広がるが、これは出来ない。(型を間違えてもエラーが出ない)
戻り値の型UVBCに対応しているかどうかが解決できないようだ。

リテラル型

PHPのマニュアルでよく見る false を返す関数が書ける。

<?php declare(strict_types = 1);

/**
 * @param mixed $input
 * @return 1|2|false
 */
function to_id($input)
{
    if (is_array($input))
        return false;

    return $input ? 1 : 2;
}

少ない数のIDなどは羅列しても良い。

以前も書いたが const と組み合わせると更に強力である。

<?php declare(strict_types = 1);

class Category
{
    const ID_PHP = 1;
    const ID_JAVASCRIPT = 2;
    const ID_HTML = 3;
    const UNKNOWN_ID = 4;
}


/**
 * @param Category::ID_* $categoryId
 */
function do_something(int $categoryId): void
{
}

function test(): void
{
    do_something(1);
    do_something(Category::ID_PHP);
    do_something(Category::UNKNOWN_ID);
}
 ------ --------------------------------------------------------------------------- 
  23     Parameter #1 $categoryId of function do_something expects 1|2|3, 4 given.  
 ------ --------------------------------------------------------------------------- 

union型

既にここまでの説明でunion型を使っていたし、strposの戻り値がint|falseとかPHPerは結構普通に使っている。

あえて言うならMaybe的なものとか

<?php declare(strict_types = 1);

/**
 * @template T
 */
class Just {
    /** @var T */
    public $value;
    /** @param T $value */
    public function __construct($value){
        $this->value = $value;
    }
}
class Nothing {}

/**
 * @param Just<int>|Nothing $input
 */
function f($input): int{
    if ($input instanceof Just)
        return $input->value++;
    else
        return 0;
}

色んな型を受け取って任意のフォーマットに変換する便利関数を作ったり

<?php declare(strict_types = 1);

class Ymd {
    /** @var int */
    public $y;
    /** @var int */
    public $m;
    /** @var int */
    public $d;
}

/**
 * @param DateTimeInterface|Ymd|string $input
 */
function toYmd($input): string
{
    if (is_string($input))
        $input = new DateTimeImmutable($input);

    if ($input instanceof DateTimeInterface)
        return $input->format('Y-m-d');

    return sprintf('%4d-%02d-%02d', $input->y, $input->m, $input->d);
}

型を気にせず使っていたPHPerなら知らず知らずのうちに同じことをやっていたはず。

Intersection型

union型の逆で、並べた型の全てを満たさなければならない。これは見慣れない。でも使いどころはある。

例えばforeachcountもしている関数があった場合。

<?php declare(strict_types = 1);

/**
 * @param array<mixed> $rows
 */
function show2($rows): void
{
    $total = count($rows);
    echo $total;
    foreach ($rows as $row){
        echo $row;
    }
}

function test2(): void
{
    show(['a', 'b', 'c']);
}

これをarray以外でも使えるように変更したい。
とりあえずiterableにしてみる。

<?php declare(strict_types = 1);

/**
 * @param iterable<mixed>|array<mixed> $rows
 */
function show2($rows): void
{
    $total = count($rows);
    echo $total;
    foreach ($rows as $row){
        echo $row;
    }
}
 ------ ------------------------------------------------------------------------------ 
  8      Parameter #1 $var of function count expects array|Countable, iterable given.  
 ------ ------------------------------------------------------------------------------ 

すると、Countableではないと怒られる。

そこでCountableに変更する。

<?php declare(strict_types = 1);

/**
 * @param Countable|array<mixed> $rows
 */
function show2($rows): void
{
    $total = count($rows);
    echo $total;
    foreach ($rows as $row){
        echo $row;
    }
}
 ------ ------------------------------------------------------------------------ 
  10     Argument of an invalid type array|Countable supplied for foreach, only  
         iterables are supported.                                                
 ------ ------------------------------------------------------------------------ 

iterableにしろと怒られる。

つまり、

<?php declare(strict_types = 1);

/**
 * @param (iterable<mixed>&Countable)|array<mixed> $rows
 */
function show($rows): void
{
    $total = count($rows);
    echo $total;
    foreach ($rows as $row){
        echo $row;
    }
}

こういうことである。

function test(): void
{
    show(['a', 'b', 'c']);
    $f = function(): Generator{
        yield 'a';
        yield 'b';
        yield 'c';
    };

    $gen = $f();

    show($gen);
}
 ------ ------------------------------------------------------------------------- 
  26     Parameter #1 $rows of function show expects array|(Countable&iterable),  
         Generator given.                                                         
 ------ ------------------------------------------------------------------------- 

正しくチェックできた。

配列型

配列はarray<KType,VType> または array{key1:VType1, key2:VType2, ...} で型指定できる。
PHPerなら配列を多用すると思うが、配列での型指定はかなり大変になる。素直にバリュークラス的なものを作った方が楽まである。

<?php declare(strict_types = 1);

/** @return mixed */
function find_db(int $id){}

/**
 * @return ?array{
 *   account_id: int,
 *   name: string,
 *   display_name: ?string,
 *   email: string,
 *   disabled: bool,
 *   roles: array<int,array{
 *     role_id: int,
 *     role_label: string,
 *   }>,
 *   updated_at: string,
 *   admin_note?: ?string
 * }
 */
function find_account(int $id): ?array
{
    $sql = '....';
    return find_db($id);
}


function test(): void {
    $account = find_account(1);
    if (!$account)
        return;

    echo $account['name'];

    if ($account['disabled']){
        // ...
    }

    if ($account['roles']){
        foreach ($account['roles'] as $role){
            // ...
        }
    }

    if ($account['admin_note']){
        // ...
    }
}

頑張れば書ける。書けるが…これを関数呼び出しのたびに書くのはとても疲れる。

 ------ ------------------------------------------------------------------------------- 
  45     Offset 'admin_note' does not exist on array('account_id' => int, 'name' =>     
         string, 'display_name' => string|null, 'email' => string, 'disabled' => bool,  
         'roles' => array<int, array('role_id' => int, 'role_label' => string)>,        
         'updated_at' => string, ?'admin_note' => string|null).                         
 ------ ------------------------------------------------------------------------------- 

エラーも見づらい。
なので連想配列を使うのは個人的には非推奨。データが5件ぐらいしか無かったとしてもクラスを書くべき。3件ぐらいが迷いどころ。

動的な解析

未定義変数の解析

次のような条件がある場合、

<?php declare(strict_types = 1);

function do_something(bool $flg): void
{
}

function test(int $input): void
{
    if ($input == 1){
        $flg = true;
    }else if ($input == 2){
        $flg = false;
    }

    do_something($flg);
}
 ------ ------------------------------------- 
  15     Variable $flg might not be defined.  
 ------ ------------------------------------- 

$flgは未定義かもしれないのでエラー。

<?php declare(strict_types = 1);

function do_something(bool $flg): void
{
}

function test(int $input): void
{
    if ($input == 1){
        $flg = true;
    }else if ($input == 2){
        $flg = false;
    }else{
        throw new \Exception('unknown id');
    }

    do_something($flg);
}

例外やreturnで飛ばせば、未定義変数での呼び出しは解決できる。フレームワークのredirect()などで途中returnする場合は設定ファイルに書けば対応できる。

<?php declare(strict_types = 1);

function do_something(bool $flg): void
{
}

function test(?int $input): void
{
    if ($input){
        $flg = true;
    }

    // ...

    if ($input){
        do_something($flg);
    }

}

このようは場合は流石に自動判別はできずにエラーになる。

 ------ ------------------------------------- 
  16     Variable $flg might not be defined.  
 ------ ------------------------------------- 

その場合はissetでチェックする。

    if (isset($flg)){
        do_something($flg);
    }

いや、$flgはどこかで必ず定義されているんだ、定義されていない場合はバグだ、という場合ならassertでも良い。

<?php declare(strict_types = 1);

function do_something(bool $flg): void
{
}

function test(?int $input): void
{
    if ($input){
        $flg = true;
    }

    // ...

    assert(isset($flg));

    // ...

    do_something($flg);
}

型、値の絞り込み

上のリテラル型で書いた to_idのサンプルコードを使うと

<?php declare(strict_types = 1);

/**
 * @param mixed $input
 * @return 1|2|false
 */
function to_id($input)
{
    if (is_array($input))
        return false;

    return $input ? 1 : 2;
}

$id = to_id($_GET['id']);
if ($id === false)
    throw new Exception('error');

if ($id === 1){
    $a = 'foo';
}else if($id === 2){
    $a = 'bar';
}else{
    $a = 'unknown';
}
echo $a;

このような分岐が書ける。
ただしこの例にはデッドコードがある。

 ------ ----------------------------------------------------------------------- 
  23     Else branch is unreachable because previous condition is always true.  
 ------ ----------------------------------------------------------------------- 

else に入って unknown になることは無い。
PHPStanを使わない場合は一応念のためということで else を書くこともあったが、PHPStanを使っているなら型レベルでデッドコードが解析されているので、逆に取り除いた方が良い。

ここではto_id関数の戻り値にPHPDocで固定値を指定したが、スコープの中でも同じようなことが自動で解析される。

<?php declare(strict_types = 1);

function test(): void{
    $id = $_GET['input'] ? 1 : 2;

    if ($id === 1){
        $a = 'foo';
    }else if($id === 2){
        $a = 'bar';
    }else{
        $a = 'unknown';
    }
    echo $a;
}
 ------ ----------------------------------------------------------------------- 
  10     Else branch is unreachable because previous condition is always true.  
 ------ ----------------------------------------------------------------------- 

$id12であることが明らかなので、elseがデッドコードであることも明らかである。

switch(true)も解析できるものは解析される。

function get_age_id(int $age): int
{
    $id = 0;
    switch (true){
    case $age < 10:
        $id = 1;
        break;
    case 10 <= $age && $age < 20:
        $id = 2;
        break;
    case 20 <= $age && $age < 30:
        $id = 3;
        break;
    }

    return $id;
}

一見問題ないコードだが、無駄な条件がある。

 ------ ----------------------------------------------------------------------- 
  10     Comparison operation "<=" between 10 and int<10, max> is always true.  
  13     Comparison operation "<=" between 20 and int<20, max> is always true.  
 ------ ----------------------------------------------------------------------- 

10 <= $age20 <= $age は不要であると怒られた。強い。

型の絞り込みは既に上の例のunionでも使っていた。

<?php declare(strict_types = 1);

function test(DateTimeInterface $d): void
{
    if ($d instanceof DateTimeImmutable ||
        $d instanceof DateTime){

        $d2 = $d->sub(new DateInterval('P1D'));
    }
    
    $d2 = $d->sub(new DateInterval('P1D'));
}
 ------ ------------------------------------------------------- 
  11     Call to an undefined method DateTimeInterface::sub().  
 ------ ------------------------------------------------------- 

ifの中ではinstanceofで指定したメソッドが使えるよとかそんなやつ。

まとめ

自分が使っていて便利だという機能+マニュアルを読んでいて気付いた機能を書いた。
個人的には、ジェネリクスが使えるようになって完成された感じがある。(まだバージョン1ではないが。)
次はcallableとジェネリクスの組み合わせが解析できるように頑張ってほしいが、これは実装されないだろうなという気がしている。

17
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?