1 ポイント 投稿者 GN⁺ 3 시간 전 | 1件のコメント | WhatsAppで共有
  • 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や repr annotationの負担を減らす方向
  • コンパイラはまだ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呼び出しを合成する
  • 例の File typeは fn drop(self: mut File) を宣言し、useFile() では const f: File = { fd: 7 }; だけを書く
    • 明示的なcleanup、defer、lifetime終了マーカーはない
    • LLVM IRには ret の直前に call void @__drop_File(ptr %1) が生成される
  • 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::exitstd::abortlongjmp、signalのような複雑さを避ける方向
    • Jamはtypeごとに1つのdrop functionを持ち、すべてのscope exitで実行する

初期化と Maybe(T)

  • Jamには undefined 値がなく、bindingを値なしで宣言できない
    • すべての varconst は実際のinitializerを要求する
    • structはfield値を先に計算し、struct literalで生成してからbindingする
  • Zigは var f: File = undefined; return f.fd; を許可し、runtimeではstack garbageを読み得る
    • Debug modeではmisuseが見えるように 0xaa fillが入る
    • Release modeではarbitrary bytesになる
  • 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として拒否する
    • unsafe prefixは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する
    • ifelsematch arm、whilefor body内のbindingは、そのblockの終わりでdropされる
    • nested block内の return は、実際の ret の前にactive scopeをinnermost-firstでdropする
    • breakcontinue は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] = xv.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なのでOK
    • moveX(p, p.x)pp.x がoverlapするためerror

C ABIとFFI

  • Rustのnative ABIはunstableなので、distribution boundaryを越えるとC形式に再encodingする必要がある
    • raw pointer dereferenceは unsafe
    • ownershipは Box::into_rawBox::from_raw で手動移譲される
    • structをby valueで渡すときは #[repr(C)] のような別途annotationが必要
    • cbindgenabi_stable のようなツールは、この境界での手作業を減らすために存在する
  • 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 Counter parameterはcaller-owned storageへの Counter * にloweringされる
  • Jam側の関数bodyはordinary Jamなので、drop、init analysis、call-site exclusivity ruleが引き続き適用される
  • Cに入る方向は extern でC signatureを宣言する
    • extern functionは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の matchPattern Block 形式で、=> を使わない
    • scrutineeは match (opcode) のように括弧を使う
    • _ はcatch-all arm
    • armはtop-to-bottom sequential first-matchで、implicit fallthroughはない
  • 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する

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件のコメント

 
GN⁺ 3 시간 전
Lobste.rs の意見
  • 特性解決は探索問題だ。借用チェックは関数全体の領域解析だ。単相化は、最も遅い段階である LLVM が見る前にコード量を増やす…

    こういう LLM 生成文は、エンジニア、とりわけ若いエンジニアが注意すべきことをやっている。定量データの代わりに、定性的でそれらしい散文で置き換えていることだ。
    物語で説得するのは、確かな数値を集めて分析するよりも、書き手にも読み手にも簡単だ。人間の脳は物語が好きで、物語は単純で整っているほど最も効きやすい。現実のデータは、見ようとすればするほどニュアンスの多い複雑な世界を反映していることが多い。
    rustc コントリビューターが書いた Rust コンパイラのプロファイリングに関する定量的なブログ記事と比べてみればよい。

    • そもそもこのプロジェクトを真剣に受け止めるべきなのか疑問に思う
    • 「定量データの代わりに定性的/喚起的な散文で置き換える」という表現が特によかった。
      よい技術記事は、適切であればその両方を含められるし含めるべきだが、本当に伝えるべきことを見失ってはいけない。保証業務の組織を大きく運営してみて、技術文書を書くことがどれほど難しいかが分かったし、LLM へのアクセス性が高まることでこの問題がどれほど悪化し得るかに注意すべきだ。
  • Zig との核心的な違いは drop があり、簡単に誤用される特定の構成要素である undefined がない点なのか?

    undefined はなく、すべての値は初期化されなければならないが、Maybe(T).empty() は中身が「まだ意味を持たない」値を返し、その直後に unsafeAssumeInit() を呼ぶとゴミ値を返しそうだ。そうなると、Rust のようにコンパイラが unsafe を明示的な unsafe { .. } が必要な汚染として扱う意味での安全性ではない。

    安全性と drop 機能を示す例がこのコードだ。

    const File = struct {  
        fd: i32,  
        fn drop(self: mut File) {  
            close(self.fd);  
        }  
    };
    
    export fn useFile() i32 {  
        const f: File = { fd: 7 };  
        return f.fd;  
    }  
    

    私の見間違いでなければ、これは安全ではないのでは? 手動でファイルディスクリプタを割り当てている点はさておき、close(7) を呼んだ後に 7 を返している。ライフタイム追跡がないので、ファイルディスクリプタのライフタイムが useFile() の返却前に終わったとユーザーが表現する方法がない。

    ABI の例で export fn counterAdd(c: mut Counter, n: i64) i64 { .. }int64_t counterAdd(Counter *c, int64_t n); になるとき、cNULL でもよいのか、そうでないのかはどう表現するのか? 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 側で生ポインタを使うように構成されている。

    後ろのこの例もそうだ。

    extern fn snprintf(buf: *mut[] u8, size: u64, fmt: *const[] u8, ...) i32;
    
    fn render(value: i32) i32 {  
        var buf: [16]u8 = [0; 16];  
        return snprintf(&buf[0], 16, "n=%d", value);  
    }  
    

    ここにはどこかに unsafe があるべきではないのか? snprintf が生ポインタを受け取るのだから、先に述べていた unsafe な操作は名前で見つけられるべきだという指針に従うなら、unsafeSnprintf とシンボル再定義のようなものが必要になりそうだ。

    「正直な手がかりを一つ挙げると、extern 行では C と話していて、C のルールが勝つ」というのは、うーん

    • 私もそう読んだ。ただし Rust の .as_raw_fd() と変わらず、そこにも同じ安全性の問題がある。
  • Rust の ABI を不安定にするものは Jam には存在しない。第一級参照も、ライフタイムも、消さなければならない niche-packed レイアウトもない

    これは Rust 標準ライブラリのFFI 安定性を誤解している。共有参照、可変参照、Box、そしてそれらの Option はすべて定義済みで安定した ABI を持つ。だから例の Box::into_raw/from_raw の手順全体は不要だ。
    ライフタイムはバイナリレベルにはそもそも存在しない。列挙型に安定 ABI を定義すると選択すれば、niche 最適化は無効化される。

    ほとんどの型が安定 ABI を定義しない理由は、型の内部を変更できなくなるため、安定 ABI を望まない場合が多いからだ。

  • Jam はまだ公開されていない。コンパイラは存在し実行もできるが、日常的に使いやすくするもの——安定した表面、パッケージマネージャ、LSP、フォーマッタ、そして無いときにだけ気づく残りのツール群——に取り組んでいる間、より広い公開を先送りしている…

    この選択は理解できない。不完全なものを「リリース」することと、単にソースを公開することの間には大きな違いがある。どうせ後でやるなら、プロジェクトを作っている間に公開して何が害になるのか?
    利点は、方向性を気に入った人たちが実際に試せて、もしかすると貢献もできることだ。もちろん「AI の時代」なので、その貢献が純益になるかは明らかではない。また、人々が何を作っているのかをよりよく理解し、それがなぜ優れているのかという主張を評価できるようにもなる。それができなければ、プロジェクトはずっと興味を引かないものになる。

    さらに、こうしたツールをまったく使わない人たちもいる。今の私のチームも自動フォーマッタの採用すら合意できていないが、それ以外は素晴らしい。だから、そういうツールを作っている間に公開を遅らせても、大した違いは生まない。

  • 人々はずっと「面倒なライフタイムのない Rust」を作ろうとして、失敗し続けている。別のコメントが失敗パターンの一つを扱っていたが、dropされた値の一部を返す問題は、参照を返せないことから生じる。もう一つの古典的な問題はこれだ。

    let mut arr = vec![1];  
    let x = &arr[0];  
    arr.push(2);  
    // `x`を使うとどうなる?  
    

    答えは3つある。

    1. 拒否する。そのためには何らかの形の借用という概念が必要になる。普通は共有 XOR 可変だが、可変だけだと不便で、共有だけだと安全ではない
    2. 許可する。別の変数を通じた参照が存在せず、すべてが GC または参照カウントポインタだからだ
    3. 許可し、実行時に未定義動作を引き起こす

    この3つのどれを選んでも、それぞれに十分な理由はあるが、Jam は Rust のように1番を目指している一方で、実際には値セマンティクスのために2番になっているように見える。すべてがコピーされるという意味なら、安全で効率的なデータ構造を書く妨げになる可能性が高い

    • Inko はかなりうまくやっていると思う。もちろん、私の明らかな偏りは差し引いて見るべきだが、それ自体のトレードオフも確かにある
      特に借用チェッカーを捨てると、いくつもの手がかりを導入しない限り、スタック割り当て型をサポートするのがずっと難しくなる。たとえば借用時にコピーする方式で、Inko と Swift はどちらもこうしている
    • Jam はよく分からないが、Hylo 式の可変値セマンティクスには subscripts という借用の形がある。なので、もう少し中間地点に近い
    • その部分を読んだとき最初に浮かんだ疑問は、「参照もライフタイム表記もないなら、構造体の中に参照をどうやって保存するのか?」だった
      言語リファレンスを見ると、参照はないが mutconst ポインタはあり、その安全性についての記述は見つけられなかった
  • Zig を Zig らしくしている大きな要素は RAII がないことで、Rust は借用チェッカーだ。ところが、これらの設計選択が行き着いた先である「参照のない RAII」を実際に誰が必要としているのかはよく分からない
    それでも、このニッチで実験する余地はあると思うし、そうした試み自体は好意的に見ている。ただし、このアプローチではないように思う

    最近ずっと考えている方向性は、Zig の comptime、Pony に似た参照権限、ライフタイムをコンパイル時の値として扱うこと、そしてライフタイムをアロケータにブランディングすることの組み合わせだ
    期待しているのは、Zig のアロケータ戦略に参照安全性を加え、ほとんど表記する必要のないライフタイムを得ることだ

  • 新しい言語は歓迎だが、すべてが LLVM のフロントエンドになるのは嫌だ。バックエンドが難しいのは分かるが、たまには別の選択肢もあってほしい

  • ほとんど Swift のように聞こえる