本文へ移動
Hikari 仕様

std:json — JSON 直列化 (parse / stringify)

標準ライブラリモジュール (index.md)。本書中の裸の §N は本書の節を指す。言語の意味論は ../language-spec.md を参照。

import json := "std:json"decode / parse / stringify の 3 slot を持つ namespace object を json に束縛する。Hikari のネイティブ値と JSON テキストを双方向に写すコーデック。外部システムとのデータ交換 (設定ファイル・API・ログ等) のための基盤である。

std:fs / std:term と違い I/O を伴わず、純粋・同期 (Future を返さない)。parse / stringifyString 上の純粋関数で、テキストの読み書き自体は呼び出し側が std:fs 等で行う。

import { decode, parse, stringify } := "std:json"

type User := { name: String, age: Int, tags: List(String) |}

match decode("{\"name\":\"a\",\"age\":30,\"tags\":[\"x\",\"y\"]}", User) {
  Ok(u) => print(u.name)      # "a"  (u の静的型は User)
  Err(e) => print(e.message)  # 構文不正・型不一致のいずれも Err
}

# スキーマが分からない場合は parse (object は InsertionMap、§1)
match parse(text) {
  Ok(v) => print(v.get("name").or("?"))
  Err(e) => print(e.message)
}

stringify({ name := "a", age := 30 |}, 0)    #> Ok("{\"name\":\"a\",\"age\":30}")
名前 意味
decode decode(text, T) JSON テキスト textT の値として復号し、Result(T) を返す (同期)。型が合わなければ Err§2.1
parse parse(text) JSON テキスト text を Hikari のネイティブ値へ復号し、Result を返す (同期)。スキーマが実行時にしか分からない場合に使う
stringify stringify(value, indent) Hikari 値 value を JSON テキストへ符号化し、Result を返す (同期)。indent0 ならコンパクト。省略できない (index.md)

本モジュールは効果を1 つも持たない../language-spec.md §17.9)。

スキーマが分かっているなら decode を使う。 parse は静的に Result(Any) を返すので、以降の値が gradual 境界 (language-spec.md §17) に落ちる。decode は結果型が Result(T) に確定するため境界を作らない。

以下の例では import json := "std:json" で束縛したものとして json.parse 等で記す。

1. 値と JSON の対応

Hikari の値には JSON に写せないもの (関数・タプル・Bytes・range) がある。std:json双方向に一意に写る部分集合 だけを扱う。対応は次のとおり:

Hikari 値 JSON 備考
Int number Int は任意精度 (language-spec.md §7)。number は整数値のみ受理する。詳細は §3 / §4
Float number stringify の入力としてのみ受理する。表記は言語の正準形 (最短往復形。小数点も指数も含まないときは .0 を補う。§4)。parse は number を Float へ復号しない (§3) ため、読み戻しは型を宣言して decode を使う (§5)
String string UTF-8。要素は rune (language-spec.md §7.2.1)
Bool bool true / false
Unit () null
List array 順序を保つ
InsertionMap(String, Any) (map.md) object キーの順序を保つ。parse はこの型で返す
record (body 省略) object stringify の入力としてのみ受理する。スロット宣言順を保つ。受理の条件は値の種別ではなく属性 — body が省略形 (language-spec.md §2.2) で、全スロットが値を持つ (束縛済みまたは既定値あり) こと

JSON に写せない Hikari 値 (stringify は黙って落とさず Err({kind := "unsupported" …}) を返す。§6):

Hikari 値 理由
関数 / body 持ちオブジェクト (closure) JSON に振る舞いの表現が無い
Tuple JSON に対応物が無く、array にすると List と区別できない (復元で曖昧、language-spec.md §7.3)
Bytes JSON に生バイト列の表現が無い (base64 等の規約は持ち込まない)。テキスト化したいなら b.to_string! で String にしてから渡す (language-spec.md §7.2.2)
range (遅延シーケンス) 直接は写せない。array にしたいなら r.map({x | x}) 等で List に実体化してから渡す (language-spec.md §7.6)

Float は有限値なら写せるが、NaN / ±Inf は JSON に number 表現が無いため stringifyErr({kind := "unsupported" …}) を返す (§4)。

2. json.decode

  • 引数: text (String) と T (型値) の 2 個。text が非 String・T が型値でない場合は呼び出し位置で Error (panic 型, language-spec.md §16)
  • 同期Result を直接返す
  • 戻り値: Result(T, Error)
    • 成功時: Ok(v)v は型 T の値。v matches T は真である
    • 失敗時: Err(e)e は回復可能エラーオブジェクト {kind, message} (§6)

復号は 2 段で行う。まず JSON テキストを構文解析し、次に T に沿って Hikari 値を組み立てる。 型は復号の指示であって事後の検査ではない — 同じ JSON でも T が違えば違う形の値になる (object を record に組むか InsertionMap に組むかなど)。

decode(text, Any)parse(text) と同じ値を返す。

2.1 型ごとの復号規則

T 受理する JSON 規則
Int number 整数値のみ (§3parse と同じ規則)。小数は type_mismatch
Float number 整数・小数のいずれも受理し Float にする
String string
Bool bool
Unit null
Option(E) null / E が受理するもの null は Noneキー自体が無い場合も None (§2.2)。それ以外は Some に包む
List(E) array 各要素を E で復号
Tuple array 要素数が一致すること。各要素を対応する型で復号
record object 宣言フィールドだけを取り出す。詳細は §2.2
InsertionMap(String, V) (map.md) object キーは String のまま写し、各値を V で復号する。InsertionMap(String, Any) なら値をそのまま写す — 部分的に動的な位置の逃げ道。キー型が String 以外の InsertionMap(K, V) は対応外 (JSON のキーは常に文字列)
refinement (language-spec.md §17.7) 基底型が受理するもの 基底型で復号したのち述語を評価する。偽なら type_mismatch
Any 任意 parse と同じ (§3 の既定復号)

対応外の型enum (タグ付きデータ)・関数型・Bytes・range・不透明型 (std:timeInstant 等)・型変数・Never — を T に渡した場合は、呼び出し位置で Error とする。復号できない型を黙って Err にすると、書き手の型の書き間違いと入力データの不正が同じ経路で返ってしまうためである。

enum を対応外にするのは、JSON にタグ付き和の表現規約が無く、規約を決めること自体が独立した設計判断になるためである。和が要る場合は Option を使うか、タグを持つ record として復号してから自分で match する。

2.2 record の復号

T が record 型のとき、宣言されたフィールドだけを JSON object から取り出して組み立てる。

  • 欠落したフィールドは type_mismatch。ただしフィールドの型が Option(E) のときはキーが無くてよく、その場合 None になる。null が入っている場合と区別しない
  • 宣言に無いキーは無視する。JSON 側がフィールドを増やしても復号が壊れないため (前方互換) である。Hikari の record が幅構造型で「宣言より多くのフィールドを持つ値」を認めているのとも揃う
  • フィールド値は宣言型で再帰的に復号する。入れ子の record・ListOption はそのまま入れ子で書ける
type Server := { host: String, port: Int, tls: Option(Bool) |}
type Config := { name: String, servers: List(Server) |}

json.decode("{\"name\":\"p\",\"servers\":[{\"host\":\"a\",\"port\":80}]}", Config)
#> Ok({ name := "p", servers := [{ host := "a", port := 80, tls := None |}] |})

json.decode("{\"name\":\"p\"}", Config)
#> Err({ kind := "type_mismatch", message := "json.decode: missing field 'servers'" |})

json.decode("{\"name\":\"p\",\"servers\":[{\"host\":\"a\",\"port\":\"80\"}]}", Config)
#> Err({ kind := "type_mismatch", message := "json.decode: .servers.0.port: expected Int, got String" |})

2.3 エラーの位置

type_mismatchmessage には値へ到達したパスを含める。パスは . 区切りで、名前は record のフィールド名・数字は配列の添字である (.servers.0.port)。ルート直下の不一致はパスを省く。

構文が壊れている場合の kindsyntax で、parse と同じ規則・同じ文面になる (§6)。JSON として不正なのか、JSON としては正しいが型に合わないのかを kind で区別できる。

2.4 静的な結果型

第 2 引数が静的に型値へ解決できるとき (type 宣言した名前・prelude の型語彙・型式そのもの)、decode の結果型は Result(T, Error) に確定する。解決できないとき (型値が実行時にしか決まらない形) は Result(Any) に落ち、そこから先は gradual 境界になる (../static-analysis.md §2)。

型は値である (language-spec.md §17) ため、対象型は普通の引数として渡す — v matches T が型値を引数にとるのと同じ形である。したがって「型を実行時に選ぶ」書き方 ({ ty: Any | decode(text, ty) }) も正当で、その場合は結果型が Result(Any) に落ちるだけである。

第 2 引数が型になり得ないと静的に分かるとき (リテラル・演算子式、および Any でない具体型に確定した束縛) は、到達すれば確実に Error になるので静的検査が報告する。型値でありうる形 (Any のパラメーターなど) は素通しし、実行時の Error に委ねる。

2.5 ラップしても結果型は運べる

decode の静的シグネチャは { String, Type(a) | Result(a) } である (language-spec.md §17.4.1)。
対象型と結果型を同じ型変数で結んでいるだけなので、ユーザー定義の関数でも同じことが書ける

my_decode: { String, Type(a) | Result(a) } := { text: String, ty: Type(a) | json.decode(text, ty) }
cfg := my_decode(text, Config)      # 結果型は Result(Config) に確定する

第 2 引数を Any で受けると型変数が結ばれないので結果型は Result(Any) に落ちる。
対象型を結果へ運びたいときは Type(a) と書く。

Type(a) を宣言した位置へ型値でないと確定した値を渡す形は、静的検査が報告する
(language-spec.md §17.4.1)。これは decode 固有の規則ではなく Type(T) 語彙の一般則で、
ユーザー定義関数にも同じく効く。

3. json.parse

  • 引数: text (String) 1 個。非 String を渡すと呼び出し位置で Error (panic 型, language-spec.md §16)
  • 同期Future ではなく Result を直接返す
  • 戻り値は Result:
    • 成功時: Ok(value)value§1 の対応に従う Hikari のネイティブ値
    • 失敗時: Err(e)e は回復可能エラーオブジェクト {kind, message} (§6)

復号規則:

  • 数値: JSON number が 整数値 のとき Int (桁数によらず任意精度に復号する。language-spec.md §7)。小数部を持つ・指数展開しても整数にならない場合は復号せず Err({kind := "unsupported_number" …}) (Float への黙った丸めで情報を失わないため。Float を含む値の読み戻しは型を宣言する decode で閉じる。§5)。11.02e31e400 は整数値なので可、1.5 は不可
  • object: std:mapInsertionMap (map.md)。静的型は InsertionMap(String, Any) (キーは String、値は §1 の対応に従う任意の値)。エントリはソース出現順に並ぶ (m.keys() がこの順を返す) ので、JSON のキー順がそのまま保たれる。値は m.get(k) で引き、m.each {k, v | …} で反復する。キーは JSON の任意の文字列で、Hikari の識別子として不正な文字 (- / 空白等) を含み得るため record では表せない — record のスロットは書いた時点で名前が決まっているフィールドであり、キーで引く辞書ではない (language-spec.md §7.5)。同一キーが複数現れた場合は 後勝ち・位置維持 (InsertionMapinsert と同じ規律。map.md §3)
  • array: 固定長 (plain) の List (language-spec.md §7.4)。要素は出現順
  • string: JSON のエスケープ (\n \t \" \\ \/ \b \f \r\uXXXX) を解釈。\uXXXX のサロゲートペアは 1 つの code point に合成する
  • null → Unit ()
  • 値の前後の空白 (space / tab / CR / LF) は許容。値の後ろに非空白が残る場合は Err({kind := "syntax" …}) (1 ファイル = 1 値)。空入力・トークン不正・文字列未終端・不正エスケープも syntax
  • 入力は UTF-8 とみなす。不正な UTF-8 は syntax
  • 入れ子の深さには実装定義の上限があり、超過時は Err({kind := "depth" …}) (スタック保護)

parseResult を直接返す (Future ではない) ので、std:fs のような ! での await は不要。値が要るだけなら parse(text).unwrap! でもよい。

match json.parse(text) {
  Ok(v) => use(v)
  Err(e) => match e.kind {
    "syntax" => print("not valid JSON")
    "unsupported_number" => print("non-integer number")
    _ => print(e.message)
  }
}

4. json.stringify

  • 引数: (value, indent) の 2 引数。indent は省略できない (index.md)
    • indent非負の Int0コンパクト (区切りに余分な空白を入れない)。正の整数 npretty-print (1 段ごとに半角空白 n 個でインデントし、改行を入れる)
    • indent が非 Int または負のときは呼び出し位置で Error (panic 型, language-spec.md §16)
  • 同期Result を直接返す
  • 戻り値は Result:
    • 成功時: Ok(text)text は JSON テキストの String
    • 失敗時: Err(e)value (またはその一部) が §1 で JSON に写せないとき。e{kind, message} (§6)

符号化規則:

  • §1 の対応に従う。InsertionMapm.keys() の順 (= 挿入順)、record は m.names() の順 (= 宣言順) で出力し、いずれも出力順は決定的
  • 数値の表記: Int は十進表記 (任意精度)。Float は言語の正準形と同じ最短往復形で、小数点も指数も含まないときは .0 を補う (2.02.057.457.41e211e+21)。いずれも valid な JSON number である。NaN / ±Inf は number として書けないため Err({kind := "unsupported" …}) を返す
  • 空の器: []{}別の値なので (language-spec.md §7.4)、そのまま写す — 空 List は [] (空 array)、空オブジェクトは {} (空 object) になる
  • String のエスケープ: JSON 必須のエスケープ ("\・U+0000〜U+001F の制御文字) のみを行う。/ はエスケープしない。非 ASCII 文字は生の UTF-8 のまま 出力する (\uXXXX には逃がさない)
    • 制御文字は \b\t\n\f\r を短縮エスケープ、それ以外の U+0000〜U+001F\uXXXX にする。\b(U+0008)/\f(U+000C) は短縮形で出し、U+2028/U+2029 は(非 ASCII として)生のまま出す。どちらも valid な JSON で parse の解釈は不変。
  • §1 後段の「JSON に写せない値」を含む場合は 黙って落とさず Err({kind := "unsupported" …})message には該当値の種別と、可能なら到達位置を含める
  • 入れ子の深さ: 符号化にも実装定義の深さ上限があり、parse の上限 (§3) を下回らないparse で読めた値は必ず書き戻せる (§5)。上限を超える入れ子と、自分自身を含む循環した構造はここで止まり Err({kind := "unsupported" …}) を返す (循環は JSON に写せる形を持たない)
json.stringify({ name := "a", items := [1, 2, 3] |})
#> Ok("{\"name\":\"a\",\"items\":[1,2,3]}")

json.stringify({ a := 1 |}, 2)
#> Ok("{\n  \"a\": 1\n}")

5. 往復則

v§1 の表現可能部分集合 (関数・Tuple・二軸同居・Bytes・range を含まない) に収まり、object を InsertionMap で表しているとき、往復は language-spec.md §9.1== で一致する:

parse(stringify(v).unwrap!).unwrap!       # 値等価で v に一致

Float は parse では閉じない。 parse は number を Int にしか復号しない (§3) ため、Float を含む値の往復は型を宣言する decode で閉じる:

decode(stringify(v).unwrap!, T).unwrap!   # T が対応する位置を Float 型で宣言しているとき v に一致

stringify が出した 57.4parse に渡すと Err({kind := "unsupported_number" …}) になる。書けるが untyped では読み戻せないというこの非対称は意図したもので、parse は対象型を知らないため、Float へ丸めるかどうかの判断を書き手へ返している (§3)。parse は untyped のゲート、decode は typed のゲートである。

record を渡した場合は型が正規化される。 stringify は object の入力として record も受理するが、parse は常に InsertionMap で返すため、parse(stringify({ name := "a" |})) は record ではなく InsertionMap になる。キーと値と順序は保たれるので JSON テキストとしては往復するが、Hikari 値としては == で一致しない (record と InsertionMap は別の型である)。値として往復させたい場合は object を InsertionMap で組む。

ただし往復は 正規化を伴う ため、テキストレベルでも安定しない点に注意:

  • parse は数値・文字列を Hikari 値へまとめるので、stringify(parse(t)) は空白・数値表記 (1.012e32000)・エスケープ表現が正規化され、元テキスト t と一字一句一致するとは限らない
  • 空の器は §4 のとおり種類ごとに写るので、[] は array、{} は object として往復する

履歴やキャッシュの一致判定は、テキスト比較ではなく parse 後の値を == (language-spec.md §9.1 の再帰的構造等価) で比べること。

6. エラーモデル

失敗時の e は回復可能エラーオブジェクト {kind, message} (language-spec.md §16)。std:json が返す正規 kind (閉じた一覧):

関数 kind 意味
decode / parse syntax JSON として不正 (トークン不正・未終端・不正エスケープ・末尾の余剰・空入力・不正 UTF-8)
decode / parse unsupported_number number が整数値でない (小数)。decode では対象型が Float のときは発生しない
decode / parse depth 入れ子が実装定義の深さ上限を超えた
decode type_mismatch JSON としては正しいが対象型に合わない (フィールド欠落・型違い・要素数不一致・refinement 述語が偽)。message に到達パスを含む (§2.3)
stringify unsupported value§1 で JSON に写せない要素 (関数・Tuple・二軸同居・Bytes・range) か、number として書けない Float (NaN / ±Inf) を含む。入れ子が深さ上限を超える場合 (循環した構造を含む。§4) も同じ kind である

messagejson.decode: ... のように関数名を含む人間可読文字列。分岐は match res { Ok(v) => … Err(e) => match e.kind {...} }kind により行い、message の文字列マッチに依存しないこと。引数不足・非 String の text・非 Int/負の indentdecode の第 2 引数が型値でない/復号に対応しない型 (§2.1) は回復可能エラーではなく、呼び出し位置で Error (panic 型, language-spec.md §16) になる。入力データの不正 (Err) と書き手のプログラムのエラー (Error) を経路で分ける。