Jamプログラミング言語
(rapha.land)- Jamは、C系言語のすぐに使える感覚を保ちながら、GCなしで安全性・低い学習曲線・高性能を同時に狙う、v1.0以前の段階にある言語
- 中核は mutable value semantics とRust式のdropシステムで、ユーザーコードに参照やlifetime構文を露出させず、所有権・borrow・自動クリーンアップをコンパイラが処理する
- 初期化モデルは
undefinedと暗黙のゼロ初期化の両方を避け、遅延初期化とout-parameterはMaybe(T)およびunsafeAssumeInit()解析で扱う exportはJam関数を C ABI として公開し、JamのstructはC互換layoutを持つよう設計されており、別途unsafeなshimやreprannotationの負担を減らす方向- コンパイラはまだC++で実装されたブートストラップ段階で未公開。108個のdistinct projectをJamで作った後にオープンソース化する計画
Jamが狙う言語上の位置づけ
- Jamはまだ v1.0以前 であり、現在説明されている仕組みはコンパイラで動作するものの、安定化前の詳細は変わる可能性がある
- 目標は、Go、Zig、modern Cのようにすぐ理解しやすい C系の感覚 を保ちながら、Cのbug classを減らす安全な言語を作ること
- 設計の中心軸は2つ
- Racordon、Abrahams et al. 2022の Mutable value semantics
- Rustの drop system
- 実際のチームは熟練度が混在し、経験の浅いメンバーがミスをする可能性が高いため、レビュー前に言語がより多くのエラーを防ぐべきだという問題意識から出発している
Rust、Zig、C++との違い
- Rustは安全性の哲学が強いが、「Rustをある程度使える」と「Rustで生産的である」の間の隔たりが大きく、チームにとって学習曲線が負担になり得る
- ZigはC-like言語に近い小さな表面積と即時的なmental modelを提供するが、言語レベルで安全な言語ではない
- uninitialized read、manual cleanup、use-after-freeの防止が言語レベルで強制されない
- 大規模なZigまたはC++のproductionプロジェクトは、Valgrind、AddressSanitizer、fuzzingのような検証ツールに大きく依存する
- AI時代にはproduction codeの多くが人間ではなくツールによって作成または草案化され、ボトルネックはcode writingから code review に移ると見ている
- code volumeは増え、review surfaceはflatなので、compilerがより多くのバグを捕まえる必要がある
自動dropシステム
- Jamのbindingは値を所有し、drop-bearing typeのbindingがscopeを抜けると、compilerがdrop呼び出しを合成する
- 例の
Filetypeはfn drop(self: mut File)を宣言し、useFile()ではconst f: File = { fd: 7 };だけを書く- 明示的なcleanup、
defer、lifetime終了マーカーはない - LLVM IRには
retの直前にcall void @__drop_File(ptr %1)が生成される
- 明示的なcleanup、
- mangled nameである
__drop_Fileは、複数typeのdrop関数がLLVM levelで衝突しないようにする self: mut Fileはpointer parameterへloweringされ、call siteはbindingのアドレスを直接渡す- Zigでは同じcleanupのために
defer f.deinit()を明示する必要がある- その行を削除するとIRのdeinit callも消える
- file descriptor leakはprogrammerがcleanupを覚えていないときに発生する
- C++ RAIIもscope exitでdestructorを自動実行するが、JamはRustの単純なdrop modelを採用する
- C++のrule of 0/3/5、virtual destructor、constructor exception、destructor exception、
std::exit、std::abort、longjmp、signalのような複雑さを避ける方向 - Jamはtypeごとに1つのdrop functionを持ち、すべてのscope exitで実行する
- C++のrule of 0/3/5、virtual destructor、constructor exception、destructor exception、
初期化と Maybe(T)
- Jamには
undefined値がなく、bindingを値なしで宣言できない- すべての
varとconstは実際のinitializerを要求する - structはfield値を先に計算し、struct literalで生成してからbindingする
- すべての
- Zigは
var f: File = undefined; return f.fd;を許可し、runtimeではstack garbageを読み得る- Debug modeではmisuseが見えるように
0xaafillが入る - Release modeではarbitrary bytesになる
- Debug modeではmisuseが見えるように
- Goはすべての
varをzero-initializeしてgarbage readを防ぐが、すぐoverwriteされるfieldにもzero patternを書くコストがある - Jamは
undefinedとimplicit zeroの両方を避ける - 遅延初期化とout-parameterには
Maybe(T)を使うempty()はまだ意味のないcontentsを持つslotを作るwrite()はslotを埋めるunsafeAssumeInit()は値を取り出す
- lint passはslotがwriteされたかを追跡し、analyzerが初期化を証明できない
unsafeAssumeInit()呼び出しをcompile errorとして拒否するunsafeprefixはhumanとAI reviewerがgrepできるanchorとして残る
Scope exit、return、break、continue
- compilerは drop scope stack を追跡し、lexical block boundaryごとに新しいscopeをpushする
- blockが終わるかbranchで抜ける直前に、そのscopeのbinding dropをemitする
if、else、matcharm、while、forbody内のbindingは、そのblockの終わりでdropされる- nested block内の
returnは、実際のretの前にactive scopeをinnermost-firstでdropする breakとcontinueはloop body内で開いたscopeをdropした後、loop exitまたは次のiterationへ移動する
- nested breakの例では、
outerがiteration 0の終わりでdropされ、iteration 1のbreak pathではinner、次にouterの順でdropされる
Parameter modeとfirst-class referenceの排除
- 関数呼び出しでbindingがdropされるかは parameter mode が決める
- デフォルトmodeはread-only borrow
- calleeが値を読み、callerのbindingはinitialized状態のまま残る
- call return時にdropは発生しない
mutはexclusive read-write borrow- callerのbindingはcall後もinitialized状態のまま残る
moveだけが値をconsumeする- calleeが所有権を受け取り、calleeの終わりでdropされる
- callerのbindingはcall後にUninitとなり、読むとcompile errorになる
- call site markerはなく、
f(x)形式はすべてのmodeで同じ - Jamには first-class reference type がない
- borrowをvariableに保存したり、returnしたり、struct fieldに保持したりできない
- parameter borrowはcall-frameの間だけ存在し、call return時に期限切れになる
- lifetime annotationが不要な理由は、attachするlifetimeが存在しないため
- collection APIもvalue-shapedに保たれる
v[i] = xはv.setAt(i, x)にdesugarされるlet y = v[i]はv.at(i)getterがelementをvalueとして返す
- call site exclusivity checkは、argumentが作るborrow setのpath overlapを検査する
swap(p.x, p.y)はdisjoint sub-pathなのでOKmoveX(p, p.x)はpとp.xがoverlapするためerror
C ABIとFFI
- Rustのnative ABIはunstableなので、distribution boundaryを越えるとC形式に再encodingする必要がある
- raw pointer dereferenceは
unsafe - ownershipは
Box::into_rawとBox::from_rawで手動移譲される - structをby valueで渡すときは
#[repr(C)]のような別途annotationが必要 cbindgenやabi_stableのようなツールは、この境界での手作業を減らすために存在する
- raw pointer dereferenceは
- Jamにはfirst-class reference、lifetime、niche-packed layoutがないため、Jam valueは最後までvalue-shapedだと見ている
- Jam structはすでに C-compatible layout を持つよう設計されている
exportはJam関数をC calling conventionのplain unmangled nameとして公開するexport fn counterAdd(c: mut Counter, n: i64) i64はCからint64_t counterAdd(Counter *c, int64_t n);として呼び出せるmut Counterparameterはcaller-owned storageへのCounter *にloweringされる
- Jam側の関数bodyはordinary Jamなので、drop、init analysis、call-site exclusivity ruleが引き続き適用される
- Cに入る方向は
externでC signatureを宣言するexternfunctionはC ABIにliteralに従う- parameter-mode machineryはboundaryの外には適用されない
- raw pointerでCにbufferを渡し、Cがpointerで何をするかはJamが検証しない
- Jamが提供しようとしている範囲は、Jam側がsafe by defaultに保たれ、Jam libraryをC ABIで公開するときに別途unsafe API mirrorやshim layerを作らなくてもよいこと
Pattern matching
- Jamの
matchはPattern Block形式で、=>を使わない- scrutineeは
match (opcode)のように括弧を使う _はcatch-all arm- armはtop-to-bottom sequential first-matchで、implicit fallthroughはない
- scrutineeは
- Game Boy emulatorのopcode dispatcherが主なユースケース
- 256個のbase opcodesと256個のprefix opcodesをdispatchする形
- enum payload matchingもサポートする
- variant patternはtagをmatchし、payload fieldをarm内部のfresh localにbindする
- compilerはvariant setに対するexhaustivenessを検査する
- 新しいvariantを追加すると、そのvariantを扱わないmatch siteはcompile failになる
matchはexpressionとしても動作する- 各arm blockはtrailing expressionの値を生成する
- すべてのarmは同じtypeをproduceしなければならない
- matchはexhaustiveでなければならない
- 内部的にすべてのmatchはLuc Maranget 2008ベースの decision tree pipeline を経てcompileされる
- integer literal cascadeはLLVM
simplifycfgが有益だと判断したときにswitchとjump tableへfoldする
- integer literal cascadeはLLVM
Compile time設計
- Rustのcompile pipelineは複数のIRと解析段階を経る
tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code- trait solvingはsearch problemで、borrow checkingはwhole-function region analysis
- monomorphizationはLLVM以前のcode volumeを増やす
- Jam pipelineはより短く設計されている
tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code- typed IRである JIR 1つを使う
- JIRはAstGenが作る時点からtyped状態
- Jamにはuntyped loweringを強制するcomptime-as-valuesがないと見ている
- drop placement、init-before-use check、call-site exclusivity ruleはJIR上のlocal dataflow passとして実行される
- type annotationがbindingごとにあるため、global type inferenceとopen-ended trait searchの負担は小さいと見ている
- ASTとJIRはflat data structure
- small fixed-size nodeをcontiguous arrayにpackingする
- pointerの代わりにindexを使い、oversized payloadはside poolに保存する
- compilerがheap-allocated treeを追跡する代わりにcache-friendly arrayを走査するようにする
- backendではLLVMがrelease build optimization時間を支配する
- debug buildにはCranelift、release buildにはLLVMを使うsplitが計画されている
- Craneliftはroadmapにあり、まだ完了していない
- 現在のcompilerはC++ implementationでlanguageをbootstrapする段階で、引用できるbuild-time benchmarkはまだない
- compile-time関連のclaimは測定結果ではなくdesign claim
Runtime performanceと例
- 目標はJamがRustとZigにperformanceで並ぶこと
- JamにはGC、managed-memory runtime、per-allocation headerがない
- codegenはstraightforward LLVM IR
- まだRustとZigの水準に達したとは見ていない
- RustとZigはstandard libraryのtarget-specific intrinsic、auto-vectorization hint、allocator-aware container、hot path tuning、LLVM pass tuningのような作業を長く続けてきた
- Jamも最後の10〜30%を詰めるには同種の作業が必要
- 現在測定したworkloadでは、gapは「別class」ではなく small constant factor の範囲内だと見ている
- terminalで動作するTetris demoがJamで書かれている
公開計画と残作業
- Jamはまだpublicではない
- compilerは存在し動作しているが、wider releaseの前段階
- day-to-dayの使いやすさのため、次の作業を進めている
- stable surface
- package manager
- LSP
- formatter
- 残りのtooling
- 別記事で扱う予定のトピックが残っている
- parameter mode system
- exclusivity rule
- generics
- Jamのcomptime
- standard library
- allocator systems
- panic model
- GPU codegen pipelineのためのMLIR exploration
- Rust ABI work for FFI
- Cranelift
- self-hosted compilerへの道筋
- オープンソース計画は、Jamで 108個のdistinct project を作ってから公開すること
- 数字の108はSuikoden 2の108 Stars of Destinyに由来するarbitrary milestone
- 現在はsmall group of usersに提供されており、toolingが追いつけば範囲を広げる計画
- early accessは jamlang.org のbeta listで受け付けている
1件のコメント
Lobste.rs の意見
こういう LLM 生成文は、エンジニア、とりわけ若いエンジニアが注意すべきことをやっている。定量データの代わりに、定性的でそれらしい散文で置き換えていることだ。
物語で説得するのは、確かな数値を集めて分析するよりも、書き手にも読み手にも簡単だ。人間の脳は物語が好きで、物語は単純で整っているほど最も効きやすい。現実のデータは、見ようとすればするほどニュアンスの多い複雑な世界を反映していることが多い。
rustc コントリビューターが書いた Rust コンパイラのプロファイリングに関する定量的なブログ記事と比べてみればよい。
よい技術記事は、適切であればその両方を含められるし含めるべきだが、本当に伝えるべきことを見失ってはいけない。保証業務の組織を大きく運営してみて、技術文書を書くことがどれほど難しいかが分かったし、LLM へのアクセス性が高まることでこの問題がどれほど悪化し得るかに注意すべきだ。
Zig との核心的な違いは
dropがあり、簡単に誤用される特定の構成要素であるundefinedがない点なのか?undefinedはなく、すべての値は初期化されなければならないが、Maybe(T).empty()は中身が「まだ意味を持たない」値を返し、その直後にunsafeAssumeInit()を呼ぶとゴミ値を返しそうだ。そうなると、Rust のようにコンパイラがunsafeを明示的なunsafe { .. }が必要な汚染として扱う意味での安全性ではない。安全性と
drop機能を示す例がこのコードだ。私の見間違いでなければ、これは安全ではないのでは? 手動でファイルディスクリプタを割り当てている点はさておき、
close(7)を呼んだ後に7を返している。ライフタイム追跡がないので、ファイルディスクリプタのライフタイムがuseFile()の返却前に終わったとユーザーが表現する方法がない。ABI の例で
export fn counterAdd(c: mut Counter, n: i64) i64 { .. }がint64_t counterAdd(Counter *c, int64_t n);になるとき、cがNULLでもよいのか、そうでないのかはどう表現するのか? Rust にはこの部分について定義された ABI があり、extern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64も可能だし、extern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64も可能だ。Rust 版も
unsafeは必要ない。参照で API を定義できる。皮肉なことにunsafeが必要になり得る唯一の場所は、最近の Rust で#[unsafe(no_mangle)]である#[no_mangle]くらいだが、例はなぜか Rust 側で生ポインタを使うように構成されている。後ろのこの例もそうだ。
ここにはどこかに
unsafeがあるべきではないのか?snprintfが生ポインタを受け取るのだから、先に述べていたunsafeな操作は名前で見つけられるべきだという指針に従うなら、unsafeSnprintfとシンボル再定義のようなものが必要になりそうだ。「正直な手がかりを一つ挙げると、
extern行では C と話していて、C のルールが勝つ」というのは、うーん。.as_raw_fd()と変わらず、そこにも同じ安全性の問題がある。これは Rust 標準ライブラリのFFI 安定性を誤解している。共有参照、可変参照、
Box、そしてそれらのOptionはすべて定義済みで安定した ABI を持つ。だから例のBox::into_raw/from_rawの手順全体は不要だ。ライフタイムはバイナリレベルにはそもそも存在しない。列挙型に安定 ABI を定義すると選択すれば、niche 最適化は無効化される。
ほとんどの型が安定 ABI を定義しない理由は、型の内部を変更できなくなるため、安定 ABI を望まない場合が多いからだ。
この選択は理解できない。不完全なものを「リリース」することと、単にソースを公開することの間には大きな違いがある。どうせ後でやるなら、プロジェクトを作っている間に公開して何が害になるのか?
利点は、方向性を気に入った人たちが実際に試せて、もしかすると貢献もできることだ。もちろん「AI の時代」なので、その貢献が純益になるかは明らかではない。また、人々が何を作っているのかをよりよく理解し、それがなぜ優れているのかという主張を評価できるようにもなる。それができなければ、プロジェクトはずっと興味を引かないものになる。
さらに、こうしたツールをまったく使わない人たちもいる。今の私のチームも自動フォーマッタの採用すら合意できていないが、それ以外は素晴らしい。だから、そういうツールを作っている間に公開を遅らせても、大した違いは生まない。
人々はずっと「面倒なライフタイムのない Rust」を作ろうとして、失敗し続けている。別のコメントが失敗パターンの一つを扱っていたが、
dropされた値の一部を返す問題は、参照を返せないことから生じる。もう一つの古典的な問題はこれだ。答えは3つある。
この3つのどれを選んでも、それぞれに十分な理由はあるが、Jam は Rust のように1番を目指している一方で、実際には値セマンティクスのために2番になっているように見える。すべてがコピーされるという意味なら、安全で効率的なデータ構造を書く妨げになる可能性が高い
特に借用チェッカーを捨てると、いくつもの手がかりを導入しない限り、スタック割り当て型をサポートするのがずっと難しくなる。たとえば借用時にコピーする方式で、Inko と Swift はどちらもこうしている
言語リファレンスを見ると、参照はないが
mutとconstポインタはあり、その安全性についての記述は見つけられなかったZig を Zig らしくしている大きな要素は RAII がないことで、Rust は借用チェッカーだ。ところが、これらの設計選択が行き着いた先である「参照のない RAII」を実際に誰が必要としているのかはよく分からない
それでも、このニッチで実験する余地はあると思うし、そうした試み自体は好意的に見ている。ただし、このアプローチではないように思う
最近ずっと考えている方向性は、Zig の
comptime、Pony に似た参照権限、ライフタイムをコンパイル時の値として扱うこと、そしてライフタイムをアロケータにブランディングすることの組み合わせだ期待しているのは、Zig のアロケータ戦略に参照安全性を加え、ほとんど表記する必要のないライフタイムを得ることだ
新しい言語は歓迎だが、すべてが LLVM のフロントエンドになるのは嫌だ。バックエンドが難しいのは分かるが、たまには別の選択肢もあってほしい
ほとんど Swift のように聞こえる