Shin x Blog

PHPをメインにWebシステムを開発してます。Webシステム開発チームの技術サポートも行っています。

PHP 8 の新機能を使ってコードを書いた雑感

PHP カンファレンス 2020 の登壇のために PHP 8 で JSON パーサを書いてみました。 https://github.com/shin1x1/php8-toy-json-parser

いくつか PHP 8 の新機能を使ったのでその雑感です。

Constructor Property Promotion

https://www.php.net/releases/8.0/en.php#constructor-property-promotion

TypeScript のようにスッキリ書けるのが良いです。

コンストラクタ仮引数にアクセス修飾子を足すだけなので、慣れないと一瞬戸惑うかもしれません。以前 TypeScript で体験したことですが、久しぶりにコードを見直すとすっかり忘れていてプロパティ宣言が無いように感じて、あれ?となりました。

下記コードのように、通常のプロパティ宣言と混在すると少し違和感がありますが、考えようによってはプロパティ宣言されたものは内部で初期化するもの(少なくともコンストラクタ引数では初期化しないもの)と判別できて良いかもしれません。

<?php 

final class Lexer
{
    private int $length;
    private int $posistion;

    public function __construct(private string $json)
    {
        $this->length = mb_strlen($this->json);
        $this->posistion = 0;
    }
}

Named arguments

https://www.php.net/releases/8.0/en.php#named-arguments

どのパラメータに対する値かを判別しやすくなるので良いです。当然、可読性も上がるので、下記のような動作オプションなどは記述していきたいですね。

PhpStorm(2020.3 以降)では、このパラメータ名もちゃんと補完候補に入っているのでかなり楽です。

<?php 
json_decode($json, associative: true)

Union Types

https://www.php.net/releases/8.0/en.php#union-types

以前から doc コメントでは使ってきましたが、正式な型として指定できるようになりました。

mixed でまとめてしまうより、明示できた方が良いです。

<?php 
private function createLiteralToken(): TrueToken|FalseToken|NullToken
{}

public function parse(): array|string|int|bool|null
{}

これができるようになると、何度も同じ型宣言を書くのは面倒なので、type alias が欲しくなりますね。

<?php 
type JsonValue = array|string|int|bool|null;

public function parse(): JsonValue {}

Match expression

https://www.php.net/releases/8.0/en.php#match-expression

お気に入りの機能の一つで、これが入ったから発表ネタを JSON パーサにしました :)

強化版 switch 文という面もありつつ、match なので、式として利用できるのがポイントです。下記のように直接 return したり、.= 演算子のオペランドにできたりします。今まで PHP には無かった書き味です。

<?php 
        return match ($ch) {
            '[' => new LeftSquareBracketToken(),
            ']' => new RightSquareBracketToken(),
            '{' => new LeftCurlyBracketToken(),
            '}' => new RightCurlyBracketToken(),
            ':' => new ColonToken(),
            ',' => new CommaToken(),
            default => throw new LexerException('Invalid character ' . $ch),
        };
<?php 
            $str .= match ($ch = $this->consume()) {
                '"' => '"',
                '\\' => '\\',
                '/' => '/',
                'b' => chr(0x8),
                'f' => "\f",
                'n' => "\n",
                'r' => "\r",
                't' => "\t",
                'u' => $this->getCharacterByCodePoint(),
                default => '\\' . $ch,
            };

ただ、他の言語で match 式を触ってるとまだ足りないものがあって、まずパターンマッチがありません。特に変数の型(クラス)にマッチさせたい時は下記のようにクラス名を取得して、クラス名の文字列比較という方法を取りました。

<?php 
        return match ($token::class) {
            TrueToken::class => true,
            FalseToken::class => false,
            NullToken::class => null,
            StringToken::class=> $token->getValue(), // psalm では $token は StringToken を認識できない
            // (snip)
        };

この書き方でも動きはするのですが、psalm のような静的解析では問題が発生して、下記の StringToken::class とマッチするケースでは $token が StringToken インスタンスであることを想定していますが、psalm ではその判別ができずエラーとなります。このあたりは上手い書き方を探らないとですね。

ERROR: UndefinedInterfaceMethod - src/Parser/ValueParser.php:24:43 - Method Shin1x1\ToyJsonParser\Lexer\Token\Token::getValue does not exist (see https://psalm.dev/181)
            StringToken::class => $token->getValue(),

追記:

下記のように書くことで match 式内で instanceof 演算子を使うことができます。書き方がしっくり来るかは別にして、こちらであれば psalm コマンドでも arm で利用する変数の型をちゃんと判別してくれます。

<?php
        return match (true) {
            $token instanceof TrueToken => true,
            $token instanceof FalseToken => false,
            $token instanceof NullToken => null,
            $token instanceof StringToken=> $token->getValue(), // psalm でも $token が StringToken であることを認識
            // (snip)
        };

また、arm には単一の式しか書けないので、arm の中で return や continue といった制御構文は書けません。このあたりを書きたい場合は switch 文が必要となるので、場面による併用になるかなとは思ってます。なお、throw に関しては、PHP 8 から式として書けるようになったので ok です。

<?php 
    while (true) {
      $token = $lexer->getNextToken();

      match ($token::class) {
        case ColonToken::class => continue, // これは NG
        // (snip)
        default => throw new LexerException(), // これは OK
      }
    }

現状は少し制限はありますが、かなり書き味としては良いので、RFC の Future scope に入っているブロックとパターンマッチ対応がいずれ入ることを期待してます。

Attribute

https://www.php.net/releases/8.0/en.php#attributes

とりあえず仕組みとして入ったという感じですね。PHP コードレベルでは、フレームワークやライブラリが今度対応してきそうな感じです。

PHP ランタイム自体の動きを制御するような Attribute が導入されてくるのも楽しみです。例えば、下記のように非推奨なクラスに Attribute を付けておくと実行時に警告が出るとかですね。

#[Deprecated]
final class Foo {}

Attribute は、PHP コードの静的解析にも利用されています。現状は、JetBrains のものと Psalm のものがあります。どちらを使うか迷ったのですが、PhpStorm 上での反応は JetBrains の方が速いというのと、Psalm には JetBrains の Attribute を認識するコードが入っている*1 ことを考えると、JetBrains のものが良さそうです。

動作イメージとしては、下記のように #[JetBrains\PhpStorm\Immutable] をクラス宣言に付けておけば、コンストラクタ以外でプロパティを変更しようとすると警告が表示されます。

f:id:shin1x1:20201211120801p:plain

このまま psalm を実行すると、プロパティへの書き込みによるエラーは表示されますが、同時に JetBrains\PhpStorm\Immutable クラスが存在しないエラーも出力されます。

ERROR: UndefinedAttributeClass - src/Lexer/Token/StringToken.php:9:3 - Attribute class JetBrains\PhpStorm\Immutable does not exist (see https://psalm.dev/241)
#[Immutable]

ERROR: InaccessibleProperty - src/Lexer/Token/StringToken.php:18:9 - Shin1x1\ToyJsonParser\Lexer\Token\StringToken::$value is marked readonly (see https://psalm.dev/054)
        $this->value = '1';

JetBrains Attribute のクラス宣言は composer パッケージとして公開されているので、下記パッケージを compoer require --dev しておけば、クラスが存在しないエラーを消すことができます。

https://github.com/JetBrains/phpstorm-attributes

ERROR: InaccessibleProperty - src/Lexer/Token/StringToken.php:18:9 - Shin1x1\ToyJsonParser\Lexer\Token\StringToken::$value is marked readonly (see https://psalm.dev/054)
        $this->value = '1';

さいごに

PHP 8 の新機能を使った印象を書いてみました。PHP 7 が登場した時も感じましたが、8 になってさらに書きやすくなりましたね。これからプロダクションのコードでも PHP 8 の機能が使えるのが楽しみです。

2020/12/12 の PHP カンファレンス 2020 では、PHP 8 で JSON パーサを書いたお話をしますので、興味がある方はご覧下さい。

https://fortee.jp/phpcon-2020/proposal/29dc0164-13a9-45f3-9820-91809be7227c