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

# `json_decode` に関する問題

## 背景

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

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

- [PHP: json_decode - Manual](https://www.php.net/manual/ja/function.json-decode.php)

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

```php
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 系インタフェース対応）

[`json_last_error()`]: https://www.php.net/manual/ja/function.json-last-error.php
[`json_last_error_msg()`]: https://www.php.net/manual/ja/function.json-last-error-msg.php


```php
<?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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/1cbdc282-4b46-b0ed-70db-2348fe86a333.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25060/0c17b7cd-fb1c-228e-5993-f4a01512860f.png)

`JSON_BIGINT_AS_STRING` や UTF-8 系のやつはいつ動いてくれるのか分からなかったし，`JSON_THROW_ON_ERROR` もデフォルトで使ってるので消去法的に **`JSON_OBJECT_AS_ARRAY`** で試すことに。

ドキュメントの更新が追いついていないっぽいですが，日本語版を見た感じだと第2引数の `$assoc = true` に指定するのと同じ効果があるよ〜とのことです。

で，書いたテストがこれ。

```php
public function testDecodeWithExtraFlags(): void
{
    $this->assertEquals((object)[], Json::decode('{}'));
    $this->assertSame([], Json::decode('{}', false, JSON_OBJECT_AS_ARRAY));
}
```

ところがこれだと `JSON_OBJECT_AS_ARRAY` が効かず，`$assoc = false` の指定が優先されてしまっているようです。

> ```php
> 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.](https://bugs.php.net/bug.php?id=73991)
- [php-src/bug73991.phpt at 49a4e695845bf55e059e7f88e54b1111fe284223 · php/php-src](https://github.com/php/php-src/blob/49a4e695845bf55e059e7f88e54b1111fe284223/ext/json/tests/bug73991.phpt)

> ```php
> <?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 渡してんじゃねーよ。ドキュメントにそんなこと何も書いてなかっただろ…

- [Allow JSON_OBJECT_AS_ARRAY option to actually have meaning · php/php-src@aa2282d](https://github.com/php/php-src/commit/aa2282df21b32f9a2a1e86b580cb4fca5ff2c3f5)

該当修正を見るとこんな感じに。

```diff
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` なら何もしない

なるほど？

## 結果

というわけで，実装はこうなりました。

```diff
-    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` について少し調べてみました。すると衝撃の事実が発覚…

- [php-src/zend_API.h at a603c06e2e3db50e1752876a8146eaa08b113269 · php/php-src](https://github.com/php/php-src/blob/a603c06e2e3db50e1752876a8146eaa08b113269/Zend/zend_API.h)

```haskell
#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` の中身を見てみるとこうなってます。こちらはインライン関数ですね。

```c
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）を受け取るところに `"あああ"` なんて文字列を渡す機会なかなか無いですからね…

- [php-src/zend_API.c at 6895eb4db59bf70f02a9354a27cd82b0fa8e8b6e · php/php-src](https://github.com/php/php-src/blob/6895eb4db59bf70f02a9354a27cd82b0fa8e8b6e/Zend/zend_API.c)

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

ということが言えます。