9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

PHPには、他の言語でよく見られる「ジェネリクス」という文法が存在しません。しかし、PHPStanやPsalmのような静的解析ツールを活用することで、docblockの@templateコメントを使ってジェネリクスを利用することが可能です。とはいえ、レガシーコードが大量に存在する場合、静的解析ツールを導入しようとしても、コードの修正が容易ではなく、既存のコーディングスタイルを静的解析に適したスタイルへ変更する必要があるため、導入が困難なケースも少なくありません。

PHPにはジェネリクスの文法がないのは残念ですが、ジェネリクスがなくても製品開発において大きな支障はありません。本記事では、PHPにジェネリクスがなくてもそれほど不便を感じなかった理由や、ジェネリクスがなくてもそれを克服してPHPを有効に活用する方法について述べます。

本論

連想配列の問題

PHPは「連想配列」というデータ構造を使用します。連想配列とは、キーと値のペアでデータを保存する形式です。PHPではキーを指定せずに配列にデータを保存しても、0、1、2、3…というインデックスが自動的に付与されます。これは、PHPの配列は必ずキーを持つ連想配列であり、キーを明示的に指定しなかった場合、自動的に0以上の整数キーが生成されるためです。

さまざまな種類のデータ構造を使用する他の言語とは異なり、PHPでは主に連想配列がデータ構造として使われます。連想配列は、さまざまなデータ構造を1つの連想配列で代用できるという利点があります。複雑なデータ構造の理解がなくても簡単に使えるため、扱いやすいというメリットがありますが、その一方で連想配列は非常に多様な構造を持つことができるため、配列を扱う際にはその中にどのようなデータが渡されているのかを把握しておく必要があるという問題点もあります。

上記のような配列を扱う場合、その配列をどのように処理すればよいのか、配列の要素を一貫した方法で扱う手段がなかったり、考えにくかったりします。

それに対して、配列のすべての要素が同じ型であれば、配列を走査しながら要素を一貫した方法で扱うことができるため、コーディングしやすくなります。

$arr = [
    'a' => 1,
    'b' => 2,
    'c' => 100,
    'd' => 200,
];

foreach($arr as $k => $e) {
    if ($e >= 100) {
        echo "Key $k's value is 100 or more.";
    }
};

配列のキーが文字列であることさえ分かっていれば、配列の中の値がどのようなものであっても、値が整数であれば if 文による数値比較を行うことができ、echoを使って文字列として出力することも可能です。

概念的なジェネリクス

概念的なジェネリクスとは、「可能な限り多様な型」を扱える汎用的なアルゴリズムやロジックを指し、特定の型に依存しない処理ができる場合に「ジェネリックである」と言います。

型依存的とは?

ジェネリクスを使用する機能には、ジェネリクスで指定した値の扱い方に制限がある場合があります。例えば、変数 $a に割り当てられた値の型が int|string だと仮定します。この変数に対してコードを作成する際、strtoupper($a)のような文字列を対象とする関数とは結びつけてはいけません。なぜなら、$aが整数の場合にエラーが発生し、$aが取りうるすべての型に対して動作しないためです。それに対してintval($a)は整数と文字列の両方に使用できるため、変数$aに対して使用しても問題ありません。このように、渡される値の型のうち一部の型にしか対応しない場合は特定の型に依存していることになり、渡される値のすべての型に対応できる場合は型に依存しないという意味になります。

  • strtoupper 関数のシグニチャー : strtoupper(string $string): string
  • intval 関数のシグニチャー : intval(mixed $value, int $base = 10): int

ある関数fnにおいて、パラメーターが複数の型の引数を受け取れる場合、特定の型への依存度が減少します。
例えば、fnに文字列sを渡した場合と整数iを渡した場合、それぞれfn(s)fn(i)が正常に動作するなら、fnは文字列sのみを対象とする場合とは異なり、より多様な型に対応しているため、型への依存度が低くなります。

ジェネリック(generic)は「包括的な」という意味を持つ用語で、特定の型だけでなく多様な型に対応可能な機能を指します。以下の説明でより具体的に理解していきましょう。

タイプスクリプトのジェネリクス

PHP開発者であれば、ある程度JavaScriptの知識があります。TypeScriptはJavaScriptに型を追加したもので、簡単なTypeScriptの例を通じてジェネリクスを簡単に理解することができます。

TypeScriptは、JavaScriptの配列の要素の型を指定できるジェネリクスを提供しています。Array<T>のTの部分に型を指定することで、配列の要素の型を制限することができます。

Tの部分に任意の型を指定することで、Array<int>Array<string>など、様々な型に対応する機能を作ることができます。

タイプスクリプトコードの例

const elements: Array<number> = [1, 2, 3, 4, 5];

型Arrayの横に <number> を付けることで、配列内のすべての要素が number 型に決まり、配列に他の型を入れた場合、IDEの静的解析やコンパイラによるエラーが発生します。

const elements: Array<string> = ['a', 'b', 'c', 'd', 'e'];

同様に <string> を付けることで、配列内のすべての要素が string 型に決まり、配列に他の型を入れた場合、IDEの静的解析やコンパイラによるエラーが発生します。

ジェネリクスとユニオン

const elements: Array<T> = [e1, e2, e3, e4, e5];

上記のコードでは、型Tに対して e1, e2, e3, e4, e5 を使うと、それぞれの要素は <T> で指定した型でなければなりません。配列を使うときに希望する型を指定できるため、<T> で型を指定して使うことは特定の型に依存しない機能を提供します。

const elements: Array<number | string> = [1, 'a', 3, 'b', 5];

<T> の部分に <number | string> のようにユニオン型を指定すると、配列内の要素は number 型と string 型の2種類の値を入れることができるため、IDEの静的解析やコンパイラによるエラーは発生しません。ただし、それぞれの要素を取り出して使うとき、要素が数値の場合と文字列の場合の両方を処理する必要があります。

ジェネリクスの有用性

const elements: Array<string> = ['a', 'b', 'c', 'd', 'e'];

console.log((elements.pop() ?? '').toUpperCase());
console.log((elements.pop() ?? '').toUpperCase());
console.log((elements.pop() ?? '').toUpperCase());
console.log((elements.pop() ?? '').toUpperCase());
console.log((elements.pop() ?? '').toUpperCase());

.toUpperCase() メソッドは、文字列型の対象でのみ使えるメソッドです。もし numbers.pop() で取り出した値が文字列でなければ、取り出した値に .toUpperCase() を使ったコードはIDEの静的解析やコンパイラによるエラーが発生します。配列からこれ以上取り出すデータがない場合は undefined が返されるので、.toUpperCase() に繋げるために空文字列になるように ?? '' のコードを追加しています。

もし elements.pop() で取り出した値が文字列だけでなく数値型の場合、.toUpperCase() メソッドは存在しないため使えません。ジェネリクスを使って配列の要素型を文字列に限定したことで、.toUpperCase() を付けるコードが使えるようになります。

ジェネリック構文

PHPの関数やメソッドの引数や戻り値は、特定の型に関係なく値が渡されるため、型に依存しないという点で概念的にはジェネリックであると言えます。

それに対して、使用する前に利用する型を具体的に指定しなければならないものを「ジェネリック構文」と呼びます。TypeScriptの例で言えば、配列(Array<T>)は型に依存しませんが、<> の部分に <string><number> などの型を指定することで、それ以降は指定した型のみで使用するように強制されるのがジェネリック構文です。

データ構造ではジェネリックが特によく使われます。その理由は、データ構造が格納する値の型に関係なく動作できるようにするためです。例えばジェネリックがなければ、特定のデータ構造がA型のみを格納するように作られている場合、B型やC型のデータを格納したければ、それぞれの型に合わせたデータ構造を別々に作成しなければなりません。しかし、ジェネリックの概念を適用したデータ構造であれば、どのような型にもとらわれず、同じデータ構造を様々な型の扱いに利用することができます。

PHPでジェネリクスを理解する

データを入れた順番とは逆にデータが取り出される「後入れ先出し(LIFO)」のデータ構造をPHPで簡単に作ってみましょう。

class Lifo
{
    private array $array = [];

    public function push($value)
    {
        $this->array[] = $value;
    }

    public function pop()
    {
        return array_pop($this->array);
    }
}

$lifo = (new Lifo);
$lifo->push('a');
$lifo->push('b');
$lifo->push('c');
var_dump($lifo->pop());
var_dump($lifo->pop());
var_dump($lifo->pop());

push('a')push('b')push('c')の順にデータを追加し、最後に追加した値から順番に値が取り出されます。

タイプヒントについて考えてみる

文字列の値を入れると考えてみましょう。public function push(string $value)public function pop(): string のようにタイプヒントを付けることができます。

整数を入れると考えてみましょう。public function push(int $value)public function pop(): int のようにタイプヒントを付けることができます。

任意の型を入れると考えてみましょう。public function push(mixed $value)public function pop(): mixed のようにタイプヒントを付けることができます。しかしこの場合、データを取り出したときにどの型なのか特定できません。

上記のクラスの内部構造を知らず、クラスのインターフェースだけ知っているとしましょう。pushpop メソッドがあることだけ分かります。クラス内部でタイプヒントを付けたいのですが、PHPにはジェネリック構文がないため、外部から保存するデータの型を決定する方法がありません。

もしPHPがジェネリクスをサポートしていれば、Lifo クラスを使うときにクラス内部でデータを保存する際、外部から渡された型で制約をかけることができます。new Lifo<Type> のようにジェネリック構文で型を渡せば、public function push(Type $value)public function pop(): Type のように、Type の部分にクラスで <Type> と指定した型が入り、型安全なコードを書くことができます。

ジェネリクスがあれば <string> を入れると push(string $value)pop(): string となり、<number> を入れると push(number $value)pop(): number となります。

各型ごとのコードを書いてみる

整数を保存するLIFO

class Lifo
{
    private array $array = [];

    public function push(int $value): void
    {
        $this->array[] = $value;
    }

    public function pop(): ?int
    {
        return array_pop($this->array);
    }
}

文字列を保存するLIFO

class Lifo
{
    private array $array = [];

    public function push(string $value): void
    {
        $this->array[] = $value;
    }

    public function pop(): ?string
    {
        return array_pop($this->array);
    }
}

型パラメータが存在する場合は?

ジェネリック構文とは、型パラメータを通じて使用したい型を指定するものです。ジェネリクスがないPHPでは、型をパラメータのように外部から注入することはできませんが、ジェネリクスがあれば次のようなコードを作ることができます。

class Lifo<T>
{
    private array<T> $array = [];

    public function push(T $value)
    {
        $this->array[] = $value;
    }

    public function pop(): ?T
    {
        return array_pop($this->array);
    }
}

$lifoString = new Lifo<string>;
$lifo->push('a');
$lifo->push('b');
$lifo->push('c');

$lifoInt = new Lifo<int>;
$lifo->push(1);
$lifo->push(2);
$lifo->push(3);

型ヒントの位置にTという型パラメータを使い、クラスをオブジェクトとしてインスタンス化する際に型パラメータに渡す型を <string><int> のように指定すれば、Tの位置に stringint の型制約がかかります。

TypeScriptで理解する

ジェネリクスをサポートするTypeScriptで、PHPコードをTypeScriptに置き換えて理解してみましょう。

class Lifo<T> {
    private array: T[] = [];

    public push(value: T): void {
        this.array.push(value);
    }

    public pop(): T | undefined {
        return this.array.pop();
    }
}

const lifo = new Lifo<string>();
lifo.push('a');
lifo.push('b');
lifo.push('c');

console.log(lifo.pop()); // 'c'
console.log(lifo.pop()); // 'b'
console.log(lifo.pop()); // 'a'
console.log(lifo.pop()); // undefined

Lifoというクラスにジェネリック構文で <string> 型を指定することで、lifo.push() で値を追加したり、lifo.pop() で値を取り出す際に、文字列だけが(取り出す値がないときは undefined)返されるようにします。

Lifo<string>Lifo<number> のように、ジェネリック構文でどの型を指定するかによって、Lifo クラス内部のTとなっている部分の型が変わります。Tはどの型を受け取るかを決めるパラメータであり、型を受け取る型パラメータの役割を果たします。

コンパイル言語におけるジェネリクス

コンパイル言語では、データ構造を使用する際に格納するデータの型を明確に指定しなければなりません。データ構造は様々な型に対応するためにジェネリクスを利用します。複数の型を指定したい場合は、ユニオン型や共通のインターフェース、親子関係にあるクラス、あるいは複数のクラスの共通の親クラスを型として指定する必要があります。

このとき、ジェネリクスで指定していない別の型の値を使用することはできません。なぜなら、あるデータ構造からデータを取り出す際に型やインターフェースが異なると、ランタイムで誤った型を参照・渡してしまい致命的なエラーが発生する可能性があるため、指定した型に合致する(intを返すならintを扱うコードへ、stringならstringを扱うコードへ)コードしか使えないように制限するためです。

コンパイル言語は、型によって制限されたコーディングスタイルを提供し、コンパイルによって機械語や機械語に近い中間言語(Javaのバイトコード、C#のCLI:Common Intermediate Language)へと変換されます。コンパイル時に厳格な型チェックを行うため、ランタイムで型チェックをしなくても型エラーが発生しません。

データ構造のケースを考えてみましょう。保存や取り出しの操作を行う際、データを取り出したときにどの型なのかを知ることで、どのコードにつなげるかを決定できます。コンパイル言語はランタイムで型チェックを行わないため、データを取り出す際に型をチェックする動作をせず、そのまま値を渡します。このとき、メモリに保存されている値と渡そうとするランタイムのコード実行時に型不一致が起きると、致命的なエラーが発生する可能性があります。

PHPの場合、型不一致が発生すると言語自体のエラーが例外のようにスローされ、スタックトレースが生成されますが、機械語や中間言語に変換されたコードを実行するコンパイル言語では、意図的に型チェックを行わない限り、ランタイムで型不一致が発生した場合にこれを処理できず、プログラムはクラッシュしてしまいます。そのため、このような事態が発生しないよう、コンパイル時に強力かつ厳格な静的検査が行われます。

そのため、コンパイル言語では利用する型をコンパイル時に指定しておく必要があります。データ構造の使用と同時に、そのデータ構造に入る型を指定し、データ構造にデータを入れたり取り出す際に明確な型を定め、つなげるコードの動作をランタイムのコード実行前に保証するための仕組みが、コンパイル言語で「ジェネリクス」と呼ばれる構文です。

ユニオン型

ユニオン型に関する一般的なコーディングスタイル

上で紹介した例のLifoクラスでは、内部でタイプヒントをstringで定義したか、intで定義したかによって、配列内に渡せるデータは文字列になったり整数になったりします。文字列のみを保存するLifoを使いたい場合は文字列のタイプヒントを持つクラスを作る必要があり、整数のみを保存するLifoを使いたい場合は整数のタイプヒントを持つクラスを作る必要があります。一つの型に限定されているため、pushメソッドで型を渡すときや、popメソッドで値を取り出すときは一つの型(取り出す対象がない場合はnullも追加)だけを考えれば済みます。

しかし、複数のクラスを作るのは効率的とは言えません。ジェネリクスが使えれば、Lifoクラスを一つだけ用意し、インスタンス化する際にnew Lifo<string>new Lifo<int>のように型を指定すれば良いのですが、PHPではジェネリクスが使えないため、ユニオン型やmixedタイプヒントを使用します。しかしこの場合、pushメソッドで型を渡すときや、popメソッドで値を取り出すときに、それぞれの要素の型はint単体やstring単体ではなく、int|stringとなるため、取り出した値を処理する際にはintに対してもstringに対しても成立するようにコードを書く必要があります。ジェネリクスで単一型を使う場合には不要だった処理も、ユニオン型を使うことで型ごとの分岐処理ロジックの記述が必要になります。

タイプヒントを使うことで、指定した型以外が渡された場合にエラーを発生させ、誤った型の値が渡された理由をタイプエラーから特定し、これを改善することで正確なコードを書き上げることができ、以降はタイプヒントで限定された範囲内でIDEの支援を受けながら安全に実装できます。

時には、実際には渡されない複数型のロジック処理を書く必要がない場合もあるため、タイプヒントで指定した全ての型ではなく一部の型のみ処理ロジックを作ることもあります。インタプリタ言語の場合、ランタイムで型を確認するため、型がint | stringであっても渡される値がintだけならintだけの処理、stringだけならstringだけの処理をすれば十分です。

タイプシステムが強い言語の場合、ユニオン型が渡された際には全ての型ケースに対する分岐ロジックを書かなければなりません。int | stringが渡された場合、必ずint用とstring用の処理を両方記述する必要があります。PHPでも強力な静的解析ツールを使う場合、型安全のためにユニオン型の全ての型に対する処理を記述しなければなりません(そうしなければ静的解析ツールを通過できません)。

タイプヒントが?int(null許容int)になっている場合、結びつくロジックはintnullの両方の処理を作らなければならず、もしnullが実際には使われない値であれば?intではなくintだけにしてタイプヒントから除外することで、そのコードを使うときに不要な処理の追加を避けることができます。

一般的には、インタプリタ言語でユニオン型に対して渡される値がユニオン型で指定した型の中の特定の型だけになることが前提だとしても、その一部の型だけでなく、ユニオン型が持つ全ての型に対する処理ロジックを追加するのが一般的なコーディングスタイルです。

ユニオン型とassertによるジェネリクスの代用

PHPでは、タイプヒントで使用したい型の内部型を設定するジェネリクス構文が存在しないため、基本的にはユニオン型、共通のインターフェース、共通の親クラスなど、複数の型に対応するロジックを作りますが、値を渡すときや取り出すときに、ユニオン型の中の一部の型やサブクラス、インターフェースの一部の型に対してのみロジックが動作するという前提でコードを書く方法があります。

事前条件による条件付け

declare(strict_types = 1);

$list = rand(0, 1) ? range(0, 30) : range('a', 'z');

assert(array_reduce($list, fn($acc, $v) => $acc && is_int($v), true));

$result = array_map(fn($v) => $v+1, $list);

var_dump($result);

上記のコードでは、$listは0から30までの整数またはaからzまでの文字列の要素を持ち、すべて整数の要素か、すべて文字列の要素を持つコードです。

array_map(fn($v) => $v+1, $list)の部分のコードは、文字列の場合には動作せず、数値(int)の場合にのみ動作します。$listの要素には明示的な型ヒントはありませんが、暗黙的にはintまたはstring型です。

assertによって、もし配列のすべての要素が整数でなければエラーで終了するようになっています。これは、その行以降のコードは整数でなければ実行できないという前提条件を設定しているのです。

この前提条件によって、配列の要素がintまたはstring型であっても、assertのような前提条件を設定するコードを通して、開発者は「この配列は内部要素がint型だからint型だけを処理しよう」と考えることができます。

上記のコードは、PHPStanやPsalmのような強力な静的解析ツールによって、文字列要素に対する処理を行っていないというエラーメッセージが表示されます。array_reduceなどで前提条件を指定する場合、配列のすべての要素を確認する少し複雑なコードが含まれるassert文による型制限は静的解析ツールが解析できないため、$listの要素がintのみであるという前提条件を指定しても、依然としてintとstringの両方に対する処理が必要だと指摘される問題があり、開発者に型の条件を伝えるだけにとどまります。

ユニオンタイプの問題

ジェネリクスを使用する場合、型パラメータTに対して new Lifo<string>new Lifo<int> のように型を指定すれば、$lifo->push($value) でデータを追加するときに $value の型が <Type> で指定した型に固定され、$el = $lifo->pop() でデータを取り出すときに $el の型がジェネリクスで指定した型、または null になります。

しかし、Lifo の型パラメータが T ではなく mixed 型で固定されている場合、Lifo は型の制限なくどんな値でも使用できます。ただし、$lifo->push($value) で値を渡すときも、$el = $lifo->pop() で値を取り出すときも、あらゆる型が可能になるため、$el を処理するにはすべての型に対応するコードを書かなければなりません。これは不可能なので、処理後の型を最低限に絞る必要があります。

PHPにはジェネリクスがないため、mixedやユニオン型を使い、値を渡したり取得したりする際に assert などのツールを使って型の前提条件を設定することで、ジェネリクスがなくても型安全なコードを作ることができます。

しかし、ジェネリクスで型制限をかける場合、取り出すたびに型を確認する必要がなく、機能を生成するタイミング(new Lifo<string>new Lifo<int>)でジェネリクスによって型が決定されます。したがって、IDEによる静的推論を便利に利用できる一方で、型を確認する方法では、取り出すたびに型確認のコードを書く必要があるというデメリットがあります。次の内容で具体的に理解してみましょう。

Type Assertion と Type Narrowing

複数の型を持つ変数のうち、一部の型だけを使用する場合には、アサートキーワード(assert)と型の絞り込み(Type Narrowing)を通じて、型アサーション(Type Assertion)を行うことができます。この機能は、さまざまな静的解析ツールでサポートされているもので、PhpStormなどのIDEを使用すれば、ユニオン型や親クラス・インターフェースで構成された型に対して、ある特定の部分集合の型やサブタイプとして動作することを前提にIDEに認識させることができます。

IDEは、ユニオン型が持つすべての型に対する処理を行わないと型ミスマッチとして指摘します。また、共通インターフェースや親クラスなどの型の変数を処理する際に、特定のサブインターフェースやサブクラスに対する処理を行う場合でも型ミスマッチとされます。型アサーションを使うことで、渡される可能性のある複数の型のうち特定の型に対してのみ成り立つという前提をassertでIDEに知らせ、部分集合の型やサブクラス、インターフェースに対する処理もIDEや静的解析を通過できるようにできます。

コードを通して理解する 1

declare(strict_types = 1);

class TypeAssertion
{
    public int|string $numeric;
}

function addOne(int $value): int {
    return $value + 1;
}

$list = [100, '100'];

$obj = (new TypeAssertion);
$obj->numeric = $list[rand(0,1)];

assert(is_int($obj->numeric));

$result = addOne($obj->numeric);
var_dump($result);

TypeAssertionのメンバー変数 $numeric は int または string 型を持ちます。$obj->numeric というコードだけでは、上記のコードから 100 が代入されるのか、'100' が代入されるのかは分かりません。addOne 関数は int 型のみを受け取りますが、文字列型が渡されるとエラーになります。そのため、IDEによる静的解析や静的解析ツールによって、addOne($obj->numeric) の部分で int だけでなく string も渡される可能性があるため、addOneint 型のみを受け取ることから静的解析上のエラーが発生します。

前述の、配列の要素をすべて確認する assert(array_reduce($list, fn($acc, $v) => $acc && is_int($v), true)) のようなコードは、静的解析ツールが配列の内部要素の型を制限していると判定するのが難しいほど複雑ですが、単純に is_int($var)is_string($var)|| is_null($var) などのコードを使えば、静的解析ツールでType narrowingキーワードで指定された方式なので、IDEや静的解析ツールはそれに基づいて型の範囲を狭めることができます。

上記のコードで assert(is_int($obj->numeric)) 部分のコメントを外すと、$obj->numericの型はint|stringですが、assertで型アサーションをすることで int と認識され、静的解析による指摘を回避できます。

コードを通して理解する 2

declare(strict_types = 1);

class Lifo
{
    // @PHPStan-ignore-next-line
    private array $array = [];

    public function push(mixed $value): void
    {
        $this->array[] = $value;
    }

    public function pop(): mixed
    {
        return array_pop($this->array);
    }
}

$stringList = json_decode(json_encode(range('a', 'i')) ?: '[]');

assert(is_array($stringList));

$lifoString = (new Lifo);
array_walk($stringList, function ($v) use ($lifoString) {
    assert(is_string($v));
    $lifoString->push($v);
});

for($i=0; $i < count($stringList) ; $i++) {
    $output = $lifoString->pop();
    assert(is_string($output));
    echo $output;
}

$numberList = json_decode(json_encode(range(1, 10)) ?: '[]');

assert(is_array($numberList));

$lifoInteger = (new Lifo);
array_walk($numberList, function ($v) use ($lifoInteger) {
    assert(is_int($v));
    $lifoInteger->push($v);
});

for($i=0; $i < count($numberList) ; $i++) {
    $output = $lifoInteger->pop();
    assert(is_int($output));
    echo $output;
}

json_decode(json_encode(range('a', 'i')) ?: '[]')json_decode(json_encode(range(1, 10)) ?: '[]')というコードを使った理由は、結果値の型を静的解析ツールが特定できないようにするためです。

assert(is_array($stringList))assert(is_array($numberList))は、不明な型の値に対して「配列である」という前提を設定し、静的解析ツールによるarray_walkでの走査を可能にしています。

配列内部の要素の型は分かりませんが、->push($v)の前にassert(is_int($v))assert(is_string($v))を使うことで、使用したい型以外の配列要素が渡されないという前提条件を設定しています。

上記のコードでecho $output;を使用すると、値は文字列に型変換されて出力されます。値によっては文字列に変換できるものとできないものがあります。PHPStanなどの静的解析ツールでこのコードを実行すると、$lifoString->pop();の返り値の型がmixedとなっているため、文字列に変換できない値が出る可能性があり、型エラーが発生します。しかし、assert(is_int($output))assert(is_string($output))echoで出力可能な型の前提条件を設定することで、IDEに使用する型を伝え、静的推論による指摘を回避できます。

このように、ジェネリクスがなくてもassertによる型アサーションを使うことで、ジェネリクスなしでも静的解析を満たすコードを作ることができます。

配列要素に対する静的解析

配列要素に対する静的解析の限界

$list = json_decode(json_encode(range('a', 'i')) ?: '[]');

assert(is_array($list));

foreach($list as $el) {
    assert(is_string($el));
}

foreach($list as $el) {
    echo $el; // Parameter #1 (mixed) of echo cannot be converted to string.
}

たとえ上記のコードのように、配列内のすべての要素が文字列であることをループで assert(is_string($el)) によって確認したとしても、その後のループ処理において、IDEの静的解析やPHPStanなどの静的解析ツールは配列内の要素の型を認識できません。そのため、ループ処理を書くたびに assert を使った型アサーションコードを繰り返し記述しなければならないという問題があります。

foreach vs array_walk

静的解析ツールは配列内の要素の型を推論できないため、配列の要素が何であるかをIDEに伝える必要があります。assertで要素の型をIDEや静的解析ツールに伝えるには、配列の値を一つずつ取り出したときに可能です。assert文の中でis_型instanceofを使うことで、IDEの静的解析による自動補完を利用できます。

foreachforを使用する場合、ループを回すたびに配列から値を1つずつ取り出すコードを書くことになります。このとき、巡回する要素の型は分からず、PHPの配列は要素の型を指定できないため、静的解析ではmixedとして扱われます。取り出した値を変数に代入し、assertを使って型を絞り込むことで、処理対象となる型だけを指定します。すると、IDEはその型としてコードの型を推論します。

array_walkも配列を巡回する機能ですが、関数を1つ受け取り、その関数のパラメータとして配列の要素を1つずつ渡して巡回処理を行います。しかし、配列の要素型が分からないためmixedと推論されます。巡回関数のパラメータに型ヒントTを設定すると、静的解析ではmixedと推論されているのに特定の型Tに結びつけようとしたとみなされ、型が合わないという指摘を受けます。したがって、巡回関数のパラメータには型ヒントを付けないかmixedにし、関数内部でパラメータ変数をassertで型を絞り込み、処理対象となる型だけを指定する方法で静的解析の指摘を避けます。

$list = json_decode(json_encode(range('a', 'i')) ?: '[]');

assert(is_array($list));

foreach($list as $el) {
    // assert(is_string($el));
    echo $el;
}

array_walk($list, function (string $el) {
    echo $el;
});

array_walk($list, function (mixed $el) {
    // assert(is_string($el));
    echo $el;
});

foreach文では $el の型が分からないため、echo $el で出力できる文字列への変換が可能かどうかも分かりません。そのため、assert を使って各要素の型が文字列かどうかを確認する必要があります。

$list 配列内の各要素は mixed と推論されますが、array_walk 関数のコールバック関数のパラメータの型ヒントは string になっています。バニラPHPではこのようなコーディングスタイルでも実行上問題ありませんが、静的解析ツールを利用している場合は型ミスマッチとして指摘されます。したがって、コールバック関数のパラメータの型は配列要素の型として推論されるものと同じにし、各要素を使用する際に assert で実際に使う型をIDEに伝えるようにしましょう。

配列の要素が単一型の場合

class Item
{
    protected int $cached;

    public function __construct()
    {
        $this->cached = rand();
    }
	
    public function getCachedValue(): int
    {
        return $this->cached;
    }
}

class SubItem extends Item
{
    public function getCachedValueToString(): string
    {
        return strval($this->cached);
    }
}

$numberList = [];

for($i=0; $i<10; $i++) {
    $numberList[$i] = new SubItem;
}

foreach($numberList as $item) {
    echo $item->getCachedValue(), PHP_EOL;
}

echo PHP_EOL."------------".PHP_EOL;

array_walk($numberList, function (Item $item): void {
    echo $item->getCachedValue(), PHP_EOL;
});

array_walk($numberList, function (Item $item): void {
    echo $item->getCachedValueToString(), PHP_EOL;;
});

上記のコードでは、foreach 文を使わずに array_walk でコールバック関数を利用しています。コールバック関数を使うことで、各要素に対して型ヒントを適用でき、型ヒントによって異なる型が渡された場合はエラーになります。また、IDEは型ヒントを通してオブジェクト内部にプロパティがあるかどうかを推論できるため、コーディングがしやすくなります。

PHPは配列に単一の型だけでなく様々な型を持つことができるため、配列からデータを取り出すたびに不安を感じることがあります。イテラブルな型にコールバック関数を使って型ヒントを付けることで、一度型を確認でき、コードに安心感を与えることができます。

phpdoc ジェネリクス

phpdocを使用して /* @var array<int, Type> *//* @template Type */ のテンプレートを使うことで、phpでもジェネリクスを利用することができます。

/**
 * @template T
 */
class Lifo
{
    /**
     * @var array<int, T>
     */
    private array $array = [];

    /**
     * @param T $value
     */
    public function push(mixed $value): void
    {
        $this->array[] = $value;
    }

    /**
     * @return T|null
     */
    public function pop(): mixed
    {
        return array_pop($this->array);
    }
}

/** @var Lifo<int> $lifo */
$lifo = new Lifo();
$lifo->push(1);
$lifo->push(2);

$sum = ($lifo->pop() ?? 0) + ($lifo->pop() ?? 0);

docblockジェネリクスは言語のネイティブ文法ではなく、サードパーティの静的解析ツールに依存し、ランタイムでの型チェックは行いません。ネイティブ文法ではないため、言語の組み込みツールと比べて記述しなければならないボイラープレートが多くなり、また言語の動作に影響を与えないようにランタイムチェックもありません。さらに、docblockで型を指定する場合、docblock自体(/* ... */の...部分)はIDEによる構文チェックが行われないため、docblockのタイピング時にミスが発生しやすく、何が間違っているのか把握しにくいという問題があります。

また、静的解析ツールはサードパーティ製なので、プロジェクトでPHPStanを使っているが、ライブラリ側はPsalmを使っている場合、ライブラリのコードを利用する際に型推論がうまく働かない互換性の問題が発生することがあります。

ユニオン型の問題

PHPのさまざまなライブラリはユニオン型で型が定義されており、ジェネリクスが使われていないため、ライブラリの機能がユニオン型やクラス型の場合、ユニオン型の中のいくつかの型やサブタイプのいずれかを使うことになります。そのため、すべてのコードで静的解析を使用すると、ライブラリの型ヒントで提供されるすべての型に対する処理を記述する必要があります。

たとえば、LaravelのEloquentを使う場合、SomeModel::someQuery()->first() というコードでは、Model|object|BuildsQueries|null が型ヒントになっています。Laravel 11以降ではPHPStanによる静的解析をサポートするために TValue|null となっていますが、Laravel 10以前の場合、Model|object|BuildsQueries|null なので静的解析ツールを使うとすべての型に対する分岐処理が必要になります。しかし、一般的な状況で実際にfirstが返す値はSomeModelクラスのインスタンス、またはnullの2種類だけです。

静的解析ツールはassertによって型の絞り込み機能を提供しているため、assertでユニオン型や上位型のうち、実際に使う型やサブタイプを具体的に指定することで、すべての型に対する分岐処理ではなく、実際に使用する型への処理だけを書くことができるようになります。

$someData = SomeModel::someQuery()->first();
assert(is_null($someData) || $someData instanceof SomeModel);

SomeModelはModelインスタンスを継承して定義されたモデルであり、Modelのサブタイプです。そのため、型の絞り込みを行うことで、変数に代入された値がSomeModel型またはnullであることを静的解析ツールやIDEに伝えることができます。そうすると、assert以降の行では、$someData変数を使用する際にnullとSomeModelの2つの型に対する処理だけを行えばよくなります。

Eloquentモデル内でメソッドをオーバーライドする方法もあります。同様に、親クラスのメソッドの戻り値がModel|object|BuildsQueries|null型なので、型アサーションを行う必要があります。

public function first(array|string $columns = ['*']): ?SomeModel
{
    $first = parent::first($columns);
    assert(is_null($first) || $first instanceof SomeModel);
    return $first;
}

この方法はfirstが定義されているクラスで定義する必要があるため、firstが定義されているBuilderClassを継承するクラスに記述する必要があります。

Type Assertionの問題

TypeScriptは型による安全性を最大限に確保するため、渡された変数を使用する際に、その変数が持つすべての型に対する処理を要求します。asを使って型アサーションを行うと、すべての型のうち一部の型だけが渡されることを前提にコードが展開されるため、実行時に絞り込んだ型とは異なる型が渡された場合、接続されたコードの型推論が誤り、実行時に不正な動作をする可能性があります。

次のReactコードでは、event.targetは複数のタグ要素のうちの一つをJavaScriptのインスタンスとして表しています。渡されるタグは複数のタグのうちどれかであり、それぞれのタグが異なる型を持つため、どの型かを明示しなければ次のコードを記述できません。そのため、asを使って型アサーションを行い、型を指定しています。

const handleClick = (event: React.MouseEvent) => {
  const target = event.target as HTMLButtonElement;
  console.log(target.value);
};

TypeScriptでは、上記のコードの代わりにジェネリクスを使って、次のように.targetの型を具体的に指定する方法が推奨されています。React.MouseEvent<HTMLButtonElement>のように、渡されるイベントの内部で使用する型を正確に指定することで、event.targetの型を正しく導き出せるようにしています。

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
  const target = event.target;
  console.log(target.value);
};

ただし、onClickイベントハンドラはランタイムで呼び出されてイベントが渡されるため、TypeScriptで指定した型と実際にイベントとして生成されてイベント関数に渡される引数の型が一致しない場合があります。そのため、開発時には必ずランタイムで異常な動作がないか確認する必要があります。JavaScriptが動作する際にダックタイピングのミスマッチが発生しなければ誤りに気付かないこともあるため、次のように間違っている部分をより明確にチェックする方が良いでしょう。

const handleClick = (event: React.MouseEvent) => {
  const target = event.target;
  if (target instanceof HTMLButtonElement) {
    console.log(target.innerText);
  } else {
    console.warn('type mismatching');
  }
};

ランタイムで動作確認を行う重要性

ビジネスロジックの表現

Javaのライブラリのように新しいクラスを作成し、クラス間の演算メソッドを定義することで新しい型を作って抽象化する方法は、場合によっては有用です。しかしこの方法は、状況によっては難しかったり不可能な場合もあり、ビジネスロジックの制約を簡単に表現できるにもかかわらず、型の安全性を過度に追求すると、かえって理解しにくいコードになってしまうこともあります。

外部から渡される値のマッピング

さまざまな外部から入力された値をプログラミング言語にマッピングする過程、たとえばリクエストデータやデータベースから取得した値など外部データをプログラミング言語にマッピングする際には、たいてい決められた範囲内の値が渡されますが、時には想定外の値が渡される可能性もあるため、必ずランタイムでの確認が必要な部分です。

論理の問題

型では検出できないビジネスロジックによるロジック問題を解決するためには、ランタイムでの動作確認が必要です。これは開発者だけでなく、サービスを運用する人たちの検証も受ける必要がある部分なので、必ずランタイム動作を通じてビジネスロジックに問題がないか、開発者と運用者の認識に違いがないかを確認することが非常に重要です。

同一型内での値の変化

一般的なWeb開発では、ビジネスロジックが重要です。ユーザーの入力値に対する適切なバリデーションや、入力値と保存された値を比較して適切な論理的制約を表現することなど、同じ型の中で値の違いが重要となるロジックが多くあります。また、同じ型であっても値の変化に依存するさまざまな計算ロジックの場合、ランタイムでの値の変化の確認が重要です。

PHPで型アサーション(Type Assertion)が有効な理由

assertによる型チェック機能は型アサーションを使用するため、型システムによる安全性を確保するという観点ではジェネリクスには及ばず、不完全です。しかし、ほとんどの言語ではコンパイル時にジェネリック型が消去されてランタイムで型を確認できないのに対して(C#はジェネリック型の消去が行われない特別な言語なのでここでは除外)、phpではランタイムでほとんどすべての型を確認することができます。また、ビルドという時間のかかる過程を経ずにすぐに実行して渡された型を確認し、型が適切かどうかをチェックできるため、ジェネリクスがなくても型安全なコードを書くことができます。

型をうまく活用できないことでバグが発生するコードを書く場合、コンパイル言語を使ってもバリューオブジェクトやenumを使わず、各種文字列の特定フォーマットの値や数値などに依存するコードを書くことになり、コンパイル言語を使っても同様の問題が生じる可能性が高いです。phpは可能な限り静的解析によって構文解析が可能な方向でコーディングするスキルが必要であり、コンパイル言語はコンパイラによって誤ったコードの確認が可能な方向でコーディングするスキルが必要です。

なぜPHPにはジェネリクスがないのか?

PHPパーサーのモダン化に貢献したニキータの意見を見ると、PHPにジェネリクスを導入することが容易ではないことがわかります。ジェネリクスを導入したPHPパーサーでは、型推論の複雑性の問題、パーサーの大規模なリファクタリングの問題、ランタイムパフォーマンスの低下など、さまざまな問題があり、簡単には導入できていません。

もしジェネリクスを導入することになったとしても、それはもはやPHPとは呼べないほどの変更になる可能性がある、という意見もあります。PHPはレガシーコードに対する最大限の互換性を維持しながらアップデートされる戦略をとっているため、ジェネリクスを導入して既存のコードを大幅に修正しなければならないのであれば、PHPの互換性というメリットが失われてしまいます。

また、PHPはオープンソースであり、PHPコアは自発的に貢献する開発者によって維持されています。言語仕様を変更できるフルタイム開発者を多額の費用をかけて雇うのは難しいため、大規模なリファクタリングは現実的に困難です。最近ではPHPファウンデーションの設立により、PHPコアに貢献するフルタイム開発者を雇うことで、この問題はある程度解決される見込みです。

PHPのジェネリクス研究については、PHPファウンデーションの記事も参考にしてください。

ランタイム型チェックにこだわる理由

TypeScript、Python、Javaなど多くの言語では、ジェネリクスをサポートするためにコンパイル後にジェネリクスの型が消去され、ランタイムで型を確認できない場合があります。しかし、PHPはできるだけランタイムで型確認が可能な言語であり続けたいと考えているため、PythonやTypeScriptのようにランタイムで型が消える方向への変化を拒否しています。もしランタイム型消去機能が必要であれば、PHP言語のネイティブ機能ではなくdocblockを使った静的解析による型チェックが十分にサポートされているので、phpdocの利用が推奨されています。

TypeScriptのようにコンパイル時に型情報が削除される言語では、型ミスマッチのエラーが発生した際に、ジェネリクス、ユニオン型、Higher-Kinded Typeなどの複雑な型体系を使用している場合、どの部分の型が誤っているのかを特定するのが難しいことがあります。もし実行時に型を確認できれば、デバッグを通じて、どのタイミングで誤った型が渡されたのかを値の変化から追跡しやすくなります。

しかし、実行時に型を確認できない場合、開発者はIDEが提案する型定義を一つ一つ追いながら、どの部分で型の齟齬が生じたのかを型体系に従って推論する必要があり、型の問題を解決するのが困難になるという課題もあります。

一方、PHPでは値をデバッグすることで型の変化を追跡することができるため、適切な型が渡されているかどうかの追跡やデバッグが容易であるという利点があります。

ランタイム型チェック機能が依然として必要な理由

PHPは型ヒントを通じてランタイム型検査を行います。型ヒントは静的解析ツールやIDEによる型チェックができるという利点もありますが、ランタイムで型検査を行うため無視できる程度とはいえ若干のパフォーマンス低下を引き起こします。このため、一部の人たちはPHPでジェネリクスを導入するなら将来的には型ヒントのランタイム型検査機能を削除し、TypeScriptのように実際のコードが実行される前に型チェックを行う言語に変えようと主張することもあります。しかし、ランタイム型チェックを行わないことが必ずしも良いとは限りません。

ランタイムで型を確認しない場合、どんな値でも渡せるという問題があります。したがって、ランタイムで型とミスマッチする値が誤って渡されないように、外部の値がプログラミング言語にマッピングされる際に、誤った型で渡されないように正確な型でマッピングされているかどうかを確認する作業が必要です。TypeScriptの場合、Zod、Ajv、Yup、Joi、Valibotなどのライブラリを使って誤った型のマッピングを防いでいます。JavaやC#の場合、ORMの定義と実際のデータベーススキーマが一致しないインピーダンスミスマッチ現象が発生しないよう注意が必要です。

もう一つ、ランタイム型チェックがない場合の問題は、ランタイムで誤った値が渡されないようにするため、過度に厳格な型定義が必要になることです。PHPでは静的推論ツールを使わずに多くのプロジェクトが作られてきましたが、開発初期から厳格な型の静的推論ができるように設計されたプロジェクトであれば、リクエストでデータが渡る部分やデータベースの値をプログラミング言語の値にマッピングする部分だけランタイムで確認すれば、その後はランタイム型検査を省いても間違った型が入るのを防ぐことができます。しかし、静的推論なしで長年書かれてきたPHPプロジェクトの場合、静的推論のためにすべてのデータフローに型を付与するのは現実的に困難な問題に直面することがあります。

さらに、外部ライブラリや外部から渡された値を使う場合、指定された型以外のどんな型の値が渡されてくるかわからないこともあります。型安全なコードを書くには、すべての例外ケースについてどんな型の処理をするか仕様を作る必要がありますが、これが不可能な場合もあるため、ランタイム型検査によって予期しない型の値が渡された場合にエラーを発生させ、予期しないケースへの対応を段階的に追加していく方法で対処します。

ランタイム型検査の利点は、すべてのデータフローの型が明確に定義されておらずバリデーションも完璧でない状態で書かれたコードでも、ランタイムで型ミスマッチが発生した部分を改善することでコードの型安全性を確保できる点です。

ジェネリクス導入のためにランタイム型検査を撤廃しようという意見も出ていますが、型推論が難しいPHPの膨大なコードベースでランタイム前に型検査ができるシステムに移行するのは現実的にはほぼ不可能であり、PHPはランタイム型チェック機能を維持しつつ発展し続けています。

assertとdocblockジェネリクスの併用

静的解析ツールやIDEを利用することで、docblockのジェネリクスを活用することができます。しかし、docblockには実行時の型を保証できないという欠点があるため、assertと併用することで効率的なコードを構成することが可能です。

/**
 * @var array<int, string> $list
 */
$list = json_decode(json_encode(range('a', 'i')) ?: '[]');

assert(is_array($list));
assert(array_reduce($list, fn($acc, $v) => $acc && is_string($v), true));

foreach($list as $el) {
    // assert(is_string($el));
    echo $el;
}

array_walk($list, function (mixed $el) {
    // assert(is_string($el));
    echo $el;
});

assert(array_reduce($list, fn($acc, $v) => $acc && is_int($v), true))というコードは、配列のすべての要素が整数であることを確認する前提条件を設定するものです。このコードでは、IDEによって配列の要素の型が推論されないことがあるため、配列をループするたびに assert(is_string($el))のように、毎回型チェックを書く必要があるとされます。

しかし、@var array<int, string> $listというdocblockを使用すれば、IDEは配列の要素の型を認識することができ、加えてassertによって実行時に型チェックも行うようにすれば、静的解析と実行時チェックの両方を満たすコードを書くことができます。その結果、配列をループするたびに個々の要素に対してassertを使って型を確認する必要がなくなります。

assert

assertは環境によってコードが実行される場合と、実行されない場合があります。開発環境や検証環境ではassertを使って型チェックを行い、本番環境ではassertのコードが実行されないようにすることで、実行時の型チェックに必要なリソースを削減することができます。

コンパイラが厳密な型チェックを行う言語では、assertは値の妥当性のみを検証するために使われますが、PHPでは値の妥当性だけでなく型のチェックや型アサーションにも利用することができます。

最後に

PHPには言語に組み込まれたジェネリクスがありません。しかし、docblockジェネリクスを利用したり、ユニオン型を使ってassertで型をIDEや静的解析ツールに伝える方法と、そしてコンパイルなしで素早くランタイム動作を確認することで、ジェネリクスがないという制約を克服しながらロジックを記述することができます。

厳格な型システムではないため、ランタイムで渡される型を確認しなければならないというデメリットもありますが、厳密な型定義を省略してランタイムチェックによって渡された型を確認することで、簡単にロジックを記述できるというメリットがあります。

PHPだけでビジネスロジックを構成し、サービスを提供することに問題はありません。しかし、最近は多くの言語が優れた型システムを導入しているため、良い型システムを学べる他の言語もあわせて活用できる開発環境を経験してみるのも望ましいでしょう。

9
0
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
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?