ヿ(koto)を使い始める
ヿは、ブラウザで動くプログラムをWasmへコンパイルする言語です。S式でプログラムを書き、型、値の移動、regionへのアクセス権限をコンパイル時に検査します。
このチュートリアルでは、小さな計算から始めて、配列、レコードと和型、参照と所有権を使う関数まで書いていきます。実装の細かな制限やホスト向けAPIは言語仕様とホストとAPIにあります。各「結果」を付けたプログラムは、それぞれ単独で実行できます。
1. 最初のプログラム
はじめにの起動手順に従ってブラウザのエディタを開き、次のプログラムを実行してください。
; 結果: 42
(defn main
(params)
(returns i32)
(i32.add 40 2))
(export main)
defnは関数の定義です。mainという名前を付け、引数はないことを(params)、結果が整数であることを(returns i32)で宣言しています。最後の(i32.add 40 2)が関数の結果になります。exportは、ブラウザなどのホストから呼び出せる関数を指定します。エディタは、通常引数・region引数がなく、スカラー型を返すmainを実行します。
ヿでは、(i32.add 40 2)のように操作名を先頭に置き、その後ろに引数を並べます。括弧で囲んだ形をS式と呼びます。次のように入れ子にもできます。
; 結果: 42
(defn main
(params)
(returns i32)
(i32.add (i32.mul 6 6) 6))
(export main)
改行と空白は読みやすさのために入れられます。;から行末まではコメントです。名前には答えなどのUnicode文字も使えます。丸括弧や空白を名前に含めることはできません。
2. 値を束縛して使う
letは、式の結果に名前を付けます。関数の引数と戻り値には型を書きますが、letの型は初期値から推論されます。
; 結果: 42
(defn main
(params)
(returns i32)
(let half 21)
(i32.add half half))
(export main)
整数などのコピー可能な値は、変数名を書くだけで繰り返し使えます。 上の例では、halfの値を足し算の両方の引数に渡しています。関数へ渡した後も、元の束縛を使えます。
配列やポインタも繰り返し使えますが、複製されるのは同じ格納先を指すハンドルです。配列の要素全体を複製する操作ではありません。regionの所有者と、それを含む値はコピーできず、使うと所有権が移ります。所有者の移動は9節で説明します。
3. 型と計算
日常的な計算には次の型を使います。
| 型 | 値の例 | 用途 |
|---|---|---|
i32 |
42、-7 |
32ビット符号付き整数 |
u8 |
(u8.from_i32 42) |
0から255のバイト値 |
f32 |
1.25、-0.5、1e3 |
32ビット浮動小数点数 |
bool |
true、false |
条件の成立・不成立 |
unit |
(unit) |
返す情報がない操作 |
str |
"hello" |
読取り専用の文字列リテラル |
型の間に暗黙の変換はありません。例えば、(i32.add true 1)は型エラーです。
整数にはi32.add(加算)、i32.sub(減算)、i32.mul(乗算)、i32.div_s(除算)、i32.rem_s(剰余)を使えます。比較のi32.eq、i32.ne、i32.lt_s、i32.gt_s、i32.le_s、i32.ge_sは、それぞれ等しい、等しくない、未満、より大きい、以下、以上を判定し、boolを返します。これらの操作は二つの引数を取ります。名前の_sは符号付き整数として計算することを示します。i32.div_u・i32.rem_uやi32.lt_uなどの_u付き演算では、同じビット列を0から4,294,967,295の符号なし整数として扱います。型は引き続きi32です。
i32の範囲は−2,147,483,648から2,147,483,647です。加減乗算の結果は32ビットで折り返します。例えば最大値に1を足すと最小値になります。除算はゼロ方向に切り捨てるため、(i32.div_s -7 3)は-2です。ゼロ除算などの実行できない計算はtrapになります。
u8を作るには(u8.from_i32 255)のように変換します。−1や256など、バイトに収まらない値はtrapになります。計算するときはi32.from_u8で戻します。例えば(i32.add (i32.from_u8 (u8.from_i32 41)) 1)は42です。u8を直接i32.addへ渡すことはできません。
ビット単位の処理にはi32.and・i32.or・i32.xor、左シフトにはi32.shl、右シフトにはi32.shr_s・i32.shr_u、回転にはi32.rotl・i32.rotrを使います。シフトと回転では、量の下位5ビットだけを使います。例えば(i32.shl 1 32)は1です。i32.clz・i32.ctzは上位側・下位側の連続した0の数、i32.popcntは1のビットの数を返します。(i32.eqz value)は値が0かどうかをboolで返します。
小数を扱うときはf32を使います。リテラルには小数点か指数を書きます。1.0と1e0はf32、1はi32です。
; 結果: 3.75
(defn scale (params (value f32) (count i32)) (returns f32)
(f32.mul value (f32.convert_i32_s count)))
(defn main (params) (returns f32)
(scale 1.25 3))
(export main)
f32.add・f32.sub・f32.mul・f32.divで計算し、f32.eqやf32.ltなどで比較します。f32.sqrtは平方根、f32.absは絶対値、f32.negは符号反転です。f32.ceil・f32.floor・f32.trunc・f32.nearestはそれぞれ上方向・下方向・ゼロ方向・最近接偶数への丸めを行います。最小・最大はf32.min・f32.max、別の値から符号を取る操作はf32.copysignです。演算の名前・引数と結果の型の正本は共有定義にあります。
f32では、各計算結果が32ビット浮動小数点数に丸められます。例えば(f32.add 16777216.0 1.0)は16777216.0になります。0で割ると無限大やNaNになり、trapにはなりません。NaNかどうかは(f32.ne value value)で判定できます。NaNの具体的なビット列を計算結果の識別に使わないでください。正の0と負の0は比較では等しい値です。
整数へ戻すときはi32.trunc_f32_sを使います。小数部分をゼロ方向へ切り捨て、NaNや範囲外ならtrapします。i32.trunc_sat_f32_sなら範囲外を最小値・最大値に収め、NaNを0にします。符号なし整数として変換する場合は末尾の_uを使います。ビット列そのものを扱うi32.reinterpret_f32とf32.reinterpret_i32は、数値変換とは異なる操作です。
真偽値のbool.andとbool.orは両方の引数を評価します。条件によって後続の処理を実行したくない場合は、次のifを使います。
4. 分岐、順次実行、スコープ
ifには、条件と式を組にした節を並べます。条件にはboolが必要です。最初に条件が真になった節の式だけを実行し、その結果を返します。最後にelse節を書くと、どの条件も成立しなかったときの式を指定できます。各節の結果の型は一致させます。
; 結果: 7
(defn magnitude
(params (x i32))
(returns i32)
(if
((i32.lt_s x 0) (i32.sub 0 x))
(else x)))
(defn main
(params)
(returns i32)
(magnitude -7))
(export main)
整数のxは、条件の比較にも選ばれた分岐にも使えます。分岐で使っても束縛には値が残るため、分岐の後でも再利用できます。
条件節は複数並べられます。上から順に条件を評価し、最初に真になった節の式を実行します。どれも真でなければ、最後のelse節を実行します。各節の結果型は同じにします。
(defn sign (params (x i32)) (returns i32)
(if
((i32.lt_s x 0) -1)
((i32.gt_s x 0) 1)
(else 0)))
elseは省略できます。その場合、条件が成立しない経路はunitを返すため、各条件節もunitを返す必要があります。例えば(if (enabled (assert valid)))は条件付きの検証になります。(if (enabled 42))はi32とunitが一致しないため型エラーです。
複数の式を一つの場所に書くにはdoを使います。各式を順番に評価し、最後の結果を返します。途中の結果は捨てます。
; 結果: 42
(defn main
(params)
(returns i32)
(let answer
(do
(let x 20)
(i32.add x 22)))
answer)
(export main)
doの中で作ったxは、そのdoの外では使えません。内側のスコープでは、外側と同じ名前を付けることもできます。同じスコープで名前を重複させることはできません。
関数本体も順番に評価される式の列です。letは関数本体かdoの直下に書きます。空の(do)と(unit)は、情報を持たないunitを返します。値を返さない関数では(returns unit)と宣言します。
引数は左から右に評価します。この順序は、移動や配列の更新がある場合にも同じです。
5. 関数と再帰
引数の名前と型はparamsに並べます。呼出し側では、関数名の後に値を順番に渡します。コピー可能な引数は、呼出し後も元の変数から使えます。所有者を含む引数を渡すときは、呼出し先へ所有権が移ります。
; 結果: 720
(defn factorial
(params (n i32))
(returns i32)
(if
((i32.le_s n 1) 1)
(else (i32.mul n (factorial (i32.sub n 1))))))
(defn main
(params)
(returns i32)
(factorial 6))
(export main)
関数は定義順にかかわらず呼び出せます。自身を呼ぶ再帰だけでなく、二つの関数が互いを呼ぶ相互再帰も使えます。
再帰にも読取り参照を渡せます。参照を返したり、参照を含むレコードを渡したりする場合も、通常の関数と同じ借用契約を使います。region所有者の受渡しや、外部セルへ所有者・参照を格納する再帰には制限があります。後半でこれらの値を使うときは、再帰呼出しの制限も確認してください。
paramsとreturnsなどの契約の節は、本体より前に書きます。引数の個数、各引数の型、戻り値の型が契約に合わなければコンパイルエラーになります。関数を値として渡す機能やラムダ式は、まだありません。
途中で結果を返す
returnを使うと、その位置から関数を終えられます。次の関数は、負数なら0を返し、それ以外なら受け取った値を返します。
(defn nonnegative (params (value i32)) (returns i32)
(if ((i32.lt_s value 0) (return 0)))
value)
returnは内側のdoやループからも関数全体を抜けます。返す値はreturnsの型に合わせます。必ず脱出する式の後に、同じ経路で評価される式を置くとE_UNREACHABLEになります。上の例の最後のvalueは、負数でなかった経路で使うため問題ありません。
失敗を宣言して扱う
呼出し元に扱ってほしい失敗はfailsで宣言し、failでその値を返します。成功時の型は引き続きreturnsに書きます。
; 結果: 42
(defn positive (params (value i32)) (returns i32) (fails str)
(if ((i32.le_s value 0) (fail "positive value required")))
value)
(defn add-two (params (value i32)) (returns i32) (fails str)
(i32.add (try (positive value)) 2))
(defn main (params) (returns i32)
(catch (add-two 40) (message 0)))
(export main)
tryは、成功すれば通常の結果を取り出し、失敗すれば今の関数からその失敗を伝播します。呼出し元にも同じ失敗型のfailsが必要です。上のadd-twoでは、positiveが失敗すると足し算を行いません。
catchは失敗したときだけhandlerを評価します。上のmessageには失敗の文字列が入り、handlerは代わりの整数0を返します。(add-two 40)を(add-two 0)へ変えると、mainの結果は0になります。handlerの結果は成功時と同じ型にするか、returnやfailでその関数から抜けます。別の型の失敗へ変換したい場合も、catchしてからfailします。
失敗可能な関数の呼出しには、必ずtryかcatchを付けます。failsを持つ関数はホストへexportできないため、ブラウザから呼ぶmainなどでcatchして処理します。範囲外アクセスやゼロ除算のtrap、assertの失敗はcatchでは回復できません。
6. 更新できる変数と繰返し
letの束縛は再代入できません。計算の途中で値を更新するにはvarで束縛し、setで新しい値を入れます。
; 結果: 55
(defn main
(params)
(returns i32)
(var i 1)
(var total 0)
(while (i32.le_s i 10)
(set total (i32.add total i))
(set i (i32.add i 1)))
total)
(export main)
whileは条件がtrueの間、本体を繰り返します。この例は1から10までを足しています。while自体の結果はunitです。
(set i (i32.add i 1))では、現在のiから計算し、結果を同じ束縛へ代入します。次の反復では更新された値を使います。新しい値の型は、最初に推論された型と同じでなければなりません。
ループの条件と本体は何度も評価されます。整数や配列のハンドルはそのまま繰り返し使えます。外側のregion所有者を使う場合は、条件評価後と各反復の終了時にも所有者を保持している必要があります。return・fail・tryの失敗で関数を抜ける経路では、次の反復に向けた復元は不要です。ループが一度も実行されない可能性もあるため、本体だけで初期化した状態をループ後に期待することはできません。
現在、varに使える型はi32、u8、f32、bool、unit、strと、21節のベクトル型・マスク型です。配列の要素は専用の更新操作で変更し、regionの所有者をsetで上書きすることはできません。
7. 文字列リテラル
strは、プログラムに書き込んだ読取り専用の文字列です。string-lengthでUTF-8のバイト数、string-getで指定位置のバイト値を取得します。位置は0から数えます。
; 結果: 3227
(defn main
(params)
(returns i32)
(let name "ヿ")
(i32.add (i32.mul (string-length name) 1000)
(string-get name 0)))
(export main)
ヿはUTF-8では3バイトで、先頭のバイトは227です。このAPIの添字は文字番号ではありません。日本語などの1文字は複数バイトになることがあります。string-getでバイト列の範囲外を指定するとtrapになります。
文字列の中では、\"で二重引用符、\\でバックスラッシュ、\nで改行、\rで復帰、\tでタブを書けます。文字列は変更できず、同じ文字列のハンドルはコピーできます。文字列リテラルのためにregionを作ったり解放したりする必要はありません。
動的な文字列の連結やUnicode文字単位の走査は、まだ標準操作として提供していません。入力を処理するプログラムでは、次のバイト配列にUTF-8の値を入れて扱えます。
8. regionに配列を置く
regionは、まとめて解放するデータの置き場所です。例えば、一回の解析に使う配列を同じregionに入れ、解析が終わったときにまとめて解放できます。
; 結果: 42
(defn main
(params)
(returns i32)
(new-region (work arena))
(let values (array-new work 3 0))
(array-set values 0 17)
(array-set values 1 25)
(let answer
(i32.add (array-get values 0)
(array-get values 1)))
(region-free arena)
answer)
(export main)
(new-region (work arena))は二つの名前を導入します。
workは、型と権限の中でregionを区別する静的な名前です。arenaは、(Region work)型の所有者です。この値がregionを解放する責任を持ちます。
(array-new work 3 0)は、そのregionに3要素の整数配列を作り、すべての要素を最後の引数の0で初期化します。結果の型は(Array work i32)です。
| 操作 | 使い方 |
|---|---|
array-new |
指定したregionに配列を作る |
array-length |
(array-length values)で要素数を取得する |
array-get |
指定した要素の値を読む |
array-set |
指定した要素を同じ型の値で更新する |
array-getは要素型の値を返します。配列から要素を取り除く操作ではありません。array-setの結果はunitです。
配列のハンドルは、valuesのように変数名を書くだけで何度でも使えます。ハンドルを別の変数へ束縛しても配列自体は一つなので、一方のハンドルで更新した要素を、他方から読むことができます。
初期値をu8にすると、1要素1バイトの配列になります。
(defn main (params) (returns i32)
(new-region (work owner))
(let bytes (array-new work 3 (u8.from_i32 0)))
(array-set bytes 1 (u8.from_i32 42))
(i32.from_u8 (array-get bytes 1)))
(export main)
bytesの型は(Array work u8)です。要素を読むとu8が返り、更新にもu8を渡します。添字と要素数は要素型にかかわらずi32です。(Array r i32)を受け取る関数へバイト配列を渡すことはできません。
(array-new work 3 0.0)は(Array work f32)を作ります。要素は4バイトの浮動小数点数で、array-getもf32を返します。
初期値にtrueを渡せば(Array work bool)、文字列を渡せば(Array work str)になります。配列のハンドルも要素にできるので、配列の配列も作れます。それぞれの配列にアクセスするときは、その配列が属するregionの権限を使います。
作成した配列の要素数は固定です。regionには、その後も別の配列を追加できます。
添字は0以上、要素数未満でなければなりません。範囲外のアクセスはtrapになります。配列を作るためのメモリを確保できなかった場合もtrapになり、その実行を継続しません。
9. 所有者、解放、残ったハンドル
regionの所有者を値として使うと、元の束縛から所有権が移ります。 例えば(let next arena)で所有者を移すと、以後はnextを使い、arenaの再使用はE_MOVEDになります。所有者を含むレコードや和型にも同じ規則を適用します。
region-freeは所有者を消費し、そのregionの配列をまとめて解放します。同じ所有者を二度使うことはできません。個々の配列に対するfreeやデストラクタはありません。
明示解放を書かなかったregionも、所有者を外へ移していなければ自動的に解放されます。コンパイラは最後の必要な利用を調べ、安全に解放できる位置を選びます。スコープの末尾まで使わない一時配列なら、後の処理へ進む前にそのregionを回収できます。明示的にregion-freeを書くこともできます。
ループで繰り返し使う配列は、後の反復でも必要なので途中では解放しません。returnや宣言された失敗で関数を抜ける場合も、その経路で残った所有者を自動解放します。返す値に必要なregionや参照は保護します。後で説明する参照の利用や、他のregionの所有者を格納した値も検査に含めます。trapした際の後処理は保証しません。
regionの寿命は、作った順序の逆でなければならないわけではありません。
; 結果: 42
(defn main
(params)
(returns i32)
(new-region (first first-owner))
(let temporary (array-new first 1 0))
(new-region (second second-owner))
(let result (array-new second 1 0))
(array-set result 0 42)
(region-free first-owner)
(array-get result 0))
(export main)
secondが生きている途中でfirstを解放しています。secondは最後の読取りの後に自動的に解放されます。
ここでtemporaryというハンドルは、firstを解放するときにも存在しています。ハンドルを持っているだけではregionを生存させません。解放したregionへのアクセス権限は失われるため、その後にtemporaryから要素を読むことはコンパイルエラーになります。ハンドルのコピーを残しておいても同じです。
; コンパイルエラー: E_CAPABILITY
(defn main
(params)
(returns i32)
(new-region (work arena))
(let values (array-new work 1 42))
(region-free arena)
(array-get values 0))
(export main)
この区別により、データ間にハンドルのつながりがあっても、アクセスしなくなったregionを独立して解放できます。ハンドルは「場所」を表し、所有者と権限は「その場所を解放・利用してよいか」を管理します。
現在の検査器では、ifの各分岐で外側のregion所有者の扱いを揃えます。ある分岐だけで所有者を移動・解放して、他の分岐で保持する書き方は認めません。分岐後に共通の解放を書くか、各分岐がそれぞれ作ったregionを内部で処理します。
10. 関数のアクセス権限
配列を関数へ渡す場合は、その配列のregionと、関数が行う操作を契約に書きます。
; 結果: 42
(defn sum-first-two
(regions r)
(params (values (Array r i32)))
(returns i32)
(requires (read r))
(i32.add (array-get values 0)
(array-get values 1)))
(defn main
(params)
(returns i32)
(new-region (work arena))
(let values (array-new work 2 0))
(array-set values 0 17)
(array-set values 1 25)
(sum-first-two values))
(export main)
(regions r)は「呼出し元のregionをrと呼ぶ」という宣言です。ここでregionを新しく作るわけではありません。sum-first-twoを呼ぶと、実引数の(Array work i32)からrがworkに対応すると推論されます。
(requires (read r))は、そのregionを読む権限を呼出し元に要求します。配列のハンドルを受け取るだけでは、読む権限を得られません。
使い分ける権限は次の三つです。
| 権限 | 許可される操作 |
|---|---|
read |
配列の読取り・要素数の取得、ポインタからの読取り借用 |
write |
読取りと既存の配列・セルの更新 |
alloc |
新しい配列・セルの割当 |
writeがあれば読むこともできますが、新しい配列を作るにはallocを別に要求します。新しく作った配列を更新する関数なら、writeとallocを両方書きます。権限なし、read、write、alloc、read + alloc、write + allocの六つの組合せを使い分けられます。
同じregionを二つのregionパラメータへ渡すこともできるので、関数の中では、一方のregionパラメータから作った読取り参照(14節)を使い終える前に、もう一方のregionパラメータを通じて更新・割当するとE_BORROWになります。
regionを解放する責任は、これらの権限とは別です。write + allocを持っているだけでregionを解放することはできません。解放にはRegion rの所有者を必要とします。
11. 呼出し元のregionに結果を作る
関数から配列を返すときは、呼出し元が用意したregionに結果を置きます。こうすれば、関数が終了した後も、呼出し元がregionの寿命を管理できます。
; 結果: 42
(defn singleton
(regions output)
(params (value i32))
(returns (Array output i32))
(requires (alloc output))
(array-new output 1 value))
(defn main
(params)
(returns i32)
(new-region (work arena))
(let values (call singleton (regions work) 42))
(array-get values 0))
(export main)
singletonの引数は整数だけなので、どのregionに割り当てるかを引数の型から推論できません。この場合は(call singleton (regions work) 42)のように、regionを明示します。regionが複数ある関数では、宣言した順に対応するregionを指定します。
array-newによる新しい配列の初期化には、allocだけを使います。既に存在する配列をarray-setで変更する場合にwriteが必要です。
region名を渡すことは、所有者arenaを移動することではありません。呼出し元は所有者を保持し、必要な操作権限を呼出し先へ貸します。戻ってきた配列を最後まで使った後で、regionを解放できます。
関数内だけで必要な一時配列には別のregionを作れます。その一時regionの配列を、呼出し元のregionに属する配列として返すことはできません。型の中のregion名が、保存先の取り違えを防ぎます。
所有者自体を渡したい場合は、通常の引数として(owner (Region r))を宣言します。この引数は移動され、呼出し先へ解放責任も渡ります。所有者はコピーできないため、二つの関数が同じregionを独立に解放することはありません。
; 結果: 42
(defn release
(regions r)
(params (owner (Region r)))
(returns unit)
(region-free owner))
(defn main
(params)
(returns i32)
(new-region (work arena))
(let values (array-new work 1 42))
(let answer (array-get values 0))
(release arena)
answer)
(export main)
このreleaseは操作権限を借りる関数ではなく、所有者を受け取って解放する関数です。同じregionについて、所有者の引数とrequiresを同時に指定することはできません。
12. レコードで値をまとめる
複数の値を一つの値として扱うにはdefrecordで型を定義します。
; 結果: 42
(defrecord Pair (fields (left i32) (right i32)))
(defn sum (params (pair Pair)) (returns i32)
(unpack pair (a b) (i32.add a b)))
(defn main (params) (returns i32)
(let pair (record Pair (left 17) (right 25)))
(sum pair))
(export main)
recordでは各フィールドを名前で指定します。すべてのフィールドをちょうど一度ずつ指定し、式は書いた順に評価します。unpackは定義のフィールド順に中の値を取り出します。コピー可能なレコードの束縛はその後も使えます。所有者を含むレコードを分解するときは、その所有権を取り出したフィールドへ移します。aとbはunpackの本体でだけ使えます。
レコードは名前で区別します。別に定義した型は、フィールドの名前と型が同じでもPairの代わりにはなりません。レコード自体を作るためにregionを指定する必要はありません。
IDなどの値を区別するため、(defrecord ItemId (fields (index i32)))のように1フィールドのレコードも使えます。このItemIdはWasm上では整数1個で渡され、構築や引数・戻り値の受け渡しで作業用メモリを確保しません。ソースでは通常の整数や別のID型と混ぜられず、recordで作り、field-copyやunpackで取り出します。他のフィールド型についての表現条件はホスト境界の仕様を参照してください。
フィールドがすべてコピー可能なら、レコードも変数名を書くだけで繰り返し使えます。この例では整数二つを値として受け渡します。フィールドが配列やポインタなら、そのフィールドのコピーはハンドルの複製になります。regionの所有者を含むレコードはコピーできません。
必要なフィールドだけを取り出すには、field-copyを使います。
; 結果: 42
(defrecord Pair (fields (left i32) (right i32)))
(defn main (params) (returns i32)
(let pair (record Pair (left 17) (right 25)))
(let left (field-copy pair left))
(i32.add left (field-copy pair right)))
(export main)
(field-copy pair left)はpairを残して、leftフィールドの値だけをコピーします。第1引数にはレコードを保持する変数名を書きます。関数呼出しなどの式から取得したい場合は、先にletで名前を付けます。結果の型はそのフィールドの型です。
コピーできるかどうかは、選んだフィールドについて検査します。たとえば整数とregionの所有者を含むレコードから整数を取得できますが、所有者そのものはコピーできません。所有者を含む元のレコードは残るため、最後にはunpackなどでその所有義務を扱います。ポインタのフィールドを取得するだけではregionの権限は使わず、そのポインタを辿る際に権限が必要です。参照のフィールドを取得すると、元の参照の借用制約を引き継ぎます。
13. 和型で場合分けする
「値がない」「整数がある」のような選択肢には、defenumとmatchを使います。
; 結果: 42
(defenum NumberResult
(case Missing)
(case Found (value i32)))
(defn unwrap-or-zero
(params (result NumberResult)) (returns i32)
(match result
(Missing () 0)
(Found (number) number)))
(defn main (params) (returns i32)
(unwrap-or-zero (variant NumberResult Found (value 42))))
(export main)
variantは型名、ケース名、そのケースのフィールドを指定します。データのないケースは(variant NumberResult Missing)で作れます。
matchはケースに応じた本体を実行します。コピー可能な値はその後も使え、所有者を含む値はmatchへ移動します。各ケースは一度ずつ必要です。Missingに値がなくても、パターンの空の括弧()を書きます。フィールドの束縛は定義順で、全ケースの結果型を揃えます。処理していないケースがあるとE_EXHAUSTIVEになります。
14. ポインタと読取り参照
レコードを共有して更新する場合は、allocでregion内のセルへ置きます。結果はその場所を指すPtrです。
; 結果: 42
(defrecord Pair (fields (left i32) (right i32)))
(defn main (params) (returns i32)
(new-region (work arena))
(let pointer (alloc work (record Pair (left 17) (right 99))))
(replace-field pointer right 25)
(let view (borrow pointer))
(let left (read-copy (field-ref view left)))
(let right (read-copy (field-ref view right)))
(i32.add left right))
(export main)
pointerは(Ptr work Pair)型です。replace-fieldは指定したフィールドを更新し、古い値を返します。この例では返された整数99を捨てています。replaceならセル全体を同じ型の新しい値に置き換え、古い値全体を返します。
レコードを添字で管理する表には、そのレコードを指すポインタの配列を使えます。
; 結果: 42
(defrecord Entry (fields (value i32)))
(defn main (params) (returns i32)
(new-region (work arena))
(let empty (alloc work (record Entry (value 0))))
(let entries (array-new work 2 empty))
(array-set entries 1 (alloc work (record Entry (value 17))))
(let selected (array-get entries 1))
(replace-field selected value 42)
(read-copy (field-ref (borrow selected) value)))
(export main)
entriesは(Array work (Ptr work Entry))です。配列から取り出すのはハンドルなので、selectedからの更新は同じセルに反映されます。emptyは初期化用の番兵セルです。複数の要素をこのセルで初期化した場合、その要素は同じセルを指します。別々の値にするには、例のようにそれぞれのセルを割り当てて配列へ入れます。
配列から得たポインタ経由では、個々のセルに入っているregion所有者や参照の移動・更新を追跡できません。その追跡を必要とする操作は拒否されます。レコードそのものやregion所有者、Refは配列要素にできません。
borrowはポインタから読取り参照を作ります。型は(Ref b Pair)で、bはここではコンパイラが推論する借用期間です。field-refでフィールドの参照へ進み、read-copyでその参照先の整数をコピーしています。viewを複数の式で使うと、同じ格納先を指す参照がコピーされます。
Ptrを保持するだけではregionを生存させません。一方、Refを使っている間は、参照先のregionへの更新・割当・解放を防ぎます。例えば次は拒否されます。
; コンパイルエラー: E_BORROW
(defn main (params) (returns i32)
(new-region (work arena))
(let pointer (alloc work 17))
(let view (borrow pointer))
(replace pointer 42)
(read-copy view))
(export main)
viewを最後に読む前に、同じregionの値を書き換えようとしています。別のフィールドや別のセルでも、同じregionならこの制約を受けます。参照を先に使い終えれば更新できます。
; 結果: 42
(defn main (params) (returns i32)
(new-region (work arena))
(let pointer (alloc work 17))
(let view (borrow pointer))
(let before (read-copy view))
(replace pointer 25)
(i32.add before (read-copy (borrow pointer))))
(export main)
beforeは独立した整数なので、その後の更新に影響されません。参照をコピーした場合は、そのコピーも使い終わるまで借用が続きます。同じregionへの新しいセルの割当や、そのregionのallocを要求する関数の呼出し、そのregionの所有者をセルへ格納することも、参照を使い終えてから行います。割当はregionを大きな領域へコピーして移すことがあり、参照は移動前の場所を指したままになるためです。所有者を格納しない限り、別のregionへの割当は参照を使っている間にも行えます。
和型を所有権を取り出さずに調べるには、match-refを使います。
; 結果: 42
(defenum NumberResult (case Missing) (case Found (value i32)))
(defn main (params) (returns i32)
(new-region (work arena))
(let pointer (alloc work (variant NumberResult Found (value 42))))
(match-ref (borrow pointer)
(Missing () 0)
(Found (value) (read-copy value))))
(export main)
Foundのvalueは整数そのものではなく、整数へのRefです。その参照が後で使われる間は、元のセルをMissingなど別のケースへ置き換えることもできません。Tがコピー不可でもRef b Tを作れますが、read-copyにはコピー可能なTが必要です。
15. 関数に参照を渡す、参照を返す
参照を扱う関数ではborrowsで借用期間に名前を付けます。これはregion名とは別の種類のパラメータです。
; 結果: 42
(defrecord Pair (fields (left i32) (right i32)))
(defn left-view
(regions r) (borrows (b r))
(params (pointer (Ptr r Pair))) (returns (Ref b i32))
(requires (read r))
(field-ref (borrow b pointer) left))
(defn read-number
(borrows b)
(params (view (Ref b i32))) (returns i32)
(read-copy view))
(defn main (params) (returns i32)
(new-region (work arena))
(let pointer (alloc work (record Pair (left 42) (right 0))))
(read-number (left-view pointer)))
(export main)
left-viewの(borrows (b r))は「返す参照の借用bはregion rに依存する」と宣言しています。(borrow b pointer)でその名前の参照を作ります。呼出し元でも、返された参照を使い終わるまではworkを更新・割当・解放できません。
read-numberは既に作られた参照を受け取ります。参照の持つ証明で読めるため、region名やrequires (read ...)を重ねて要求する必要はありません。呼出し時の借用パラメータは引数から推論します。
参照をレコードに入れて渡したい場合は、型定義にもborrowsを付けられます。同様に、ポインタを含む型にはregionsを付けます。
; 型定義の例。関数本体を含まない。
(defrecord View (borrows b) (fields (value (Ref b i32))))
(defrecord Link (regions r) (fields (target (Ptr r i32))))
これらは(View b)、(Link r)と書いて使います。参照を包んでも借用は消えません。一方、レコード内のポインタを読み出してさらに辿るには、そのポインタが指すregionのread権限が必要です。
ポインタを介すれば、型が自分自身を参照するデータ構造も作れます。
; 結果: 42
(defenum Chain (regions r)
(case End)
(case Link (number i32) (rest (Ptr r (Chain r)))))
(defn total (regions r) (borrows b)
(params (chain (Ref b (Chain r)))) (returns i32)
(requires (read r))
(match-ref chain
(End () 0)
(Link (number rest)
(i32.add (read-copy number)
(total (borrow (read-copy rest)))))))
(defn main (params) (returns i32)
(new-region (work arena))
(let end (alloc work (variant (Chain work) End)))
(let tail
(alloc work (variant (Chain work) Link (number 25) (rest end))))
(let head
(alloc work (variant (Chain work) Link (number 17) (rest tail))))
(total (borrow head)))
(export main)
Linkは次のChainへのポインタを持ちます。各セルの大きさを有限にできるため、この再帰は扱えます。自分自身の値をポインタなしで直接フィールドへ含む循環は、有限の大きさを持たないので拒否します。
totalは参照で現在のセルを読み、次のポインタをread-copyで取り出してborrowし直します。この新しい借用のために(requires (read r))が必要です。参照を返す再帰でも、呼出し元はその参照を使い終わるまで元のregionを更新・割当・解放できません。宣言した失敗値に参照を含める場合も同じです。
16. 所有者を含むデータを処理する
レコードや和型には、別のregionの所有者を入れられます。この場合、元の束縛から所有権が移り、データ構造がその所有義務を持ちます。オブジェクトごとの暗黙のデストラクタはありません。所有者を含む値を、そのまま捨てることはできません。
次の例は、子regionの所有者を親regionのセルに格納し、後で取り出して処理します。
; 結果: 42
(defenum MaybeOwner (regions child)
(case Empty)
(case Owned (owner (Region child))))
(defn main (params) (returns i32)
(new-region (parent parent-owner))
(new-region (child child-owner))
(let slot
(alloc parent (variant (MaybeOwner child) Owned (owner child-owner))))
(let previous (replace slot (variant (MaybeOwner child) Empty)))
(region-free parent-owner)
(match previous
(Empty () 42)
(Owned (owner) (region-free owner) 42)))
(export main)
replaceによって、セルには所有義務のないEmptyが入り、古いOwnedはpreviousに移ります。そのため、親regionを先に解放できます。matchで子の所有者を取り出して解放すると、両方のregionの処理が完了します。
Ownedを格納したまま親regionを解放すると、子の所有者を失うためE_STORAGEになります。previousを処理せず捨てることもできません。レコードならunpackで所有者を取り出します。取り出したRegionが単独の束縛になれば、明示的なregion-freeの代わりに通常の自動解放も使えます。returnやfailで早く抜ける場合にも、完成した所有者入りの値を未処理で残せません。失敗値として渡した場合は、受け取るhandler側で処理します。
所有者を含む値のコピーや、読取り参照から所有者をread-copyで取り出す操作はできません。replaceは、古い所有値を失わずに新しい値を入れるための操作です。ループでこの処理を行う場合は、各反復の中で所有者を取り出して処理します。条件や本体で外側のregionに割り当てたセルへ、未処理の所有者や新しい借用を残したまま次の反復へ進むことはできません。
17. エラーを読む
ヿは、実行前に確認できる問題をコンパイルエラーとして報告します。代表的なものは次のとおりです。
- 括弧の対応が取れていない。
- 宣言した型と式の結果が一致しない。
- 移動した束縛をもう一度使っている。
- 関数が要求するregionの権限を持っていない。
- 解放したregionの配列やポインタへアクセスしようとしている。
- 参照を後から使うのに、その前に同じregionを更新・割当・解放しようとしている。
- 和型のケースをすべて扱っていない。
- 所有者を含む値を処理せずに捨てようとしている。
診断には問題のあるソース位置が付くので、まずその式と、同じ変数の直前の使用箇所を確認します。移動エラーなら、所有者を渡した先と、その後に同じ所有者を使っている箇所を確認します。権限エラーなら、関数が実際に行う操作とrequiresを照らし合わせます。借用エラーのE_BORROWでは、更新・割当するregionに依存する参照が後で使われていないか確認します。関数の中では、同じregionを指しうる他のregionパラメータに依存する参照も対象になります(10節)。E_STORAGEでは、格納した所有者が未処理か、ポインタの別名関係を検査器が確定できない可能性があります。
E_EFFECTなら、failsの宣言、呼出しのtry・catch、伝播する失敗の型を確認します。E_UNREACHABLEなら、returnやfailによって到達しなくなった後続の式を取り除くか、分岐の位置を見直します。
E_RECURSIONは、再帰の解析で扱えない所有権や格納効果を示します。region所有者を再帰へ渡していないか、外部セルに所有者や参照を格納していないか確認します。読取り用のRefを渡す再帰や、各呼出しの中で作業用regionを作って解放する処理は使えます。詳しい範囲は再帰呼出しの制限を参照してください。
実行時にだけ分かる問題にはtrapがあります。例えば配列の範囲外アクセス、ゼロ除算、メモリ確保の失敗、深すぎる再帰によるスタックの枯渇です。ブラウザの実行画面はtrapした実行を終了し、原因の名前と説明、範囲外アクセスなどの検査でtrapした場合はその位置、呼び出したexportを表示します。テスト結果にも、trapで終わったテストの原因の名前が付きます。失敗後の状態で処理を続けず、修正したプログラムを新しく実行してください。無限ループなどで処理が終わらない場合は「停止」を使えます。
18. テストを書く
deftestでテストをソースに書けます。名前は文字列で指定し、同じソース内で重複しないようにします。次のプログラムをブラウザのエディタへ貼り付けて「テスト実行」を押すと、二つのテストが成功します。テストの実行にはmainやexportは不要です。
(defn sum-two
(regions r)
(params (values (Array r i32)))
(returns i32)
(requires (read r))
(i32.add (array-get values 0) (array-get values 1)))
(deftest "配列の値を足す"
(new-region (work arena))
(let values (array-new work 2 21))
(let total (sum-two values))
(assert (i32.eq total 42))
(assert (i32.gt_s total 0)))
(deftest "範囲外アクセス"
(new-region (work arena))
(let values (array-new work 2 0))
(expect-trap (array-get values 2)))
assertは真偽値を受け取り、真なら処理を続け、偽ならそのテストを終了して失敗位置を報告します。上の例で42を43へ変えると、一つ目のassertが失敗します。assertは通常の関数でも使え、通常のプログラムとしてコンパイルした場合も検証します。
テストでも、型・所有者の移動・region・借用の規則は同じです。整数のtotalは二つの検証で繰り返し使えます。テスト本体は引数を取らず、最後の結果はunitにします。必要なregionは本体で作成でき、安全な位置で自動解放されます。
テスト自体にはfailsを宣言できません。失敗可能な関数はcatchして、その値をassertで確認します。例えば、先ほどのpositiveなら次のように書けます。
; 断片: positiveは「失敗を宣言して扱う」の例の関数。
(deftest "正でない入力を拒否する"
(let result (catch (positive 0) (message -1)))
(assert (i32.eq result -1)))
tryでテストの外へ失敗を渡すことはできません。catchで処理した失敗はtrapではないため、その処理をexpect-trapで囲んでも成功しません。
trapすることを確かめるには、expect-trapをテスト本体の最後に直接置きます。対象の式がtrapすれば成功し、正常終了すれば失敗します。それより前の準備処理で起きたtrapや、対象内のassertの失敗は成功として扱いません。expect-trapをifやdoの内側に置くことはできません。
各テストは新しいWasmインスタンスで実行されるため、他のテストのメモリやtrap後の状態を引き継ぎません。終了しないテストは「停止」で中断できます。中断はテストの成功にはなりません。
通常のコンパイルでもテスト本体の型や所有権を検査しますが、テストの実行コードは含めません。APIから実行するときはmode: "test"でコンパイルしてrunTestsへ渡します。返されるテスト一覧、結果、ソース位置の形はホストとAPIのテストAPIを参照してください。
コンパイルエラーを確認するテストは、deftestの中には書けません。処理系の検証では、拒否されるソースをそれぞれ独立してコンパイルし、manifestに指定した診断コードと照合しています。
19. ブラウザとセルフホスト
ヿのプログラムはWasmとして実行されます。ブラウザ側は、ユーザー入力、結果の表示、Wasmインスタンスの作成などを受け持ちます。通常のプログラムからブラウザのDOMやファイルシステムを直接操作する機能は、まだ提供していません。
コンパイラ自身もヿのソースで書かれています。ソースのUTF-8バイト列を配列で受け取り、構文解析と検査を行い、生成したWasmのバイト列を必要な長さの配列に確保して返します。
実際のレコードの使用例は、借用解析のLoanContextにあります。一緒に渡す配列ハンドルと関数ごとの集合を一つの値にまとめ、関数内でunpackして使います。ソース整理のCleanupContextでは、必要なフィールドだけをfield-copyで取り出す処理もあります。操作分類のAdvancedOperationは和型の使用例です。操作に応じた情報をケースに持たせ、matchで処理を分けています。
コンパイラ用のコレクションも、通常のヿの型と関数で実装しています。collectionsモジュールの必要な名前をimportして使います。ByteBuilderにbyte-builder-stringで静的文字列のUTF-8バイトを追記し、byte-builder-viewでByteSliceを得られます。sliceは(Array r u8)・開始位置・長さを持つ値です。byte-slice-getで読むとu8が返り、byte-builder-pushへもu8を渡します。builderが成長しても、以前に得たsliceの範囲は変わらず、所属regionが生きている間は読み取れます。読取り器のテストでこの組合せを使っています。
整数の作業領域にはIntBuffer、重複しない整数の集合にはIntSetを使えます。バッファは容量と初期化済みの長さを分け、通常の取得・更新は初期化済み範囲だけを扱います。reserveで容量を準備した後、push-reservedを使えば追記操作にalloc権限を渡す必要がありません。他のプログラムに含める際の依存関係はコンパイラ用ライブラリを参照してください。
このコンパイラはブラウザのWasm上で自分自身のソースをコンパイルできます。この状態をセルフホストと呼びます。再生成したコンパイラも同じ言語機能を扱い、共通のヿテストと、コンパイラ自身のソース内にあるdeftestを通ることを検証します。
ブラウザのエディタは、検証済みのWasmコンパイラseedを読み込み、ユーザーのプログラムをコンパイルします。自己再コンパイルはseed更新時と検証時に行います。具体的な起動手順ははじめに、検証手順は開発を参照してください。
ホストに公開する関数はexportで選びます。参照や借用パラメータを持つ型を引数・戻り値に含む関数は、ホストからの呼出しを借用検査で確かめられないためexportできません。公開名memoryと__koto_で始まる名前は処理系が使います。ホストから任意の関数を呼ぶ際の引数表現や、配列の転送方法は言語仕様のホスト境界に従ってください。
20. モジュールで公開範囲を分ける
moduleで関連する型と関数をまとめ、publicで外部から使える名前を選べます。利用側はimportで必要な名前を取り込みます。
; 結果: 42
(module pairs
(public Pair make-pair sum)
(defrecord Pair (fields (left i32) (right i32)))
(defn make-pair (params (left i32) (right i32)) (returns Pair)
(record Pair (left left) (right right)))
(defn sum (params (pair Pair)) (returns i32)
(i32.add (field-copy pair left) (field-copy pair right))))
(import pairs (Pair make-pair sum))
(defn main (params) (returns i32)
(sum (make-pair 17 25)))
(export main)
モジュールの外側はルートです。この例ではルートのmainがpairsの公開関数を呼び出します。モジュールを使うために定義順を合わせる必要はありません。publicに含めない関数や型は、そのモジュールの内側だけで使えます。ルートで定義した関数も、名前付きモジュールから自動的に見えることはありません。
Pairという型を公開しても、そのフィールドを外部から操作できるようにはなりません。外部の関数はPairを受け取って渡したり、コピー可能なら繰り返し使ったりできますが、作成やフィールド取得は定義したモジュールの関数に任せます。これにより、作成時や更新時の規則を公開関数にまとめられます。
利用側にも表現を公開したい型では、publicの型名を(representation Pair)にします。
; 結果: 42
(module pairs
(public (representation Pair))
(defrecord Pair (fields (left i32) (right i32))))
(import pairs (Pair))
(defn main (params) (returns i32)
(let pair (record Pair (left 17) (right 25)))
(i32.add (field-copy pair left) (field-copy pair right)))
(export main)
この場合、外部でもrecord・unpack・field-copyなどを使えます。和型についても同じ指定でケースの構築やパターン照合を公開できます。表現を隠しても、所有権の規則は変わりません。regionの所有者を含む型は、外部でもコピーしたり未処理のまま捨てたりできません。
コンパイラ自身では、関数表のFunctionTableと束縛表のBindingTableが不透明型の使用例です。内部の配列と件数を隠し、登録・情報の取得・更新を公開関数にまとめています。登録する情報は、表現を公開した宣言レコードの名前付きフィールドで渡します。ノード表のNodeTableも配置を隠し、構文や解析情報を用途に応じた関数で読み書きします。
検査状態のCompilerStateも内部表現を隠します。関数を再帰的に検査するときは、呼出し元の状態をFunctionCheckFrameとして保存・復元します。利用側は、その状態が何個のフィールドを持つかを知る必要がありません。
関数と型の名前は別々に管理します。同じモジュールで同名の関数と型を定義した場合、通常のpublicとimportは該当する両方を扱います。異なるモジュールの同名型は別の型です。importは名前を変更しないため、同じ名前空間で名前が衝突する場合は定義側の名前を分けます。
モジュールの入れ子、同名モジュールへの定義の追加、importした名前の再公開、循環するimportは認めません。exportはホストから呼べるルート自身の関数を選ぶ宣言です。モジュールの関数をホストへ公開する場合は、最初の例のmainのようにルートに呼出し用の関数を書きます。
importは入力ソース内の名前を解決します。ファイルを検索したり、ネットワークからパッケージを取得したりはしません。ファイルを分ける場合は、ホストがその内容を改行で連結し、一つのソースとしてコンパイラへ渡します。
21. ベクトルで複数の値を同時に計算する
128ビットのベクトルを使うと、同じ計算を複数の値へ一度に適用できます。ベクトルの各要素をlaneと呼びます。
| 型 | lane | 用途 |
|---|---|---|
i8x16 |
16個の8ビット整数 | バイト列の比較・検索 |
i16x8 |
8個の16ビット整数 | 16ビット幅の整数計算 |
i32x4 |
4個のi32 |
整数計算 |
f32x4 |
4個のf32 |
浮動小数点数の計算 |
m8x16・m16x8・m32x4 |
16・8・4個の真偽値 | 比較結果のマスク |
型名を先頭に書くと、laneの値を並べてベクトルを作れます。splatは全laneを同じ値にします。演算の名前はf32x4.addのように型名から始まり、laneごとに計算します。(lane v k)はlane kの値を取り出します。
; 結果: 6.25
(defn main (params) (returns f32)
(let values (f32x4 1.0 2.0 3.0 4.0))
(let scaled (f32x4.add values (f32x4.mul values (f32x4.splat 0.25))))
(f32.add (lane scaled 1) (lane scaled 2)))
(export main)
scaledはvaluesの各laneにその0.25倍を足した(f32x4 1.25 2.5 3.75 5.0)です。laneのkには整数リテラルを書き、範囲外の番号はコンパイルエラーになります。(with-lane v k x)はlane kだけを置き換えた新しいベクトルを返します。i8x16とi16x8のlaneはi32で扱い、取り出すときは符号付きのi8x16.extract_lane_sか符号なしのi8x16.extract_lane_uのように符号の扱いを選びます。
配列の連続する要素は、ブロックロードでまとめて読めます。
; 結果: 2.5
(defn average4
(regions r)
(params (values (Array r f32)) (start i32))
(returns f32)
(requires (read r))
(let block (f32x4.load values start))
(f32.div
(f32.add (f32.add (lane block 0) (lane block 1)) (f32.add (lane block 2) (lane block 3)))
4.0))
(defn main (params) (returns f32)
(new-region (work arena))
(let values (array-new work 6 0.0))
(array-set values 1 1.0)
(array-set values 2 2.0)
(array-set values 3 3.0)
(array-set values 4 4.0)
(average4 values 1))
(export main)
(f32x4.load values start)のlane jは要素start + jです。読む4要素がすべて配列に収まるか、つまりstartが0以上でstart + 4が要素数以下かを一回だけ検査し、収まらなければtrapになります。配列の要素と同じく(requires (read r))が必要で、書き込むf32x4.storeにはwriteが必要です。次のテストは、4要素の配列で添字0からは読め、添字1からはtrapすることを確かめます。
(deftest "4要素の配列からブロックを読む"
(new-region (work arena))
(let values (array-new work 4 1.5))
(assert (f32.eq (lane (f32x4.load values 0) 3) 1.5))
(expect-trap (f32x4.load values 1)))
比較の結果はマスクです。マスクの各laneは真偽値を表します。m8x16.bitmaskはlane jの真偽をビットjに置いた整数を返すので、i32.ctzで最初に真になったlaneを見つけられます。
; 結果: 5
(defn first-space
(regions r)
(params (bytes (Array r u8)))
(returns i32)
(requires (read r))
(let found (i8x16.eq (i8x16.load bytes 0) (i8x16.splat 32)))
(if
((m8x16.any found) (i32.ctz (m8x16.bitmask found)))
(else -1)))
(defn main (params) (returns i32)
(new-region (work arena))
(let bytes (array-new work 16 (u8.from_i32 97)))
(array-set bytes 5 (u8.from_i32 32))
(array-set bytes 9 (u8.from_i32 32))
(first-space bytes))
(export main)
m8x16.anyはいずれかのlaneが真かをboolで返します。マスクはifの条件に直接使えないため、anyやallで真偽値にします。(i32x4.select a b m)は、mのlaneが真ならa、偽ならbのlaneを選びます。
; 結果: 16
(defn main (params) (returns i32)
(let values (i32x4 -3 7 -1 9))
(let zeros (i32x4.splat 0))
(let positive (i32x4.select values zeros (i32x4.gt_s values zeros)))
(i32.add (i32.add (lane positive 0) (lane positive 1))
(i32.add (lane positive 2) (lane positive 3))))
(export main)
マスクと整数ベクトルは別の型で、暗黙にも明示的にも変換しません。整数ベクトルが必要ならselectで値を選び、マスクが必要なら比較を使います。
ベクトルとマスクは整数と同じくコピー可能です。let・var・引数・戻り値・レコードのフィールドに使えますが、配列の要素にはできません。全laneの和のような集計は、shuffleでlaneを並べ替えて足し合わせます。(shuffle a b k...)は、aのlane 0〜3にbのlane(番号4〜7)を続けた列から、添字kで選んだlaneを並べます。
; 結果: 136
(defn sum
(regions r)
(params (values (Array r i32)))
(returns i32)
(requires (read r))
(var total (i32x4.splat 0))
(var i 0)
(while (i32.le_s (i32.add i 4) (array-length values))
(set total (i32x4.add total (i32x4.load values i)))
(set i (i32.add i 4)))
(let pairs (i32x4.add total (shuffle total total 2 3 0 1)))
(let sums (i32x4.add pairs (shuffle pairs pairs 1 0 3 2)))
(lane sums 0))
(defn main (params) (returns i32)
(new-region (work arena))
(let values (array-new work 16 0))
(var i 0)
(while (i32.lt_s i 16)
(array-set values i (i32.add i 1))
(set i (i32.add i 1)))
(sum values))
(export main)
このsumは要素数が4の倍数の配列を想定しています。残りの要素がある場合は、array-getで読んで足します。
ベクトルやマスクはJavaScriptとの境界を越えられないため、引数や戻り値に含む関数はexportできません。例ではベクトルを関数内で使い、mainはスカラーを返しています。
; コンパイルエラー: E_EXPORT
(defn double (params (values f32x4)) (returns f32x4)
(f32x4.add values values))
(export double)
f32x4.min・f32x4.maxはスカラーのf32.min・f32.maxと同じくNaNを伝播し、−0を+0より小さい値として扱います。NaNと符号付きゼロの扱いが不要なら、x86で1命令になるf32x4.pmin・f32x4.pmaxを使えます。
名前にrelaxed_を含む演算は、ホストごとに速い方法を選べる代わりに、結果がホストによって異なることがあります。例えばf32x4.relaxed_maddはa·b + cを一度または二度丸めます。
; 結果: 3.25
(defn main (params) (returns f32)
(lane (f32x4.relaxed_madd (f32x4.splat 1.5) (f32x4.splat 2.0) (f32x4.splat 0.25)) 0))
(export main)
この例の値は丸めの回数によらず同じですが、一般には結果の最下位ビットなどが異なることがあります。relaxed命令を使うプログラムは、コンパイル結果に必要な機能relaxed-simdを記録します。対応しないブラウザでは実行せずにその旨を表示し、生成したWasmの保存だけができます。演算の詳しい規則はベクトルとマスク、relaxed命令が返しうる結果はrelaxed命令を参照してください。
22. 現在の境界
一つの入力ソースに型・関数・モジュールを並べてプログラムを書きます。一般の型パラメータ、動的文字列、汎用コレクションの標準ライブラリは未実装です。レコードと和型には、region名と借用名のパラメータを指定できます。
持続する参照は読取り専用で、更新権限の粒度はregion全体です。同じregionの異なる場所への独立した借用やRefMutは、今後検討します。ローカルregionを隠して返す存在型のパッケージも、まだありません。
ヿはWasmGCや独自GCによる回収を行わず、regionの所有とアクセス権限を基盤にしています。低レイテンシーを目指していますが、ブラウザでの実行時間の上限を保証する言語にはまだなっていません。詳しい制限は言語仕様、課題と設計の議論はGitHub Issuesで確認できます。
LLMと編集・検証を繰り返す
複数ファイルを扱うときも、ヿのmoduleとimportを使って関係を記述します。LLM向けツールでは、例えば関数を定義したmath.kotoと、その関数のdeftestを置いたmath-test.kotoを一緒に開きます。
koto_openへファイル名と本文を順番付きで渡します。- 返された
versionでkoto_testを呼び、失敗したテストのファイルと位置を確認します。 koto_editへ変更する範囲、元の本文、置換後の本文を渡します。型や所有権の検査に通ると、新しい本文と版が返ります。- 新しい版でテストを再実行します。
koto_readで最終的なファイルを取得します。
編集が検査に通っても、期待する計算結果になるとは限りません。report.failedが0かを確認してください。古い版を使った編集は拒否されるので、別の編集が入ったら読み直します。終了しないテストはkoto_stopで止められます。
この作業領域はブラウザのセッション内にあります。ディスクへの保存は利用側で行います。接続の設定と正確な引数はホストとAPIを参照してください。
GPUで計算と描画を行う
GPU向けのコードはgpu-moduleに書きます。CPU向けのWasmとは別にコンパイルし、ブラウザのWebGPUで実行します。計算の例は配列の各値を2倍にします。
(gpu-module double-values
(buffer values (group 0) (binding 0) (array i32) (access read_write))
(defn double-values
(stage compute)
(workgroup-size 64 1 1)
(params (id (vec3 u32) (builtin global_invocation_id)))
(returns unit)
(let index (lane id 0))
(if
((u32.lt index (buffer-length values))
(buffer-set values index
(i32.mul (buffer-get values index) 2))))))
valuesはホストから渡すGPUの配列です。global_invocation_idは実行するinvocationごとに異なる座標を持ち、laneでx成分を取り出しています。workgroup一つには64個のinvocationがあるため、配列の長さを調べ、範囲内のinvocationだけが書き込みます。indexはu32です。GPUで扱う値も、変数名を繰り返し使えます。
描画の例にはvertexとfragmentの二つのentryがあります。vertexはvertex_indexから頂点の位置を選び、(vec4 f32 ...)を返します。fragmentは赤色を返します。f32.from_i32で浮動小数点値を作り、f32.divなどで計算できます。u32とvec2・vec3・vec4はGPUコード用の型で、CPUのベクトル型(f32x4など)とは別の型です。CPUのf32とGPUのf32は同じ32ビットの表現を使いますが、利用できる演算と変換はそれぞれの定義に従います。
ブラウザのGPU実行画面で計算を繰り返すと、前回の配列を引き続き更新します。「終了・資源を解放」でセッションを閉じると、次回は初期値から始まります。描画への切替時にも前の資源を解放します。
アプリケーションへ組み込む場合は、ホスト側でセッションを作り、利用後にclose()を呼びます。停止にはAbortSignalを使えます。CPU regionの解放だけでGPU資源が終了するわけではありません。GPUの処理中は同じセッションの配列へ読み書きせず、完了を待ってから次の操作を行います。具体的なAPIはホストとAPI、対応する構文と制限は仕様を参照してください。