ファイルの冒頭で条件を確かめて return しても、その下にあるトップレベルの function と class は、多くの場合もう定義されています。PHP の早期バインディング(early binding)という仕組みです。
先に断っておくと、これは既知の話です。PHP マニュアルのユーザー定義関数の項には、条件付きで定義された場合を除いて関数は参照より前に定義しておく必要がない、と書かれています。仕組みの詳細は、nikic さんの Early binding in PHP(2021年)がいちばん詳しいと思います。
この記事で自分が足せるのは2つです。自作プラグインで実際に踏んだときの経路と、PHP 7.4 / 8.0 / 8.3 / 8.4 × opcache の有無で、どの宣言が束縛されるかを測った表です。測ってみると、extends を含むクラスだけは、バージョンと opcache の組み合わせで結果が変わりました。
前半:踏んだこと
ガードは発動していた
WordPress 用のキャッシュプラグイン Prime Cache Pro で、APCu を使うオブジェクトキャッシュのドロップインを書いています。APCu が無い環境で読み込まれたときのために、冒頭でガードを入れました。
// 実物は apcu_enabled() も見ています(ここでは省略)
foreach ( array( 'apcu_add', 'apcu_store', 'apcu_fetch', 'apcu_delete', 'apcu_inc', 'apcu_dec' ) as $fn ) {
if ( ! function_exists( $fn ) ) {
define( 'PRIME_CACHE_OBJECT_BACKEND_MISSING', true );
return;
}
}
function wp_cache_init() { /* ... */ }
function wp_cache_get( $key, $group = '' ) { /* ... */ }
// ...
class WP_Object_Cache { /* ... */ }
2026年8月10日、Xserver の SSH から WP-CLI を叩いたところで落ちました。CLI では APCu が既定で無効(apc.enable_cli=0)なので、ガードに引っかかる環境です。
PHP Fatal error: Uncaught Error: Call to undefined function apcu_fetch()
調べると、ガードは正しく発動して return まで到達していました。それでも wp_cache_init() は定義済みでした。
WordPress は function_exists で分岐する
WordPress の wp_start_object_cache() は、ドロップインを読み込んだあと function_exists( 'wp_cache_init' ) を見て、外部オブジェクトキャッシュを使うかどうかを決めています。定義済みなので「外部キャッシュあり」と判定され、標準の wp-includes/cache.php にフォールバックしません。そのまま wp_cache_init() が呼ばれ、最初の wp_cache_get() の奥で apcu_fetch() が呼ばれて落ちる。
ガードは効いていないのではなく、効いても手遅れでした。return は実行を止めますが、コンパイル時に済んでいる宣言は取り消せません。
最初の修正で「ガードを入れたから大丈夫」と思っていた自分に言いたい。確かめたのは return に来るかどうかだけでした。
直し方
宣言をまるごと条件ブロックで囲いました。条件付きの宣言は早期バインディングの対象外になり、実行時に、条件が真のときだけ束縛されます。
if ( $prime_cache_apcu_ok ) :
function wp_cache_init() { /* ... */ }
// ... 既存の関数とクラスをそのまま ...
class WP_Object_Cache { /* ... */ }
endif;
if ... : / endif; の形にしたのは、既存コードのインデントを1文字も変えずに済むからです。差分を見たときに、囲っただけだと一目で分かります。
後半:測ったこと
何を測ったか
return; の1行だけを先頭に置いたファイルを用意して、その下に宣言を1種類ずつ書き、require したあとに function_exists() / class_exists( $name, false ) で定義済みかを見ました。コードは記事の最後にまとめて置いてあります。
使った PHP は次の4つです。7.4 と 8.0 はサポートが切れているのでソースからビルドし、8.3 と 8.4 は Ubuntu 24.04 のパッケージです。opcache はそれぞれ opcache.enable_cli=0 と =1 で切り替えました。
- PHP 7.4.33(Prime Cache の動作要件の下限)
- PHP 8.0.30(Xserver で SSH から素で叩くと、この版になります)
- PHP 8.3.6
- PHP 8.4.21
結果
Y は return の後なのに定義済み、N は未定義です。
| return の下に書いたもの | 7.4 / 8.0 opcache なし | 7.4 / 8.0 opcache あり | 8.3 / 8.4 opcache なし・あり |
|---|---|---|---|
function f() {} |
Y | Y | Y |
class A {} |
Y | Y | Y |
同じファイル内の親を extends
|
Y | Y | Y |
読み込み済みの別ファイルのクラスを extends
|
Y | N | Y |
組み込みクラス(ArrayObject)を extends
|
Y | N | Y |
implements Countable |
N | N | N |
if (true) { function f() {} } |
N | N | N |
if ($ok) : ... endif;($ok は false) |
N | N | N |
表を作る前は、全部 Y か全部 N のどちらかだと思っていました。
目を引いたのは2つです。
1つめ。**implements を付けたクラスは、どの組み合わせでも早期バインディングされませんでした。**nikic さんの記事にも、インターフェースを実装するクラスやトレイトを使うクラスは対象外とあります。__toString() を持つクラスは暗黙に Stringable を実装するので、これも対象外になるそうです。
2つめ。**親クラスを持つクラスは、7.4 と 8.0 で opcache を有効にすると未定義に、8.3 と 8.4 では opcache の有無に関係なく定義済みになりました。**opcache 側に遅延早期バインディング(delayed early binding)という別の経路があることは nikic さんの記事で知りましたが、8.1 以降で結果がそろった理由までは追えていません。8.1 で opcache に継承キャッシュ(inheritance cache)が入った、と PHP.Watch にあるので、そのあたりが関係しているのかもしれません。
ここは地味に怖いところです。Xserver では、サイトは PHP 8.3 で動き、SSH で素の php を叩くと 8.0 になります。同じファイルでも、Web と CLI で「return の下のクラスが定義されているか」が変わりうるということです。自分の件は素の function だったので全部 Y で、この差には当たりませんでしたが。
ついでに:Cannot redeclare も止められない
もう1つ、同じ仕組みで起きる罠を測りました。WordPress でよく見る、先に定義されていたら何もしない、という書き方です。
if ( function_exists( 'pc_h' ) ) {
return;
}
function pc_h() {}
pc_h() を先に定義しておいてからこのファイルを require すると、4つの版すべて、opcache の有無にかかわらず Fatal error になりました。
PHP Fatal error: Cannot redeclare function pc_h() (previously declared in .../run.php:4) in .../h_redeclare.php on line 3
(8.3 までは Cannot redeclare pc_h()、8.4 で function の語が入りました。)
return に届く前に、ファイルのコンパイルの時点で二重定義が見つかって止まります。同じ意図なら、宣言そのものを if ( ! function_exists( 'pc_h' ) ) { function pc_h() {} } の形で囲う必要があります。WordPress の pluggable な関数が、どれもこの形で書かれている理由がここにあります。
これは知っていたつもりでした。つもりだったので、表の最後に足しました。
最後に
return で早めに抜ける書き方は、関数の中では読みやすいのでよく使います。ファイルの先頭で同じことをすると、宣言だけが先回りして残ります。
自分のドロップインは、条件ブロックで囲って直りました。ただ、表を見るかぎり、**クラスに extends を足しただけで、同じファイルが版によって違う動きをします。**あなたのプロジェクトで、return の下に宣言を置いているファイルは、どの版の PHP で読まれていますか。
再現コード
8つの宣言ファイルと、それを読み込んで判定する run.php です。
<?php
return;
function pc_a() {}
<?php
return;
class PC_B {}
<?php
return;
class PC_C_Parent {}
class PC_C extends PC_C_Parent {}
<?php
return;
class PC_D implements Countable { public function count(): int { return 0; } }
<?php
return;
class PC_E extends ArrayObject {}
<?php
return;
if (true) { function pc_f() {} }
<?php
$ok = false;
if ($ok) :
function pc_g() {}
class PC_G {}
endif;
<?php
if (function_exists('pc_h')) { return; }
function pc_h() {}
<?php
return;
class PC_I extends PC_Loaded_Parent {}
<?php
$case = $argv[1];
class PC_Loaded_Parent {}
if ($case === 'h') { function pc_h() {} }
register_shutdown_function(function () {
$e = error_get_last();
if ($e && $e['type'] & (E_ERROR | E_COMPILE_ERROR)) {
echo "FATAL: ", strtok($e['message'], "\n"), "\n";
}
});
$map = [
'a' => ['a_func.php', 'f', 'pc_a'],
'b' => ['b_class.php', 'c', 'PC_B'],
'c0' => ['c_extends_same_file.php', 'c', 'PC_C_Parent'],
'c' => ['c_extends_same_file.php', 'c', 'PC_C'],
'd' => ['d_implements.php', 'c', 'PC_D'],
'e' => ['e_extends_builtin.php', 'c', 'PC_E'],
'f' => ['f_if_block.php', 'f', 'pc_f'],
'g' => ['g_endif.php', 'f', 'pc_g'],
'g2' => ['g_endif.php', 'c', 'PC_G'],
'h' => ['h_redeclare.php', 'f', 'pc_h'],
'i' => ['i_extends_loaded.php', 'c', 'PC_I'],
];
[$file, $kind, $name] = $map[$case];
require __DIR__ . '/' . $file;
$r = $kind === 'f' ? function_exists($name) : class_exists($name, false);
echo $r ? "defined" : "not defined", "\n";
実行はこうです。opcache を試すときは -d opcache.enable_cli=1 を付けます(7.4 / 8.0 のソースビルドでは -d zend_extension=opcache.so も必要でした)。
for c in a b c0 c d e f g g2 h i; do
printf '%-3s ' "$c"
php -d opcache.enable_cli=0 run.php "$c" 2>/dev/null | tail -1
done
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.com/