25
Help us understand the problem. What are the problem?

posted at

updated at

array shapes記法(Object-like arrays)と旧PSR-5記法で型をつける

この数年でPHPでの開発でもCI(継続的インテグレーション)はかなり活発に行われるようになり、PHPUnitなどのテスティングフレームワークのほか、PHPStanやPhanなどのツールによる静的解析も浸透しつつあります。

関数/メソッドの引数と返り値、そしてオブジェクトのプロパティは比較的に型がつけやすいところですが、現状で無法地帯な箇所があります。そうです、配列の内部構造です。実際のところ、PHPDocに @param array@return array と書くことは mixed と書くのとあまり大きな違いはありません。

ご存じの通り、PHPの配列は、動的配列(可変長配列)連想配列(ハッシュテーブル、マップ)の特徴を併せ持ったデータ構造であり、配列は変数と同じくあらゆる値を格納することができるからです。

近年の開発が活発なPHPの静的解析ツール(PhpStormを除く)では配列の内容に型をつけることができるようになっています。この記事ではPHPに静的解析で立ち向かうための一大地雷原である配列に鉈を入れるためのテクニックについて説明します。

(この記事は2019年8月頃に書いてQiitaの下書きと社内のesaに書きかけのまま放置されていたものに加筆したものです。同様の趣旨の雑なまとめはPHP勉強会@東京のLTスライド(arrayの型と向き合う #phpstudy)にも書きましたが、本記事の方がちょっとだけ詳しいです。)

これまでのあらすじ

この記事を読む前にJetBrainsアカウントを持ってるPhpStormユーザーはSupport for array shape definitions / object-like arrays : WI-47052に投票しておいてください。

[2021年8月2日追記]
PhpStorm 2021.2でarray-shapesが実装されたので、PhpStormを使っている方はすぐにアップデートしてください。deep-assocプラグインなどがなくても補完などがきくようになります。

例題

例として検索エンジンにクエリを投げる関数があったとします。この関数は検索ワードのほかに、オプショナルな要素として期間が指定できるとします。

function search(string $word, array $options): array
{
    $begin = null;
    if (isset($options['begin'])) {
        $begin = $options['begin']->getTimestamp();
    }

    $end = null;
    if (isset($options['end'])) {
        $end = $options['end']->getTimestamp();
    }

    $query = compile_query('search_book', $word, $begin, $end);

    return request_search_engine($query);
}

この実装には現状ドキュメントもなく、$optionsには何を渡せばよいのかわからない状態です。

引数リストを (string $word, ?DateTimeInterface $begin = null, ?DateTimeInterface $end = null) と変形すれば問題は解決するでしょうか。いいえ、検索期間の指定はあくまでオプションなので、別個の引数を指定できるようにするのはあまり望ましい解決策ではありません。

クラスを使った解法

いきなり解を出してしまいますが、このような用途には専用のクラスを作ってしまうのが、静的解析を妨げない「正攻法」な手段の一つです。

class SearchOptions
{
    /** @var ?DateTimeInterface 検索期間の始点 */
    public $begin;
    /** @var ?DateTimeInterface 検索期間の終点 */
    public $end;
}

function search2(string $word, SearchOptions $options): array
{
    $timestamp = ['begin' => null, 'end' => null];

    $begin = $options->begin;
    if ($begin !== null) {
        $timestamp['begin'] = $options->begin->getTimestamp();
    }

    $end = $options->end;
    if ($end !== null) {
        $timestamp['end'] = $options->end->getTimestamp();
    }

    $query = compile_query('search_book', $word, $timestamp['begin'], $timestamp['end']);

    return request_search_engine($query);
}

ここではコードを短くするためにpublicプロパティを使ってますが、実際にはセッター/ゲッターメソッドを定義するとかマジックメソッドを定義してやるとか好きなようにしてやってください。この定義でも、開発メンバーが全員PhpStormを使って注意深くコーディングするなら、あまり大きな問題はないかもしれません。PHP 7.4になればTyped propertiesが入りますし、言語標準の機能だけで堅く書けるようになりますしね。

ただし、この解法にはちょっとしたデメリットがあります。

ひとつはステートメントをまたいだボイラープレートを要求する、つまり ; と改行区切りで書かないといけなくなることです。

// Before
$books = search('ネコソギラジカル', [
    'begin' => new DateTimeImmutable('2002-11-01'),
]);

// After
$search_option = new SearchOption();
$search_option->begin = new DateTimeImmutable('2002-11-01');
$books = search('ネコソギラジカル', $search_option);

BeforeとAfterのどちらが良いかは、好みによるところがあります。行数こそ同じですが、不思議なことにコード量は増えてしまいましたね。では、クラスを利用することと、ボイラープレートのステートメントを増やさないことは両立するでしょうか。その方法もいちおうあります。

SearchOptions.php
class SearchOptions
{
    /** @var ?DateTimeInterface 検索期間の始点 */
    public $begin;
    /** @var ?DateTimeInterface 検索期間の終点 */
    public $end;

    public static function fromArray(array $array): self
    {
        $option = new self();
        $option->begin = $array['begin'] ?? null;
        $option->end = $array['end'] ?? null;

        return $option;
    }
}
$books = search('ネコソギラジカル', SearchOptions::fromArray([
    'begin' => new DateTimeImmutable('2002-11-01'),
]));

SearchOptions::fromArray()のぶんだけちょっと長くはなりましたが、悪くはないんじゃないでしょうか。静的メソッドを公開するかコンストラクタを公開するかも好みの問題ですね。ただ、問題が「SearchOptions::fromArray()に何を渡せばよいのかわからなくなった」ことにすりかわってますね。ううむ。

旧PSR-5ジェネリクス記法による配列の型付け

phpDocumentorやPhpStormを含めた複数の処理系がサポートするPHPDocのデファクトな仕様では Book[] のように記述することで [new Book("a"), new Book("b"), new Book("c")] のようなBookクラスのオブジェクトだけが並んだ配列を表現することができます。

この記法のメリットはいくつかあります。わかりやすく言うと、foreachの中でも型がついていることです。

$books = getBooks();

foreach ($books as $book) {
    $book-> // Bookオブジェクトのメソッドの補完が効く
}

このgetBooks()は以下のように型をつけることができます。

/**
 * @return Books[]
 */
function getBook(): array
{
    // ...
}

この記法には弱点があります。PHPには複数のイテレータがありますが、[]は飽くまで配列を表すので、ジェネレータやArrayIterator、あるいはその他のコレクションクラスなどは表現できないのです。

そこで、PHPDocを解釈するツールの多くは以下のようなジェネリクス記法をサポートします。

/**
 * @return ArrayObject<Book>
 */
function getBook(): array
{
    // ...
}

従来記法では値の型のみを指定できましたが、ジェネリクス記法はキーと値の型を同時に指定できます。

/**
 * @return ArrayObject<int, Book>
 */
function getBook(): array
{
    // ...
}

さて、ここまで「旧PSR-5」とわざわざ銘打っているということは、現在の策定作業中のPSR-5 Draftにはこの仕様が含まれていません。議論の長期化により、remove references to generics by ashnazg · Pull Request #1102 · php-fig/fig-standardsで一旦取り除かれました。ただし、これは将来的な議論を妨げるものではないことを明言されています。実際にはPhan, Psalm, PHPStan, phpDocumentor/TypeResolver, Phpactorなど数多くのツールでこの形式をサポートしているので、使えるものと見てよいでしょう。

array-shapes(Object-like arrays)を使った型付け

array-shapes記法はPhanでは0.10.4で導入された記法で、PsalmではObject-like arraysと呼ばれています。簡素ですが、仕様はPsalmのドキュメントに掲載されています。

連想配列を返す関数は以下のように型付けできます。

/**
 * @return array{name: string, birthday?: DateTimeImmutable, age: int}
 */
$f = function ():  {
    return [
        'name' => 'Miku',
        'age' => 16,
    ];
}

birthday?はキーの最後に?がついているのではなく、birthdayというキーが省略可能だということです。

また、PHPのlist()はタプル展開のための機能に書いたようにタプル風のリストで値を返す場合は以下のように型付けできます。

/**
 * @return array{0: string, 1: ?DateTimeImmutable, 2: int}
 */
$f = function ():  {
    return ['Miku', null, 16];
}

[$name, $birthday, $age] = $f();

PHPではキーを明示的に指定しない値は0からの連番の数字が振られています。先程はbirthdayのキーそのものを省略可能にしていますが、こちらは文字列でキーを指定していたときと意図的に構造を変えていて、キーを省略可能にするのではなく、要素の型を?DateTimeImmutableにして明示的にnullを返しています。

キーを省略するのか値の型をnullableにするのか、あるいはどちらでも良いように許容するのか、何が適しているは各自の考えかたによるので、各自よく考えて決めてください。

ということで、今回の問題提起のきっかけになった関数はSearchOptionsクラスを定義することでも解決できますし、配列を受け取るようにもできます。

/**
 * @param array{begin?: ?DateTimeInterface, end?: ?DateTimeInterface} $options
 */
function search(string $word, array $options): array
{
    $begin = null;
    if (isset($options['begin'])) {
        $begin = $options['begin']->getTimestamp();
    }

    $end = null;
    if (isset($options['end'])) {
        $end = $options['end']->getTimestamp();
    }

    $query = compile_query('search_book', $word, $begin, $end);

    return request_search_engine($query);
}

PHPのisset($array['key'])は「$array['key']が未定義だったとき」と「$array['key']の要素がnullだったとき」の両方でtrueになります。そのためbegin?: ?DateTimeInterfaceと書くことで両方に対応しています。

また、SearchOptionsに渡す引数の方に型を付けても構いません。

SearchOptions.php
<?php

class SearchOptions
{
    /** @var ?DateTimeInterface 検索期間の始点 */
    public $begin;
    /** @var ?DateTimeInterface 検索期間の終点 */
    public $end;

    /**
     * @param array{begin?: ?DateTimeInterface, end?: ?DateTimeInterface} $options
     */
    public static function fromArray(array $array): self
    {
        $option = new self();
        $option->begin = $array['begin'] ?? null;
        $option->end = $array['end'] ?? null;

        return $option;
    }
}

互換性の問題

ここまで紹介してきた旧PSR-5形式のジェネリクス記法array shapes記法(Object-like arrays)ですが、PhpStormは2020年現在(2020.1 EAP)でも、依然としてサポートしていません(PhpStormに実装されてない未来のPHPDocとPSR-5ジェネリクス記法 - Qiita)。

ただし、deep-assoc-completion - for IntelliJ IDEA, PhpStorm | JetBrainsプラグインをインストールすることでPhpStormにこれらの基本の入力補完を追加することができます。インストールしましょう。

また、LSP Support - plugin for IntelliJ IDEs | JetBrainsプラグインでPsalmをLSPサーバとして登録することでもPsalmの静的解析を組み込むこともできますが、(私の感想では)やや不安定な感があります。

まとめ

PhpStormの互換性をある程度に妥協できればarray shapes記法(Object-like arrays)と旧PSR-5記法は実用的に使えるので、使い始めましょう。

より発展的な機能としてPHPDocを使ったPHPのジェネリクスというものもあります。

おまけ: WordPressの@typeタグ

WordPressでは連想配列の内部構造に型をつける記法として@typeが用いられています。この記法は内部構造のドキュメント化ができるという点で優れていますが、多くの静的解析ツールには実装されておらず、WordPress文化圏での独自仕様に留まっています。このタグについての仕様はWordPress Coreの開発マニュアル(PHP Documentation Standards – Make WordPress Core)にまとめられています。WordPress本体やプラグイン以外では多用しないのが無難でしょう。この記法はほかのタグの記法と矛盾はしませんが、2020年2月現在の多くのツールでは利用されないため、この記事では紹介しません。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
25
Help us understand the problem. What are the problem?