json_decode
に関する問題
背景
かなりエッジケースなネタです。一般 Web 開発者には殆ど役に立たないので,PHP 自体に関心のある方だけお読みください…w
まずこちらをご覧ください。
JSON をデシリアライズするおなじみの関数ですね。この関数のシグネチャはドキュメント上では
json_decode ( string $json [, bool $assoc = false [, int $depth = 512 [, int $options = 0 ]]] ) : mixed
となっています。従来は
- 成功時はデシリアライズした値を返す
- 失敗時は
NULL
を返し,エラー原因をjson_last_error()
やjson_last_error_msg()
で取れるようにする
という動きでしたが,PHP 7.3 からエラー時に例外を投げてくれる JSON_THROW_ON_ERROR
というフラグオプションが追加されました。ということで,こいつをデフォルトで使ってくれるユーティリティクラスを作っていたんですよね…
(Jsonable
JsonSerializable
Arrayable
とかチェックしてるのはおまけ機能です。Laravel でよくある JSON 系インタフェース対応)
<?php
namespace App\Utils;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use JsonSerializable;
class Json
{
public static function encode($value, bool $pretty = false, int $additionalFlags = 0): string
{
$flags = JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | ($pretty ? JSON_PRETTY_PRINT : 0) | $additionalFlags;
if ($value instanceof Jsonable) {
$json = $value->toJson($flags);
} elseif ($value instanceof JsonSerializable) {
$json = json_encode($value, $flags);
} elseif ($value instanceof Arrayable) {
$json = json_encode($value->toArray(), $flags);
} else {
$json = json_encode($value, $flags);
}
return $json;
}
public static function decode(string $json, bool $assoc = false, int $flags = 0)
{
return json_decode($json, $assoc, 512, JSON_THROW_ON_ERROR | $flags);
}
}
で, Json::decode()
のユニットテストを書こう…というところで問題が発生しました。
問題
$flags
を指定するテストをしたいけど,いかんせんデコードのほうは使えるフラグが少ない!
JSON_BIGINT_AS_STRING
や UTF-8 系のやつはいつ動いてくれるのか分からなかったし,JSON_THROW_ON_ERROR
もデフォルトで使ってるので消去法的に JSON_OBJECT_AS_ARRAY
で試すことに。
ドキュメントの更新が追いついていないっぽいですが,日本語版を見た感じだと第2引数の $assoc = true
に指定するのと同じ効果があるよ〜とのことです。
で,書いたテストがこれ。
public function testDecodeWithExtraFlags(): void
{
$this->assertEquals((object)[], Json::decode('{}'));
$this->assertSame([], Json::decode('{}', false, JSON_OBJECT_AS_ARRAY));
}
ところがこれだと JSON_OBJECT_AS_ARRAY
が効かず,$assoc = false
の指定が優先されてしまっているようです。
json_decode ( string $json [, bool $assoc = false [, int $depth = 512 [, int $options = 0 ]]] ) : mixed
でもデフォルト値 false
だしブール引数だしどうすればいいの…?と疑問に思っていたらこんなバグレポートを発見。
- PHP :: Bug #73991 :: JSON_OBJECT_AS_ARRAY flag does not function.
- php-src/bug73991.phpt at 49a4e695845bf55e059e7f88e54b1111fe284223 · php/php-src
<?php $json = '{"foo":"bar"}'; var_dump(json_decode($json, false)); var_dump(json_decode($json, true)); var_dump(json_decode($json, null, 512, 0)); var_dump(json_decode($json, null, 512, JSON_OBJECT_AS_ARRAY)); ?>
いやいやいやオイオイオイ,さらっと NULL 渡してんじゃねーよ。ドキュメントにそんなこと何も書いてなかっただろ…
該当修正を見るとこんな感じに。
diff --git a/ext/json/json.c b/ext/json/json.c
index 38fc587ab9ec..9803f48a3a05 100644
--- a/ext/json/json.c
+++ b/ext/json/json.c
@@ -259,13 +259,14 @@ static PHP_FUNCTION(json_decode)
char *str;
size_t str_len;
zend_bool assoc = 0; /* return JS objects as PHP objects by default */
+ zend_bool assoc_null = 1;
zend_long depth = PHP_JSON_PARSER_DEFAULT_DEPTH;
zend_long options = 0;
ZEND_PARSE_PARAMETERS_START(1, 4)
Z_PARAM_STRING(str, str_len)
Z_PARAM_OPTIONAL
- Z_PARAM_BOOL(assoc)
+ Z_PARAM_BOOL_EX(assoc, assoc_null, 1, 0)
Z_PARAM_LONG(depth)
Z_PARAM_LONG(options)
ZEND_PARSE_PARAMETERS_END();
@@ -288,10 +289,12 @@ static PHP_FUNCTION(json_decode)
}
/* For BC reasons, the bool $assoc overrides the long $options bit for PHP_JSON_OBJECT_AS_ARRAY */
- if (assoc) {
- options |= PHP_JSON_OBJECT_AS_ARRAY;
- } else {
- options &= ~PHP_JSON_OBJECT_AS_ARRAY;
+ if (!assoc_null) {
+ if (assoc) {
+ options |= PHP_JSON_OBJECT_AS_ARRAY;
+ } else {
+ options &= ~PHP_JSON_OBJECT_AS_ARRAY;
+ }
}
php_json_decode_ex(return_value, str, str_len, options, depth);
-
$assoc = true
ならPHP_JSON_OBJECT_AS_ARRAY
を付与 -
$assoc = false
ならPHP_JSON_OBJECT_AS_ARRAY
を除外 -
$assoc = null
なら何もしない
なるほど?
結果
というわけで,実装はこうなりました。
- public static function decode(string $json, bool $assoc = false, int $flags = 0)
+ public static function decode(string $json, ?bool $assoc = false, int $flags = 0)
{
return json_decode($json, $assoc, 512, JSON_THROW_ON_ERROR | $flags);
}
ちなみに $depth
引数のほうは, NULL を渡すと整数キャストされて 0 扱いにしてきます。さすが PHP!
Zend API 探検
せっかくなので, Z_PARAM_BOOL_EX
について少し調べてみました。すると衝撃の事実が発覚…
#define Z_PARAM_BOOL_EX2(dest, is_null, check_null, deref, separate) \
Z_PARAM_PROLOGUE(deref, separate); \
if (UNEXPECTED(!zend_parse_arg_bool(_arg, &dest, &is_null, check_null))) { \
_expected_type = Z_EXPECTED_BOOL; \
_error_code = ZPP_ERROR_WRONG_ARG; \
break; \
}
#define Z_PARAM_BOOL_EX(dest, is_null, check_null, separate) \
Z_PARAM_BOOL_EX2(dest, is_null, check_null, separate, separate)
#define Z_PARAM_BOOL(dest) \
Z_PARAM_BOOL_EX(dest, _dummy, 0, 0)
まず Zend API のヘッダファイルにたどり着きます。Z_PARAM_BOOL
は論理値を受け取るための基本マクロで, null
を受け取る場合は Z_PARAM_BOOL_EX
を使って「NULLチェックオプション」を有効にしているように見えますね。
どうやら PHP の標準関数,すべて論理値の代わりに null
を受け取れそうです。
bool
なんて無かった,全部 ?bool
や!
ええんかこれ…?
で,zend_parse_arg_bool
の中身を見てみるとこうなってます。こちらはインライン関数ですね。
static zend_always_inline int zend_parse_arg_bool(zval *arg, zend_bool *dest, zend_bool *is_null, int check_null)
{
if (check_null) {
*is_null = 0;
}
if (EXPECTED(Z_TYPE_P(arg) == IS_TRUE)) {
*dest = 1;
} else if (EXPECTED(Z_TYPE_P(arg) == IS_FALSE)) {
*dest = 0;
} else if (check_null && Z_TYPE_P(arg) == IS_NULL) {
*is_null = 1;
*dest = 0;
} else {
return zend_parse_arg_bool_slow(arg, dest);
}
return 1;
}
-
true
だったら「真」 -
false
だったら「偽」 -
NULLチェックオプションが有効で
null
だったら 「偽」,ただしis_null
に情報を持たせてNULLであったことは呼び出し元に伝える - それ以外なら
zend_parse_arg_bool_slow
に委ねる
slow と名前にある通り,こちらは使用頻度の低さから非インライン関数となっているようです。そりゃ標準関数で論理値(かNULL)を受け取るところに "あああ"
なんて文字列を渡す機会なかなか無いですからね…
ZEND_API int ZEND_FASTCALL zend_parse_arg_bool_slow(zval *arg, zend_bool *dest) /* {{{ */
{
if (UNEXPECTED(ZEND_ARG_USES_STRICT_TYPES())) {
return 0;
}
return zend_parse_arg_bool_weak(arg, dest);
}
ZEND_API int ZEND_FASTCALL zend_parse_arg_long_weak(zval *arg, zend_long *dest) /* {{{ */
{
if (EXPECTED(Z_TYPE_P(arg) == IS_DOUBLE)) {
if (UNEXPECTED(zend_isnan(Z_DVAL_P(arg)))) {
return 0;
}
if (UNEXPECTED(!ZEND_DOUBLE_FITS_LONG(Z_DVAL_P(arg)))) {
return 0;
} else {
*dest = zend_dval_to_lval(Z_DVAL_P(arg));
}
} else if (EXPECTED(Z_TYPE_P(arg) == IS_STRING)) {
double d;
int type;
if (UNEXPECTED((type = is_numeric_str_function(Z_STR_P(arg), dest, &d)) != IS_LONG)) {
if (EXPECTED(type != 0)) {
if (UNEXPECTED(zend_isnan(d))) {
return 0;
}
if (UNEXPECTED(!ZEND_DOUBLE_FITS_LONG(d))) {
return 0;
} else {
*dest = zend_dval_to_lval(d);
}
} else {
return 0;
}
}
if (UNEXPECTED(EG(exception))) {
return 0;
}
} else if (EXPECTED(Z_TYPE_P(arg) < IS_TRUE)) {
*dest = 0;
} else if (EXPECTED(Z_TYPE_P(arg) == IS_TRUE)) {
*dest = 1;
} else {
return 0;
}
return 1;
}
PHPらしく,「強いて言うなら真っぽい」「強いて言うなら偽っぽい」を頑張って判断してくれる関数です。
まとめ
最初は json_decode()
限定かと思っていましたが,実際は予想よりもっと幅広く…
- PHP の標準関数は論理値の代わりに
NULL
を受け取れる NULL
は原則「偽」扱いになるが,特殊な扱いをするかどうかは関数側に委ねられている
ということが言えます。