LoginSignup
16
5

More than 3 years have passed since last update.

json_decode の第2引数 $assoc は null も受け取れる

Last updated at Posted at 2019-12-17

json_decode に関する問題

背景

かなりエッジケースなネタです。一般 Web 開発者には殆ど役に立たないので,PHP 自体に関心のある方だけお読みください…w

まずこちらをご覧ください。

JSON をデシリアライズするおなじみの関数ですね。この関数のシグネチャはドキュメント上では

json_decode ( string $json [, bool $assoc = false [, int $depth = 512 [, int $options = 0 ]]] ) : mixed

となっています。従来は

という動きでしたが,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 を指定するテストをしたいけど,いかんせんデコードのほうは使えるフラグが少ない!

image.png

image.png

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

$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 は原則「偽」扱いになるが,特殊な扱いをするかどうかは関数側に委ねられている

ということが言えます。

16
5
3

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
16
5