return より下にある関数は、return したのだから定義されない。そう思っていました。
再現するコードは15行です。順にたどります。
手元で起きたこと
自作のWordPressプラグインが wp-content/object-cache.php を設置します。バックエンドはAPCu。APCuが無い環境では動かないので、ファイルの冒頭でガードを入れました。
if ( ! function_exists( 'apcu_fetch' ) ) {
return;
}
function wp_cache_get( /* ... */ ) { /* apcu_fetch を呼ぶ */ }
function wp_cache_init() { /* ... */ }
class WP_Object_Cache { /* ... */ }
エックスサーバーのSSHでWP-CLIを叩いたところ、こうなりました。
$ wp eval 'echo "ok";'
PHP Fatal error: Uncaught Error: Call to undefined function apcu_fetch()
in .../dropins/apcu-object-cache.php:201
#3 wp-includes/functions.php(1780): wp_cache_get('is_blog_installed')
#4 wp-includes/load.php(943): is_blog_installed()
#5 wp-settings.php(180): wp_not_installed()
CLI SAPIは apc.enable_cli の既定値が 0 なので、APCuの関数は呼べません。ガードが素通りしたのかと思いましたが、var_dump を仕込むと return にはきちんと到達していました。
到達したうえで、wp_cache_get() が呼ばれています。
最小再現
WordPressは関係ありません。2ファイルで再現します。
<?php
if ( ! function_exists( 'this_function_does_not_exist' ) ) {
echo "guard fired\n";
return;
}
function declared_below_the_return() {
return 'I should not exist';
}
<?php
require __DIR__ . '/guarded.php';
var_dump( function_exists( 'declared_below_the_return' ) );
echo declared_below_the_return(), "\n";
実行結果です。
$ php main.php
guard fired
bool(true)
I should not exist
return は実行されている。それでも関数は定義されている。
なぜそうなるか
PHPはファイルをまずコンパイルし、そのあとで実行します。このコンパイルの時点で、トップレベルにある無条件な function 宣言と class 宣言は束縛されます。早期バインディング(early binding)と呼ばれる挙動です。
return が止められるのは実行の続きだけで、コンパイル時に済んだ束縛には届きません。
条件ブロックの中に置かれた宣言は、この対象外です。実行がその行に到達したときに束縛されます。ポリフィルの定番である次の書き方が成立するのは、そのためです。
if ( ! function_exists( 'str_contains' ) ) {
function str_contains( $haystack, $needle ) { /* ... */ }
}
同じ仕組みの表と裏を、こちらは踏み抜いていました。
| 宣言の置き場所 | 束縛のタイミング |
return で止まるか |
|---|---|---|
| トップレベル・無条件 | コンパイル時 | 止まらない |
| 条件ブロックの中 | 実行時(到達したとき) | 止まる |
include されるファイル全体 |
そのファイルのコンパイル時 | — |
なぜ Fatal まで行ったか
定義が残るだけなら実害はなさそうに見えます。今回は、読み込む側がまさに「定義の有無」で分岐していました。
wp-includes/load.php の wp_start_object_cache() は、ドロップインを読んだあと function_exists( 'wp_cache_init' ) を確認します。定義されていれば「外部オブジェクトキャッシュが使える」と判断し、標準実装の wp-includes/cache.php を読み込みません。
ガード発動 → return
↓
wp_cache_init() は定義済み
↓
WordPress が「外部キャッシュあり」と判定
↓
wp-includes/cache.php を読まない(フォールバックしない)
↓
wp_cache_init() を呼ぶ
↓
最初の wp_cache_get() → apcu_fetch() → Fatal
ガードが効いていなかったのではなく、効いても手遅れでした。
直し方
return はそのままで、ガード以降の宣言をファイル末尾まで条件ブロックで囲います。
$ok = function_exists( 'apcu_fetch' ) /* ... 他の関数も ... */;
if ( ! $ok ) {
return;
}
if ( $ok ) :
function wp_cache_get( /* ... */ ) { /* ... */ }
function wp_cache_init() { /* ... */ }
class WP_Object_Cache { /* ... */ }
endif;
if ( ... ) : / endif; を使うと、既存コードのインデントを変えずに囲えます。差分が先頭と末尾の1行ずつになるので、レビューでも意図が読み取れます。中括弧でも動作は同じです。
効かなかった案も書いておきます。
-
returnをやめてif / elseにする →elseの外にある宣言は結局トップレベルなので束縛される -
class_exists()で判定して抜ける → 判定が実行時である以上、同じ - 読み込む側で判定してから
requireする → 有効。ただし読み込む側が自動生成物だと、更新が届くまで古いまま
修正の確認は、読み込む側の判定を直接見るのが確実でした。
$ wp eval 'var_dump( wp_using_ext_object_cache() );'
bool(false)
気づいた経緯
このバグ、ウェブ側では一度も出ていません。ブラウザからは正常に動いていて、SSHでコマンドを1本叩いた瞬間に露見しました。しかも探していたのは別のもの(記事のメタ情報をCSVに書き出そうとしていた)で、完全な副産物です。
起動時のFatalなら、これ1本で拾えます。
$ wp option get home
拡張機能の有無で分岐するコードを書いていて、そのファイルのトップレベルに関数やクラスを置いているなら、いま手元のそれは return で止まっているでしょうか。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。