Bahasa Pemrograman Jam
(rapha.land)- 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
undefinedmaupun zero-initialization implisit, dan menangani lazy initialization serta out-parameter melalui analisisMaybe(T)danunsafeAssumeInit() exportmengekspos fungsi Jam sebagai C ABI, dan struct Jam dirancang memiliki layout yang kompatibel dengan C, sehingga arahnya mengurangi beban unsafe shim atau annotationreprterpisah- 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
Filemendeklarasikanfn drop(self: mut File), dan diuseFile()cukup menulisconst f: File = { fd: 7 };- Tidak ada cleanup eksplisit,
defer, atau penanda akhir lifetime - Di LLVM IR,
call void @__drop_File(ptr %1)dibuat tepat sebelumret
- Tidak ada cleanup eksplisit,
- Nama yang di-mangle,
__drop_File, mencegah fungsi drop dari berbagai type bertabrakan di level LLVM self: mut Filediturunkan 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
- Arahnya adalah menghindari kompleksitas seperti rule of 0/3/5 di C++, virtual destructor, constructor exception, destructor exception,
Inisialisasi dan Maybe(T)
- Di Jam tidak ada nilai
undefined, dan binding tidak dapat dideklarasikan tanpa nilai- Semua
vardanconstmembutuhkan initializer nyata - Struct menghitung nilai field terlebih dahulu, lalu dibuat dengan struct literal dan di-bind
- Semua
- Zig mengizinkan
var f: File = undefined; return f.fd;, dan pada runtime ini dapat membaca stack garbage- Dalam Debug mode, fill
0xaadimasukkan agar misuse terlihat - Dalam Release mode, hasilnya menjadi arbitrary bytes
- Dalam Debug mode, fill
- Go melakukan zero-initialize untuk semua
varagar mencegah garbage read, tetapi ada biaya menulis zero pattern bahkan pada field yang segera di-overwrite - Jam menghindari baik
undefinedmaupun zero implisit - Untuk lazy initialization dan out-parameter, Jam menggunakan
Maybe(T)empty()membuat slot dengan contents yang belum bermaknawrite()mengisi slotunsafeAssumeInit()mengekstrak nilainya
- Lint pass melacak apakah slot sudah di-write, dan menolak pemanggilan
unsafeAssumeInit()sebagai compile error bila analyzer tidak dapat membuktikan inisialisasi- Prefix
unsafetetap menjadi anchor yang bisa di-grep oleh reviewer manusia maupun AI
- Prefix
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, armmatch,while, dan bodyfordi-drop pada akhir block terkait returndi dalam nested block men-drop active scope dari yang terdalam terlebih dahulu sebelumretsebenarnyabreakdancontinuemen-drop scope yang terbuka di dalam loop body sebelum berpindah ke loop exit atau iteration berikutnya
- Binding di dalam
- Dalam contoh nested break,
outerdi-drop pada akhir iteration 0, dan pada path break di iteration 1, drop terjadi berurutaninnerlaluouter
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
mutadalah exclusive read-write borrow- Binding milik caller tetap initialized setelah call
- Hanya
moveyang 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] = xdi-desugar menjadiv.setAt(i, x)let y = v[i]memakai getterv.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 disjointmoveX(p, p.x)error karenapdanp.xoverlap
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_rawdanBox::from_raw - Saat meneruskan struct by value, annotation terpisah seperti
#[repr(C)]diperlukan - Tool seperti
cbindgendanabi_stableada untuk mengurangi kerja manual pada boundary ini
- Dereference raw pointer adalah
- 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
exportmengekspos fungsi Jam sebagai plain unmangled name dengan C calling conventionexport fn counterAdd(c: mut Counter, n: i64) i64dapat dipanggil dari C sebagaiint64_t counterAdd(Counter *c, int64_t n);- Parameter
mut Counterditurunkan menjadiCounter *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
externmengikuti 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
- Function
- 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
matchdi Jam berbentukPattern Blockdan 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
- Scrutinee memakai tanda kurung seperti
- 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
matchjuga 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
simplifycfgmenjadiswitchdan jump table ketika menguntungkan
- Cascade integer literal akan di-fold oleh LLVM
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
Pendapat di Lobste.rs
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
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, yaituundefined?Tidak ada
undefineddan semua nilai harus diinisialisasi, tetapiMaybe(T).empty()tampaknya mengembalikan nilai yang isinya “belum bermakna”, dan jika langsung diikuti pemanggilanunsafeAssumeInit(), sepertinya akan mengembalikan nilai sampah. Kalau begitu, ini bukan aman dalam pengertian seperti Rust, di mana compiler memperlakukanunsafesebagai kontaminasi yang membutuhkanunsafe { .. }eksplisitContoh yang menunjukkan fitur keamanan dan
dropadalah kode ini:Kalau saya tidak salah lihat, bukankah ini tidak aman? Terlepas dari alokasi file descriptor secara manual, ia memanggil
close(7)lalu mengembalikan7. Karena tidak ada pelacakan lifetime, pengguna tidak punya cara untuk mengekspresikan bahwa lifetime file descriptor sudah berakhir sebelumuseFile()mengembalikan nilaiDalam contoh ABI, ketika
export fn counterAdd(c: mut Counter, n: i64) i64 { .. }menjadiint64_t counterAdd(Counter *c, int64_t n);, bagaimana cara mengekspresikan apakahcbolehNULLatau tidak? Rust punya ABI yang terdefinisi untuk bagian ini;extern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64bisa, danextern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64juga bisaVersi Rust juga tidak membutuhkan
unsafe. API bisa didefinisikan dengan reference. Ironisnya, satu-satunya tempat yang mungkin membutuhkanunsafedi Rust modern hanyalah#[no_mangle], yang sekarang menjadi#[unsafe(no_mangle)], tetapi entah kenapa contohnya disusun agar sisi Rust memakai raw pointerContoh di bagian belakang ini juga:
Bukankah seharusnya ada
unsafedi suatu tempat di sini? Karenasnprintfmenerima raw pointer, sesuai pedoman sebelumnya bahwa operasiunsafeharus bisa ditemukan lewat namanya, sepertinya perlu ada sesuatu sepertiunsafeSnprintfdan redefinisi simbol“Satu petunjuk jujur: di baris
extern, Anda sedang berbicara dengan C, dan aturan C yang menang” ya hmm.as_raw_fd()di Rust, dan di sana juga ada masalah keamanan yang samaIni salah paham tentang stabilitas FFI di standard library Rust. Shared reference, mutable reference,
Box, danOptiondari semuanya memiliki ABI yang terdefinisi dan stabil. Jadi seluruh prosedurBox::into_raw/from_rawdalam contoh itu tidak diperlukanLifetime 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
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-
dropmuncul karena tidak bisa mengembalikan reference. Masalah klasik lainnya adalah ini:Jawabannya ada tiga:
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
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
Melihat referensi bahasanya, memang tidak ada reference, tetapi ada pointer
mutdanconst, dan saya tidak menemukan penjelasan tentang keamanannyaSalah 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
comptimemilik Zig, kapabilitas reference yang mirip Pony, memperlakukan lifetime sebagai nilai compile-time, dan memberi branding lifetime pada allocatorHarapannya 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