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?

PHP 8.5 は「戻り値の無視」をどう見抜くのか 〜 `#[\NoDiscard]` と `(void)` キャストの裏側を php-src で追ってみた

0
Posted at

本記事は、関西PHP勉強会で LT した内容を記事向けに再構成したものです。LT では時間の都合で飛ばした「なぜそうなるのか」の部分を、php-src(PHP 本体の C ソースコード)を引用しながら補っています。

当日のスライドはこちらです。

TL;DR

  • PHP 8.5 で #[\NoDiscard] 属性と (void) キャストが追加され、戻り値を受け取り忘れると Warning を出してくれるようになった
  • じゃあ PHP のエンジンは、どうやって「戻り値が捨てられた」ことを知るのか?
    • 「捨てた」という情報はどこにも記録されていない
  • エンジンが見ているのは、関数呼び出し命令に「結果を扱っているかどうか」
  • (void) キャストの正体は「結果を一度受け取って、即捨てる」をして、VM に「使った」と思わせていた

僕自身、php-src をちゃんと読んだのは今回が初めてです。
なので C 読めない人も置いていかないように書いてます(たぶん)。

はじめに:「あるある」な日付の扱い

突然ですが、このコードを見てください。

$deadline = new DateTimeImmutable('2026-08-20');

$deadline->modify('+7 day');   // 締切を1週間延ばすぞ・・・

echo $deadline->format('Y-m-d');

このコードを実行すると、実際の出力は 2026-08-20 になります。
締切は1週間延びていません。

DateTimeImmutable は名前のとおり Immutable(不変)なので、modify() は自分自身を書き換えず、書き換えた新しいオブジェクトを返します。つまり2行目は、せっかく作った新しいオブジェクトを誰にも渡さずにそのまま捨てている、という状態でした。

つまり、正しくは処理するならこう書くべき・・・

$deadline = $deadline->modify('+7 day');

これ、PHP を書いている人なら一度はやったことあるんじゃないでしょうか。日付の処理に限らず、元のインスタンスの状態を変えるのか、変えた結果をもらうのか、PHPの処理にはこのどちらの種類の関数がある関係でごっちゃになります。

僕も何度か間違えたことあります。

これの厄介なのは、PHP 8.4 までは別にエラーも警告も何も出なかったということです。
文法的には何も間違っていないので、テストで気づくか、本番で「締切が延びてへんぞ」と言われて気づくかのどちらかという状態でした。

しかし、去年になって状況が変化しました。

PHP 8.5 にアップグレードすると・・・

同じコードを PHP 8.5 で動かすと、こんなエラーが出ます。

Warning: The return value of method DateTimeImmutable::modify() should
either be used or intentionally ignored by casting it as (void),
as DateTimeImmutable::modify() does not modify the object itself

訳すと「DateTimeImmutable::modify() の戻り値は使うか、(void) でキャストしてわざと無視していると宣言してください。modify() はオブジェクト自身を書き換えません」

これめちゃくちゃ親切なエラーですね。しかも「なぜダメなのか」まで教えてくれています。

これが PHP 8.5 で新しく追加された #[\NoDiscard] 属性の仕事です。php-src の中(ext/date/php_date.stub.php)で、modify() にはこんな属性が付いています。

#[\NoDiscard(message: "as DateTimeImmutable::modify() does not modify the object itself")]
public function modify(string $modifier): DateTimeImmutable {}

属性の引数に渡した文字列が、警告メッセージの末尾にくっつく仕組みですね(区切りの , は自動で入ります)。

もう少し深堀りしていきます

まず使い方

自作の関数やメソッドにも、もちろん付けられます。

#[\NoDiscard("Be sure to use the returned value.")]
function returnFalse(): bool { return false; }

returnFalse();          // ⚠️ Warning が出る
$ok = returnFalse();    // ✅ 使っているので出ない
(void) returnFalse();   // ✅ 「わざと捨てた」の意思表示。出ない

まとめるとこうです。

機能 何をするか
#[\NoDiscard] 関数・メソッドに付ける属性。戻り値が捨てられたら Warning を出す
(void) キャスト PHP 8.5 で追加された新しいキャスト。「わざと捨てた」と宣言して警告を黙殺する公式な方法

ここまではマニュアルにも書いてある話です。「ふーん、便利やん」で終わってもいいんですが、ここで僕は一つ気になる疑問が生まれました。

本題:PHP のエンジンは、どうやって「捨てられた」ことを知るのか?

returnFalse(); と $ok = returnFalse(); の違いを、PHP はどうやって見分けているんでしょうか。

実行時に「戻り値を誰も受け取らなかった」ことを検知しているわけですよね。考えられる実装をいくつか挙げてみます。

  • A. 構文木(AST)に「この呼び出しの結果は捨てられた」というフラグが記録されている
  • B. 実行時に戻り値の参照カウントを監視して、誰にも参照されなかったら警告する
  • C. もっとシンプルな何か

僕は最初、B やろなと思っていました。
実際に答え合わせをするために、 php-src を読むことにしました。

その前にPHPの仕組みをおさらいしておきましょう。

まずそもそもの話:PHP のコードが実行されるまで

内部の話に入る前に、PHP のソースコードが実行されるまでの流れをざっくり押さえておきます。

PHP はこの流れで動いているらしいです。

今回の主役は ③ と ④ です。その前に、ここまでに出てきた用語をまとめて補足しておきます。

  • AST(構文木):ソースコードを木構造に組み替えたものです(選択肢 A のやつ)
  • 参照カウント:ある値を何箇所が参照しているかの数で、PHP がメモリ管理に使っています(選択肢 B のやつ)
  • OPCode:PHP が実行前にソースコードを変換した「中間命令」です。CPU が読む機械語の PHP 専用版だと思ってください。ASSIGN(代入)、ECHO(出力)、DO_FCALL(関数呼び出し)のように、1命令が1つの単純な仕事を表します
  • Zend VM:その OPCode を1つずつ読んで実行する、PHP の心臓部です。C で書かれていて、「この OPCode が来たらこの C の関数を呼ぶ」という対応表(ハンドラ)を持っています
  • opcache:③ の変換結果をメモリにキャッシュして、リクエストのたびに変換し直さなくて済むようにする機能です。ついでに OPCode の最適化もしてくれます。

で、この OPCode、実は簡単に見ることができます。opcache のデバッグ用オプションを使います。

php -d zend_extension=opcache \
    -d opcache.enable_cli=1 \
    -d opcache.optimization_level=0 \
    -d opcache.opt_debug_level=0x10000 \
    demo.php
  • opcache.opt_debug_level=0x10000 が「最適化前の OPCode をダンプしろ」という指定です(0x20000 にすると最適化後)
  • opcache.optimization_level=0 は、最適化前の素の状態を見たいので最適化を切っています
  • opcache がすでに組み込み済み・読み込み済みの環境(Homebrew の PHP など)では、-d zend_extension=opcache は外してください(読み込み失敗や二重読み込みの Warning が出ます)

OPCode ダンプの読み方

まずは一番シンプルなこの1行をダンプしてみます。

$ok = validate();

すると、こんな3命令になります(番号はこの後の実験から抜粋したものなので、途中の 0005 から始まっています)。

0005 INIT_FCALL 0 80 string("validate")   ; 呼び出しの準備
0006 V3 = DO_FCALL_BY_NAME                ; 実行して、結果を V3 に入れる
0007 ASSIGN CV0($ok) V3                   ; V3 を $ok に代入

1行で3命令も使うんかい、と思いますよね(笑)
; 以降は僕が付けたコメントで、実際の出力にはありません。

記号 意味
0005 命令の通し番号
INIT_FCALL 「これから validate を呼ぶぞ」という準備命令。引数があればこの後に SEND_VAL などが並ぶ
0 80 「引数の数」と「この呼び出しが使うスタック領域のサイズ」。今回は気にしなくて大丈夫です
DO_FCALL_BY_NAME 実際に関数を呼ぶ命令
V3 テンポラリ変数。命令の結果を一時的に置いておく場所で、PHP コードからは見えない。頭の V は内部の種別 IS_VAR を表す(関数呼び出しの結果はこの V になります)
CV0($ok) コンパイル済み変数(Compiled Variable)。PHP コードの $ok そのもの
V3 = の部分 結果オペランド。「この命令の結果をどこに置くか」の指定

この「結果オペランド」という言葉を覚えておいてください。

実験:3通りの「呼び方」を並べてみる

では本題。#[\NoDiscard] 付きの関数を、3通りの書き方で呼んでみます。

<?php

#[\NoDiscard("check the result")]
function validate(): bool { return false; }

validate();          // ① 捨てる     → Warning が出る
(void) validate();   // ② わざと捨てる → 出ない
$ok = validate();    // ③ 使う       → 出ない

普通に実行すると、①の行だけ Warning が出ます。

Warning: The return value of function validate() should either be used or
intentionally ignored by casting it as (void), check the result in demo.php on line 6

さて、これを OPCode にダンプするとどうなるか。

0000 INIT_FCALL 0 80 string("validate")
0001 DO_FCALL_BY_NAME              ; ① 結果オペランドが無い!
0002 INIT_FCALL 0 80 string("validate")
0003 V2 = DO_FCALL_BY_NAME         ; ② 結果を V2 に受け取って・・・
0004 FREE V2                       ;    直後に捨てている!
0005 INIT_FCALL 0 80 string("validate")
0006 V3 = DO_FCALL_BY_NAME         ; ③ 結果を V3 に受け取って・・・
0007 ASSIGN CV0($ok) V3            ;    $ok に代入

よく見比べてください。

  • ① だけ、DO_FCALL_BY_NAME の左側に Vほげ = がありません。結果に対して何もしていない。
  • ② はなんと、V2 に結果を一度受け取ってから、直後の FREE V2 で捨てています**
  • ③ は V3 に受け取って $ok に代入。これはまあ想像どおり

②を見たとき、僕は思わず「詐欺やんけ」と思いました。「わざと捨てる」ための (void) が、内部では「一回受け取ってから捨てる」という、使ったような使ってないような命令になっているんです。でも逆に言うとコレがミソというか、仕組みの正体でした。

「捨てた」という情報は、どこにも無い

以上のことから答えが見えてきましたね。

  • エンジンの中に「戻り値が捨てられた」ことを表す専用のデータはない(!)
  • あるのは、関数呼び出し命令の結果オペランドが空(UNUSED)かどうかだけ
  • VM は実行時に「この呼び出し命令、結果の置き場が無いな」と見て警告を出す

そして (void) キャストの正体は、

結果を一度受け取って、即 FREE する命令列をわざと出させることで、
VM に「ちゃんと使ったな」と思わせる

という、ある意味「使った」という事実だけを残す仕掛けでした。

最初のABCの選択肢で言うと、答えは C. もっとシンプルな何か でした。構文木にフラグを立てるでも、参照カウントを監視するでもなく、命令の結果欄が空欄かどうかを見ているだけでした。マジでシンプル。

では実際に、php-src の C ソースコードで確認していきます。
(そこまで興味ない人は、以降は読み飛ばしてもらってもOKです!)

php-src で確認①:「捨てる処理」はどう実装されているか

foo(); のように結果を使わなかったとき、コンパイラは zend_do_free() で後始末をします。名前的に FREE 命令を出しそうですが、関数呼び出しの場合は出しません。代わりに CALL 命令の結果欄そのものを消します。

Zend/zend_compile.c
// zend_do_free()
zend_op *opline = &CG(active_op_array)->opcodes[CG(active_op_array)->last-1];
/* ...直前の命令までさかのぼって... */
if (opline->result_type == IS_VAR
    && opline->result.var == op1->u.op.var) {
    ...
    SET_UNUSED(opline->result);   /* 結果オペランドを UNUSED に */
}

直前の CALL 命令を振り返って、結果オペランドを UNUSED に書き換えているだけです。だからダンプで①だけ Vほげ = が無かったんですね。

php-src で確認②:(void) はどうなっているか

(void) には専用のコンパイル関数があります。こちらは UNUSED 化をあえてせず、FREE 命令を出します。

Zend/zend_compile.c
// zend_compile_void_cast()
zend_compile_expr(&expr_node, expr_ast);   /* まず中身の式を普通にコンパイル */

switch (expr_node.op_type) {
    case IS_TMP_VAR:
    case IS_VAR:
        opline = zend_emit_op(NULL, ZEND_FREE, &expr_node, NULL);
        opline->extended_value = ZEND_FREE_VOID_CAST;
        break;
    ...
}
  • 中身の呼び出しを普通にコンパイルするので、結果オペランド V2 が残りますね。VM から見れば「使った」なので警告は出ない
  • その直後に V2 を捨てる FREE 命令を1つ足す
  • FREE には ZEND_FREE_VOID_CAST という「(void) 由来だよ」の印を付けておく(最適化のときに「消すな」の目印として使われます)

ちなみに (void) は「式」ではなく「文」としてPHPでは文法が定義されているので、以下のように書くと構文エラーです。「捨てる」と言いながら変数に入れるのは矛盾してますからね。

$x = (void) f();
// Parse error: syntax error, unexpected token "(void)"

php-src で確認③:警告を出す VM 側

次は実行側、Zend VM です。ダンプに出てきた DO_FCALL_BY_NAME を処理するハンドラの冒頭に、こんなコードがあります。

const uint32_t no_discard =
    RETURN_VALUE_USED(opline) ? 0 : ZEND_ACC_NODISCARD;

if (UNEXPECTED(fbc->common.fn_flags
        & (ZEND_ACC_DEPRECATED | no_discard))) {
    if (fbc->common.fn_flags & ZEND_ACC_DEPRECATED) {
        zend_deprecated_function(fbc);
    }
    if ((fbc->common.fn_flags & no_discard) && EG(exception) == NULL) {
        zend_nodiscard_function(fbc);   /* ここで Warning を発報 */
    }
    ...
}
#define RETURN_VALUE_USED(opline) ((opline)->result_type != IS_UNUSED)
  • fbc->common.fn_flags は、呼ばれる関数の「属性スイッチ」を1ビットずつ詰めた整数。#[\NoDiscard] の実体はこの中の1ビット(ZEND_ACC_NODISCARD)
  • RETURN_VALUE_USED() は「結果オペランドが UNUSED でないか」を見るだけのマクロ
  • 結果が使われていれば no_discard を 0 にして、NoDiscard ビットを AND の対象から外す

つまり「NoDiscard な関数で、かつ結果欄が空」のときだけ zend_nodiscard_function() に入って Warning が出ます。

警告メッセージはどう作られているか

int code = fbc->type == ZEND_INTERNAL_FUNCTION ? E_WARNING : E_USER_WARNING;

if (fbc->common.scope) {
    zend_error_unchecked(code, "The return value of method %s::%s() should either be used or intentionally ignored by casting it as (void)%S",
        ZSTR_VAL(fbc->common.scope->name),
        ZSTR_VAL(fbc->common.function_name),
        message_suffix
    );
} else {
    zend_error_unchecked(code, "The return value of function %s() should either be used or intentionally ignored by casting it as (void)%S", ...);
}

最初に見たあの Warning 文は、ここで組み立てられています。末尾の message_suffix が #[\NoDiscard("...")] に渡した文字列で、属性オブジェクトを実際に生成して取り出しています。組み込み関数なら E_WARNING、自作関数なら E_USER_WARNING という使い分けも地味にしています。

まとめ

  • 「戻り値を使ったか」の判定は、OPCode の結果オペランドが UNUSED かどうかだけで行われていました。「捨てた」という情報は別にありませんでした。
  • foo(); → コンパイラが CALL 命令の結果欄を消す(zend_do_free)
  • (void) foo(); → 結果を受け取って即 FREE する命令を出し、VM に「使った」と思わせる(zend_compile_void_cast)
  • VM は Deprecated チェックと相乗りのビット演算1回で警告を判定する。

php-src は C で書かれていますが、grep -r NODISCARD Zend/ のコマンドで今回のコードの部分は見つけることが出来ます。

興味があればぜひ覗いてみてください。

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?