言語仕様
本書は実装の合意基準である。本書から逸脱する場合は本書を改訂する。設計の動機・思想は別文書 philosophy.md を参照。
各構文要素の形と実行例の一覧は、doc コメントから自動生成される reference/syntax.md にもある。本書はそれらの意味論・文法・優先順位・設計根拠を規範として定める(reference が形の索引、本書が意味の定義)。
1. 字句構造
1.1 コメント
# これは行コメント、行末まで # 多くの例で出てくる #> 5 は「直前の式の値が 5」を意味する慣例表記であり、 # 言語が特別扱いするものではない
複数行コメントは持たない。
字句上の特例は無い: # で始まる行はファイル先頭でも同じく行コメントである。ファイルが mode を宣言する先頭行は持たない (§13.1)。#{...} も # 行コメントとして処理される。
1.2 識別子
通常の識別子は ASCII 英字または _ で始まり、英数字または _ が続く (ASCII のみ)。
オペレーター記号 (+、-、*、/、==、!=、<、<=、>、>= ほか) もスロット名として有効。{+ := {...} |} のように書ける。これがユーザー定義オペレーターの実体で、既存演算子の意味を型ごとに差し替える (§8.2)。
記号スロット名に使えるのは、演算子表 (§8.3) の項目のうちメソッド呼び出しに脱糖するもの (§8.6) だけである。表は閉じており、新しい記号列 (++・<>・<=> 等) を作ることはできない。記号と英字の混在 (+!・add+ 等) も不可。脱糖しない記号 (=・:=・&&・|| ほか。§8.6) はスロット名にならない。
表を閉じておくのは、記号列の追加を許すと優先順位と結合性を決める仕組み (宣言させるか、先頭文字から導くか) が必要で、読み手が知るべき規則がその分増えるためである。閉じている限り「中置になれるのは表にあるものだけ」の単一の規則で済む。
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 §12 / §13)。ユーザーは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 format は「大文字始まりの callee = 値 / 型コンストラクター」を単一引数でも括弧付きに保ち、小文字始まりの関数適用は並置化する (format.md「整形規則」)。match のアームでも、大文字の値コンストラクター (None / Less / Some(…)) はタグ照合として読め、小文字の束縛 (Some(x) の x) と視覚的に区別できる (prelude.md §8.4)。
型コンテキストでは casing が役割を分ける。ただし分け方は位置で 2 通りある(§17.2)。型式の位置(注釈・type の右辺・型構築子の引数)は 大文字始まり=具体型参照 / 小文字始まり=型変数。構造型の slot-list に置く bare エントリは 大文字始まり=埋め込み / 小文字始まり=メソッドフィールド(型変数にはならない)。どちらも「大文字は型を名指す・小文字は名指さない」点で値位置の慣用と整合する。
1.2.2 予約語
字句解析が識別子として認識せず、束縛名・スロット名に使えない予約語は次の 14 語である。真偽値リテラル true / false(§7)と、宣言・照合の構文形 type / enum / match / import / export / loop / conduit / refinement / refinement_opaque / inner / inner_bound / mutable(構文と対象の形は §1.4)。@ 記号はコア言語のどの位置にも登場しない(§1.4)。
true := 5 # エラー: true は識別子ではなくリテラルなので束縛対象にできない type := 5 # エラー: type は予約語(構文形)なので束縛対象にできない
一方、制御構造 if / when / while・自己参照名 self / me / super・演算子などは予約語ではなく、prelude 束縛または書き手が選ぶ普通の名前である。ゆえに再束縛もユーザー名としての利用もできる。演算子表の英数字エントリ matches(§8.3)も予約語ではない — 中置位置に現れたときだけ演算子として読み、他の位置では普通の識別子である。
if := 5 # OK: if は普通の名前(prelude 束縛を覆う局所束縛) self := 5 # OK: 自己参照名は予約語ではない(宣言子 inner だけが予約語。§4.2)
1.3 行の扱い
改行は文脈によって意味が変わる。区切りトークン (改行 / ; / ,) は文脈ごとの規則に従って「区切り」または「空白」として働く。
{...}本体 (body 領域) とファイル全体 (§13.1): 改行はシーケンス区切り (;と同じ)。;も使えるが、改行があれば省略可。区切りが連続・末尾にあっても無視する (末尾;可、空行可)。,はここでは区切りではなく多値 (タプル) 構築を表す (§11.2)。{...}のスロット宣言領域 (先頭〜body 前のパイプまで): 改行・,・;は等価なスロット区切り。,で区切っても、,を省いてスロットを縦に並べてもよい。区切りが連続・前後・末尾にあっても無視する (末尾,/;可、空行可)。(...)の内側: 要素区切りは,または改行 (両者は等価)。区切りが連続・前方・末尾にあっても無視する (末尾改行可)。ただし 末尾,は不可 — カンマは後続要素を要求する中置区切りで、(x,)は 1 要素タプル(3,)と誤読されうるため許さない (§3.3)。;は()内では区切りにならない。これにより(\n a\n b\n)のようにタプル要素を縦に並べられ、従来のif(cond,\n {then},\n {else})も引き続き書ける。ただし、ある行の最後の有意トークンが右オペランドを要求する二項演算子 (+-*/%**==!=<<=>>=&&||->と、演算子表の英数字エントリmatches。§8.3) であるとき、直後の改行は区切りとして働かず吸収され、式は次行へ継続する。二項演算子で終わらない行の改行は従来どおり要素区切りとして働くため、(a &&\n b)は単一式a && b、(a\n b)は 2 要素タプルと明確に区別できる。
ファイル全体は slot-list 領域だが (§13.1)、区切りは上の body 側の規則に従う。その領域は文を受けるので (§1.5)、, をスロット区切りにすると同じ字面が場所で意味を変える — f(), g() がファイルでは文 2 つ・関数本体では 2 要素タプルになる。, の意味は「文が書ける場所ではタプル構築」の 1 つに揃え、トップレベルのスロットは改行 (または ;) で区切る。
いずれの文脈でも、1 つの式 (スロットのデフォルト値・本体の各式・括弧内要素) は改行をまたげない — 改行はその式を終える。例外は上記の (...) 内での二項演算子継続のみで、行末が二項演算子のときに限り式は次行へ続く。複数行に分けたいときは式の途中ではなく区切りの位置で分ける。
文の後には区切りが要る。 body 領域とファイル全体では、1 つの文を読み終えた位置に改行・;・その領域の閉じ (} / EOF / ファイルレベルの |) のいずれかが無ければ構文エラーである。区切り無しで次の文が始まることは無い — 1 行が黙って 2 文に割れると、書いたつもりの式が評価されないまま値を捨てられる。とくに構文形の予約語 (§1.4) は並置の引数になれないので、println match x { … } は println と match x { … } の 2 文ではなく構文エラーとして止まる。match の結果を引数に渡すならグルーピング括弧で囲む (println (match x { … }))。
1.4 予約語(構文形)
宣言・照合の構文形は予約語である — prelude 束縛の値(if / while 等)とは別系統で、識別子として束縛名・スロット名には使えない(真偽値リテラル true / false と併せた予約語の全体は §1.2.2)。構文形は次の 12 個:
| 形 | 意味 | 参照 |
|---|---|---|
mutable name := expr |
可変の宣言(ローカル束縛・スロット宣言の両領域) | §6.1 / §2 |
inner name |
inner-name(自己参照名)宣言。slot-list 領域限定 | §4.2 |
inner_bound name |
束縛を写した自分に届く名前の宣言。slot-list 領域限定 | §4.2.1 |
conduit{...} |
制御透過関数(return/break/continue を境界で trap せず素通し) |
§18.3 |
loop{...} |
ユーザー定義ループ(break/continue の捕捉境界。return は素通し) |
§18.4 |
refinement{...} |
述語で絞った型(refinement 型)。中身は 1 スロット述語 | §17.7 |
refinement_opaque{...} |
同上。述語が決定可能断片の外にあることを明示し、静的検査の証明対象から外す | §17.7 |
import <対象> := "path.hika" |
モジュール読込(宣言形。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 を名前リスト(§6.3)で列挙して限定(無ければ全公開) | §13.2 |
キーワードと対象の間の空白は任意 (type Name:=T / type Name := T は等価。import は from 相当を持たず import m := "x" の形)。予約語であるため、これらの語は関数適用の並置 (f x) と曖昧にならず、パーサーは構文形として確定できる。
後続リテラルの読み方を変える前置予約語 4 語 (conduit / loop / refinement / refinement_opaque) は、表示上の正準形をキーワードと開き括弧の間に空白を 1 つ置いた形 (conduit {...} / loop {...} / refinement {...} / refinement_opaque {...}) に統一する — hikari format の出力・エラーメッセージ・Inspect はこれに揃える (format.md)。上表の「形」列は構文が要求する最小要素を示すラベルであり、この表示規則を左右しない。
各構文形は 要求する対象の形が構文で固定 されており、合致しなければ 構文エラー (パース時に検出、static-analysis.md §1)。具体的には:
mutableはmutable <束縛の左辺> := exprを要求する。左辺は 1 個の識別子 (型付きはmutable name: T := expr)・タプル分解の名前リスト (mutable a, b := expr)・スロット分解の名前リスト (mutable { a, b } := expr) のいずれか (§6.3)。slot-list 領域と body 領域の両方に書けるが、:=を欠く (mutable x = 1/mutable x)・スロットアクセスを左辺に取る (mutable obj.x := 1。スロットは宣言時に固定されるため。§2.4)・部分式の位置に現れる → 構文エラーinnerはinner name(name は 1 個の識別子、型付きはinner name: T) を要求し、slot-list 領域限定 (§1.5)。name を欠く・body/部分式の位置に現れる・1 リテラルに 2 個以上 → 構文エラーinner_boundはinner_bound name(name は 1 個の識別子) を要求し、同じく slot-list 領域限定。name を欠く・body/部分式の位置に現れる・1 リテラルに 2 個以上 → 構文エラー。型注釈を持たない ためinner_bound name: Tも構文エラーで、innerと同じ識別子を宣言するのも構文エラー (§4.2.1)conduitの後が{以外 → 構文エラー。透過は呼び出し挙動にかかる修飾なので、オブジェクトリテラルの綴りである{...}に固定するrefinement/refinement_opaqueの後が{以外 → 構文エラー(conduitと同じく関数リテラルの正準の綴り{...}に固定する)。中身はちょうど 1 スロットで型注釈必須・body 必須。スロットが 0 個/2 個以上/型注釈を欠く/body が無い → 構文エラー。conduit/loopとの併記 → 構文エラーloopの後が{以外 → 構文エラー(conduitと同じく関数リテラルの正準の綴り{...}に固定する)。捕捉境界の宣言は §18.4importは<対象> := "path"を要求する。対象が bare 名でも名前リスト{…}(§6.3) でもない・:=が無い → 構文エラー。:=右辺が 文字列リテラル以外 (変数・補間"${...}"・bare 識別子・任意式) → 構文エラー (動的 import 禁止)matchは「任意の subject 式(省略可)+ アームブロック{...}」を要求する。subject 位置では末尾ブロックの juxtaposition 適用を無効化し、matchと{の間に式があれば subjectful、無ければ subjectless とする。波括弧で始まる/末尾に波括弧を持つ subject は括弧化する (match ({...}) {...})。アームブロックに=>を欠く・subject 位置が解釈不能 → 構文エラー
import の path をリテラル限定にすることで、モジュールグラフを実行前に静的確定できる。
@ 記号はコア言語のどの位置にも存在しない。
1.5 文と式
構文形は式 (expression) と文 (statement) に分かれ、別枠として宣言 (declaration) がある。区別の軸は書ける位置(文法上の出現位置)であって、評価値の種類ではない。
式は値を生み、任意の式の位置に書ける — body の要素、部分式(引数・(...) 内・レシーバー)、:= / = の右辺、スロットのデフォルト値など。ほとんどの構文形は式である。制御構造(if / when / while)は prelude 値への適用にすぎず(§3.9)、conduit(§18.3)・loop(§18.4)・match(prelude.md §8.4)も式。
文は body 領域(§1.3)とファイルレベルの slot-list(§13.1)の要素の位置にのみ書ける。部分式の位置(引数・:= / = の右辺・(...) 内など)に現れると構文エラー。文は次の代入族(§6)のみ:
| 文 | 参照 |
|---|---|
ローカル束縛 name := expr / mutable name := expr / name = expr(型注釈付き name: 型 := expr を含む) |
§6.1 / §17 |
スロット代入 recv.slot := expr / recv.slot = expr |
§6.2 |
タプル destructure a, b := expr / mutable a, b := expr / a, b = expr |
§6.3 |
スロット destructure { a, b } := expr / mutable { 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 / mutable name := expr は文ではなくスロット宣言(§2.1)。スロット destructure・import・type はスロット/文の二相を持ち、slot-list 領域ではスロット宣言、body 領域では文として働く(§6.3・§13.2・§17.4)。タプル destructure は body 領域とファイルトップレベル(§6.3・§13.1)。inner name(inner-name 宣言。§4.2)と inner_bound name(§4.2.1)は逆に slot-list 領域限定で、body 領域・部分式には現れない。
宣言は import / export / type / enum の宣言形(§1.4)を指す。式ではないため部分式の位置には置けず、配置は各節の規則に従う — import はファイルトップレベルのみ(§13.2 / §13.4)、export もファイルトップレベルのみ(§13.2)、type / enum の定義位置は §17.4。宣言が文の位置で評価されたときの値も unit () — 代入族の文(§6.1)と同じく、名前を導入する操作は値を生まない。定義した型値は次の文から名前(T 等)で参照する。
2. オブジェクト (値の統一形)
2.1 構文
オブジェクトリテラルの正規形はパイプ1 個で2 つのセクションを区切る:
{ slot-list | body }
各セクションはそれぞれ独立に空にできる。
slot-list: スロットの列。区切りは,・改行・;のいずれか (すべて等価、連続・前後・末尾を無視。§1.3)。エントリの形は次で閉じている — 下記のいずれにも当たらない綴りは構文エラーである:name: 必須スロット (デフォルトなし)。値は immutable (caller が bind した後は書換不可)name := expr: デフォルトあり (immutable)mutable name := expr: デフォルトあり (mutable)。本体内・slot アクセスで=更新できる- 上記いずれも、名前の直後に 型注釈
: 型を前置できる (name: 型/name: 型 := expr/mutable name: 型 := expr)。意味論は §17「型注釈」を参照 (注釈は構文として受理して AST に保持し、値が束縛される地点で照合する) inner name(型付きはinner name: T): inner-name (自己参照名) 宣言 (§4.2)。本体内でこの名前を通じて値自身に到達できる。データスロットではない (obj.nameでは引けず、等価・shape・merge に影響しない)。slot-list 領域限定の特別形 (§1.5) で、1 リテラルに高々 1 つ。innerは予約語 (§1.4)、nameは書き手が選ぶ識別子で常に immutableinner_bound name: 束縛を写した自分に届く名前の宣言 (§4.2.1)。同じく slot-list 領域限定{ a, b } := expr: スロット destructure のスロット相 (§6.3)。抽出した各名前を immutable なスロットとして宣言するtype/enum/import/exportの宣言形 (§1.4)。type/enumスロットは実行時の値を持たないため arity に数えない (§2.2)- bare identifier は常にスロット宣言 として解釈される (関数構文
{x, y | x + y}を保つため) - 必須 / デフォルトあり immutable / デフォルトあり mutable は任意の順序で混在できる
- 必須スロットは常に immutable (必須 mutable は表現しない。回しながら使いたければ body 内で mutable local にコピーするか default を与える、§6.1)
- slot-list 全体を空にできる (= スロットなし)
- スロットの可変性 (mutable / immutable) と、スロット集合がリテラル宣言時に固定されること (§2.4) は別の話である
body: 任意の本体コード。複数式は;または改行で区切る。空可 (= 空 body)。
オブジェクトが持つ軸は 1 つである。 スロットは名前で宣言し名前で引く (recv.name)。整数添字で引く並びを持つのは List (§7.4)・Tuple (§7.3)・String (§7.2)・Bytes (§7.2.2)・range (§7.6) であって、オブジェクトではない。オブジェクトに xs.0 / xs.[i] の軸は無い。
オブジェクトの種類は一つしかない。すべての {...} リテラルは同じ「オブジェクト」を生成し、body はその object の属性にすぎない。record と function という値の種別は無く、呼び出しも等価も body の有無で分岐しない。
- body を評価すると最後の式の値を返す。末尾の要素が代入族の文 (
:=/=/ スロット代入 / 分解代入 / 複合代入。§1.5) のときは、その文の値である unit()が body の値になる (§6.1) - body を省略したリテラルは、「その literal の
inner_boundを返す本体」を持つものとして読む (§2.2)。省略した綴りと、inner_boundを宣言して裸で返す綴りは同じ値を生む
リテラルを評価すると object ができる。body はそのとき評価しない — 走るのは呼び出しのときで、規則は §3.6 の一本だけである。
呼び出しの binding 対象は未束縛のスロットである (既定値の有無は問わない。§3.5 / §3.6)。
正規形の空・非空の組み合わせはすべて意味を持つ:
| slot-list | body | 例 | 用途 |
|---|---|---|---|
| 空 | 省略 | {} (≡ { | }) |
空オブジェクト |
| 空 | あり | { | body } (≡ { body }) |
無引数ブロック |
| あり | 省略 | { x := 0 | } |
スロット持ちオブジェクト (慣用に「データオブジェクト」) |
| あり | あり | { x | body } |
関数として使うオブジェクト |
用途の列は慣用の呼び名であって値の種別ではない。どの行も同じ 1 種類の object である。
inner-name (inner name) と inner_bound name は宣言なので上のどの行にも足せる: { inner self, x \| body } は inner-name を持つ関数、{ inner self, x := 0 \| } は inner-name 付きデータオブジェクト。
字句上の注意: パイプ2 個がスペースなしで連続する || は論理 OR 演算子トークンとして読まれる。空 slot-list と空 body を明示して {|} と書く場合はパイプの前後にスペースを1 個以上入れる ({||} は構文エラー。空オブジェクトは {} とも書ける)。
ファイルレベルとの関係: ファイル全体も暗黙にリテラルだが、持つのは slot-list 領域だけである (| も body も 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)。
2.2 body を省略したオブジェクト
body セクションを省略したリテラルは、「その literal の inner_bound を返す本体」を持つものとして読む (§4.2.1)。「データ」として使うオブジェクトはこの形で書く:
{ slot-list | } # 正規形 (slot-list 非空、body 省略)
{ inner name, slot-list | } # 内部名付き
{ inner name | } # 内部名付き・他スロットなし
{|} # スロットなし (= `{}` と等価)
{} # 同上 (0-pipe 省略形)
慣用的にこの形のオブジェクトを「データオブジェクト」と呼ぶことがある。値の種別ではなく呼び名で、文法的にも意味論的にも他のオブジェクトと同種である (§2.1)。
省略は綴りの省略である。 省略した綴りと、inner_bound を宣言して裸で返す綴りは同じ値を生む。等価が 2 つを見比べて一致と判定するのではなく、処理系が値を組む前に 2 つの綴りを同じものへまとめる — したがって等価だけでなく、検分表示・値の種別・持ち物のすべてが一致する。スロットの構成に依らない:
{ x, y | } == { inner_bound b, x, y | b } #> true
{ x: Int | } == { inner_bound b, x: Int | b } #> true 型注釈が付いても同じ
{|} == { inner_bound b | b } #> true スロットが無くても同じ
まとめるのは裸の参照 1 つだけの body である。 括弧・アスクリプション・スロットアクセス・呼び出しが 1 つでも付けば別の綴りであり、別の値になる (§9.1.1 の崖)。整形はどちらの綴りもそのまま保ち (format.md)、省略形で書けることの指摘は hikari lint が担う (lint.md)。
コンストラクターとしての data object: 未束縛スロットを残したこの形は、呼び出しで残りを束縛した完成値を返す (§3.6)。定数の判別子 (タグ) は既定値スロット (t := "int") で与える。これにより { v: Int, t := "int" |} のような data object を、関数で包まずそのままコンストラクターとして使える — 入れ子も同名スロットへの転記 (v := v) も不要になる。
既定値スロットは宣言順の後ろに置く。 既定値の有無に依らず未束縛スロットは束縛対象なので (§3.5)、{ t := "int", v: Int |}("+", 1) は先頭の t から埋まる。タグのように呼び出しで埋めたくないスロットを前に置くと実引数に乗っ取られるため、必須スロットを先に並べる。hikari lint がこの順序を勧告する (lint.md)。
可変スロットを持つリテラル
可変スロット (mutable) も束縛対象である。 { mutable count := 0 |} は arity 1 の構築子で、呼び出しが count を束縛し、明示 kick (! / ()) が既定値を補完してから可変スロットを持つ完成値を組む。スロット宣言も本体の束縛と同じ綴りで、宣言は :=・可変は mutable が担う (§6.1)。
c := { mutable count := 0 |} # 構築子 (派生元)
i := c! # 可変スロット count を持つ完成値
i.count = 5 # 通る。書き込む先は完成値のスロット
c.count = 5 # エラー。未束縛スロットに現在値は無い (§4.1)
可変性は束縛済みスロットの属性である。 構築子の側のスロットは既定値の有無に依らず未束縛なので (§3.5)、現在値を持たない。書き込みを許せば以後の全派生へ漏れるため、構築子への代入はエラーである。
既定値の中の裸の兄弟参照は完成値に届かない。 相互可視 (§6.3) が見せるのはリテラル評価時の値そのものなので、inc が捕獲するのは 0 であって完成値のスロットではない。
{ mutable count := 0, inc := { count += 1 } |} # inc はリテラル評価時の 0 を捕獲する
完成値の可変状態へ届く名前は inner_bound (§4.2.1) である。「状態 + メソッド」を持つオブジェクトはこの形で書く。
counter := { inner_bound b, mutable count := 0, inc := { b.count += 1; b.count } |}!
counter.inc! #> 1
counter.inc! #> 2
既定値は派生をまたいで共有される。 既定値はリテラル評価時に 1 度だけ確定するので (§12)、同じ構築子から 2 回派生させると、既定値が可変な値であれば両方が同じものを掴む。hikari lint の shared-mutable-default がこれを勧告する (lint.md)。
型・enum スロットを持つリテラル
型スロット・enum スロット (§17.4) は arity に数えない。 実行時の値を持たないので実引数で埋める対象にならず、名前としてだけリテラルに載る。
mk := { type Pos := { a: Int |}, x: Int |} # arity 1 (x のみ)
ns := { type A := Int, type B := String |} # arity 0。その場で値になる
型・enum スロットしか持たないリテラルは未束縛スロットを持たないので、§3.6 のとおりその場で body が走り値になる — 名前空間として使う綴りは呼び出しを要求しない。
型・enum スロットは値の流れを追わない。構築子から直に読む形 (mk.Pos) と、呼び先が静的に分かる派生からの読みだけが通る。
2.3 例
# 関数: スロット + 本体 add := { x: Int, y: Int | x + y } # データオブジェクト: スロット (デフォルトあり) + body 省略 point := { x := 0, y := 0 |} # 内部名 (inner) + スロット + 本体 fact := { inner self, n: Int | if(n <= 1, { 1 }, { n * self(n-1) }) } # 無引数ブロック (制御構造に渡す用) block := { "hello" } # 内部名のみの無引数ブロック (他スロット無し) ref := { inner self | self.value } # 必須とデフォルトの混在 mix := { x: Int, y := 0, z: Int | x + y + z } # x, z が必須、y はデフォルトあり # スロットなしデータオブジェクト (= {}) empty_data := {|}
2.4 スロット集合はリテラル宣言時に固定される
リテラル宣言後にスロットを追加する手段は言語に無い。 obj.x := expr / obj.x = expr で新しい名前を作ることはできない。名前でキーを引く辞書が要る場合は標準ライブラリの std:map を使う (§7.5)。
これはレイアウトを静的に決められるようにするための制約である。record は幅構造型なので、スロットが後から生えると静的型が知らないスロットが存在しうることになり、固定オフセットの構造体へ低レベル化する前提が壊れる。
slot 規則:
| 操作 | 既存 slot | 既存無し |
|---|---|---|
obj.x := expr |
静的エラー (再宣言不可) | 静的エラー (slot は追加できない) |
obj.x = expr |
mutable → 更新 / immutable → ランタイムエラー | ランタイムエラー (slot は追加できない) |
:= と = で報告の時期が違う。 obj.slot := expr は受け手に依らず常にエラーなので、受け手の形も型も解かずに静的に報告する。obj.slot = expr の可否 (既存 slot か否か・mutable か否か) は実行時の値の性質なので、静的に判る受け手だけを報告し (static-analysis.md §2.10)、残りは評価時に振り分ける。どちらも 構文としては valid (パース可能) で、:= を構文エラーにしない理由は §6.2。
スロット集合が固定であることと、slot 値の immutable / mutable (§4.1) は別の軸である:
| slot value | Hikari | 用途 |
|---|---|---|
| immutable | {x := 0 |} |
値型 (slot 固定 + 値固定) |
| mutable | { mutable x := 0 |} |
record (slot 固定 + 値可変) |
# 値型 (slot 固定 + 値固定) p := { x := 3, y := 5 |} p.x = 99 # ランタイムエラー (immutable slot) # record (slot 固定 + 値可変) counter := { mutable count := 0 |} counter.count = counter.count + 1 # OK counter.total = 0 # ランタイムエラー (slot は追加できない) # 名前でキーを引く辞書は std:map (§7.5) import m := "std:map" cache := m.insertion.build { h | h.set("user_42", { name := "alice" |}) h.set("user_42", { name := "bob" |}) # 同じキーを上書き }
List は長さを変えられる別の型ではない。 List も不変で、要素を足した結果が欲しければ map などで組み直すか、可変 scratch ハンドルを frozen で List に確定する std:array を使う (§7.4・std/array.md)。
ファイルトップレベルの暗黙リテラル (§13.1) も同じくスロット集合が固定である。
2.5 全値に生える universal method
一部の method は受け手の型に依らず全ての値で解決される (原始型・コレクション・関数・タグ付きデータを問わない)。「値の統一形」の帰結で、いずれも受け手の同名スロットで上書きできる (§8.6) — 上書きの有無にかかわらず、その名前は全ての値で解決可能なまま保たれる (受け手定義に置き換わるか組込既定のままかの違いだけ)。
| method | 形 | 役割 | 詳細 |
|---|---|---|---|
matches |
v matches T / v.matches(T) |
型テスト。Bool を返す。演算子表の唯一の英数字エントリ (§8.3) | §17.4 |
compare |
a.compare(b) |
順序比較。Ordering を返し < <= > >= を導出 |
§9.4 / prelude.md §13 |
inspect |
v.inspect() / v.inspect! |
人間可読なデバッグ表現。String を返す | prelude.md §14 |
reference_equals |
a.reference_equals(b) |
参照同一性。同一インスタンスなら Bool true。== (値等価) と独立 |
§9.1 |
copy |
v.copy() / v.copy! |
現在状態を持つ独立コピー。可変部を作り直し不変部は共有 | §9.5 |
apply_when |
v.apply_when cond f |
条件付き適用。真なら f v、偽なら v をそのまま返す |
prelude.md §16 |
-> |
a -> b / a.->(b) |
ペア構成。2-tuple (a, b) を返す |
§8.3 |
- 呼び出し時の型要件は method ごとに違う:
matchesの右辺は型値、compareは受け手と引数が相互に Comparable でなければ panic (§9.4)、copyは受け手が Copyable でなければ panic (§9.5)、apply_whenは第 2 引数が Bool でなければ panic (第 3 引数は真のときだけ適用されるため、偽なら callable でなくても成功する。prelude.md §16)、inspect/reference_equals/->は全値で成功する (reference_equalsは §9.1)。 apply_whenはconduit(§18.3): 渡したfの中のreturn/break/continueはapply_whenを貫いて呼び出し側の関数・ループへ届く。他の universal method は通常の関数境界を持つ。->:a -> bはa.->(b)に脱糖する (§8.1) universal method で、常に 2-tuple(a, b)を返す。全値がこの baseline 実装を持ち、他の universal method と同じ規則で受け手の->スロットがあればそちらが優先される — この優先順位が働いても->という名前自体は全ての値で解決可能なままである。結果はタプルなので位置アクセス (("a" -> 1).0) や destructure ({k, v | …}) がそのまま効く。- 構造照合による分岐 dispatch は universal method ではなく
match構文 (§1.4、prelude.md §8.4)。matches(型テスト) だけが universal method として残り、matchは名前として解決されない構文キーワードであるため、v.matchの第一級取り出し・受け手matchスロットによる上書きは持たない。matchesが演算子表の項目である (§8.3) こととこれは両立する — 表への登録は中置位置での読み方を決めるだけで、名前を予約語にはしないため、v.matchesの取り出しもスロット上書きも従来どおり働く。 - 二項演算子 (
+ - * / % == != < <= > >=) も同様に全値で解決される (型チェックは呼び出し時。§8)。 - これらと別系統の読み取り method がある。要素の並びを見る
Sequence(§17.5。each/map/filter/fold/first/last/positions/has_positionほか) は List・String・Bytes・range・タプルで動き、スロットの自己記述 (names/values/has_name) はオブジェクトで動く。どちらも対象が全値ではない点が universal method と異なる。
3. 呼び出しと適用
3.1 概念モデル
Hikari の関数は概念的に常に1 引数を取る。スロット宣言 {x, y | ...} はその1 引数を destructure するパターンを表す。これを軸に次のルールが組み合わさる:
- 並置 = 関数適用:
f xはfにxを適用 - タプルは複数要素のみ:
(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 を適用 recv + x # 中置演算子。演算子表 (§8.3) にある名前だけが中置になる → recv.+(x) recv.[expr] # 動的な要素アクセス。expr の評価値で Sequence の要素を引く (§3.10) recv.[expr] = v # 動的アクセスでの代入。mutability / open-closed 規則は §3.10 / §6.2 v! # v() と等価 (引数なしの postfix 短縮)
並置は常に関数適用である。 f x が中置メソッド呼び出しに読み替えられることはない。中置になれるのは演算子表 (§8.3) にある名前だけで、どれが中置かは構文解析の時点で決まる — 受け手の値には依存しない。recv name x のような任意の識別子を中置に使う記法は持たないので、メソッド呼び出しは recv.name(x) または recv.name x と書く。
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: Int, y: Int | 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: Int | value + other } |}
one.add(2) #> 3
one.add 2 #> 3 ドット + 並置。one add 2 とは書けない (§8.1)
3.5 引数の束縛規則
関数 f に適用するとき、f の 未束縛のスロット数を R とする (型スロット・enum スロットは実行時の値を持たないため数えない。§17.4)。元関数を直接呼ぶときは R = スロット総数 N、部分適用ならその時点で残っているスロット数。
引数の形は 2 つある。 f(a, b) のように書いた表層の引数列 (個数を K とする) と、値としてのタプル 1 個である。両者は R = 1 のときだけ同じものになる。
表層の引数列
行は上から順に当て、最初に一致した行が勝つ(K = 2 / R = 1 は 2 行目と 5 行目の両方に当たるので、順序が意味を持つ)。
| K と R | 規則 |
|---|---|
K = 0(() / !) |
スロットを一切埋めない(明示 kick。v! ≡ v()) |
| K ≥ 2 かつ R = 1 | 引数列をタプルへまとめてその 1 スロットへ丸ごと束縛({pair |}(3, 5) は pair = (3, 5)) |
| K = R(R ≥ 1) | 各引数を未束縛のスロットへ前から順に束縛し、飽和して本体を評価 |
| K < R | 前から順に束縛した部分適用(curry)。f(1, 2) は f(1)(2) と同じ値になる |
| K > R | エラー (引数数不一致)。R = 0 なら「束縛先のスロットがない」 |
個数に依らず curry である。 if x {y} {z} が段階的な単一値適用で動く(1 回目で cond、2 回目で then、3 回目で else が埋まる)のと、f(1, 2) が 2 スロット分進むのは同じ規則の別の断面にすぎない。
タプル 1 個
値としてのタプル (要素数を M とする) を渡したときは、完全な引数列として扱う。
| M と R | 規則 |
|---|---|
| R = 1 | その 1 スロットへ丸ごと束縛(destructure しない) |
| R ≥ 2 かつ M = R | destructure: 各要素を未束縛のスロットへ前から順に束縛 |
| R ≥ 2 かつ M ≠ R | エラー (引数数不一致) |
| R = 0 | エラー (束縛先のスロットがない) |
タプルは部分適用へ倒さない。 表層の引数列と違って要素数の不足をエラーにするのは、(3, 5) を 3 スロット関数へ渡すタイポを検出するためである(明示的に選んだ厳密性)。「書き落とした引数」と「まとめて渡した引数列」を綴りで区別できるのは、この 1 点だけである。
「未束縛のスロット数」を基準とすることで、通常の呼び出しと部分適用への追加適用が同じ規則で説明できる。
R = 0 のオブジェクト (スロットを持たない、または全スロットが既に bound) に引数を渡すと、束縛先がないためエラーになる。{} 5 や [1, 2, 3] "x" がこれである (リストはスロットを持たないので R = 0 である。§7.4)。() または ! を使えば引数 0 個として正しく扱われ、body を省略したオブジェクトなら自身と等しい値が返る (§3.6)。
注 (引数の可変性): caller から bind される値は called frame の必須スロット (immutable、§2.1) に入るため、本体内で引数を=で再代入することはできない。引数を回しながら使いたい場合はn = arg + 1のように mutable local へコピーする (§6.1)。
注 (タプルの位置づけ): 未束縛スロットが 1 つの関数{pair |}に(3, 5)を適用すると、タプルが値としてpairに丸ごと束縛される(R=1)。f(a, b)がタプル(a, b)の適用と同義になるのは R = 1 のときだけで、{pair |}(3, 5)も{pair |}((3, 5))もpair = (3, 5)になる。R ≥ 2 では両者が分かれる —{x, y, z |}(3, 5)は前から 2 つを埋めた部分適用だが、{x, y, z |}((3, 5))は要素数が合わないタプルなのでエラーである。タプルを destructure したいときは未束縛スロットを要素数ちょうどにする({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 の経路のみ — 値適用で本体評価に至る時点では全スロットが束縛済み)、本体を評価して結果を返す
- 部分適用: 新しいオブジェクトを返す。元の関数への参照と「束縛済みスロット名→値」のマップを保持する
分岐はこの 2 つだけである。 body を省略したリテラルにも別経路は無い — 省略した body は「その literal の inner_bound を返す本体」なので (§2.2)、ステップ 3 がそのまま束縛を写した値を返す。引数で残りのスロットを束縛したなら完成した data object が、束縛が増えない呼び出し (! / ()、R = 0) なら自身と等しい値が返る。引数の検証 (ステップ 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 の有無にも依らない — body を省略したリテラルのデフォルトも同じく未束縛スロットとして数える (下記all_defaults参照)。
スロットを持たないリテラル{|}は R = 0 なので、o!/o()は引数 0 個の検証を通って自身と等しい値を返す。body を省略したオブジェクトを引数付きで呼ぶ場合の振る舞いは §3.5 の引数検証規則に従う —{}!/{} ()は自身と等しい値、{} 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 省略なので inner_bound が返る)
obj() #> 同上
{}! #> {} と等しい値
{|}! #> 同上 (`{}` と等価)
{} 5 #> error (R = 0 に単一値)
{} (1, 2) #> error (R = 0 に 2-tuple)
[] "x" #> error (R = 0 に単一値)
# 既定値スロットも未束縛スロットとして数える (body の有無に依らない)。
all_defaults := { x := 0, y := 0 |}
all_defaults.x #> 0 (既定値は読める)
all_defaults 3 #> 部分適用 (x = 3、y は未束縛のまま既定値 0 を持つ)
all_defaults! #> 全デフォルトを補完した、自身と等しい値
# 必須スロットだけの body 省略オブジェクトは R ≥ 1 を持つ。
pair := { x: Int, y: Int |}
p1 := pair 3 #> x := 3 を bind した部分適用オブジェクト
p1.x #> 3
pair(3, 5).x #> 3 (x, y 両方 bind した完成 data object)
# 必須スロット + 末尾の定数 tag は「コンストラクター」になる。
# 完全適用すると束縛を写した完成値が返り、部分適用は curry になる。
n_bin := { op: String, l: Int, r: Int, t := "binop" |}
n_bin("+", 1, 2).op #> "+" (op, l, r を束縛した完成 data object)
n_bin("+", 1, 2).t #> "binop" (未束縛のまま既定値を読む)
n_bin "+" #> 部分適用 (op := "+"、l, r, t 未束縛)
3.7 部分適用の値表現
部分適用は新しいオブジェクトとして表現される:
- 元関数への参照を保持
- 束縛済みスロットの「名前 → 値」マップを保持
部分適用に対するスロットアクセス:
- 束縛済みスロットは読める:
add5 := add 5; add5.x #> 5 - 未束縛スロットを読むとエラー (Error)
add := { x: Int, y: Int | 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: Int, y: Int | 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: Bool, then: {| Unit } | if(cond, then, { () }) }
when c.not! {print "foo"} のように、else を省略したい用途で使う。
3.10 動的な要素アクセス
recv.[expr] は expr を評価した Int をキーとして、Sequence (§17.5) を実装する値の要素を動的に引く。recv.0 (§3.2) の添字部分を「リテラル整数」から「式の評価結果」に拡張したものである。
要素の並び専用である。 オブジェクトのスロットを実行時のキーで引く手段は言語に無い。キーで引く辞書が要る場合は標準ライブラリの std:map を使う (§7.5)。
評価規則:
recvを評価するexprを評価して値kを得るkが Int でなければ実行時エラーrecvの要素をkで引く (recv.<k>と同じ)recvがSequenceでない、またはキーに対応する要素が無ければ実行時エラー
添字には静的な証明義務がかかる。 添字が受け手の範囲(0 <= i < recv.length!)に収まることを実行前に証明できなければ静的検査が報告する (static-analysis.md §2.11「添字の範囲義務」)。recv.N (§3.2) も同じである。解消の道は 2 つで、条件で検査する形 (if (i < xs.length!) { xs.[i] })・range から引く形 (range(0, xs.length!).map { i | xs.[i] }) である。義務がかかるのは受け手の長さが静的に分かる位置に限る — 述語は自由変数を持てない (§17.7) ので「この引数の長さは N 以上」という契約は型として書けず、材料の無い位置で鳴らせば書き手に直す道が無い。かからない位置(Any・型変数・Range・長さの分からない受け手)では範囲外は従来どおり実行時エラーになる。
等価性:
xs := [10, 20, 30] xs.[0] #> 10 (≡ xs.0) xs.[1+1] #> 30 (キー部分は任意の式)
型による制約:
xs.["x"] #> error (key must be Int, got STRING) xs.[true] #> error (key must be Int, got BOOLEAN) xs.[(1, 2)] #> error (key must be Int, got TUPLE)
なぜ要素の並びだけなのか: スロットの動的キーは、どのスロットへ触るかが静的に決まらない。record は幅構造型なので、静的型が知らないスロット(別の視点が具体型で宣言しているスロット)へ書かれる可能性が残り、値のレイアウトを固定オフセットの構造体へ低レベル化する前提が壊れる。要素の並びにはこの危険が無い — List(T) は要素型が均質なので、添字が実行時に決まっても要素の型は変わらない。したがって要素の動的アクセスは gradual 境界ではない (§17)。
結合性・優先順位: . と同レベル (§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 は常にランタイムエラーである。Sequence を実装する値 — List・String・Bytes・タプル・range — はいずれも不変で、要素を差し替える経路を持たない (§7.4)。任意添字への O(1) 散在書き込みが要る場合は std:array (§7.4) を使う。
xs := [10, 20, 30] xs.[0] = 99 # ランタイムエラー (リストは不変)
ユーザー定義によるオーバーロードは持たない。recv.[k] の意味論は組み込み (= Sequence の要素の動的検索) に固定。
存在確認との対: recv.[i] は位置不在のとき実行時エラーになる。引く前に確かめたい場合は recv.has_position(i) を使う (prelude.md §6)。位置の全列挙は recv.positions!。オブジェクトのスロットの列挙・存在確認は recv.names! / recv.has_name(k) で行えるが (§2.5)、名前で引く手段は無い (recv.<リテラル識別子> だけ)。組込型メソッド・演算子は列挙・存在確認の対象外なので、名前で解決できる全てを列挙する手段は言語に無い。
4. スロットと自己参照
4.1 スロットの可変性
スロットには mutable と immutable の 2 種類があり、どちらで宣言したかは slot-list の形で決まる (§2.1):
name(bare 必須) /name := default→ immutablemutable name := default→ mutable
immutable slot を = で書き換えるとランタイムエラー。mutable slot は = で更新できる。値は参照共有のセマンティクスを持ち、mutable slot 経由の更新は共有先からも見える。
可変性は束縛済みスロットの属性である。 未束縛スロット (§3.5) には現在値が無いので、mutable と宣言してあっても書き込めない — { mutable count := 0 |} そのもの (構築子) や、その部分適用への c.count = 5 はランタイムエラーである。書き込む先を持つのは呼び出しが束縛を写した完成値だけである (§2.2)。
obj := { name := "john", mutable score := 100 |}! # 完成値 ([§2.2](#22-body-を省略したオブジェクト))。name は immutable、score は mutable
obj.score = 200
obj.score #> 200
foo := obj
foo.score = 999
obj.score #> 999 # 参照共有 (mutable slot)
! を落とした綴り ({ … |} そのもの) は構築子であり、そのスロットは未束縛なので obj.score = 200 は上記のとおりランタイムエラーである。完成値に対しても、immutable slot への代入 (obj.name = "bob") はランタイムエラー (immutable-slot-assign) になる。
可変性は shallow — immutable slot が指す先が mutable な List / Object であれば、その中身は依然変更できる (slot 自体の再代入だけが禁じられる)。スロットを実行時に追加することはできない (§2.4) — キーで引く辞書が要る場合は std:map を使う (§7.5)。
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: Int |
if(n <= 1, { 1 }, { n * self(n-1) })
}
:= の右辺評価中、左辺名 (この例では fact) はまだ未束縛である。再帰したい場合は inner-name を使う (letrec セマンティクスは持たない)。
内部名が指すのは リテラルが生んだ値そのもの である。部分適用 (§3.5・§3.7) で派生した値の本体を評価するときも同じで、内部名には派生元が入る (束縛済みスロットを持つ派生値ではない)。したがって f(1)(2) と f(1, 2) は本体から見える自己参照も一致し、{ inner rec, a, b | … (rec(a - 1))(b + 1) … } のように curry 形で自己呼び出しを書いても、実引数は毎回すべてのスロットへ先頭から束縛される。
束縛を写した自分に届きたい場合は inner_bound を使う (§4.2.1)。
4.2.1 束縛を写した自分 (inner_bound)
slot-list に inner_bound name を書くと、束縛を写した自分に届く名前を宣言できる。inner と対になるが、指す先が違う。
| 宣言 | 指すもの | 派生時 |
|---|---|---|
inner name |
リテラルが生んだ値そのもの (派生元) | 再束縛しない |
inner_bound name |
束縛を写した自分 | 派生のたびに再束縛する |
node := { inner_bound b, op: String, l: Int, r: Int | validate(b) }
node("+", 1, 2) # op, l, r を束縛した値を validate に通してから返す
body を省略したリテラルが返すのはこの値である (§2.2)。{ x, y | } は { inner_bound b, x, y | b } と同じ値を生む。
規則は inner (§4.2) と揃える。
- slot-list 領域限定の特別形 (§1.5) で、1 リテラルに高々 1 つ。名前は書き手が選ぶ識別子
- 宣言形のため代入演算子を持たず、常に immutable
- データスロットではない —
obj.nameでは引けず、等価・shape・merge に影響しない (§9.1 の等価は自己参照名の綴りを正規化して比べる) - 型注釈を持たない。束縛後の型はスロット宣言から導出できるため、注釈は言い直しにしかならない。
inner_bound b: Tは構文エラー (§1.4) innerと併用でき、同じ識別子を両方に宣言するのは構文エラー。再束縛は名前ごとに決まる — 派生した値のメソッドスロットはinner_boundの捕獲だけが新しい値へ束縛し直され、innerの捕獲は派生元を指したままになる- 指すのは値そのもので、複製ではない。可変スロットへの書き込みは同じ値に効き、
reference_equals(§9.1) も真になる。copyしたときは §9.5 の自己参照名の再束縛と同じ規律で複製先へ束縛し直す - 既定値を評価している最中は読めない (§12)。まだ束縛が始まっておらず、そこから読める値を言語は定めない。静的エラーである (static-analysis.md §2.7)
- 既定値が作る関数リテラルの中からは捕獲できる。既定値の評価中に裸で読むこと (上記) とは別で、捕獲した名前が読まれるのはその関数が呼ばれたときなので、そのときには束縛が済んでいる
{ inner_bound b, x: Int, get := {| b } | x }
- 再束縛は派生のたびに起きる。 派生とは実引数を束縛して新しい値を作ることで、リテラル評価 (派生元を作る側) では起きない。部分適用も派生である — 未束縛スロットが残る呼び出しで得た値でも、そのスロット既定値が捕獲する
inner_boundはその派生値を指す
c := { inner_bound b, x: Int, y: Int, get := {| b } |}
c(1).get! #> { x := 1, y, get := {| ... } |}
- 既定値が作った入れ子の閉包は最後の派生を指す。 再束縛が届くのはスロットの値そのものが関数リテラルである形までで、
wrap {| b.x }のように閉包が呼び出しの実引数として作られると、その閉包は既定値の一部として派生をまたいで共有される (§12.1)。共有された 1 個の閉包が名指せる派生は 1 つなので、最後に派生した値を指す
c := { inner_bound me, x := 0, direct := {| me.x }, nested := wrap {| me.x } |}
a := c(1) ; b := c(2)
a.direct! #> 1 スロットの値そのもの — 自分の派生を指す
a.nested.run! #> 2 既定値の中の閉包 — 最後の派生 (b) を指す
a.nested.reference_equals b.nested #> true そもそも同じ 1 個の値である
4.3 レキシカルスコープによる外側参照
外側のオブジェクトリテラルが内部名を持っていれば、内側からその名前で外側に到達できる。これは特別な仕組みではなくレキシカルスコープそのものである。parent も super も予約語ではなく、外側リテラルがその名前を選んだだけ。
outer := { inner parent, value := 1 |
child := { inner self, value := 100 |
self.value + parent.value
}
child() #> 101
}
5. 名前解決
本体内で裸の名前 x が参照されたとき、以下の順に探索する:
- 呼び出しフレームの束縛 (引数や本体内
:=で導入されたローカル) - 自身のスロット (ファイルレベルではトップレベル宣言が作る slot)
- 自身の内部名 (宣言されていれば)
- 外側リテラル群のスロット・内部名・本体ローカル (レキシカルチェイン)
§13.1 の評価モデルにより、ファイル全体は暗黙のオブジェクトリテラル { slot-list |} として扱われる。ファイルのトップレベル宣言は最外側リテラルのスロットなので、ステップ 2 (自身のスロット) と、内側のリテラルから見たときはステップ 4 (外側レキシカル) で解決される。
裸の value と self.value は同じ意味で、両方有効。self.value を書くのは読み手への明示のため。
6. 代入演算子
6.1 ローカル束縛: := と =
宣言は :=・代入は = であり、可変かどうかは前置予約語 mutable (§1.4) が担う。この割り当ては領域で変わらない — slot-list 領域のスロット宣言 (§2) もスロットアクセス (§6.2) も同じ 2 本の規則で読める。
name := expr: immutable なローカル宣言を作る。同じ呼び出しフレーム / 同じリテラルのスロット宣言内に既に同名があれば静的エラー (シャドウ不可。static-analysis.md §2.10)。外側スコープ (別のオブジェクトリテラル) の同名を「結果として隠す」のは可 — 別スコープなので衝突ではないmutable name := expr: mutable なローカル宣言を作る。重複・シャドウの規則は:=と同じ (既存が immutable でも mutable でも、同一スコープの再宣言は重複宣言エラー)name = expr: 代入。スコープチェインを上に探索し:- 既存 mutable が見つかれば 更新
- 既存 immutable が見つかれば 静的エラー (static-analysis.md §2.10)
- どこにも無ければ 静的エラー —
=は名前を導入しない (static-analysis.md §2.10)
= が名前を導入しないことは、打ち間違いの防波堤である。 lenght = 5 は「そんな名前は無い」と静的に報告される。可変な名前が生まれる位置は mutable の綴りがある所だけなので、読み手も書き手も宣言を探すのに名前の初出を追う必要が無い。
prelude の束縛は最外スコープの immutable 束縛である。 = の探索はこれを見つけるので、println = f や max = 0 は上の「既存 immutable」の枝に落ちて静的エラーになる (static-analysis.md §2.10)。prelude 名を特別扱いする規則ではなく、x := 1 の後の x = 2 と同じ 1 本の規則が当たるだけである。
prelude は利用者のトップレベルより外側のスコープなので、:= / mutable := による被覆 (if := 5。§1.2.2) は正当で、重複宣言にはならない。prelude が占める名前の可変ローカルが要るなら mutable max := 0 と宣言する — 宣言は被覆として通り、以後その名前はその宣言を指す。
_ := expr/_ = expr: 名前が_(破棄子) のときは 右辺を評価したうえで何も束縛しない。_は環境に入らないため、同一スコープで何度でも書けて (:=のシャドウ不可の例外)、後で読むと未定義 (undefined: _) になる。「捨てた」ことが構文上保証される。_ = exprは上の「どこにも無ければ静的エラー」の例外である —_は探索の対象ではなく捨て先なので、宣言の有無を問わない。破棄が効くのは bare な宣言順束縛の左辺のみ —_に値を与えた slot ({_ := d |}/{ mutable _ := 1 |}) やobj._、スロット destructure{ a, _ }では_は通常の識別子のまま (§6.3 / §3.5)。
代入族の文の値は unit (): := / = による束縛・代入は、評価すると常に unit () を返す (§3.3) — 右辺の値ではない。束縛は「名前を導入する」操作であって値を生む式ではない、という立場を値でも表す。破棄子 _ := 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 |
mutable n := count + 1 # 可変の宣言 (count は immutable のためコピー)
n = n + 1 # 既存 mutable を更新
n
}
6.2 スロットアクセス: = と :=
スロットアクセスの代入は、対象 slot の mutability だけで決まる。スロットの集合は open / closed によらず固定なので (§2.4)、代入で新しいスロットが生まれることは無い。 ローカル束縛 (§6.1) と同じく = は名前を導入せず、宣言はリテラルの slot-list だけが行う。
obj.slot = expr: 既存 mutable → 更新 / 既存 immutable → ランタイムエラー / 未定義 → ランタイムエラーobj.slot := expr: 静的エラー (slot 追加不可・再宣言不可)
obj.slot := expr は構文として valid (パース可能) だが、受け手に依らず常にエラーである。スロット集合はリテラル宣言時に固定される (§2.4) ので、この綴りが成立する受け手は存在しない。したがって受け手の形も型も解かずに静的に報告する (static-analysis.md §2.10)。obj.slot = expr の側は違う — 既存 slot か否か・mutability は実行時の値の性質なので、静的に判る受け手だけを報告し、残りは評価時に振り分ける。
それでも := を構文エラーにはしない。 綴りの意味は明瞭 (「スロットを足したい」) なので、返すべき答えは「読めない」ではなく「その操作は無く、実行時にキーを増やしたいなら std:map である」(§7.5) だからである。構文エラーの位置にはこの案内を置けず、同じファイルの他の診断も出せなくなる。
obj := { mutable name := "john" |} # name は mutable
obj.name = "bob" # OK (既存 mutable スロットを更新)
obj.score = 30 # ランタイムエラー (未定義スロット)
obj.score := 30 # 静的エラー (slot 追加不可)
p := { id := 1 |} # id は immutable
p.id = 2 # ランタイムエラー (immutable slot)
cache := {} # スロットを持たないオブジェクト
cache.user_42 = "alice" # ランタイムエラー (slot は追加できない)
slot を = / := により後付け追加できないのは、タイポによる事故を防ぐためと、スロット集合を静的に固定してレイアウトを決められるようにするためである (§2.4)。キーで引く辞書が要る用途は std:map を使う (§7.5)。
スロット代入も文の値は unit () (§6.1)。代入対象が無い・immutable などのエラーは従来どおり伝播し、成功時のみ () を返す。
6.3 分解代入
分解代入は 2 形 (位置 / 名前) あり、それぞれ宣言版 (:=。mutable 前置で可変) と代入版 (=。§6.1 の = 規則どおり既存の可変を更新し、無ければ静的エラー) を持つ。mutable は名前リスト全体に掛かり、一部だけを可変にする綴りは無い — 混ぜたいなら宣言を分けて書く。
| 形 | 意味 |
|---|---|
a, b := expr (タプル destructure) |
a, b を immutable で導入 |
mutable a, b := expr |
a, b を mutable で導入 |
a, b = expr |
既存の可変な a, b を更新 |
{ name1, name2 } := value (スロット destructure) |
各 name を immutable で導入 |
mutable { name1, name2 } := value |
各 name を mutable で導入 |
{ name1, name2 } = value |
既存の可変な各 name を更新 |
分解代入も文の値は unit () (§6.1) — 右辺のタプル/オブジェクトではない。
タプル destructure: a, b := v(...) で右辺の要素の並びから複数のローカルを順に一度に導入する。多値返却のタプルを受けるのが主用途だが、取り出す先はタプル専用ではない — Sequence (§17.5) を実装する値ならどれでも分解できる。
| 右辺 | 例 | 結果 |
|---|---|---|
| タプル (§7.3) | a, b := (1, 2) |
a = 1, b = 2 |
| リスト (§7.4) | a, b := [1, 2] |
a = 1, b = 2 |
| String の rune 列 (§7.2) | a, b := "xy" |
a = "x", b = "y" |
| Bytes (§7.2.2) | a, b := "ab".to_bytes! |
a = 97, b = 98 |
| range (§7.6) | a, b := range(0, 2) |
a = 0, b = 1 |
要素数は厳密に一致する (§3.5)。左辺の名前の個数と右辺の要素数が違えば実行時エラーになる。Sequence でない値を右辺に置くのも実行時エラーで、オブジェクト (§2)・Int・Float・Bool・unit・variant (Option / Result / Ordering / Control とユーザー定義 enum のタグ値)・std: の不透明型 (§13.4)・Future (§10) がこれにあたる。どちらのエラーも右辺の型が静的に確定していれば hikari check が実行前に報告する (static-analysis.md §2.7)。
タプル destructure の左辺に _ を置くと、その位置の値を捨てる (束縛しない、§6.1)。a, _ := (1, 2) で第 2 要素を破棄でき、_ も 1 ポジションを占めるため要素数の厳密一致 (§3.5) は変わらない。_, _ := pair のように複数捨ててもよい。一方、スロット destructure { a, _ } := obj では _ は破棄子にならず、「slot 名 _ を抽出」する通常 lookup になる (名前で選ぶ側は要らない名前を列挙しなければ済むため、破棄概念が不要)。
スロット destructure: オブジェクトから複数 slot を同名のローカルに一括束縛する:
{ name1, name2, ... } := value
名前リスト (name-list)
{…} で囲んだ名前の列は、スロット destructure の左辺・import の対象・export の列挙
(§13.2) の 3 か所に現れる。これは独立した構文カテゴリであり、オブジェクトリテラル
(§2.1) でも slot-list (§1.3) でもない。
- 中身は bare 識別子、または各名に型注釈を付けた
name: 型のいずれか - 区切りは §1.3 と同じ (
,・改行・;、連続・前後・末尾を無視) - bare 識別子は「束縛または公開する名前」を表す
match のレコードパターンと綴りが一致する。 名前リストは、match のアームに書く
レコードパターン { field… } (prelude.md §8.4) の束縛のみの部分集合である
— どちらも {…} の中に名前を並べ、対象のスロットを同名で束縛する。レコードパターンは
これに加えて値照合 name := v と型照合 name: T を書けるが、名前リストは束縛だけを行う。
{ a, b } := v # 文で束縛
match v { { a, b } => … } # arm で束縛
紛れないのは末尾の代入演算子で決まるからである。 名前リストは宣言位置にしか現れないが、
その 3 か所のうち代入の左辺は式が来られる位置である ({ a, b } はタプル (a, b) を返す
ブロックとして読める。§2.1.1)。パーサーは { から識別子・型注釈・区切りだけを走査し、閉じ
括弧の次が := / = であるかを見て名前リストと判定する。識別子以外のトークンを見た瞬間に
打ち切る自己中断スキャンなので、通常のブロックリテラルは巻き込まれない。import /
export はキーワード直後で式が来られないため、この判別を要しない。slot-list 領域でも
エントリの形が閉じている (§2.1) ため、{ 始まりのエントリは名前リストしかありえない。
分解の綴りが、右辺のどの軸から取るかを示す。 裸のカンマ列は Sequence (§17.5) から
宣言順に取り、{…} はオブジェクトのスロットから名前で取る。裸のカンマ列がタプルの括弧を
省いた形であることは適用 v(3, 5) (§3.5)・多値返却の末尾式 sum, diff (§11.2)・タプル
destructure a, b := t で一貫している。
- 左辺は 0-pipe
{}形のみ。{ name1 | body }のような|入り形は不可 - 左辺の bare identifier は「抽出する slot 名 = 束縛するローカル名」として解釈
- 右辺のオブジェクトに該当 slot が無ければ実行時エラー (§3.7 の未束縛 slot エラーと同種)
- リネーム形 (
{ x := value.foo, ... }等) は不採用。リネームが必要ならx := value.fooを個別に書く =形は文の相だけに書ける。slot-list の相が宣言するのは immutable なスロットなので (下記「スロット/文の二相」)、slot-list 領域の{…} = exprは構文エラー- 左辺の
{...}は要素を改行で区切ってもよい (カンマと改行のいずれも区切り。区切り規則は §1.3 と同じ)。名前が多いとき縦に並べられる:
{
name1
name2
name3
} := value
import mod := "math.hika" { sqrt, log } := mod # mod.sqrt → sqrt, mod.log → log (immutable) import { sqrt, log } := "math.hika" # 中継無しに直接書ける (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)も同じ二相を持ち、ファイルトップレベルは slot-list なので(§13.1)、import { Token } := "lexer.hika" と書けば後続スロットの型注釈や本体から Token を参照できる。
slot-list の相は := のみである。スロットの可変性は宣言形が決める(§4.1・§2.1)ので、スロット destructure が mutable なスロットを作る道は置かない — 可変スロットが要るなら mutable name := expr を個別に書く。
二相を持つのは スロット destructure({…} 形)のみ。タプル destructure a, b := expr は 文の位置でのみ使える — 内側リテラルの slot-list では a, b := expr が「必須スロット a + デフォルト付きスロット b := expr」という関数スロット構文(§2.1)と衝突するため、slot-list エントリにはならない({…} 形は { 始まりで曖昧が無い)。ファイルトップレベルは文の位置である — そこでは , がスロット区切りではなく多値構築を表すので(§1.3・§13.1)衝突が起きず、a, b := expr はタプル分解として読まれ、a と b はどちらもそのファイルのスロットになる。
リストの要素を括弧付きで分解する形 [a, b] := xs は持たない。 Sequence からの分解は裸のカンマ列 a, b := xs が担っており(上記)、形を 2 つ持つ理由が無い。角括弧は List のリテラル(§7.4)なので、[a, b] := xs は List への代入として読まれ構文エラーになる — 要素が裸の識別子だけなら診断は a, b := ... を促す。match にも List パターンは無く(prelude.md §8.4)、「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 を取れる。= の意味論のみで、: を伴う宣言形 (:+= のような記法) は持たない。単一ターゲットのみで、分解形 (a, b += ...) は不可。
規則の帰結として現れる挙動:
- 読みが先に起きるため、対象は既存で読める必要がある。未宣言ローカルへの
i += 1はundefined: i、未定義スロットへの複合代入はスロット無しエラーになる。名前を導入しない点は=と同じで (§6.1)、複合代入に宣言形 (mutable i += 1) は無い - 書き戻しは
=の規則に従うため、immutable なローカル / スロットへの複合代入はエラー。スロット集合はリテラル宣言時に固定されるので (§2.4)、複合代入で新規スロットが生まれることは open / closed のどちらでも無い - 型エラー (例:
true += 1) は内部のop呼び出しからそのまま表出し、手書きのtrue + 1と同一のエラーになる
複合代入演算子は字句上 1 トークンで、演算子記号と = は隣接必須 (i += n は可、i + = n は + と = に分かれて構文エラー)。これは || と | | の区別と同じ字句レベルの規則で、§8.3.1 の「空白は優先順位に影響しない」とは独立。
mutable i := 0 i += 5 # i = i + 5 → 5 i *= 3 # 15 i -= 1 # 14 i %= 4 # 2 obj := { mutable n := 10 |} obj.n += 5 # obj.n = obj.n + 5 → 15 cache := {} cache.x += 2 # ランタイムエラー (slot は追加できない。§2.4 / §6.2) mutable s := "a" s += "b" # "ab" (+ は String 連結)
7. 原始型と基本リテラル
すべての原始型はオブジェクトとして扱われる。内部表現は最適化のため特殊化されるが、ユーザーから見える意味論は統一されている。
| 型 | リテラル例 | 備考 |
|---|---|---|
| 整数 | 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) で得る。要素は Int 0〜255 (§7.2.2) |
| 真偽値 | true、false |
|
| unit | () |
void を表す 0 要素タプル値 (§3.3)。print / each / else 無し when / 代入族の文 (§6.1) 等の戻り |
| リスト | [1, 2, 3] |
不変 (要素の差し替え l.0 = X 不可・長さも変わらない)。長さは生成時に決まる。組み立てには std:array を使う (§7.4) |
| タプル | (1, 2, 3) |
不変・固定長、多値返却の値。要素数 0 または 2 以上 (§3.3) |
| マップ | — | 言語の値ではない。キーで引く辞書は std:map を使う (§7.5) |
7.1 数値の演算
二項算術 + - * / % ** と単項 - は 両オペランドの実行時型でディスパッチする。Int と Float は別の数値型であり、暗黙の型変換 (numeric coercion) は行わない。
Int op Int → Int(任意精度整数上で実行):- 除算
/はゼロ方向への切り捨て:7 / 2 #> 3、-7 / 2 #> -3 - 剰余
%は切り捨て除算の剰余でありa % b == a - (a / b) * b。符号は被除数に従う:-7 % 2 #> -1、7 % -2 #> 1 - オーバーフローしない (任意精度。結果が int64 に収まらないときは内部表現が自動的に多倍長へ切り替わるが、値の意味論に表現の区別はない)
Float op Float → Float(float64/ IEEE 754 上で実行):+ - * /は実数演算。/は実数除算 (7.0 / 2.0 #> 3.5)- 剰余
%は Int と同じく切り捨て除算の剰余で、符号は被除数に従う (5.5 % 2.0 #> 1.5、-5.5 % 2.0 #> -1.5)。被除数がInfのときはNaN - オーバーフローは検知せず
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 とも実行時エラー (
Error、§16.2):a / 0、a % 0、x / 0.0、x % 0.0。-0.0での除算も同じく実行時エラーである (§9.4 の全順序では-0.0と0.0は別の値だが、除数としては両方が定義されない) - 除数には静的な証明義務がかかる。
/と%の除数が0でないことを実行前に証明できなければ静的検査が報告する (static-analysis.md §2.11「除数の非ゼロ義務」)。解消の道は条件で検査する形 (if (d != 0) { a / d })・上流で宣言型を絞る形 (d: NonZero := …)・式アスクリプションで実行時へ委ねる形 (a / (d: NonZero)) の 3 つで、NonZero/NonZeroFloatは prelude が持つ (prelude.md) **の負指数は義務の対象ではない。同じ実行時エラーの族だが、被検査項が除数ではなく指数で、述語もv >= 0と違う。まとめて閉じるかは別の判断に属する — 現状は実行時エラーのままである- 累乗
**も両オペランドの実行時型でディスパッチする (右結合・優先順位は §8.3): Int ** Int → Intは任意精度の整数べき (正確、オーバーフローなし):2 ** 10 #> 1024、0 ** 0 #> 1。負指数 (2 ** -3) は結果が整数で表せず実行時エラー (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 / Sequence の method など) は prelude.md §4 に集約。
要素アクセス: String は Sequence (§17.5) を 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 ソースに貼り戻せる (往復可能)。
複数行文字列 """...""": 生の改行を含む文字列は """ で囲む。開き """ の直後には改行が必須で、閉じ """ は自分の行に単独で置く。閉じ """ の行頭にある空白 (インデント) を接頭辞とみなし、本文の各行から同じ空白を取り除く (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: そのまま / Int: 10進文字列 / Float: 最短往復形 — 小数点も指数も含まない形になるときは
.0を補う (2.0は"2.0")。指数形になる値はそのまま指数で書く (1e21は"1e+21")。どちらも Int の 10 進表記とは読み違えない (§7.1.1 の指数付きは Float である)。NaN/Infはそのまま —Infは符号を付けて-Infと書くが、NaNは符号を出さない (負の NaN もNaNである。順序では別の位置に並ぶ。§9.4) / 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)、または Int の List からの構築関数 bytes(list) による。Bytes リテラル構文 (b"..." 等) は持たず、range と同じく構築関数で作る (bytes/Bytes は range/Range と対)。
- 要素アクセス:
b.0/b.[i]は Int (0〜255) を返す (String と違って長さ 1 値ではない) length: バイト数 (rune 数ではない)Sequence(§17.5):each/map/filter/fold/first/last等が見る各要素は Int (0〜255)- 変換:
b.to_string!は UTF-8 デコードしてSome(String)、不正 UTF-8 ならNone(to_int!の失敗仕様と同じ — 不在は Option) - 構築:
bytes(list)は Int の List を Bytes に詰め替えOption(Bytes)を返す。全要素が 0〜255 の Int ならSome(Bytes)、0〜255 外を含むとNone、非 Int 要素・非 List 引数は型エラー (panic)、空 List はSome(空 Bytes)。to_string!の逆方向 - immutability: Bytes は immutable。
b.0 = N/b.[i] = Nは実行時エラー - 演算子:
+等は持たない (b1 + b2はoperator '+' not defined for Bytes and Bytes)。連結は List 側で行う —to_listで開いて伸ばし、bytes()で確定する
メソッド一覧は prelude.md §5 に集約。
7.3 タプル
不変・固定長。t.0、t.1 でアクセス。t.0 = X はエラー。要素数 0 または 2 以上 (§3.3)。
タプルの用途: 多値運搬専用 — 関数への複数引数のパック (v(3, 5) の適用。R≥2 なら destructure)、多値返却 (末尾式 sum, diff / return(a, b)。§11.2)、分解代入 (a, b := v(...))。
タプルとリストは内部表現が別 (タプルは固定長で immutable な値型として残す)。「データとして保持・引き回す」用途ではリスト (§7.4) を使う。
7.4 リスト
リストは [e1, e2, …] と書く組込型である。Int / String / Bytes / Tuple と同じレイヤーに属し、オブジェクト (§2) ではない — slot-list も body も持たず、スロットを宣言できない。
- 要素は
,・改行のいずれかで区切る (§1.3 の(...)と同じ規則。末尾,は不可) - 要素アクセスは整数添字
xs.0/xs.[i]で、頭から0, 1, 2, …と番号付ける - 空リストは
[]。空オブジェクト{}とは別の値で、[] == {}はfalseである (§9.1) - 要素の型は均質である (静的型は
List(T)、§17.2)。異種の並びが要るならタプル (§7.3) を使う
リストは不変である。 要素の差し替え (xs.0 = X / xs.[i] = X) はランタイムエラーで、長さも生成後に変わらない。長さは静的に決まるとは限らない — src.map { … } の結果長は入力次第である。この点がタプル (§7.3) との差で、タプルは長さが静的に決まり要素型が異種でよい代わりに、実行時添字 t.[i] と map / filter / fold を持たない。
要素を変えた結果が欲しければ map で新しいリストを組み立てる。組み立てや任意添字への O(1) 散在書き込み (累積器・DP テーブル・グリッド更新・in-place sort 等) が要る場合は、可変 scratch ハンドルを frozen で List に確定する std:array (std/array.md) を使う。可変性はそのハンドルの内部に閉じ、外へ出るのは不変な List である — std:map の ScratchMap → frozen() → InsertionMap (§7.5) と同じ二段構えである。
import arr := "std:array" xs := [1, 2, 3] xs.0 #> 1 xs.length! #> 3 xs.0 = 99 # ランタイムエラー (リストは不変) doubled := xs.map { x | x * 2 } # 新しいリスト out := arr.collect { a | # 長さ未定の組み立て xs.each { x | a.push(x * 10) } }
リストは Sequence を実装する (§17.5)。length / is_empty / each / map / filter / fold / find / any / all / count / first / last / positions / has_position / to_list と整数添字 xs.N がこれにあたる。同じ interface を String (§7.2)・Bytes (§7.2.2)・range (§7.6)・タプル (§7.3、一部) が実装しており、リストを舐める場所へそのまま差し込める。各 method の意味 — is_empty が length! == 0 と同値であることを含む — は prelude.md §6 を参照。
Sequence は全ての値に生えるわけではない。オブジェクトは整数添字の並びを持たないので (§2.1)、{ x := 1 |}.length! は解決できない。オブジェクトが持つ自己記述の method は names / values / has_name (§2.5) である。
7.4.1 オブジェクトとリストの + 演算
+ は構造を右勝ちでマージした新しい値を返す。左右は同じ種類でなければならない。
リスト同士は順序を保って連結する:
[1, 2] + [3, 4] #> [1, 2, 3, 4]
オブジェクト同士はスロットをマージし、同名は右側が勝つ:
{x := 1, y := 2 |} + {y := 3 |}→{x := 1, y := 3 |}- inner-name / body: 右が持てば右、右が持たなければ左を継承
{ inner self, x := 1 | self.x } + { y := 2 |}→ inner-nameself、スロットxとy、body は左のself.xを継承 (右が body を持たないため){ x := 1 |} + { inner me, y := 2 | me.y }→ inner-nameme、スロットxとy、bodyme.y- 関数の合成も同じルール:
{x | x + 1} + {y | y * 2}→ スロットxとy、body は右のy * 2。動的合成の表現力 +の左右が 片方だけオブジェクト (もう片方が List / String / Int / Boolean / Tuple) ならエラー
7.5 マップ
マップは言語の値ではなく標準ライブラリの型である。 キーで値を引く辞書が要る場合は std:map を使う (std/map.md)。
import m := "std:map" h := m.insertion.of([("Content-Type", "application/json"), ("X-Trace", "42")]) h.get("Content-Type") #> Some("application/json") h.keys() #> ["Content-Type", "X-Trace"] (挿入順)
std:map は 3 つの型を提供する。SortedMap は Comparable (§9.4) をキーにとりキー昇順で反復する不変な値型、InsertionMap は任意の値をキーにとり (== で同一性判定・§9.1) 挿入順で反復する不変な値型、ScratchMap は InsertionMap と同じキー同一性を持つ可変な scratch ハンドルで frozen() で InsertionMap に確定する。破壊的更新が要る辞書は ScratchMap で組み立てて確定させる — std:array が List に対して立つのと同じ二段構えである (§7.4)。集合が要る場合は std:set の OrderedSet (std/set.md)。
オブジェクトのスロットはマップではない。 スロット集合はリテラル宣言時に固定され (§2.4)、実行時のキーで引く手段も無い (§3.10) — スロットは静的に決まった record のフィールドであり、辞書ではない。m.names() / m.values() (§2.5) は自身のスロットを列挙する自己記述の手段であって、キーで引く経路は与えない。
この分離が本質的なのは、境界の理由が「辞書であること」ではなく「幅構造型の record に静的型が知らないスロットが書かれうること」だからである (§17)。値の型が均質だと分かるマップとして型が付けば、その危険は無い。
7.5.1 record と map の使い分け
| 欲しいもの | 使うもの |
|---|---|
| フィールド名が書いた時点で決まっている構造体 | オブジェクトのスロット ({ x := 1, y := 2 |}) |
| キーが実行時に決まる / 識別子として不正な文字を含む | std:map の InsertionMap / SortedMap |
| 上記で破壊的更新が要る | std:map の ScratchMap |
| 添字が実行時に決まる均質な列 | List(T) (読みは xs.[i]、§3.10) |
| 上記で任意添字への O(1) 書き込みが要る | std:array の Array (§7.4) |
7.6 range (遅延シーケンス)
range(start, end) は 半開区間 [start, end) の整数列を表す遅延シーケンス値を返す。range は prelude が束縛する大域名 (if / while と同格の組み込みオブジェクト) であり、専用リテラル構文 (.. 等) は持たない。
- 引数:
start/endとも Int 必須の 2 引数。非 Int はランタイムエラー。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 は要素を保持しないが、Sequence (§17.5) を実装する。論理添字 i の要素は start + i*step で、リストを舐める場所にそのまま差し込める。map / filter は List の同名メソッドと同じく List を eager に返す。
range method: length, first, last, each, map, filter, fold, positions, has_position, r.N 添字 (Sequence)。加えて range 固有に step, reverse, to_list (いずれも新しい range / List を返し合成可能)。各メソッドの形と意味は prelude.md §6.5 に表でまとめる。
step と reverse の直交: 向きは reverse、刻みの大きさは step(n) (n は正の Int、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 中置になれる名前と、三つの等価な呼び出し記法
中置になれるのは演算子表 (§8.3) にある名前だけである。表にある名前は三つの記法で書けて、意味は同じ。
1 + 2 # 中置 1.+ 2 # ドット+スロット名、引数は並置 1.+(2) # ドット+括弧 "a" -> 1 # 中置 (ペア構成。§8.3・§2.5) "a".-> 1 # ドット+スロット名、引数は並置 "a".->(1) # ドット+括弧 — いずれも (a, 1) を返す one := { value := 1, add := { other: Int | value + other } |} one.add 2 # add は表に無いのでドット形のみ one.add(2)
統一規則として:
receiver op argは、opが演算子表にあるときreceiver.op(arg)の糖衣。
opの解決はreceiver.opと同一規則 — receiver のスロット、無ければ型メソッドレジストリ。
外側スコープの同名関数は引かない (=recv.opがエラーならrecv op argもエラー)f xは関数適用 (並置)。常に適用であり、中置に読み替えられることはない- どちらであるかは構文解析の時点で決まる。受け手の値を見て解釈が変わることはない —
one add 2・xs map fのように任意の識別子を中置に使う記法を持たないのはこのためで、並置と同形になると「適用か中置か」が受け手の値に依存し、構文解析だけでは決まらなくなる
表の英数字エントリ (matches) だけは右辺の読み方が異なる — 中置形は右辺を型コンテキストで読み、ドット形は値コンテキストで型値を受け取る (§17.1)。この 1 点で三記法の等価性が崩れる。
受け手のスロットは値の種別より先である。 中置は receiver.op(arg) の糖衣なので、スロットの引き方はドット形と同じ — 受け手が本体を持つ形 ({ slot-list | body }) でも、宣言したスロットがあれば中置はそこへ届く。組込の判定 (数の算術・§7.4.1 のオブジェクトとリストの +・§9.1 の既定の等価・§9.4 の順序) は、受け手がそのスロットを宣言していないときの受け皿である。スロットが未束縛のまま (構築子の部分適用) なら値を持たないので、そのときも組込の判定へ落ちる。
8.2 ユーザー定義オペレーター
スロット名は記号 (+、==、< 等) も識別子 (add、map 等) も自由に使える。+ などのオペレーターをユーザー定義型に実装するには、その名前のスロットを定義するだけ:
type Vec := { x: Int, y: Int |} vec := { inner_bound me, x: Int := 0, y: Int := 0, + := { other: Vec | ({ x := me.x + other.x, y := me.y + other.y |}): Vec } |} v1 := vec(1, 2)! v2 := vec(3, 4)! v1 + v2 # v1.+(v2) → { x := 4, y := 6 |}
自己参照名は inner_bound でなければならない (§4.2.1)。inner が指すのは派生元 — この例では x / y が未束縛のままの vec そのもの — なので、self.x は実引数ではなく既定値 0 を読んでしまい、v1 + v2 が { x := 3, y := 4 |} になる。演算子スロットが読みたいのは束縛を写した自分の側であり、それに届く名前は inner_bound だけである。
vec(1, 2) は + スロットを未束縛のまま残す部分適用なので、完成値を得るには明示 kick (!) が要る (§3.6)。
8.3 演算子表 — 優先順位と結合性
この表が中置になれる名前の全てである。表は閉じており、書き手が項目を増やすことはできない。表に無い名前を中置位置に置くことはできず、並置はすべて関数適用として読む (§8.1)。
外側 (弱) から内側 (強) の順:
- 代入:
=、:=、+= -= *= /= %= - ペア:
->(2-tuple。a -> b≡(a, b)。左結合) - 論理OR:
|| - 論理AND:
&& - 比較:
==、!=、<、<=、>、>=、matches(型テスト。非結合。§17.4) - 加減:
+、-(二項) - 乗除:
*、/、% - 単項:
-(受け手のnegateスロットからの導出。§8.6。※論理否定!は廃止 — §8.5) - 累乗:
**(右結合) - 関数適用 (並置
f x。左結合) - ドット (
.、.[])、postfix call (!、())、postfix 失敗 bail (?、§16.5)
同一レベルは左結合。step!? は (step!)? (postfix の左結合)。-> も左結合: 1 -> 2 -> 3 は (1 -> 2) -> 3 ≡ ((1, 2), 3)。
英数字エントリ matches: 表の項目のうち matches だけが記号ではなく英字綴りである。予約語ではない — 中置位置に現れたときだけ演算子として読み、それ以外の位置 (束縛 matches := …・スロット宣言・引数) では普通の識別子である。したがって受け手の matches スロットによる上書き (§2.5) も従来どおり働く。予約語の一覧は §1.2.2 の 14 語のままで、本エントリはそこに加わらない。
matches を比較と同じ層に置くのは、返す値が Bool だからである。算術より弱いので a + b matches Int は (a + b) matches Int と読め、&& / || より強いので v matches Int && w matches String が括弧なしで書ける。非結合なので a matches T matches U は構文エラーになる (意味を持たない綴りなので、左結合として黙って通すよりエラーとして出す)。
非結合が禁じるのは matches 同士の連鎖だけである。 同じ比較層の記号演算子と混ぜた綴りは、層の既定どおり左結合で読む — a == b matches Bool は (a == b) matches Bool である。matches の右辺は型コンテキストなので (§17.1)、この向きにしか読みようがない。
-> と式アスクリプション : (§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))。
これにより:
f x + g y ≡ (f x) + (g y) # 関数適用 > 記号オペレーター f x y ≡ ((f x) y) # 並置は左結合 (curry) a + b matches Int ≡ (a + b) matches Int # matches は比較層。加減より弱い v matches Int && w matches String ≡ (v matches Int) && (w matches String) # && より強い 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() は呼ばれない
これは「すべてはオブジェクト呼び出し」原則からの意図的な逸脱である(もう 1 つは宣言順束縛の左辺の破棄子 _、§6.3)。理由: 短絡を関数化するには右辺をブロックで包む必要があり、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 で、mutable 前置 (§1.4) なら mutable。シャドウ不可。スロットアクセスへの := は常に静的エラー (slot 追加不可) |
§6.1, §6.2 |
= |
代入 | 既存の mutable な束縛・スロットの更新のみ。名前もスロットも導入しない | §6.1, §6.2 |
op= |
代入糖衣 | 複合代入 += -= *= /= %=。lhs op= rhs は place を 1 回評価して cur op rhs を書き戻す read-modify-write。op 部分は演算子スロット呼び出しに脱糖、代入部分は非糖衣 |
§6.4 |
, |
構造 | リスト的並列性 — タプル要素・スロット列・引数列・多値返却 | §2.1, §3.3 |
; / 改行 |
構造 | シーケンス (最後の値が返り値) | §1.3, §11.1 |
| |
構造 | スロット宣言と本体の区切り ({slot-list | body}) |
§2.1 |
| キーワード | 構文形 | 予約語=宣言・照合の構文形 (§1.4)。type / enum / match / import / export / loop / conduit / refinement / refinement_opaque / inner / inner_bound |
§1.4 |
f x (並置) |
呼び出し | 関数適用。中置糖衣 recv op arg の 脱糖の着地点でもある。並置そのものは常に適用で、中置に読み替えられることはない |
§3.1, §3.2 |
. |
アクセス | スロットの取り出し (呼び出しではない) | §3.2 |
.[] |
アクセス | 動的な要素の取り出し (key 式の評価値 (Int) で Sequence を引く。スロットは引けない)。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 |
逆に、これら以外の演算子 — 算術 (+ - * / % **)、比較 (==)、ペア構成 ->、型テスト matches — はすべて receiver.name(arg) への糖衣である (§8.1)。ユーザー定義型で同名スロットを定義すれば挙動を差し替えられる (-> と matches は全値が baseline を持つ universal method。§2.5)。matches は右辺を型コンテキストで読む点だけが他と異なる (§17.1)。ここでの - は二項の減算である (単項 - は negate からの導出。下記)。
なぜこの分類が必要か:
- 短絡 (
&&/||) は右辺の遅延評価が本質。関数化するとa.&&({b})のようなブロック包みを毎回強いることになり、実用上の負担が大きいため特例とする (§8.4)。 - 束縛・代入 (
:=/=) はスコープを操作する構文要素であり、スロット呼び出しでは表現できない。 - 構造区切り (
,/;/|) はパース時に構造を作るためのもので、値ではない。 - 呼び出し / アクセス記法 (並置 /
./()/!) は、糖衣の着地点を提供する側であり、それ自体が糖衣ではない。 !=は字句解析の都合 (!単体との曖昧性回避) で単一トークン化されているが、意味的には==の派生である。
導出形を持つ演算子は差し替えられない。 順序演算子 < <= > >= は compare から (§9.4)、!= は == から (§9.1)、単項 - は negate から (下記) 導かれるので、受け手がその綴りのスロットを持っていても参照しない。差し替えたいときは導出元の compare / == / negate を定義する。導出形の綴りでスロットを宣言した形は静的検査が報告する (static-analysis.md §2.9) — 黙って無視すると書いた側がエラーに気づけないためである。ただし - は例外で報告しない — 二項の減算スロットとして正当な綴りなので、宣言だけを見て単項を差し替えるつもりだったかは判別できない。
単項 - の導出元は negate である。 -a は受け手の negate スロットを引き (引数なしで呼ぶ)、無ければ値の種別でディスパッチする (§7.1 の数値だけが応答し、それ以外は実行時エラー)。- スロットが差し替えるのは二項の減算 a - b だけで、単項はそこへ届かない — 二項のスロットは引数を 1 つ取るので、単項を 0 引数呼びとして同じスロットへ低レベル化すると arity が合わないためである。引くのは受け手が宣言したスロットだけで、ランタイム層が実装する組込メソッドは引かない — std:time の Duration.negate (std/time.md §1) は綴りが同じだが組込なので -dur は種別のディスパッチへ落ち、dur.negate! と書く。
単項だけが届かないのではなく、算術演算子そのものが不透明 std 型に載らない。 dur + dur も dur - dur も同じく実行時エラーで、加減算は add / sub メソッドで書く (std/time.md §1・std/decimal.md §1 がそれぞれ「+ / - 演算子はオーバーロードしない」と定める)。載るのは順序と等価だけで、そちらは compare から導く導出形なので不透明な std 型でも組込の比較で決まる (§9.4・§9.1)。符号反転を持つ std 型は Duration.negate と Decimal.neg の 2 つで綴りも揃っていないので、導出元 1 つでどちらも引く形にはならない。
type Vec := { x: Int, y: Int |} vec := { inner_bound me, x: Int := 0, y: Int := 0, negate := { ({ x := 0 - me.x, y := 0 - me.y |}): Vec } |} v := vec(1, 2)! n := -v # v.negate! と同じ println n.x #> -1
9. 等価性と真偽値
9.1 == のセマンティクス
== は値等価である。同一インスタンスかではなく、値の中身が等しいかで判定する。
- 原始型 (整数・浮動小数・文字列・真偽値・バイト列・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) - オブジェクト (リスト / タプル / マップ / データオブジェクト / 関数として使う値) と Variant (Option / Result / Ordering・
enum) は再帰的構造等価: 次の 3 つがすべて一致すれば等しい (Variant は Tag + payload を比べる) - スロットが読める値 — 束縛済みならその値、無ければ既定値。List・タプルでは要素を順に比べる
- body のトークン列 — 空白・改行・コメントは無視する。自己参照名 (
inner/inner_bound) の綴りは正規化してから比べる。比較は字句に限る (§9.1.1) - body が捕獲した外側の値 — 再帰的に同じ規則で比べる
[1, 2, 3] == [1, 2, 3] #> true、(1, 2) == (1, 2) #> true、{x := 1 |} == {x := 1 |} #> true(別インスタンスでも中身が同じならtrue)- 自己参照名の綴りは等価に影響しない —
{ inner self, x := 1 | self.x }と{ inner me, x := 1 | me.x }は等しい - 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)
- 値の種別で分岐しない (§2.1)。body を持つ値も持たない値も上の 1 つの規則で比べる。データオブジェクトが持つメソッド (関数スロット) も同じ規則で比べられ、同じ綴り・同じ捕獲なら別々に生成しても等しい
- オーバーロード: ユーザー定義型で
== :=スロットを定義すれば独自の等価性を実装できる - 実行時は
a == bを解釈する際、aに==スロットが定義されていればそれを呼び出す。なければ上記の規定に従う - 入れ子の位置でも同じ規則が働く: List / タプルの要素・スロットの値・variant の payload・捕獲した値を突き合わせるときも、左辺がその位置で
==(無ければcompare) を宣言していればそれを呼ぶ。a == bがtrueなら[a] == [b]もSome(a) == Some(b)も{x := a |} == {x := b |}もtrueである — 同じ問いに 2 つの答えを持たせない。例外はstd:mapのInsertionMap/ScratchMapのキー同一性で、そちらは宣言を見ず構造だけで比べる (std/map.md §2.2 が理由を述べる) ==/compareスロットの本体は中断してはならない (!による await・sleep・wait_anyを含まない。static-analysis.md §2.9)。等価と順序は式のどこにでも現れるので、中断しうるとa == bを含む式すべてが中断点になり、実行形が呼び出し規約に乗る。中断を伴う比較が要るなら、比較の結果を先に求めてからsort_by/sort_with(prelude.md §6) に渡すことcompareとの整合 (導出規則): object がcompareスロット (§9.4) を持つとき、その==はcompareから導出される —a == b≡a.compare(b) == Equal。これにより型の固有compareと==は必ず一致する (「順序と等価が食い違う型」を構造的に排除する)- 等価の源泉を一つに固定するため、
compareスロットと==スロットの同居は禁止 (両方を持つ object リテラルは静的検査が報告する。static-analysis.md §2.9)。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となる - range 値 (§7.6) は箱の参照で比べる: 別々に作った
range(1, 3)同士はfalseで、同じ値を 2 つの名前で指しているときだけtrueになる。range は要素を保持しない遅延シーケンスでありComparableでもない (§9.4 が順序を与えていない) ので、端の組から値としての同一性を決めていない。内容で比べたいときはto_list!で実体化する (range(1, 3).to_list! == range(1, 3).to_list!はtrue) - 型値 (型語彙・
type/enumで名付けた型。§17.2 / §17.4) も値なので等価を持ち、その==は型等価である: 種別と構造が同一なら等しい。別インスタンスでも同じ型を表すならtrue(Int == Int・List(Int) == List(Int)) - 別名は透過:
type Pos := IntのときPos == Intはtrue— 名付けた型は照合・等価では基底 (展開) 型と完全に同一である (§17.4)。表示は別名Posを保つが、表示は等価に影響しない (別名保持は表示の規則であって型の区別ではない) - refinement は基底型と述語の原文で決まる (§17.7 の型等価): 同じ原文なら別インスタンスでも
true、綴りの違う同値な述語 (v > 0とv >= 1) はfalse(等価判定に論理を持ち込まない) refinementとrefinement_opaqueは別の型: 述語の原文が同じでもfalse。両者の実行時の振る舞いは同一だが (§17.7)、refinement_opaqueは「述語を決定可能断片へ写さない」と表明した型であり、静的検査が引き受ける証明義務が違う — 振る舞いが同じであることは同じ型であることではない- 要素・スロットは位置で比べる:
OneOfの候補順・tuple の要素順・record のスロット順が違えばfalse(OneOf(Int, String) == OneOf(String, Int)も{x: Int, y: String |} == {y: String, x: Int |}もfalse)。§17.2 がOneOfを集合と呼ぶのは照合の規則 (どの候補に当たってもよい) であって等価の規則ではない。record の値等価とは非対称である — 値の側はスロットの集合で比べるので宣言順に依らないが (上記)、型の側は並びまで含めて同一性を見る (順序に依らない型等価には型値の正準順序が要る) - 型値と型値でない値の比較は
false(異型はfalseの一般規則) - 循環参照しても停止する (自己参照する型。body を持たない値と同じ bisimulation)
!= は == の否定として実装される (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.1.1 body の比較が字句である理由
body の一致はトークン列の一致で判定する。正規化するのは自己参照名の綴りだけで、それ以上は行わない。
- 意味を見ない。同じ入力に同じ出力を返すか (外延一致) は停止性に帰着するので判定できない
- 畳み込み・簡約を挟まない。正規化の強さが等価を左右すると、最適化器を触るたびに値の等価が変わる。字句に限れば、判定はパース時に確定し実行系の都合から独立する
- 自己参照名だけ正規化する。inner-name はデータスロットではなく等価に影響しない (§4.2) と定めている以上、書き手が
selfとmeのどちらを選んだかで等価が割れてはならない
省略形と、それを綴った形の一致は等価の仕事ではない。 body が自リテラルの inner_bound 名への裸の参照 1 つだけのとき、その綴りは値を組む前に body を省略した綴りへまとめられる (§2.2)。等価が突き合わせるときには既に同じものになっているので、「省略した body」という架空のトークン列を作って比べる必要はない。§2.2 の約束が等価の一致だけでなく検分表示にも及ぶのはこのためである。
代償として、外延的に同じでも綴りが違えば別の値になる。次はいずれも { x, y | } と等しくない:
{ inner_bound b, x, y | { b }! } # ブロックに包んで起動している
{ inner_bound b, x, y | b: T } # アスクリプションが付いている
{ inner_bound b, x, y | (if c { b } { b })! } # 同じ値を返すが綴りが違う
区切り記号だけの違いで区別を作らないこと。 冗長な括弧 ({ … | (b) }) と 1 引数呼び出しの括弧 ({ … | f(x) }) も綴りが違う以上は別の値だが、hikari format は抽象構文木から印字を組み直すため、構文木に残らないこれらの区切りを正準形へ寄せる (format.md)。整形を通したソースにこの区別は残らない。上の 3 つのように構文木に残る綴りで区別すること。
捕獲まで見るのは省けない。 同じ綴りの body でも捕獲が違えば別の値だからである:
mk := { n: Int | {| n } }
mk 1 == mk 2 #> false (body の綴りは同一だが、捕獲した n が 1 と 2 で違う)
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 §13) を返す。Less=aが前、Greater=aが後、Equal=同順位。compareスロットの本体は中断してはならない (§9.1 の==と同じ規則)。順序演算子</<=/>/>=はcompareから導かれるので (下記)、中断しうると比較を含む式すべてが中断点になる。- 原始型の全順序 (組込
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 にも定位置を与える。順序はビット列を単調なキーへ写したもので、負の NaN <-Inf< … <-0.0<0.0< … <Inf< 正の NaN となる (-0.0と0.0も別の位置に並ぶ)。これによりsort・Comparableが NaN 混入時も破壊されない。IEEE 流の「NaN との比較は全て false」が要る場合はis_nan!で明示判定する。表示は NaN の符号を出さない (§7.2.1 の表示規則) ので、NaN を含む列を並べ替えると両端に同じNaNの字面が現れる。どちらの側かは字面では分からず、x < 0.0のように順序で問うと決まる (負の NaN は-Infより小さいので真、正の 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: { x: Int |} | 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 では走らない。これが「複製」と「構築」を分ける観測点でもある。
メソッドの自己参照名の再束縛: object を複製するとき、自身の inner_bound 名 (§4.2.1) を捕捉するメソッドスロットは、新インスタンスへ束縛し直したクロージャとして作り直される。これにより複製先のメソッドは複製先の状態を触る。捕獲されるのが inner_bound なのは、可変スロットへ書き込めるのが束縛を写した完成値だけだからである (§2.2 / §4.1) — 派生元を指す inner (§4.2) から可変スロットへ書くことはできない。
counter := { inner_bound b, mutable count := 0, inc := { b.count += 1; b.count } |}!
counter.inc! #> 1
counter.inc! #> 2
d := counter.copy! # count=2 を引き継ぎ、inc 内の b は d を指す
d.inc! #> 3
counter.count #> 2 (状態は独立)
深さは可変性が決める: copy が新しく作り直すのは可変な構造だけで、透過的に不変な部分はそのまま共有する — 不変な値は変異によって別名 (aliasing) を観測できないため、共有しても安全である。
- 可変な object = 可変 (
=) スロットを 1 つ以上持つ。→ 作り直し、全スロット値を同じ規則で再帰的に copy する。 - 透過的に不変 = 到達可能なグラフに可変 object を一切含まない (原始型、全スロットが
:=かつ closed で不変な referent しか持たない object、plain list など)。→ そのまま共有する。 - List (§7.4) とタプル (§7.3) の各要素も同じ規則で辿る。
したがって防御的コピー (可変状態の分離) が成立する一方、大きな不変データは複製されない。可変スロットに不変な referent が入っていれば共有され、:= スロットに可変な object が入っていればその referent は作り直される — 判定はスロット宣言の可変性(mutable の有無)ではなく 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, mutable y := 2 |}
q := p.copy({ y := 20 |}) # { x := 1, mutable y := 20 |} — y は可変のまま
p.copy! # 純粋複製 (overlay 無し)
p.copy({ z := 9 |}) # panic — z は p のスロットに無い
overlay の規則:
- 差し替えるのはスロットの値のみ。overlay に受け手のスロットに無いキーがあれば panic (バグ層、§16.2)。
- スロットの可変性は受け手側を保持する: 受け手の
mutable x := 1(可変) は overlay 後も可変、x := 1(不変) は不変のまま。overlay リテラルの綴りは値の運搬にのみ使われ、スロット種別 (可変性・必須/デフォルトの別) を書き換えない。 - 土台の複製規則 (可変部の作り直し・透過的に不変な部分の共有・メソッドの self 再束縛・default 式を再評価しない) は overlay の有無で変わらない。差し替えた値はそのまま複製に入る。
- shape は変えない: overlay はスロットを増やさない (追加は
+マージ §7.4.1 の担当)。
Copyable: 複製できる値を表す。v matches Copyable は、v が原始型・object・list・関数などの通常値なら true、意味ある複製を定義できない不透明な可変ホスト資源は false。copy を Copyable でない値に対して呼ぶと panic する (§16.2)。順序を持つ値が限られる Comparable と違い、Copyable は inspect と同様ほぼ全値を覆う。Copyable も組込の特別な型ではなく、構造的 interface {copy |} として prelude が束縛する値である (§17.5。copy のベースラインが非 Copyable 以外の全値に効く)。
非 Copyable なのは不透明で「可変」なホスト資源だけである。 判定は「ホストの可変状態を持ち、作り直しても同じものにならないか」で決まる。不透明であることそのものは理由にならない — 不透明でも不変な値は複製に意味があり、Int を複製するのと変わらないためである。
| 区分 | prelude / std の型 |
|---|---|
| 非 Copyable | Future (§10)・std:array の Array・std:map の ScratchMap・std:fs の File・std:net の Conn・std:db/sqlite の Db と Tx・std:hikari/repl の Session |
| Copyable | 上記以外のすべて。std の不透明型では std:time の Instant / Date / Time / Duration・std:decimal の Decimal・std:map の InsertionMap / SortedMap・std:set の OrderedSet・std:test の Trial |
この集合は 3 つの位置で同じでなければならない — v matches Copyable の照合・copy のディスパッチ (非 Copyable なら panic)・spawn と std:parallel の捕獲検査 (到達グラフに非 Copyable があれば panic。prelude.md §9.9)。3 つが別々の集合を持つと、同じ値が「複製できないのに複製される」形が生まれる。実行系による違いも無い。
10. スケジューラー意味論
if / when / while / break / continue / return などの制御構造、および fork / sleep / now / wait_any などの並行性プリミティブは、予約語ではなく prelude が束縛した特殊オブジェクト である。「特殊」とは実装が組み込み (ホスト言語で書かれている) という意味のみで、評価規則は他の関数と同じ — 引数は呼び出し前に評価され、ブロックを渡せばその評価は遅延される。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などは中断しない)。データ競合は構造上発生しない。効果ハンドラー (§17.9) が捕まえた継続の再開も通常の関数適用であり、中断点を増やさない - 切り替え順序・公平性は実装定義
不変条件 (実装形態に依らず維持する): 可変状態を共有しうるフロー同士は同時に走らず、その間の切り替えは中断点に限る。ゆえにデータ競合は構造上表現できず、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) は隔離しない協調フローで、従来どおり可変状態を共有し並列化しない。
移譲した捕獲の隔離: 上の「複製して切り離す」は、静的に移譲と判定された捕獲でも変わらない (prelude.md §9.9)。変わるのは非 Copyable な実体に達したときの振る舞いだけで、複製が失敗する位置を移譲は共有して進む (§17.8「複製と別フローへの捕獲」)。親がその実体へ届かないことは消費追跡が保証するので、共有しても経路は増えない。それ以外の可変な構造は移譲でも作り直されるため、多重度型の可変な内部を指す別の束縛が親側に残っていても、子の書き込みは親から観測できない。
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 (Error) で解決する。 - 中断点を持たない CPU バウンドなフローはキャンセルを観測できず最後まで走る。
- キャンセルで解決した
Futureは、意図的な停止であってバグではないため、§10.2 の「未 await の panic」診断の対象にしない。
11. シーケンスと多値返却
11.1 本体内の式列
本体は式と文 (§1.5) の列であり、; または改行で区切る。改行がある場合 ; は省略可能。最後に評価された要素の値が返り値 (文なら unit ()。§6.1)。
v := { x: Int |
a := x + 1
b := x * 2
a + b # これが返り値
}
11.2 多値返却
失敗値のエラーモデル全体は §16 (Result) を参照。
最終位置の , 区切りは多値 = タプル化を意味する。
v := { x: Int, y: Int |
sum := x + y
diff := x - y
sum, diff # タプル (sum, diff) を返す
}
; (シーケンス) と , (多値) は明確に異なる:
;/ 改行: 順次評価、中間値は破棄、最後の値だけ返す,: タプル構築、すべての値を保持して返す
多値返却は値の運搬一般に使う (失敗運搬専用ではない)。失敗は Result で表す (§16)。不在は Option (§9.2)。
parse_amount := { s: String |
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: Int := self.x + self.y # 宣言順なので x, y は初期化済み
|}
p.sum #> 30
既定値から inner-name で読めるのは、その位置より上で宣言したスロットだけである。self.x はその兄弟スロットの評価済みの値そのものに解決する。次の 4 つは静的エラーである (static-analysis.md §2.7)。
- 前方参照 — まだ評価していない兄弟を読む形 (
{ inner self, sum := self.y, y := 20 |}) - スロット以外を引く形 — universal method (
self.inspect!)・型メソッドなど、完成したオブジェクトでなければ答えられない読み - inner-name の裸の出現 —
me := selfのように半端なオブジェクトそのものを持ち出す形 inner_bound名の出現 — 束縛を写した自分 (§4.2.1) を既定値から読む形。既定値の評価時点では束縛がまだ始まっていない
いずれも既定値を評価している最中のオブジェクトは半端で、そこから読める値を言語は定めないからである。読みたいものが上の兄弟なら inner-name を介さず bare な名前でも書ける (sum := x + y) — スロットリストの中では兄弟が名前で見えるため (§6.3) で、inner-name はこの 2 通りの綴りのうち明示的な方にあたる。
body 領域と、既定値が作る関数リテラルの中では制限が無い。 どちらも既定値の評価より後に走るので、inner-name は完成したオブジェクトを指す。
q := { inner self,
x := 10,
get := {| self.x } # 関数の本体。呼ぶのは構築の後なので self は完成している
|}
q.get! #> 10
12.1 既定値は派生をまたいで共有される
既定値が確定するのはリテラル評価時に 1 度だけなので、同じ構築子から 2 回派生させると (§4.2.1 の語法で、派生とは実引数を束縛して新しい値を作ること)、既定値が可変な値であれば両方が同じものを掴む。
c := { mutable box := { mutable n := 0 |}! |}
a := c! ; b := c!
a.box.n = 2
b.box.n #> 2 — a と b は同じ object を掴む
これは可変スロット (mutable) に限らず、{ box := { mutable n := 0 |}! |} でも同じである。既定値が指す先が可変であれば、スロット自体の可変性とは無関係に共有が観測できる (可変性は shallow。§4.1)。リストは不変なので (§7.4) この形にはならない — 共有が観測できるのは可変スロットを持つ object と std:map の可変ハンドルである。
派生ごとに新しい値が要るなら、既定値ではなく実引数で渡すか、body 付きの factory ({ { mutable n := 0 |}! }) で毎回リテラルを評価する。hikari lint の shared-mutable-default がこの形を勧告する (lint.md)。
13. トップレベルと評価モデル
13.1 ファイルは暗黙のオブジェクトリテラル
Hikari プログラムはファイル単位で書く。各ファイルは body を省略したオブジェクトリテラル { slot-list |} (§2.2) として扱われる。宣言を置く領域と文を置く領域の区別は無く、ファイル全体が 1 つの slot-list 領域である。
ファイルレベルの構造:
- 暗黙の inner-name 無しの
{ slot-list |}リテラル。ファイルレベルではinner宣言を持たない (§4.2 の inner-name は内側リテラル専用) - 領域は 1 つである。 ファイルレベルに
|は現れず、書くと構文エラーになる (§2.1) - トップレベルに直接並ぶ宣言がスロットになる。
name := exprは immutable スロットを、mutable name := exprは mutable スロットを宣言し (§6.1)、どちらもファイルオブジェクトの member になる (§13.2)。name = exprは既存の束縛への代入であって名前を導入しないので、member を増やさない。入れ子リテラルの中の宣言はそのフレームのローカルであってスロットにならない - ファイルレベルの slot-list には文を書ける。 副作用のための式と代入族の文 (§6) を宣言に混ぜてよく、評価は記述順・値は捨てる。これがファイルレベルにだけ在る規則であり、内側リテラルの slot-list では従来どおり構文エラーである (§2.1)。下の区切りの違いはこの規則の帰結で、独立した規則ではない
- 既定値を持たないスロット宣言 (
name: Typeだけの形) はファイルレベルでは静的エラーである。ファイルオブジェクトはロードで完成するため、未束縛スロットを埋める呼び出し側が居ない (下記「ファイルは構築子にならない」) - スロット集合は固定である (§2.4)。slot 生成はトップレベル宣言時のみ
- 宣言と名前解決の規則は内側リテラルの slot-list と同一 (§2.1, §5, §6.1)。区切りだけは body 領域の規則に従う — 文を受ける領域なので
,はスロット区切りではなく多値 (タプル) 構築を表し、スロットは改行 (または;) で区切る (§1.3)。したがって裸のタプル分解a, b := expr(§6.3) もトップレベルに書け、aとbはどちらもスロットになる - shebang
#!...は 1 行目に置ける。行コメント・空行は何行あってもよい - ファイル先頭に mode を宣言する行は無い。
#{...}はどの位置でも通常の#行コメントである (§1.1)。#{ hikari }/#{ hikari |}で始まる既存ファイルは、その 1 行がコメントとして読まれるだけで意味は変わらない (hikari lintが残存を勧告する。lint.md)
評価モデル:
ファイルのロードは暗黙リテラルの評価そのもの:
- トップレベルの要素を記述順に評価する。スロット宣言は既定値を eager 評価して確定束縛し (§12)、文は評価して値を捨てる
- そのオブジェクトを返す (スロットは確定済み)
ファイルは構築子にならない。 未束縛スロットを残したリテラルは呼び出しで残りを埋める構築子になるが (§2.2)、ファイルレベルのスロットはロードで確定束縛されるため、ファイルオブジェクトは常に完成値である。
実行は「この暗黙リテラルを 1 回ロード (= 評価) する」ことに等価である。最外側フレームは、その中で定義された関数がレキシカル環境として参照する間、生存し続ける。
ロードは完走するか、オブジェクトを生まずに終わるかのどちらかである。 トップレベルの return (§18.2) と、トップレベルへ達した ? の失敗 bail (§16.5) はロードを中断し、途中まで組んだオブジェクトを返さない。したがって「宣言されたが評価されていないスロット」が外から観測されることはなく、スロット集合の固定は保たれる。中断したファイルはキャッシュにも残らない (§13.4)。
静的検査 (static-analysis.md §2) は全実行系の既定であり、ファイル単位で有効・無効を切り替える手段は無い。ファイルが宣言できるのは中身だけで、処理系の振る舞いを変える属性は持たない。
# main.hika greet := { name: String | "hello, ${name}" } print(greet("world"))
# run.hika (shebang) #!/usr/bin/env hikari print("hi")
# math.hika (宣言だけを並べたファイル。PI と area がこのファイルの member になる) PI := 3.14 area := { r: Float | PI * r * r }
# logger.hika (宣言と初期化の副作用が混ざる。評価は記述順) log := { msg: String | print(msg) } log("logger initialized")
# checked.hika (すべてのファイルが評価前に静的型検査される) add: { Int, Int | Int } := { x, y | x + y } print(add(1, 2))
13.2 ファイルと名前空間
ファイル間の参照は import 構文 (§1.4) で行う。import は type / enum と同型の宣言形 <キーワード> 対象 := "path" で、パス先のファイルを §13.1 の規則で評価し、その member を対象名へ束縛する。import-target が bare 名 (import m := "x") ならモジュール名前空間全体を、名前リスト (import { a, b } := "x"、§6.3) なら列挙した member を分解束縛する ({…} の有無で分岐)。path は := 右辺の 文字列リテラル 1 個のみ、キーワードと対象・:=・path の間の空白は任意。
import は ファイルトップレベルの宣言 としてのみ現れる。関数本体・入れ子リテラルの内部には書けない (文法エラー)。これによりモジュールの初期化を起動時・単一の import チェイン上に閉じ込める。式ではないため := の右辺や部分式には置けない。
呼び出し側は丸ごと束縛した名前を通常のオブジェクトとして slot アクセスする:
import math := "math.hika" math.sqrt(16)
名前リスト {…} と組み合わせて、必要 member だけローカルに引くこともできる。ファイルトップレベルは slot-list なので (§13.1)、引いた名前は後続スロットの宣言から参照でき、そのファイルの member にもなる:
import { sqrt, log } := "math.hika" sqrt(16)
import は同じ構文で 標準ライブラリモジュール も取得する。引数が std: プレフィックスを持つ文字列リテラル ("std:fs" 等) のとき、ファイルではなくホスト提供の標準ライブラリ名前空間を解決する:
import { read, write } := "std:fs" content := read("foo.hika")!.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のみ公開。export {}(空の名前リスト)がある → 何も公開しない。
import は評価したオブジェクトの exported member だけを露出する。未列挙のトップレベル名はファイル内ではレキシカルに参照でき(私的ヘルパー・相互参照)、importer からは member として存在しない(丸ごと束縛越しの .slot・import { 名前 } := "…" の分解とも該当スロット無しとして扱う)。
export { … } の中身は名前リスト(§6.3)である — bare 識別子、または各名に任意で型注釈を付けた name: 型(export { n1, n2: T2, … })。変数・式・文字列・補間は書けない(import のパス・リテラル限定と対称に、公開 member 集合を実行前に確定する)。export はファイルトップレベルに最大 1 個。入れ子リテラルの内部(slot-list・body とも)に現れると静的エラー。
空の名前リスト export {} は「何も公開しない」と名乗る形である。 ファイルは 1 領域なので(§13.1)トップレベルの束縛は必ず member になる。member にしない選択はこの 1 行でだけ書ける。要るのは単体で走らせるプログラム — 外へ出す名前が無く、しかも多重度型の値(§17.8)をトップレベルに束縛するファイルである。多重度型の値は export できない(static-analysis.md §2.14)ので、公開しないことを名乗らなければトップレベルで資源を開けない。
# main.hika — 何も公開しない。conn はこのファイルの中で開いて閉じる export {} conn := db.open("app.db")! conn.close()!
# util.hika — render だけを公開する。escape はファイル内からしか見えない export { render } escape := { s: String | s } render := { s: String | escape(s) }
型注釈付き 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 の残りの評価は走らない (実装は当該フローを止める責任を持つ)。
最後まで結果を必要とする Future は明示的に f! で待つ義務がユーザー側にある。
13.4 import の詳細仕様
import <対象> := "path.hika" (§13.2) の対象形・パス解決・キャッシュ・循環検出・エラー伝搬を定める。import は prelude 組込ではなく言語コアの宣言形 (§1.4) であり、評価時に 呼び出し元ファイル のパス情報を参照する点が通常の宣言と異なる。
対象とパス解決
- 対象は bare 名 (丸ごと束縛) または名前リスト
{…}(分解束縛。§6.3) のいずれか。path は:=右辺の 文字列リテラル 1 個のみ (import m := "math.hika")。変数・文字列補間"${...}"・bare 識別子・任意式は 構文エラー (static-analysis.md §1)。リテラル限定によりモジュールグラフを実行前に確定する。 - 引数のプレフィックスで解決系統が分岐する:
std:→ 標準ライブラリ解決、pkg:→ third-party 解決 (いずれも下記)。プレフィックス無しは ファイルパス解決: - 拡張子
.hikaは明示必須 (import m := "math.hika")。 - パスは 呼び出し元ファイル基準の相対 として解決。
./../の有無を問わない ("math.hika"≡"./math.hika")。 - 絶対パスは静的エラー (static-analysis.md §1)。
/で始まるパスは受け付けない — ソースが機械上の配置に依存すると、hikari buildの embed も別環境での取得も成り立たないためである。位置に依らない参照が要るならpkg:を使う。 - 検索パスによる解決は未対応 (third-party は
pkg:経由)。
標準ライブラリ解決 (std: プレフィックス)
- 引数が
std:で始まるとき ("std:fs"等)、std:に続く名前を ホスト提供の標準ライブラリモジュール として解決し、その名前空間オブジェクトを返す。一覧は std/index.md。 .hika拡張子は 付けない ("std:fs"、"std:fs.hika"ではない)。ファイル相対パス解決・cwd・呼び出し元ファイル基準は 適用されない (ホストが提供するため位置に依らず常に同じオブジェクト)。- 循環検出・ファイル不在・読み込み失敗の対象外。host code は常に存在するため、これらのエラーは起こらない。未知の
std:名は実行前の静的検査で弾く (static-analysis.md §1)。 - キャッシュは絶対パスではなく
std:名そのもの をキーとする (std:fs→ 同一オブジェクト)。プロセス内 1 回だけ構築し以降は同じオブジェクトを返す。 embedバイナリ (hikari build) でもstd:モジュールは常に解決可能 (ホストコードはバイナリに同梱される)。.hikaソースの embed FS とは独立。std:モジュールは値メンバー (コンストラクター等) に加え、.hikaの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) を返す。 - 別名は マニフェスト・ローカル — パッケージごとに独立した名前空間で、解決は常に「その
.hikaを含むパッケージのマニフェスト」基準。これにより推移依存同士で別名が衝突しない。マニフェストの探索・スキーマ・取得元記法・lockfile・キャッシュ・推移依存の意味論は packages.md。 pkg:<alias>は相手パッケージの 入口 1 点 に着地する。ファイル単位のpkg:<alias>/<path>.hikaは持たない (相手パッケージ内部のファイル分割は、そのパッケージ自身の相対 import で閉じる)。.hika拡張子は 付けない。ファイル相対パス解決・cwd・呼び出し元基準は適用されない (別名は論理名で、実体の場所はマニフェスト/lock/キャッシュが決める)。- キャッシュは
(取得元, バージョン, commit)をキーとし、同一の解決先は同じオブジェクトを返す。別バージョンの同一パッケージは別実体として共存する (バージョン解決器を持たない)。 - マニフェストを持たないファイルからの
pkg:import、depsに無い別名、未取得 (キャッシュ不在) は実行前の静的検査で弾く (static-analysis.md §1)。 embedバイナリ (hikari build) では依存ソースが embed FS の別名前空間に同梱され、pkg:は常に解決可能 (packages.md)。
型メンバーの export — ジェネリック宣言
- 型引数を持つ
type/enumの宣言(type Box(a) := { value: a |})も 型メンバーとして export される。取り込んだ側は修飾形(import m := "box.hika"のもとでm.Box(Int))と分解形(import { Box } := "box.hika"のもとでBox(Int))のどちらでも型引数を当てられる。宣言したファイルの中で書いたBox(Int)と同じ型になる。 - 公開されるのは型ではなく型構築子である。 型引数を当てるまで型ではないので、型式の位置に引数無しで書いた
m.Boxはエラーでありtype-arity-mismatchとして断る(Listを型式の位置へ引数無しで書けないのと同じ理由。要素型が決まらない、§17.2)。引数の個数が宣言と食い違う形も同じ診断で、個数は宣言が持つ個数を述べる。この扱いは取り込み方で変わらない — 同じファイルで宣言したBoxを裸で書いた形も、分解形で取り出したBoxを裸で書いた形も、同じ診断で断る。 - 分解形が束縛するのは型構築子であって値ではない。
import { Box } := "box.hika"の後、Boxは型式の位置でだけ引ける名前であり、Box(Int)と適用して型になる。値の位置にBoxと書いてスロットへ入れる道は持たない — 型引数を当てるまで記述子が無く、渡した先で照合にも構築にも使えないためである。取り込み方に依らず、修飾形のm.Boxも同じく型式の位置の名前である。 - prelude の
List/Optionを値として引けるのはこれとは別の話である。あちらは prelude が値としても束縛している型コンストラクター(§17.2)で、importが公開する書き手のジェネリック宣言はその束縛を持たない。 - 非ジェネリックの
type/enumは従来どおり型そのものを公開する。 引数を持たないので適用の余地が無く、この節の区別は生じない。
キャッシュ
- 同一の解決済み絶対パスは プロセス内 1 回だけ評価、以降は同じオブジェクトを返す。
- キャッシュ登録は 評価完了時のみ。評価中エラーで中断したファイルはキャッシュに残らず、次の
importでゼロから再評価される。
循環検出
- 評価中のファイルパスをスタックに保持し、同一パスがスタックに既に含まれる
importを 循環 import としてErrorで raise。 - スコープは同一プロセス内、トップレベル import チェインのみ。エラーメッセージに検出時の import チェインを含める。
hikari <file>/hikari check/hikari build実行・LSP では、実行前の静的検査 (static-analysis.md §1) が同じ輪をimport-cycleとして先に報告する。実行時の raise は REPL や動的到達のフォールバックで、読み込み失敗と同じ位置づけである(下記「エラーモデル」)。
エラーモデル
- 読み込み失敗 (パス未解決 / 読み込み失敗):
import位置でErrorを raise。なおhikari <file>/hikari build実行・LSP では import 先不在・import 先の構文エラーは 実行前の静的検査 (static-analysis.md §1) で先に報告される。実行時の raise は REPL や動的到達のフォールバック。 - 評価エラー (import 先の評価中の例外): import 先で発生した例外がそのまま呼び出し元へ伝搬。中断したファイルはキャッシュに残らない。
意図的に 入れない もの: bare 識別子 import、任意式 import、リネーム付きスロット destructure、部分循環 import、動的読み込み / 遅延読み込み / 明示再読み込み、ファイルレベル inner-name、ファイル単位のメタデータ宣言 (ファイルは中身だけを宣言し、処理系の振る舞いを変える属性は持たない。§13.1)。
import をファイルトップレベルの宣言に限定する (§13.2) のは、この遅延読み込み排除と整合する — 関数本体内 import は実質的な初回タッチ遅延読み込みになるため許さない。宣言形のため配置制限は文法で担保され、別途の placement 静的検査は要らない。
14. 標準ライブラリ
prelude (起動時に root フレームに束縛される) の定義は別書 prelude.md に分離した。import <対象> := "std:<name>" で取得する 標準ライブラリモジュール (std:fs 等、root には常在しない) は別書 std/index.md で定義する。
15. 章をまたいで効く規則
本書の規則には、章をまたいで効くものがある — 並置=関数適用(§3.1)、
(...) は要素数で意味が変わる(§3.3)、; / 改行はシーケンス
(§11.1)、:= は宣言で = は代入(§6.1)、
予約語は宣言・照合の構文形だけ(§1.4)などである。
それらをまとめた一覧はどこにも持たない。 かつて同じ項目を 2 か所に持っていた結果、
片方だけが更新されて実際に食い違っていた — 予約語の一覧に inner_bound が無く、postfix
? が落ち、ある番号に至っては 2 つが別の規則を載せていた(片や多重度、片や match
への一本化)。規則の正はそれを定める章にあり、本書の他の章も他の文書もその章を指す。
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 する という | Error |
評価のどの位置でも上位へ自動伝播し、トップレベルに達したところで停止する |
時点と層は直交する。 回復可能エラーとバグはどちらも実行時に生じる — 分かれるのは生じた後の運び方 (値として呼び手へ返すか、自動で上へ伝播するか) であって、いつ分かるかではない。本書が「実行時エラー」と書くのは実行前に証明できるもの (static-analysis.md §2.11 の証明義務) との対比であって、層の名前ではない。
panic は必ずプログラムを落とすわけではない。 停止・非ゼロ終了はトップレベルまで達したときの結末で、手前に受け止める位置があればそこで止まる。現状 3 つある — std:test の run は Trial の境界で捕まえて FAIL に数え (std/test.md §2)、REPL は行ごとに表示してプロンプトへ戻り (repl.md)、一度も ! しなかった Future の panic は surface しないまま診断だけが出る (§10.2。終了コードは変わらない)。
16.2 層の振り分け原則
ある失敗をどの層に置くかは 「プログラムのバグか、想定内の状況か」 で決める。
- バグ (型不一致、未定義参照、arity 不一致、ゼロ除算など、コードを直すべきもの) → バグ層 (panic する)
- 想定内の状況 (ファイル不在、権限不足など、起こりうるもの) → 回復可能エラー
- 実行前に判明するもの (構文エラー等) → 静的エラー
これらの panic は型不一致や unwrap! のように 言語・組込が contract 違反として自動で起こすのが基本だが、ユーザーコードからも明示的に panic を起こせる。組込 panic(message) (prelude.md §1) は message (String) を持つバグ層エラーを発火する — 「到達しないはず」「未実装」「呼び出し前提の違反」のような バグを表明するための手段である。
- 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 §3)。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)。別記であって E を未確定にする書き方ではない — Result(T) と書いた時点で E は Error に確定し、照合も Error として行う。エラーを型で区別したいとき (parse の Syntax / Overflow 等) は Result(T, E) でエラー型をパラメーター化し、E に構造化型 (enum 等、§17.4) を据える。組込 (input / std:fs ほか) はすべて既定の Error を返す。
import { read } := "std:fs" match read("config.hika")! { 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 §12.3) でも組める。専用構文は要らない - 受け手は 未知 kind 用の default 分岐 を持つことを慣習とする (
kindは開いた String なので網羅性は静的に保証されない。closed variant —Option/Result/enum— のmatchは静的型検査が網羅性を要求する。static-analysis.md §2.9)
実行開始前の一括構文検査 (構文エラー・動的 import 排除・import 解決層エラー) は static-analysis.md §1 を参照。
16.5 失敗 bail (?)
? は Result / Option の 失敗 variant に当たったら即座に関数から脱出 する postfix 演算子で、§18.2 の return の上の構文糖である (新しい脱出機構ではない)。逐次処理の「失敗なら即抜け、成功なら中身を取り出して次へ」を、match や when (…is_err!) {return …} のネストなしに平坦に書ける。
e? は対象 e を一度だけ評価し、その variant で分岐する:
| 対象 | 成功 → 式の値 | 失敗 → 脱出 |
|---|---|---|
Result |
Ok(v) → v |
Err(e) → return r (Result をそのまま返す) |
Option |
Some(v) → v |
None → return None |
- postfix:
!(§3.1) と同じ後置で、対象が 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。 - 戻り型の収束:
?の失敗 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 の種別が割れる。静的検査はこれを確実に割れる合流として報告する (static-analysis.md §2 / §4)。「?を Result / Option を返さない関数で使う」ケース (末尾値と bail の種別が割れる) も同じ収束判定で拾う。
- トップレベルに達した失敗の表面化: 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 を直接渡した場合も同様に表面化する。ファイルには本節「戻り型の収束」が及ばない — ファイルは body を持たず、ロードが返すのはファイルオブジェクト自身である(§13.1)。トップレベルの bail は値を生まずロードを中断するのでNeverに型付き、Neverは合流の恒等元なので(static-analysis.md §3.3)締めの値を要求しない。REPL では行単位のエラー表示となりプロンプトへ戻る(プロセスは終了しない)。unwrap!(失敗=バグの表明 → panic)との違いは、こちらが「想定内の失敗が未処理で最上位に達した」通常のエラー経路である点で、診断は Trace を持たない単一行になる。
- 表面化は bail の浮上元に依らない: 上の表面化は、entry のトップレベル(§13.1)へ達したすべての bail に同じに起きる。スロット宣言の既定値から浮上しても、トップレベルの文から浮上しても違いは無い。
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 §12。
17. 型注釈 (type annotations)
変数・パラメーター・スロット・分解束縛・inner-name・関数の結果型・任意の式に 型注釈 名前: 型(式に対しては 式: 型)を付けられる。型はキーワードではなく 値(述語) であり、prelude が束縛する。
意味論(実行時照合): 注釈は構文として受理され AST に保持され、値が束縛される地点で照合される。対象は変数束縛 (x: 型 := v / mutable x: 型 := v)・分解束縛 (a: 型 := … / { x: 型 } := …)・呼び出し時のパラメーター束縛 ({x: 型 | …}(arg)、引数とデフォルトの両方)・スロットのデフォルト確定 ({x: 型 := d |})・式アスクリプション ((式): 型、式の評価地点で照合)。照合は §17.4 の表に従い、一致しなければ 型不一致として panic する (§16.2 のバグ層。未定義参照・arity 不一致と同じ)。注釈式が型値に評価できない場合 (型でない名前を注釈に書いた等) も同位置で panic する。注釈の無い箇所・Any 注釈は素通しする (漸進的型付け)。この実行時照合は、静的検査が具体型を割り当てられなかった箇所——gradual 境界(下記)——の実行時強制である。静的に閉じた領域では検査が先に同じ不一致を報告するため、実行時照合が初めて捕捉するのは gradual 境界を跨いだ逸脱に限られる。
型消去: 上の照合のうち、静的検査が「実行時照合は確実に成功する」と証明した地点では照合を行わない。証明できるのは注釈の型と値の静的型の双方が具体型に確定している地点に限られ、gradual 境界を跨ぐ地点は証明できないため照合が残る。この証明は過小近似である — 確実に成功すると言えない位置はすべて「証明できなかった」に倒れ、照合が残る。したがって消去は観測可能な意味論を変えない(消える照合は成功することが証明済みのものだけで、失敗しうる照合は残る)。証明手続きがどの位置を真とするかは処理系側の実装事項で、../internals/type-erasure.md が持つ。手続きが強くなっても弱くなっても変わるのは速度だけである。
パラメーター束縛の消去は呼び出しごとに決まる。 実引数に来る値は呼び出しごとに違い、関数値は gradual 境界へ渡って呼ばれうるので、定義位置で「すべての呼び出しが宣言型を守る」ことは証明できない。したがって注釈位置では消えない。代わりに呼び出し位置が自分一件について証明する — 実引数の静的型が宣言パラメーター型を満たすと証明できた呼び出しでは、その呼び出しに限って当該パラメーターの照合を省く。同じ関数への他の呼び出し・間接呼び出し・部分適用の照合は残るので、gradual 境界から渡った実引数は従来どおり実行時照合が捕捉する。
refinement 型の注釈(§17.7)では、基底型の一致に加えて述語が真であることの証明が要る。述語の証明は静的検査の証明義務(static-analysis.md §2.11「境界の証明義務」)が果たすもので、証明できなかった述語は残る。消えるのは決定可能断片内の述語だけなので(断片外と refinement_opaque は証明が成立しない)、消去によって述語の評価に伴う副作用が消えることはない。
消去の対象はその場で照合して終わる地点に限る。関数型注釈が付いた境界は照合に加えて呼出時強制の契約を値に載せる(§17.4)ため、消去しない。契約はその地点の照合ではなくその関数値が将来呼ばれるすべての地点の強制装置であり、束縛地点で型が合っていることを証明しても、その値が後で正しく呼ばれることまでは証明しないためである。したがって宣言型が関数型の注釈位置は、証明できても消去しない。封印契約(§17.6)も同じ理由で残す。
位置づけ(健全静的な型付け・gradual 境界付き): Hikari は健全静的な型付け言語である。型検査は評価前に必ず行い(static-analysis.md §2)、診断が 1 件でもあれば評価しない。注釈は任意で、確定しない箇所は Any に倒れる(漸進的型付け)— ただし Any を暗黙に導入することは検査が禁じ、明示的に書いた Any だけが動的逃げ道になる。明示的に書いた Any という gradual 境界(下記)の外では、静的型付けとして振る舞う。gradual 境界の外では照合が静的に証明されて消去され(上記「型消去」)、境界の内側では実行時照合が強制を担う — どちらの領域でも観測できる振る舞いは同じで、違うのは検査が実行前に済むか実行中に走るかだけである。
健全性保証: 評価されるプログラムは静的検査(static-analysis.md §2)を診断ゼロで通っている。したがって、チェッカーが具体型(Any でも「型不明」でもない型)を割り当てた各評価点で、実行時の値はその型と整合する。すなわち Any を経由しない領域では §16.2 の型的 panic(型不一致・no such slot/method・arity 不一致・未定義参照)が起きない。gradual 境界(下記)での逸脱は、その境界に居る注釈位置では上記の実行時照合が捕捉する。ただし境界を経由した書き換えが別の注釈位置の前提を壊した場合、その位置の照合は型消去で消えていることがあり捕捉されない。境界を一つも含まないプログラムではこの状況は起きない。系: 明示的な Any を一つも書かないプログラムは全域で型的 panic を起こさない。
境界はプログラムのどこにあってもよい — 前提を壊された注釈位置に Any が無くても起きる。
o := { mutable x := 1 |}!
a: Any := o # Any 経由の別名
a.x = "s" # 受け手の静的型が Any なので代入の型安定性は素通しする
p: { x: Int |} := o # この注釈位置に Any は無いが、前提は既に壊れている
消去する前はこの位置の実行時照合が破れを捕捉して見せていたが、消した後は黙って誤った値を通す。これは消去に固有の危険であり、環境変数 HIKARI_VERIFY_ERASURE(hikari-command.md §13)が消去済みの地点でも照合を実行して破れを報告する。Any を一つも書かないプログラムでは前提が成立し、消去は観測可能な意味論を変えない。
この保証が可変状態に対しても成り立つのは、可変な位置の型が動かないことを検査が保証しているからである。可変スロットへの再代入・List の伸長は、受け手の静的型に対して型を変えないことを検査され、加えて可変な位置(record のフィールドと List の要素)は不変に扱われるため(§17.2「変性」)、同じ値に広狭 2 つの静的な視点が同時に立つことがない。この 2 つは片方だけでは足りない — 代入の検査だけなら広い視点を経由した書き換えが漏れ、不変性だけなら単一の視点内での型変更代入が漏れる。可変状態は可変スロットへの再代入と List の伸長の 2 つで尽きる — スロットの追加は言語から無くなったため(§2.4)、可変状態はいずれも保証の内側にある。規則の分担は static-analysis.md §2.10「可変状態と健全性」に集約する。
gradual 境界の定義: チェッカーが Any または「型不明」に倒す箇所を gradual 境界と呼び、上の保証の適用外とする。gradual 境界は明示的に書いた Any だけである。
要素の動的アクセス(xs.[i]、§3.10)は境界ではない。 添字が実行時に決まっても、List(T) は要素型が均質なので読み出す値の型は変わらない。境界の理由は「動的であること」ではなく「どのスロットへ触るかが静的に決まらないこと」であり、要素の並びにはその危険が無い。
スロットの動的機構は言語から撤去された。 スロットを実行時のキーで引く手段は無く (§3.10)、リテラル宣言後にスロットが増えることも無い (§2.4)。したがって「幅構造型(§2.4)の未宣言メンバー参照」「open object へのアクセス」「非定数キーの動的スロット書き込み」はいずれも境界として存在しない — 前二者はスロット集合が固定されたことで、後者はスロットの動的キーが無くなったことで消えた。キーで引く辞書は std:map が型の付いた値として提供する (§7.5)。
未定義参照は gradual 境界ではなく(値が存在せず確実に stuck するため)、静的検査が報告する。
関数型の照合の深さ(§17.4): 関数型 {P… | R} は宣言シグネチャの構造照合を行う — 引数数に加え、関数値が宣言する各引数スロットの型注釈を期待引数型 P… と、body 末尾アスクリプション(§17.3 form 3)で宣言された結果型を期待結果型 R と、型値として突き合わせる(不変。変性は取らない=§17.2 の higher-rank 非対応と整合)。注釈の無い引数位置・結果型を宣言しない関数は素通しする(漸進的型付け。無注釈関数は従来どおり引数数のみで一致し、後方互換)。
- 部分適用された関数値は、その時点で未束縛のスロット数で照合する(§3.5 の R と同じ)。束縛済みのスロットは後から上書きできないので、値としての引数数は残りの数である —
{ a: Int, b: Int | a + b }(1)は「bを待つオブジェクト」(§3.5)であり、{ Int | Int }に一致して{ Int, Int | Int }には一致しない。引数型の突き合わせも残りのスロットに対して行う。
- inner-name 型注釈(§17.3 form 2)が宣言する引数型・結果型も構造照合に用いる — 値に載る宣言なので、インライン引数注釈・body 末尾アスクリプションが無い位置の補完源として突き合わせる(両方あればインライン優先)。
- 束縛注釈による宣言も、その値に契約(下記の呼出時強制の wrapper)として載るため構造照合に用いる — inner-name 型注釈と同じく、インライン引数注釈・body 末尾アスクリプションが無い位置の補完源になる(優先順位はインライン注釈 > inner-name 注釈 > 契約)。
- 関数型注釈が付いた境界の値(パラメーター型・束縛注釈・inner-name 注釈が関数型)は、呼出しごとに宣言 param 型で実引数を・宣言 result 型で実返り値を wrap して照合する(呼出時強制。具体型の位置のみ照合し、
Any・型変数の位置は素通し)。逸脱は型不一致として panic する(§16.2)。これにより高階の境界(無注釈・結果型省略で bind 地点の構造照合が素通しした関数値)も、呼び出された時点で宣言型に対して実照合される。 - 契約の畳み込み: 既に契約を持つ関数値がさらに関数型注釈の境界を通るとき、新旧の契約を位置ごと(各引数・結果)に突き合わせ、狭い方を残して 1 つの契約にまとめる。比較不能な組はその境界で型不一致とする。
Any・型変数は最も広い型なので、この規則の帰結として具体型の方が残る。不変条件は「境界を通るたび契約は狭まるだけで、決して緩まない」であり、注釈を足して実行時保証が減ることはない。契約は常に 1 段にまとめられるため、境界の数に比例して wrapper が積まれることもない(§18.5 の定数スタックを保つ)。同一基底の refinement 型同士は述語の論理積を取って 1 つにまとめる(§17.7)ため、どちらが狭いか比較できずに型不一致へ落ちる場合が生じない。 - 例外: 宣言結果型が
Unit({… | Unit})のときは結果照合を行わない —Unit結果位置はdiscard コンテキストであり、呼び出し側は結果を捨てる(fire-and-forget コールバックの慣用)。本体が非Unit値を返しても呼出時強制の対象外として素通しする。この扱いは bind 地点の構造照合にも及ぶ — 結果位置のUnitはAny・型変数と同じワイルドカードとして素通しし、具体的な結果型を宣言した関数値も{… | Unit}の境界を通せる。結果を実際に使う箇所では use-site の束縛注釈(x: T := f(…))が backstop になるため健全性は保たれる。パラメーター型の照合はUnit位置でも通常どおり行う(discard は結果位置に限る)。 - 既定エラー型の
Result(T)(エラー型Eを省いた形、§17.2)を宣言結果型に持つ関数の呼出時強制では、Err(...)の payload を標準Error形として照合する。Result(T)はResult(T, Error)の別記でありEはErrorに確定しているので、束縛注釈の実行時照合(x: Result(String) := v)と同じ厳格さになる。Errに文字列など別の形を載せたいときはResult(T, E)でEを明示する。
浅い照合に留める箇所: 次の 2 つは実行時照合が中身まで降りない。どちらも版の都合ではなく、照合を置ける地点が無いことの帰結である。
Future(T)の payload — 見るのは「Future かどうか」だけである。束縛の時点で payload はまだ存在しないので(fork が完了しているとは限らない)、読む値が無い。そこで待って検査すれば束縛が中断点になり、意味論が変わる。静的に決まる食い違いは静的検査が拾う(x: Future(String) := fork({ 42 })はfuture payload type mismatchで止まる。static-analysis.md §2.12)ので、素通りするのはAnyを挟んで型を静的に消した形に限られる。- inner-name 型注釈が宣言する record 型(データオブジェクト) — 宣言したフィールド型は実行時に効かない。record には関数の呼び出し境界にあたる繰り返し通る地点が無く、照合を置けるのは構築の 1 点だけだが、そこではスロットが束縛されていないことがある(
{ inner self: { x: Int |}, x: Int |}はxが未束縛のまま値として存在する構築子である。§4.2.1)。値を wrapper で包めば境界を作れるが、inner-name は「データスロットではなく、等価・shape・merge に影響しない」(§4.2)ため包めない — 包むと §9.1 の等価と §7.4.1 のマージがその wrapper を観測してしまう。
inner-name 型注釈が宣言する引数型・record 型は構造照合に用いる(インライン注釈・body 末尾アスクリプションが無い位置の補完源)。inner-name の関数型注釈が宣言する結果型は浅い照合ではなく、束縛注釈・body 末尾アスクリプションと対称に呼出時に強制する(§17.4) — 関数には呼び出しという境界があるためで、上の 2 つとの差はそこにある。inner-name 型 ({ inner self: 型 | … }) と slot/結果型の整合は静的型検査が検査する(矛盾=型エラー、static-analysis.md §2)。
17.1 記号と位置
: は型注釈専用の記号。:=(束縛)とは字句上区別される(x: Int は x : Int の 3 トークン、x := 0 は := の 1 トークン)。
- パラメーター / スロット:
{x: Int, y: Int | x + y} - デフォルト付き(immutable / mutable):
{x: Int := 0 | ...}/{ mutable count: Int := 0 | ...} - 変数束縛:
total: Int := 0/mutable 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 format は曖昧になりやすい二項式アスクリプションに括弧を補う)。: と := は字句区別されるため foo := bar: Baz は foo := (bar: Baz)(束縛の右辺へのアスクリプション)であり、束縛注釈 foo: Baz := bar とは別位置の表現。アスクリプション右辺の型は型コンテキストで評価される(§17.2)ため、タプル型は x: (Int, String) と単一括弧でよい。
型オペランドを取る中置演算子は右辺を型コンテキストで読む。これは : と matches(§8.3・§17.4)に共通の規則で、右辺が型として一意に読めることを構文が保証する。帰結として、両者とも構造型リテラルを単一括弧で書ける(x: (Int, String) / v matches (Int, String))。
一方ドット形 v.matches(T) は普通のメソッド呼び出しで、引数は値コンテキストで評価され型値を受け取る。したがって構造型リテラルを直接は渡せず、type で名前を付ける(type P := (Int, Int) → v.matches(P))。この 1 点だけ中置形とドット形が等価にならない(§8.1 の三記法の例外)。等価性より「型の位置には型が書ける」を優先した結果である。
matches の右辺に裸で書いた識別子は、束縛が無ければ未定義として断る。 型コンテキストの bare 小文字は型変数だが(§17.2)、無境界の型変数はそこに照合の材料を持たない — 実行時に消去されるので何にでも当たり、答えが常に true になる。したがって右辺に裸で書いた識別子が値としても束縛されていないとき、型変数として素通しさせず undefined: <名前> として断る。大文字始まりの未定義名(v matches Nosuch)と同じ答えであり、打ち間違いが静かに真を返す形を作らないためである。次の 3 つは対象外で従来どおり働く — 境界付き型変数({ v: a: Comparable, w: Any | w matches a })は実行時に確定型へ単一化されるので束縛があり(§17.2)、型値を運ぶ値(T := Int の T・パラメーター t: Any)は値として読み(上記)、型構築子の引数に置いた型変数(v matches List(a) / v matches Type(a)、§17.4.1)は外側の構築子が材料を持つ。
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}(名前付き+結果)。引数名は表示専用で、照合は引数数・引数型・結果型だけを見る(§17.4 の照合表)。書いた綴りは型値の印字にそのまま出る - 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で参照する(フラット束縛はしない) - 型値の型:
Type(T)— 型Tを表す型値の型。型は値なので(§17.2)型値も型を持ち、Type(T)がそれである。型を引数にとる関数がその意図をシグネチャに書けるようにするためのもので、型引数は必須(無パラメーターのTypeは持たない)。詳細は下記「型値の型Type(T)」 - 順序型:
Ordering(=Less∣Equal∣Greater、順序比較の結果。§9.4) - 構造的 interface(capability 型): メソッド契約を表す構造型。
Comparable(={compare |}、§9.4)/Copyable(={copy |}、§9.5)は組込特別型ではなく prelude 定義の構造型であり、専用の型 kind を持たない。ユーザーも同じ構文で定義できる(§17.5) - refinement 型:
refinement { v: Int | v > 0 }— 基底型を述語で絞った型。前置予約語(§1.4)を伴う 1 スロット述語のリテラルで書き、その基底型の部分型になる。述語が決定可能断片の外にあるものはrefinement_opaqueで明示する(§17.7) - 多重度型:
AtMostOnce(T)/ExactlyOnce(T)— 値を何回使えるかを制約する型。付随する語彙としてBorrowed(T)(借りた値)とConsuming(F)(受け手を消費するメソッド)を持つ。いずれも prelude 定義の型構築子で、値ではなく使用を制約するため実行時に照合するのは基底型(T/F)だけである(§17.8) - 効果型:
Effect(T, …)— 結果型Tと、その計算が起こす効果ラベル(Io/Fs/Net/Proc/Time/Rand/Mut/Susp)の集合。付随する語彙としてEffectConduit(F)(その引数の効果が呼び出し元へ透過する)を持つ。prelude 定義の型構築子で、書けるのは関数型の結果型位置だけであり、実行時には素通しする(§17.9)
型コンテナーの役割は一つに固定する: {...} = object/record/function、(A, B) = tuple、List(T) = list。型位置に [...] は現れない(下記)— list は List(T)、固定位置は tuple (...) に書く。
型位置の {} の解釈(値位置と異なる):
| 結果型あり = function 型(結果型を持つ)。| 結果型なし = structural 型(object/record/interface)。末尾|は必須(0-pipe は下記の function 型)。エントリは:
(従前ここには「bare 識別子=位置型フィールド」の記載があったが、positional shape 型は実装を持たず dormant だったため、bare エントリは上記のメソッドフィールド/埋め込みに割り当てる。§17.5。)
この 3 つで尽きる。 値位置の slot-list が持つ他の綴り —— 既定値つきスロット(name := 型 / 可変スロット mutable name := 型)と、名前でない位置エントリ({ 1 |})—— は型位置のエントリではなく、構文エラーとして断る。型位置の [...](brackets are not a type)と同じ、書いた綴りだけで決まる判定である。
可変性は型の綴りに載らない。 mutable を型位置に書けないのはこのためで、record 型が深さについて部分型を持たない根拠(下記「変性」)は値の可変スロットにあり、型の側がスロットごとの可変性を述べるわけではない。可変スロットを持つ値は name: 型 の位置へそのまま渡せる(幅・深さの規則は値の可変性を見ない)。
pipe なしの {T}(0-pipe)は body 形であり、値位置の {body} と同じ規則に従う。型位置では body が結果型になるため {T} ≡ {| T} =「引数なしで T を返す関数型」となる(structural 型にしたい場合は末尾 | が必須で {… |} と書く)。ただし body が空の {} は例外で、結果型が無いため関数型にならず、空の structural 型 {|} として読む — 型位置では {} ≡ {|} がどちらも「フィールドを問わない空の structural 型」であり、要求が無いので全値に一致する(Any 相当。§17.5)。値位置で {} ≡ {|} が 1 つの空オブジェクトになる(§2.1.1)のと揃う。これは値 {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 の関数型 |
変性(variance): record 型は幅について部分型を持ち、深さについては持たない。宣言していないスロットを余分に持つ値は通る(幅。{x: Int, y: Int |} の値は {x: Int |} の位置へ渡せる — スロットは実行時に減らせない)が、宣言したフィールドの型は厳密に一致していなければならない(深さ。{x: Int |} の値は {x: Any |} の位置へ渡せない)。理由は可変性にある — 可変スロット(mutable)は中身を差し替えられるので、同じ値に広狭 2 つの静的な視点を同時に持たせると、狭い視点では違反である書き換えを広い視点から行えてしまう(可変な器を共変に扱ったときに生じる不健全性そのものである)。
幅が開いているのは「渡せる値」だけで、「触れるメンバー」は宣言が閉じる。 上の幅部分型が定めるのはどんな値をその位置へ渡せるかである。受け手の側でどの名前を読めるかは別の規則が定め、こちらは宣言したメンバー集合に閉じる。{ o: {x: Int |} | o.y } は、y を持つ値が渡ってくることがあっても静的エラーである(no-such-member。static-analysis.md §2.7)。2 つは独立している — 幅を開いておくのは「宣言より広い値をそのまま渡せる」ためであり、そこから「宣言に無い名前を読んでよい」は導かれない。読みたい名前は宣言に書く。
この規則は注釈の位置を問わない。 パラメーター・束縛・export のいずれで宣言しても、その型のメンバー集合が受け手から触れる範囲になる。位置によって違うのは実行時の裏付けだけで、契約外スロットを実行時にも到達不能にする封印は export { name: T } に限られる(§17.6)。問わないのは位置であって、宣言の有無ではない — 書き手が型を名乗っていない受け手(注釈の無い束縛など)は閉じた視点を約束していないので、この規則の対象ではない(static-analysis.md §2.7)。
差し替える手段が無い位置は共変である。 Option / Result / Future の payload、tuple の要素、そして List(T) の要素型(List は不変で要素を差し替える経路を持たない。§7.4)がこれにあたり、狭い型の値を広い位置へ渡せる。一方 std:array の Array(e) は set / push で、std:map の ScratchMap(k, v) は set / update で要素を差し替えるため、その型引数は不変(invariant)である(std/array.md §3・std/map.md §3.1)。同じ std でも不変な値である SortedMap(k, v) / InsertionMap(k, v)(std/map.md)と OrderedSet(e)(std/set.md)は差し替える経路が無いので共変である。静的検査での扱いは static-analysis.md §2.10「可変コンテナー位置の不変性」。
型位置に […] は現れない
値位置の […] は List のリテラル (§7.4) だが、型位置に […] は無い。List 型は型構築子 List(T) で書く。
| 書き方 | 意味 |
|---|---|
List(Int) |
Int のリスト |
[Int] |
構文エラー。診断は List(Int) を促す |
[x: Int, y: Int] |
構文エラー。record 型は { x: Int, y: Int |} |
[Int, Int | Int] |
構文エラー。関数型は { Int, Int | Int } |
[T] を List(T) の糖衣として置かないのは 3 つの理由による。
Listは型値として名前で参照できる必要がある。 型は値なので (§17.2)、v matches List(Int)・Type(T)(§17.4.1)・型変数の境界・map: { List(a), { a | b } | List(b) }のような高階シグネチャはListを名前で引く。[T]は構文であって値ではない- 型構築子の形が揃う。
Option(T)/Result(T, E)/Future(T)/OneOf(A, B)/Type(T)はすべてName(args)形である。Listだけ括弧記法を持てば例外が 1 つ増える - 同じ型に綴りが 2 つある状態を作らない。 型値の正準表示 (エラーメッセージ・
Inspect) もList(T)の一形である
固定長で異種の並びは tuple (Int, String) に書く (§7.3)。
関数型の位置エントリはパラメーター型である。 { Int, Int | Int } の Int, Int は「スロットの型を宣言順に並べたもの」であって、値位置の要素の並びとは別物である — §17.4 の照合表のとおり、引数名は表示専用で照合は引数数・引数型・結果型だけを見る。値位置の {…} が名前でスロットを宣言するのに対し、型位置では名前を省いて型だけを並べられる、という関係である。
型語彙もシャドウできる: 型位置の識別子は普通の式として解決されるので、書き手が同名を束縛すればそちらが勝つ。type Error := {code: Int |} と書いたファイルでは、以後の e: Error はその書き手の定義を指し、prelude の Error は隠れる(外側スコープの同名を隠すのは §6.1 のとおり衝突ではない)。型語彙は予約語ではない以上、Int / Range / Error のような名前を自分の型に使うことを言語は妨げない。静的検査の解決順は static-analysis.md §2.2 に従い、実行時の注釈評価と一致する。
型位置の bare 識別子には規則が 2 つあり、位置で決まる。
- 型式の位置(注釈
x: T・typeの右辺・型構築子の引数・タプル型の要素): 大文字始まりは型参照、小文字始まりは型変数(下記)。小文字名に束縛した型値を型位置で参照するには大文字名(typeなど)を経由する - 構造型の slot-list に置く bare エントリ: 大文字始まりは埋め込み、小文字始まりはメソッドフィールド(§17.5)。ここでは小文字は型変数にならない —
{ a |}は「aに応答する値」であって「任意の型」ではない
値位置との対応はメソッドフィールドまでである。 {} の値位置では bare 識別子は casing に依らず必須スロットなので、型位置の小文字(「その名前に応答すること」を要求する)とは対応する。大文字の埋め込みだけが対応先を持たない — 別の構造型のエントリを取り込む操作は型の側にしか無く、値の側に相当するものが無いためである(値で同じことをするのは + によるマージ(§7.4.1)で、これは綴りではなく演算子である)。
型変数(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)・無注釈関数の自動多相化は持たない。
確定は型式の中まで及ぶ: 型変数が実引数の型そのものではなく型式の内側に現れるとき — r: Rec で type Rec := { v: a: Int |} と宣言されている a、xs: List(a: Int) の a — その位置に対応する値で確定する。record 型のフィールドはそのフィールドの値、List(T) の要素型は先頭要素である。対応する値が 1 つも無いとき(空リスト)は確定せず、その型変数は無境界のまま素通しする。注釈の綴りに型変数が現れなくても働く — 名前付き型を経由すると r: Rec としか書かれないが、a は照合の途中で確定する。
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 の型は同じ値への独立した表明であり、矛盾は型エラー(いずれかが暗黙に優先することはない)。両者の整合は静的型検査が検査する(static-analysis.md §2)。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 末尾アスクリプションは呼び出しごとに返り値を実照合する — これは式アスクリプションの一般規則からそのまま出るもので、束縛注釈・内部名注釈が契約 wrapper で得る呼出時強制(§17.4)と同じ強さになる(下記「3 形とも呼出時に結果型を強制する」)。
静的検査(static-analysis.md §2)は 3 形すべてを契約として用いる(body 末尾アスクリプションは上記のとおり実行時にも照合される)。関数値の構造照合(§17.4)では 3 形すべてが宣言する引数型・結果型を照合に用いる — 内部名注釈(form 2)と body 末尾アスクリプション(form 3)は値に載る宣言として、束縛注釈(form 1)は値に載る契約(§17.4 の呼出時強制の wrapper)として、いずれもインライン注釈が無い位置の補完源になる(優先順位はインライン注釈 > 内部名注釈 > 契約)。
3 形とも呼出時に結果型を強制する: 内部名注釈が宣言する関数型注釈の結果型も、束縛注釈・body 末尾アスクリプションと対称に、呼び出しごとに実際の返り値を照合する(値の生成時点で結果型を wrap する。§17.4 の呼出時強制と同じ機構)。これにより 3 形は宣言位置が違うだけの、結果型についての同一の実行時契約になる。値が複数の関数型注釈の境界を通る場合、契約は §17.4 のとおり狭い方を残してまとめられるため、後から来た注釈が先の宣言を打ち消すことはない。
照合は全脱出経路に及ぶ: 宣言結果型は「関数がその呼び出しで返す値」の契約なので、末尾式に到達した値だけでなく return(および ? の失敗 bail、§16.5)で脱出する値も同じ結果型で照合する。body 末尾アスクリプション(form 3)はアスクリプションが末尾式の位置にしか無いが、照合は返り値境界で行うため早期 return も素通りしない — when cond { return Err(e) } の定型が結果型宣言を回避する抜け道にならない。素通しする位置は 3 形で共通(Any・型変数・Unit の結果型。§17.4)。
Ok(v) は Result(a, b) の b を確定させない(prelude.md §12)ため、成功側の構築だけからエラー型 E は決まらない。E を関数の契約に載せるには結果型を 3 形のいずれかで宣言する。早期 return で bail する関数では束縛注釈(form 1)が最も強い — form 3 の照合も返り値境界に及ぶが、form 1 は引数型も同じ呼出時強制の対象に入る。
17.4 型語彙・型定義・照合
型注釈で使う型は 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)としても使える - 多重度:
AtMostOnce(T)/ExactlyOnce(T)(使用回数の制約)/Borrowed(T)(借りた値)/Consuming(F)(受け手を消費するメソッド)。prelude 定義の型構築子で、照合は基底型(T/F)に落ちる(§17.8) - 効果:
Effect(T, …)(結果型と効果ラベルの集合)/EffectConduit(F)(引数の効果が透過する)と、効果ラベルIo/Fs/Net/Proc/Time/Rand/Mut/Susp。prelude 定義の型構築子と値で、照合は基底型に落ちる(§17.9) - 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)が指す)
OneOf の正規化: Never はどの値にも一致しないボトム型(§16.2)であり、union の恒等元である。OneOf(...) は選択肢から Never を除去した形に正規化する — 残り 1 本ならその型そのもの(OneOf(A) ≡ A と同じ退化)、0 本なら Never。したがって OneOf(Int, Never) は Int と同一の型であり、表示も照合も Int として振る舞う。分岐合流が Never を恒等元として扱う(発散アームは合流結果を変えない。static-analysis.md §3.3)のと対称の規則である。
型定義は 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 型(§17.2)、(A, B)=tuple 型、bare 識別子=型参照(§17.2)。角括弧は型位置に現れない(§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 位置は従来どおり検査する。
名前付きと位置は混ぜられない
function 型の slot-list は、すべて位置({ Int, String | Int })かすべて名前付き
({ x: Int, s: String | Int })のどちらかである。 混ぜた綴り({ x: Int, String | Int })は
静的検査が報告する(static-analysis.md §2.4)。
位置形が基本である。 関数の値 { params | body } の各 param を型で置き換えたものが関数型なので、
型の側に名前は要らない。高階の型(map: { List(a), { a | b } | List(b) } の内側 { a | b })には
そもそも与えるべき名前が無い。名前付きの綴りが書けるのは値と型で {…} の文法を共有しているためで、
関数型のパラメーター名は照合に参加しない — { x: Int | Int }・{ y: Int | Int }・{ Int | Int } は
同じ関数に一致する(§17.4 の照合は arity と型だけを見る)。名前は読み手への注記である。
混在を禁じるのは、混ぜると書いた順が保てないためである。位置エントリとスロットは別の列に載るので、
混在した綴りは位置エントリを先に並べた形になり、{ x: Int, String | Int } は「第 1 引数が String」の
関数型になってしまう。名前が照合に参加しない以上、混在を許して並び順の規則を読者に負わせるより、
書けなくする方が安い。混ぜない綴りは書いた順のままである。
17.4.1 型値の型 Type(T)
型は値である(§17.2)から、型値そのものも型を持つ。それが Type(T) — 「型 T を表す型値」の型である。
Int matches Type(Int) #> true Int matches Type(String) #> false (Int を表す型値は Type(String) ではない) 5 matches Type(Int) #> false (5 は型値ではない)
照合は型値の同一性で決まる。 v matches Type(T) は、v が型値であり、かつ v が表す型が T と同一(§17.4 の型の同一性)のときに真。部分型は見ない — Int matches Type(Any) は 偽である(Type(Any) は「型値 Any そのもの」を表す)。
型変数と組み合わせると型引数になる。 Type(a) の a が自由な型変数のとき、v matches Type(a) は「v が型値である」ことだけを要求し、a を v が表す型に束縛する。呼び出し単位の単一化(§17.2)がそのまま働くので、引数に渡した型を結果型へ運べる。
# 対象型を受け取り、その型の値を返す関数のシグネチャ decode: { String, Type(a) | Result(a) } cfg := decode(text, Config) # 結果型は Result(Config) に確定する
「型値を受け取るが結果型には結ばない」形も同じ書き方で表せる — a を結果で使わなければよい。
all_match := { vs: List(Any), ty: Type(a) | vs.all { v | v matches ty } }
無パラメーターの Type は持たない。 「何らかの型値」は上のとおり Type(a) で書けるため、別の綴りを増やす理由が無い。List(T) / Option(T) が型引数を必須にしているのとも揃う。
シグネチャに現れることに意味がある。 型を引数にとる関数は Type(a) を書くことで「この呼び出しは実行時に型値を要求する」ことを宣言する。注釈が Any だと、その意図はシグネチャから読めず、型でない値を渡しても静的には捕まらない。
値位置に置いた型値の静的型
型値を値位置に置けば、その位置の静的型は Type(T) である。 型は値である(§17.2)以上、型値を束縛した名前にも、引数に渡した型値にも型が付く。付く型が Type(T) であることは上の照合(Int matches Type(Int))の帰結にすぎない — 型値 Int が Type(Int) に一致するなら、Int を束縛した名前の静的型もそれである。
x := Int # x の静的型は Type(Int) ts := [Int, Int] # 要素の静的型は Type(Int)。リストは List(Type(Int))
合流も型値でない値と同じ規則に従う(§17.4)— 型値であることが例外を作るわけではない。したがって型値を混ぜた [Int, String] の要素型は Any へ合流し、注釈の無い束縛は implicit-any-binding になる(static-analysis.md §2.2)。これは [1, "a"] が同じ診断になるのと同じことで、要る型を書けば通る。
mixed: List(Any) := [Int, String]
注釈の位置では Type の層を剥がす。 T := Int と書いた後の x: T が指すのは Int であって Type(Int) ではない。「束縛の型」と「注釈の解決」は別の問いである — 前者は「T という値が何であるか」(型値なので Type(Int))を答え、後者は「x: T が何に解けるか」(T が表す型なので Int)を答える。型値を注釈に書くとはその表す型を指すことなので、剥がすのはこの 1 段だけである。
type Rec := { v: Int |} R := Rec # R の静的型は Type(Rec) take := { r: R | r.v } # 注釈 R は Rec に解ける(Type(Rec) ではない)
可変の型値束縛の束縛の型は Type(a) である。 mutable T := Int の T は後から別の型値を指しうるので、その名前を注釈に書いた位置は静的に解かず、実行時の照合が受け持つ(§6.1 の可変束縛。解いてしまうと差し替えた後の実行時の振る舞いと食い違い、T = String の後の x: T が Int を要求することになる)。注釈を解かないことと、束縛そのものに型が付かないことは別で、可変でも「T は型値である」ことは分かる。記録するのはその「何らかの型値」であり、綴りは上のとおり Type(a) である(無パラメーターの Type は持たない)。
f := {|
mutable T := Int # T の静的型は Type(a)
g := { x: T | x } # 注釈 T は静的に解かない(実行時に照合する)
_ := g 1
T = String # 型値の差し替え。Type(a) と単一化するので不安定ではない
g "s": String
}
type で宣言した名前はまだこの型を持たない。 type Rec := { v: Int |} と書いた Rec を値位置に置いた形(println Rec)の静的型は、今は Rec そのものであって Type(Rec) ではない。値束縛(R := Rec)との違いは実装上のもので、type 宣言の束縛は注釈を解く経路が複数あり、そのすべてが中身へ戻す必要があるためである。書き手から見える振る舞いは変わらない — どちらの綴りでも注釈は同じ型に解け、照合も同じである。差が出るのは値位置に置いたときに観測できる静的型だけである。
a が自由な型変数であることが、差し替えを通す根拠である。 可変位置の不変性(static-analysis.md §2.10)は確立した型と代入する型の照合で決まるので、Type(Int) を記録すると T = String が型の不安定として報告されてしまう。記録を Type(a) に留めれば、可変位置の規則に Type だけの例外を彫らずに差し替えが通る — 例外を彫る側を採ると「注釈は実行時に照合する」という上の定めと食い違い、同じ判断が 2 か所に分かれる。
17.5 構造的 interface(メソッド契約)
structural 型 { … |} は、データ契約(x: 型)とメソッド契約(bare 名)を同じ器で表す。メソッド中心のものを interface と呼ぶ。Comparable・Copyable はこの仕組みの上に prelude 定義された値であり、組込の特別な型ではない(§9.4 / §9.5 / prelude.md §13・§15)。ユーザーも同じ構文で定義できる。
エントリ(§17.2 の型位置 { … |}):
- メソッドフィールド
m(bare 小文字): 「値がmに応答する」ことを要求する(callable であればよい)。シグネチャまで契約するならm: {In | Out}(型付きフィールドの型に関数型を据える形。§17.4 の宣言シグネチャ照合で引数数・引数型・結果型〔body 末尾アスクリプション由来〕まで見る)。baremは応答のみ、m: 関数型はシグネチャまで。 - 型付きフィールド
x: 型(named): 現行 record shape と同じデータ契約(余分な slot 可・幅構造型)。型が関数型なら上記のメソッドシグネチャ契約になる(メソッドフィールドの上位互換で、構文は非破壊)。 - 埋め込み
Name(bare 大文字):Nameが指す structural 型の全エントリを取り込む。構築時に展開する。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)は、値が自前スロットを持たなくても組込ディスパッチで応答する(=ベースライン)。ベースラインの範囲:
| method | ベースラインで応答する値 |
|---|---|
compare |
原始型 Int/Float/String/Bool/Unit(§9.4) |
copy |
Copyable な値(Future / 非 Copyable な opaque 以外。§9.5) |
apply_when / inspect / matches / reference_equals / -> |
全値 |
ベースラインを持つ名前と、メソッドフィールドに書ける名前は一致しない。 メソッドフィールドは bare 小文字の識別子だけなので(§17.2)、記号綴りの -> を要求する interface({ -> |})は書けず構文エラーになる。-> のベースラインは §2.5 の universal method としての解決 — v.->(x) の名前解決と、スロットを持たない受け手での中置 — にだけ現れる。
ユーザー定義メソッド(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 | "s": String } |} matches Adder #> false (add が宣言する結果型が String。不一致) { add := { x: Int | "s" } |} matches Adder #> true (結果型を宣言しない関数は素通しする。§17.4「関数型の照合の深さ」)
結果型は宣言したときだけ照合する。 上の最後の 2 行の差はそこにある — 照合が見るのは body 末尾アスクリプション(§17.3 form 3)が宣言した結果型であって、実際に返る値の型ではない(§17.4「関数型の照合の深さ」の漸進的型付け)。宣言しない関数を素通しで通したうえで、実際の返り値は呼出時強制(§17.4)が境界で照合する。
帰結: {compare |} は現行 Comparable、{copy |} は現行 Copyable と同値(ベースラインの差から、{copy|} は copy:=5 を持つ object も一致し〔Copyable ベースラインが優先〕、{compare|} は非原始型 object の compare:=5 を弾く)。{match |} / {inspect |} などベースラインが全値のメソッドで作る interface は全値に一致する(Any 相当。意味は薄いが一貫)。同様に、要求の無い空 interface {|} は全値に一致する(Any 相当の top 型)。非 universal 名のみの型付きフィールド {x: Int |} は現行 record shape と完全同一(後方互換)。
メソッドシグネチャ契約: m: {In | Out} は型付きフィールドの一種であり、上の照合規則(型付きフィールド x: 型)にそのまま従う — m にベースラインがあり v に効けば型検査をスキップ(ベースライン優先。compare: {a, a | Ordering} は原始型に対しベースラインで一致)、無ければ v の自前スロット m が関数型 {In | Out} に一致するかを §17.4 の宣言シグネチャ照合で見る(引数数・引数型・結果型 Out〔body 末尾アスクリプション由来〕まで。無注釈引数・結果型を宣言しないメソッドは素通し)。
Sequence — 要素の並びを持つ契約
Sequence は整数添字で引ける要素の並びを持つことを表す prelude 定義の interface である。 Comparable / Copyable と同じ仕組みの上に立ち、組込の特別な型ではない。
実装するのは List (§7.4)・タプル (§7.3)・String (§7.2)・Bytes (§7.2.2)・range (§7.6) の 5 つで、オブジェクトは実装しない — オブジェクトが持つのは名前で引くスロットだけだからである (§2.1)。
| method | 意味 |
|---|---|
length / is_empty |
要素数。is_empty は length! == 0 と定義される (独立した測度ではない) |
recv.N / recv.[i] |
添字で引く (§3.2 / §3.10)。範囲外は実行時エラー |
positions / has_position |
添字の列挙と存在確認 |
first / last |
先頭・末尾の要素 |
each / map / filter / fold |
走査。map / filter は List を返す |
find / any / all / count |
述語による検索・判定・計数 |
to_list |
List への実体化 |
各 method の形と戻り値は prelude.md §6 に集約する。
タプルは一部だけを持つ — t.N と length のみで、is_empty は持たない (唯一の空値が () であり判定に意味が無いため。prelude.md §7)。したがってタプルは { length |} には一致するが Sequence の全体には一致しない。
要素はいずれも不変である。 recv.N = v / recv.[i] = v は実装に依らず実行時エラーになる (§3.10)。任意添字への O(1) 散在書き込みが要る場合は std:array (std/array.md) を使う。
Sequence は分解と照合の対象でもある。 タプル destructure a, b := v が右辺から取るのは Sequence の要素であり (§6.3)、Sequence でない値を右辺に置くのは実行時エラーである。
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)。
静的検査: 封印契約型に確定した受信者への契約外アクセス(importer 側)と、封印契約に流れる値の契約外・未使用スロット(定義側)の診断は static-analysis.md §2 の契約系診断として実装する。
メンバー集合が閉じること自体は封印契約に固有ではない。 宣言した型のメンバー集合が受け手から触れる範囲になるのは、注釈の位置を問わない一般の規則である(§17.2)。封印契約が足すのは次の 3 つで、いずれも export { name: T } が持つ「値が型 T としてのみファイル外へ出る」という構文的な保証に依る — 実行時封印(下記。契約外スロットを実行時にも到達不能にする)・定義側の検査(契約外・未使用スロット)・契約外が importer から到達不能であることである。したがって受信者側の診断が別コード(sealed-contract-violation)で立つのは、判定が違うからではなく強制の水準が違うからである。
実行時の封印: 契約型が record 型(データオブジェクト)に確定し、かつ束縛の右辺がそのファイルのオブジェクトリテラル(name := { … |})である export { name: T } は、評価後の値の公開スロット集合を契約のメンバー集合へその場で絞る。契約外のスロットは実行時にも到達不能になり、Any を経由したアクセスからも読み書きできない。封印は新しい値を作らず既存の値に適用されるため、値の identity は保たれる(値を複製しない既定の意味論と両立する)。スロット表そのものは壊さない — 封印はメンバー解決のゲートであって削除ではない。
対象はそのファイルが作った値に限る: 右辺が変数参照・メンバーアクセス(re := m.pub)・呼び出し結果のときは実行時封印を行わない。封印は値を書き換える操作なので、他ファイル由来の値に適用すると同じ値を掴む別のファイルからの見え方まで変わってしまう(その値を直接 import した第三者のコードが壊れる)。リテラルの評価結果はそのファイルが作った新しい値であり、封印を据えた時点で他ファイルが掴んでいることはない。出自を静的に辿れない右辺は保守側に倒して封印せず、その契約は静的検査のみの強制に留まる。
契約型が関数型のとき(export { make: { Int | { balance: Int |} } })は、封印すべきは make 自身ではなくその適用結果であり、実行時封印の対象外とする。据える場所が無いからではない — 実行系は返り値の境界を持っており(§17.3 の呼出時強制が使うのと同じ境界)、そこで封印しても値を作り直さないので identity は保たれる。満たせないのは上記の「そのファイルが作った値に限る」方である。export 束縛の地点では右辺がそのファイルのリテラルかを見て決められるが、返り値境界では返ってきた値の出自が分からない — 契約付きの関数は呼び手から受け取った値をそのまま返せる(pick := { r: { balance: Int |} | r })ので、そこで封印すると呼び手の値を書き換え、同じ値を掴む第三者のコードが壊れる。この場合の結果 record の契約外スロットは、上記の静的検査が引き続き担う。
後方互換: 型注釈の無い export { name } や export 自体が無いファイルは、封印契約の対象にならず従来どおり gradual に振る舞う。
定義側の検査は export に限らない: 「宣言メンバー集合の外は契約外」という構造自体は、任意の型注釈束縛 name: T := 値 が持つ(§17.2 — 受信者側はそちらの一般の規則が閉じる)。定義側の検査もそこへ及ぶ。ただし export { name: T } は値が必ず型 T としてのみファイル外へ出るため契約外が importer から到達不能と構文的に保証されるのに対し、非 export の型注釈束縛にはその保証が無い。値が型注釈の無い(Any)位置を経由してファイル外へ渡ると、契約外のスロットへ他ファイルからアクセスされる可能性を排除できない。そのため、契約外・未使用サブスロットの静的検出(static-analysis.md §2.7「封印契約の契約外・未使用スロット(定義側)」)は、非 export の型注釈束縛については値がバラの値として外へ渡らないことが構文的に確実な束縛(エスケープ安全な束縛)に限る。エスケープ安全条件を満たさない束縛は検出対象から外れるが、値そのものの意味論(型注釈の実行時照合・スロット構成)には影響しない。
構造的 interface は型述語(v matches I / x: I)としても、型変数の境界(a: I。interface 境界、§17.2)としても使える — いずれも同じ Matches/照合規則を用いる。境界に置くときは「I を満たす単一の型で、同一シグネチャ内の全 a はその同じ型を共有する」ことを表明し、閉じた union を境界とする type set 境界(a: Number)と同じ機構(§17.2)を一般化したものになる。
17.7 refinement 型(述語で絞った型)
refinement 型は基底型を述語で絞った型である。「正の整数」「空でない文字列」のようなドメイン不変条件を型として書き下し、境界(束縛・パラメーター・結果型)で契約として強制する。
type Pos := refinement { v: Int | v > 0 } type NonEmpty := refinement { s: String | s.length! > 0 } type Percent := refinement { n: Int | n >= 0 && n <= 100 }
述語はスロットの中の場所(名前フィールドで辿れる位置)も見られるので、フィールド同士の関係も型として書ける。
type Form := { minLength: Int, name: String |} type Valid := refinement { f: Form | f.name.length! >= f.minLength }
「型は値(述語)である」(§17 冒頭・§17.2)という建て付けに対し、refinement 型はその述語を書き下したものにあたる。§2 の { slot-list | body } 一形を曲げる箇所は無い。
refinement 型の構文
refinement / refinement_opaque は後続リテラルの読み方を変える前置予約語であり、conduit{...} / loop{...} と同じ枠に属する(§1.4)。形の固定は §1.4 のとおりで、要点は次の 2 つである。
- 中身はちょうど 1 スロットで、型注釈が必須(その型が基底型になる)。body も必須で、述語となる Bool 式を書く。
conduit/loopとの併記は構文エラー。
slot-list は型コンテキスト、body は値コンテキストで読む。v: Int は型注釈、v > 0 は普通の Bool 式である。型位置の {} が body を結果型として読む規則(§17.2)とは前置語で分岐するため、関数型 {Int | Int} との曖昧さは生じない。
述語は自由変数を持たない — 参照できるのは自スロット名だけである。外側スコープの名前を参照する述語は、型値を構築する地点で型エラーとする(型値が定義環境に依存すると、type の透過性と表示の同一性〔§17.4〕が壊れるため)。
正準表示は refinement { v: Int | v > 0 } の形。type で名付けた型は §17.4 の別名保持の規則どおり Pos と表示する。型等価は「基底型が等しく、述語の原文が一致し、かつ refinement / refinement_opaque の別が同じ」ことで定める — 綴りの違う同値な述語(v > 0 と v >= 1)は等価としない(保守側。等価判定に論理を持ち込まない)。不透明性を含めるのは、下記のとおり実行時の振る舞いが同じでも静的検査が引き受ける証明義務が違うためである(同じ型なら義務も同じでなければならない)。述語の論理を見るのは等価判定の外側で、§17.4 の契約の畳み込み・下記の分岐合流・フロー絞り込み(static-analysis.md §2.3)・静的検査の証明義務(同 §2.11「境界の証明義務」)がそれにあたる。
refinement 型の実行時照合
照合が走る地点は増えない — 次の 7 つという既存の地点をそのまま使う(§17 冒頭・§17.4)。static-analysis.md §2.11「境界の証明義務」 が証明義務を課す境界はこのうち式アスクリプションを除く 6 つである(アスクリプションは同規則のオプトインの書式にあたるため義務を課さない。ただし述語を満たしえないと証明できる値はそこでも報告する)。
- 束縛注釈(
x: T := v/x: T = v) - 分解束縛(位置・名前)
- パラメーター束縛(実引数とデフォルトの両方)
- スロットのデフォルト確定
- 式アスクリプション(
式: T) - 関数型注釈の呼出時強制(実引数と宣言結果型。§17.3)
enumの payload 構築(§17.4「構築時に payload 型を検査」)
一致の条件は §17.4 の照合表のとおり「値が基底型に一致し、かつ述語がその値に対し true を返す」ことである。
- 不一致は既存の注釈違反と同じ型不一致 panic(§16.2 のバグ層)。
- 述語が Bool 以外を返した場合は §9.3 の Bool 厳格に従い panic する。
- 述語の評価中に panic した場合はそのまま伝播する。
matches(§17.4)はそのまま通る — refinement 型値は特別扱いされない普通の型値なので、5 matches Posはtrue、0 matches Posはfalseになる。
refinement_opaque の実行時の振る舞いは refinement と同一である(差は下記「決定可能断片」の扱いだけ)。
証明できた注釈位置の照合は消える。 上記 1・4(束縛注釈・スロットのデフォルト確定)の位置で述語の充足を実行前に証明できたときは、照合そのものを生成しない(§17 冒頭)。消えるのは決定可能断片内の述語を証明できた位置だけなので、refinement_opaque と断片外の述語では常に残り、消去によって述語の評価に伴う副作用が消えることもない。消える位置の集合は判定手続きの強さ(外部 SMT solver の有無。check.md)に依るが、消すのは成功が証明済みの照合だけなので、構成が変わっても観測できる意味論は変わらない。
refinement 型の部分型と契約の畳み込み
refinement 型はその基底型の部分型である。x: Int := p(p: Pos)は通り、逆向き(Int から Pos へ)は述語の充足を要する。
§17.4 の契約の畳み込みでは、同一基底の refinement 同士は述語の論理積を取って 1 つの契約にまとめる。refinement{v: Int | v > 0} と refinement{v: Int | v < 100} が重なれば、両方を検査する 1 つの契約になる。論理積は常に元の 2 つより狭いため「境界を通るたび契約は狭まるだけで、決して緩まない」という不変条件が自動的に保たれ、狭さを比較できずに型不一致へ落ちる場合が refinement 同士では生じない。基底型が異なる refinement 同士、および refinement と非 refinement の組は、従来どおり基底型の比較に従う。
分岐合流(if / match のアームやリスト要素の合成。static-analysis.md §3.4)は畳み込みと逆向きで、両方の上位型を採る。型等価な組(上記のとおり原文が一致する組。refinement_opaque 同士を含む)はその型のまま合流する。それ以外の同一基底の refinement 同士では、片方の述語がもう片方を含意することを下記の決定可能断片の中で証明できたとき、含意される側(広い方)へ合流する — refinement{v: Int | v > 100} と refinement{v: Int | v > 0} の合流は後者になる。どちらの向きも証明できない組・基底型が異なる組・断片外(refinement_opaque)を含む異なる組は基底型へ持ち上げる(述語の選言は型として表せない)。含意の判定は過小近似(証明できたときだけ真)なので、分からない組は必ず基底型へ倒れ、合流が実際より狭い型を作ることはない。
互いに含意する(論理的に同値な)組では、どちらも上位型として正しいが、合流結果は枝の順に依らず 1 つに決める — 綴りが違えば述語の論理式の形が違い、含意の判定手続きはその形に敏感だからである(同値でも節数が違えば判定の打ち切りにかかるかどうかが変わり、枝を入れ替えると検査の結果が変わってしまう)。規則は「下流の証明が判定手続きの上限に当たりにくい、安い方の前提を残す」であり、選言標準形の節数が少ない方、同数なら原子数が少ない方、それも同数なら正準形の辞書順で決める。
述語の決定可能断片
述語に書ける式のうち、充足を実行前に判定できる範囲を決定可能断片と呼ぶ。断片は次の形だけを許すホワイトリストであり、下に挙げていない式はすべて断片外である。
- 変数と場所: 述語スロット名、およびそこから名前フィールドのアクセスだけで辿れる場所(
f.minLength・f.name・f.inner.count)。深さに上限は無い。各場所は基底型がその位置に宣言した型に応じて Int・Bool・String・Float のいずれかの領域を持ち、場所ごとに独立した未解釈の定数として扱う(同じ場所は同じ定数、違う場所は違う定数)。レコードのフィールドは互いに独立した場所なので、これは健全である。基底型がその位置の構造を宣言していない場合 — 型変数・Any・フィールド以外のアクセス(要素アクセスxs.0/t.1)— はその式ごと断片外である。基底型が Bool のとき、変数そのものが述語本体になってよい(refinement { b: Bool | b })— これはb == trueと同じ制約を表す。書き分けで断片の内外が割れないよう、両方の綴りを断片内とする。Bool の場所そのものを述語本体に置く形(f.on)も同じ扱いである。
場所を証明に使うには、述語側(断片・判定手続き)と材料側(static-analysis.md §2.11「境界の証明義務」 の「レコードの場所の材料」)の両方が要る。断片だけを広げると診断が「断片外」1 件から境界ごとの「証明できない」へ移り、書き手に直しようが無くなるためである。
断片の定義は構文と基底型の構造で決まる。構文だけでは決まらない点は次の 4 つで、いずれも下の項目で明示する。
- 裸のスロット変数が述語本体になれるのは基底型が
Bool(または場所の型がBool)のときだけ。 - String のメソッド述語(
contains/starts_with/ends_with)が部分列・接頭辞・接尾辞を意味するのは基底型がStringのときだけ。 - 組込 measure(
length!/is_empty!)が断片内なのは基底型が長さを持つときだけ。 - フィールドパスの領域(Int / Bool / String / Float)は基底型がその位置に宣言した型で決まる。
構造を解決できない位置は断片外へ倒す(安全側)。断片内と誤って受けると、その場所を誤った領域の項として解釈し、偽の証明を作りうるためである。
- リテラル: 整数(算術と比較の両方に置ける)/真偽値・文字列・Float(比較の項としてのみ置ける)。数値リテラルは符号を問わない —
-1.5のような負のリテラルは単項マイナスをまとめて 1 つの定数として扱う(-0.0と0.0は §9.4 の全順序では別の値なので区別する)。 - Float の定数式: 項がすべて Float リテラルの算術(
+/-/*//と単項マイナス)は 1 つの定数へまとめて比較の項に置ける(v > 1.0 + 2.0はv > 3.0と同じ制約)。畳み込みは IEEE754 binary64・最近接偶数丸め(§7.1)で行うので実行時の値とビット単位で一致する。変数を含む Float 算術は断片外(下記「断片の外にあるもの」)。ゼロ除算は実行時エラー(§7.1)で値が定まらないため断片外。 - 算術:
+/-、定数倍の*、定数除数の/と%。項は変数・length!・整数リテラル、およびそれらから/%で作った商と剰余に限る —length!は「0以上という公理を持つ、変数とは別のもう 1 つの項」として算術に混ぜてよい(例:s.length! - 1 >= 0)。/と%の詳細は次の 2 項で定める。係数・定数の大きさに上限は無い(Intは任意精度である。§7.1)。 - 除算と剰余の形:
/と%の除数は0でない整数定数に限る(変数を除数に置くと非線形になるため断片外。0除算は値が定まらないため断片外)。被除数は算術の項からなる任意の式でよく、(v + 1) % 3 == 0のような複合式もv / 2 / 3のような入れ子も断片内である。商と剰余は普通の項なので比較演算子 6 種すべてと自由に組み合わせられ(v % 5 < 2は断片内)、剰余の値に範囲の制限は無い。 - 剰余と商の符号: Hikari の
/と%はゼロ方向への切り捨てに基づく(§7.1)ため、剰余は被除数と同符号で絶対値が除数未満になる。したがってv % 3 == 1はvが1, 4, 7, …のいずれかであることを意味し(v >= 0も同時に成り立つので-2を含まない)、v % 3 == -1は-1, -4, -7, …を意味する。v % 3 != 1はその論理否定で-2のような負の値も含み、.not()による否定((v % 3 == 1).not())も同じ制約になる。除数の符号はe / -kが-(e / k)、e % -kがe % kに等しい(剰余の符号は被除数だけで決まる)。 - 比較:
</<=/>/>=/==/!=。ただし順序比較 (<<=>>=) が断片内なのは Int・Float・length!同士の比較のみ — String と Bool の順序比較(辞書式・真偽の順序)は断片外とし、等価比較 (==!=) のみ許す。Float は比較のみ断片内(算術には出せない。上のリテラルの節参照)。 - Bool 結合:
&&/||/.not() - 組込 measure:
length!(String・List・Bytes)— 未解釈関数として扱い、「長さは 0 以上」だけを公理として持つ。断片内なのは対象が長さを持つ型のときだけである —length!を持たない型(Intなど)に書いたv.length!は断片外になる。この公理を型を見ずに与えるとv.length! >= 0が恒真として導けてしまい、実行時にはメソッドが無くて panic する述語を「証明できた」ことにしてしまう。measure は場所に対して取れる(f.name.length!は場所f.nameの長さ)。場所ごとに独立した未解釈の項になり、それぞれが「0 以上」の公理を持つ - String のメソッド述語:
contains/starts_with/ends_withを、引数が文字列リテラルのときに限り断片内とする(s.contains("@"))。意味は prelude.md のとおりで、containsは連続部分列、starts_with/ends_withは先頭・末尾の一致(いずれも rune 単位)。この 3 つが断片内なのは基底型がStringのときだけである — 同名のメソッドはList(要素の等値)・Bytes(部分バイト列)・Range(算術判定)にもあり意味が違うので、基底型を見ずに解釈すると誤った推論になる。引数が空文字列の形(s.contains(""))は恒真としてまとめる。 is_empty!: 断片内に書けるが、独立した未解釈関数としては持たない。is_empty!はlength! == 0と同値である(prelude.md)ため、断片ではlength! == 0へ正規化して扱う。これによりs.length! > 0とs.is_empty!.not()は同じ制約として相互に証明できる。
断片外の代表例は、ユーザー関数の呼び出し・上に挙げていない組込メソッド(s.split("@") など)・要素アクセス(xs.0 / t.1)・非線形項(変数同士の積 v * v、変数除数の v / v や 3 / v)・0 除算・変数を含む Float 算術・String や Bool への順序比較・累乗である。ここに挙げていない形もすべて断片外であることに注意する。
判定手続きと完全性: 処理系は断片内の述語について健全な判定手続きを持つ — 「証明できた」と答えたものは必ず真である。一方、手続きは完全である必要はない: 判定できないものは常に「証明できない」へ倒す。したがって「断片内であること」は「証明できること」を保証しない。判定は必ず停止する — 述語に現れる項(変数・場所・measure・商と剰余)は構文で有限に決まり、いずれも決定可能な理論に収まるからである。判定手続きの強さは処理系の構成に依り、外部 SMT solver を有効にした構成(check.md・build.md の --smt)では導ける範囲が広がる。断片の内外そのものは構文と基底型の構造で決まるため、構成に依らない。
断片を越える述語は refinement_opaque と明示して書く。
type Domain := refinement_opaque { s: String | s.split("@").length! == 2 }
opaque を書くこと自体が「ここは実行時検査に委ねる」という書き手の表明であり、静的検査はこれを証明対象外の gradual 境界として素通しする(static-analysis.md §2.11「境界の証明義務」)。この位置では実行時照合が述語を実際に走らせて逸脱を捕捉する。逆に refinement(opaque なし)の述語が断片外の式を含む場合は静的検査が診断する(同 §2.11「述語の断片適合」)。
断片の外にあるもの
次は決定可能断片に入らない。実装が進んでも入らないもので、理由は 2 つに分かれる —
最初の 1 つは載せないと決めたもの(依存型化しない)、残りは判定手続きの側の限界である。
- 値同士の関係(
refinement { v: Int | v > other })— 述語は自由変数を持たないため書けない。値を型に載せる依存型化は行わない。refinement_opaqueでも書けない — opaque は判定を実行時へ送る綴りであって、閉じていない述語を許す綴りではない。どちらの綴りでも静的検査が自由変数を名指して報告する(refinement-free-variable。static-analysis.md §2.11)。 - 変数を含む Float 算術の述語(
v + 1.0 > 2.0)—refinement_opaqueまたは式アスクリプション経由でのみ書ける。定数だけの Float 算術は断片内である(上記)。
変数を含む形を断片へ入れるには浮動小数点そのものの理論が要る。Hikari の Float は==がビット等価で順序が全順序(§9.1 / §9.4)なので IEEE の述語とはずれ、順序を保つ整数キーへの写像は加法・乗法の準同型ではない(key(a+b)はkey(a)とkey(b)から決まらない)ため、比較で使っているキー変換を算術へ広げることはできない。判定手続きの側にも正しい丸め方向つきの浮動小数点区間演算が要る。実装が済むまでこの形は断片外に留め置く。 - 非線形の整数演算(変数同士の積
v * v、変数を除数に置くv / vや3 / v、累乗)— 非線形整数算術は決定不能なので、断片に入れると「判定は必ず停止する」という断片の建て付けが崩れる。この形はrefinement_opaqueに書くか、式アスクリプション(式: T)で実行時検査に委ねる。
将来枠
次は入れられる余地があるが、今は持たない。
- 要素アクセスを含む述語 — List・Tuple・String の要素アクセス(
refinement { xs: List(Int) | xs.0 > 0 }・refinement { t: (Int, Int) | t.0 < t.1 })は断片外である。名前フィールドのアクセス(f.minLength)は断片内の場所として扱う(上記ホワイトリスト)が、添字は場所にしない。これは上記「値同士の関係」とは別の理由である —xs.0は述語スロットxsの内側にあるので、自由変数も依存型も含まない。書きたい場合はrefinement_opaqueに置く(実行時照合は述語をそのまま走らせるので、制約は契約として効く)。
要素アクセスを断片へ入れるには、添字がlength!の範囲に収まることを結ぶ公理が要る(さもないと「その位置に要素がある」ことを前提にした推論が、実行時に panic する述語に対しても通ってしまう)。変数を添字に置く形では列の理論そのものが必要で、これは一般には決定不能なので「判定は必ず停止する」という断片の建て付けと当たる。定数添字に限れば入れられるが、下記の panic の扱いを決める必要がある。
要素アクセスをrefinement_opaqueに書くときは、述語自身が panic しうる点に注意する。範囲外の添字は型不一致ではなく添字の panic になり(上記「実行時照合」の「述語の評価中に panic した場合はそのまま伝播する」)、報告位置も境界ではなく述語の位置になる。refinement_opaque { xs: List(Int) | xs.is_empty!.not() && xs.0 > 0 }のように長さを先に確かめる形へ書けば、&&の短絡により不一致は通常の型不一致 panic として報告される。 - ループ不変条件の推論 — 断片内であっても不変条件は推論せず、注釈された境界だけを検査する。
- prelude / stdlib のシグネチャへの refinement 付与 — 付けていない。添字のような主要な用途では付けたい制約が型として書けない —
std:arrayのget/setの添字に要るのは0 <= i < lengthだが、上限は受け手の長さとの関係であり上記「値同士の関係」にあたる。表現できる下限(i >= 0)だけを付けても範囲外は防げない(範囲外は実行時にArray.get: index out of rangeで落ちる)。付けるとしても、既存の呼び出し全体へ証明義務が及ぶので移行手段を同時に決める必要がある。
言語の要素アクセス(§3.2 / §3.10)は型ではなく証明義務で守る。 関係は型として書けないが、証明義務の側には「述語は 1 スロットに閉じる」という要請が無い — 判定手続きは項を場所ごとに独立した定数として扱うので、別々の名前に根ざした項を同じ論理式に載せられる。範囲の材料はフロー絞り込みが関係の事実として運ぶ(static-analysis.md §2.3・§2.11「添字の範囲義務」)。std:array のような関数のシグネチャは宣言型で語る以上この道に乗らないので、上記のとおり付けていない。
- 注釈位置以外の照合の除去(最適化)— 消えるのは束縛注釈・スロットのデフォルト確定・証明の届いた呼び出しのパラメーター束縛(上記「パラメーター束縛の消去は呼び出しごとに決まる」)で、残る 4 地点の照合は述語を証明できても走る。分解束縛は要素の値式を材料に結び付ける(右辺がタプルリテラルで個数が合う形)が、証明の成否を照合の消去へ配線していない。関数型注釈の呼出時強制は照合ではなく契約であり、その地点ではなく将来の呼び出しを守るので消せない。式アスクリプションは証明義務のオプトインの書式(static-analysis.md §2.11)なので、そこに引ける証明が無い。
enumの payload 構築は、宣言型の内側へ降りるときに対応する値式を運ばないため証明を結びつける対を作れない。内側の位置(List(Pos)の要素・タプルの要素・record のフィールド)は値が対応するリテラルなら消える(設定表のように書き下した定数)が、値が呼び出しなどリテラルでない位置は対を作れないので残る。
17.8 多重度型(使用回数を載せた型)
本節が定める型は、値を記述する型ではなく使用を制約する型である。Hikari の型が担う意味をここで一段広げる。 これまで型はすべて値について語ってきた — Int は値の種類を、refinement 型(§17.7)は値が満たす述語を、構造的 interface(§17.5)は値が応答するメソッドを述べる。本節の型はそうではない。同じファイルハンドルが、ある位置では所有され、ある位置では借用される。値は何も変わっていない。
語の範囲を先に定める。多重度型とは AtMostOnce(T) / ExactlyOnce(T) の 2 つを指す — 値をこれから何回使えるかを述べる型である。Borrowed(T) と Consuming(F) はそれに付随する語彙で、前者は多重度型の値を借りた位置を、後者は受け手を消費するメソッドを表す。この 2 つは多重度そのものを述べるわけではないので、Borrowed(Borrowed(T)) のような重ね掛けには意味が無い。
多重度型構築子(AtMostOnce / ExactlyOnce / Borrowed / Consuming)の基底に、同じ構築子をその場で重ねて書くことは、宣言の時点で型エラーとする。 重ね掛けに意味が無い以上、黙って受け取ると「書いたのに効かない」位置ができる。判定は書かれた位置の構文だけで決まり、基底が解決できない位置(型変数・解決できない名前)では報告しない。
別名を経由した重ね掛けは報告しない。 type B := Borrowed(Int) と書いてから Borrowed(B) と書く形がこれにあたる。判定が構文だけで決まる以上の当然の帰結で、見逃し側である。名前を辿って判定する形にはしない — 辿ると次の Borrowed(M) の正規の書式(M が多重度型の別名である形)と区別が付かなくなり、正しいコードを誤検出する。重ね掛けは「書いたのに効かない」だけで安全性には影響しないので、誤検出を避ける側に倒している。
Borrowed(M)(M が多重度型)は重ね掛けではなく借用の正規の書式である。 多重度型の値を借りた位置を表すのが Borrowed の役目なので、基底が多重度型であることは当然である。type H := ExactlyOnce({ … |}) に対する Borrowed(H) も、std のハンドル型に対する Borrowed(arr.Array) も、どちらもこの形にあたる。上の禁止が対象にするのは、その場で構築子を 2 つ並べて書いた Borrowed(Borrowed(T)) や AtMostOnce(ExactlyOnce(T)) である。
上の性格から次の 2 つが帰結として導かれる(下記「多重度の実行時照合」)。いずれも個別の例外規定ではない。
- 実行時に照合するものが無い — その地点に存在するのは値だけで、使用の履歴ではないため。
matchesは基底型の判定に落ちる — 同じ理由による。
内在と文脈で性格が分かれる点も定めておく。AtMostOnce / ExactlyOnce は資源に内在する(ファイル記述子は本当に一度しか閉じられない)。Borrowed は文脈依存であり、値ではなくその位置で許された操作を述べる。
この 4 つはいずれも prelude が束縛する型構築子であり、新しい予約語も新しい記号も持たない(§1.2.2 / §1.4 の予約語は増えない)。Comparable / Copyable が組込の特別な型ではなく prelude 定義の構造型である(§17.5)のと同じ位置づけで、書式は List(T) / Option(T) と同じ型構築子の適用に乗る。本節は型語彙・部分型関係・複製と別フローへの捕獲・実行時の扱いを定める。どの操作が値を消費し、消費済みの値への出現や未消費をどう診断するかはフロー解析の管轄で、static-analysis.md が定める。
多重度の型語彙
| 型 | 意味 |
|---|---|
AtMostOnce(T) |
高々 1 回。使い残しは咎めない |
ExactlyOnce(T) |
ちょうど 1 回。未消費を診断する |
Borrowed(T) |
借用。消費できない |
Consuming(F) |
F は関数型。このメソッドはレシーバーを消費する |
AtMostOnce(T) / ExactlyOnce(T) / Borrowed(T) の T と、Consuming(F) の F を基底型と呼ぶ(refinement 型で述語が絞る前の型を基底型と呼ぶ〔§17.7〕のと同じ用法)。
これらの型構築子は基底型を包む器ではない。 値の構造は基底型のままで、AtMostOnce(T) の値には T のメンバー・メソッドがそのまま生えており、取り出し(unwrap)は要らない。静的なメンバー解決と結果型の推論も同じで、多重度の層を基底へ降りてから行う — h: AtMostOnce(R) の h.peek() は R のスロット peek の宣言型で解け、R に無い名前は素の record と同じく no-such-member になる(static-analysis.md §2)。変わるのはその値に課される使用の規律だけである。ただし基底型と同一の型ではない — refinement 型が基底型の部分型であるのに対し(§17.7)、多重度型は基底型との間にどちら向きの部分型関係も持たない。もっとも、型の照合はこの違いを見ない — 注釈・引数・結果のどの位置でも多重度の層を剥がし、基底同士で照合する(refinement 型を基底で照合するのと同じ形。§17.7)。多重度は値に内在せずその位置の宣言が課すものなので、照合すべき差が型の側に無いためである。多重度の付与は宣言が行う — ある値をこれから何回使えるかは、その値を持つ名前の宣言型が決める。
照合が多重度を見ない以上、型の側だけでは多重度型の値を多重度を持たない宣言型のパラメーターへ渡す形が通ってしまう。この向きはフロー解析が禁じる(static-analysis.md §2.14 の pass-of-multiplicity-value)— 渡した先の名前が追跡対象にならず、そこで規律が消えるためである。逆向き(多重度を持たない値を多重度型の位置へ渡す形)は受け側の規律が強まるだけなので禁じない。多重度型同士の緩和は下記「多重度型の部分型関係」が定める。
Consuming(F) は §17.5 のメソッドシグネチャ契約 m: {In | Out} の位置にそのまま入るので、既存の書式の内側に収まる。
type File := AtMostOnce({ read_at: { Int, Int | Bytes }, sync: {| Unit }, close: Consuming({| Unit }), |}) type Txn := ExactlyOnce({ exec: { String | Unit }, commit: Consuming({| Unit }), rollback: Consuming({| Unit }), |})
態が 2 種類あるのは、注釈する対象が違うためである。Consuming(F) はメソッド(動作する側)に付くので現在分詞、Borrowed(T) は値(動作される側)に付くので過去分詞になる。この読みが効くのは結果型位置で、set: { Int, Any | Borrowed(T) } の Borrowed(T) は「借りられている T の値」であって「この位置が何かを借りる」ではない。
Consuming(F) を書けるのは多重度型のスロットに限る。多重度を持たない型に書いても消費される先が無くマークに意味が無いため、宣言の時点で型エラーとする(黙って無視すると「マークを書いたのに効かない」位置ができる)。
位置は構文で決める — Consuming(F) を書けるのは、多重度型構築子(AtMostOnce / ExactlyOnce)の直接の基底として書いた record のスロットと、同じ位置へ型変数を書いたときそこへその場で添えた境界 record のスロットだけである。 上の File / Txn が前者、下記「基底の型変数へ境界を付ける」が後者で、それ以外の位置(束縛やパラメーターの注釈・タプルの要素・関数型のパラメーターと結果・enum の variant payload・多重度型構築子以外の型構築子の引数)はすべて型エラーになる。
Borrowed(T) には同じ制限を掛けない。Consuming(F) は「レシーバーを消費する」マークなので、消費される先である多重度型が無ければ指す対象が無く、宣言の時点で意味が定まらない。Borrowed(T) が述べるのは「この位置の値は保存できない」であり、基底が何であれ指す対象がある。基底が多重度型でない Borrowed(T) は、消費の禁止が空振りするだけで保存の禁止はそのまま効く。
名前を経由して包む形(type B := { m: Consuming(F) |} の後に type T := ExactlyOnce(B))も書けない。 型は export できるので、B の宣言だけを見ても他ファイルが ExactlyOnce(m.B) と包むかどうかは分からない。この形を許すと「宣言の時点で型エラー」が原理的に成立せず、マークが効くかどうかがファイルをまたいだ後づけで決まってしまう。多重度型を直接書けば B の側に迷いが無くなる。
Consuming を含まない record を名前で置いてから包むことは引き続きできる。 禁じているのは Consuming を含む record を直接の基底以外の位置に置くことだけで、type B := { ping: {| Int } |} の後の type T := ExactlyOnce(B) は通る。
type H := AtMostOnce({ peek: {| Int }, close: Consuming({| Unit }) |}) # 通る type B := { m: Consuming({| Unit }) |} # エラー: 直接の基底ではない type T := ExactlyOnce(B)
多重度型の部分型関係
- 多重度型
Mの値は、関数の引数位置に限りBorrowed(M)へ渡せる。 これが借用であり、受け側はBorrowed(M)で宣言する。 - 保存位置ではこの緩和を認めない。 束縛・コンテナー・別フローへの捕獲・メソッド値の取り出しのいずれでも借用は置けない(下記「借用は保存できない」)。
ExactlyOnce(T) <: AtMostOnce(T)は許す。 使用回数の下限を捨てる方向であり、安全側だからである。基底型との間に部分型関係が無いこと(上記「これらの型構築子は基底型を包む器ではない」)はこの緩和の外側で、緩和は多重度型同士にしか働かない。逆向き(AtMostOnceの値をExactlyOnceの位置へ渡す形)も型の照合は通す — 照合が基底で行われるためで、受け側の義務が強まるだけなので害が無い。判断を要するのは前者だけである — 義務を捨てる向きだけが、捨ててよいかを問う。
借用のもう 1 つの現れ方はレシーバー位置である。Consuming で包まれていないメソッド呼び出しのレシーバーはすべて借用であり、注釈は要らない。注釈無しの意味は 2 つの位置で反転している — レシーバーは注釈無しが借用、関数の引数は注釈無しが move である。反転は意図したものである。§17.5 のメソッド契約 m: {In | Out} に受け手の欄が無い — レシーバーは引数ではない — ので、レシーバーの既定は型の意味に縛られず自由に選べ、当然の方を選んだ。引数の処遇はパラメーターの型そのものが述べるため、既定は型の意味と噛み合う側に置いた。レシーバーと引数は独立に指定でき、4 通りすべてが書ける。引数の消費に注釈が要らないのは既定が move だからであって、表現できないからではない。
type Pool := AtMostOnce({ absorb: Consuming({ fs.File | Unit }), # レシーバーを消費し、引数も消費する give: { fs.File | Unit }, # レシーバーは借用、引数は消費 peek: { Borrowed(fs.File) | Int }, # レシーバーも引数も借用 seal: Consuming({ Borrowed(fs.File) | Unit }), # レシーバーを消費、引数は借用 |})
安全性は 1 本の規則で立つ — 借用したレシーバーでは Consuming メソッドを呼べない。 これにより、借用が呼び先から漏れて生き延びても閉じられないので、閉じた後の使用を作れない。
基底に型変数を置く
多重度型構築子の基底には型変数を置ける — { x: AtMostOnce(a) | … } / { x: ExactlyOnce(a) | … } がその形である。これがジェネリックなコードで資源を受け渡す正書法である。
規律はそのまま働く。消費追跡の起動は宣言型が多重度型に解けるかで決まり(static-analysis.md §2.14)、基底が型変数でも層は多重度型のままだからである。したがって次の 3 つがいずれも成り立つ。
- body に規律がかかる。
{ x: AtMostOnce(a) | (x, x) }はuse-after-consume、{ x: ExactlyOnce(a) | 0 }はnever-consumedになる。返す・包む・別の関数へ渡すのはいずれも 1 回の消費なので通る。 - 呼び手は渡した時点で失う。 実引数へ置くことが消費として記録されるので、渡した後にその名前を使うと
use-after-consumeになる。 - 結果は追跡対象に戻る。
aが具体型に解けるので、結果を受けた名前の静的型は多重度型になる。
export {} # 多重度型の値をトップレベルに束縛するので公開しない(§13.2) type Lease := ExactlyOnce({ ping: {| Int }, done: Consuming({| Unit }) |}) new_lease := {| { ping := {| 1 }, done := {| () } |}: Lease } hand_over := { x: ExactlyOnce(a) | x } l: Lease := new_lease() m := hand_over(l) m.done()
境界の無い型変数を基底に置いた間メンバーは参照できない。 型変数にスロットが無いので、body でできるのは渡す・包む・返すことである。受け取った資源の Consuming メソッドを body から呼びたいなら、基底を具体型で書く({ x: Lease | x.done() })か、下記のとおり型変数へ境界を付ける。
参照は静的検査が禁じる(static-analysis.md §2.14 の cannot-call-on-type-variable-base)。メンバーが解けない以上そのメソッドが Consuming かどうかを引く先が無く、消費とも借用とも決められないためである。素通しにすると、その位置は型検査を一つも受けないまま(存在しないメンバーでも報告が出ない)ExactlyOnce の義務だけが静かに消える。決められないことを禁じる側へ倒すのは、型変数のパラメーターへ渡せないこと(下記)と同じ判断である。
型変数({ x: a | … })のパラメーターへは渡せない。 宣言位置で多重度型に解けないため規律を起動する先が無く、素通しにすると呼び手と受け側の両方に同じ資源への経路が残る。したがって多重度型の値をそこへ置く形は静的検査が報告する(static-analysis.md §2.14 の pass-of-multiplicity-value)。資源をジェネリックに扱う関数は基底に型変数を置く形で書くこと — 書き忘れは通らない。境界(§17.2)の有無は問わない — 境界は確定できる型を絞るだけで、多重度の規律を課す先を作らないからである。
タグ構築子の payload へは渡せる。 Ok(x) / Err(x) やユーザー定義 enum の variant 構築子は payload 型が型変数だが、包みが多重度を伝播する(static-analysis.md §2.14「包みは多重度を伝播する」)ので規律が消えない。入れることが 1 回の消費として記録され、取り出しでまた追跡へ戻る。
基底の型変数へ境界を付ける
基底の型変数に interface 境界(§17.2)を付ければ、その境界が宣言したメンバーは参照できる。 境界はメンバーの一覧を持つので、そのメソッドが Consuming かどうかを引く先ができる — 上の禁止が塞いでいた「決められない」がここで解ける。これがジェネリックなまま資源を閉じる正書法である。
# 古い方を閉じ、新しい方を同じ型のまま返す swap := { old: ExactlyOnce(a: { close: Consuming({| Unit }) |}), new: ExactlyOnce(a) | old.close() new }
境界の record には Consuming を書ける(上記「多重度の型語彙」の位置の規則)。判定は今までどおり書かれた位置の構文だけで決まり、名前は辿らない。その場に添えた境界だけを認めるのはそのためである — 境界を名前で書いた形(ExactlyOnce(a: Closer))でその Closer が Consuming を持つことはありえない。Consuming を含む record を名前で置く宣言自体が通らないからである。名前で使い回したいときは全体を別名にする(type Closing := ExactlyOnce(a: { close: Consuming({| Unit }) |}))。
境界を経由したメンバーは、基底が具体 record のときとまったく同じに扱う。 境界が宣言したスロットの型が Consuming(F) ならその呼び出しは消費であり、Consuming(F) でないスロットの呼び出しは借用である。境界が宣言していない名前は no-such-member になる(下記「境界に無い名前は型エラーである」)ので、消費か借用かの判定には届かない。したがって ExactlyOnce の義務が静かに消えることはない — 借用だけを呼ぶ body は never-consumed になる。ここに新しい判断は無く、多重度の層を基底へ降りてからメンバーを解く既存の規則(上記「これらの型構築子は基底型を包む器ではない」)の降り先が 1 段増えるだけである。
境界が record にも不透明型にも解けないときは今までどおり禁じる。 閉じた union や原始型を境界に置いてもメンバーを列挙できないので、Consuming を引く先ができない。境界が付いたことではなく、境界がメンバーを数え上げられることが禁止を解く条件である。
この境界は基底に付くものであり、パラメーターの型そのものが型変数である位置(上記「基底に型変数を置く」)とは別である。 あちらで境界の有無を問わないのは、そこに規律を課す先が無いからで、本節が足すのは規律の届いている値のメンバーをどう解くかである。
実行時に走るのは境界の照合だけである。 境界付き型変数は実行時にも単一化する(§17.2)ので、x: ExactlyOnce(a: I) の位置では I の照合が走り、確定した型は同一シグネチャ内の以降の a へ pin される。多重度の層が何も照合しない(下記「多重度の実行時照合」)のとは別の層で、多重度の側が増えるわけではない。その照合は境界の Consuming(F) を F として見る — Consuming は受け手の使用を述べる語彙であって値の形を述べないので、ここにも照合すべき対象が無い。
境界に無い名前は型エラーである
境界が宣言していない名前のメンバー参照は no-such-member である。多重度に固有の規則ではない — 宣言した型のメンバー集合が受け手から触れる範囲になるという §17.2 の一般の規則が、境界を基底とみなすこの位置にも同じように働くだけである。
上の消費と借用の読みは、境界がその名前を宣言している場合の話である。 宣言していない名前はメンバー解決の段で止まるので、そこから先の判定には届かない。
関数値の多重度
基底が関数型の多重度型では、完全適用が消費である。 AtMostOnce({ In | Out }) は「高々 1 回だけ呼べる」、ExactlyOnce({ In | Out }) は「ちょうど 1 回呼ぶ」を表す。部分適用は消費ではない — 本体が走らない(§3.6)ので資源も使われず、回数の規律は下記のとおり結果の側へ移る。明示的 kick(f! / f())は本体をちょうど 1 回走らせるので完全適用として数える。
type Once := AtMostOnce({ Int | Unit }) f: Once := mk() f(1) # 消費 f(2) # エラー: use-after-consume
Consuming は書けないし、要らない。 マークを置く場所が構文として無い(Consuming を書けるのは多重度型構築子の直接の基底として書いた record のスロットだけ。上記「多重度の型語彙」)うえに、置く意味も無い。record を基底に持つ型では借用メソッドと Consuming メソッドを選べるからマークが要るのに対し、関数値の操作は呼び出し 1 つしか無いので選ぶものが無い。
「手渡しは 1 回だが呼び出しは何度でも」は表せない。 その読みを採らないのは、守るものが無いからである — 資源を閉じ込めた関数値を何度でも呼べるなら資源も何度でも使われるので、多重度を載せる意味が消える。回数を制限したくないなら多重度を書かなければよい。
部分適用の結果には多重度が載る。 追跡対象の実引数(多重度型そのものと、多重度を伝播する包み)を埋めた部分適用の結果型は多重度型になる。埋めた資源はその関数値を呼んだときにちょうど 1 回使われるので、回数の規律が結果へ移る。
use := { h: fs.File, n: Int | h.close() }
p := use(f) # p の型は ExactlyOnce({ Int | … })
p(1) # 消費。f もここで 1 回だけ閉じられる
p(2) # エラー: use-after-consume
載せる多重度は強い方が勝つ — ExactlyOnce を 1 つでも埋めれば結果は ExactlyOnce である。弱めると「ちょうど 1 回」の義務が結果を呼ばないことで消える。借用の引数は消費できないので多重度を載せない。呼び先が既に多重度型なら、そこへの部分適用の結果もその多重度を引き継ぐので、多段に分けて埋めても義務が落ちない。判定の詳細と、包みの内側を辿る走査の予算は static-analysis.md §2.14 が定める。
この形の主な使い手は効果ハンドラーの継続である(§17.9)。操作スロットが受け取る継続の型は AtMostOnce({ R | a }) であり、二度目の再開が本節の規律で use-after-consume になる。
ブロックの起動回数を宣言する
ブロックを取るパラメーターを ExactlyOnce(基底が関数型)で宣言すると、そこへ渡したブロック位置は「起動が高々 1 回」になる — その内側から外側の資源を消費できる(static-analysis.md §2.14「ブロックの起動回数」)。db.transaction や arr.build と構造が同じ自前の bracket が、std でなくても書けるということである。
with_conn := { pool: Pool, f: ExactlyOnce({ Borrowed(Conn) | a }) |
c := pool.acquire()!
r := f(c)
pool.release(c)!
r
}
lease: Lease := take_lease()
with_conn(pool) { c | lease.done(); c.query("select 1") } # 外側の資源を閉じられる
載せる先は関数値の多重度そのものである。 起動回数は「呼び先がそのパラメーターをどう扱うか」だが、それを述べる位置はパラメーターの宣言型そのものであり、上記「関数値の多重度」が定めたとおり基底が関数型の多重度型では完全適用が消費である。「ちょうど 1 回消費する」と「ちょうど 1 回起動する」は同じことを別の言葉で述べており、新しい軸は要らない。呼び手が持つ関数値の多重度(何回呼べるか)と、呼び先のパラメーターの多重度(何回呼ぶか)は同じ型の同じ読みで、位置が違うだけである。
宣言は検証される。 呼び先の本体では f が追跡対象の名前なので、1 度も呼ばなければ never-consumed、2 度呼べば use-after-consume になる。「高々 1 回」の表の他の行が仕様の側で閉じているのに対し、この行は呼び先の本体が根拠を示す。
ブロックの中の消費は義務を果たす。 外側の ExactlyOnce の値をブロックの中で消費すると、その消費が義務を果たしたものとして数えられる。合流の扱いは既存の「高々 1 回」の行と同じで、外側の名前の消費状態そのものは動かさない — したがってブロックの後ろで同じ名前を使う形は報告されない。これは見逃しの側であり、宣言で開く行に固有のものではない。
AtMostOnce では開かない。 0 回の可能性が残るので、開くと起動されなかった経路でも義務が果たされたことになる — 資源が解放されないまま検査が黙る。ExactlyOnce はちょうど 1 回が保証されるのでこの見逃しが無く、義務を果たしたと数えることが正確になる。Borrowed({ In | Out }) も開かない — 消費義務を持たないので起動回数について何も述べていない。宣言で開く行を足すときは、見逃しを増やさない側へ倒す。
ブロックへ渡すパラメーターは Borrowed で宣言する。 std の bracket にかかる規定(static-analysis.md §2.14)と同じで、ブロックが受け取ったハンドルを消費できると bracket 自身の後始末が壊れる。呼び先が宣言した型は注釈の無いブロックパラメーターへ流れる(static-analysis.md §2.2)ので、書き手は { c | … } と書くだけでよい。
ブロックが資源を返すときは、結果を受ける名前に注釈が要る。 bracket の結果型は型変数なので、注釈が無いと束縛の静的型が多重度型に解けず、そこで追跡が切れる(static-analysis.md §2.14 の素通し条件)。made: Lease := with_conn(pool) { c | take_lease() } と書けば義務は呼び手へ移り、閉じ忘れが never-consumed になる。
新しい語彙は要らない。 「このブロック位置はちょうど 1 回起動する」は既存の型の綴りがそのまま述べており、マークも注釈も増えない。
借用は保存できない
借用を型として持ち、かつ保存できる設計は、例外なく生存期間の添字(region / lifetime)を型に持つことになる。保存できるなら「いつまで有効か」を型が言わねば宙に浮いた参照が作れてしまうためである。Hikari は保存を禁じることでこの添字を不要にする。
| 位置 | 借用を置けるか |
|---|---|
束縛(g := f.chain()) |
不可 |
コンテナー({ mutable h := b |} / [b] / タプル) |
不可 |
別フローへの捕獲(spawn / fork / Future のブロック) |
不可 |
メソッド値の取り出し(r := f.read_at) |
不可(取り出した関数値がレシーバーへの借用を保存するため) |
| 関数の結果 | 自身を返す連鎖メソッドと、Borrowed(T) を返すと宣言した関数のみ |
| 呼び出しのレシーバーとして使う | 可 |
| 引数として渡す | 受け側のパラメーターが Borrowed で宣言されているときに限り可 |
受け側が Borrowed でないパラメーターは既定が move(上記「注釈無しの意味は 2 つの位置で反転している」)なので、そこへ渡すと借用が呼び先に保存されうる。部分型関係の側から見ても、緩和は M → Borrowed(M) の一方向だけで、Borrowed(M) → M はどの規則も許していない。
これにより借用は式スコープに閉じる — 呼び出しに使うか、別の関数へ借用として渡すか、その場で連鎖するかだけになる。生存期間が構文で決まるので、region が原理的に要らない。この禁止は書式の整理ではなく、region を回避する土台そのものである。
自身を返す連鎖メソッドは結果型を Borrowed(T) で宣言する。そうしないと b := a.set(2, 7) が同じ値への 2 つ目の名前を作る。束縛位置に借用を置けない規則がここで噛み合う。
判定は式の推論結果の型で行う
上表が定めるのは位置であって綴りではない。その位置に現れた値の静的型が Borrowed(T) に解けるなら、値が裸の識別子であっても式であっても等しくかかる。借用を返すメソッド(下記「可変ホスト資源はすべて多重度を持つ」の set / swap / update)が日常の書き方である以上、借用の式を素通しすると上表の禁止が「裸の名前に束縛したときだけ効く」規則に痩せてしまう。
steal := { a: arr.Array(Int) | f := spawn {| a.set(0, 42); 0 }; f! }
b := a.set(0, 1) # 束縛の行に当たる
steal(b) # 引数の行に当たる
steal(a.set(0, 1)) # 同じく引数の行 — 借用の式でも変わらない
同じ理由で、レシーバーが連鎖や式のとき(a.set(0, 1).frozen())も「借用したレシーバーで Consuming メソッドを呼べない」規則がかかる。
例外 — 連鎖のルートが名前を持たない所有の式のとき
その場で作った資源を連鎖のルートに持つ形だけは対象外である。
arr.array(2, 0).set(0, 1).set(1, 2).frozen() # 通る(ルートは名前を持たない所有の式)
上表が保存を禁じるのは、借用の指す資源を別の経路が持ち続けているからである。連鎖を遡ったルートがその場で作られ名前に束縛されていない所有の式なら、その資源へ届く経路は連鎖そのものしか無い。借用が漏れて生き延びる先が無いので、規則が守る危険がそもそも生じない。これを例外にしないと「作って・書いて・確定する」一息の書き方が丸ごと書けなくなり、上記「連鎖は借用のまま書ける」と噛み合わなくなる。
ルートが名前なら例外にならない。その名前が資源を持ったまま借用経由で保存・消費される形であり、まさに上表が禁じる 2 本目の経路そのものである。ルートまで遡っても借用のままのとき(借用が呼び先から返ってきた形)も同じで、所有者は式の外に居る。
借用は「読み取り専用」ではない。 借用したハンドルへ書き込めるし、連鎖もできる。禁じているのは消費だけである。
規律が届かない位置
消費追跡(static-analysis.md §2.14)の側は裸の識別子のままである。 消費は名前ごとのフロー状態なので、状態を持たせる先が名前しか無い。したがって所有の多重度型を式のまま扱う形(mk().close のメソッド値取り出しなど)は引き続き対象外で、std の可変ホスト資源ではそこを実行時の封印が backstop として受け持つ — 閉じたハンドルへの次の操作がその場で失敗する。Consuming を宣言したユーザー定義の多重度型には封印が無いため、この位置では二重消費が実行時にも捕捉されない。
呼び先が型を述べていないブロックパラメーターには借用の規律が働かない。 呼び先が宣言するブロックパラメーターの型は書き手の未注釈パラメーターへ流れる(static-analysis.md §2)ので、arr.build のようにブロックパラメーターを借用として宣言するメソッドでは注釈の有無を問わず規律が働く。届かないのは呼び先の宣言型が Any や型変数に倒れる位置で、そこには借用かどうかを言う情報がそもそも無い。
checksum := { f: Borrowed(fs.File), n: Int | # 借用。呼び出し側は f を持ったまま
r := f.read_at(0, n)!
bytes := r?
bytes.length!
}
drain := { f: fs.File | # 注釈無しなので move
r := f.read_at(0, 4096)!
f.close()!
r
}
可変ホスト資源はすべて多重度を持つ
不透明で可変なホスト資源は多重度型である。 多重度は資源に内在する性質であり(Borrowed が
文脈依存であるのと対照的)、非 Copyable(§9.5)と同じ層にある。std が提供する可変ホスト資源は
下表のとおりすべてこれに従う。
ユーザー定義の型についてこれは規範であって記述ではない。今はユーザーが不透明な可変ホスト資源を
作る手段が無く(不透明型を導入できるのは std だけである)、規則を強制する検査も存在しない。std を
拡張する側が守るべき規則として読む。
分岐点は「使い残しが害になるか」である。
| 型 | 多重度 | Consuming メソッド |
|---|---|---|
std:array の Array |
AtMostOnce |
frozen |
std:map の ScratchMap |
AtMostOnce |
frozen |
std:fs の File |
ExactlyOnce |
close |
std:net の Conn |
ExactlyOnce |
close |
std:db/sqlite の Db |
ExactlyOnce |
close |
std:db/sqlite の Tx |
AtMostOnce |
持たない |
std:hikari/repl の Session |
AtMostOnce |
持たない |
Tx が AtMostOnce で Consuming メソッドを持たないのは、閉じるのがブロック側ではないため
である。db.transaction(block) は block の結果で commit / rollback を決め、そこでハンドルを封印
する。ブロックは貸し出された Tx を使うだけなので、block のパラメーター型は Borrowed(Tx) になる
(std:array の arr.build が貸し出す Borrowed(Array(e)) と同型である)。
規則の対象は資源そのものである。 §9.5 が非 Copyable に挙げるもののうち、Future(§10)だけは
資源そのものではないので多重度を持たず、上表にも載らない — 資源ではなく多重度を伝播する包み
であり、包んだ中身の多重度がそのまま外へ出るので Future 自身に多重度を載せる必要が無い
(static-analysis.md §2.14)。
受け手を消費しないメソッドが受け手そのものを返すときは、Borrowed を返さねばならない(上記
「借用は保存できない」が定める規則を、可変ホスト資源へ当てはめたものである)。所有を返すと、消費
されていない受け手と合わせて同じ資源への経路が 2 本になり、上表が閉じたはずの穴がそのメソッド経由で
開く。連鎖のために self を返す Array の set / swap / update と ScratchMap の
set / remove / update はこの規則に従い Borrowed を返す(std/array.md §4・
std/map.md §4.1)。連鎖は借用のまま書けるので、この制約で失われる書き方は無い。
この規則が spawn の隔離を支える。 移譲した捕獲を複製しないことが安全なのは「捕獲した後親が
その値へ触れない」からだが、消費追跡が追うのは変数であって構造の内部ではない。可変資源そのものが
多重度を持てば、その資源をコンテナーへ入れた時点で元の名前が消費済みになるため、親側に 2 本目の経路が
残らない。追加の規則も解析も要らない。
実行時は何も変わらない。 多重度は使用を制約する型であって値の形を制約する型ではないので、
matches も copy のディスパッチも spawn の非 Copyable 捕獲検査も基底型の判定のままである
(下記「多重度の実行時照合」)。
複製と別フローへの捕獲
多重度型の値と、それをスロット・要素に持つ構造への copy(§9.5)は静的な型エラーとする。 自前の copy スロットによる上書き(§9.5「上書き」)も認めない — 認めれば f.copy! が同じ資源への 2 つ目の名前を作り、move 規律が破れる。構造へも及ぼすのは、copy が作り直すのは可変な構造だけで透過的に不変な部分は共有する(§9.5)ためである。多重度型の値をコンテナーへ入れてからコンテナーを複製すると、同じ資源への 2 本目の経路が生えてしまう。
実行時の側は変わらない — v matches Copyable も copy のディスパッチも基底型の判定のままである(下記「多重度の実行時照合」)。多重度型は使用を制約する型なので、値を見ても複製してよいかは分からない。静的にだけ担うこの非対称には refinement_opaque(§17.7)という前例がある。したがってこの禁止を実行時の失敗が肩代わりすることはない — 基底型が record である多重度型(type Txn := ExactlyOnce({ … |}) の形)は実行時にはただの Copyable な値なので、塞ぐのは静的検査だけである。
多重度型の値を、別フローで走り起動が高々 1 回のブロックへ捕獲するときは、複製ではなく所有権の移譲とする。 対象は「行き先が別フローであり、かつ起動が高々 1 回である」という性質で決め、spawn / fork と Future のメソッド(prelude.md §9.1 / §9.6 / §9.9)がこれにあたる。捕獲した時点で親側ではその値が消費済みになる(static-analysis.md §2.14)ので、複製しなくても隔離は保たれる — 親からもう触れないので共有が起きない。spawn は静的に移譲と判定した捕獲について複製も非 Copyable 検査も行わない(同 §9.9)。起動が要素ごとに繰り返されるブロック(std:parallel の並列コンビネーター。std/parallel.md)は移譲の対象にしない — 捕獲を 1 回の消費とみなすと実体が n 回の消費である形が通ってしまうので、そこは起動回数が不明なブロックとして外側の値の消費そのものを禁じる(static-analysis.md §2.14)。
移譲が使えるのは、捕獲が消費として記録される値に限る。 静的型が多重度型そのものである値と、多重度を伝播する包み(enum の variant payload・タプル・Future・List。static-analysis.md §2.14)がこれにあたる。多重度型の値を伝播しない構造(伝播する上記 4 つ以外のすべて。record)に入れてから別フローへ捕獲することは静的に禁じる — そこでは親側に値が残るので、複製した場合と同じく同じ資源への 2 本目の経路ができる。この禁止の側は起動回数を問わず、別フローで走るブロックすべてにかかる。copy の禁止と違って伝播する包みを除くのは、複製が親側に元の値を残すのに対し移譲は残さないためである。この禁止も静的検査だけが塞ぐ — spawn の非 Copyable 捕獲 panic は実行時の値を見る規則なので、基底型が record の多重度型を含む構造は素通ししてしまう。
移譲した捕獲も隔離複製する — 共有するのは非 Copyable な実体だけである。 上の根拠(親側で消費済みになるので触れない)は値そのものへの経路しか無いときにだけ正しい。基底型が record である多重度型(type Txn := ExactlyOnce({ … |}) の形)は、多重度を持たない可変な値を内部へ持てる。その値を入れても呼び手の名前は消費済みにならない(多重度を伝播しない構造へ入れた時点で追跡が終わるため。static-analysis.md §2.14)ので、捕獲した値ごと共有すると親側に 2 本目の経路が残る。メソッドを関数型スロットへ置く綴りも同じで、その閉包が捕獲した可変な値へ親から届く。
そこで移譲が省くのは複製そのものではなく、非 Copyable な実体の複製である。捕獲は §9.5 の copy と同じ深さで作り直し、到達グラフの中で非 Copyable な実体(不透明なホスト資源・Future)に達したらそこだけ共有する。親がその実体へ届かないことは消費追跡が保証しており(資源はすべて多重度を持つので、入れた時点で元の名前が消費済みになる。上記「可変ホスト資源はすべて多重度を持つ」)、届かない相手を共有しても経路は増えない。それ以外の可変な構造は作り直されるので、親側にどんな束縛が残っていても子の書き込みは観測できない。
この形は静的な判定を 1 つも要求しない。 移譲してよい型を型で選り分けようとすると、record のスロットに可変な値が無いことを型から決められず(型はスロットの可変性を持たない。下記「多重度の実行時照合」)、メソッドを持つ型が軒並み対象外になる。別名を追う道は下記「規律を課さないと決めたもの」で恒久的に対象外と定めた名前フィールドとコレクション要素を追い直すことになる。複製の深さは実行時の値が決めるので、どちらも要らない。
したがって移譲と複製の違いは 1 点に縮む — 複製は非 Copyable に達すると失敗するが、移譲はそこを共有して進む。spawn が移譲と判定した捕獲について非 Copyable 検査を行わない(prelude.md §9.9)のはこの 1 点の言い換えである。
同じ危険は捕獲以外の口にも開く。 std が状態をブロックへ引き渡す口を持つとき(std/http/server.md §13 の serve の state)、そこへ置く値も引き渡した先で後から使われる。置いた値が多重度型なら、置くことが消費として記録されるので呼び手の側の名前は使えなくなる。多重度型を含むが自身は追跡対象でない値では追跡が既に切れており、置いても消費にならないので呼び手に同じ構造が残る — 捕獲を禁じたのとまったく同じ理由で、この形も静的に禁じる(static-analysis.md §2.14 の state-not-owned)。多重度型を 1 つも含まない値は呼び手に残る写しが不変のデータでしかないので対象にしない。
多重度の実行時照合
注釈位置は基底型だけを照合する。 これは 4 つの型構築子すべてにかかる。その地点に存在するのは値だけで、使用の履歴ではない。多重度が制約するのは後者なので、照合すべき対象がそもそも無い。§17 冒頭の「注釈は値が束縛される地点で照合される」という規定は値を記述する型を前提にしており、本節の型はその前提の外にある。x: AtMostOnce(fs.File) := v の照合は x: fs.File := v の照合と同一で、照合が走る地点も増えない。
matches は基底型の判定に落ちる。 v matches AtMostOnce(fs.File) は v matches fs.File と同値である。理由は上と同じで、値を見ても多重度は分からない。型は値(§17.2)なので matches の右辺に書けること自体は保つ。「書けるが静的と実行時で担うものが違う」型には既に前例がある — refinement_opaque(§17.7)は静的検査の証明対象から外れる一方で、実行時の振る舞いは refinement と同一である。非対称の向きは逆(あちらは静的が緩く実行時が完全、こちらは静的だけが担う)だが、非対称そのものは新種ではない。
型消去(§17 冒頭)は影響を受けない。 消去の判定は「その位置の実行時照合を外してよいか」を問うものであり、多重度はそもそも照合していない。AtMostOnce(T) の注釈位置は T の注釈位置とまったく同じ判定になる。
消費の追跡には素通しする位置がある(static-analysis.md)ため、std:fs などのハンドルが持つ実行時の封印は backstop として残る。封印は「その資源が既に閉じている」という実行時の状態に対する検査であり、静的な多重度の証明が保証するのは「このプログラムのこの経路では二度触らない」ことだけである。両者は置き換え関係ではなく、静的な層が実行時の層の手前に積まれる形になる。
規律を課さないと決めたもの
次は多重度の規律の対象にしない。実装が進んでも対象にしない。
- 共有借用と排他借用の区別 — 借用は何本でも取れ、すべて書き込める。読みと書きの排他を型で分ける仕組みは持たず、可変状態の別名制御は §17.2「変性」と static-analysis.md §2.10 の型安定性の規則(「可変ローカルの型安定性」・「スロット代入の型安定性」)の管轄に留める。
- 名前フィールドのパスとコレクション要素を多重度の規律の対象にすること —
o.hのようなパスやリストの要素は対象にしない。対象にすると正しいコードを誤検出する — 同じ型のスロットへの差し替えは通る(static-analysis.md §2.10)以上、o.hを消費済みと記憶していると差し替え後の使用が誤検出になる。加えてコンテナーは自由に別名が作れるので、追っても漏れる。refinement 型が名前フィールドを断片内の場所とし添字を断片外に置いている(§17.7)のに対し、多重度は名前フィールドも外す側へ一段保守的に線を引く。コレクション要素は恒久的に対象外であり、std のコレクションについてはそこへ入れること自体を禁じる — 組込型メソッドの実引数へ多重度型の値を渡す形はpass-of-multiplicity-valueになる(static-analysis.md §2.14)。std 側に抜き出す API を用意して追跡を通す道は採らない。恒久的に対象外と定めたものを追跡し直すことになり、しかも組込型メソッドは呼び先の関数型が引けないので、抜き出す口を足しても規律は復活しないからである。
要素を対象外にすることと、器を対象にすることは両立する。 Hikari 側の List は器そのものが言語の値なので、器を束縛した名前を 1 つの追跡対象にできる(static-analysis.md §2.14「包みは多重度を伝播する」)。要素は相変わらず主語にならず、追跡しているのは器の名前だけである。器を外すと取り出すたびに新しい義務が生まれる一方で器からは何も減らないので、1 つの資源に独立に果たせる義務がいくつでもできてしまう。器を主語にすると取り出しがその器の消費になり、2 度目の取り出しは消費後の出現として止まる。
record のスロットはこの扱いに乗らない。 器を主語にできるのは、別名を作る q := p をその器の消費として記録でき、差し替えが起きないからである。record は上のとおり差し替えが通るので、スロットを主語にすると差し替え後の使用が誤検出になる。したがって r := { held := l |} から r.held を 2 度取り出す形は今も黙って通る — 規律を課さないと決めた側にそのまま残っている。
取り出してローカルへ移す形が規律に乗るのは、器から要素が減るときである。 ユーザー定義のコンテナーなら h, rest := o.take_h()! がこれにあたり、入れる形が消費として記録され、(要素, 残り) を返す関数で取り出せば追跡対象に戻る。器から何も減らさずに覗くだけの形は、器の側を消費として記録しない限り義務を増やすだけである。
17.9 効果型(計算が外界に対して何をするかを載せた型)
本節が定める型は、値でも使用でもなく計算を記述する型である。 §17.8 が「値を記述する型」から「使用を制約する型」へ射程を広げたのに続き、本節はさらに「その計算が外界に対して何をするか」を型に載せる。Int は値の種類を、refinement 型(§17.7)は値が満たす述語を、多重度型(§17.8)は値を何回使えるかを述べる。本節の型が述べるのは、その関数を呼んだときに起きることである。
語の範囲を先に定める。効果ラベルとは prelude が束縛する 8 つの名前(Io / Fs / Net / Proc / Time / Rand / Mut / Susp)を指す。効果型とは Effect(T, …) を指し、結果型 T とその計算が起こす効果ラベルの集合を述べる。EffectConduit(F) はそれに付随する語彙で、引数の効果が呼び出し元へ透過することを表す。
Effect / EffectConduit と 8 つの効果ラベルはいずれも prelude が束縛する値であり、新しい予約語も新しい記号も持たない(§1.2.2 / §1.4 の予約語は増えない)。Comparable / Copyable が組込の特別な型ではなく prelude 定義の構造型である(§17.5)のと同じ位置づけで、書式は List(T) / Option(T) と同じ型構築子の適用に乗る。効果の推論・伝播の詳細と診断は static-analysis.md が定める。
効果ラベル
| ラベル | 意味 | 該当 |
|---|---|---|
Io |
対話デバイスへの入出力 | print / println / eprint / eprintln / input、std:term |
Fs |
ファイルシステム | std:fs |
Net |
ネットワーク | std:net、std:http/client、std:http/server |
Proc |
プロセス環境 | std:process、std:env、exit |
Time |
時計を読む | now、std:time の現在時刻取得口 |
Rand |
非決定 | random.new() |
Mut |
外側の可変状態の読み書き | 下記「Mut の判定」 |
Susp |
中断点 | 未完了 Future への !・sleep・wait_any(§10.1) |
集合は閉じている。 ユーザーが効果ラベルを追加する手段は持たない(下記「将来枠」)。ラベルは処理系が外界に触れる口の分類であり、書き手が増やす対象ではないためである。
線はモジュール単位ではなく能力単位で引く。 std:time は暦の算術が大半で、外界に触るのは現在時刻を読む口だけなので、その口だけが Time を持ち残りは純粋である。同様に std:path はパス文字列だけを触りファイルシステムを見に行かないので純粋であり、std:math / std:json / std:crypto / std:regex / std:decimal / std:bits / std:set / std:array / std:map も同じく効果を持たない。
random.seed(n) は同じ n に同じ列を生む決定的な口なので Rand ではなく、ジェネレーターの内部状態が前進する分の Mut だけを持つ。Rand を持つのはエントロピーを取る random.new() である。
効果型の書式
add: { Int, Int | Int } := { x, y | x + y } # 効果なし(従来の書き方のまま)
greet: { String | Effect(Unit, Io) } := { name |
println("Hello, ${name}")
}
type CopyFile := { String, String | Effect(Future(Result(Unit)), Fs, Susp) }
効果は集合なので、Effect(T, …) の第 2 引数以降は順序を正規化し重複をまとめる。効果ゼロの Effect(T) は T に退化する — OneOf(A) ≡ A(§17.4)と同じ正規化であり、Effect(Int) と Int は同一の型として振る舞い、表示も Int になる。
Effect(T, …) は基底型 T を包む器ではない。 値の構造は T のままで、取り出し(unwrap)は要らない。多重度型が基底型を包まない(§17.8)のと同じ規律で、帰結として本体の書き方は効果の有無で変わらない — 逐次実行は §11.1(; / 改行はシーケンス、最後の値が返り値)のままであり、bind の連鎖も専用の記法も導入しない。
効果の層は多重度の義務を素通しする。 Effect(ExactlyOnce(T), …) を結果型に宣言した関数の返り値を束縛した名前は、ExactlyOnce(T) を宣言したときとまったく同じ義務を背負う。効果の層は器ではない以上そこに在るのは T の値そのものであり、値に課された規律を消す位置が無いためである。効果を書いただけで判定が変わってはならないという上の規律を、型の照合だけでなく使用の規律へも及ぼす読み方である。
層は包みと重なってよい。 効果の層の内側が多重度を伝播する包み(static-analysis.md §2.14「包みは多重度を伝播する」)なら、層を跨いだうえで包みの規則がそのまま働く — Effect(Future(Result(File)), Fs, Susp) は層 1 枚と包み 2 段の先にある File の義務を届ける。
束縛の型から効果の層が落ちるわけではない。 型の側は Effect(…) のままで、剥がすのは構造を覗く側である(上記「基底型 T を包む器ではない」)。したがって束縛に別の型を注釈して食い違わせれば、診断には効果の層を付けた綴りが出る。どの診断がどの形で出るかは static-analysis.md §2.14 が定める。
type Txn := ExactlyOnce({ commit: Consuming({| Unit }) |}) begin := {| { commit := {| () } |}: Effect(Txn, Fs) } run := {| t := begin() # t は ExactlyOnce の義務を背負う () # エラー: never-consumed(効果の層は義務を消さない) }
report := { path: String |
text := fs.read(path)!?
println(text)
text.length!: Int
}
# 推論される型: { String | Effect(Int, Fs, Io, Susp) }
書ける位置は結果型に限る
Effect(T, …) を書けるのは関数型の結果型位置だけである。 束縛注釈・パラメーターの型・タプルの要素・enum の variant payload・型構築子の引数に書いたら宣言の時点で型エラーとする。効果は「呼んだときに起きること」なので、値を受け取る位置に書いても指す対象が無く、黙って受け取ると「書いたのに効かない」位置ができる。Consuming(F) の位置を構文で決めている(§17.8)のと同じ理由・同じ判定である。
結果型の宣言 3 形(束縛注釈・内部名注釈・body 末尾アスクリプション、§17.3)はいずれも結果型位置なので、どの形でも書ける。
伝播と部分型
- 効果を生むのは組込だけである。 ユーザーコードは効果の葉を持たず、prelude / std の組込呼び出しだけが効果を生む。
- 関数の効果は本体で起こる効果の和集合である。
- 効果は呼び出し地点に帰属する。
fork(block)/spawn(block)はその地点でblockの効果を計上するので、f!が同じ効果を二重に計上することはない。ただし!そのものは中断点なのでSuspを足す。 - 宣言は包含を要求する。 結果型を宣言した関数は、宣言した効果集合が本体の効果を包含していなければならない。
効果集合の部分型関係は上向きにだけ緩む — 効果の少ない値は多い位置へ渡せるが、逆はできない。純粋な関数は Effect(T, Io) を宣言した位置へ渡せる。これは record の幅部分型(宣言より多くのスロットを持つ値が通る、§17.2)とは向きが逆であり、同じ「緩められる方向」という語で両者を読まないよう注意する。効果は「起こしうるものの上限」を述べるので、上限を上げる向きだけが安全である。
効果多相 — EffectConduit(F)
高階関数は、渡されたブロックが起こす効果をそのまま呼び出し元へ返す。この性質をそのパラメーターの型に書く。
map: { List(a), EffectConduit({ a | b }) | List(b) }
sort_by: { List(a), EffectConduit({ a | b }) | List(a) }
xs.map { x | x * 2 } # List(Int)(効果なし) xs.map { x | println(x) } # Effect(List(Unit), Io)
EffectConduit(F) を宣言した関数の効果は「自分の本体が起こす効果 ∪ その引数に渡された値の効果」になる。効果変数(あらゆる高階関数に伝染する全称量化された変数)は導入しない — 表現力と引き換えに、すべての高階関数へ注釈負担を課すことになるためである。Consuming(F) と同じく関数型を基底に取る位置マーカーであり、基底が関数型でなければ宣言の時点で型エラーとする。
効果透過と制御透過は別の軸である
conduit(§18.3)は制御の透過だけを述べ、効果には関与しない。 両者は独立に指定でき、片方だけを持つ関数が実在する。sort_by の key ブロックは副作用を持ちうる(prelude.md §13.4 が「副作用も 1 回」と定める)ので効果は透過させたいが、key ブロック内の break が呼び出し元のループへ抜けるとソートが途中で放り出されるため制御は透過させたくない。したがって 2 つの軸を 1 つのマークに束ねてはならない。
制御透過は現在も値に載る前置注釈(conduit)であって型には現れない。これを型に載せる場合の名前は ControlConduit(F) を予約し、ControlConduit は EffectConduit を包含する(制御を通すものは効果も通す)ものとする。導入は本節の範囲外である。
ハンドラー
効果の意味は、呼び出し側が後から与えられる。 効果を起こす側は「何をするか」ではなく「何を要求するか」だけを述べており、handle はその要求を捕まえて意味を与える。
handle は prelude が束縛する組込であり(if / when / while と同じく特殊オブジェクト、§10)、新しい構文形でも予約語でもない。並置=関数適用(§3.1)なので、ブロックとハンドラーの 2 つを渡すだけである。
handle { greet("world"); greet("hikari") } { println := { s, k | log.push(s); k(()) } |}
ハンドラーの各スロットを操作スロットと呼ぶ。 上の println がそれで、スロット名が横取りする操作を指し、値がその操作を受け取るブロックである。match のアームとは選ばれ方が違う — アームは書いた順に試して外れたら次へ落ちるが、操作スロットは名前で引くだけで、無ければ引けないだけである(順序も、落ちる先も持たない)。
継続パラメーターには注釈が要らない。 操作スロットの型 { P…, AtMostOnce({ R | a }) | a } が書き手の未注釈パラメーターへ流れるので、{ s, k | … } と書いた k の静的型は AtMostOnce({ R | a }) になる(static-analysis.md §2「注釈の無いブロックパラメーターへ型を流す」)。多重度がそこに載るからこそ、二度目の再開が静的に捕まる。
継続は操作スロットの第 2 引数として渡り、その名前は書き手が選ぶ。 継続を指す予約語も大域名も持たない — 自己参照名を予約語にせず書き手に名付けさせる(§4.2)のと同じ姿勢であり、k は cont でも resume でも構わない。
効果ラベル L の操作 op: { P… | Effect(R, L) } に対し、ハンドラーの操作スロット op の型は { P…, AtMostOnce({ R | a }) | a } である(a は handle 式の結果型)。
操作スロット名と、扱うラベルの決まり方
操作スロットの名前は操作の素の名前である — 修飾を落とした末尾の識別子であり、prelude の組込はその名前そのもの(println)、std のメンバーはモジュール修飾を外した名前(fs.read なら read)になる。スロット名は識別子 1 つなので、fs.read のような修飾名をそのまま書く形は持たない。
操作とは、prelude と std が操作として宣言したものである。 効果を持つことと操作であることは別で、型に Effect(…) が出るかどうかからは決まらない。宣言に無い名前を操作スロットに書くと unknown-effect-operation とし、その効果は名前を持たない葉と同じ扱いにする(他の操作スロットが同じラベルを扱っても差し引かれず、handle 式の効果として残る)。どの関数が操作かは reference/prelude.md と各モジュールのリファレンスが項目ごとに明記する。
宣言してよいのは、実体を実行系が持つ効果つきの関数だけである。 横取りは呼び出し地点で呼び先の実体を差し替える仕組みなので、実体が Hikari 自身で書かれたメンバー(std:term/event の next_event は read_key / peek_key の合成である)は名指しても捕まらない。宣言すると「操作スロットが受理されてラベルが差し引かれるのに実行時は素通りする」形が通り、効果を宣言していない位置で実際の効果が起きる。捕まえたければ、その実体が呼んでいる操作(next_event なら read_key / peek_key)を名指す — 非操作の効果は必ずそれが呼ぶ操作から来るので、捕まえられなくなるものは無い。
操作の一覧は公開契約である。 実装の都合であるメンバーを Hikari 側の実体へ移すと、そのメンバーは操作でなくなり利用者のハンドラーが壊れる。内部の書き換えとして黙って行ってはならない。
ユーザー定義の関数は操作ではない。 ユーザーコードは効果の葉を持たない(上記「伝播と部分型」)ので、効果を持つ関数を操作スロットに名指しても unknown-effect-operation になる。効果の出どころである組込・std の操作を名指す。
ハンドラーが扱うラベルは、書かれた操作スロット名から決まる。 各操作スロット名が指す操作のラベルを集めた和がそのハンドラーの扱うラベル集合であり、handle に別途ラベルを書く形は持たない。素の名前が複数のラベルにまたがる場合(fs.read と net.read)、その 1 つの操作スロットが両方を受け、両方のラベルが扱われたことになる。
網羅の対象は、ブロックが実際に起こす操作である。 扱うラベル L について、handle されるブロックが起こす L の操作すべてに操作スロットが要る。起こさない操作の分までは要らない — 上の例のブロックは Io のうち println しか起こさないので、操作スロットは println だけで網羅している。部分ハンドルを持たないという規律はラベル単位で読む — 起こしている L の操作を一部だけ扱って残りを外へ漏らす形が無い、という意味であり、そのラベルの全操作をブロックの内容と無関係に書き並べよという意味ではない。
継続の多重度は AtMostOnce である(§17.8)。この 1 点から次の 3 つが帰結として導かれ、いずれも個別の規定を要さない。
- 継続を呼ばない形が脱出になる。
AtMostOnceは使い残しを咎めないので、早期脱出が型のまま許される。 - 2 回以上の再開はできない。 消費追跡が二重消費として報告する。定数スタックの保証(§18.5)と両立させるために必要な制限であり、緩める予定は持たない。
- 継続の保存・複製・別フローへの捕獲は §17.8 の規律にそのまま従う。 コンテナーへ入れれば消費、別フローへ捕獲すれば所有権の移譲、
copyは静的な型エラーである。
# exit を捕まえ、プロセスを終わらせず終了コードだけを値として受け取る code := handle { run(args); 0 } { exit := { n, k | n } # k を呼ばない = 脱出 |}
exit の結果型は Never(§12)なので、この操作スロットの継続は { Never | a } を受け取る形になり、呼べる引数が存在しない。発散する操作のハンドラーは脱出しか書けないことが、継続の型からそのまま従う。
handle 式の結果型は、ブロックの効果からハンドラーが扱うラベルを取り除いたものになる。ハンドラーの本体自身が効果を起こせば、それは handle 式の効果として残る。
中断点は増えない。 継続の呼び出しは通常の関数適用であり、§10.1 のフロー切り替え規則((a) 未完了 Future への !、(b) sleep / wait_any)を拡張しない。ハンドラーの本体が中断すれば、それは Susp として handle 式の効果に現れる。キャンセル(§10.3)が中断点で観測されたとき、未消費の継続はそのまま捨てられる — AtMostOnce は使い残しを咎めないので、追加の規定は要らない。
制御値は handle を素通しする
handle はブロックと操作スロットのどちらも、書き手の文脈の一部として起動する。 したがってそこに書いた return / break / continue(構文糖の ? を含む、§16.5)は handle の境界でまとめられず、外側の関数・ループへそのまま届く。if / when / map と同じ扱いであり(§18.2 の「ブロックを取る組込メソッド一般」)、handle だけの特例は無い。まとめてはならない — まとめれば制御値がデータ構造へ漏れ、同節が禁じた形になる。
操作スロットから抜けたとき、そのハンドラーは途中で放棄される。 未消費の継続はそのまま捨てられる — 継続は AtMostOnce(上記)なので使い残しを咎めないためで、キャンセルで捨てるのと同じ扱いである。抜けた先から見れば handle 式は値を生まず、制御が通り抜けただけになる。
この素通しは制御の軸であって効果の軸ではない(本節の「効果透過と制御透過は別の軸である」)。handle が扱うラベルを効果から取り除く規則は、制御値が通り抜けた場合も変わらない。
Mut の判定
Mut は自分の呼び出しフレームの外にある可変状態への書き込みを指す。 フレーム内で生まれフレーム内でしか触られない可変状態は呼び出し元から観測できないので、効果ではない。
sum := { xs: List(Int) |
mutable total := 0
xs.each { x | total = total + x }
total: Int
} # 効果なし
bump := { o: { score: Int |} | o.score = o.score + 1 } # { { score: Int |} | Effect(Unit, Mut) }
bump が Mut を持つのは、スロットの可変性が参照共有のセマンティクスを持つ(§4.1)ため、書き換えが呼び出し元から観測できるからである。
スコープ吸収: EffectConduit なブロック引数を取る関数が、そのブロックへ Borrowed(T) を貸し出しており、かつブロック内の可変操作がすべて「貸し出された借用」か「ブロック内で生まれた束縛」に解決するなら、そのブロックの Mut はその関数の外へ出ない。
この規則により、可変配列を内部で使う関数が効果を持たずに済む。
sorted := arr.build(xs.length!, 0) { a | a.set(i, v) } # 効果なし
借用が保存できないこと(§17.8)がこの吸収を支えている。 貸し出されたハンドルは束縛にもコンテナーにも別フローにも置けないので、ブロックの外へ出られない。生存期間の添字(region)を持たずに借用を式スコープへ閉じ込めた設計が、そのまま「内部の可変性は外から観測できない」ことの根拠になっている。
fork は可変状態を共有する協調フローなので、そのブロックの Mut は透過する。spawn は捕獲した可変状態を複製して切り離す(§10.1)ので、子フローの書き込みは親から観測できず Mut を起こさない。std:parallel の並列コンビネーターも捕獲を隔離複製するが、そちらは捕獲した可変状態への書き込みそのものを実行時に拒む(std/parallel.md §1)ので、Mut を問う前に止まる。
効果の実行時の扱い
注釈位置は基底型だけを照合する。 その地点に存在するのは値だけで、その値を作った計算が何をしたかではない。効果が述べるのは後者なので、照合すべき対象がそもそも無い。多重度型が同じ理由で基底型だけを照合する(§17.8)のと同一の論法である。
matches では効果を素通しする。 v matches { Int | Effect(Int, Io) } は v matches { Int | Int } と同じ判定になる。関数値の構造照合は宣言された引数型・結果型を見る(§17.4)ので効果も原理的には見られるが、結果型を宣言しない関数を素通しするという同節の規則により、効果を宣言しない大多数の関数は常に一致する。宣言のある少数だけを厳しく判定すると、同じ式の可否が「書き手が注釈を書いたかどうか」で割れてしまう。
静的検査は素通ししない。 効果は注釈が無くても本体から計算できるためである。ここが型と効果の違いで、引数型・結果型は書かれていなければ不明だが、効果は本体を見れば決まる。したがって純粋を宣言した位置に効果を持つ関数を渡せば、書き手が効果注釈を一切書いていなくても静的エラーになる。
apply_twice: { { Int | Int }, Int | Int } := { f, x | f(f(x)) }
apply_twice({ n | n + 1 }, 3) # 通る
apply_twice({ n | println(n); n + 1 }, 3) # 静的エラー
穴は gradual 境界だけである。 本体が見えなければ効果は計算できず、関数型注釈の呼出時強制(§17.4)も効果を強制できない(実行時に照合する対象が無いため)。したがって保証はこう述べられる — 明示的な Any を一つも書かないプログラムでは、効果を宣言していない位置に効果を持つ関数が入らない。 §17 冒頭の「明示的な Any を一つも書かないプログラムは全域で型的 panic を起こさない」と同じ形の系である。
型消去(§17 冒頭)は影響を受けない。 消去の判定は「その位置の実行時照合を外してよいか」を問うものであり、効果はそもそも照合していない。{ String | Effect(Unit, Io) } の注釈位置は { String | Unit } とまったく同じ判定になる。
持たないと決めたもの
次は効果の型に載せない。実装が進んでも載せない。
- マルチショット継続 — 継続は
AtMostOnceであり、複数回の再開は恒久的に対象外とする。許すと継続の捕捉がヒープコピーを伴い、§18.5 の定数スタック保証が成立しなくなる。静的検査が二重消費として報告する — 関数値の呼び出しは消費であり(§17.8「関数値の多重度」)、操作スロットの継続パラメーターはAtMostOnce({ R | a })として型が付くためである(下記「継続の多重度」)。実行時のガードはそのまま残す — 2 度目の再開はその場でcontinuation has already been resumedになる。静的検査が素通しする位置(gradual 境界を経由した継続など)の backstop であり、std:fsのハンドルが持つ封印と同じ役どころである。 - 効果ラベルの部分ハンドル — あるラベルを扱うハンドラーは、そのラベルの操作をすべて扱わねばならない。一部だけ扱って残りを外へ通す形は持たない。
- リソースごとの
Mutの細分 — 「この配列へのMut」と「あの配列へのMut」を型で区別しない。区別しないため、上記のスコープ吸収は「ブロックが外側の可変状態を触らない」ことの検査に依存する。 - 制御透過の型化 — 上記「効果透過と制御透過は別の軸である」のとおり、
ControlConduit(F)は導入しない。名前を取り置いてもいない — 値位置でも型位置でも束縛が無いので、書き手がControlConduitを別の意味で使うことは妨げられない。将来この綴りで入れるなら、そのとき名前が空いているかは確かめる必要がある。
将来枠
次は載せる余地があるが、今は持たない。
- ユーザー定義の効果ラベル — 効果ラベルは prelude の閉じた 8 種である。ユーザーが自分の効果を宣言し、その操作を
handleで捕まえる形は持たない。開くには、効果ラベルとその操作シグネチャを宣言する構文形と、ラベルの名前空間の規律(enumのタグがNameの下に置かれるのと同種の判断)が同時に要る。 - 効果に基づく並列実行の緩和 — 「可変状態を共有しうるフロー同士は同時に走らない」という §10.1 の不変条件は、効果が型に載っても緩めない。効果を根拠に隔離を判定する形は将来の課題である。
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)。これにより「呼ばれた文脈のループを遠隔で切る」動的脱出 —breakが、自分を字句的に囲うループではなく、呼び出し元が実行時に積んでいるループへ届く形 — を排し、関数を呼び出し元から独立に読めるようにする。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 §3)。 - 値つき・多値
break:break(value)はループ式全体の戻り値をvalueにする。多値はbreak(a, b)またはbreak((a, b))(両者は同義、後者の外側括弧はグルーピング)で抜ける —returnと対称。,はタプル構築のため、括弧なしのbreak a, bは(break a), bと解釈される。break!/ 通常終了時の戻り値は()(unit)。 - ループ外での使用: 字句的に囲うループの無い箇所での
break!/continue!は panic (バグ層、§16.2)。 - 名前として引ける:
break/continue/returnは予約語ではなく prelude 束縛なので (§1.2.2)、呼び出さずに名前として参照できる。裸の参照は発火せず、その脱出を表す値になる。値の発火先は、その値を作った時点で字句的に囲っていたフレームに固定される。呼び出し時点のフレーム鎖は見ないので、値を別の場所へ持ち出しても「呼ばれた文脈のループを遠隔で切る」動的脱出にはならない。一度きりの脱出継続である。
発火できるのは、固定先のフレームが呼び出しの主体であるときだけである。 それ以外で呼べば panic する (§16.2)。条件が 1 つなので外れ方も 2 つに尽きる — フレームが既に戻っている形 (値を外へ持ち出して呼ぶ) と、フレームは生きているが、そこから呼んだ別の関数の中で呼ぶ形 (f := {| r() } に脱出値 r を閉じ込めて呼ぶ) である。後者を認めるには巻き戻しが行き先を持つ必要があり、break / continue では行き先がフレームではなく囲うループの層になる。判定は保守側に倒す — 認める形を狭くしても、遠隔で切れてしまう形が生まれることはない。
- ラベル付き脱出は持たない (最内ループのみ)。多重ループの一括脱出はフラグや関数分割で表現する。
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のアーム body は呼び元 (ビルトインまたはmatchの評価) が呼ぶため 透過 —when (bad) {return v}はwhenを貫いて外側の関数から脱出する (breakがifを貫いてループに効くのと同一経路)。自前の高階関数に渡したブロック内のreturnは、そのブロック自身から脱出する。ただしconduit関数 (§18.3) は境界でreturnを捕捉せず素通しする — ユーザー定義の制御構造 (conduit {cond, then | …}) に渡したブロック内のreturnは、builtinwhenと同じく外側の関数から脱出する。 - 透過は制御構造に限らない: 「ブロックを内部経路で起動する」性質は
if/when/while/eachに固有ではなく、ブロックを取る組込メソッド一般に及ぶ。xs.map/xs.find/o.and_then/r.map_err/ord.thenのブロック内returnも、eachと同じく外側の関数から脱出する — 組込はブロックの評価結果を値としてまとめてはならない (まとめると制御値がデータ構造へ漏れる)。例外はFutureのメソッド (f.map/f.and_then、prelude.md §9): これらのブロックは別フローで遅延起動され、評価される時点で外側の関数は既に戻っているため脱出が成立しない。Futureのブロックにreturn(構文糖の?を含む) やbreak/continueを書くのは誤用であり、評価時に panic する — 制御値は payload に入らず、!を当てた消費側フローへ panic として伝播する (§10.2)。素通しするとreturnを書いた関数ではなく!を書いた無関係な関数が脱出することになり、§18.1 が排除した動的脱出をFuture経由で復活させてしまう。誤用は static-analysis.md §2.12 が静的にも報告する。 - 実現は特殊戻り値方式: 内部表現は Return 制御値 (§18.1 の Break / Continue と同系統) で、回復可能エラーとは別に処理系の内部を値として伝播し、ユーザー関数の呼び出し境界で捕捉される。
break/continueが境界を越えると panic 化されるのに対し、returnは捕捉時に 値を取り出して関数の戻り値にする。break/continueを消費するのは反復駆動の組込 (while/each) だけで、結果を値として使う組込 (map/filter/and_thenほか) のブロックに現れたら診断になる。 - 式としての型は
Never(§17.2):return vはその式位置で値に評価されず関数から脱出するため、型上は発散 (ボトム型Never) する。if (c) { v } { return d }の結果型はvの型に解ける (分岐合流の恒等元、static-analysis.md §3)。脱出する値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) のトップレベルに書いた
returnは そのファイルのロードを中断 する。ファイルは body を持たないので「止めて値を返す」相手が居らず、オブジェクトは生成されない — したがって「宣言されたが評価されていないスロット」が観測されることはなく、スロット集合の固定 (§2.4) は保たれる。中断は?の bail と同じ経路で表面化する (§16.5)。break/continueが囲うループ不在で panic になるのと異なり、returnは root でも意味を持つ。
18.3 制御透過関数 (conduit)
if / when / while 等の制御構造は prelude が束縛した builtin (match は 予約語) であり、ブロック (枝の 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: Bool, then: {| Unit } | if cond then { () } } unless := conduit { cond: Bool, then: {| Unit } | if cond { () } then } guard := conduit { cond: Bool | when (cond.not!) { return () } } # 早期 return を呼び出し元へ透過
- 対象は関数リテラル
{...}のみ(§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 化される(検出位置が一段上がるだけで意味は同じ)。 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 の網羅性は静的型検査が要求する(static-analysis.md §2)。
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と同様ループとして扱う。 - 対象は関数リテラル
{...}のみ(§1.4)。conduitとの併用は不可(透過プロファイルの衝突のため構文エラー)。 loop本体にcaptureを介さず裸のbreak!/continue!を直書きした場合は、§18.1 のとおり「字句的に囲うループの無い break」として panic(ループ性はcaptureで観測したサブブロックを通じて実現され、loop本体そのものは反復されないため)。
self-host 例
反復は inner 内部名(§4.2)の再帰で駆動する(go := {... go ...} は右辺評価時に名が未束縛のため不可)。再送出する return 等の制御値を内側ドライバーから外へ通すため、ドライバーには conduit(§18.3)を付ける。
each := loop { xs: List(Int), body: { Int | Unit } | conduit { inner recur, i: Int | 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: Control | match c { Normal(v) => v Broke(v) => break v Continued => continue! Returned(v) => return v } } # 停止位置(最初に述語が偽になった添字)を求めるドライバー。take_while / drop_while の共通核。 stop_at := conduit { xs: List(Int), pred: { Int | Bool } | conduit { inner recur, i: Int | 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) } take_while := conduit { xs: List(Int), pred: { Int | Bool } | xs.take stop_at(xs, pred) # 述語が false になる手前まで }
(catch-all で Control 値全体に名前を付けるため型付き束縛 c: Control => … を使う。match の値全体束縛は型付き束縛で行う、prelude.md §8.4。)
横流しコンビネーターのうち take_while / drop_while は実際に prelude(prelude.hika)へ self-host 移設済みである(停止位置を上記の stop_at で求め、結果リストの構築は native take / drop へ委ねる)。これにより述語内の break / continue / return がコンビネーターを貫いてユーザーの外側ループ・関数へ届く(native 実装はこれらを map / filter 同様「不可」とエラー化していた)。一方 while / each は native 実装である。partition / sort_by / sort_with も 2 リスト蓄積・native 比較が要るため native である。
なお上記 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 … }では、各アームbodyの末尾式が末尾位置(照合対象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): 定数スタックになるのは self への末尾呼び出しだけである。相互再帰も別関数への末尾呼び出しも結果は正しいが、深さに比例してスタックを使う。定数スタックが要るなら、相互再帰を 1 つの self 再帰関数へまとめる(どちらの側かを引数で持つ)。
- 部分適用の自己呼び出し(新しい Function 値を返すため末尾呼び出しにならない)。
- 標準の制御構文を rebind した関数を通した位置: 末尾位置判定は unshadowed な
if/whenとmatch/conduit/loopを前提とする(§18.1 / §18.2 の透過が同じくこれらを前提とするのと同一)。これらを rebind した関数を貫く位置は定数スタック保証の対象外(スタックにフォールバック、結果は正しい)。
観測可能性
深い自己末尾再帰がスタックを溢れさせず完走することは観測可能な挙動であり、注釈は要らない(@tailrec 相当のマークは持たない — loop / conduit が既にドライバーに付く)。非末尾の再帰を末尾に書き換えれば定数スタックになる、という予測可能な性質として与える。