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

function_exists でガードして return したファイルの関数が、なぜか定義されている

0
Posted at

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ファイルで再現します。

guarded.php
<?php
if ( ! function_exists( 'this_function_does_not_exist' ) ) {
    echo "guard fired\n";
    return;
}

function declared_below_the_return() {
    return 'I should not exist';
}
main.php
<?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.phpwp_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まわりのことを書いています。

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