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 / stringify は String 上の純粋関数で、テキストの読み書き自体は呼び出し側が 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 テキスト text を 型 T の値として復号し、Result(T) を返す (同期)。型が合わなければ Err。§2.1 |
parse |
parse(text) |
JSON テキスト text を Hikari のネイティブ値へ復号し、Result を返す (同期)。スキーマが実行時にしか分からない場合に使う |
stringify |
stringify(value, indent) |
Hikari 値 value を JSON テキストへ符号化し、Result を返す (同期)。indent が 0 ならコンパクト。省略できない (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 表現が無いため stringify が Err({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 | 整数値のみ (§3 の parse と同じ規則)。小数は 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:time の Instant 等)・型変数・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・
List・Optionはそのまま入れ子で書ける
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_mismatch の message には値へ到達したパスを含める。パスは . 区切りで、名前は record のフィールド名・数字は配列の添字である (.servers.0.port)。ルート直下の不一致はパスを省く。
構文が壊れている場合の kind は syntax で、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:
復号規則:
- 数値: JSON number が 整数値 のとき
Int(桁数によらず任意精度に復号する。language-spec.md §7)。小数部を持つ・指数展開しても整数にならない場合は復号せずErr({kind := "unsupported_number" …})(Float への黙った丸めで情報を失わないため。Float を含む値の読み戻しは型を宣言するdecodeで閉じる。§5)。1・1.0・2e3・1e400は整数値なので可、1.5は不可 - object:
std:mapのInsertionMap(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)。同一キーが複数現れた場合は 後勝ち・位置維持 (InsertionMapのinsertと同じ規律。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" …})(スタック保護)
parse は Result を直接返す (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は 非負の Int。0で コンパクト (区切りに余分な空白を入れない)。正の整数nで pretty-print (1 段ごとに半角空白n個でインデントし、改行を入れる)indentが非 Int または負のときは呼び出し位置でError(panic 型, language-spec.md §16)- 同期。
Resultを直接返す - 戻り値は
Result:
符号化規則:
- §1 の対応に従う。
InsertionMapはm.keys()の順 (= 挿入順)、record はm.names()の順 (= 宣言順) で出力し、いずれも出力順は決定的 - 数値の表記: Int は十進表記 (任意精度)。Float は言語の正準形と同じ最短往復形で、小数点も指数も含まないときは
.0を補う (2.0→2.0、57.4→57.4、1e21→1e+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.4 を parse に渡すと 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.0→1、2e3→2000)・エスケープ表現が正規化され、元テキスト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 である |
message は json.decode: ... のように関数名を含む人間可読文字列。分岐は match res { Ok(v) => … Err(e) => match e.kind {...} } で kind により行い、message の文字列マッチに依存しないこと。引数不足・非 String の text・非 Int/負の indent・decode の第 2 引数が型値でない/復号に対応しない型 (§2.1) は回復可能エラーではなく、呼び出し位置で Error (panic 型, language-spec.md §16) になる。入力データの不正 (Err) と書き手のプログラムのエラー (Error) を経路で分ける。