Hikari 言語仕様 公式 v1
本書は実装の合意基準である。本書から逸脱する場合は本書を改訂する。設計の動機・思想は別文書 philosophy.md を参照。
各構文要素の形と実行例の一覧は、doc コメントから自動生成される reference/syntax.md にもある。本書はそれらの意味論・文法・優先順位・設計根拠を規範として定める(reference が形の索引、本書が意味の定義)。
1. 字句構造
1.1 コメント
# これは行コメント、行末まで # 多くの例で出てくる #> 5 は「直前の式の値が 5」を意味する慣例表記であり、 # 言語が特別扱いするものではない
複数行コメントは持たない。
字句上の特例: #{hikari} / #[hikari] と、strict 属性付きの #{hikari strict} / #[hikari strict] の 4 形だけはファイル先頭の header トークンとして読まれる (詳細は §13.1)。#{hikari} は省略可能で、省略時は明示したのと同じ意味になる。それ以外の位置に出現する #{ / #[ は通常の # 行コメントとして処理される。
1.2 識別子
通常の識別子は ASCII 英字または _ で始まり、英数字または _ が続く (ASCII のみ)。
オペレータ記号 (+、-、*、/、==、!=、<、<=、>、>=、!、その他) もスロット名として有効。{+ := {...} |} のように書ける。
ユーザー定義オペレータのスロット名としては、記号 1〜複数文字 (++、<>、<=> など) を許す。記号と英字の混在 (+!、add+ 等) は不可。
1.2.1 大小文字の慣用 (casing)
字句解析は大文字・小文字を区別する (Foo と foo は別の識別子)。一方、言語は casing を強制しない — 予約語は無く、どの名前も普通の束縛である (型名も型値を束縛した普通の名前。§17.2)。以下は慣用であり、spec / prelude の表記と整形・検査ツールがこれに従う。
- 小文字始まり — 通常の値・関数・束縛・メソッド名、prelude の大域 (
print/range/if…)。2 語以上はsnake_case(to_int/sort_by/read_bytes)。 - 大文字始まり (UpperCamelCase) — 次の 2 種。複合語は区切り無しで各語頭を大文字にする (
OneOf/ ユーザ定義のRingBufferなど)。 - 型値: 原始型・型コンストラクタ・
type名 (Int/String/Option/Result/Ordering/Comparable/Copyable/ ユーザ定義型。§17)。 - 値コンストラクタ: タグ付きデータの各タグに対応する名前。payload を取るタグ (arity ≥1) は関数値
Some/Ok/Err、payload 無しのタグ (arity 0) は呼び出し不要でそのまま値None/Less/Equal/Greater(§9.2 / §16 / prelude.md §13 / §14)。ユーザはenum(§17.4)で自分のタグ付きデータを宣言でき、生成されるタグも prelude のタグと同列の値コンストラクタである。型値ではないが大文字側に置く。タグは型値Nameの下に住む slot(Name.Tag)として公開され、prelude のSome/None/Ok/Err/Less/Equal/Greaterは先取りの名前 destructure(§6.3)で bare 公開されている点だけがユーザ定義タグと異なる(§17.4)。 - 例外 (小文字の組込リテラル): 真偽値リテラル
true/falseは小文字 (原始リテラルであって値コンストラクタではない。§7)。
この慣用にツールが依存する: hikari fmt は「大文字始まりの callee = 値 / 型コンストラクタ」を単一引数でも括弧付きに保ち、小文字始まりの関数適用は並置化する (format.md「整形規則」)。[...] の slot-list でも、大文字の値コンストラクタ ([None | …] / [Less | …]) はパターン照合として読め、小文字の束縛参照と視覚的に区別できる (§8.4)。
型コンテキストでは casing が役割を分ける — 大文字始まり=具体型参照、小文字始まり=型変数(§17.2)。値位置の慣用(型値・コンストラクタは大文字)と整合する。
1.2.2 予約語
字句解析が識別子として認識せず、束縛名・スロット名に使えない予約語は次の 11 語である。真偽値リテラル true / false(§7)と、宣言・照合の構文形 type / enum / match / import / export / loop / conduit / extensible / inner(構文と対象の形は §1.4)。@ 記号はコア言語のどの位置にも登場しない(§1.4)。
true := 5 # エラー: true は識別子ではなくリテラルなので束縛対象にできない type := 5 # エラー: type は予約語(構文形)なので束縛対象にできない
一方、制御構造 if / when / while・自己参照名 self / me / super・演算子などは予約語ではなく、prelude 束縛または書き手が選ぶ普通の名前である。ゆえに再束縛もユーザ名としての利用もできる。
if := 5 # OK: if は普通の名前(prelude 束縛を覆う局所束縛) self := 5 # OK: 自己参照名は予約語ではない(宣言子 inner だけが予約語。§4.2)
1.3 行の扱い
改行は文脈によって意味が変わる。区切りトークン (改行 / ; / ,) は文脈ごとの規則に従って「区切り」または「空白」として働く。
- トップレベル / `{...}` 本体 (body 領域): 改行はシーケンス区切り (
;と同じ)。;も使えるが、改行があれば省略可。区切りが連続・末尾にあっても無視する (末尾;可、空行可)。,はここでは区切りではなく多値 (タプル) 構築を表す (§11.2)。 - `{...}` / `[...]` のスロット宣言領域 (先頭〜body 前のパイプまで。
[...]で body 領域が無ければ全体): 改行・,・;は等価なスロット区切り。,で区切っても、,を省いてスロットを縦に並べてもよい。区切りが連続・前後・末尾にあっても無視する (末尾 `,` / `;` 可、空行可)。この規則はファイルレベル slot-list (§13.1) と同一。 - `(...)` の内側: 要素区切りは
,または改行 (両者は等価)。区切りが連続・前方・末尾にあっても無視する (末尾改行可)。ただし 末尾 `,` は不可 — カンマは後続要素を要求する中置区切りで、(x,)は 1要素タプル(3,)と誤読されうるため許さない (§3.3)。;は()内では区切りにならない。これにより(\n a\n b\n)のようにタプル要素を縦に並べられ、従来のif(cond,\n {then},\n {else})も引き続き書ける。ただし、ある行の最後の有意トークンが右オペランドを要求する二項演算子 (+-*/%==!=<<=>>=&&||->) であるとき、直後の改行は区切りとして働かず吸収され、式は次行へ継続する。二項演算子で終わらない行の改行は従来どおり要素区切りとして働くため、(a &&\n b)は単一式a && b、(a\n b)は 2 要素タプルと明確に区別できる。
いずれの文脈でも、1 つの式 (スロットのデフォルト値・位置スロット・本体の各式・括弧内要素) は改行をまたげない — 改行はその式を終える。例外は上記の (...) 内での二項演算子継続のみで、行末が二項演算子のときに限り式は次行へ続く。複数行に分けたいときは式の途中ではなく区切りの位置で分ける。
1.4 予約語(構文形)
宣言・照合の構文形は予約語である — prelude 束縛の値(if / while 等)とは別系統で、識別子として束縛名・スロット名には使えない(真偽値リテラル true / false と併せた予約語の全体は §1.2.2)。構文形は次の 9 個:
| 形 | 意味 | 参照 |
|---|---|---|
inner name |
inner-name(自己参照名)宣言。slot-list 領域限定 | §4.2 |
extensible[...] / extensible{...} |
shape-extensible なリテラル | §2.4 |
conduit{...} |
制御透過関数(return/break/continue を境界で trap せず素通し) |
§18.3 |
loop{...} |
ユーザ定義ループ(break/continue の捕捉境界。return は素通し) |
§18.4 |
import <対象> := "path.hikari" |
モジュール読込(宣言形。import m := "x" / import [a, b] := "x") |
§13.2 |
type Name := <型式> |
型定義(型エイリアス) | §17.4 |
enum Name := OneOf(...) |
タグ付きデータ定義(直和型) | §17.4 |
match subject { pattern => body } |
構造照合による分岐 dispatch(+束縛・述語ガード)。subject を省くと述語チェーン | prelude.md §8.4 |
export [name, …] |
ファイル外へ公開する member を列挙して限定(無ければ全公開) | §13.2 |
キーワードと対象のあいだの空白は任意 (type Name:=T / type Name := T は等価。import は from 相当を持たず import m := "x" の形)。予約語であるため、これらの語は関数適用の並置 (f x) と曖昧にならず、パーサは構文形として確定できる。
各構文形は 要求する対象の形が構文で固定 されており、合致しなければ 構文エラー (パース時に検出、static-analysis.md §1)。具体的には:
innerはinner name(name は 1 個の識別子、型付きはinner name: T) を要求し、slot-list 領域限定 (§1.5)。name を欠く・body/部分式の位置に現れる・1 リテラルに 2 個以上 → 構文エラーextensibleの後が[/{以外 → 構文エラーconduitの後が{以外 (関数リテラル以外) → 構文エラー (透過は呼び出し挙動の性質で、リスト[...]には意味がないため対象外)importは<対象> := "path"を要求する。対象が bare 名でも[名前リスト]でもない・:=が無い → 構文エラー。:=右辺が 文字列リテラル以外 (変数・補間"${...}"・bare 識別子・任意式) → 構文エラー (動的 import 禁止)matchは「任意の subject 式(省略可)+ arm ブロック{...}」を要求する。subject 位置では末尾ブロックの juxtaposition 適用を無効化し、matchと{の間に式があれば subjectful、無ければ subjectless とする。波括弧で始まる/末尾に波括弧を持つ subject は括弧化する (match ({...}) {...})。arm ブロックに=>を欠く・subject 位置が解釈不能 → 構文エラー (Rust の no-struct-literal 制限と同型)
import の path をリテラル限定にすることで、モジュールグラフを実行前に静的確定できる。
@ 記号はコア言語のどの位置にも存在しない。生 JavaScript に触れる escape hatch は jsgen 専用の std モジュール std:ffi/js(import js := "std:ffi/js" で束縛し js.<member>(...) の形で呼ぶ。hikari build-js 専用でインタプリタでは評価不可)で提供する。サーフェス表・値の写像・認識スコープなどコア言語非依存のバックエンド固有仕様は js-backend.md に集約する。
1.5 文と式
構文形は式 (expression) と文 (statement) に分かれ、別枠として宣言 (declaration) がある。区別の軸は書ける位置(文法上の出現位置)であって、評価値の種類ではない。
式は値を生み、任意の式の位置に書ける — body の要素、部分式(引数・(...) 内・レシーバ)、:= / = の右辺、スロットのデフォルト値など。ほとんどの構文形は式である。制御構造(if / when / while)は prelude 値への適用にすぎず(§3.9)、extensible(§2.4)・conduit(§18.3)・loop(§18.4)・match(prelude.md §8.4)も式。
文は body 領域(§1.3。ファイルトップレベル §13.1 を含む)の要素の位置にのみ書ける。部分式の位置(引数・:= / = の右辺・(...) 内など)に現れると構文エラー。文は次の代入族(§6)のみ:
| 文 | 参照 |
|---|---|
ローカル束縛 name := expr / name = expr(型注釈付き name: 型 := expr を含む) |
§6.1 / §17 |
スロット代入 recv.slot := expr / recv.slot = expr(動的キー recv.[k] も同様) |
§6.2 / §3.10 |
位置 destructure a, b := expr / a, b = expr |
§6.3 |
名前 destructure [a, b] := expr / [a, b] = expr |
§6.3 |
複合代入 lhs op= expr(+= 等) |
§6.4 |
文も評価されると値を持ち、その値は常に unit ()(§6.1)。body 末尾に文を置くこともでき、そのとき body の値は () になる(§2.2)。つまり hikari は文を持つが、文も含めすべての構文形が評価値を持つ。
unit を返すこと ≠ 文であること: 区別は出現位置であって評価値ではない。print(...) / each / else 無し when は unit を返すが式であり、部分式の位置に書ける(x := print("hi") は valid で x は ())。代入族が文であるのは unit を返すからではなく、名前の導入・変更という操作を式の合成の外に置く設計だからである(§6.1)。
同じ表面形でも領域で役割が変わる: slot-list 領域(§1.3)の name := expr / name = expr は文ではなくスロット宣言(§2.1)。名前 destructure・import・type はスロット/文の二相を持ち、slot-list 領域ではスロット宣言、body 領域では文として働く(§6.3・§13.2・§17.4)。位置 destructure は body 領域限定(§6.3)。inner name(inner-name 宣言。§4.2)は逆に slot-list 領域限定で、body 領域・部分式には現れない。
宣言は import / export / type / enum の宣言形(§1.4)を指す。式ではないため部分式の位置には置けず、配置は各節の規則に従う — import はファイルトップレベルのみ(§13.2 / §13.4)、export はファイルレベル slot-list 領域のみ(§13.2)、type / enum の定義位置は §17.4。宣言が文の位置で評価されたときの値も unit () — 代入族の文(§6.1)と同じく、名前を導入する操作は値を生まない。定義した型値は次の文から名前(T 等)で参照する。
2. オブジェクト (値の統一形)
2.1 構文
オブジェクトリテラルの正規形はパイプ1個で2つのセクションを区切る:
{ slot-list | body }
各セクションはそれぞれ独立に空にできる。
slot-list: スロットの列。区切りは,・改行・;のいずれか (すべて等価、連続・前後・末尾を無視。§1.3)。各エントリは次の 4 形式のいずれか:name: 名前スロット (必須・デフォルトなし)。値は immutable (caller が bind した後は書換不可)name := expr: 名前スロット (デフォルトあり、immutable)name = expr: 名前スロット (デフォルトあり、mutable)。本体内・slot アクセスで=更新できる- 上記以外の任意の式 (リテラル, 演算式, 関数呼び出し, 括弧式
(...)等): 位置スロット。値はリテラル評価時に eager に決まり immutable inner name(型付きはinner name: T): inner-name (自己参照名) 宣言 (§4.2)。本体内でこの名前を通じて値自身に到達できる。データスロットではない (obj.nameでは引けず、等価・shape・merge に影響しない)。slot-list 領域限定の特別形 (§1.5) で、1 リテラルに高々 1 つ。innerは予約語 (§1.4)、nameは書き手が選ぶ識別子で常に immutable- 上記いずれの名前スロットも、名前の直後に 型注釈 `: 型` を前置できる (
name: 型/name: 型 := expr/name: 型 = expr)。意味論は §17「型注釈」を参照。v1 では構文のみ受理し評価しない - 名前と位置は同じ slot-list に 混在可能
- bare identifier は常に名前スロット宣言 として解釈される (関数構文
{x, y | x + y}を保つため)。変数値を位置スロットに置きたい場合は括弧(foo)または[foo]糖衣を使う - 位置インデックスは位置スロットだけを頭から
0, 1, 2, ...と番号付け (名前スロットは飛ばす) - 名前スロット (必須 / デフォルトあり immutable / デフォルトあり mutable) は任意の順序で混在可能。位置スロットも任意位置に混ぜられる
- 必須スロットは常に immutable (必須 mutable は表現しない。回しながら使いたければ body 内で mutable local に複写するか default を与える、§6.1)
- スロット全体の空可: slot-list 全体を空にできる (= スロットなし)
- スロットの可変性 (mutable / immutable) と object 全体の shape 拡張可否 (
extensible、§2.4) は直交する別軸 body: 任意の本体コード。複数式は;または改行で区切る。空可 (= 空 body)。
オブジェクトの種類は一つしかない。すべての {...} リテラルは同じ「オブジェクト」を生成する。呼び出し時の振る舞いだけが body の有無で分岐する (§3.6):
- body が非空: body を評価して最後の式の値を返す (関数 / ブロック相当)。末尾の要素が代入族の文 (
:=/=/ スロット代入 / 分解代入 / 複合代入。§1.5) のときは、その文の値である unit()が body の値になる (§6.1) - body が空: 自身 (self) を返す (「データ」として扱われる)
位置スロットと呼び出し: 位置スロットはリテラル時に値が確定するため、呼び出し時の引数 binding 対象には ならない。binding 対象となるのは未束縛の必須名前スロットのみ (§3.5 / §3.6)。
正規形の空・非空の組み合わせはすべて意味を持つ:
| slot-list | body | 例 | 用途 |
|---|---|---|---|
| 空 | 空 | {} (≡ { | }) |
空オブジェクト (call で self) |
| 空 | あり | { | body } (≡ { body }) |
無引数ブロック |
| あり | 空 | { x := 0 | } |
スロット持ちオブジェクト (call で self) |
| あり | あり | { x | body } |
関数 |
inner-name (inner name) はスロットの一つなので上のどの行にも足せる: { inner self, x \| body } は inner-name self を持つ関数、{ inner self, x := 0 \| } は inner-name 付きデータオブジェクト。
位置スロットを含む例:
| 例 | 意味 |
|---|---|
{| 1, 2, 3 |} |
位置 3 個のリスト相当 (data) |
{| 1, x := 2 |} |
位置 1 個 + 名前 x (default 2) の混在 data |
{1, x, 3 | x*2} |
位置 [1, 3] + 名前 x 必須 + body x*2 の関数 (o(9) → 18) |
{inner self, 1, 2 | self.0 + self.1} |
位置 2 個 + inner-name self の関数 (o! → 3) |
字句上の注意: パイプ2個がスペースなしで連続する `||` は論理 OR 演算子トークンとして読まれる。空 slot-list と空 body を明示して { | } と書く場合はパイプの前後にスペースを1個以上入れる ({||} は構文エラー。空オブジェクトは {} とも書ける)。同じ規則は [ | ] にも適用され、[||] も構文エラー。
ファイルレベルとの関係: ファイル全体も暗黙にこの 2 セクション構造を持つ (ただし inner 宣言は持たない)。詳細は §13.1。
2.1.1 省略表現 (shorthand)
頻出する形には短い表記を許す。意味は正規形と完全に同じ:
| パイプ数 | 短い形 | 同等な正規形 | 備考 |
|---|---|---|---|
| 0 | { body } |
{ | body } |
body 非空のブロック |
| 0 | {} |
{ | } |
空オブジェクト (call で self) |
正規形 { slot-list | body } (1-pipe) は各セクションを独立に空にできるため、{ slot-list \| }・{ \| body }・{ \| } はいずれもそのまま正規形である (省略形ではない)。{ \| body } および { \| } の先頭 \| は「空 slot-list セクションの区切り」であり、{ と \| の間、\| と続く要素の間の空白は任意 ({\|body} も同じトークン列)。inner-name は inner name をスロットに書く (§4.2)。
slot-list は名前スロット・位置スロット・両者の混在を許す。{1, 2, 3 |} は位置 3 個のリスト相当、{x, y | x+y} は名前 2 個の関数。
[||] ブラケット形
[…] は {…} と同じ 2 セクション構造 ([ slot-list | body ]) を取る。違いは省略時の意味と bare identifier の解釈だけ:
| パイプ数 | 短い形 | 同等な正規形 | 備考 |
|---|---|---|---|
| 0 | [ slot-list ] |
[ slot-list | ] |
0-pipe `[X]` は slot-list ({X} は body であり、ここが {} との差) |
| 0 | [] |
[ | ] |
空 (≡ [ | ] ≡ { | } ≡ {}) |
正規形 [ slot-list | body ] (1-pipe) は各セクションを独立に空にできる。inner-name は inner name をスロットに書く (§4.2)。
`[]` と `{}` の差分 (bare identifier ルール):
両形の差は 型注釈の付かない bare identifier 1 点だけである。[] 内の slot-list における bare identifier (例: foo) は変数参照 (式) として扱われ、その値が位置スロットに積まれる。{} 内では同じ位置で bare identifier は名前スロット宣言として扱われる。それ以外のエントリ形 — 型注釈付きの名前 (name: 型)・デフォルトあり (name := expr / name = expr)・(任意の式) — の解釈は両者で完全に共通。
| slot-list 内のエントリ | {} での解釈 |
[] での解釈 |
|---|---|---|
foo (型注釈なし bare identifier) |
必須名前スロット foo (immutable) |
変数 foo の値 = 位置スロット |
name: 型 |
必須名前スロット (immutable) | 必須名前スロット (同) |
name := expr / name: 型 := expr |
デフォルトあり名前スロット (immutable) | デフォルトあり名前スロット (同) |
name = expr / name: 型 = expr |
デフォルトあり名前スロット (mutable) | デフォルトあり名前スロット (同) |
任意の他の式 (1, foo+1, (foo) 等) |
位置スロット (immutable) | 位置スロット (同) |
帰結として [] で必須名前スロットを宣言できないのは 型注釈を伴わない場合に限る。型注釈を付ければ [] でも宣言でき、[ x: String ] は { x: String |} と同じ値になる (両形の == は真)。注釈も付けずに必須名前スロットを持つ値を作りたい場合は {} 形を使うこと。
アスクリプションとの優先順位: [] 内の bare identifier は式なので [ n: Int ] は式アスクリプション ((n): Int、§17.1) とも読めるが、slot-list 直下の無括弧 `名前: 型` は常に名前スロット宣言として読む ({} 側と揃える。この位置では n は外側の変数を参照せず、同名の必須スロットを宣言する)。変数の値を位置スロットに積んだうえで型を表明したい場合は [ (n): Int ] と括弧で包む — bare identifier を位置スロットに置くときの (foo) エスケープ (§2.1) と同じ規則である。
bare identifier を含まないオブジェクトリテラル (例: [1, 2, 3]、[1, foo + 1, x := 2] 等) では [1, 2, 3] ≡ [ 1, 2, 3 | ] ≡ { 1, 2, 3 | } のように両形は完全に等価。
ファイルレベルでも {} / [] の 2 形が選べる: ファイル先頭の header #{hikari} / #[hikari] がこの選択を決める (§13.1)。
2.2 body が空のオブジェクト
body セクションを空にしたリテラルは、呼び出されても自身 (self) を返す (§3.6)。「データ」として使うオブジェクトはこの形で書く:
{ slot-list | } # 正規形 (slot-list 非空、body 空)
{ inner name, slot-list | } # 内部名付き
{ inner name | } # 内部名付き・他スロットなし
{ | } # スロットなし (= `{}` と等価)
{} # 同上 (0-pipe 省略形)
慣用的にこの形のオブジェクトを「データオブジェクト」と呼ぶことがある。文法的には他のオブジェクトと同種で、body が空であることだけが区別点。
コンストラクタとしての data object: 必須スロットを残した body 空オブジェクトは、呼び出しで必須スロットを束縛した完成値を返す (§3.6 ステップ5)。定数の判別子 (タグ) は default スロット (t := "int") で与える。これにより { t := "int", v: Int |} のような data object を、関数で包まずそのままコンストラクタとして使える — 入れ子も同名スロットへの転記 (v := v) も不要になる。可変状態を持つオブジェクトが必要な場合は引き続き本体付きの factory 形 ({ { inner self, count = 0 |} }) を使う。
2.3 例
# 関数: スロット + 本体 add := { x, y | x + y } # データオブジェクト: スロット (デフォルトあり) + body 空 point := { x := 0, y := 0 |} # 内部名 (inner) + スロット + 本体 fact := { inner self, n | if(n <= 1, { 1 }, { n * self(n-1) }) } # 無引数ブロック (制御構造に渡す用) block := { "hello" } # 内部名のみの無引数ブロック (他スロット無し) ref := { inner self | self.value } # 必須とデフォルトの混在 mix := { x, y := 0, z | x + y + z } # x, z が必須、y はデフォルトあり # スロットなしデータオブジェクト (= {}) empty_data := { | }
2.4 shape 拡張可能オブジェクト (extensible)
リテラルの開き括弧の前に修飾子キーワード extensible (§1.4) を置くと、その object は shape-extensible (open) になり、リテラル宣言後にも slot を追加できる。extensible{...} と extensible[...] の両方で動く。修飾子なし (default) は現状どおり closed: slot 生成はリテラル宣言時のみ。
shape の二軸: object の "shape" は 名前スロット集合 と 位置スロット個数 の両方を指す。closed (default) は両軸ともリテラル評価時に固定 — 名前 slot の追加 (obj.x := / =、§6.2) も位置 slot の伸縮 (push / pop、§7.4) もできない。extensible (open) は両軸とも後から成長できる。slot 値の immutable / mutable (§4.1) とは引き続き直交する別軸。
キーワードと対象リテラルのあいだの空白は任意 (extensible[...] でも extensible [...] でも可)。修飾は そのリテラル単位で、内側のリテラルへは伝播しない (extensible{x := extensible[] |} の外側は open、内側は別途修飾した extensible[] だけが open)。
open object 上の slot 規則 (frame のローカル束縛 §6.1 と完全対称):
| 操作 | 既存 slot | 既存無し |
|---|---|---|
obj.x := expr |
重複宣言エラー (シャドウ不可) | 新規 immutable slot 追加 |
obj.x = expr |
mutable → 更新 / immutable → エラー | 新規 mutable slot 追加 (auto-vivify) |
closed object 上の slot 規則 (現状維持 + slot mutability):
| 操作 | 既存 slot | 既存無し |
|---|---|---|
obj.x := expr |
ランタイムエラー (closed は slot 追加不可) | ランタイムエラー |
obj.x = expr |
mutable → 更新 / immutable → ランタイムエラー | ランタイムエラー |
位置軸も同じ統治下にある: closed object への push / pop (§7.4) はランタイムエラー。位置 slot を伸縮したい場合は extensible[...] を使う。each / map / filter / fold / first / last / length / positions / names 等の 非破壊な読み取り method は closed でも動く (shape を変えないため)。
obj.slot := expr は 構文としては valid (パース可能)。object が open か closed かは実行時の値の性質であり、パーサは静的に判定できないため、open / closed の振り分けは評価時に行う。動的キー recv.[expr] := v / recv.[expr] = v も同じ規則を適用する (§3.10)。
object の open / closed (shape 軸) と slot 値の immutable / mutable (値軸) は 直交 する。4 通りすべてが書ける:
| object | slot value | hikari | 用途 |
|---|---|---|---|
| closed | immutable | {x := 0 |} |
値型 (slot 固定 + 値固定) |
| closed | mutable | {x = 0 |} |
record (slot 固定 + 値可変) |
| open | immutable | extensible{x := 0 |} |
追記専用ログ (slot 増やせるが各値は固定) |
| open | mutable | extensible{x = 0 |} / extensible[] |
dict-like (slot 拡張可 + 値可変) |
# 値型 (slot 固定 + 値固定): 素の { ... } / [...] で十分 p := { x := 3, y := 5 |} p.x = 99 # ランタイムエラー (immutable slot) # record (slot 固定 + 値可変) counter := { count = 0 |} counter.count = counter.count + 1 # OK # dict-like (slot 拡張可 + 値可変) cache := extensible[] cache.user_42 = { name := "alice" |} # 新規 mutable slot cache.user_42 = { name := "bob" |} # 同じ slot を上書き # 拡張可だが値は immutable に固定 (記録系) log := extensible[] log.entry_1 := "started" # 新規 immutable slot log.entry_1 = "changed" # ランタイムエラー (immutable)
ファイルトップレベルの暗黙リテラル (§13.1) は引き続き closed で、extensible は適用できない。
2.5 全値に生える universal method
一部の method は受け手の型に依らず全ての値で解決される (原始型・コレクション・関数・タグ付きデータを問わない)。「値の統一形」の帰結で、いずれも受け手の同名スロットで上書きでき (§8.6)、reflect.names(v) (prelude.md §11) に全値で現れる。
| method | 形 | 役割 | 詳細 |
|---|---|---|---|
matches |
v matches T / v.matches(T) |
型テスト。Bool を返す | §17.4 |
compare |
a.compare(b) |
順序比較。Ordering を返し < <= > >= を導出 |
§9.4 / prelude.md §14 |
inspect |
v.inspect() / v.inspect! |
人間可読なデバッグ表現。String を返す | prelude.md §15 |
reference_equals |
a.reference_equals(b) |
参照同一性。同一インスタンスなら Bool true。== (値等価) と独立 |
§9.1 |
copy |
v.copy() / v.copy! |
現在状態を持つ独立コピー。可変部を作り直し不変部は共有 | §9.5 |
-> |
a -> b / a.->(b) |
ペア構成。2-tuple (a, b) を返す。Smalltalk の Association に相当 |
§8.3 |
- 呼び出し時の型要件は method ごとに違う:
matchesの右辺は型値、compareは受け手と引数が相互に Comparable でなければ panic (§9.4)、copyは受け手が Copyable でなければ panic (§9.5)、inspect/reference_equals/->は全値で成功する (reference_equalsは §9.1)。 - `->`:
a -> bはa.->(b)に脱糖する (§8.1) universal method で、常に 2-tuple(a, b)を返す。全値がこの baseline 実装を持ち、他の universal method と同じ規則で受け手の `->` スロットがあればそちらが優先される (reflect.names(v)にも全値で現れる)。結果はタプルなので位置アクセス (("a" -> 1).0) や destructure ({k, v | …}) がそのまま効く。 - 構造照合による分岐 dispatch は universal method ではなく `match` 構文 (§1.4、prelude.md §8.4)。
matches(型テスト) だけが universal method として残り、v.matchの第一級取り出し・reflect.namesへのmatch露出・受け手matchスロットによる上書きは持たない。 - 二項演算子 (
+ - * / % == != < <= > >=) も同様に全値で解決される (型チェックは呼び出し時。§8)。 - これらと別系統の 軸 universal method — 位置軸 (
each/map/filter/fold/first/last/positions/has_positionほか) と名前軸 (names/values/has_name) の読み取り method (§7.4) — は「その軸を持つオブジェクト」で動くもので、対象 (全値ではない) が異なる。
3. 呼び出しと適用
3.1 概念モデル
hikari の関数は概念的に常に1引数を取る。スロット宣言 {x, y | ...} はその1引数を destructure するパターンを表す。これを軸に次のルールが組み合わさる:
- 並置 = 関数適用:
f xはfにxを適用 (Haskell 流) - タプルは複数要素のみ:
(a, b, c)はタプル、(x)は単純グルーピング、()は空タプル - 自動 destructure / 値束縛: 関数にタプルを適用すると、未束縛スロット総数と一致すれば destructure される。未束縛スロットが 1 つだけなら、タプルはそのスロットへ値として丸ごと束縛される(destructure しない)
- auto-curry: すべての名前スロットが埋まらない値適用は部分適用 (新しいオブジェクト) を生む。デフォルトの補完は明示的 kick (
()/!) でのみ起こる - postfix `!`:
v!はv()の短縮 — 「引数なしで呼ぶ」を最短で書ける - `f(a, b)` はタプルの適用: カンマ区切りの呼び出し
f(a, b, …)はタプル(a, b, …)を 1 引数として適用するのと同じ。f((a, b))の外側括弧は単なるグルーピングで、両者は同義
3.2 構文
f x # 並置で適用 (x を渡す) f(x, y) # タプル (x, y) を渡す → R=1 なら丸ごと束縛、R≥2 なら destructure (§3.5) f.method x # method を取り出してから x を適用 receiver name x # receiver.name x の糖衣 (中置メソッド) → receiver.name(x) # name は receiver の slot → 型メソッドレジストリ の順で解決 # (= receiver.name と同一規則)。外側スコープの同名は引かない recv.[expr] # 動的スロットアクセス。expr の評価値で名前/位置スロットを引く (§3.10) recv.[expr] = v # 動的アクセスでの代入。mutability / open-closed 規則は §3.10 / §6.2 v! # v() と等価 (引数なしの postfix 短縮)
3.3 タプルとグルーピング
(...) の意味は要素数だけで決まる:
| 形 | 意味 |
|---|---|
(e) |
単純グルーピング — 式 e をそのまま返す。タプルではない |
() |
unit — void を表す 0 要素タプル値 (print / each / else 無し when / 代入族の文 §6.1 等の戻り)。() 単独は不活性な値で、評価を駆動するのは並置 (適用) |
(e1, e2) |
2要素タプル |
(e1, e2, e3, ...) |
N要素タプル |
1要素タプル表記は持たない。(3,) 等は字句的に許可しない。
要素の区切りは , でも改行でもよい (§1.3)。(\n a\n b\n) は (a, b) と同じ 2要素タプル。要素が1つだけなら、改行で囲ってもグルーピングのまま — (\n a\n) は (a) と同じ単純グルーピングで、タプルにはならない (1要素タプルを持たないため。末尾改行は区切りとして無視される)。行末が二項演算子のとき (a +\n b 等) は式が次行へ継続する (§1.3)。二項演算子で終わらない改行はその式を終える。
3.4 例
v := { x, y | x + y }
v 3 2 #> 5 並置で curry: ((v 3) 2)
v(3, 2) #> 5 タプル (3,2) を destructure
v(3)(2) #> 5 部分適用してから 2
v 3 #> 部分適用 (y を待つオブジェクト)
t := (3, 2)
v(t) #> 5 タプル変数を destructure
v t #> 5 並置でも同じ
one := { value := 1, add := { other | value + other } |}
one.add(2) #> 3
one add 2 #> 3 中置: one.add 2 と等価
3.5 引数の束縛規則
引数 arg を関数 f に適用するとき、f の 未束縛の名前スロット数を R とする (位置スロットはリテラル時に値が確定しており、呼び出し時の binding 対象ではない)。元関数を直接呼ぶときは R = 名前スロット総数 N、部分適用ならその時点で残っている名前スロット数。
| arg の形 | 規則 |
|---|---|
| R = 1(任意の arg、タプル含む) | その 1 スロットへ丸ごと束縛(destructure しない) |
| R ≥ 2 かつ R-要素タプル | destructure: 各要素を未束縛の名前スロットに前から順に束縛 |
| R ≥ 2 かつ単一値 (非タプル) | 最初の未束縛の名前スロットに束縛(部分適用=curry) |
| R ≥ 2 かつ K-要素タプル (K ≠ R) | エラー (引数数不一致) |
| R = 0 かつ単一値・タプル | エラー (束縛先のスロットがない) |
() 空タプル (unit) |
名前スロットを一切埋めない(引数なし呼び出しと等価。v! ≡ v()) |
「未束縛の名前スロット数」を基準とすることで、通常の呼び出しと部分適用への追加適用が同じ規則で説明できる。位置スロットは常に値で満たされているため binding 対象に入らず、o := {1, x, 3 |}; o(9) のような混在ケースも単一値ルールで x に bind されて自然に動作する。例えば if x {y} {z} は段階的な単一値適用で動く: 1回目で cond が埋まり、2回目で then が、3回目で else が — 毎回「最初の未束縛の名前スロット」に入る。
R = 0 のオブジェクト (位置スロットのみ、または全名前スロットが既に bound) に単一値を渡すと、束縛先がないためエラーになる。{} 5 や [1, 2, 3] "x" は引数数不一致 (実質 K = 1 ≠ R = 0) として扱う。() または ! を使えば引数 0 個として正しく扱われ、body 空オブジェクトなら §3.6 ステップ5 で self が返る。
注 (引数の可変性): caller から bind される値は called frame の必須スロット (immutable、§2.1) に入るため、本体内で引数を=で再代入することはできない (Haskell / OCaml / Rust と同じ)。引数を回しながら使いたい場合はn = arg + 1のように mutable local へ複写する (§6.1)。
注 (タプルの位置づけ): 未束縛スロットが 1 つの関数{pair |}に(3, 5)を適用すると、タプルが値としてpairに丸ごと束縛される(R=1)。f(a, b)はタプル(a, b)の適用と同義なので、{pair |}(3, 5)も{pair |}((3, 5))もpair = (3, 5)になる。タプルを destructure したいときは未束縛スロットを 2 つ以上にする({x, y |}(3, 5)でx=3, y=5)。
注 (破棄子 `_`): slot パラメータの bare な_は引数を 1 つ受け取って捨てる (束縛しない、§6.1)。{_, x | x}は第 1 引数を無視して第 2 引数を返す。_は未束縛名前スロット数 R に算入されるため arity / 部分適用の判定は通常スロットと同じ ({_, _ | …}は 2 引数を要求する)。_に default を与えた{_ := d | …}は破棄ではなく「_という名の optional slot」になる。
注 (コールバックへの要素適用):map/and_then/each/fold/filter等の高階ライブラリメソッドがブロックへ「1 要素 (payload)」を渡すときは、その要素を単一値としてブロックのパラメータに束縛する (R=1 は丸ごと、R≥2 は要素タプルを destructure)。表層の明示 kickf ()(この表の最終行) と異なり、要素が unit `()` でも 1 個の引数として最初のパラメータに束縛される (0 引数 kick に潰れない)。これによりResult(Unit)の payload をr.map {x | …}/r.and_then {x | …}で受け取れ、unit 要素の List もxs.map {x | …}で写せる。()が「明示 kick」になるのは表層の関数適用に限る — コールバックは常に「要素を 1 個渡す」意味論で動く。
3.6 呼び出しのセマンティクス
呼び出しはステートレスである。元の値は変更されない。
- 引数を §3.5 のルールでスロットに束縛 (フレッシュなフレーム)
- 本体評価か部分適用かを判定する。判定軸は「全名前スロットの束縛」と「明示的 kick」の 2 つで、宣言形 (必須かデフォルトあり) には依存しない:
- 値適用 (single value / タプル) ですべての名前スロット (デフォルトの有無を問わず) が束縛されたなら → 本体評価 (3 へ)
- 明示的 kick (
()/!、引数 0 個) なら → デフォルトを持たない必須スロットが未束縛で残れば error、残らなければ本体評価 (3 へ) - それ以外 (値適用したが未束縛の名前スロットが残る) なら → 部分適用 (4 へ)
- 本体評価: 未束縛のままデフォルトを持つスロットに default 値を入れ (補完が起こるのは明示的 kick の経路のみ — 値適用で本体評価に至る時点では全スロットが束縛済み)、本体を評価して結果を返す
- 部分適用: 新しいオブジェクトを返す。元の関数への参照と「束縛済みスロット名→値」のマップを保持する
- body が空の場合は、ステップ 3 で本体を評価する代わりに、束縛を反映した値を返す。引数で必須スロットを束縛したなら、その束縛を持つ新しい値 (完成した data object) が返る。束縛が一つも増えない呼び出し (
!/()、R=0) では値自身 (self) をそのまま返す。引数の検証 (ステップ 1 = §3.5 の引数数チェック) はそのまま行われ、引数の形が不正なら通常のエラーになる
注 (デフォルト補完と curry 化判定の統一): 部分適用か本体評価かは「全名前スロットが束縛されたか」だけで決まり、スロットがデフォルトを持つか否かに依存しない。よって{x, y | ...}も{x := 0, y := 0 | ...}もf 3は等しく部分適用になる。デフォルトの補完は明示的 kick (`()` / `!`) に固有の意味論で、f!/f(3)!のときだけ未束縛スロットへ default が入る。これにより部分適用の挙動が宣言形に依らず統一される。代償として、デフォルトを当てにした OO コンストラクタは明示的 kick (make_point(3)!) か全引数指定 (make_point(3, 0)) が要る。なお body 空 data object のデフォルトはリテラル時に確定束縛されるため (§12)、この curry 化判定は本体付き関数にのみ効く (下記all_defaults参照)。
位置スロットは binding 対象外であるため、{ 1, 2, 3 | }等の位置のみリテラルは『名前スロットなし』として扱われ、o!/o()は引数 0 個の検証を通って body 空のため self を返す。混在{1, x, 3 | ...}の場合は名前スロットxのみが束縛/部分適用判定に関与する。body 空オブジェクトを引数付きで呼ぶ場合の振る舞いは §3.5 の引数検証規則に従う —{}!/{} ()は self、{} 5/{} (1, 2)は引数不一致エラー。
v := { x := 0, y := 0 | x + y }
v.x #> 0 (デフォルトを読む)
v! #> 0 (明示的 kick → 全デフォルト補完して本体評価)
v(3, 2) #> 5 (全名前スロット束縛 → 本体評価)
v.x #> 0 (変わらない、ステートレス)
v(7, 1) #> 8 (何回でも)
v 3 #> 部分適用 (x=3、y 未束縛。デフォルトは kick まで補完されない)
(v 3)! #> 3 (明示的 kick → y にデフォルト 0 を補完して本体評価)
(v 3) 4 #> 7 (残り 1 スロットを値適用 → 全束縛 → 本体評価)
obj := { name := "john" |}
obj! #> obj 自身 (body が空)
obj() #> obj 自身 (同上)
{}! #> {} 自身 (body が空)
{ | }! #> { | } 自身 (同上、`{}` と等価)
{} 5 #> error (R = 0 に単一値)
{} (1, 2) #> error (R = 0 に 2-tuple)
[] "x" #> error (R = 0 に単一値)
# データオブジェクト (body 空) は default 値がリテラル時に Bound へ確定するため R = 0。
# 後付けで引数を渡しても束縛先がなくエラー (#3.6 例の `obj 5` も同じ)
all_defaults := { x := 0, y := 0 |}
all_defaults 3 #> error (R = 0、x はリテラル時に 0 で確定済み)
# 必須スロットだけの body 空オブジェクトは R ≥ 1 を持つ。
# 引数で bind した結果は body 空のため self を返す (新しい部分適用 / 完全束縛オブジェクト)
pair := { x, y |}
p1 := pair 3 #> body 空・x := 3 を bind した部分適用オブジェクト
p1.x #> 3
pair(3, 5).x #> 3 (body 空・x, y 両方 bind した完成 data object)
# 定数 default tag + 必須スロットの body 空オブジェクトは「コンストラクタ」になる。
# 完全適用すると必須スロットを束縛した完成値が返り、部分適用は curry になる。
n_bin := { t := "binop", op, l, r |}
n_bin("+", 1, 2).op #> "+" (op, l, r を束縛した完成 data object)
n_bin("+", 1, 2).t #> "binop" (default tag はそのまま)
n_bin "+" #> 部分適用 (op := "+"、l, r 未束縛)
3.7 部分適用の値表現
部分適用は新しいオブジェクトとして表現される:
- 元関数への参照を保持
- 束縛済みスロットの「名前 → 値」マップを保持
部分適用に対するスロットアクセス:
- 束縛済みスロットは読める:
add5 := add 5; add5.x #> 5 - 未束縛スロットを読むとエラー (object.Error)
add := { x, y | x + y }
add5 := add 5 # 部分適用、x = 5、y は未束縛
add5.x #> 5
add5.y #> error (未束縛スロット)
add5(10) #> 15
add5 10 #> 15
3.8 多値返却
, で区切られた値のリスト (シーケンスの最終位置) はタプルとして返る。「多値返却」は単に , でタプルを作って返している実態。
多値返却は return(a, b) でよい(タプル 1 個の適用 = R=1 の return へ丸ごと束縛。return((a, b)) も同義)。
v := { x, y | x + y, x - y }
r := v(5, 3)
r #> (8, 2) タプル値
r.0 #> 8
a, b := v(5, 3) # 分解代入
a #> 8
b #> 2
3.9 制御構造への波及 — if と when
if は 必須3スロットとして定義される (組み込み):
if := { cond, then, else | ... } # 全スロット必須、デフォルトなし
これにより if x {y} {z} の段階 curry が自然に成立する:
if x→ cond=x、then と else が未束縛 → 部分適用(if x) {y}→ then={y}、else 未束縛 → 部分適用(if x {y}) {z}→ else={z}、必須全て埋まる → 本体評価
タプル形 if(x, {y}, {z}) も同じ意味。
else を持たない 2-arg ヘルパとして when を別途定義する (prelude):
when := { cond, then | if(cond, then, { () }) }
when c.not! {print "foo"} のように、else を省略したい用途で使う。
3.10 動的スロットアクセス
recv.[expr] は expr を評価した値をキーとして、recv のスロットを動的に引く。recv.name (§3.2) の名前部分を「リテラル識別子・整数」から「式の評価結果」に拡張したもの。
評価規則:
recvを評価するexprを評価して値kを得るkの型で分岐:- String:
recvの名前スロット軸をkの文字列で引く (recv.<k>と同じ。スロット → 型メソッドレジストリの順) - Integer:
recvの位置スロット軸をkの整数で引く (recv.<k>と同じ) - その他の型 (Bool/Tuple/オブジェクト): 実行時エラー
- キーに対応するスロットも型メソッドも見つからなければ実行時エラー
等価性:
m := { x := 1, y := 2 |}
m.["x"] #> 1 (≡ m.x)
m.x #> 1
m.["z"] #> error (no such slot or method: z)
xs := [10, 20, 30]
xs.[0] #> 10 (≡ xs.0)
xs.[1+1] #> 30 (キー部分は任意の式)
識別子として不正な文字列もキーとして使える:
m := { x := 1 |}
key := "x"
m.[key] #> 1 (動的キーで読む)
m.["my-key"] #> error (no such slot or method: my-key)
# スロット名規則 (§1.2) は変えない — `my-key` という名前のスロットは作れない。
# 動的アクセスで存在しないキーを引くと §3.7 と同じ「未束縛/不在」エラー
ただし recv.my-key のような不正識別子は依然パース時エラー (§1.2)。「ドット記法は識別子のみ、ブラケット記法は任意文字列」と覚える。
型による制約:
m.[true] #> error (key must be String or Integer, got BOOLEAN) m.[(1, 2)] #> error (key must be String or Integer, got TUPLE)
結合性・優先順位: . と同レベル (§8.3 表の項 9)。左結合: a.[k1].[k2] は (a.[k1]).[k2]。f x.[k] は f (x.[k]) (.[] は並置より強い)。
空白の扱い: recv.[k] / recv .[k] / recv. [k] / recv . [ k ] はすべて等価 (§8.3.1)。
代入: recv.[expr] = v / recv.[expr] := v は、expr の評価値 (String/Integer) で引いたスロットに §2.4 / §6.2 の open/closed・mutability 規則を適用する。recv.[expr] := v は構文として valid だが、意味を持つのは open object のときだけで、closed object ではランタイムエラー。
m := { x = 1 |} # x は mutable
m.["x"] = 99 # OK (既存 mutable スロット)
m.x #> 99
p := { y := 1 |} # y は immutable
p.["y"] = 2 # ランタイムエラー (immutable slot)
p.["z"] = 1 # ランタイムエラー (closed: no such slot)
c := extensible[] # open object
c.["k"] := "v" # OK (open に新規 immutable slot を追加)
c.["k"] = "w" # ランタイムエラー (immutable slot)
ユーザー定義によるオーバーロードは持たない。recv.[k] の意味論は組み込み (= 名前/位置スロットの動的検索) に固定。
存在確認との対: recv.[k] は名前不在のとき実行時エラーになる (組込型メソッド・演算子は名前として解決される)。引く前にスロットの存在を確かめたい場合は recv.has_name(k) (k は String、名前軸) / recv.has_position(i) (i は Integer、位置軸) を使う (prelude.md §6)。スロット名の全列挙は recv.names! / recv.positions!。recv.[k] がメソッド・演算子も含めて解決するか / 名前で解決できる全列挙は reflect.has_name(recv, k) / reflect.names(recv) (prelude.md §11)。
4. スロットと自己参照
4.1 スロットの可変性
スロットには mutable と immutable の 2 種類があり、どちらで宣言したかは slot-list の形で決まる (§2.1):
name(bare 必須) /name := default/ 位置スロット → immutablename = default→ mutable
immutable slot を = で書き換えるとランタイムエラー。mutable slot は = で更新できる。値は参照共有のセマンティクス (JS/Python/Ruby 流) を持ち、mutable slot 経由の更新は共有先からも見える。
obj := { name := "john", score = 100 |} # name は immutable、score は mutable
obj.score = 200
obj.score #> 200
obj.name = "bob" #> ランタイムエラー (immutable slot)
foo := obj
foo.score = 999
obj.score #> 999 # 参照共有 (mutable slot)
可変性は shallow — immutable slot が指す先が mutable な List / Object であれば、その中身は依然変更できる (slot 自体の再代入だけが禁じられる)。slot を実行時に追加したい場合は open object (extensible、§2.4) を使う。
4.2 自己参照 (inner-name)
slot-list に `inner name` を書くと内部名を宣言でき、本体内でその名前を通じて値自身に到達できる。inner は予約語 (§1.4)、name は書き手が選ぶ識別子で、self/this/me など任意 (予約語は持たない)。宣言形のため代入演算子を持たず、inner-name は常に immutable (self = ... のような内部名そのものへの代入はできない — 自身を別の値に差し替える意味は持たせない)。inner name はデータスロットではなく (obj.name では引けず、等価・shape・merge に影響しない)、slot-list 領域限定の特別形 (§1.5) で 1 リテラルに高々 1 つ。
obj := { inner self, value := 100 |
self.value + 1 # 自身のスロットを self 経由で参照
}
内部名は再帰関数の自己参照にも使う:
fact := { inner self, n |
if(n <= 1, { 1 }, { n * self(n-1) })
}
:= の右辺評価中、左辺名 (この例では fact) はまだ未束縛である。再帰したい場合は inner-name を使う (letrec セマンティクスは持たない)。
4.3 レキシカルスコープによる外側参照
外側のオブジェクトリテラルが内部名を持っていれば、内側からその名前で外側に到達できる。これは特別な仕組みではなくレキシカルスコープそのもの。parent も super も予約語ではなく、外側リテラルがその名前を選んだだけ。
outer := { inner parent, value := 1 |
inner := { inner self, value := 100 |
self.value + parent.value
}
inner() #> 101
}
5. 名前解決
本体内で裸の名前 x が参照されたとき、以下の順に探索する:
- 呼び出しフレームの束縛 (引数や本体内
:=で導入されたローカル) - 自身のスロット (ファイルレベルでは slot-list 領域の slot も同列)
- 自身の内部名 (宣言されていれば)
- 外側リテラル群のスロット・内部名・本体ローカル (レキシカルチェイン)
§13.1 の評価モデルにより、ファイル全体は暗黙のオブジェクトリテラル (header に応じて { slot-list | body } または [ slot-list | body ]) として扱われる。ファイル内のトップレベル body の束縛 (:=) は最外側の呼び出しフレームのローカル、ファイル先頭の slot-list 宣言は最外側リテラルのスロットとして、それぞれステップ 1 と 4 (外側レキシカル) で解決される。
裸の value と self.value は同じ意味で、両方有効。self.value を書くのは読み手への明示のため。
6. 代入演算子
6.1 ローカル束縛: := と =
name := expr: immutable なローカル宣言を作る。同じ呼び出しフレーム / 同じリテラルのスロット宣言内に既に同名があればエラー (シャドウ不可。既存が mutable local でも「mutable がある所への immutable 宣言」として重複宣言エラー)。外側スコープ (別のオブジェクトリテラル) の同名を「結果として隠す」のは可 — 別スコープなので衝突ではないname = expr: mutable。スコープチェインを上に探索し:- 既存 mutable が見つかれば 更新
- 既存 immutable が見つかれば ランタイムエラー
- どこにも無ければ 現フレームに新規 mutable local を作る (auto-vivify)
= は「宣言」と「再代入」を区別しない。mutable は記述側から常に同じ操作で扱える (Python 流の auto-vivify と Pascal 流のスコープ探索の組み合わせ)。auto-vivify が効くのは ローカル束縛だけで、slot アクセス (§6.2) では効かない。
_ := expr/_ = expr: 名前が_(破棄子) のときは 右辺を評価したうえで何も束縛しない。_は環境に入らないため、同一スコープで何度でも書けて (:=のシャドウ不可の例外)、あとで読むと未定義 (undefined: _) になる。「捨てた」ことが構文上保証される。破棄が効くのは bare な宣言順束縛の左辺のみ —_に値を与えた slot ({_ := d |}/{_ = 1 |}) やobj._、名前 destructure[a, _]では_は通常の識別子のまま (§6.3 / §3.5)。
代入族の文の値は unit `()`: := / = による束縛・代入は、評価すると常に unit () を返す (§3.3) — 右辺の値ではない。束縛は「名前を導入する」操作であって値を生む式ではない、という立場を値でも表す (Rust の let・Scala の val と同じ)。破棄子 _ := expr / _ = expr も同じく ()。したがって body 末尾が代入族の文のとき、body の値は () になる (§2.2)。この規則はスロット代入 (§6.2)・分解代入 (§6.3)・複合代入 (§6.4) にも一様に及ぶ。右辺の短絡・エラー伝播 (? や右辺自体のエラー) は束縛前に生じるため従来どおりその制御値を伝播し、正常完了時のみ () を返す。なお代入族は文であり、body の要素の位置にのみ書ける — 部分式の位置 (引数・:= / = の右辺・(...) 内) に現れると構文エラー (§1.5)。
v := { x, y |
sum := x + y # immutable 宣言 (シャドウ不可: sum := を再度でエラー)
x = 99 # ランタイムエラー: x は必須スロット (immutable)
sum
}
# 引数を回しながら使う: mutable local に複写する
counter_step := { count |
n = count + 1 # 新規 mutable local (count は immutable のため複写)
n = n + 1 # 既存 mutable を更新
n
}
6.2 スロットアクセス: = と :=
スロットアクセスの代入は、object が closed (§2.4 の default) か open (extensible) か、および対象 slot の mutability で決まる。
closed object (slot はリテラル宣言時のみ生成):
obj.slot = expr: 既存 mutable → 更新 / 既存 immutable → ランタイムエラー / 未定義 → ランタイムエラーobj.slot := expr: ランタイムエラー (closed は slot 追加不可)
open object (extensible{...} / extensible[...]、ローカル束縛 §6.1 と対称):
obj.slot := expr: 既存 → 重複宣言エラー / 未定義 → 新規 immutable slot 追加obj.slot = expr: 既存 mutable → 更新 / 既存 immutable → エラー / 未定義 → 新規 mutable slot 追加 (auto-vivify)
obj.slot := expr は構文として valid (パース可能)。open / closed は実行時の値の性質であり、パーサは静的に判定できないため、振り分けは評価時に行う。
obj := { name = "john" |} # name は mutable
obj.name = "bob" # OK (既存 mutable スロットを更新)
obj.score = 30 # ランタイムエラー (closed: 未定義スロット)
obj.score := 30 # ランタイムエラー (closed: slot 追加不可)
p := { id := 1 |} # id は immutable
p.id = 2 # ランタイムエラー (immutable slot)
cache := extensible[] # open object
cache.user_42 = "alice" # OK (新規 mutable slot を auto-vivify)
cache.token := "xyz" # OK (新規 immutable slot)
closed object で slot を = / := により後付け追加できないのは、タイポによる事故を防ぐため (ローカル束縛 §6.1 と同じ厳密さ)。slot を実行時に増やしたい用途は open object を明示的に選ぶ。
動的キーによる代入 (§3.10):
recv.[expr] = v / recv.[expr] := v も上と同じ規則に従う。expr の評価値 (String/Integer) で引いた slot に対して open/closed・mutability を判定する。
m := { name = "john" |} # mutable
m.["name"] = "bob" # OK
m.["age"] = 30 # ランタイムエラー (closed: no such slot)
c := extensible[]
c.["age"] := 30 # OK (open に新規 immutable slot)
スロット代入も文の値は unit () (§6.1)。代入対象が無い・immutable などのエラーは従来どおり伝播し、成功時のみ () を返す。
6.3 分解代入
分解代入は 2 形 (位置 / 名前) あり、それぞれ := (導入するローカルは immutable) と = (mutable、§6.1 の = 規則どおり既存があれば更新・無ければ新規) の両版を持つ。
| 形 | 意味 |
|---|---|
a, b := expr (位置 destructure) |
a, b を immutable で導入 |
a, b = expr |
a, b を mutable に (既存更新 / 無ければ新規) |
[name1, name2] := value (名前 destructure) |
各 name を immutable で導入 |
[name1, name2] = value |
各 name を mutable に |
分解代入も文の値は unit () (§6.1) — 右辺のタプル/オブジェクトではない。
位置 destructure: a, b := v(...) で多値返却 (タプル) から複数のローカルを位置順に一度に導入する。
位置 destructure の左辺に _ を置くと、その位置の値を捨てる (束縛しない、§6.1)。a, _ := (1, 2) で第 2 要素を破棄でき、_ も 1 ポジションを占めるため要素数の厳密一致 (§3.5 / 原則 4) は変わらない。_, _ := pair のように複数捨ててもよい。一方、名前 destructure `[a, _] := obj` では `_` は破棄子にならず、「slot 名 _ を抽出」する通常 lookup になる (by-name 選択は要らない名前を列挙しなければ済むため、破棄概念が不要)。
名前 destructure: オブジェクトから複数 slot を同名のローカルに一括束縛する:
[name1, name2, ...] := value
- 左辺は 0-pipe
[]形のみ。[name1 | body]のような|入り形は不可 - 左辺の bare identifier は「抽出する slot 名 = 束縛するローカル名」として解釈
- 右辺のオブジェクトに該当 slot が無ければ実行時エラー (§3.7 の未束縛 slot エラーと同種)
- リネーム形 (
[x := value.foo, ...]等) は不採用。リネームが必要ならx := value.fooを個別に書く - 左辺の
[...]は他の slot-list (§1.3) と同様、要素を改行で区切ってもよい (カンマと改行のいずれも区切り)。名前が多いとき縦に並べられる:
[ name1 name2 name3 ] := value
import mod := "math.hikari" [sqrt, log] := mod # mod.sqrt → sqrt, mod.log → log (immutable) import [sqrt, log] := "math.hikari" # 中継無しに直接書ける (import 宣言形。§13.2)
スロット/文の二相: 名前 destructure [a, b] := expr は name := value や type(§17.4)と同じくスロット/文の二相を持つ。オブジェクトリテラルの slot-list(ファイルトップレベルを含む。§2.1)に置くと、抽出した各名前を immutable な名前スロットとして宣言する — 右辺 expr をその slot-list の宣言環境で 一度だけ評価し、各 slot 値を取り出して、(1) 後続スロットの宣言(デフォルト式・型注釈)から参照でき、(2) 他の name := … と同様にオブジェクト/モジュールのメンバ(export)になる。文/ブロック body や REPL ではローカルを導入する文として働く(上記)。import(§13.2)も同じ二相を持ち、これにより #[hikari] ライブラリ先頭で import [Token] := "lexer.hikari" と書いて、後続スロットの型注釈や本体から Token を参照できる。
二相を持つのは 名前 destructure(`[…]` 形)のみ。位置 destructure a, b := expr は body/文でのみ使える — slot-list 内の a, b := expr は「必須スロット a + デフォルト付きスロット b := expr」という関数スロット構文(§2.1)と衝突するため slot-list エントリにはならない([…] 形は [ 始まりで曖昧が無い)。
6.4 複合代入
lhs op= rhs は「読んで・演算して・書き戻す」(read-modify-write) の糖衣。対象演算子は算術 5 種 += -= *= /= %= のみ (論理 && / || の複合形は持たない)。
意味論 (place は 1 回だけ評価する):
lhsの場所 (place) を 1 回評価する。obj.slotならobjを、obj.[key]ならobjとkeyを左→右で 1 回ずつ評価する- その場所から現在値
curを読む rhsを評価するcur op rhs(=cur.op(rhs)のスロット呼び出し、§8.1) を計算する- 結果を同じ場所へ
=の規則 (§6.1 / §6.2) で書き戻す
ローカルでは i += n は i = i + n と完全に同一。op がスロット呼び出しに脱糖されるため、ユーザー定義型で + 等のスロットを実装していれば複合代入もそのまま効く。複合代入も他の代入族と同じく文の値は unit () (§6.1) — 書き戻した新値ではない (下の例のコメント → 5 等は変数 i の値であって文の値ではない)。
左辺は = と対称に、ローカル i / スロット obj.slot / 動的キー obj.[key] を取れる。= の意味論のみで、: を伴う宣言形 (:+= のような記法) は持たない。単一ターゲットのみで、分解形 (a, b += ...) は不可。
規則の帰結として現れる挙動:
- 読みが先に起きるため、対象は既存で読める必要がある。未束縛ローカルへの
i += 1はundefined: i、未定義スロットへの複合代入はスロット無しエラーになる (i = 1のような auto-vivify は起きない) - 書き戻しは
=の規則に従うため、immutable なローカル / スロットへの複合代入はエラー。closed object に複合代入で新規スロットは追加されない - 型エラー (例:
true += 1) は内部のop呼び出しからそのまま表出し、手書きのtrue + 1と同一のエラーになる
複合代入演算子は字句上 1 トークンで、演算子記号と = は隣接必須 (i += n は可、i + = n は + と = に分かれて構文エラー)。これは || と | | の区別と同じ字句レベルの規則で、§8.3.1 の「空白は優先順位に影響しない」とは独立。
i = 0 i += 5 # i = i + 5 → 5 i *= 3 # 15 i -= 1 # 14 i %= 4 # 2 obj := { n = 10 |} obj.n += 5 # obj.n = obj.n + 5 → 15 cache := extensible[] cache.x = 1 cache.x += 2 # 3 s = "a" s += "b" # "ab" (+ は String 連結)
7. 原始型と基本リテラル
すべての原始型はオブジェクトとして扱われる (Smalltalk スタイル)。内部表現は最適化のため特殊化されるが、ユーザーから見える意味論は統一されている。
| 型 | リテラル例 | 備考 |
|---|---|---|
| 整数 | 0、42、-5、0xFF、0b1010、0o755、1_000 |
任意精度 (桁数上限なし。int64 に収まる値は内部で int64 表現に最適化)。型名は Int。基数 prefix (0x/0b/0o) と桁区切り _ は §7.1.1 |
| 浮動小数 | 3.14、0.5、1e10、1.5e-3 |
IEEE 754 倍精度 (float64)。型名は Float。リテラルは小数点の両側に数字を要求 (§7.1.1)。NaN / Inf は値として存在しうる (§7.1) |
| 文字列 | "hello"、"hi, ${name}"、"""...""" |
ダブルクォートのみ。要素単位は Unicode code point (rune)。エスケープ \n \t \r \\ \" \$ \u{…} のみ。補間 ${expr} あり。複数行は """..."""(§7.2) |
| Bytes | (リテラル無し) | 不変のバイト列。s.to_bytes! / std:fs の read_bytes(path) で得る。要素は Integer 0〜255 (§7.2.2) |
| 真偽値 | true、false |
|
| unit | () |
void を表す 0 要素タプル値 (§3.3)。print / each / else 無し when / 代入族の文 (§6.1) 等の戻り |
| リスト | [1, 2, 3] |
plain [...] は 固定長 (位置要素 immutable、l.0 = X 不可)。伸縮する list は extensible[...] で作り push / pop する (§7.4) |
| タプル | (1, 2, 3) |
不変・固定長、多値返却の値。要素数 0 または 2 以上 (§3.3) |
| マップ | {key := value, ... |} |
{|...|} の通常用法。識別子キーは静的、動的キーは m.[expr] (§3.10)。実行時に key を増やすには open object extensible{...} / extensible[] (§2.4) |
7.1 数値の演算
二項算術 + - * / % ** と単項 - は 両オペランドの実行時型でディスパッチする。Int と Float は別の数値型であり、暗黙の型変換 (numeric coercion) は行わない (Go / Rust 流)。
- `Int op Int → Int` (任意精度整数上で実行):
- 除算 `/` はゼロ方向への切り捨て (Go の
/準拠):7 / 2 #> 3、-7 / 2 #> -3 - 剰余 `%` は Go の
%準拠 - オーバーフローしない (任意精度。結果が int64 に収まらないときは内部表現が自動的に多倍長へ切り替わるが、値の意味論に表現の区別はない)
- `Float op Float → Float` (
float64/ IEEE 754 上で実行): + - * /は実数演算。/は実数除算 (7.0 / 2.0 #> 3.5)- 剰余 `%` は Go の
math.Mod準拠 (5.5 % 2.0 #> 1.5) - オーバーフローは検知せず `Inf` になる (
1e308 * 1e308 #> Inf) - 混合 (`Int op Float` / `Float op Int`) は型不一致として panic (バグ層、§16.2。異型比較 §9.4 と同じ扱い)。暗黙変換しないため
1 + 2.0は panic (リテラルなら1.0 + 2.0と書く)。Int 値を Float 計算に持ち込むときはn.to_float! + 2.0のように明示変換する (Int.to_floatは全域関数でFloatを直接返す。下記「数値間の変換」参照) - ゼロ除算 は Int / Float とも実行時エラー (
object.Error、§16.2):a / 0、a % 0、x / 0.0、x % 0.0 - 累乗 ``** も両オペランドの実行時型でディスパッチする (右結合・優先順位は §8.3):
- `Int Int → Int
** は任意精度の整数べき (正確、オーバーフローなし):2 10 #> 1024`、`0 0 #> 1。**負指数** (2 ** -3) は結果が整数で表せず実行時エラー (object.Error`、ゼロ除算と同格) - `Float Float → Float
** は実数べき:2.0 0.5 #> 1.4142135623730951`。負指数は分数を返す (`2.0 -1.0 #> 0.5)。整数値の指数なら負の底も定義される ((-2.0) 3.0 #> -8.0`) が、負の底に小数指数を与えると実数解がなくNaNを値として返す (`(-2.0) 0.5 #> NaN、is_nan!で判定)。オーバーフローはInf` - 混合 (`Int Float
/Float Int`) は他の算術と同じく型不一致で panic
数値間の変換 (`to_float` / `to_int`) — 変換が失敗しうるかは「変換先で値を表現できるか」で決まり、方向で非対称である:
- `Int → Float` (`Int.to_float`) は全域関数 — Option を返さず
Floatを直接返す。任意精度 Int をfloat64へ最近接丸めし、float64の有限範囲 (約1.8e308) を超える巨大 Int は `Inf` を返す (Float 算術のオーバーフローがInfになるのと同じ扱い、上記)。失敗しないのでunwrap不要 (n.to_float! * pitch)。範囲超えを検出したいときは結果をis_finite!で判定する。 - `Float → Int` (`Float.to_int`) は Option を返す — 有限値は 0 方向へ切り捨てて
Some(Int)(Int は任意精度なのでオーバーフローしない)。NaN / Inf は `None`。Int で表現できない値の不在を表す (Bytes のto_string!が不正 UTF-8 でNoneになるのと同じ、§7.4)。 - `String → Int` / `String → Float` は Option — 文字列解析であり (
"42".to_int! #> Some(42)、非数値はNone)、上の数値間変換とは別枠。
Float は NaN / Inf を値として持ちうる (オーバーフロー由来・Int.to_float の範囲超え・(-2.0) ** 0.5 など)。これらの等価・順序の扱いは §9.4、判定メソッド (is_nan! ほか) は prelude.md を参照。
7.1.1 数値リテラル
基数 prefix (整数のみ): 整数リテラルは 0x (16 進) / 0b (2 進) / 0o (8 進) の prefix を取れる。prefix は小文字のみ (16 進桁 A–F は大小両方)。prefix 無しは 10 進で、先頭 0 も 10 進 (0755 は 755)。基数 prefix は整数のみで Float には付かない。不正な桁 (0b2 等) や桁なし (0x) は構文/評価エラー。整数リテラルに桁数の上限はない (int64 に収まらない値も任意精度の Int になる)。
桁区切り `_`: 整数・Float とも桁区切り _ を数字の間に書ける。先頭・末尾・連続・記号隣接 (小数点) は不可。基数 prefix 直後の 0x_FF のみ例外的に許容。例: 1_000_000、0xdead_beef、1_000.5、1e1_000。位置違反 (1__0 / 1000_) はエラー。
位置アクセスとの区別: 基数 prefix・桁区切りは数値リテラルの内部にのみ現れる。メンバ/位置アクセスのドット直後 (obj.0) は常に 10 進整数で、prefix を適用しない。
Float リテラル: hikari は . をスロットアクセス (§3.2) に多用するため、Float リテラルは 小数点の両側に数字を要求し、かつ ドット直後の数字は常に整数と読むことで曖昧性を断つ。
- Float になる形:
3.14、0.5、1e10、2.0E8、1.5e-3(digits.digits形、または指数e/E付き) - `3.` (末尾ドット) / `.5` (先頭ドット) は Float にしない:
3.は3.(整数のメソッド/位置アクセス3.fooの頭)、.5は.5として字句解析される - メンバ/位置アクセスのドット `.` の直後に来る数字は常に整数 (位置アクセス) として読む。したがって連鎖位置アクセス
obj.0.0は従来どおり「位置 0 の要素の位置 0」を意味し、Float リテラルはメンバアクセスのドット直後には現れない。t.1・xs.0・m.0.1は位置アクセスのまま壊れない - 表現不能な大きさのリテラル (
1e400など) は 構文エラー (パース時に検出) - 指数のみ (
1e10) も Float。整数が欲しい場合は指数表記を使わない
3.14 # Float 0.5 # Float 1e10 # Float (指数のみ) 3.0 / 2.0 #> 1.5 [1.5, 2.5] # Float のリスト 3. # `3` `.` … (Float ではない) xs.0 # 位置アクセス (Float ではない) xs.0.0 # 連鎖位置アクセス (Float ではない。ドット直後は整数) 0xFF #> 255 (16進) 0b1010 #> 10 (2進) 0o755 #> 493 (8進) 1_000_000 #> 1000000 (桁区切り) 0755 #> 755 (先頭 0 も 10進)
7.2 文字列の演算
"hello" + "world"で連結。`+` は String + String のみ。他型との+はエラー(整形は §7.2.1 の補間を使う)
メソッド (length / split / splitn / slice / contains / index_of / replace / trim / to_lower / to_upper / starts_with / ends_with / to_int / code / to_bytes / 位置軸 universal methods など) は prelude.md §4 に集約。
位置軸アクセス: String はオブジェクトモデルの「位置スロット軸」を rune (Unicode code point) 1 個単位で公開する。s.0 / s.[i] は §7.4 / §3.10 の規則どおりに動き、長さ 1 の String を返す。範囲外は実行時エラー (List と同じ規則)。length は rune 数を返す。
immutability: String は immutable。s.0 = "x" / s.[i] = "x" は実行時エラー。新しい値が必要なら slice / 連結 / map で新規 String を組み立てる。
エスケープ: 文字列リテラル中で使えるエスケープは次のみ。\n(改行) \t(タブ) \r(復帰) \\(\) \"(") \$($, §7.2.1) と \u{HEX}(Unicode code point)。\u{HEX} の HEX は 1〜6 桁の16進で、0〜0x10FFFF のコードポイントを表す (\u{41} → "A"、\u{1f600} → 😀)。範囲外・サロゲート範囲 (0xD800〜0xDFFF)・空の \u{}・{} 欠落・不正な16進はコンパイルエラー。code / to_char の定義域と一致する (prelude.md §3・§4)。これ以外の \x 等の未知エスケープもエラー。単一行 "..." の中に生の改行が現れるとエラー(改行を含めたい場合は下記の複数行 """...""" を使う)。
REPL のエコーや Inspect() 相当のデバッグ表現は、文字列をこのエスケープ規則で出力する — 非表示文字 (制御文字など) は \n \t \r や \u{…} で見えるように表記し、出力をそのまま hikari ソースに貼り戻せる (往復可能)。
複数行文字列 `"""..."""`: 生の改行を含む文字列は """ で囲む (Swift 流)。開き """ の直後には改行が必須で、閉じ """ は自分の行に単独で置く。閉じ """ の行頭にある空白 (インデント) を接頭辞とみなし、本文の各行から同じ空白を取り除く (dedent)。本文の各行は、空白のみの行を除き、その接頭辞と厳密に一致する空白で始まらなければならずエラーになる。開き直後の改行と閉じ直前の改行は値に含めない。本文中に単独の " や "" が現れても終端せず、そのままリテラルの文字として扱う (""" の並びだけが終端する)。エスケープ \n \t \r \\ \" \$ \u{…} と補間 ${expr} は単一行 "..." と同じ規則で使える (cooked)。
msg := """ Hello, ${name} Line "quoted" ok """ # msg == "Hello, ${name の展開}\nLine \"quoted\" ok"
シングルクォート (U+0027) は未使用: この記号は文字列リテラルの区切りではなく、他のどの構文にも割り当てられていない完全に未使用の記号である。ソース中に現れると字句エラーになる。
7.2.1 文字列補間 ${...}
文字列リテラル中に ${expr} を書くと、expr を評価して文字列として埋め込む。
- 例:
"hello, ${name}"、"残高: ${n + 50}"、"sum: ${f(x, y)}" - 評価値の文字列化:
- String: そのまま / Integer: 10進文字列 / Float: 最短往復可能形 + 整数値でも小数点を付与 (
2.0は"2.0"、NaN/Infはそのまま) / Boolean:"true"/"false"/ unit():"()" - List / Object / Function 等のオブジェクト:
Inspect()相当 (人間可読のデバッグ表現) - 補間内に書ける式は `{` `}` ダブルクォート `"` を含まないもの に限定
- OK:
${name}、${x + 1}、${f(x, y)}、${a.b.c} - NG:
${ {x | x+1}(5) }、${ "inner" }(関数本体や文字列リテラルが必要なら事前に変数代入する) - リテラルの
${を書くには\${でエスケープ。単独の$(後ろが{でない) はそのまま "\${100}" #> "${100}"、"$50" #> "$50"
7.2.2 Bytes
Bytes は不変のバイト列。生成は s.to_bytes!、std:fs の read_bytes(path) (std/fs.md)、または Integer の List からの構築関数 bytes(list) による。Bytes リテラル構文 (b"..." 等) は持たず、range と同じく構築関数で作る (bytes/Bytes は range/Range と対)。
- 位置軸:
b.0/b.[i]は Integer (0〜255) を返す (String と違って長さ 1 値ではない) - `length`: バイト数 (rune 数ではない)
- 位置軸 universal method (§7.4):
each/map/filter/fold/first/last等が見る各要素は Integer (0〜255) - 変換:
b.to_string!は UTF-8 デコードしてSome(String)、不正 UTF-8 ならNone(to_int!の失敗仕様と同じ — 不在は Option) - 構築:
bytes(list)は Integer の List を Bytes に詰め替えOption(Bytes)を返す。全要素が 0〜255 の Integer ならSome(Bytes)、0〜255 外を含むとNone、非 Integer 要素・非 List 引数は型エラー (panic)、空 List はSome(空 Bytes)。to_string!の逆方向 - immutability: Bytes は immutable。
b.0 = N/b.[i] = Nは実行時エラー - 演算子:
+等は v1 では未対応 (v2 検討)
メソッド一覧は prelude.md §5 に集約。
7.3 タプル
不変・固定長。t.0、t.1 でアクセス。t.0 = X はエラー。要素数 0 または 2 以上 (§3.3)。
タプルの用途: 多値運搬専用 — 関数への複数引数のパック (v(3, 5) の適用。R≥2 なら destructure)、多値返却 (return x, y)、分解代入 (a, b := v(...))。
タプルとリストは内部表現が別 (タプルは固定長で immutable な値型として残す)。「データとして保持・引き回す」用途ではリスト (§7.4) を使う。
7.4 リスト
リスト ≡ [] 形で書かれるオブジェクト。[] 構文は {} と同じ 2 セクション構造 ([ slot-list | body ]) を持つ (§2.1.1 参照)。[] と {} の唯一の差は bare identifier の解釈 と 0-pipe の省略形 (`[X]` は slot-list、`{X}` は body)。
[1, 2, 3]と[ 1, 2, 3 | ]と{ 1, 2, 3 | }は同じ値構造を生む (位置スロット 3 個・名前スロット 0 個・body 空)- 空リスト:
[]≡[ | ]≡{ | }≡{}(位置 0・名前 0・body 空の唯一の空値) [...]と{ ... | }の構文上の差は bare identifier の解釈ルール:[foo]内ではfooは変数参照 (値で 1 要素リスト){ foo | }内ではfooは名前スロット宣言 (必須スロット 1 個のオブジェクト)- bare identifier を含まないリテラル (例:
[1, 2, 3]) では両形式は完全に等価 []内で 必須名前スロット (デフォルトなし bare identifier) は宣言できない。name := expr(デフォルトあり) は許容- 位置インデックスは位置スロットを頭から
0, 1, 2, ...と番号付け。名前スロットは飛ばす (混在時) - 混在例:
o := {1, x, 3 |}でo.0 == 1,o.1 == 3,o.xは未束縛エラー、o(99)でxに bind → 値そのものは body 空のため self を返す - 位置軸の 読み取り method は全オブジェクトが持つ universal method:
length,each,map,filter,fold,first,last,positions,has_positionほか (closed / open を問わず動く。全 method の一覧と意味は prelude.md §6) - 名前軸の読み取り universal method:
names,values,has_name - 破壊的に位置軸を伸縮する
push/popは `extensible` (open object) 限定 (§2.4)。closed object / plain list に対しては「not extensible」ランタイムエラー extensible{x := 1 |}.push(3)は動作 (位置軸が 0 → 1 要素に成長。名前xには影響なし)。{x := 1 |}.push(3)は closed のためエラーeach/map/filterは位置軸のみを舐める。名前スロットは反復対象外
位置要素の可変性: 位置スロットは literal 由来・push 追加とも immutable (要素の差し替え l.0 = X / l.[i] = X はランタイムエラー)。長さの伸縮 (push / pop) は `extensible` object に限り 可能で、closed では不可。要素を変えた結果が欲しければ map で新リストを組み立てる。任意添字への O(1) 散在書き込み (DP テーブル・グリッド・in-place sort 等) が要る場合は、可変 scratch ハンドルを frozen で List に確定する std:array (std/array.md) を使う — 位置スロットの immutable 性自体は変えず、内部 buffer をメソッドで更新する別経路を与える。名前スロットの mutability は §4.1 どおり宣言形 (name := / name =) で決まる。
`[]` の応用例 (body / inner-name 付き):
[] でも body と inner-name を持てる。ただし bare identifier = 変数参照のため、名前スロットを引数として受け取る関数を [] で書きたい場合は name := default 形を使う:
[x := 0 | x + 1]—xがデフォルト 0 の名前スロット + bodyx + 1。f()で 1、f(10)で 11[1, 2 | self.0 + self.1]— 位置 [1, 2] + body。inner-name なしのためselfは外側スコープから引く必要あり[inner self, 1, 2 | self.0 + self.1]— 位置 [1, 2] + inner-nameself+ body。v!で 3[foo]— 変数fooの値を 1 要素として持つリスト (現行と同じ)[foo | bar]— 位置foo(変数参照) + bodybar。bare identifier が slot-list 領域でも body 領域でも変数参照 なので、fooもbarも外側スコープから引かれる
リスト method: length, first, last, each, map, filter, fold, positions, has_position(読み取り、universal)、push, pop(extensible 限定)。名前軸 universal: names, values, has_name。
7.4.1 オブジェクトの + 演算
§7 冒頭の通り、リスト・マップ・データオブジェクト・関数はすべてオブジェクトの異なる側面である。+ はこれらに統一的に作用し、構造を右勝ちでマージした新しいオブジェクトを返す:
- 位置スロット: 順序を保って連結 —
[1, 2] + [3, 4] #> [1, 2, 3, 4] - 名前スロット: マージ。同名は 右側が勝つ —
{x := 1, y := 2 |} + {y := 3 |} #> {x := 1, y := 3 |} - 混在:
[1, x := 2] + [3, x := 4] #> [1, 3, x := 4] - inner-name / body: 右が持てば右、右が持たなければ左を継承 (マップ merge と同じ意味論)
[inner self, 1, 2 | self.0 + self.1] + [3, 4]→ inner-nameself、位置[1, 2, 3, 4]、body は左のself.0 + self.1を継承 (右が body を持たないため)[1, 2] + [inner me, 3, 4 | me.0]→ inner-nameme、位置[1, 2, 3, 4]、bodyme.0- 関数の合成も同じルール:
{x | x + 1} + {y | y * 2}→ スロット[x, y]、body は右のy * 2。動的合成の表現力。 +の左右は 両方ともオブジェクト (≒ String/Integer/Boolean/Tuple 以外) でなければエラー。他型との+はエラー
7.5 マップ
マップ ≡ 名前スロットだけを持つオブジェクト。位置スロット軸は空。スロット名 (= 識別子) がキー、スロット値が値。識別子キーはスロット宣言時に固定される (§1.2)。既存スロットは動的キー `m.[expr]` で読み書きできる (§3.10)。動的キーは名前軸 (String) と位置軸 (Integer) のみで、それ以外の型はキーに使えない。全キーは m.names()、全値は m.values() で宣言順に取り出せる (§7.4 名前軸 universal)。m.values() は m.names().map({ k | m.[k] }) と等価で、両者は同じ位置に同じスロットのキー / 値が並ぶ。
実行時にキーを増やしたい場合は closed な {...} ではなく open object (extensible{...} / extensible[]、§2.4) を使う。closed map への未定義キー代入 (m.[k] = v / m.[k] := v) はランタイムエラー。
extensible なマップなら位置軸に push して位置スロットも生やせる (内部的にはリストと同型のオブジェクト) が、実用上「マップ」と「リスト」は使い分けるのが普通。
ここでのマップは String 識別子キー専用で、キー照合はスロット名 (String) の一致による (マップ値どうしの == は §9.1 の構造的な値等価)。String 以外の値をキーにしたい / キーを `compare` 同一性で引きたい / キー昇順で反復したい場合は標準ライブラリの順序付きコレクション std:map (SortedMap) / std:set (OrderedSet) を使う (std/map.md・std/set.md)。これらは Comparable (§9.4) をキー / 要素にとり、compare を同一性基準とする不変な値型 (std:map にはこれとは別に、任意キー・挿入順・== 同一性の InsertionMap もある。std/map.md §2)。
7.6 range (遅延シーケンス)
range(start, end) は 半開区間 `[start, end)` の整数列を表す遅延シーケンス値を返す。range は prelude が束縛する大域名 (if / while と同格の組み込みオブジェクト) であり、専用リテラル構文 (.. 等) は持たない。
- 引数:
start/endとも Integer 必須の 2 引数。非 Integer はランタイムエラー。range(5)は 2 引数に満たないため部分適用 (§3.5) となり、シーケンスにはならない - 半開区間:
range(0, 10)は 0,1,…,9 (endを含まない)。range(0, xs.length())が全インデックスを舐め、positions/ 0-base 添字と整合する - 空 range:
start >= endのとき長さ 0 (エラーにしない)。range(5, 5)もrange(10, 0)もlength() == 0、eachは一度も回らず、mapは[] - 遅延: 要素は要求時に算出され、巨大な範囲でもリストを実体化しない。実体が欲しいときは
to_list
仮想位置軸: range は実体の位置スロットを持たないが、位置軸 universal read method (§7.4) を実装する。論理添字 i の要素は start + i*step で、リストを舐める場所にそのまま差し込める。map / filter は List の同名メソッドと同じく List を eager に返す。
range method: length, first, last, each, map, filter, fold, positions, has_position, r.N 添字 (位置軸 universal)。加えて range 固有に step, reverse, to_list (いずれも新しい range / List を返し合成可能)。各メソッドの形と意味は prelude.md §6.5 に表でまとめる。
step と reverse の直交: 向きは reverse、刻みの大きさは step(n) (n は正の Integer、step(0) / 負数はエラー) が担う。降順 2 刻みは range(0, 10).step(2).reverse()。負の step は持たない (向きと大きさを 1 引数に混ぜないため)。
位置と値の区別: range(5, 10) の要素値は 5,6,7,8,9 だが、positions() は添字 [0,1,2,3,4] を返す (List と同じく「位置 ≠ 値」)。r.0 == 5、r.length() == 5、r.first() == 5、r.last() == 9。
型語彙としての `Range`: range 値の型は型語彙 Range。r matches Range で判定でき、x: Range と注釈にも書ける (§17.4)。step / reverse 派生も同じ Range。
8. 演算子と中置記法
8.1 三つの等価な呼び出し記法
hikari は同じメソッド呼び出しを三つの記法で表せる。意味は同じ。
1 + 2 # 記号オペレータ中置 1.+ 2 # ドット+スロット名、引数は並置 1.+(2) # ドット+括弧 one add 2 # 識別子中置: one.add 2 と等価 one.add 2 # ドット+並置 one.add(2) # ドット+括弧 "a" -> 1 # 記号オペレータ中置 (ペア構成。§8.3・§2.5) "a".-> 1 # ドット+スロット名、引数は並置 "a".->(1) # ドット+括弧 — いずれも (a, 1) を返す
統一規則として:
- `receiver name arg` は
receiver.name(arg)の糖衣 (nameが記号でも識別子でも)。
nameの解決はreceiver.nameと同一規則 — receiver のスロット、無ければ型メソッドレジストリ。
外側スコープの同名関数は引かない (=recv.nameがエラーならrecv name argもエラー) - `f x` は関数適用 (Haskell 流の並置)。
fの値が呼び出し可能なときに成立 - パーサは常に並置
((recv name) arg)を作り、実行時にrecvが非関数値かつnameが Identifier
の場合に中置糖衣として再解釈する (§8.6 末尾参照)
8.2 ユーザー定義オペレータ
スロット名は記号 (+、==、< 等) も識別子 (add、map 等) も自由に使える。+ などのオペレータをユーザー定義型に実装するには、その名前のスロットを定義するだけ:
vec := { inner self,
x := 0, y := 0,
+ := { other |
{ x := self.x + other.x, y := self.y + other.y |}
}
|}
v1 := { x := 1, y := 2 |}
v2 := { x := 3, y := 4 |}
v1 + v2 # v1.+(v2) → { x := 4, y := 6 |}
8.3 優先順位と結合性
外側 (弱) から内側 (強) の順:
- 代入:
=、:=、+= -= *= /= %= - ペア:
->(2-tuple。a -> b≡(a, b)。左結合) - 論理OR:
|| - 論理AND:
&& - 比較:
==、!=、<、<=、>、>= - 加減:
+、-(二項) - 乗除:
*、/、% - 単項:
-(※論理否定!は廃止 — §8.5) - 累乗: ``** (右結合)
- 識別子中置メソッド (
a foo b、f x、one add 2等) - ドット (
.、.[])、postfix call (!、())、postfix 失敗 bail (?、§16.5)
同一レベルは左結合。step!? は (step!)? (postfix の左結合)。-> も左結合: 1 -> 2 -> 3 は (1 -> 2) -> 3 ≡ ((1, 2), 3)。
`->` と式アスクリプション `:` (§17.1) の関係: : は本表に含めていない (式アスクリプションとして §17 で別掲、二項演算子・関数適用より弱く非結合)。-> はその : より強く、|| より弱い (ASCRIBE < PAIR < OR)。
字句: -> は - と > が隣接した単一トークン (<=/>=/!= などと同じ扱い)。a - > b (間に空白) は -> トークンにならない。a->b ≡ a -> b、a - b (減算) とは曖昧性なく区別される (§1.2)。
** (累乗) はこの表で唯一の右結合二項演算子である。2 ** 3 ** 2 は 2 ** (3 ** 2)。単項 - より強く結合し -2 ** 2 は -(2 ** 2)、指数側 (右オペランド) は先頭の単項マイナスを取れる (2.0 ** -1.0 は 2.0 ** (-1.0))。
これにより:
a foo b + c ≡ (a foo b) + c # 識別子中置 > 記号オペレータ f x + g y ≡ (f x) + (g y) a foo b bar c ≡ ((a foo b) bar c) # 左結合 1 + 2 * 3 ≡ 1 + (2 * 3) # 標準的な算数優先順位 2 ** 3 ** 2 ≡ 2 ** (3 ** 2) # 累乗は右結合 -2 ** 2 ≡ -(2 ** 2) # 累乗は単項マイナスより強い 2 * 3 ** 2 ≡ 2 * (3 ** 2) # 累乗は乗除より強い "n" -> a + b ≡ "n" -> (a + b) # -> は || より弱く、値側に演算式を括弧なしで書ける ["a" -> 1, "b" -> 2] ≡ [("a", 1), ("b", 2)] # , は区切りであり演算子ではない。常に -> より緩い 1 -> 2 -> 3 ≡ ((1, 2), 3) # -> は左結合
8.3.1 空白と結合優先順位
トークン間の空白の有無は §8.3 の優先順位・結合性に影響しない。空白はトークンの区切りとしてのみ機能する。
f(x) ≡ f (x) ≡ f ( x ) # 呼び出し a+b ≡ a + b # 二項演算 f{ b }! ≡ f { b }! # juxtaposition + postfix call obj.m() ≡ obj.m () # メソッド呼び出し
これにより、フォーマッタや手作業の整形によって意味が変わることはない。
注: || (短絡 OR) と | | (パイプ 2 個) のように、字句解析でトークン分割自体が変わるケースは §2.1 の規則であり、本節とは独立。
8.4 短絡評価 (例外)
&& と || だけは言語組み込みの特例として短絡評価する。メソッド呼び出しに脱糖しない。
false && expensive() # expensive() は呼ばれない true || expensive() # expensive() は呼ばれない
これは「すべてはオブジェクト呼び出し」原則からの唯一の意図的な逸脱である。理由: 短絡を関数化するには右辺をブロックで包む必要があり、a.&&({b}) のような記述は実用上負担が大きいため。
&&/|| の被演算子は Bool 必須 (§9.3、非 Bool は panic)。&& は左が false なら左を、true なら右を返す。|| は左が true なら左を、false なら右を返す (短絡評価)。いずれも結果は Bool。
8.5 論理否定 ! の扱い
! は postfix 呼び出し短縮 (§3.2) に使うため、prefix の論理否定としては廃止する。論理否定は .not() メソッドで表現:
cond.not() # 否定 cond.not! # postfix 短縮形 (同義) !cond # NG (構文エラー) cond! # 全く別: cond を引数なしで呼ぶ
!= (不等) は単一トークンとして字句解析されるため、! 単体との曖昧性はない。
8.6 メソッド糖衣でない記法の一覧
hikari の中心思想は「演算子はスロット呼び出しの中置記法」だが、いくつかの記法はメソッド呼び出しに脱糖されない。それらは原則に対する例外ではなく、原則そのものを支える土台 (構文構造・スコープ・遅延評価) である。実装やレビュー時の判断基準として、本節で一覧化する。
| 記法 | 区分 | 役割 | 参照 |
|---|---|---|---|
&& |
短絡論理演算 | 被演算子は Bool 必須。左が false なら左、true なら右を返す (右辺を遅延評価)。a.&&(b) には脱糖しない |
§8.4 |
|| |
短絡論理演算 | 被演算子は Bool 必須。左が true なら左、false なら右を返す (右辺を遅延評価)。a.||(b) には脱糖しない |
§8.4 |
:= |
束縛 | immutable 宣言 (ローカル / スロット)。シャドウ不可。open object では新規 immutable slot を追加 | §6.1, §6.2 |
= |
代入 | mutable 束縛・代入 (ローカルは auto-vivify、slot は mutability / open-closed を検査) | §6.1, §6.2 |
op= |
代入糖衣 | 複合代入 += -= *= /= %=。lhs op= rhs は place を 1 回評価して cur op rhs を書き戻す read-modify-write。op 部分は演算子スロット呼び出しに脱糖、代入部分は非糖衣 |
§6.4 |
, |
構造 | リスト的並列性 — タプル要素・スロット列・引数列・多値返却 | 原則 5 |
; / 改行 |
構造 | シーケンス (最後の値が返り値) | §1.3, 原則 6 |
| |
構造 | スロット宣言と本体の区切り ({slot-list | body}) |
§2.1 |
| キーワード | 構文形 | 予約語=宣言・照合の構文形 (§1.4)。type / enum / match / import / export / loop / conduit / extensible / inner |
§1.4 |
f x (並置) |
呼び出し | 関数適用 (Haskell 流)。中置メソッド糖衣 recv name arg の 脱糖の着地点でもある。実行時に f が非関数で AST 上 x が Identifier なら f.x を §3.2 規則 (slot → 型メソッドレジストリ) で解決して中置呼び出しを試みる |
§3.1, §3.2 |
. |
アクセス | スロットの取り出し (呼び出しではない) | §3.2 |
.[] |
アクセス | 動的スロットの取り出し (key 式の評価値で名前/位置軸を引く)。recv.[k] は呼び出しではない |
§3.10 |
() |
呼び出し / 構造 | タプル化、または引数なし呼び出し | §3.3 |
! (postfix) |
呼び出し | () の短縮形 (v! ≡ v())。prefix の論理否定としては廃止 |
§3.2, §8.5 |
? (postfix) |
失敗 bail | Result / Option の失敗 variant で関数から脱出する return の構文糖。成功は中身に開く |
§16.5 |
!= |
比較 | 字句的に単一トークン。意味は (a == b).not() と等価だが、! と = への分解ではなく != のまま扱う |
§9.1 |
逆に、これら以外の演算子 — 算術 (+ - * / %)、比較 (== < <= > >=)、単項 -、ペア構成 ->、識別子中置 (a foo b) — はすべて receiver.name(arg) への糖衣である (§8.1)。ユーザー定義型で同名スロットを定義すれば挙動を差し替えられる (-> は全値が baseline を持つ universal method。§2.5)。
なぜこの分類が必要か:
- 短絡 (
&&/||) は右辺の遅延評価が本質。関数化するとa.&&({b})のようなブロック包みを毎回強いることになり、実用上の負担が大きいため特例とする (§8.4)。 - 束縛・代入 (
:=/=) はスコープを操作する構文要素であり、スロット呼び出しでは表現できない。 - 構造区切り (
,/;/|) はパース時に構造を作るためのもので、値ではない。object の shape を open にするのはキーワードextensible(§2.4)。 - 呼び出し / アクセス記法 (並置 /
./()/!) は、糖衣の着地点を提供する側であり、それ自体が糖衣ではない。 - `!=` は字句解析の都合 (
!単体との曖昧性回避) で単一トークン化されているが、意味的には==の派生である。
9. 等価性と真偽値
9.1 == のセマンティクス
== は値等価である。同一インスタンスかではなく、値の中身が等しいかで判定する (Haskell の Eq・Rust の PartialEq と同系統)。
- 原始型 (整数・浮動小数・文字列・真偽値・バイト列・unit) は構造等価: 値そのものが等しいかで判定
1 == 1 #> true、"a" == "a" #> true、() == () #> true、3.14 == 3.14 #> true- 異型同士の比較は false:
1 == "1" #> false、0 == false #> false、1 == 1.0 #> false(Int と Float は別型 = 別の値。暗黙変換しない §7.1) - Float の構造等価は IEEE 非準拠:
NaN == NaN #> true(同一ビット = 等値)。IEEE 流の「NaN は自身と不等」が要る場合はis_nan!を使う (prelude.md) - body を持たない値 (リスト / タプル / マップ / データオブジェクト) と Variant (Option / Result / Ordering・
enum) は再帰的構造等価: 要素・スロット (Variant は Tag + payload) を再帰的に==で比べ、すべて一致すれば等しい [1, 2, 3] == [1, 2, 3] #> true、(1, 2) == (1, 2) #> true、{|x := 1|} == {|x := 1|} #> true(別インスタンスでも中身が同じならtrue)- データオブジェクトは名前スロットの集合と各値、リストは位置スロット列を比べる。inner-name や open/closed (
extensible) などのメタ情報は等価に影響しない - Variant の Tag 同一性は所属 `enum` を含む (§17.4): 異なる
enumが同名タグを持つ場合、タグ名が同じでも所属が違えば別タグとして扱う (例:enum Color := OneOf(Red, None)とenum Auth := OneOf(User, None)のColor.None == Auth.Noneはfalse)。組込 (Option/Result/Ordering/Control) のタグは所属を持たないため、組込同士・組込と prelude 直接構築値との等価は従来どおり。所属は宣言された `enum` 名で識別するため、別モジュールがそれぞれenum Color := …を宣言していても同名なら同一の所属として扱う (m1.Color.None == m2.Color.Noneはtrue) - 異型 (リスト vs タプル等) は
false。長さ・スロット集合が違えばfalse - 循環参照しても停止する (比較中のペアを等しいと仮定する bisimulation)
- body を持つ関数 (
{slots | body}) は参照等価: 本体は構造比較できないため、同一インスタンスかで判定する - データオブジェクトがメソッド (関数スロット) を持つ場合、その関数スロットは参照等価で比べられる。別々に生成した同形クロージャは不等になりうる
- オーバーロード: ユーザー定義型で
== :=スロットを定義すれば独自の等価性を実装できる - 評価器は
a == bを解釈する際、aに==スロットが定義されていればそれを呼び出す。なければ上記の規定に従う - `compare` との整合 (導出規則): object が
compareスロット (§9.4) を持つとき、その==は `compare` から導出される —a == b≡a.compare(b) == Equal。これにより型の固有compareと==は必ず一致する (Haskell の *Ord refines Eq* / Swift の *Comparable refines Equatable* と同型。「natural ordering inconsistent with equals」を構造的に排除する) - 等価の源泉を一つに固定するため、`compare` スロットと `==` スロットの同居は禁止 (両方を持つ object リテラルは panic)。
compare無しで==単独を定義するのは可 (Eq without Ord) - 導出
==も全域:compareが比較不能な相手 (異型・shape 不一致) にはfalseを返す (panic しない)。{inner self, x := 1, compare := {o | self.x.compare(o.x)} |}同士はxが同じなら==もtrue、xが違えばfalse、Intなど別型とはfalse - 等価とは別の「その場限りの順序」が要るなら、
compareスロットを定義せずsort_by/sort_with(prelude.md §6) に明示コンパレータを渡す - 不透明値型: stdlib モジュールは値等価・順序比較を持つ不透明値型を導入できる (例:
std:timeのInstant)。その等価は参照ではなく内部表現 (値) で判定され、別インスタンスでも同じ値ならtrueとなる
!= は == の否定として実装される (a != b ≡ (a == b).not())。
参照同一性 `reference_equals`: == (値等価) とは独立の軸として、同一インスタンスかを問う universal method reference_equals (§2.5) を持つ。a.reference_equals(b) は a と b が同じインスタンスを指すとき true、構造が同じでも別インスタンスなら false。可変オブジェクトの別名 (aliasing) 判定 — 「a を書き換えると b も変わるか」 — に使う。
xs := [1, 2]; ys := [1, 2]; zs := xsのときxs == ys #> true・xs.reference_equals(ys) #> false・xs.reference_equals(zs) #> true- 原始型 (整数・文字列等) は不変で固有のインスタンスを持たないため
reference_equalsは意味を持たない (1.reference_equals(1)は実装上のボックス同一性に依存する)。値の一致を見たいときは==を使う - 受け手が
reference_equalsスロットを定義していればそれが優先される (他の universal method と同じ)
9.2 不在の判定 (Option)
不在は nil ではなく Option で表す。first / last / index_of / to_int / to_string などは値があれば Some(v)、無ければ None を返す。判定は match の値コンストラクタパターンか述語メソッドで行う:
match o { Some(x) => use(x), None => default }o.is_some()/o.is_none()、o.or(default)、o.unwrap!(None なら panic)
hikari は形式的なラッパ (Option) を持つ方針へ転換した。if などの void 戻りは unit () であり不在ではない (§9.3 は Bool 必須のため () を条件にはできない)。
9.3 真偽値文脈 (Bool 厳格)
if、when、while、&&、|| の条件は Bool 必須。非 Bool 値を条件に与えると panic する (バグ層、§16.2)。truthy / falsy は持たない。
- 整数の非ゼロ判定は
n != 0、コレクションの非空判定はxs.length! > 0、Option の在判定はo.is_some()のように 明示的に Bool を作る。 if (xs) {…}のような暗黙の真偽判定は書けない (panic)。
if(true, { "t" }, { "f" }) #> "t" if(false, { "t" }, { "f" }) #> "f" if(0, { "t" }, { "f" }) #> panic (Bool でない) if((), { "t" }, { "f" }) #> panic (Bool でない)
9.4 順序比較 (compare と < <= > >=)
等価 == (§9.1) と独立した順序の軸。比較可能な値に全順序を与え、その正準を universal method compare (§2.5) が担う。
- `compare`:
a.compare(b)はOrdering(Less∣Equal∣Greater、prelude.md §14) を返す。Less=aが前、Greater=aが後、Equal=同順位。 - 原始型の全順序 (組込
compare):
| 型 | 順序 |
|---|---|
| Int | 数値順 |
| Float | 全順序 (NaN を含む)。下記参照 |
| String | 辞書順 (Unicode code point 単位) |
| Bool | false < true |
| Unit | 単一値。().compare(()) は常に Equal |
- `< <= > >=` は `compare` から導出:
a < b≡a.compare(b) == Less、a > b≡== Greater、a <= b≡!= Greater、a >= b≡!= Less。Int は観測上 §7.1 の従来挙動と一致する。 - Float の順序は全順序 (IEEE 非準拠):
compareが必ずLess/Equal/Greaterを返す invariant を保つため、NaN にも定位置を与える (Rust のtotal_cmp相当)。これによりsort・Comparableが NaN 混入時も破壊されない (Haskell のOrd Doubleが抱える「演算子と compare の不一致」を構造的に回避)。IEEE 流の「NaN との比較は全て false」が要る場合はis_nan!で明示判定する。 - 異型比較は panic (バグ層、§16.2)。
==は異型をfalseとするが (§9.1)、順序はfalseを返せないため1.compare("a")/1 < "a"は panic する。Int と Float も別型であり、1 < 2.0/1.compare(2.0)は panic する (暗黙変換しない §7.1。Int 値を比較するときはn.to_float! < 2.0と明示変換する)。 - overload (ユーザ型の自然順):
compareスロットを定義した object は順序を持つ。a.compare(b)はレシーバのcompareスロットへ dispatch され (§9.1 の==overload と同じ機構)、< <= > >=もそれ経由で効く。等価 `==` はこの `compare` から導出される (§9.1 整合規則):a == b≡a.compare(b) == Equal。compareと==スロットの同居は禁止され、両者のEqual一致は構造的に保証される (型定義側に委ねず、ズレを作れない)。
3.compare(5) #> Less "b".compare("a") #> Greater 3 < 5 #> true "ab" < "abc" #> true (辞書順) # ユーザ型は compare 一個で順序一式が揃う (self は inner-name §4.2) v := { inner self, x := 1, compare := { o | self.x.compare(o.x) } |}
Comparable: 順序を持つ値、すなわち 原始型 (Int/Float/String/Bool/Unit) または callable な `compare` スロットを持つ object。Comparable は組込の特別な型ではなく、構造的 interface `{compare |}` として prelude が束縛する値である (§17.5。compare のベースラインが原始型に効き、object は自前の callable compare で応答する)。ユーザーも type MyOrd := {compare |} で同値の型を定義できる。型値 Comparable で v matches Comparable と判定できる (§17.4)。sort (prelude.md §6) は要素が Comparable であることを要求する。
9.5 複製 (copy / Copyable)
等価 (§9.1)・順序 (§9.4) と同じく全値に生える universal method copy (§2.5)。x.copy! は x と同じ現在状態を持つ独立したコピーを返す。「今あるものと同じ物をもう一つ」という複製であって、無からの構築とは別の操作 — 引数を取った初期化や副作用つき構築が要るときは factory (本体付き 0 引数ブロックで毎回 fresh を返すイディオム) を使い、copy はそれを置き換えない。
default 式を再評価しない: copy はスロットの現在値を引き継ぐのであって、リテラルの default 式 (§12) を評価し直さない。ゆえに default 式に埋めた副作用 (採番・時刻取得・リソース確保) は copy では走らない。これが「複製」と「構築」を分ける観測点でもある。
メソッドの self 再束縛: object を複製するとき、自身の self (inner-name §4.2) を捕捉するメソッドスロットは、新インスタンスの self に束縛し直したクロージャとして作り直される。これにより複製先のメソッドは複製先の状態を触る。
counter := { inner self, count = 0; inc := { self.count += 1; self.count } |}
counter.inc!; counter.inc! # count=2
d := counter.copy! # count=2 を引き継ぎ、inc 内の self は d を指す
d.inc! #> 3 # counter は 2 のまま (状態は独立)
深さは可変性が決める: copy が新しく作り直すのは可変な構造だけで、透過的に不変な部分はそのまま共有する — 不変な値は変異によって別名 (aliasing) を観測できないため、共有しても安全である。
- 可変な object = 可変 (
=) 名前スロットを 1 つ以上持つ、またはextensible(§2.4)。→ 作り直し、全スロット値を同じ規則で再帰的に copy する。 - 透過的に不変 = 到達可能なグラフに可変 object を一切含まない (原始型、全スロットが
:=かつ closed で不変な referent しか持たない object、plain list など)。→ そのまま共有する。 - 位置スロット (§7.4) の各要素も同じ規則で辿る。
したがって防御的コピー (可変状態の分離) が成立する一方、大きな不変データは複製されない。可変スロットに不変な referent が入っていれば共有され、:= スロットに extensible (可変) が入っていればその referent は作り直される — 判定はスロットの :=/= ではなく referent 自身の可変性で決まる。
循環と内部エイリアスの保存: copy は同一性マップを用いたグラフコピーで、1 回の copy 内で同じ可変インスタンスに複数経路から到達してもコピーは 1 つに集約する。これにより循環構造も終端し、元にあった内部別名関係もコピー後に保存される。
`reference_equals` との関係 (§9.1): y := x.copy! のとき、x の可変部分と y の対応部分は reference_equals が false (独立)、共有された不変部分は true になりうる。counter.copy!.reference_equals(counter) は counter が可変 object なので false。
上書き: 受け手が copy スロットを定義していればそれが優先される (他の universal method と同じ §8.6)。深いコピーを強制したい型や不透明リソースを抱える型は自前の copy を定義する。overlay 形 (下記) をサポートしたい型は、override 側も overlay を受け取る 1 引数 (デフォルトは空 object) として定義する。overlay を宣言しない 0 引数 override に overlay を渡すとアリティエラーになる。
引数つき複製 (copy-and-update): copy は overlay を 1 つ受け取れる。x.copy! は overlay 無しの純粋複製、x.copy(overlay) は複製と同時に overlay の名前スロットの値だけを差し替えた複製を返す。overlay を省いた copy! は空 overlay と等価。新しい構文は導入せず、overlay は既存の object リテラルで渡す (空 overlay は {})。
p := { x := 1, y = 2 |}
q := p.copy({ y := 20 |}) # { x := 1, y = 20 |} — y は可変のまま
p.copy! # 純粋複製 (overlay 無し)
p.copy({ z := 9 |}) # panic — z は p の名前スロットに無い
overlay の規則:
- 差し替えるのは名前スロットの値のみ。overlay に受け手の名前スロットに無いキーがあれば panic (バグ層、§16.2)。overlay が位置スロットを含む場合も panic。
- スロットの可変性は受け手側を保持する: 受け手の
x = 1(可変) は overlay 後も可変、x := 1(不変) は不変のまま。overlay リテラルの:=/=は値の運搬にのみ使われ、スロット種別 (可変性・必須/デフォルトの別) を書き換えない。 - 土台の複製規則 (可変部の作り直し・透過的に不変な部分の共有・メソッドの self 再束縛・default 式を再評価しない) は overlay の有無で変わらない。差し替えた値はそのまま複製に入る。
- shape は変えない: overlay はスロットを増やさない (追加は
+マージ §7.4.1 の担当)。
Copyable: 複製できる値を表す。v matches Copyable は、v が原始型・object・list・関数などの通常値なら true、意味ある複製を定義できない不透明な可変リソース (例: `Future` や外部ハンドル)は false。copy を Copyable でない値に対して呼ぶと panic する (§16.2)。順序を持つ値が限られる Comparable と違い、Copyable は inspect と同様ほぼ全値を覆う。Copyable も組込の特別な型ではなく、構造的 interface `{copy |}` として prelude が束縛する値である (§17.5。copy のベースラインが Future/opaque 以外の全値に効く)。
10. スケジューラ意味論
if / when / while / break / continue / return などの制御構造、および fork / sleep / now / wait_any などの並行性プリミティブは、予約語ではなく prelude が束縛した特殊オブジェクト である。「特殊」とは実装が組み込み (ホスト言語 Go で書かれている) という意味のみで、評価規則は他の関数と同じ — 引数は呼び出し前に評価され、ブロックを渡せばその評価は遅延される。API の詳細は prelude.md §8 (制御構造) と §9 (非同期評価) を参照。値の等価・型・レコードの形で分岐する唯一のディスパッチ構造 match は前置の特殊オブジェクトでも method でもなく 予約語の専用構文で (§1.4。match subject { pattern => body }、API は prelude.md §8.4、型テスト matches §17.4 は別系統の universal method)、break / continue の意味論は §18.1、return (脱出) は §18.2 を参照。
本節では fork / sleep を介して非同期評価を行う際のスケジューラの意味論を規定する。
10.1 フロー切り替え規則
- hikari プログラム実行中は単一の論理フローのみアクティブ
- フロー切り替えは次の中断点でのみ起こりうる:
- (a) 未完了
Futureを!で呼び出したとき - (b) 現フローを中断する組み込み関数を呼んだとき (現状は
sleepとwait_any) - 上記以外のコードは絶対に他フローに割り込まれない (普通の関数呼び出しの
!や resolve 済みFutureへの!、nowなどは中断しない)。データ競合は構造上発生しない - 切り替え順序・公平性は実装定義
不変条件 (実装形態に依らず維持する): 可変状態を共有しうるフロー同士は同時に走らず、その間の切り替えは中断点に限る。ゆえにデータ競合は構造上表現できず、hikari はロック・mutex・atomic をユーザー語彙に持たない。将来の並列実行 (マルチコア対応) は、可変状態を共有しない隔離されたフローに限って導入する — その場合も同期辺は Future の resolve→await のみであり、resolve 前の全書き込みは await 後の読み出しから見える (happens-before)。プリエンプティブな切り替え (中断点以外での割り込み) は導入しない。
隔離フローの並列実行: 上の不変条件は「可変状態を共有しうるフロー同士」を対象とする。可変状態を共有しない隔離フローは同時に走ってよい。隔離フローは spawn (prelude.md §9) で作る — spawn は捕獲した可変状態を複製 (§9.5 の copy) して呼び出し元から切り離し、子フローを専用のスケジューラ領域で並列に評価する。親子はどちらも自分の中断点規則 (本節) に従うが、可変状態を共有しないためデータ競合は生じない。親子間の唯一の同期辺は子の Future の resolve→await であり、resolve 前の子の全書き込みは await 後の親の読み出しから見える (happens-before)。子の結果値は resolve 時点で完了した子ヒープ内に閉じており、親へは参照のまま渡る (複製しない)。fork (§9.1) は隔離しない協調フローで、従来どおり可変状態を共有し並列化しない。
wait_any(futures) は sleep と同じ中断点であり (規則 b)、futures のいずれかが最初に解決するまで現フローを中断し、解決した入力 Future 自身を返す (外側の Future で包まず単層)。呼び出し時点で複数が既に解決済みなら中断せず入力 List の最小位置の Future を返す。負けた入力 Future は解決状態を変えず、root 完了時には §13.3 の未解決 Future として扱われる。
root リテラル本体の評価が完了した時点で未解決の Future が存在する場合の扱いは §13.3 を参照。
10.2 失敗の伝播
fork / spawn した別フローの「失敗」は §16 の三層モデルにそのまま乗る。専用の例外機構や Future 固有の失敗チャネルは持たず、失敗は次の 2 種に分かれて別々の層で扱う。
- 回復可能エラー (§16.1): block が
Err(e)を 返す 場合。これはFutureが運ぶ通常の値であり、f!がErr(e)を返して呼び手が match する。組込のinput/std:fsほかブロッキング I/O はこの形で resolve 値をResultに揃える (prelude.md §9.4)。スケジューラは関与しない。 - panic (§16.1 のバグ層): block 評価中に panic した場合 (型不一致・ゼロ除算・
Errへのunwrap!等)。panic は await の `!` エッジに沿って、それを取り出した消費側フローへ伝播する。v := f!は block の結果を呼び手フローに引き込む点であり、引き込んだ結果が panic なら その `!` の地点で呼び手フローが panic する — あたかも呼び手自身がその式を評価したかのように振る舞う。そこから先は §16.1 どおり呼び手フローの上位へ自動伝播し、トップレベルで停止する。
この規則から派生する振る舞い:
- 複数回の `!`: 「同一
Futureへの複数回!は同じ値を返す」(prelude.md §9.1) を panic にも広げる。panic も memoize され、各!が同じ panic を決定的に再現する (block 本体評価は 1 回)。 - sibling のキャンセル: 消費側フローが panic してトップレベルで停止すると、§13.3 により他の未解決
Futureが一括キャンセルされる。fork失敗時の sibling 停止に追加規則は要らない。 - `wait_any` との関係: panic で解決した
Futureも「解決済み」として勝者になりうる。wait_any自身は panic せず勝者を返し、panic は勝者を!で取り出した時に上記規則で surface する。 - 未 await の panic: root リテラル本体の評価が完了するまで、panic した
Futureを一度も!しなかった場合、その panic は surface せず、プログラムは正常に終了しうる (await されて初めて消費側フローに伝播するため)。これは §13.3 の「未解決Futureは放棄」と整合する (panic で解決済みだが未消費のFutureも、消費側が無い以上いずれのフローも停止させない)。バグの握り潰しを避けるため、処理系は root 終了時にこの未消費 panic を診断として stderr へ報告する (終了コードは変えない)。
10.3 キャンセル
Future の .cancel! (prelude.md §9.8) と root 完了時の一括キャンセル (§13.3) は、いずれもフローへ「キャンセル」を要求する。協調スケジューラでは走行中フローは中断点でしか制御を手放さないため、キャンセルは中断点で観測される (プリエンプティブではない)。
- 未開始のフロー (token 未取得) は block を評価せずキャンセルされる。
- 中断点 (§10.1 の (a) 未完了
Futureへの!、(b)sleep/wait_any) で待機中のフローは、その中断点でキャンセルを検知してフローを中断する。中断は §10.2 の panic と同じ unwind 経路でfork境界まで戻り、そのFutureを panic (object.Error) で解決する。 - 中断点を持たない CPU バウンドなフローはキャンセルを観測できず最後まで走る。
- キャンセルで解決した
Futureは、意図的な停止であってバグではないため、§10.2 の「未 await の panic」診断の対象にしない。
11. シーケンスと多値返却
11.1 本体内の式列
本体は式と文 (§1.5) の列であり、; または改行で区切る。改行がある場合 ; は省略可能。最後に評価された要素の値が返り値 (文なら unit ()。§6.1)。
v := { x |
a := x + 1
b := x * 2
a + b # これが返り値
}
11.2 多値返却
失敗値のエラーモデル全体は §16 (Result) を参照。
最終位置の , 区切りは多値 = タプル化を意味する。
v := { x, y |
sum := x + y
diff := x - y
sum, diff # タプル (sum, diff) を返す
}
; (シーケンス) と , (多値) は明確に異なる:
;/ 改行: 順次評価、中間値は破棄、最後の値だけ返す,: タプル構築、すべての値を保持して返す
多値返却は値の運搬一般に使う (失敗運搬専用ではない)。失敗は Result で表す (§16)。不在は Option (§9.2)。
parse_amount := { s |
match s.to_int! {
Some(n) => Ok(n)
None => Err({ kind := "invalid", message := "金額が不正です: ${s}" |})
}
}
12. デフォルト値の評価
スロットのデフォルト値式はリテラル評価時にeagerに、宣言順 (上から下) に1回だけ評価される。
make := {
{ x := compute() |}
}
make() # compute() 1回走る
make() # compute() もう1回走る (新しいリテラル評価)
a := { x := compute() |} # ここで compute() 走る
a.x # 走らない (キャッシュ済み)
a.x # 走らない
スロット間で参照したい場合は inner-name を使う:
p := { inner self,
x := 10,
y := 20,
sum := self.x + self.y # 宣言順なので x, y は初期化済み
|}
p.sum #> 30
13. トップレベルと評価モデル
13.1 ファイルは暗黙のオブジェクトリテラル
Hikari プログラムはファイル単位で書く。各ファイルは先頭の header で mode を宣言し、そのファイル全体が header に応じた暗黙のオブジェクトリテラル として扱われる。
| header | 暗黙ラッパ | 0-pipe (= ファイル中に | 無し) |
想定用途 |
|---|---|---|---|
#{hikari} (省略可) |
{ slot-list | body } 形 |
全体が body | 実行スクリプト / main |
#[hikari] |
[ slot-list | body ] 形 |
全体が slot-list | ライブラリ / モジュール |
hikari の後に空白 1 個を挟んで 属性 を書ける (#{hikari strict} / #[hikari strict])。属性は mode 選択 ({} / []) とは独立で、ファイル単位のオプションを宣言する。v1 で定義する属性は strict のみ (下記)。
header の規則:
- header を書く場合、ファイル先頭の「最初の非空白・非行コメント行」に置く。shebang
#!...は 1 行目に置ける (置く場合は header はその次の有意行)。行コメント・空行は header より前にいくつあってもよい - header を省略したファイルは
#{hikari}を明示したのと同一に扱う (属性なし)。#{hikari}形だけが省略可で、#[hikari]形 (ライブラリ) は省略不可 - header の mode 形は
#{...}/#[...]の 2 形のみ。括弧内はhikari、またはhikari+ 半角スペース 1 個 + 属性 1 個 - 属性は
strict1 語のみ。属性間・前後の余分な空白、未知の属性語 (#{hikari foo}等)、複数属性は構文エラー - 空白だけを含む header (例:
#{ hikari }) は構文エラー #{hikari}/#[hikari]/#{hikari strict}/#[hikari strict]は字句解析の特例として専用トークンとして読まれる。それ以外の位置に出現する#{/#[は通常の#行コメント (§1.1)
`strict` 属性:
#{hikari strict}/#[hikari strict]を宣言したファイルは、評価・静的検査の前に strict 型検査 (static-analysis.md §3) を受ける。CLI フラグ--strictを付けずにhikari <file>/hikari check <file>/ LSP で扱った場合でも、そのファイル単体が strict として検査される- ファイルの実効 strict は「CLI
--strict」OR「header のstrict属性」。--strictは対象すべてを strict に上書きするため、header にstrictが無くても strict になる (header での opt-out は無い) - strict 属性はそのファイル自身の検査単位にのみ作用する。import 先ファイルは各自の header (そのファイルを entry / ドキュメントとして検査したとき) で判定される。詳細な適用経路は hikari-command.md
ファイルレベルの構造:
- 暗黙の inner-name 無し、
{ slot-list | body }または[ slot-list | body ]リテラル - ファイルレベルでは
inner宣言を持たない (§4.2 の inner-name は内側リテラル専用) - ファイルレベルに出現する
|は 0 個または 1 個。2 個以上は構文エラー (内側リテラルも同じ。§2.1) - 各領域内の構文・名前解決規則は内側リテラルと同一 (§1.3, §2.1, §5, §6.1)
- slot-list 領域と body 領域は 同じリテラル に属する。slot 宣言と body local が同名ならシャドウ不可エラー (§6.1)
- ファイルレベルの暗黙リテラルは closed (
extensible適用不可、§2.4)。slot 生成はトップレベル slot-list 宣言時のみ
評価モデル:
ファイルのロードは暗黙リテラルの評価そのもの:
- slot-list 領域があれば、宣言順に slot default を eager 評価 (§12)
- body 領域があれば 1 回だけ評価。戻り値は捨てる (副作用専用)
- そのオブジェクトを返す (slot は確定済み)
評価モデルは mode に依らず統一されており、mode は 0-pipe 時の領域解釈 にのみ作用する。
実行は「この暗黙リテラルを 1 回ロード (= 評価) する」ことに等価:
- body 内の
name := exprはこの最外側フレームの immutable ローカル束縛 (§6.1) - body 内の
name = exprは mutable (既存 mutable を更新 / 無ければ現フレームに新規 mutable を auto-vivify、§6.1) - 最外側フレームは、その中で定義された関数がレキシカル環境として参照する間、生存し続ける
# main.hikari #{hikari} greet := { name | "hello, ${name}" } # body 領域のローカル print(greet("world"))
# script.hikari (header 省略 — 上の main.hikari と等価) greet := { name | "hello, ${name}" } print(greet("world"))
# run.hikari (shebang のみ、header 省略) #!/usr/bin/env hikari print("hi")
# math.hikari (ライブラリ) #[hikari] PI := 3.14 sqrt := { x | ... }
# checked.hikari (strict 属性 — このファイル単体が strict 型検査される) #{hikari strict} add: { Int, Int | Int } := { x, y | x + y } print(add(1, 2))
# logger.hikari (slot + init) #[hikari] log := { msg | ... } | log("logger initialized") # body 領域 (init 副作用)
13.2 ファイルと名前空間
ファイル間の参照は `import` 構文 (§1.4) で行う。import は type / enum と同型の宣言形 <キーワード> 対象 := "path" で、パス先のファイルを §13.1 の規則で評価し、その member を対象名へ束縛する。import-target が bare 名 (import m := "x") ならモジュール名前空間全体を、`[名前リスト]` (import [a, b] := "x") なら列挙した member を分解束縛する ([…] の有無で分岐)。path は := 右辺の 文字列リテラル 1 個のみ、キーワードと対象・:=・path のあいだの空白は任意。
import は ファイルトップレベルの宣言 としてのみ現れる (slot-list 領域・body 領域とも可)。関数本体・入れ子リテラルの内部には書けない (文法エラー)。これによりモジュールの初期化を起動時・単一の import チェイン上に閉じ込める。式ではないため := の右辺や部分式には置けない。
呼び出し側は丸ごと束縛した名前を通常のオブジェクトとして slot アクセスする:
import math := "math.hikari" math.sqrt(16)
名前分解 […] と組み合わせて、必要 member だけローカルに引くこともできる。import はスロット/文の二相を持つ (§6.3 と同じ) ため、スクリプト (#{hikari}) の body でもライブラリ (#[hikari]) の slot-list 先頭でも同じく書ける (後者では引いた名前が後続スロットから参照でき、export メンバにもなる):
import [sqrt, log] := "math.hikari" sqrt(16)
import は同じ構文で 標準ライブラリモジュール も取得する。引数が std: プレフィックスを持つ文字列リテラル ("std:fs" 等) のとき、ファイルではなくホスト提供の標準ライブラリ名前空間を解決する:
import [read, write] := "std:fs" content := read("foo.hikari")!.unwrap!
標準ライブラリのモジュール一覧と各 slot は std/index.md で定義する。std: 解決の詳細 (拡張子・キャッシュ・静的検査) は §13.4 を参照。
import は同じ構文で third-party パッケージ も取得する。引数が pkg: プレフィックスを持つ文字列リテラル ("pkg:http" 等) のとき、pkg: に続く 別名 を、呼び出し元ファイルが属するパッケージの マニフェスト で解決し、相手パッケージの入口モジュールを返す:
import [get, post] := "pkg:http" get("https://example.com")
別名 → 取得元の対応・マニフェスト・lockfile・キャッシュ・推移依存の意味論は別書 packages.md で定義する。pkg: 解決の言語側の詳細は §13.4 を参照。
ファイル import の パス解決・キャッシュ・循環検出・エラー伝搬の詳細は §13.4 を参照。
ファイル名・パスはエラーメッセージの Position と import のパス解決にのみ使われ、意味論上の他の区別は持たない。
export によるファイル外公開の限定
ファイルオブジェクトの exported member 集合 を次で定める:
exportが無い → 全トップレベル member を公開(既定)。export [n1, …, nk]がある →{n1, …, nk}のみ公開。
import は評価したオブジェクトの exported member だけを露出する。未列挙のトップレベル名はファイル内ではレキシカルに参照でき(私的ヘルパ・相互参照)、importer からは member として存在しない(丸ごと束縛越しの .slot・import [名前] := "…" の分解とも該当スロット無しとして扱う)。
export [ … ] の中身は bare 識別子、または各名に任意で型注釈を付けた `name: 型` のいずれか(export [n1, n2: T2, …])。変数・式・文字列・補間は書けない(import のパス・リテラル限定と対称に、公開 member 集合を実行前に確定する)。export はファイルトップレベルの slot-list 領域に最大 1 個。body 領域(| 後、または | 無し #{hikari} の本体全体)や入れ子リテラルに現れると静的エラー。
型注釈付き export(封印契約): name: T の形で型注釈を付けた名前は、封印契約(sealed export contract)付きで公開する(§17.6)。importer から見た name の静的型を T に確定し、T が構造的に閉じたメンバ集合を持つ型(structural 型・関数型の結果型を含む)なら、その集合の外にあるスロットは importer から静的に到達不能になる。T は型式として検証し、未定義の型名・型になり得ない式は §2.2 と同型の静的エラーにする。型注釈の無い名前と`export` 自体が無いファイルは従来どおり(gradual・封印なし)で、挙動もエラーも変わらない(後方互換)。
13.3 root 終了時の未解決 Future
root リテラルの本体評価が完了した時点で、fork で生成され未だ resolve していない Future が存在する場合、それらは放棄される。block の残りの評価は走らない (実装は当該 goroutine を cancel する責任を持つ)。
最後まで結果を必要とする Future は明示的に f! で待つ義務がユーザ側にある。
13.4 import の詳細仕様
import <対象> := "path.hikari" (§13.2) の対象形・パス解決・キャッシュ・循環検出・エラー伝搬を定める。import は prelude 組込ではなく言語コアの宣言形 (§1.4) であり、評価時に 呼び出し元ファイル のパス情報を参照する点が通常の宣言と異なる。
対象とパス解決
- 対象は bare 名 (丸ごと束縛) または
[名前リスト](分解束縛) のいずれか。path は:=右辺の 文字列リテラル 1 個のみ (import m := "math.hikari")。変数・文字列補間"${...}"・bare 識別子・任意式は 構文エラー (static-analysis.md §1)。リテラル限定によりモジュールグラフを実行前に確定する。 - 引数のプレフィックスで解決系統が分岐する: `std:` → 標準ライブラリ解決、`pkg:` → third-party 解決 (いずれも下記)。プレフィックス無しは ファイルパス解決:
- 拡張子
.hikariは明示必須 (import m := "math.hikari")。 - パスは 呼び出し元ファイル基準の相対 として解決。
./../の有無を問わない ("math.hikari"≡"./math.hikari")。 - 検索パスによる解決は未対応 (third-party は
pkg:経由)。
標準ライブラリ解決 (`std:` プレフィックス)
- 引数が
std:で始まるとき ("std:fs"等)、std:に続く名前を ホスト提供の標準ライブラリモジュール として解決し、その名前空間オブジェクトを返す。一覧は std/index.md。 .hikari拡張子は 付けない ("std:fs"、"std:fs.hikari"ではない)。ファイル相対パス解決・cwd・呼び出し元ファイル基準は 適用されない (ホストが提供するため位置に依らず常に同じオブジェクト)。- 循環検出・ファイル不在・読み込み失敗の対象外。host code は常に存在するため、これらのエラーは起こらない。未知の
std:名は実行前の静的検査で弾く (static-analysis.md §1)。一部のstd:名は特定バックエンド専用で、静的検査は既知として受理するがインタプリタでは解決できずエラーになる (hikari build-js専用モジュールの扱いは js-backend.md §2.3)。 - キャッシュは絶対パスではなく `std:` 名そのもの をキーとする (
std:fs→ 同一オブジェクト)。プロセス内 1 回だけ構築し以降は同じオブジェクトを返す。 embedバイナリ (hikari build) でもstd:モジュールは常に解決可能 (ホストコードはバイナリに同梱される)。.hikariソースの embed FS とは独立。std:モジュールは値メンバー (コンストラクタ等) に加え、.hikariのtype/enumと同じく 型メンバー を export しうる。std:timeの不透明型Instant/Date/Time/Durationがこれにあたり、import t := "std:time"のもとでt.Instantと修飾参照するかimport [Instant] := "std:time"で取り出して型注釈に書ける (§17.4 と対称、static-analysis.md §2)。不透明型は型名のみ公開され、内部表現は露出しない (パターン分解不可)。
third-party 解決 (`pkg:` プレフィックス)
- 引数が
pkg:で始まるとき ("pkg:http"等)、pkg:に続く名前を 別名 (alias) とみなし、呼び出し元ファイルが属するパッケージの マニフェスト のdepsで解決する。解決先パッケージの 入口モジュール (マニフェストのentry) を返す。 - 別名は マニフェスト・ローカル — パッケージごとに独立した名前空間で、解決は常に「その
.hikariを含むパッケージのマニフェスト」基準。これにより推移依存どうしで別名が衝突しない。マニフェストの探索・スキーマ・取得元記法・lockfile・キャッシュ・推移依存の意味論は packages.md。 pkg:<alias>は相手パッケージの 入口 1 点 に着地する。ファイル単位のpkg:<alias>/<path>.hikariは持たない (相手パッケージ内部のファイル分割は、そのパッケージ自身の相対 import で閉じる)。.hikari拡張子は 付けない。ファイル相対パス解決・cwd・呼び出し元基準は適用されない (別名は論理名で、実体の場所はマニフェスト/lock/キャッシュが決める)。- キャッシュは
(取得元, バージョン, commit)をキーとし、同一の解決先は同じオブジェクトを返す。別バージョンの同一パッケージは別実体として共存する (バージョン解決器を持たない)。 - マニフェストを持たないファイルからの
pkg:import、depsに無い別名、未取得 (キャッシュ不在) は実行前の静的検査で弾く (static-analysis.md §1)。 embedバイナリ (hikari build) では依存ソースが embed FS の別名前空間に同梱され、pkg:は常に解決可能 (packages.md)。
キャッシュ
- 同一の解決済み絶対パスは プロセス内 1 回だけ評価、以降は同じオブジェクトを返す。
- キャッシュ登録は 評価完了時のみ。評価中エラーで中断したファイルはキャッシュに残らず、次の
importでゼロから再評価される。
循環検出
- 評価中のファイルパスをスタックに保持し、同一パスがスタックに既に含まれる
importを 循環 import としてobject.Errorで raise。 - スコープは同一プロセス内、トップレベル import チェインのみ。エラーメッセージに検出時の import チェインを含める。
エラーモデル
- 読み込み失敗 (パス未解決 / 読み込み失敗):
import位置でobject.Errorを raise。なおhikari <file>/hikari build実行・LSP では import 先不在・import 先の構文エラーは 実行前の静的検査 (static-analysis.md §1) で先に報告される。実行時の raise は REPL や動的到達のフォールバック。 - 評価エラー (import 先の評価中の例外): import 先で発生した例外がそのまま呼び出し元へ伝搬。中断したファイルはキャッシュに残らない。
意図的に 入れない もの: bare 識別子 import、任意式 import、リネーム付き名前 destructure、部分循環 import、動的読み込み / 遅延読み込み / 明示再読み込み、ファイルレベル inner-name、header のメタデータ拡張 (strict 属性を除く一般のメタデータ。§13.1)。詳細は language-spec-future.md。
import をファイルトップレベルの宣言に限定する (§13.2) のは、この遅延読み込み排除と整合する — 関数本体内 import は実質的な初回タッチ遅延読み込みになるため許さない。宣言形のため配置制限は文法で担保され、別途の placement 静的検査は要らない。
14. 標準ライブラリ
prelude (起動時に root フレームに束縛される) の定義は別書 prelude.md に分離した。import <対象> := "std:<name>" で取得する 標準ライブラリモジュール (std:fs 等、root には常在しない) は別書 std/index.md で定義する。
15. 統一原則の一覧
最後に、本仕様を貫く統一原則をまとめておく。実装やレビューでの判断基準として参照する。
- すべての値は `{ slot-list | body }` の形を取る — slot-list は名前軸と位置軸の両方を持ち得る (原始型・コレクションを含む)。inner-name は
inner nameとしてスロットに書く (§4.2) - 関数は概念的に1引数を取る (スロット宣言はその引数を destructure するパターン)
- 並置 = 関数適用 (
f xは常に apply) - `(...)` は要素数で意味が変わる (1要素はグルーピング、2要素以上はタプル、0要素は空タプル。関数適用時に自動 destructure、N と要素数不一致ならエラー)
- `,` はリスト的並列性 (スロット列・タプル要素・引数列・多値返却)
- `;` / 改行はシーケンス (中間値は捨て、最後の値が返り値)
- `:=` は immutable 宣言、`=` は mutable (宣言と再代入を統合。bare 必須スロット・位置スロットは immutable。closed object では slot はリテラル宣言時のみ生まれる)
- メソッド呼び出しは三つの記法で等価 (
1 + 2≡1.+ 2≡1.+(2)、識別子も同様) - 構文形のみ予約語 — 宣言・照合の構文形 (
type/enum/match/import/export/loop/conduit/extensible/inner) を予約語とする。制御構造 (if/when/while)・演算子・エラー処理・自己参照名は予約語ではなく、値/スロット/書き手が選ぶ名前である - 唯一の意図的な逸脱は `&&` / `||` の短絡 (実用上の妥協)
- `!` は postfix 呼び出しの短縮 (論理否定は
.not())。`?` は postfix 失敗 bail (Result / Option の失敗 variant で関数から脱出するreturnの構文糖、§16.5) - すべての名前スロットが埋まれば本体評価、一つでも欠ければ部分適用。デフォルトの補完は明示的 kick (`()` / `!`) に固有 (位置スロットは binding 対象外。判定は宣言形に依存しない。制御構造もこの規則で
if x {y} {z}の段階 curry が成立する) - 真偽値文脈は Bool 必須 (truthy/falsy なし、非 Bool は panic。
if (xs)不可、if (xs.length! > 0)と明示) - トップレベルは暗黙 root リテラルの body (
:=は root フレームのローカル束縛として一元化) - リテラルは既定で closed、`extensible` で開く (無印は closed で literal 宣言時のみ slot 生成、
extensible{...}/extensible[...]は実行時に slot 追加可な shape-extensible リテラル。slot 値の immutable/mutable とは直交)。宣言・照合の構文形 (type/enum/match/import/export/loop/conduit/extensible/inner) は予約語 (§1.4) - 値ディスパッチは `match` に一本化 (予約語の専用構文
match subject { pattern => body }。値は==、型値はmatches、レコードは構造照合+束縛、値コンストラクタSome(x)/Ok(v)はタグ判別+payload 束縛で分岐する。arm はpattern => body、OR はpattern1, pattern2 => body、ガードはpattern | guard => body、catch-all は_ => body。prelude.md §8.4)。独立した述語の順次分岐は subject を省いた subjectlessmatch { pred => body }が担う (§8.4)。subjectful =単一 subject の構造照合、subjectless =述語チェーンで分岐の軸が異なる。
16. エラーモデル
hikari のエラーは 値 であり、専用の例外機構や大域脱出を持たない。失敗の重さに応じて 3 つの層に分ける。同じ「大域脱出を持たない」方針を共有する制御構文(break / continue / return・ユーザ定義制御構造・末尾呼び出し)は、エラーとは別系統の制御値で実現され §18「制御フローと制御値」にまとめる。本章で扱う失敗 bail ?(§16.5)はその制御値を用いるが、エラー処理の一部としてここに置く。
16.1 三層モデル
| 層 | 種別 | 表現 | 伝播 |
|---|---|---|---|
| 静的エラー | 構文エラー等、実行前に判明するもの | (値にならない) | 実行開始前に全体を検査して報告 |
| 回復可能エラー | ファイル不在など想定内の失敗 | Result 値 Ok(v) / Err(e) (§17 型語彙)。e はエラー型 E の値 (既定は {kind, message} の Error) |
自動伝播しない。呼び手が match (値コンストラクタパターン) / unwrap! で分岐する |
| panic | プログラムのバグを示す致命的失敗 | object.Error |
評価のどの位置でも上位へ自動伝播し、トップレベルで停止する |
16.2 層の振り分け原則
ある失敗をどの層に置くかは 「プログラムのバグか、想定内の状況か」 で決める (Rust / Go と同じ指針)。
- バグ (型不一致、未定義参照、arity 不一致、ゼロ除算など、コードを直すべきもの) → panic
- 想定内の状況 (ファイル不在、権限不足など、起こりうるもの) → 回復可能エラー
- 実行前に判明するもの (構文エラー等) → 静的エラー
これらの panic は型不一致や unwrap! のように 言語・組込が contract 違反として自動で起こすのが基本だが、ユーザコードからも明示的に panic を起こせる。組込 panic(message) (prelude.md §1) は message (String) を持つ panic 層エラーを発火する — 「到達しないはず」「未実装」「呼び出し前提の違反」のような バグを表明するための手段である (Rust の panic! / Haskell の error に相当)。
- panic は §16.1 のとおり評価のどの位置でも上位へ自動伝播し、トップで診断付き停止・非ゼロ終了する。診断は発生位置とメッセージに加え、そこへ至る関数呼び出しの経路 (各呼び出しの位置と、呼ばれた関数の名前。内側から外側の順) を含む。経路は診断表示のみに使われ、hikari コードから観測する手段は無い。診断を出さず exit code だけを返す
exit(code)(意図的終了、prelude.md §1) とは別系統である。 panicは 発散する (値を返さない) ため、型上の結果は `Never` (ボトム型、§17.2) で任意の位置に置ける (match { p => v, _ => panic("unreachable") }のような分岐の穴埋めに使える)。Neverは全型の部分型なので発散アームは分岐合流で他方の具体型へ寄り、結果型をAnyに倒さない (static-analysis.md §4)。messageが String でなければそれ自体が型不一致 panic になる。- 想定内の失敗 (回復可能エラー) には
panicを使わない。失敗を値で運ぶ §16.3 の Result /Err(e)を使う。panicはあくまでバグ層への明示的な入口である。
16.3 回復可能エラーの形
回復可能エラーは kind (String) と message (String) の 2 スロットを持つ closed なデータオブジェクト (§2.4)。
kind: 機械可読な種別。分岐に使う。match err.kind { … }(prelude.md §8.4) でそのまま key にできるmessage: 人間可読な説明。表示・ログ用。分岐に message の文字列マッチを使わない
型語彙 Error(= {kind: String, message: String |})でこの形を照合・注釈できる (e matches Error / x: Error、§17.4)。
Error は Result の 既定のエラー型である。Result(T) は Result(T, Error) の別記で、E を省略するとこの {kind, message} 形を既定にする (§17.4)。エラーを型で区別したいとき (parse の Syntax / Overflow 等) は Result(T, E) でエラー型をパラメータ化し、E に構造化型 (enum 等、§17.4) を据える。組込 (input / std:fs ほか) はすべて既定の Error を返す。
import [read] := "std:fs" match read("config.hikari")! { Ok(content) => use(content) Err(e) => match e.kind { "not_found" => use_default! "permission_denied" => abort! _ => print(e.message) # 未知 kind } }
16.4 kind の集合 (閉/開のハイブリッド)
- 組み込みが返す kind は正規一覧として文書化する (閉じている。prelude 各節が列挙)
- ユーザ定義のエラーは任意の kind 文字列を使ってよい (開いている)。エラー値は
{kind := "...", message := "..." |}のオブジェクトリテラルでも、prelude のerror(kind, message)(prelude.md §13.3) でも組める。専用構文は要らない - 受け手は 未知 kind 用の default 分岐 を持つことを慣習とする (
kindは開いた String なので網羅性は静的に保証されない。closed variant —Option/Result/enum— のmatchは strict モードでのみ網羅性を要求する。static-analysis.md §3 規則 5)
実行開始前の一括構文検査 (構文エラー・動的 import 排除・import 解決層エラー) は static-analysis.md §1 を参照。
16.5 失敗 bail (?)
? は Result / Option の 失敗 variant に当たったら即座に関数から脱出 する postfix 演算子で、§18.2 の return の上の構文糖である (新しい脱出機構ではない)。逐次処理の「失敗なら即抜け、成功なら中身を取り出して次へ」を、match や when (…is_err!) {return …} のネストなしに平坦に書ける (Rust の ? 相当)。
e? は対象 e を一度だけ評価し、その variant で分岐する:
| 対象 | 成功 → 式の値 | 失敗 → 脱出 |
|---|---|---|
Result |
Ok(v) → v |
Err(e) → return r (Result をそのまま返す) |
Option |
Some(v) → v |
None → return None |
- postfix:
!(§15 #11) と同じ後置で、対象が Option / Result 値であることを要求する。step!?は(step!)?と割れ、「!で resolve / kick した結果の Option / Result に?」が一様に通る。手元の値にはr?。Option / Result 以外への?は panic (バグ層、§16.2)。 - `return` の構文糖:
r?≡match r { Ok(v) => v; Err(_) => return r; Some(v) => v; None => return None }(対象は一度だけ評価)。脱出は §18.2 の Return 制御値で実現し、字句スコープ・透過範囲・トップレベル終了は §18.2 と完全に同一。if/when/match/while/eachのブロックは透過 (?の失敗が外側の関数から脱出)、自前の高階関数に渡したブロック内の?はそのブロックから脱出、トップレベルの失敗はモジュール評価を終了する。 - bail 値: Result は Result そのものを返す (
Err(e)を再構築せず payload{kind, message}を保全)、Option はNone。 - 戻り型の収束 (strict):
?の失敗 bail は関数の戻り型に「失敗 variant 型」を寄与する (§18.2 のreturnと同様、末尾値と合流する)。脱出する値はErr(e)/Noneであって成功 payload を持たないため、寄与するのは失敗 variant 型のみ — bail の成功 payload 型は関数の戻り成功型を制約しない。したがって Ok 型の異なる逐次?(上の例のread(path)!?はResult(String)、末尾Ok(cfg)はResult(Cfg)) も末尾値と収束する (Err のEだけが戻り型へ流れる)。一方、1 つの関数で Option bail (return None) と Result bail (return Err(…)) を混ぜると variant の種別が割れる。通常モードはこれをAnyに倒して素通しするが (漸進性)、strict モードは確実に割れる合流として報告する (static-analysis.md §3 / §4)。「?を Result / Option を返さない関数で使う」ケース (末尾値と bail の種別が割れる) も同じ収束判定で strict が拾う。
- トップレベルに達した失敗の表面化: entry(実行スクリプト)の最上位で
?が失敗 variant(Err(e)/None)により bail したとき、それは「未処理のまま最上位へ達した失敗」として評価エラー扱いになり、stderr に診断を出して非ゼロ終了する(exit code 1、hikari-command.md §5.4)。診断本文はErr(e)ならeの message(標準 Error の場合)、Noneならbailed on None。?の下地であるreturnに失敗 variant を直接渡した場合も同様に表面化する。strict モードでは file も暗黙の関数(§13.1)として本節「戻り型の収束」の対象になるため、トップレベルで?を使う strict ファイルは末尾値を bail の失敗 variant と収束する型にする — 典型的にはOk(())で締める(Rust のfn main() -> Result<(), E>と同じ契約)。非 strict では収束検査が無く末尾型を問わない。REPL では行単位のエラー表示となりプロンプトへ戻る(プロセスは終了しない)。unwrap!(失敗=バグの表明 → panic)との違いは、こちらが「想定内の失敗が未処理で最上位に達した」通常のエラー経路である点で、診断は Trace を持たない単一行になる。
process := { path |
content := read(path)!? # Err なら read の Result をそのまま return、Ok なら content
cfg := parse(content)? # Err なら parse の Result をそのまま return、Ok なら cfg
Ok(cfg)
}
Option / Result の値・メソッド (unwrap! / or / map ほか) は prelude.md §13。
17. 型注釈 (type annotations)
変数・パラメータ・スロット・分解束縛・inner-name・関数の結果型・任意の式に 型注釈 名前: 型(式に対しては 式: 型)を付けられる。型はキーワードではなく 値(述語) であり、prelude が束縛する。
意味論(実行時照合): 注釈は構文として受理され AST に保持され、値が束縛される地点で照合される。対象は変数束縛 (x: 型 := v / x: 型 = v)・分解束縛 (a: 型 := … / [x: 型] := …)・呼び出し時のパラメータ束縛 ({x: 型 | …}(arg)、引数とデフォルトの両方)・スロットのデフォルト確定 ({x: 型 := d |})・式アスクリプション ((式): 型、式の評価地点で照合)。照合は §17.4 の表に従い、一致しなければ 型不一致として panic する (§16.2 のバグ層。未定義参照・arity 不一致と同じ)。注釈式が型値に評価できない場合 (型でない名前を注釈に書いた等) も同位置で panic する。注釈の無い箇所・Any 注釈は素通しする (漸進的型付け)。
位置づけ(健全 gradual な静的型付け): hikari は健全 gradual な静的型付け言語である。型検査は評価前に行える(hikari check / --strict / LSP、static-analysis.md)が、注釈は任意で、確定しない箇所は Any に倒れる(漸進的型付け)。Any を残すことは「静的か動的か」を分けず「無条件全域健全か否か」を分けるだけである — 型消去をしないため(注釈は上記のとおり実行時照合され backstop になる)、動的逃げ道を使わなければ静的型付けとして振る舞う。
健全性保証(`--strict`): --strict(static-analysis.md §3)が診断ゼロで通れば、チェッカが具体型(`Any` でも「型不明」でもない型)を割り当てた各評価点で、実行時の値はその型と整合する。すなわち `Any` を経由しない領域では §16.2 の型的 panic(型不一致・no such slot/method・arity 不一致・未定義参照)が起きない。Any 境界での逸脱は上記の実行時照合が backstop として捕捉する。系: Any・未確定を一切含まないプログラムは全域で型的 panic を起こさない。
gradual 境界の定義: チェッカが Any または「型不明」に倒す箇所を gradual 境界と呼び、上の保証の適用外とする。幅構造型(§2.4)の未宣言メンバ参照・open object(`extensible`)へのアクセスは、受信者が `Any` でなくても定義上 gradual 境界である(その未宣言部の型は静的に閉じない)。未定義参照だけは gradual 境界ではなく(値が存在せず確実に stuck するため)、--strict は静的に報告する。
関数型の照合の深さ(§17.4): 関数型 {P… | R} は宣言シグネチャの構造照合を行う — 引数数に加え、関数値が宣言する各引数スロットの型注釈を期待引数型 P… と、body 末尾アスクリプション(§17.3 form 3)で宣言された結果型を期待結果型 R と、型値として突き合わせる(不変。変性は取らない=§17.2 の higher-rank 非対応と整合)。注釈の無い引数位置・結果型を宣言しない関数は素通しする(漸進的型付け。無注釈関数は従来どおり引数数のみで一致し、後方互換)。
- inner-name 型注釈(§17.3 form 2)が宣言する引数型・結果型も構造照合に用いる — 値に載る宣言なので、インライン引数注釈・body 末尾アスクリプションが無い位置の補完源として突き合わせる(両方あればインライン優先)。
- 束縛注釈による結果型宣言は、bind 地点の構造照合(
funcSignatureMatches)では値に載らないため対象外だが、下記の呼出時強制では wrapper が宣言型を保持するため強制される。 - 関数型注釈が付いた境界の値(パラメータ型・束縛注釈・inner-name 注釈が関数型)は、呼出しごとに宣言 param 型で実引数を・宣言 result 型で実返り値を wrap して照合する(呼出時強制。具体型の位置のみ照合し、
Any・型変数の位置は素通し)。逸脱は型不一致として panic する(§16.2)。これにより高階の境界(無注釈・結果型省略で bind 地点の構造照合が素通しした関数値)も、呼び出された時点で宣言型に対して実照合される。 - 例外: 宣言結果型が `Unit`(
{… | Unit})のときは結果照合を行わない —Unit結果位置はdiscard コンテキストであり、呼び出し側は結果を捨てる(fire-and-forget コールバックの慣用)。本体が非Unit値を返しても呼出時強制の対象外として素通しする(TypeScript のvoid戻り型と同型)。結果を実際に使う箇所では use-site の束縛注釈(x: T := f(…))が backstop になるため健全性は保たれる。パラメータ型の照合はUnit位置でも通常どおり行う(discard は結果位置に限る)。 - 既定エラー型の `Result(T)`(エラー型
Eを省いた形、§17.2)を宣言結果型に持つ関数の呼出時強制では、Err(...)の payload を未制約として受理する(static-analysis.md §4 が既定 Error を identity=未制約とみなすのに揃える。Ok(T)のTは引き続き照合する)。これによりfmt: {String | Result(String)}のようにErrにメッセージ文字列を載せる慣用が呼出時強制で壊れない。エラー型を明示したResult(T, E)はEを照合する。なお束縛注釈の実行時照合(x: Result(String) := v)は既定 Error を標準 Error 形に厳格照合する既存挙動のままで、契約 wrapper(緩い)との非対称が残る(将来この厳格さの解消を別途検討)。
浅い照合に留める箇所(深い検査は将来段階、language-spec-future.md): Future(T) の payload(await 前は検査できず、Future かどうかのみ)。inner-name 型注釈が宣言する引数型・record 型(データオブジェクト)は構造照合に用いる(インライン注釈・body 末尾アスクリプションが無い位置の補完源)。inner-name の関数型注釈が宣言する結果型は浅い照合ではなく、束縛注釈・body 末尾アスクリプションと対称に呼出時に wrapper で強制する(§17.4)。inner-name 型 ({ inner self: 型 | … }) と slot/結果型の整合は strict 型検査モードが検査する(矛盾=型エラー、static-analysis.md §3)。
17.1 記号と位置
: は型注釈専用の記号。:=(束縛)とは字句上区別される(x: Int は x : Int の 3 トークン、x := 0 は := の 1 トークン)。
- パラメータ / スロット:
{x: Int, y: Int | x + y} - デフォルト付き(immutable / mutable):
{x: Int := 0 | ...}/{count: Int = 0 | ...} - 変数束縛:
total: Int := 0/total: Int = 0(型注釈付き束縛は値を伴う。name: T単体の宣言は持たない) - inner-name:
{ inner self: T, slot-list | body }(自身の値の型。§4.2) - 分解束縛(位置 / 名前):
a: Int, b: String := f()/[x: Int, y: Int] := v(破棄子_に型は付けない) - 式アスクリプション:
式: 型(任意の式に後置し「この式は当該型に評価される」と表明。(self.x - other.x): Int/f(x): String。関数の結果型は body 末尾式へのアスクリプションで書ける。§17.3)
`:` の結合強度: 式アスクリプションの : は最低優先(二項演算子・関数適用より弱く、非結合)。f x: Int ≡ (f x): Int、a - b: Int ≡ (a - b): Int と読む。括弧は任意(hikari fmt は曖昧になりやすい二項式アスクリプションに括弧を補う)。: と := は字句区別されるため foo := bar: Baz は foo := (bar: Baz)(束縛の右辺へのアスクリプション)であり、束縛注釈 foo: Baz := bar とは別位置の表現。アスクリプション右辺の型は型コンテキストで評価される(§17.2)ため、タプル型は x: (Int, String) と単一括弧でよい(: が型オペランドを区切るので matches の二重括弧問題は起きない)。
17.2 型の正体(型=値、値が一形なら型も一形)
値が { slot-list | body } の一形なら、型も同じ一形で表す。function と record は同じ `{}` 型フォームで、結果型(body)の有無だけが違う(値の「body 有無=関数かデータか」と対称):
- 原始型:
Int/Float/String/Bool/Unit/Bytes/Any/Never - 特殊型:
Any(任意の値に一致するトップ型)/Never(値を持たないボトム型。全型の部分型。panic/exitのような発散位置の結果型で、どの値にも一致しない=v matches Neverは常にfalse。§16.2) - record / object 型(body なし):
{x: Int, y: Int |} - function 型(body 位置に結果型):
{Int, Int | Int}(位置の型+結果)/{x: Int, y: Int | Int}(名前付き+結果) - tuple 型:
(Int, String)((T)の 1 要素はグルーピングであり tuple にならない、§3.3) - 型コンストラクタ(固定形に書けない可変長 / union / 不透明 payload):
List(T)/OneOf(A, B)/Option(T)(=Some(T)∣None)/Result(T, E)(=Ok(T)∣Err(E)、E 既定は{kind, message}のError。Result(T)≡Result(T, Error))/Future(T)。ユーザ定義のタグ付きデータ(enum、§17.4)の型値もOneOf(...)相当でここに属する — 各タグは型値Nameの下に住む slot として公開され、Name.Tagで参照する(フラット束縛はしない) - 順序型:
Ordering(=Less∣Equal∣Greater、順序比較の結果。§9.4) - 構造的 interface(capability 型): メソッド契約を表す構造型。
Comparable(={compare |}、§9.4)/Copyable(={copy |}、§9.5)は組込特別型ではなく prelude 定義の構造型であり、専用の型 kind を持たない。ユーザーも同じ構文で定義できる(§17.5)
型コンテナの役割は一つに固定する: {...} / [...] = object/record/function、(A, B) = tuple、List(T) = list。型位置の `[...]` はリスト型ではない(list は List(T)、固定位置は tuple (...) に書く)。値位置と同じく [...] は {...} と同じ 2 セクション構造を取り、0-pipe の意味だけが違う(下記・§2.1.1)。
型位置の `{}` の解釈(値位置と異なる):
| 結果型あり = function 型(結果型を持つ)。| 結果型なし = structural 型(object/record/interface)。末尾|は必須(0-pipe は下記の function 型)。エントリは:name: 型= 型付きフィールド(データ契約。名前スロットが型に一致)- bare 小文字
m= メソッドフィールド(mに応答する契約。§17.5 の構造的 interface) - bare 大文字
Name= 埋め込み(Nameが指す structural 型の全エントリを取り込む。§17.5)
(従前ここには「bare 識別子=位置型フィールド」の記載があったが、positional shape 型は評価器が未実装で dormant だったため、bare エントリは上記のメソッドフィールド/埋め込みに割り当てる。§17.5。)
pipe なしの `{T}`(0-pipe)は body 形であり、値位置の {body} と同じ規則に従う。型位置では body が結果型になるため {T} ≡ {| T} =「引数なしで T を返す関数型」となる(structural 型にしたい場合は末尾 | が必須で {… |} と書く)。これは値 {5}(引数なしで 5 を返すブロック)の型が {Int} になる、という値↔型の対称の帰結である:
| 書き方 | 意味 |
|---|---|
{Int} |
引数なし → Int を返す関数型(≡ {| Int}) |
{x: Int |} |
フィールド x: Int を持つ record/structural 型 |
{compare |} |
compare に応答する構造的 interface(§17.5。Comparable) |
{draw: {| Unit} |} |
draw が {| Unit}(引数なし→Unit)に応答する interface。メソッドシグネチャ契約(§17.5) |
{Int, Int | Int} |
(Int, Int) → Int の関数型 |
型位置の []
型位置の [...] は型位置の {...} と同じ意味を持ち、エントリの解釈(name: 型 = 型付きフィールド / bare 小文字 = メソッドフィールド / bare 大文字 = 埋め込み)も共通である。違いは値位置と同じく 0-pipe の意味だけ — [X] は slot-list なので structural 型になり、末尾 | が要らない:
| 書き方 | 同等な {} 形 |
意味 |
|---|---|---|
[x: Int, y: Int] |
{x: Int, y: Int |} |
フィールド 2 個の record/structural 型 |
[compare] |
{compare |} |
compare に応答する構造的 interface(§17.5) |
[] |
{|} |
フィールドを問わない空の structural 型 |
[Int, Int | Int] |
{Int, Int | Int} |
(Int, Int) → Int の関数型 |
record 型を書く頻度が高い type 宣言では [...] 形のほうが短く書ける:
type Point := [x: Int, y: Int] # ≡ type Point := { x: Int, y: Int |}
`[Int]` はリスト型ではない。bare 大文字は埋め込みなので [Int] は「Int を埋め込んだ structural 型」と読まれ、Int は structural 型でないためエラーになる(List(Int) を促す診断を出す)。リストは List(Int)、固定長・異種の並びは tuple (Int, String) に書く。
型値の正準表示(エラーメッセージ・Inspect)は {} 形に統一する。[x: Int, y: Int] と書いた型も { x: Int, y: Int |} と表示される(同じ型の書き方が 2 つあるだけで、値としては 1 つのため)。
型名は大文字始まりの慣用であり、予約語ではない(prelude 束縛の普通の値)。casing 慣用の全体は §1.2.1 を参照(型値・値コンストラクタは大文字、通常の値・関数は小文字)。
型位置の bare 識別子のうち大文字始まりは型参照、小文字始まりは型変数(下記)。小文字名に束縛した型値を型位置で参照するには大文字名(type など)を経由する。
型変数(type variable): 型コンテキストの bare 小文字識別子は型変数を表す(大文字は具体型参照)。関数型 { … | … } に現れる型変数はその関数型単位で全称量化され(最外周量化)、同一シグネチャ内の同名は同一の型変数を指す。呼び出し側が各呼び出しごとに具体型へ確定する。
id := { x: a | x } # 型 { a | a }
map: { List(a), { a | b } | List(b) } := { … } # a, b を量化
境界付き型変数(type set 境界 / interface 境界): 型変数には、確定できる型を制限する境界を付けられる。境界は型変数の初出にアスクリプションで後置する — named 位置は x: a: Number(パラメータ x の型は型変数 a、a の境界は Number)、位置形式はグルーピング括弧で (a: Number)(§3.3 の 1 要素括弧)。境界の後置は一段のみ(a: Number: T は構文エラー)。文の束縛注釈(v: 型 := …)の型位置はアスクリプション非結合(§17.1)のため連鎖形を書けない — グルーピング括弧 v: (a: Number) := … で書く(slot-list 内の named 位置は連鎖形 x: a: Number を書ける)。同一シグネチャ内の同名型変数は同一なので境界は初出の 1 箇所に書く(複数箇所に書く場合は同じ境界でなければ型エラー)。境界に置けるのは閉じた union(OneOf(...)・そのエイリアス・原始型単体)または構造的 interface(Comparable・Copyable・ユーザー定義 type interface・合成・埋め込み・データ契約を含む TypeRecord、§17.5)——前者は「確定型がメンバーか」、後者は「確定型が interface を満たすか」で閉じる。interface 境界 a: I は「I を満たす単一の型で、同一シグネチャ内の全 a はその同じ型を共有する」ことを表す(union を境界とする type set 境界の同型リンクを interface へ一般化したもの)。境界付き型変数は量化の単位である関数型の中でのみ意味を持つ。
sum := { x: a: Number, y: a | x + y: a } # 型 { (a: Number), a | a }
sum(3, 7) #> 10 (a = Int に確定)
sum(1.5, 0.5) #> 2.0 (a = Float に確定)
sum(1, 2.0) # 型エラー / panic(a が Int と Float に割れる — 混在は不可)
sum("a", "b") # 型エラー / panic(String は Number のメンバーでない。body の + 以前に境界で弾かれる)
境界は「型の集合」への制約であって値の型ではない。union を直接注釈した x: Number, y: Number は「各引数が Int または Float」を独立に言うだけで混在を許すのに対し、x: a: Number, y: a は「両引数が同じ数値型で、結果もその型」まで表明する(数値多相の正書法。prelude.md §3・std/math.md §1)。interface 境界も同様の対比を持つ — x: Comparable, y: Comparable は「各引数が独立に Comparable」を言うだけで混在を許すが、x: a: Comparable, y: a は「両引数が同じ型で、ともに Comparable」まで表明する:
max := { x: a: Comparable, y: a | if(x < y, { y }, { x }): a }
max(3, 7) #> 7 (a = Int)
max("a", "b") #> "b" (a = String)
max(1, 2.0) # 型不一致(a が Int と Float に割れる)
無境界の型変数は実行時に消去され、注釈位置の型変数は実行時照合で素通しする(Any と同じ実行時挙動。静的には型の同一性を追跡する)。境界付き型変数は実行時にも単一化する — 呼び出し時、型変数の初出で実引数 v を境界に照らして検査する(union 境界は確定型が境界のメンバーか、interface 境界は Matches(I, v)〔§17.5 の照合〕を満たすかで判定し、満たさなければ型不一致 panic)。満たせば確定型を union 境界ではその型(メンバー自身)に、interface 境界では値 `v` の実行時型に pin し、同一シグネチャ内の以降の出現(引数・body 末尾アスクリプション由来の結果)は確定型と同型照合する(違反は通常の照合と同じ panic。§17.4)。interface 境界は原始型(Comparable の主戦場)では TypeOf が Int/Float/String/Bool を返すため type set 境界と完全に同じ挙動になり、非原始型では確定型が構造型となるため構造的同型を要求する(形の異なる値どうしは以降の照合で panic)。ネストした関数型をまたぐ多相(higher-rank)・無注釈関数の自動多相化は持たない。
17.3 評価前と評価後
名前: 型 は常に「名前が今持つ値(評価前)」を表す。関数を束縛した名前の型は関数型であって結果型ではない:
add: { Int, Int | Int } := { x, y | x + y } # add は関数(評価前)
r: Int := add(1, 2) # 呼び出し結果(評価後)を別途束縛
呼び出し g(a) と await f! は「型を一段はがす」操作で、それぞれ function 型の結果型・Future の payload を取り出す(将来の検査規則)。slot の型と inner-name の型は同じ値への独立した表明であり、矛盾は型エラー(いずれかが暗黙に優先することはない)。両者の整合は strict 型検査モードが検査する(static-analysis.md §3)。inner-name 型注釈が宣言する引数型・結果型は、値の構造照合(§17.4)で補完源として用いる(body 末尾アスクリプション・インライン注釈が無い位置を埋める)。
関数の結果型の宣言: 関数値の結果型は、次の 3 形のいずれでも宣言できる(いずれも既存注釈の応用であり、新しい記号・予約語は導入しない)。3 形とも「結果型を含む関数型」を表明する:
add: { Int, Int | Int } := { x, y | x + y } # 束縛注釈: 束縛名 add の型として結果 Int を宣言
add := { inner self: { Int, Int | Int }, x, y | x + y } # 内部名注釈: self の型=自身の関数型 (body 位置が結果型)
add := { x, y | x + y: Int } # body 末尾アスクリプション: 返り値 (body の値) に結果型を後置
内部名注釈は関数値そのものに結び付くため、束縛名を持たないメソッド・演算子スロット(+ := {other | …} 等)でも結果型を表明できる。
body 末尾アスクリプションは、結果型を別建ての関数型に書き出さず返り値そのものに後置するため、パラメータの slot-list を二度書かずに済む(パラメータ型は値の slot-list、結果型は body にそれぞれ一度だけ現れる)。value↔型の対称を強める: 関数*型* {params | ResultType} で結果型が body 位置に来るのと対称に、関数*値*でも結果型が body の値に付く。また式アスクリプションは「式の評価地点で照合」する(§17 冒頭)ため、body 末尾アスクリプションは呼び出しごとに返り値を実照合する — 束縛注釈の浅い照合(callable と arity のみ)より強い実行時保証になる。
通常モードではいずれの結果型注釈も静的照合は将来段階だが(body 末尾アスクリプションは上記のとおり実行時には照合される)、strict 型検査モード(static-analysis.md §3)は 3 形すべてを契約として用いる。関数値の構造照合(§17.4)では、内部名注釈(form 2)と body 末尾アスクリプション(form 3)が宣言する引数型・結果型を照合に用いる(内部名注釈は値に載る宣言のため、インライン注釈が無い位置の補完源になる)。束縛注釈(form 1)は値に載らないため構造照合では用いない。
3 形とも呼出時に結果型を強制する: 内部名注釈が宣言する関数型注釈の結果型も、束縛注釈・body 末尾アスクリプションと対称に、呼び出しごとに実際の返り値を照合する(値の生成時点で結果型を wrap する。§17.4 の呼出時強制と同じ機構)。これにより 3 形は宣言位置が違うだけの、結果型についての同一の実行時契約になる。
17.4 型語彙の値化と型定義 type
型注釈で使う型は prelude 束縛の値(型値) である。型値の束縛と type 定義・照合の表現はここで確定する(注釈位置 名前: 型 自体の評価・検査は §17 のとおり将来段階)。
- 原始:
Int/Float/String/Bool/Unit/Bytes/Any/Never(Neverはボトム型。下記照合表と §16.2) - 型コンストラクタ:
List(T)/OneOf(A, B, …)/Option(T)(=Some(T)∣None)/Result(T, E)(E 既定は{kind, message}のError。Result(T)≡Result(T, Error))/Future(T) - 順序:
Ordering(=Less∣Equal∣Greater) - 数値グループ:
Number(=OneOf(Int, Float)の透過的エイリアス。閉じた union。主用途は境界付き型変数の境界a: Number(§17.2)で、単独の注釈x: Numberは「Int または Float」のみを表し引数間の同型は要求しない) - 構造的 interface:
Comparable(={compare |}、§9.4 / §17.5)/Copyable(={copy |}、§9.5 / §17.5)。prelude 定義の structural 型で、ユーザーも{メソッド名… |}で定義できる。単独の型述語(v matches Comparable・x: Comparable)としても、境界付き型変数の境界a: Comparable(interface 境界。§17.2)としても使える - shape: record
{x: Int |}/ function{Int, Int | Int}(body=結果型)/ tuple(Int, String) - 組込値型:
Range(§7.6 の遅延シーケンス値の型)/Error(={kind: String, message: String |}の shape エイリアス。Resultの既定エラー型でResult(T)≡Result(T, Error)のErr(Error)が指す)
型定義は type で書く:
type Span := { text: String |} # record 型 type Add := { Int, Int | Int } # function 型 type Pair := (Int, String) # tuple 型 type Nums := List(Int) # 型コンストラクタ(type の有無で同値)
type Name := <型式> は右辺を型コンテキストで評価し、結果の型値を Name に束縛する(:= のみ・immutable)。型コンテキストでは {...}=shape 型、(A, B)=tuple 型、[...]=構文エラー、bare 識別子=型参照(§17.2)。Name は型値として普通に参照できる(pos: Name)。型名は大文字始まりの慣用(予約語ではない)。
type Name := <型式> で名付けた型は、照合・等価では基底(展開)型と完全に同一だが、Name を経由して参照された型値の表示(エディタ支援の hover・型診断メッセージ)では展開形ではなく別名 Name を保つ。注釈なしのリテラルから推論された構造型には別名は付かない(基底型をそのまま表示する)。
出現位置: type Name := <型式> は通常の束縛と同じ場所に書ける。オブジェクトリテラルの slot-list(ファイルトップレベルを含む。§2.1)では `Name` の名前スロットを宣言する(値=型値・immutable・他の Name := … と同様にモジュール export になる)。文/ブロック body や REPL では Name を束縛する文として働く。どちらも「Name に型値を束縛する」点は同じで、name := value が slot-list では名前スロット・body では束縛文になるのと対称である。宣言であって式ではないため、部分式の位置(引数・:= / = の右辺・(...) 内)に現れると構文エラー(§1.5。enum も同じ)。import と違い入れ子リテラルの body には書ける。文として評価されたときの値は unit ()(§1.5。body 末尾に置いても body の値は () — 定義した型値は Name で参照する)。slot-list のスロット値は従来どおり型値そのもの(member として export される値)。
ジェネリック型定義: type Name(a, b) := <型式> / enum Name(a) := <variants> は LHS の頭部で型パラメータ(小文字型変数)を束縛し、RHS でそれを参照する。
type Pair(a, b) := (a, b) # 透過的別名 enum Box(a) := MkBox(a) # 単一タグ enum Either(a, b) := OneOf(Lft(a), Rgt(b)) # 多相直和
適用 Pair(Int, String) は本体に引数を代入した型(≡ (Int, String))に展開する。arity 不一致は型エラー。type は透過的で基底型と構造的に同一、表示は別名 Pair(Int, String) を保つ。enum のタグ値コンストラクタは多相(Either.Lft の型は {a, b | Either(a, b)}、Either.Lft(5) → Either(Int, b))。インスタンス化済みジェネリック enum(Either(Int, String))の matches は List(T) / Option(T) と同じく payload を再帰照合する。payload 位置が型変数なら構築時検査は素通しし、具体型の payload 位置は従来どおり検査する。
再帰ジェネリック enum: 自己参照する型パラメータを持つ再帰ジェネリック enum(例: enum Tree(a) := OneOf(Leaf(a), Node(Tree(a), Tree(a))))も書ける。Name(...) を先行登録してから右辺を評価するため、タグ payload に自分自身の適用 Tree(a) を参照できる。インスタンス化 Tree(Int) は遅延適用として表現し(その場では展開しない)、matches 時に 1 段だけ展開して値に照合する — 値は有限なので照合は停止する。非ジェネリックの再帰 enum(enum Tree := OneOf(Leaf(Int), Node(Tree, Tree)))も従来どおり使える。
照合の意味論(変数束縛・分解束縛・呼び出し時のパラメータ束縛・スロットのデフォルト確定で照合する。関数型の引数型と body 末尾アスクリプション由来の結果型は宣言シグネチャの構造照合を行い、さらに関数型注釈が付いた境界の値は呼出しごとに実 arg/結果を wrap して照合する〔§17 冒頭。無注釈位置・Any・型変数は素通し〕。内部名注釈が宣言する関数型の結果型も同じ呼出時強制の対象(§17.3)。Future payload と、内部名注釈が宣言する record 型(データオブジェクト)の構造照合は浅い照合に留める=将来段階、language-spec-future.md):
| 型 | 一致 |
|---|---|
Int/Float/String/Bool |
その種類の値 |
Unit |
空タプル () |
Any |
任意 |
Never |
なし(どの値にも一致しない。ボトム型。発散位置の結果型。§16.2) |
List(T) |
リスト値で全要素が T(空は一致) |
OneOf(A, …) |
いずれかに一致 |
Option(T) |
Some(x) で x が T、または None |
Result(T, E) |
Ok(x) で x が T、または Err(e) で e が E(Result(T) ≡ Result(T, Error) なので既定では e が Error) |
(A, B) |
同要素数の tuple、各要素が位置対応 |
{x: Int |} |
slot x を持ちその値が Int(余分な slot 可・幅構造型) |
{compare |}(メソッドフィールド) |
compare に応答する値(ベースライン優先、else callable な自前スロット。§17.5) |
Future(T) |
Future(payload 型は await 前に検査しない) |
{Int, Int | Int} |
同引数数の関数で、宣言引数型と body 末尾アスクリプション由来の結果型が一致(不変。無注釈位置は素通し)。加えて関数型注釈の境界と、内部名注釈が宣言する結果型は、呼出しごとに実 arg/結果を wrap 照合する(呼出時強制。§17 冒頭・§17.3) |
Ordering |
Less / Equal / Greater のいずれか |
Number(= OneOf(Int, Float)) |
Int 値または Float 値(OneOf と同じ「いずれかに一致」) |
Comparable(= {compare |}) |
原始型 Int/Float/String/Bool/Unit、または callable な compare スロットを持つ object(§17.5) |
Copyable(= {copy |}) |
Future / 非 Copyable な opaque 以外の値(§17.5) |
Bytes |
Bytes 値 |
Range |
range 値(range(...) と step / reverse 派生) |
Error |
kind(String)と message(String)を持つ record(余分 slot 可・幅構造型) |
タグ・コンストラクタ(Circle / Shape.Circle) |
同タグかつ同じ所属 `enum` の Variant(arity 一致・payload 型は不問)。enum のタグを型述語に使う形。bare 形は destructure 済み(§6.3)またはスコープ内のタグ、修飾形 Name.Tag は enum 型値の下から直接参照する形(§17.4)。組込(Option/Result/Ordering/Control)のタグは所属を持たないため、組込タグ同士の照合は従来どおり |
再帰的なタグ付きデータ型は enum(後述)で書ける(enum Tree := OneOf(Leaf(Int), Node(Tree, Tree)))。type の record 自己参照(type Tree := {value: Int, children: List(Tree) |})も書ける — Name を先行登録してから右辺を型コンテキストで評価するため、フィールド型に自分自身を参照できる(非ジェネリックの再帰 enum と同じプレースホルダ方式)。
照合の値化 `matches`(真偽値の型テスト): 値が型に一致するか(上の照合表)を Bool で返す universal method(§2.5。スロット上書き・reflect.names 出現もそこに集約)。value.matches(T) / 中置 value matches T(§8.1)で書ける。
3.matches(Int) #> true 3 matches String #> false xs.filter({ v | v matches Int }) (1, 2) matches ((Int, Int)) #> true (二重括弧。単括弧は arity エラー) type P := (Int, Int) (1, 2) matches P #> true
Tは型値(型語彙・type名・shape)か、タグ・コンストラクタ(enumのタグ名。bareCircleまたは修飾Shape.Circle。§17.4)。いずれでもなければ型不一致として panic する(§16.2。falseは返さない)。- 構造型リテラル: タプル
(Int, Int)・レコード{x: Int |}を右辺に直接書ける。これらはtype右辺や型注釈と同じ型コンテキストで型値に解釈される。(1, 2) matches ((Int, Int))はtrue。 - タプルは二重括弧が要る:
v matches (Int, Int)は並置適用でmatchesへの 2 引数matches(Int, Int)と読まれ arity 不一致になる(§3.5)。タプル型を 1 つの被演算子として渡すには二重括弧((Int, Int))、またはtypeで名前付けする(type P := (Int, Int)→v matches P)。レコード{…|}は波括弧が引数列と曖昧でないため単一で書ける。 - 関数型は `type` で: 関数 shape
{Int | Int}は右辺位置では値の関数リテラル({引数 | 本体})として読まれ型に解釈できない。関数型で照合するにはtype F := {Int | Int}で名前付けしてv matches Fとする。 - 構造型の変数渡しは `type`:
t := (Int, Int)は型値ではなくタプル値を束ねるためv matches tは通らない。型値type T := (Int, Int)ならv matches Tが通る(型語彙Intを変数経由で渡せるのと同じ規則)。reflect.type_of(x)が返す型値もそのまま使える。 - タグ・コンストラクタ
Circle(bare)/Shape.Circle(修飾)は「対象が同タグの Variant か」をタグで照合する述語として働く(arity は一致を要するが payload 型は問わない)。c matches Shape.Circleはmatch c { Shape.Circle(_) => true, _ => false }と同義。payload 型まで効かせたいときは enum 型値(Shape)やreflect.type_ofの結果を渡す。 - 照合は上の表どおり(関数型は宣言引数型・body 末尾アスクリプション結果型の構造照合。内部名注釈が宣言する関数型の結果型は呼出時強制も加わる。
Futurepayload と内部名注釈の record 型は浅い照合のまま)。 matchesは真偽値のみを返し、束縛しない。束縛を伴う照合はmatch(§1.4、prelude.md §8.4)が担う(match=分岐+束縛match value { … }/matches=真偽値テストvalue matches T)。
タグ付きデータ定義 `enum`
enum Name := OneOf(Tag1(T…), Tag2(T…), …) はユーザ定義のタグ付きデータ型(直和・代数的データ型)を宣言する。右辺 OneOf(...) の各引数はタグ宣言として解釈する(新タグを名付けるトークンであって参照ではない)。評価がトップレベルに束縛するのは `Name`(型値)だけである — タグの値コンストラクタはフラット束縛しない。
`Name` は型値であると同時にタグの名前空間: Name は OneOf(Tag1, Tag2, …) 相当の型値(matches・型注釈・match の型パターンで使える。§17.2)であり、かつ各タグを自分の slot として公開する — Name.Tag1 / Name.Tag2 で参照する。payload を取るタグ(Circle(Int))の Name.Circle は arity ぶんの引数を取る関数値(値コンストラクタ)、payload 無しのタグ(Empty)の Name.Empty は arity 0 の値そのもの(Some/None と同形。§1.2.1)。タグは大文字始まり(Color.None)、universal method は小文字(Color.inspect)で slot 名として衝突しない(§1.2.1)。異なる enum は同名タグを持てる(例: Color と Auth がそれぞれ None を宣言できる) — Name 自体への matches(v matches Color)も各タグの所属 enum まで見るため、他の enum の同名タグの値は一致しない(Auth.None matches Color は false)。
enum Shape := OneOf(Circle(Int), Rect(Int, Int), Empty) # 混在 ADT enum Color := OneOf(Red, Green, Blue) # payload 無し(名前集合) enum Meters := Meter(Int) # 単一タグ(OneOf 省略) enum Meters := Meters(Int) # 型名 = タグ名も可(別 namespace。下記参照)
- 単一タグは `OneOf` 不要:
OneOf(A) ≡ A(1 要素の直和は退化してその要素自体)なので、タグが 1 つなら右辺にタグ宣言を直接書く(enum Meters := Meter(Int))。OneOf(...)は 2 タグ以上で用いる。 - payload は位置形式:
Shape.Circle(5)/Shape.Rect(3, 4)で構築し、取り出しは match のみ(match s { Shape.Circle(r) => … })。名前フィールドアクセスは持たない(Some(x)と同一モデル)。各 payload 位置には型を書き(Circle(Int))、型を問わなければAny(Circle(Any))。 - 構築時に payload 型を検査: タグの値コンストラクタを適用するとき、各 payload を宣言型と照合し、不一致は型不一致=バグ層として panic(§16.2)。
Shape.Circle("hello")は構築時 panic、Any宣言(Wrap(Any))は任意値を通す。再帰型は各層が自分の payload を検査するため、enum Tree := OneOf(Leaf(Int), Node(Tree, Tree))でNode(Leaf(1), 5)は 5 が Tree でなく panic する。この検査により構築できた Variant は必ず自分の enum 型に `matches` する。hikari check/ LSP はこれに加えてリテラル違反を実行前に検出する(static-analysis.md §2)。 - arity: 宣言数を超える引数(
Rect(1, 2, 3))は構築時 panic。宣言数に満たない引数(Rect(1))は §3.5 の部分適用となり、完全適用された時点で payload 型が照合される。 - タグを型述語・パターンに使える: 個別タグは
matchesの右辺・型注釈(x: Shape.Circle/{c: Shape.Circle | …})・matchの修飾コンストラクタパターンShape.Circle(...)でタグ照合の述語・パターンとして使える(タグ+arity 一致・payload 型は不問)。enum 全体での照合はShape、特定タグかの判定はShape.Circleを使い分ける。 - bare 化は名前 destructure(§6.3): タグを都度修飾せず使いたい場合、既存の名前 destructure
[Circle, Rect, Empty] := Shapeで bare 名として引き込める。destructure 後はCircle(5)の構築・match s { Circle(r) => … }の bare パターンが従来どおり動く。prelude のSome/None/Ok/Err/Less/Equal/Greaterは、Option/Result/Orderingをこの機構で先取り destructure して bare 公開したものにすぎない(prelude.md §12・§13・§14)——組込とユーザ定義 enum は同格であり、prelude だけを特別扱いしない。 - 型名とタグ名は別 namespace: タグは
Nameの下(Name.Tag)に住み、トップレベルの名前空間とは別なので、型名と同名のタグも書ける(enum Meters := Meters(Int))。旧版(型名と全タグ名の相異必須)は撤廃した。 - 再帰可:
enum Tree := OneOf(Leaf(Int), Node(Tree, Tree))。Nameを先行登録してから右辺を評価するため、タグの payload 型に自分自身を参照できる。 - 持たないもの: higher-rank 多相、自動一般化(let 多相)、arity 0 タグへの整数割り当て(ordinal)。型 union の
|記法も持たず、enum も型 union も同じOneOf語彙で書く。matchの leading-dot 短縮(.Noneのような型省略)も持たない。
enum も type と同じく slot-list(ファイルトップレベル含む)では `Name` の名前スロット、body/文 文脈では Name を束縛する文として働く(タグの値コンストラクタは Name の slot としてのみ生成され、フラットな環境へは束縛しない)。enum 型値 Name に対する matches は「値が Variant で、その Tag がいずれかのタグ名に一致し、payload 型が宣言と照合できれば true」(個別タグ Name.Circle を渡した場合のタグ照合は上記「タグを型述語・パターンに使える」を参照)。
s := Shape.Circle(5) # 構築(修飾) match s { Shape.Circle(r) => r * r * 3 # 修飾コンストラクタパターン Shape.Rect(w, h) => w * h Shape.Empty => 0 } s matches Shape #> true s matches Shape.Circle #> true (タグ述語。arity 一致・payload 型は不問) s matches Shape.Rect #> false Shape.Circle("hello") #> panic (Circle は Int を要求。構築時に検査) [Circle, Rect, Empty] := Shape # 名前 destructure で bare 化(§6.3) match s { Circle(r) => r * r * 3, Rect(w, h) => w * h, Empty => 0 } import mod := "shapes.hikari" mod.Shape.Empty # import 経由も素直に連鎖(static-analysis.md §2)
型注釈の実行前検査は static-analysis.md を参照 —hikari check/ LSP の健全・漸進的な型検査 (§2)、strict モード--strictの追加規則 (§3)、多相な組込シグネチャの単一化による結果型推論 (§4)。
17.5 構造的 interface(メソッド契約)
structural 型 `{ … |}` は、データ契約(x: 型)とメソッド契約(bare 名)を同じ器で表す(TypeScript の interface = object 型と同型)。メソッド中心のものを interface と呼ぶ。Comparable・Copyable はこの仕組みの上に prelude 定義された値であり、組込の特別な型ではない(§9.4 / §9.5 / prelude.md §14・§16)。ユーザーも同じ構文で定義できる。
エントリ(§17.2 の型位置 { … |}):
- メソッドフィールド
m(bare 小文字): 「値がmに応答する」ことを要求する(callable であればよい)。シグネチャまで契約するならm: {In | Out}(型付きフィールドの型に関数型を据える形。§17.4 の宣言シグネチャ照合で引数数・引数型・結果型〔body 末尾アスクリプション由来〕まで見る)。baremは応答のみ、m: 関数型はシグネチャまで。 - 型付きフィールド
x: 型(named): 現行 record shape と同じデータ契約(余分な slot 可・幅構造型)。型が関数型なら上記のメソッドシグネチャ契約になる(メソッドフィールドの上位互換で、構文は非破壊)。 - 埋め込み
Name(bare 大文字):Nameが指す structural 型の全エントリを取り込む(Go の interface 埋め込み / TS のextends相当)。構築時に展開する。structural 型でない値の埋め込みは型エラー。
type Comparable := { compare |} # compare に応答する値 type Copyable := { copy |} type Drawable := { draw, area |} # 複数メソッド type CompCopy := { compare, copy |} # 合成 type Point := { Comparable, x: Int, y: Int |} # 埋め込み+型付きフィールド
応答の定義とベースライン: universal method(§2.5)のうち compare / copy / inspect / matches / reference_equals は、値が自前スロットを持たなくても組込ディスパッチで応答する(=ベースライン)。ベースラインの範囲:
| method | ベースラインで応答する値 |
|---|---|
compare |
原始型 Int/Float/String/Bool/Unit(§9.4) |
copy |
Copyable な値(Future / 非 Copyable な opaque 以外。§9.5) |
inspect / matches / reference_equals |
全値 |
ユーザー定義メソッド(draw 等)にはベースラインが無い(原始型に生やせない)ため、応答は自前スロットの有無だけで決まる。
照合規則(v matches { … |} / 注釈 / match の型パターン)。各エントリについて:
- メソッドフィールド `m`:
mにベースラインがありvに効くなら一致(自前スロットは見ない=ベースライン優先)。無ければ、vが自前スロットmを持ちその値が callable(関数・メソッド・組込)なら一致。いずれでもなければ不一致。 - 型付きフィールド `x: 型`:
xにベースラインがありvに効くなら一致(型検査スキップ)。無ければ、vが自前スロットxを持ちその値が型に一致すれば一致(=現行の幅構造型照合)。 - 埋め込み は展開後のエントリ列として上記を適用する。
3 matches Comparable #> true (Int は compare のベースライン) { compare := { o | 0 } |} matches Comparable #> true (callable な compare スロット) { compare := 5 |} matches Comparable #> false (非 callable。ベースライン非適用の object) { x := 1 |} matches Comparable #> false (compare 無し・非原始型) 5 matches Copyable #> true (Copyable ベースライン) p := { x := 1; draw := { () } |} p matches Drawable #> false (area 無し) type Adder := { add: { Int | Int } |} # add が Int→Int のメソッド { add := { x: Int | x } |} matches Adder #> true (add の宣言引数型が Int に一致) { add := { x: String | x } |} matches Adder #> false (add の引数型が String。不一致) { add := { x, y | x } |} matches Adder #> false (add が 2 引数。引数数不一致) { add := { x: Int | x: String } |} matches Adder #> false (add の結果型が String。不一致)
帰結: {compare |} は現行 Comparable、{copy |} は現行 Copyable と同値(ベースラインの差から、{copy|} は copy:=5 を持つ object も一致し〔Copyable ベースラインが優先〕、{compare|} は非原始型 object の compare:=5 を弾く)。{match |} / {inspect |} などベースラインが全値のメソッドで作る interface は全値に一致する(Any 相当。意味は薄いが一貫)。同様に、要求の無い空 interface `{|}` は全値に一致する(Any 相当。Go の interface{} と同じ top 型)。非 universal 名のみの型付きフィールド {x: Int |} は現行 record shape と完全同一(後方互換)。
メソッドシグネチャ契約: m: {In | Out} は型付きフィールドの一種であり、上の照合規則(型付きフィールド x: 型)にそのまま従う — m にベースラインがあり v に効けば型検査をスキップ(ベースライン優先。compare: {a, a | Ordering} は原始型に対しベースラインで一致)、無ければ v の自前スロット m が関数型 {In | Out} に一致するかを §17.4 の宣言シグネチャ照合で見る(引数数・引数型・結果型 Out〔body 末尾アスクリプション由来〕まで。無注釈引数・結果型を宣言しないメソッドは素通し)。
17.6 封印契約(sealed export contract)
export [name: T](§13.2)で型注釈を付けた名前は、その静的型 T を 封印契約 として公開する。契約は importer から見た name の型を T に確定し、T が構造的に閉じたメンバ集合を持つ型のとき、その集合が 公開面 になる。
閉じたメンバ集合の決め方:
Tが structural 型{ f: 型, m |}(§17.2)なら、公開メンバは宣言したフィールド・メソッドのみ。Tが function 型{ P… | R }なら、適用結果の型Rに同じ規則が再帰的に適用される(契約の透過的伝播)。- 埋め込み(§17.5)は取り込んだエントリを公開面に含める。
伝播は「exported binding の型 T と、そこから構造的に静的到達できる封印契約型」まで及ぶ。到達の途中で型が Any や未確定に倒れたら、その先は従来どおり gradual 境界(未宣言メンバ参照は静的検査の対象外。static-analysis.md §2 冒頭・§3)。
Stage 1 は静的検査のみ: 本節が定める公開面の限定は静的な契約であり、実行時の値そのものは変わらない。契約外のスロットは評価後の値に物理的に残ったままで、Any 経由や封印契約を経由しないアクセスからは引き続き読み書きできる。封印契約型に確定した受信者への契約外アクセス(importer 側)と、封印契約に流れる値の契約外・未使用スロット(定義側)の診断は static-analysis.md §3 の --strict 契約系診断として実装する(通常モードは無変更)。実行時にも契約外スロットを到達不能化する Stage 2 は language-spec-future.md §20 に予約する。
後方互換: 型注釈の無い export [name] や export 自体が無いファイルは、封印契約の対象にならず従来どおり gradual に振る舞う。
封印契約は `export` に限らない: 封印契約という考え方自体は、export の型注釈に限らず、任意の型注釈束縛 name: T := 値 が持ちうる。T が構造的に閉じたメンバ集合を持つ型であれば、name の値についても同じ「宣言メンバ集合の外は契約外」という構造が成り立つ。ただし export [name: T] は値が必ず型 T としてのみファイル外へ出るため契約外が importer から到達不能と構文的に保証されるのに対し、非 export の型注釈束縛にはその保証が無い。値が型注釈の無い(Any)位置を経由してファイル外へ渡ると、契約外のスロットへ他ファイルからアクセスされる可能性を排除できない。そのため、契約外・未使用サブスロットの静的検出(static-analysis.md §3 規則(12))は、非 export の型注釈束縛については値がバラの値として外へ渡らないことが構文的に確実な束縛(エスケープ安全な束縛)に限る。エスケープ安全条件を満たさない束縛は検出対象から外れるが、値そのものの意味論(型注釈の実行時照合・スロット構成)には影響しない。
構造的 interface は型述語(v matches I / x: I)としても、型変数の境界(a: I。interface 境界、§17.2)としても使える — いずれも同じ Matches/照合規則を用いる。境界に置くときは「I を満たす単一の型で、同一シグネチャ内の全 a はその同じ型を共有する」ことを表明し、閉じた union を境界とする type set 境界(a: Number)と同じ機構(§17.2)を一般化したものになる。
18. 制御フローと制御値
break / continue(局所制御)・return(脱出)・ユーザ定義の制御構造とループ(conduit / loop)・末尾呼び出しは、いずれも §16 冒頭の「専用の例外機構や大域脱出を持たない」という原則を共有する制御構文である。これらは例外機構ではなく、回復可能エラー(§16.1)とは別系統で評価器内を値として伝播し境界で捕捉される制御値(Break / Continue / Return / TailCall)で実現される — だからこそ関数境界を越えず、各関数を呼び出し元から独立に読める。失敗 bail ?(§16.5)も同じ Return 制御値を用いるが、エラー処理の一部として §16 で扱う。本章はこれら制御値の意味論と、それをユーザ/prelude が self-host するための注釈(conduit / loop)・観測 primitive(capture)・末尾呼び出しの定数スタック保証をまとめる。
18.1 局所制御フロー (break / continue)
break / continue はループ反復の 局所制御 であり、§16 冒頭の「専用の例外機構や大域脱出を持たない」原則の例外ではない。API と例は prelude.md §8.5。
- 字句スコープ: それを字句的に囲うループ (
while/each系) のブロックにのみ効く。関数定義境界を越えない — ループ内から呼び出した関数の中のbreakは、字句的に囲うループが無いため panic (§16.2)。これにより「呼ばれた文脈のループを遠隔で切る」動的脱出 (Perl のlast、Tcl のリターンコード伝播がこれにあたる) を排し、関数を呼び出し元から独立に読めるようにする。動的変数スコープを持つ Lisp 系でもループ脱出 (block/return-from) は字句に保つのと同じ方針。conduit関数 (§18.3) は例外で、自分の境界でbreak/continueを捕捉せず素通しするため、その関数を貫いて字句的に囲うループ (呼び出し側) へ届く (builtinif/whenと同じ透過)。 - 実現は特殊戻り値方式: 内部表現は Break / Continue 制御値で、回復可能エラーとは別に評価器内を値として伝播し、(a) 囲うループ構造、(b) 関数定義境界 の両方で捕捉される。関数境界で捕捉する (越えると panic) ため、値としては伝播しても大域脱出にはならない。
- 式としての型は `Never` (§17.2):
break/continueはその式位置で決して値に評価されず制御がループへ移るため、型上は発散 (ボトム型Never) する。panic/exitと同じく分岐合流の恒等元で、if (c) { v } { break }の結果型はvの型に解ける (static-analysis.md §4)。 - 値つき・多値 `break`:
break(value)はループ式全体の戻り値をvalueにする (Rust のbreak value相当)。多値はbreak(a, b)またはbreak((a, b))(両者は同義、後者の外側括弧はグルーピング)で抜ける —returnと対称。,はタプル構築のため、括弧なしのbreak a, bは(break a), bと解釈される。break!/ 通常終了時の戻り値は()(unit)。 - ループ外での使用: 字句的に囲うループの無い箇所での
break!/continue!は panic (バグ扱い、§16.2)。 - ラベル付き脱出は持たない (最内ループのみ)。多重ループの一括脱出はフラグや関数分割で表現する。
18.2 脱出 (return)
return は関数本体から値を返して 脱出 する明示的構文であり、§16 冒頭の「専用の例外機構や大域脱出を持たない」原則の例外ではない (break / continue と同じ局所制御)。body の 任意の位置 に書ける — 末尾に置いた return v は v をそのまま書くのと等価 (body は元々最後の式の値を返すため、§11.1) であり、return の意義は 末尾でない位置から関数を抜けられる こと (ガード節・失敗 bail などの早期脱出の用途) にある。API と例は prelude.md §8.6。
- 字句スコープ:
returnは 最も近い、実呼び出し (関数適用) で入ったユーザ関数 `{...}` の境界 で取り出され、その関数の戻り値になる。if/when/while/eachのブロックとmatchの arm body は呼び元 (ビルトインまたはmatch評価器) が呼ぶため 透過 —when (bad) {return v}はwhenを貫いて外側の関数から脱出する (breakがifを貫いてループに効くのと同一経路)。自前の高階関数に渡したブロック内のreturnは、そのブロック自身から脱出する (Kotlin の inline / 非 inline と同型)。ただし `conduit` 関数 (§18.3) は境界で `return` を捕捉せず素通しする — ユーザ定義の制御構造 (conduit {cond, then | …}) に渡したブロック内のreturnは、builtinwhenと同じく外側の関数から脱出する。 - 実現は特殊戻り値方式: 内部表現は Return 制御値 (§18.1 の Break / Continue と同系統) で、回復可能エラーとは別に評価器内を値として伝播し、ユーザ関数の呼び出し境界で捕捉される。
break/continueが境界を越えると panic 化されるのに対し、returnは捕捉時に 値を取り出して関数の戻り値にする。 - 式としての型は `Never` (§17.2):
return vはその式位置で値に評価されず関数から脱出するため、型上は発散 (ボトム型Never) する。if (c) { v } { return d }の結果型はvの型に解ける (分岐合流の恒等元、static-analysis.md §4)。脱出する値 `v` の型は関数の結果型 (末尾値・?の bail と合流) に別途寄与するもので、return式そのもののNeverとは独立。 - `while` / `each` との関係: ループ本体の
returnはループでなく 外側の関数 から脱出する (breakはループだけを抜ける)。ループは Return を消費せず素通しする。 - 多値: タプルで返す。
return(a, b)またはreturn((a, b))(両者は同義、後者の外側括弧はグルーピング)。,はタプル構築のため括弧なしのreturn a, bは(return a), bと解釈される点に注意。受け側はx, y := …で分解する(§6.3)。 - トップレベル: ファイル (暗黙 root リテラル、§13.1) の body での
returnは モジュール / スクリプトの評価を終了 する (root body から脱出)。break/continueが囲うループ不在で panic になるのと異なり、returnは root でも「body を止めて値を返す」意味を持つ。
18.3 制御透過関数 (conduit)
if / when / while 等の制御構造は prelude が束縛した builtin (match は 予約語) であり、ブロック (arm body) を内部経路で起動するため return / break / continue 制御値を素通しする(§18.1 / §18.2 の「透過」)。一方、ユーザが hikari で同型の制御構造を書こうとして when := {cond, then | if cond then {()}} のように普通の関数で定義すると、その関数の呼び出し境界で return が unwrap され(break/continue は「ループ外」panic)、透過が途切れる。つまり透過は builtin の特権で、ユーザ関数は既定で不透過——という非対称があった。
conduit はこの非対称を埋める注釈である。関数リテラルの開き括弧の前に conduit を置くと、その関数は自分の呼び出し境界で `return` / `break` / `continue` を捕捉せず上位へ素通しする(builtin 制御構造と同じ扱い)。これにより制御構造をユーザ/prelude で hikari 定義できる。
when := conduit { cond, then | if cond then { () } } unless := conduit { cond, then | if cond { () } then } guard := conduit { cond | when (cond.not!) { return () } } # 早期 return を呼び出し元へ透過
- 対象は関数リテラル `{...}` のみ。
conduit[...](リスト)は構文エラー(透過は呼び出し挙動の性質で、データには意味がない、§1.4)。conduitと{の間の空白は任意。 - 透過するのは制御値の伝播だけ。適用規則(§3.5 / §3.6 のカリー・束縛)・スコープ・型・通常の戻り値(末尾式の値)は普通の関数と同じ。
- 既定(`conduit` なし)の関数挙動は不変。
conduitを付けない関数は従来どおり境界でreturnを unwrap し、break/continueをループ外 panic にする(§18.1 / §18.2)。本注釈は後方互換な opt-in 追加で、既存コードの意味は変わらない。 - 字句スコープの維持(§18.1):
break/continueトークンは呼び出し側のブロックに字句的に在り、conduit関数はそれを通すだけで、builtinwhen/ifが貫通させているのと同一経路に乗る。「呼ばれた文脈のループを遠隔で切る動的脱出は持たない」は保たれる。conduit関数の本体に裸のbreak!を直書きした場合は、その制御値が上位の非透過境界(通常関数 or トップレベル)まで素通しして、そこで「ループ外 break」として panic 化される(検出位置が一段上がるだけで意味は同じ)。 - `extensible` との併用は不可(v1)。
conduitは本体付き関数への注釈、extensibleはオブジェクトの開閉で、同時指定は構文エラー。 - `break` / `continue` を消費するループそのものは対象外。独自
repeat/for等、break/continueを自分で消費するループを書くにはloop注釈とcaptureprimitive を使う(§18.4)。conduitは「ブロック起動を builtin に委譲する conduit 系制御構造」とreturn透過を対象とする。
§18.1(break / continue)・§18.2(return)の「if / when / while / each のブロックは透過」という規則は、conduit 関数にも同様に及ぶ(builtin 制御構造と conduit 関数を貫いて外側の関数 / ループへ届く)。
18.4 ユーザ定義ループ (loop / capture)
conduit(§18.3)は return / break / continue を素通しするだけの conduit 系制御構造を self-host できるが、while / each のように `break` / `continue` を自分で消費するループは書けない。ループには (a) 自分が break / continue の捕捉境界だと宣言する印と、(b) 起動したブロックの制御転帰を値として観測する手段が要る。前者が loop、後者が capture である。
capture(制御転帰の観測)
capture は prelude 束縛の値(break / while と同系で新予約語ではない)。capture(block, args…) は `block` を呼び出し境界でトラップせず起動(builtin each がブロックを内部経路で起動するのと同じ)し、その転帰を Control 値として返す。第 2 引数以降が block への実引数になる(capture(body, x) ≡「body(x) の起動を観測」)。
Control := OneOf(Normal(Any), Broke(Any), Continued, Returned(Any))
| タグ | payload | 意味 |
|---|---|---|
Normal(v) |
ブロックの末尾値 | 正常終了 |
Broke(v) |
break の値(break! は ()、多値は tuple 1 個) |
break 発火(§18.1 の値つき break) |
Continued |
なし | continue 発火 |
Returned(v) |
return の値 | return 発火 |
通常の構文呼び出し body(x) ではブロック内の return が unwrap され、break / continue は「ループ外」panic 化されて転帰を区別できない。capture は block を非トラップ経路で直接起動してこれらを生の制御値のまま値化する。ブロックを引数として渡す形(thunk capture { body(x) } ではない)なのはこのためで、thunk に包むと内側の構文呼び出し body(x) が body の境界で先にトラップされてしまう。一方 block の内部でさらに別の関数を呼びその中で `break` した場合は、従来どおりその関数境界で「ループ外」panic になる(字句スコープ保証 §18.1 は不変。capture が観測するのは渡した block 自身の転帰のみ)。Control は Ordering / Option / Result と同族の closed variant で、match の網羅性は strict モードで要求される(static-analysis.md §3)。
loop(捕捉境界の宣言)
loop {...} は関数を break / continue の捕捉境界(ループ)として宣言する前置注釈。
- runtime: 自分の呼び出し境界で `return` は素通しする(ループ本体の
returnは外側の関数へ脱出。each/whileと同じ)。break/continueは本体内のcaptureが観測・消費するため境界には現れない。透過プロファイルはconduit(return / break / continue の全てを素通し)とは異なり、`return` のみ素通しする。 - static: この関数へ渡したブロック内の
break!/continue!を「ループ外」panic ではなく正当と判定し(§18.1)、Never / 発散解析がこの関数をwhile/eachと同様ループとして扱う。 - 対象は関数リテラル `{...}` のみ。
loop[...]は構文エラー。conduit/extensibleとの併用は不可(v1。透過プロファイルの衝突・別軸のため構文エラー)。 loop本体にcaptureを介さず裸の `break!` / `continue!` を直書きした場合は、§18.1 のとおり「字句的に囲うループの無い break」として panic(ループ性はcaptureで観測したサブブロックを通じて実現され、loop本体そのものは反復されないため)。
self-host 例
反復は inner 内部名(§4.2)の再帰で駆動する(go := {... go ...} は右辺評価時に名が未束縛のため不可)。再送出する return 等の制御値を内側ドライバから外へ通すため、ドライバには conduit(§18.3)を付ける。
each := loop { xs, body |
conduit { recur | i |
if (i < xs.length()) {
match capture(body, xs.[i]) {
Broke(v) => v # ループ式の値は v(値つき break)
Continued => recur(i + 1) # 消費して次反復
Normal(_) => recur(i + 1)
Returned(v) => return v # conduit ドライバ→loop 境界を貫き外側関数へ
}
} { () }
}(0)
}
横流しコンビネータ(take_while 等)は同じ capture を使うが loop は付けず、break / continue / return を再送出してユーザの外側文脈へ通す。再送出は conduit な補助関数 forward(Control を直接形に撃ち直す)で書ける。forward は primitive ではなく prelude が conduit で定義する(effect handler が未処理エフェクトを外側ハンドラへ forward するのと同義で、reified な制御をそのまま外へ送り直す)。
forward := conduit { c | match c {
Normal(v) => v; Broke(v) => break(v); Continued => continue!; Returned(v) => return v
} }
take_while := conduit { xs, pred |
stop := conduit { recur | i |
if (i < xs.length()) {
match capture(pred, xs.[i]) {
Normal(true) => recur(i + 1)
Normal(false) => i
c: Control => forward(c) # Broke/Continued/Returned をユーザ文脈へ再送出
}
} { xs.length() }
}(0)
xs.take(stop) # 述語が false になる手前まで
}
(catch-all で Control 値全体に名前を付けるため型付き束縛 c: Control => … を使う。match の値全体束縛は型付き束縛で行う、prelude.md §8.4。)
横流しコンビネータのうち take_while / drop_while は実際に prelude(prelude.hikari)へ self-host 移設済みである(停止位置を上記ドライバで求め、結果リストの構築は native take / drop へ委ねる)。これにより述語内の break / continue / return がコンビネータを貫いてユーザの外側ループ・関数へ届く(Go native 実装はこれらを map / filter 同様「不可」とエラー化していた)。一方 while / each は Go 組込(native)実装である。partition / sort_by / sort_with も 2 リスト蓄積・native 比較が要るため Go 組込である。
なお上記 stop_at の inner-name 再帰ドライバ recur(i + 1) は §18.5 の自己末尾再帰にあたり、長い prefix でも定数スタックで回る。
18.5 末尾呼び出しと定数スタック(自己末尾再帰)
関数 `f` の末尾位置にある `f` 自身への完全適用呼び出し(自己末尾再帰)は、呼び出しスタックを増やさず反復として実行される(定数スタックで回る)。§18.4 の self-host ループドライバ(inner 内部名の recur 自己再帰)が長い入力でスタックを溢れさせないための保証である。
末尾位置
式が関数本体の末尾位置にあるとは、その値がその関数の結果になり、以降その関数内で評価が残らないことをいう。再帰的に定める:
- 関数本体の最後の文の式は末尾位置。
- 末尾位置の
if (c) {t} {e}では、ブロックt/eの末尾式が末尾位置(条件cは非末尾)。 - 末尾位置の
match recv { pat => body … }では、各 armbodyの末尾式が末尾位置(スクルティニrecv・ガードは非末尾)。subjectlessmatch { pred => body … }も各 body 末尾式が末尾位置(述語は非末尾)。 - 末尾位置の
whenでは、分岐 body の末尾式が末尾位置(条件は非末尾)。 conduit(§18.3)/loop(§18.4)関数の本体の末尾式は、その関数の末尾位置。
この末尾位置は §18.1(break / continue)・§18.2(return)の制御値透過集合と同一である(builtin 制御構造のブロックと conduit / loop を貫く)。`return` がその位置から関数境界へ届くなら、その位置の self への完全適用は末尾呼び出しである(末尾=以降に評価が残らない、を加えた部分集合)。新しい透過規則は導入せず、既存の透過をそのまま使う。
対象と対象外
- 対象は「self への完全適用」のみ。self は inner-name(
inner宣言。§4.2)が指す関数それ自身。recur(i + 1)のような単一スロット完全適用が該当する。 - 対象外は従来どおりスタックを使う(エラーにはしない — 定数スタックは self 末尾再帰の保証、それ以外は非保証):
- 末尾でない自己呼び出し:
1 + recur(n - 1)・f(recur(n - 1))・自己呼び出しの後に文が続く場合(自己呼び出しの値に演算・束縛・後続評価が乗るため末尾でない)。 - 相互再帰・別関数への末尾呼び出し(proper tail calls): v1 では自己再帰のみ。
- 部分適用の自己呼び出し(新しい Function 値を返すため末尾呼び出しにならない)。
- 標準の制御構文を rebind した関数を通した位置: 末尾位置判定は unshadowed な
if/whenとmatch/conduit/loopを前提とする(§18.1 / §18.2 の透過が同じくこれらを前提とするのと同一)。これらを rebind した関数を貫く位置は定数スタック保証の対象外(スタックにフォールバック、結果は正しい)。
観測可能性
深い自己末尾再帰がスタックを溢れさせず完走することは観測可能な挙動であり、注釈は要らない(@tailrec 相当の印は持たない — loop / conduit が既にドライバに付く)。非末尾の再帰を末尾に書き換えれば定数スタックになる、という予測可能な性質として与える。設計判断の記録は ADR 0048。