1 poin oleh GN⁺ 3 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • Jam adalah bahasa tahap pra-v1.0 yang bertujuan menggabungkan keamanan, kurva belajar rendah, dan performa tinggi tanpa GC, sambil mempertahankan rasa penggunaan langsung ala bahasa keluarga C
  • Intinya adalah mutable value semantics dan sistem drop ala Rust; compiler menangani ownership, borrow, dan pembersihan otomatis tanpa mengekspos sintaks reference atau lifetime ke kode pengguna
  • Model inisialisasinya menghindari baik undefined maupun zero-initialization implisit, dan menangani lazy initialization serta out-parameter melalui analisis Maybe(T) dan unsafeAssumeInit()
  • export mengekspos fungsi Jam sebagai C ABI, dan struct Jam dirancang memiliki layout yang kompatibel dengan C, sehingga arahnya mengurangi beban unsafe shim atau annotation repr terpisah
  • Compiler masih berada pada tahap bootstrap yang diimplementasikan dengan C++ dan belum dipublikasikan; rencananya akan dijadikan open source setelah 108 distinct project dibuat dengan Jam

Posisi bahasa yang ditargetkan Jam

  • Jam masih pra-v1.0, dan mekanisme yang dijelaskan saat ini sudah berjalan di compiler, tetapi detail sebelum stabilisasi masih dapat berubah
  • Tujuannya adalah membuat bahasa aman yang mengurangi bug class di C, sambil mempertahankan nuansa keluarga C yang mudah langsung dipahami seperti Go, Zig, dan modern C
  • Poros utama desainnya ada dua
    • Mutable value semantics dari Racordon, Abrahams et al. 2022
    • drop system milik Rust
  • Tim nyata memiliki tingkat keahlian yang beragam, dan anggota yang kurang berpengalaman lebih mungkin membuat kesalahan; desain ini berangkat dari kesadaran bahwa bahasa harus mencegah lebih banyak error sebelum tahap review

Perbedaan dengan Rust, Zig, dan C++

  • Rust memiliki filosofi keamanan yang kuat, tetapi jarak antara “bisa memakai Rust sampai tingkat tertentu” dan “produktif dengan Rust” cukup besar sehingga kurva belajarnya dapat menjadi beban bagi tim
  • Zig memberi permukaan bahasa kecil yang dekat dengan C-like language dan mental model yang langsung, tetapi bukan bahasa yang aman pada level bahasa
    • uninitialized read, manual cleanup, dan pencegahan use-after-free tidak dipaksakan pada level bahasa
    • Proyek production Zig atau C++ besar sangat bergantung pada alat verifikasi seperti Valgrind, AddressSanitizer, dan fuzzing
  • Di era AI, banyak bagian production code ditulis atau didraft oleh tool, bukan manusia, sehingga bottleneck dianggap bergeser dari code writing ke code review
    • volume code meningkat sementara review surface tetap flat, jadi compiler harus menangkap lebih banyak bug

Sistem drop otomatis

  • Binding di Jam memiliki nilai, dan ketika binding dari drop-bearing type keluar dari scope, compiler menyintesis pemanggilan drop
  • Contoh type File mendeklarasikan fn drop(self: mut File), dan di useFile() cukup menulis const f: File = { fd: 7 };
    • Tidak ada cleanup eksplisit, defer, atau penanda akhir lifetime
    • Di LLVM IR, call void @__drop_File(ptr %1) dibuat tepat sebelum ret
  • Nama yang di-mangle, __drop_File, mencegah fungsi drop dari berbagai type bertabrakan di level LLVM
  • self: mut File diturunkan menjadi pointer parameter, dan call site meneruskan alamat binding secara langsung
  • Di Zig, cleanup yang sama harus dinyatakan eksplisit dengan defer f.deinit()
    • Jika baris itu dihapus, deinit call di IR juga hilang
    • Kebocoran file descriptor terjadi ketika programmer lupa melakukan cleanup
  • C++ RAII juga menjalankan destructor otomatis saat scope exit, tetapi Jam mengadopsi model drop Rust yang sederhana
    • Arahnya adalah menghindari kompleksitas seperti rule of 0/3/5 di C++, virtual destructor, constructor exception, destructor exception, std::exit, std::abort, longjmp, dan signal
    • Jam memiliki satu drop function per type, dan menjalankannya pada setiap scope exit

Inisialisasi dan Maybe(T)

  • Di Jam tidak ada nilai undefined, dan binding tidak dapat dideklarasikan tanpa nilai
    • Semua var dan const membutuhkan initializer nyata
    • Struct menghitung nilai field terlebih dahulu, lalu dibuat dengan struct literal dan di-bind
  • Zig mengizinkan var f: File = undefined; return f.fd;, dan pada runtime ini dapat membaca stack garbage
    • Dalam Debug mode, fill 0xaa dimasukkan agar misuse terlihat
    • Dalam Release mode, hasilnya menjadi arbitrary bytes
  • Go melakukan zero-initialize untuk semua var agar mencegah garbage read, tetapi ada biaya menulis zero pattern bahkan pada field yang segera di-overwrite
  • Jam menghindari baik undefined maupun zero implisit
  • Untuk lazy initialization dan out-parameter, Jam menggunakan Maybe(T)
    • empty() membuat slot dengan contents yang belum bermakna
    • write() mengisi slot
    • unsafeAssumeInit() mengekstrak nilainya
  • Lint pass melacak apakah slot sudah di-write, dan menolak pemanggilan unsafeAssumeInit() sebagai compile error bila analyzer tidak dapat membuktikan inisialisasi
    • Prefix unsafe tetap menjadi anchor yang bisa di-grep oleh reviewer manusia maupun AI

Scope exit, return, break, continue

  • Compiler melacak drop scope stack dan melakukan push scope baru pada setiap lexical block boundary
  • Saat block berakhir atau tepat sebelum keluar melalui branch, compiler meng-emit drop untuk binding pada scope tersebut
    • Binding di dalam if, else, arm match, while, dan body for di-drop pada akhir block terkait
    • return di dalam nested block men-drop active scope dari yang terdalam terlebih dahulu sebelum ret sebenarnya
    • break dan continue men-drop scope yang terbuka di dalam loop body sebelum berpindah ke loop exit atau iteration berikutnya
  • Dalam contoh nested break, outer di-drop pada akhir iteration 0, dan pada path break di iteration 1, drop terjadi berurutan inner lalu outer

Parameter mode dan penghapusan first-class reference

  • Dalam pemanggilan fungsi, apakah sebuah binding di-drop ditentukan oleh parameter mode
  • Mode default adalah read-only borrow
    • Callee membaca nilai, dan binding milik caller tetap dalam keadaan initialized
    • Tidak ada drop saat call return
  • mut adalah exclusive read-write borrow
    • Binding milik caller tetap initialized setelah call
  • Hanya move yang mengonsumsi nilai
    • Callee menerima ownership dan nilai di-drop pada akhir callee
    • Binding milik caller menjadi Uninit setelah call, dan membacanya menjadi compile error
  • Tidak ada marker di call site; bentuk f(x) sama untuk semua mode
  • Di Jam tidak ada first-class reference type
    • Borrow tidak dapat disimpan ke variable, di-return, atau disimpan pada struct field
    • Parameter borrow hanya ada selama call-frame dan kedaluwarsa saat call return
    • Alasan lifetime annotation tidak diperlukan adalah karena tidak ada lifetime yang bisa ditempeli
  • API collection juga dipertahankan berbentuk value-shaped
    • v[i] = x di-desugar menjadi v.setAt(i, x)
    • let y = v[i] memakai getter v.at(i) yang mengembalikan element sebagai value
  • Pemeriksaan exclusivity di call site memeriksa overlap path dari borrow set yang dibuat oleh argument
    • swap(p.x, p.y) OK karena sub-path-nya disjoint
    • moveX(p, p.x) error karena p dan p.x overlap

C ABI dan FFI

  • Native ABI Rust tidak stabil, sehingga ketika melewati distribution boundary harus di-encoding ulang ke bentuk C
    • Dereference raw pointer adalah unsafe
    • Ownership diteruskan secara manual dengan Box::into_raw dan Box::from_raw
    • Saat meneruskan struct by value, annotation terpisah seperti #[repr(C)] diperlukan
    • Tool seperti cbindgen dan abi_stable ada untuk mengurangi kerja manual pada boundary ini
  • Jam tidak memiliki first-class reference, lifetime, atau niche-packed layout, sehingga nilai Jam dianggap value-shaped all the way down
    • Struct Jam sudah dirancang memiliki C-compatible layout
  • export mengekspos fungsi Jam sebagai plain unmangled name dengan C calling convention
    • export fn counterAdd(c: mut Counter, n: i64) i64 dapat dipanggil dari C sebagai int64_t counterAdd(Counter *c, int64_t n);
    • Parameter mut Counter diturunkan menjadi Counter * ke storage milik caller
  • Body fungsi di sisi Jam adalah Jam biasa, sehingga drop, init analysis, dan call-site exclusivity rule tetap berlaku
  • Arah masuk dari C dideklarasikan dengan extern
    • Function extern mengikuti C ABI secara literal
    • Parameter-mode machinery tidak diterapkan di luar boundary
    • Buffer diteruskan ke C sebagai raw pointer, dan Jam tidak memverifikasi apa yang dilakukan C terhadap pointer tersebut
  • Cakupan yang ingin diberikan Jam adalah mempertahankan sisi Jam safe by default, dan ketika library Jam diekspos sebagai C ABI, tidak perlu membuat mirror unsafe API atau shim layer terpisah

Pattern matching

  • match di Jam berbentuk Pattern Block dan tidak memakai =>
    • Scrutinee memakai tanda kurung seperti match (opcode)
    • _ adalah arm catch-all
    • Arm bersifat sequential first-match dari atas ke bawah dan tidak ada implicit fallthrough
  • Opcode dispatcher untuk emulator Game Boy adalah use case utama
    • Bentuknya men-dispatch 256 base opcodes dan 256 prefix opcodes
  • Enum payload matching juga didukung
    • Variant pattern mencocokkan tag dan mengikat payload field sebagai fresh local di dalam arm
    • Compiler memeriksa exhaustiveness terhadap variant set
    • Jika variant baru ditambahkan, match site yang tidak menangani variant tersebut akan compile fail
  • match juga berfungsi sebagai expression
    • Setiap arm block menghasilkan nilai dari trailing expression
    • Semua arm harus menghasilkan type yang sama
    • Match harus exhaustive
  • Secara internal, semua match dikompilasi melalui decision tree pipeline berbasis Luc Maranget 2008
    • Cascade integer literal akan di-fold oleh LLVM simplifycfg menjadi switch dan jump table ketika menguntungkan

Desain compile time

  • Pipeline compile Rust melewati beberapa IR dan tahap analisis
    • tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code
    • Trait solving adalah search problem, dan borrow checking adalah whole-function region analysis
    • Monomorphization meningkatkan volume code sebelum LLVM
  • Pipeline Jam dirancang lebih pendek
    • tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code
    • Menggunakan satu typed IR, yaitu JIR
  • JIR sudah typed sejak dibuat oleh AstGen
    • Jam dianggap tidak memiliki comptime-as-values yang memaksa untyped lowering
    • Drop placement, init-before-use check, dan call-site exclusivity rule dilakukan sebagai local dataflow pass di atas JIR
  • Karena ada type annotation pada setiap binding, beban global type inference dan open-ended trait search dianggap lebih kecil
  • AST dan JIR adalah flat data structure
    • Node small fixed-size dipacking ke contiguous array
    • Menggunakan index alih-alih pointer, dan oversized payload disimpan di side pool
    • Ini membuat compiler menelusuri cache-friendly array alih-alih melacak heap-allocated tree
  • Di backend, LLVM mendominasi waktu optimisasi release build
    • Direncanakan split dengan Cranelift untuk debug build dan LLVM untuk release build
    • Cranelift ada di roadmap dan belum selesai
  • Saat ini compiler adalah implementation C++ untuk bootstrap language, dan belum ada benchmark build-time yang layak dikutip
    • Klaim terkait compile-time adalah klaim desain, bukan hasil pengukuran

Runtime performance dan contoh

  • Targetnya adalah performa Jam setara dengan Rust dan Zig
  • Jam tidak memiliki GC, managed-memory runtime, atau per-allocation header
    • Codegen berupa LLVM IR yang straightforward
  • Belum dianggap sudah mencapai level Rust dan Zig
    • Rust dan Zig sudah lama mengerjakan target-specific intrinsic di standard library, auto-vectorization hint, allocator-aware container, hot path tuning, dan LLVM pass tuning
    • Jam juga memerlukan jenis pekerjaan yang sama untuk menutup 10–30% terakhir
  • Pada workload yang diukur saat ini, gap dianggap berada dalam small constant factor, bukan “class berbeda”
  • Demo Tetris yang berjalan di terminal ditulis dengan Jam

Rencana publikasi dan pekerjaan tersisa

  • Jam belum public
    • Compiler sudah ada dan bekerja, tetapi belum dirilis lebih luas
  • Untuk kegunaan day-to-day, pekerjaan berikut sedang dilakukan
    • stable surface
    • package manager
    • LSP
    • formatter
    • tooling lainnya
  • Masih ada topik yang rencananya akan dibahas dalam tulisan terpisah
    • parameter mode system
    • exclusivity rule
    • generics
    • comptime di Jam
    • standard library
    • allocator systems
    • panic model
    • eksplorasi MLIR untuk GPU codegen pipeline
    • Rust ABI work for FFI
    • Cranelift
    • jalur self-hosted compiler
  • Rencana open source-nya adalah mempublikasikan Jam setelah membuat 108 distinct project dengan Jam
    • Angka 108 adalah arbitrary milestone yang berasal dari 108 Stars of Destiny di Suikoden 2
    • Saat ini Jam sudah diberikan kepada small group of users, dan cakupannya akan diperluas ketika tooling sudah menyusul
  • Early access dapat diperoleh melalui beta list di jamlang.org

1 komentar

 
GN⁺ 3 jam lalu
Pendapat di Lobste.rs
  • Penyelesaian trait adalah masalah pencarian. Borrow checking adalah analisis pada cakupan seluruh fungsi. Monomorphization menambah jumlah kode bahkan sebelum LLVM, tahap paling lambat, melihatnya…

    Tulisan yang dihasilkan LLM seperti ini melakukan sesuatu yang harus diwaspadai oleh para engineer, terutama engineer muda: mengganti data kuantitatif dengan prosa kualitatif yang terdengar meyakinkan
    Meyakinkan lewat cerita lebih mudah bagi penulis maupun pembaca daripada mengumpulkan dan menganalisis angka-angka yang solid. Otak manusia menyukai cerita, dan cerita paling ampuh ketika sederhana dan rapi. Data dunia nyata sering kali mencerminkan dunia kompleks yang penuh nuansa sejauh kita mau menelitinya
    Bandingkan saja dengan artikel blog kuantitatif tentang profiling compiler Rust yang ditulis oleh kontributor rustc

    • Saya bahkan jadi bertanya-tanya apakah proyek ini layak dianggap serius
    • Saya sangat suka ungkapan “mengganti data kuantitatif dengan prosa kualitatif/evokatif”
      Tulisan teknis yang baik bisa dan seharusnya memuat keduanya bila tepat, tetapi tidak boleh melewatkan hal yang benar-benar perlu disampaikan. Setelah mengelola organisasi besar untuk pekerjaan assurance, saya jadi tahu betapa sulitnya penulisan teknis, dan dengan makin mudahnya aksesibilitas LLM, kita harus berhati-hati terhadap seberapa buruk masalah ini bisa menjadi
  • Perbedaan inti dengan Zig adalah adanya drop, dan tidak adanya komponen tertentu yang mudah disalahgunakan, yaitu undefined?

    Tidak ada undefined dan semua nilai harus diinisialisasi, tetapi Maybe(T).empty() tampaknya mengembalikan nilai yang isinya “belum bermakna”, dan jika langsung diikuti pemanggilan unsafeAssumeInit(), sepertinya akan mengembalikan nilai sampah. Kalau begitu, ini bukan aman dalam pengertian seperti Rust, di mana compiler memperlakukan unsafe sebagai kontaminasi yang membutuhkan unsafe { .. } eksplisit

    Contoh yang menunjukkan fitur keamanan dan drop adalah kode ini:

    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;  
    }  
    

    Kalau saya tidak salah lihat, bukankah ini tidak aman? Terlepas dari alokasi file descriptor secara manual, ia memanggil close(7) lalu mengembalikan 7. Karena tidak ada pelacakan lifetime, pengguna tidak punya cara untuk mengekspresikan bahwa lifetime file descriptor sudah berakhir sebelum useFile() mengembalikan nilai

    Dalam contoh ABI, ketika export fn counterAdd(c: mut Counter, n: i64) i64 { .. } menjadi int64_t counterAdd(Counter *c, int64_t n);, bagaimana cara mengekspresikan apakah c boleh NULL atau tidak? Rust punya ABI yang terdefinisi untuk bagian ini; extern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64 bisa, dan extern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64 juga bisa

    Versi Rust juga tidak membutuhkan unsafe. API bisa didefinisikan dengan reference. Ironisnya, satu-satunya tempat yang mungkin membutuhkan unsafe di Rust modern hanyalah #[no_mangle], yang sekarang menjadi #[unsafe(no_mangle)], tetapi entah kenapa contohnya disusun agar sisi Rust memakai raw pointer

    Contoh di bagian belakang ini juga:

    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);  
    }  
    

    Bukankah seharusnya ada unsafe di suatu tempat di sini? Karena snprintf menerima raw pointer, sesuai pedoman sebelumnya bahwa operasi unsafe harus bisa ditemukan lewat namanya, sepertinya perlu ada sesuatu seperti unsafeSnprintf dan redefinisi simbol

    “Satu petunjuk jujur: di baris extern, Anda sedang berbicara dengan C, dan aturan C yang menang” ya hmm

    • Saya juga membacanya begitu. Namun itu tidak berbeda dari .as_raw_fd() di Rust, dan di sana juga ada masalah keamanan yang sama
  • Hal-hal yang membuat ABI Rust tidak stabil tidak ada di Jam. Tidak ada reference kelas satu, tidak ada lifetime, tidak ada layout niche-packed yang harus dihapus

    Ini salah paham tentang stabilitas FFI di standard library Rust. Shared reference, mutable reference, Box, dan Option dari semuanya memiliki ABI yang terdefinisi dan stabil. Jadi seluruh prosedur Box::into_raw/from_raw dalam contoh itu tidak diperlukan
    Lifetime sama sekali tidak ada pada level biner. Jika memilih untuk mendefinisikan ABI stabil pada enum, optimisasi niche dinonaktifkan

    Alasan sebagian besar tipe tidak mendefinisikan ABI stabil adalah karena hal itu membuat bagian internal tipe tidak bisa diubah, sehingga sering kali ABI stabil memang tidak diinginkan

  • Jam belum dipublikasikan. Compilernya ada dan berjalan, tetapi rilis yang lebih luas ditunda sementara mereka mengerjakan hal-hal yang membuatnya nyaman dipakai setiap hari—permukaan yang stabil, package manager, LSP, formatter, dan sisa tooling yang baru terasa ketika tidak ada…

    Saya tidak memahami pilihan ini. Ada perbedaan besar antara “merilis” sesuatu yang belum lengkap dan sekadar membuka source. Kalau toh nanti akan dilakukan juga, apa ruginya membuka proyek saat sedang dibangun?
    Keuntungannya adalah orang-orang yang menyukai arahnya bisa mencoba sendiri dan mungkin ikut berkontribusi. Tentu saja karena ini “era AI”, belum jelas apakah kontribusi semacam itu akan menjadi keuntungan bersih. Itu juga memungkinkan orang lebih memahami apa yang sedang dibuat dan menilai klaim tentang mengapa itu hebat. Kalau tidak bisa melakukan itu, proyeknya menjadi jauh kurang menarik

    Selain itu, ada juga orang-orang yang tidak memakai tooling seperti ini sama sekali. Tim saya sekarang bahkan belum bisa sepakat untuk mengadopsi formatter otomatis, tetapi selain itu semuanya baik-baik saja. Jadi menunda publikasi sambil membuat tooling seperti itu tidak banyak mengubah keadaan

  • Orang-orang terus mencoba membuat “Rust tanpa lifetime yang menyebalkan” dan terus gagal. Komentar lain sudah membahas salah satu pola kegagalannya; masalah mengembalikan sebagian dari nilai yang sudah di-drop muncul karena tidak bisa mengembalikan reference. Masalah klasik lainnya adalah ini:

    let mut arr = vec![1];  
    let x = &arr[0];  
    arr.push(2);  
    // Apa yang terjadi jika `x` digunakan?  
    

    Jawabannya ada tiga:

    1. Tolak. Untuk itu diperlukan semacam konsep borrowing. Biasanya shared XOR mutable; kalau hanya mutable akan merepotkan, dan kalau hanya shared tidak aman
    2. Izinkan. Karena tidak ada reference melalui variabel lain, dan semuanya adalah GC atau pointer reference-counted
    3. Izinkan dan timbulkan undefined behavior saat runtime

    Ada alasan yang cukup baik untuk memilih salah satu dari ketiganya, tetapi Jam tampaknya ingin menjadi nomor 1 seperti Rust, sementara dalam praktiknya menjadi nomor 2 karena value semantics. Jika itu berarti semuanya disalin, kemungkinan besar ini akan menghalangi penulisan struktur data yang aman sekaligus efisien

    • Menurut saya Inko melakukannya cukup baik. Tentu saja, abaikan bias saya yang jelas, tetapi komprominya sendiri juga jelas ada
      Khususnya, jika membuang borrow checker, mendukung tipe yang dialokasikan di stack menjadi jauh lebih sulit tanpa memperkenalkan banyak petunjuk. Misalnya dengan menyalin saat dipinjam; Inko dan Swift sama-sama melakukan ini
    • Saya tidak tahu banyak soal Jam, tetapi mutable value semantics ala Hylo memiliki bentuk borrowing yang disebut subscripts. Jadi ini lebih dekat ke posisi tengah
    • Pertanyaan pertama yang muncul saat membaca bagian itu adalah, “kalau tidak ada reference maupun anotasi lifetime, bagaimana menyimpan reference di dalam struct?”
      Melihat referensi bahasanya, memang tidak ada reference, tetapi ada pointer mut dan const, dan saya tidak menemukan penjelasan tentang keamanannya
  • Salah satu hal besar yang membuat Zig terasa seperti Zig adalah tidak adanya RAII, sedangkan Rust adalah borrow checker. Namun saya tidak begitu paham siapa sebenarnya yang membutuhkan titik temu dari pilihan desain ini, yaitu “RAII tanpa reference”
    Meski begitu, saya pikir ada ruang untuk bereksperimen di celah ini, dan saya memandang upaya semacam itu secara positif. Hanya saja, rasanya pendekatan ini bukan yang tepat

    Arah yang belakangan terus saya pikirkan adalah kombinasi comptime milik Zig, kapabilitas reference yang mirip Pony, memperlakukan lifetime sebagai nilai compile-time, dan memberi branding lifetime pada allocator
    Harapannya adalah menambahkan keamanan reference ke strategi allocator Zig, serta mendapatkan lifetime yang hampir tidak perlu dianotasi

  • Bahasa baru itu bagus, tetapi saya tidak suka jika semuanya menjadi frontend untuk LLVM. Saya tahu backend itu sulit, tetapi sesekali saya ingin ada pilihan lain juga

  • Kedengarannya hampir seperti Swift