22
8

More than 1 year has passed since last update.

PHPにおけるクラスの事前バインディングについて

Last updated at Posted at 2021-11-01

Ogirinal article:https://www.npopov.com/2021/10/20/Early-binding-in-PHP.html by Nikita Popov

$test = new Test;

class Test {}

これは正常に動作します。
Testクラスを定義するより先に使っているのに動きます。
ふしぎですね。

これは一見JavaScriptにおける巻き上げと同じように見えますが、実際はもっと複雑でややこしいことをやっています。
そこらへんについて、PHPの第一人者Nikita Popovが解説していました。
以下はEarly binding in PHPの日本語訳です。

Early binding in PHP

PHPでは、同一ファイル内で宣言前のクラスを使用することができる場合があります。
我々はこれをearly bindingと呼んでいますが、この正確な動作はかなり難解であり、具体的な文書化も見当たりません。
この記事を読めば、事前バインディングに関するバグレポートのへの回答が何故won't fixばかりになるのかが理解できるようになるでしょう。

Early binding != class hoisting

事前バインディングは巻き上げではない。

事前バインディングの最も単純な例はこのようなものです。

$test = new Test;
class Test {
    /* ... */
}

Testクラスを定義する前に使用しているにもかかわらず、このコードは動作します。
これはPHPがクラス宣言の巻き上げを行っているから、すなわち、暗黙のうちにコードを以下のように変換しているから、と考えることができるでしょう。

class Test {
    /* ... */
}
$test = new Test;

しかし、実際の処理はもっと複雑です。
次の例を考えてみましょう。

# a.php
class Test {}
require 'b.php';

# b.php
if (class_exists(Test::class)) {
    return;
}
class Test {}

b.phpで何も考えずにクラス宣言を巻き上げると、クラスの再宣言エラーになってしまいます。
このような早期に終了するコードをサポートするために、事前バインディングの動作は以下のようにする必要があります。

スクリプトの実行開始時にクラスが存在していなければ、その時に宣言する。 それ以外の場合は、出現した時点で宣言する。

しかし、これだけでは終わりません。
さらに次の例を考えてみましょう。

# a.php
require 'b.php';
class Test2 extends Test {}

# b.php
class Test {}

a.phpのクラス宣言を巻き上げると、必要な依存関係を呼び出すrequireより先になってしまいます。
そのため、事前バインディングをさらに調整する必要があります。

スクリプトの実行開始時にクラスが存在していなければ、依存関係が全て解決したときに宣言する。 それ以外の場合は、出現した時点で宣言する。

Limitations

依存関係が全て解決したという要件は非常に厄介であり、解決は困難です。
たとえば、以下のコードでAは解決が必要な依存関係でしょうか?

class Test2 extends Test {
    public function method(): A {}
}

答えはTestクラスの構造によって変わります。
Testが以下のようになっていた場合、クラスをロードすることなくサブタイプ判定を行えるため、Aは依存関係ではありません。
クラスAは常にAクラスのサブタイプです。

class Test {
    public function method(): A {}
}

しかし、もしTestクラスがこうなっていたら、ABの両方ともが依存関係になります。
なぜならば、ABがサブタイプの関係にあるかを判断するために、両者のクラス宣言が必要となるからです。

class Test {
    public function method(): B {}
}

これが単にextendsしただけのクラスであった場合は、比較的簡単にチェック可能です。
どのメソッドがオーバーライドされているかを探し、引数をチェックするだけです。
ここで依存関係が解決できなかった場合は、事前バインディングをあきらめ、遅延バインディングにフォールバックされます。

しかし、interfaceやtraitが絡んでくると状況は一気に悪化します。
traitのようなエイリアスが関わってくると、何が何を上書きするのか、継承を判定するためにどのサブタイプが必要なのか依存関係の解決が困難にまります。

このような場合、継承が成功するか否かを確実に判断する唯一の方法は、実際に継承を行ってみることです。
すなわち、クラスのコピーを作成し、継承を行い、失敗した場合は結果を破棄します。
PHP8.1以降、プリロードにおいてはこの方法が用いられています。
しかし、事前バインディングへの適用は慎重にならないといけません、
PHPの継承は、失敗したときにソフトランディングするように設計されておらず、例外ではなく致命的エラーになります。
今後継承エラーをキャッチできる仕組みにしていく可能性もありますが、現在はそうなっていません。

結果として、クラスがintercaceやtraitを使っている場合は、事前バインディングを行わずに遅延バインディングされます。

スクリプトの実行開始時にクラスが存在しておらず、interfaceやtraitを使っていなければ、依存関係が全て解決したときに宣言する。 それ以外の場合は、出現した時点で宣言する。

interfaceの使用には、暗黙的なinterfaceも含まれることに注意が必要です。
たとえばtoString()を実装したクラスは暗黙のインターフェイスStringableをimplementsしているため、事前バインディングされることはありません。
enumUnitEnumもしくはBackedEnumをimplementsしているため、事前バインディングされることはありません。

Delayed early binding

opcacheがからむと、事態はさらに複雑化の一途を辿ります。
通常、事前バインディングはコンパイル時に試みられます。
成功すればクラスがそのまま登録され、失敗すればDECLARE_CLASSのオペコードが発行されます。

opcacheでは、事前バインディングに対応することができません。
なぜならば、opcacheでキャッシュするスクリプトはファイルごとに独立していなければならず、他のファイルからの依存関係を解決することができないからです。

opcacheでは、"遅延事前バインディング Delayed early binding"という仕組みでこの問題を解決しています。
事前バインディングの対象となる可能性のあるクラスに対してはオペコードDECLARE_CLASS_DELAYEDを発行します。
スクリプトがロードされた際にオペコードDECLARE_CLASS_DELAYEDが見つかれば、opcacheは事前バインディングを試みます。
これが成功すれば、該当のクラスは既にキャッシュで宣言されていると見做し、その後通常のフローでクラス宣言のオペコードが見つかっても実際には宣言しないようにします。

もちろん、この方法は単純化しすぎています。
次のコードを考えてみましょう。

if (true) {
    return;
}
class Test2 extends Test {}

このコードは必ず冒頭でreturnされるため、オプティマイザはその後のコードをすべて削除します。
DECLARE_CLASS_DELAYEDも削除されます。
しかし、事前バインディングのセマンティクスでは、スクリプトが実行された時点でTestが利用可能であれば、Test2も定義されるべきです。

この問題はPHP8.2で修正され、遅延事前バインディングを必要とするクラスは、オペコードDECLARE_CLASS_DELAYEDとは別の構造で追跡するようにされます。

しかし、かような努力にもかかわらず、opcacheを使った場合と使わなかった場合での事前バインディングの動作は未だに同じではありません。

new Test2;
class Test2 extends Test {}
class Test {}

opcacheを使わなかった場合、Test2クラスは宣言時にTestクラスが存在しないため事前バインディングされず、エラーになります。

opcacheを使うと、Testクラスは事前バインディングされ、Test2クラスは早期事前バインディング対象になります。
実行時、Test2クラスが読み込まれたときにはTestクラスは既に存在するので、コードはエラーにならず実行されます。

簡単にまとめると、opcacheを使わないときは事前バインディングは一回だけ走り、opcacheを使うと2回走ります。

Conclusion

クラス宣言が機能するためには、主に2種類の賢明な方法があります。

・クラスが宣言されたその位置で定義する。
非常にシンプルでわかりやすいですが、1ファイルに1クラスのスタイルに従わないようなぐだったコードには不便かもしれません。

・全てのクラス宣言をスクリプトの先頭に巻き上げる。
こちらは、依存関係を考慮してクラス宣言の順序を変更するなどの対応が容易です。

PHPはもちろん3つめの道を選択しました。
どちらもいいかんじに動くようにしてもいいじゃん。
我々は、時々うまくいくような、複雑なシステムを手に入れました。

この記事が気に入ったら、他の記事も読んだり、Twitterでフォローしたりしてくださいね。

感想

// OK
class Test {}
class Test2 extends Test {}
new Test2;

// OK
class Test2 extends Test {}
class Test {}
new Test2;

// OK
new Test2;
class Test {}
class Test2 extends Test {}

// Fatal error: Class "Test2" not found ただしopcacheならOK
new Test2;
class Test2 extends Test {}
class Test {}

確かにこれはややこしい。

とはいえ、普通に考えたら正しく動くべきなのは一番目だけですよね。

昔のPHPはルールもあまりはっきりしていませんでしたから、適当な順番で書かれたコードでもなるべく動くようにしよう、という考えでこのような仕様になったのかもしれません。
そして一度そうなった仕様を、後から変更することは容易ではありません。

ただ最近ではコーディング規約に従うことも一般的になり、読み込みもComposerのautoloadに任せたりすることが普通でしょうから、今となってはこれほど苦労してまで実装を維持し続けるような仕様でもなさそうな気がしますね。
今後このあたりが整理されたりすることはあるでしょうか。

22
8
1

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
22
8