JavaScriptクイズ: JSON.parseできるけどJSとして有効でないことはある?
以下のクイズの解説を書きます。
JavaScriptクイズ: 以下の関数はtrueを返すことがある?
function quiz(s) {
if (typeof s !== 'string') return false;
try { JSON.parse(s); } catch { return false; }
try { eval('(' + s + ')'); } catch { return true; }
return false;
}
※ ただし、 JSON
や eval
を外のスコープから置き換える、といったことは禁止。
バージョンはES2023 (ECMA-262, 14th edition, June 2023 ECMAScript® 2023 Language Specification) を想定。ただし、さしてバージョン間の差異の影響はないと思います。
この問題は仕様を眺めながらqnighyさんの以下のクイズをふと思い出して作ったクイズです。
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
解答
true
を返すことがある。以下がそのようなJSONの例だ。
{"__proto__":0,"__proto__":0}
console.log(quiz('{"__proto__":0,"__proto__":0}'));
解説。
まず JSON.parse(s)
について。こちらの仕様は ECMA 25.5.1 JSON.parse ( text [ , reviver ] )
[1] によって定められており、簡単に書けば以下のような挙動です。
-
s
がJSONの仕様[2]にある構文を満たすか確認。満たさなければエラー -
'(' + s + ')'
をパースして評価。ただし、このときパースと評価はキーが__proto__
のときに関して、通常の JS の評価とは少し違う動きをする。 - そのオブジェクトを返す。
上記の通り、 JSON.parse
はそのまま JS エンジンとほぼ同じ挙動を取ります。 __proto__
に関してはレガシー指定[3]の項目を除けば2箇所で言及されています。
一つは紹介させていただいたqnighyさんのクイズでも触れられている、プロトタイプのセッターになるというもの [4]。
もう一つが重複して指定した際に他のプロパティとは違いエラーになるという点です。 [5]
たとえば、 {"abc":1,"abc":2}
のような単にキーが重複しているJSONは構文的には問題がなく、また少なくとも ECMAScript では曖昧性のない一意な解釈を与えています。しかし、 __proto__
がキー名の場合は、(おそらくプロトタイプセッターとしての意味を持たせているので)Computed Propertyを使わずに重複させるとエラーになります。
というわけでそこを攻めてあげれば今回のクイズは攻略可能です。
ちょっとだらないクイズかなとも思いましたが、回答者のうち24%ほどが「trueを返すことはない」と回答していたので、お役に立てば嬉しいです。
V8の実装をみてみた
ところで、構文チェックをせねばならない点と __proto__
の例外的な処理を除けば、JSON.parse
はJSのパーサーを流用できるけど、実際そうしてるのかなと思ってみてみました。
// ES6 section 24.3.1 JSON.parse.
BUILTIN(JsonParse) {
HandleScope scope(isolate);
Handle<Object> source = args.atOrUndefined(isolate, 1);
Handle<Object> reviver = args.atOrUndefined(isolate, 2);
Handle<String> string;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, string,
Object::ToString(isolate, source));
string = String::Flatten(isolate, string);
RETURN_RESULT_OR_FAILURE(
isolate, String::IsOneByteRepresentationUnderneath(*string)
? JsonParser<uint8_t>::Parse(isolate, string, reviver)
: JsonParser<uint16_t>::Parse(isolate, string, reviver));
}
そしてさらに JsonParser::Parse
を見てみたところ、専用のパーサーが組まれていそうでした。パフォーマンスを重視するV8にとって、当然ということでしょうね。
というか、そういえば前に const value = {...}
とJSONを書くより、 const value = JSON.parse('{...}')
と書くほうが速い、という話題がありましたが、このJSON特化の実装が要因ということでしょうね。
As long as the JSON string is only evaluated once, the JSON.parse approach is much faster compared to the JavaScript object literal, especially for cold loads. A good rule of thumb is to apply this technique for objects of 10 kB or larger — but as always with performance advice, measure the actual impact before making any changes.
引用元: https://v8.dev/blog/cost-of-javascript-2019#json
意訳: JSON文字列が一度しか評価されないという前提のもとで、JSON.parseを利用する方法はJSオブジェクトリテラルを利用するよりはるかに速い。特にコールドロードにおいて。指標として、このテクニックは10kBより大きいオブジェクトに利用するとしておけばよいだろう。しかし、いかなる場合でも、パフォーマンス一般のアドバイスとして、変更する前に計測しよう。
-
ES 25.5.1 JSON.parse ( text [ , reviver ] ) https://262.ecma-international.org/14.0/#sec-json.parse ↩︎
-
ECMA404 https://ecma-international.org/publications-and-standards/standards/ecma-404/ ↩︎
-
ESの仕様の中でLegacyという形で指定されているもの。利用者はそれらがエンジンに実装されていることを仮定するべきではない、としている。 ↩︎
-
ES 13.2.5.5 Runtime Semantics: PropertyDefinitionEvaluation https://262.ecma-international.org/14.0/#sec-runtime-semantics-propertydefinitionevaluation ↩︎
-
ES 13.2.5.1 Static Semantics: Early Errors https://262.ecma-international.org/14.0/#sec-object-initializer-static-semantics-early-errors ↩︎
Discussion