Ngôn ngữ lập trình Jam
(rapha.land)- Jam là một ngôn ngữ trước v1.0, hướng tới sự an toàn, đường cong học tập thấp và hiệu năng cao mà không dùng GC, đồng thời vẫn giữ cảm giác sử dụng tức thì của các ngôn ngữ họ C
- Cốt lõi là mutable value semantics và hệ thống drop kiểu Rust; compiler xử lý ownership, borrow và tự động dọn dẹp mà không để lộ cú pháp reference hay lifetime trong mã người dùng
- Mô hình khởi tạo tránh cả
undefinedlẫn zero initialization ngầm định; lazy initialization và out-parameter được xử lý bằngMaybe(T)cùng phân tíchunsafeAssumeInit() exportphơi bày hàm Jam qua C ABI, còn Jam struct được thiết kế để có layout tương thích C, theo hướng giảm gánh nặng phải viết unsafe shim riêng hoặc annotationrepr- Compiler vẫn đang ở giai đoạn bootstrap, được hiện thực bằng C++ và chưa công khai; kế hoạch là open source sau khi tạo 108 project riêng biệt bằng Jam
Vị trí ngôn ngữ mà Jam nhắm tới
- Jam vẫn đang ở giai đoạn trước v1.0; các cơ chế được mô tả hiện đã hoạt động trong compiler, nhưng chi tiết trước khi ổn định có thể thay đổi
- Mục tiêu là tạo ra một ngôn ngữ an toàn, giảm các nhóm bug của C, đồng thời giữ cảm giác họ C dễ hiểu ngay như Go, Zig, modern C
- Trục thiết kế trung tâm gồm hai điểm
- Mutable value semantics của Racordon, Abrahams et al. 2022
- drop system của Rust
- Xuất phát điểm là nhận thức rằng trong các đội thực tế, trình độ thường không đồng đều và thành viên ít kinh nghiệm hơn dễ mắc lỗi, nên ngôn ngữ cần chặn được nhiều lỗi hơn trước khi tới bước review
Khác biệt với Rust, Zig, C++
- Rust có triết lý an toàn mạnh, nhưng khoảng cách giữa “có thể dùng Rust ở mức nào đó” và “làm việc hiệu quả bằng Rust” khá lớn, khiến đường cong học tập có thể trở thành gánh nặng cho đội ngũ
- Zig mang lại bề mặt nhỏ gần với ngôn ngữ C-like và mental model tức thì, nhưng ở cấp độ ngôn ngữ thì không phải là ngôn ngữ an toàn
- Việc ngăn uninitialized read, manual cleanup, use-after-free không được cưỡng chế ở cấp ngôn ngữ
- Các dự án production lớn bằng Zig hoặc C++ phụ thuộc nhiều vào các công cụ kiểm chứng như Valgrind, AddressSanitizer, fuzzing
- Trong thời đại AI, nhiều phần của production code được công cụ viết hoặc phác thảo thay vì con người, và nút thắt được xem là dịch chuyển từ code writing sang code review
- code volume tăng trong khi review surface phẳng, nên compiler phải bắt được nhiều bug hơn
Hệ thống drop tự động
- Binding trong Jam sở hữu giá trị, và khi binding của một drop-bearing type ra khỏi scope, compiler sẽ tổng hợp lời gọi drop
- Kiểu
Fileví dụ khai báofn drop(self: mut File), còn tronguseFile()chỉ cần viếtconst f: File = { fd: 7 };- Không có cleanup tường minh,
defer, hay dấu hiệu kết thúc lifetime - Trong LLVM IR,
call void @__drop_File(ptr %1)được tạo ngay trướcret
- Không có cleanup tường minh,
- Tên đã mangle
__drop_Filegiúp các hàm drop của nhiều type không xung đột ở mức LLVM self: mut Fileđược lowering thành pointer parameter, và call site truyền trực tiếp địa chỉ của binding- Trong Zig, để cleanup tương tự phải viết tường minh
defer f.deinit()- Nếu xóa dòng đó, deinit call trong IR cũng biến mất
- Rò rỉ file descriptor xảy ra khi programmer quên cleanup
- C++ RAII cũng tự động chạy destructor khi thoát scope, nhưng Jam chọn mô hình drop đơn giản của Rust
- Hướng đi là tránh các phức tạp như rule of 0/3/5 của C++, virtual destructor, exception trong constructor, exception trong destructor,
std::exit,std::abort,longjmp, signal - Jam có một drop function cho mỗi type và chạy nó ở mọi lần thoát scope
- Hướng đi là tránh các phức tạp như rule of 0/3/5 của C++, virtual destructor, exception trong constructor, exception trong destructor,
Khởi tạo và Maybe(T)
- Jam không có giá trị
undefined, và không thể khai báo binding mà không có giá trị- Mọi
varvàconstđều yêu cầu initializer thực sự - struct tính giá trị các field trước, tạo bằng struct literal rồi mới bind
- Mọi
- Zig cho phép
var f: File = undefined; return f.fd;, và ở runtime có thể đọc stack garbage- Ở Debug mode, có fill
0xaađể misuse dễ lộ ra - Ở Release mode, nó trở thành byte tùy ý
- Ở Debug mode, có fill
- Go zero-initialize mọi
varđể ngăn đọc garbage, nhưng có chi phí ghi zero pattern cả vào những field sắp bị overwrite - Jam tránh cả
undefinedlẫn implicit zero - Lazy initialization và out-parameter dùng
Maybe(T)empty()tạo một slot có contents chưa có ý nghĩawrite()điền vào slotunsafeAssumeInit()trích xuất giá trị
- Lint pass theo dõi slot đã được write hay chưa, và từ chối bằng compile error những lời gọi
unsafeAssumeInit()mà analyzer không chứng minh được đã khởi tạo- Prefix
unsafevẫn là anchor để human và AI reviewer có thể grep
- Prefix
Scope exit, return, break, continue
- compiler theo dõi drop scope stack và push scope mới ở mỗi lexical block boundary
- Khi block kết thúc hoặc ngay trước khi thoát bằng branch, compiler emit drop cho các binding trong scope đó
- Binding bên trong
if,else,matcharm,while,forbody được drop ở cuối block tương ứng returntrong nested block sẽ drop các active scope theo thứ tự innermost-first trướcretthực sựbreakvàcontinuedrop các scope đang mở trong loop body rồi mới đi tới loop exit hoặc iteration tiếp theo
- Binding bên trong
- Trong ví dụ nested break,
outerđược drop ở cuối iteration 0, còn ở đường đi break của iteration 1 thì drop theo thứ tựinnerrồiouter
Parameter mode và loại bỏ first-class reference
- Việc binding có bị drop trong lời gọi hàm hay không được quyết định bởi parameter mode
- Mode mặc định là read-only borrow
- callee đọc giá trị, còn binding của caller vẫn ở trạng thái initialized
- Không có drop khi call return
mutlà exclusive read-write borrow- Binding của caller vẫn ở trạng thái initialized sau lời gọi
- Chỉ
movemới consume giá trị- callee nhận ownership và drop ở cuối callee
- Binding của caller trở thành Uninit sau lời gọi, và đọc nó sẽ là compile error
- Không có marker ở call site; dạng
f(x)giống nhau cho mọi mode - Jam không có first-class reference type
- Không thể lưu borrow vào variable, return nó, hay giữ trong struct field
- Parameter borrow chỉ tồn tại trong call-frame và hết hạn khi call return
- Lý do không cần lifetime annotation là vì không có lifetime nào để attach
- API collection cũng được giữ theo dạng value-shaped
v[i] = xđược desugar thànhv.setAt(i, x)let y = v[i]là getterv.at(i)trả về element dưới dạng value
- Kiểm tra exclusivity ở call site xét path overlap của borrow set do các argument tạo ra
swap(p.x, p.y)OK vì là các sub-path rời nhaumoveX(p, p.x)lỗi vìpvàp.xoverlap
C ABI và FFI
- Native ABI của Rust không ổn định, nên khi vượt qua distribution boundary phải encoding lại theo dạng C
- Dereference raw pointer là
unsafe - Ownership được truyền thủ công bằng
Box::into_rawvàBox::from_raw - Khi truyền struct by value cần annotation riêng như
#[repr(C)] - Các công cụ như
cbindgenvàabi_stabletồn tại để giảm thao tác thủ công ở ranh giới này
- Dereference raw pointer là
- Jam không có first-class reference, lifetime, hay niche-packed layout, nên Jam value được xem là value-shaped đến tận cùng
- Jam struct đã được thiết kế để có layout tương thích C
exportphơi bày hàm Jam bằng plain unmangled name theo C calling conventionexport fn counterAdd(c: mut Counter, n: i64) i64có thể được gọi từ C dưới dạngint64_t counterAdd(Counter *c, int64_t n);- Parameter
mut Counterđược lowering thànhCounter *trỏ tới caller-owned storage
- Body hàm phía Jam là Jam thông thường, nên drop, init analysis và call-site exclusivity rule vẫn tiếp tục áp dụng
- Chiều đi vào C được khai báo C signature bằng
extern- Hàm
externtuân theo C ABI một cách literal - Cơ chế parameter-mode không áp dụng bên ngoài boundary
- Buffer được truyền cho C bằng raw pointer, và Jam không kiểm chứng C làm gì với pointer đó
- Hàm
- Phạm vi Jam muốn cung cấp là phía Jam vẫn safe by default, và khi phơi bày thư viện Jam qua C ABI thì không cần tạo unsafe API mirror hay shim layer riêng
Pattern matching
matchcủa Jam có dạngPattern Blockvà không dùng=>- Scrutinee dùng ngoặc như
match (opcode) _là catch-all arm- Các arm được xét tuần tự từ trên xuống dưới theo first-match và không có implicit fallthrough
- Scrutinee dùng ngoặc như
- Opcode dispatcher của trình giả lập Game Boy là use case chính
- Dạng dispatch 256 base opcode và 256 prefix opcode
- Cũng hỗ trợ matching enum payload
- Variant pattern match tag và bind payload field thành fresh local bên trong arm
- compiler kiểm tra exhaustiveness với tập variant
- Khi thêm variant mới, các match site không xử lý variant đó sẽ compile fail
matchcũng hoạt động như expression- Mỗi arm block tạo giá trị của trailing expression
- Mọi arm phải produce cùng type
- match phải exhaustive
- Bên trong, mọi match được compile qua decision tree pipeline dựa trên Luc Maranget 2008
- Integer literal cascade được LLVM
simplifycfgfold thànhswitchvà jump table khi có lợi
- Integer literal cascade được LLVM
Thiết kế compile time
- Pipeline compile của Rust đi qua nhiều IR và bước phân tích
tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code- Trait solving là search problem, còn borrow checking là whole-function region analysis
- Monomorphization làm tăng code volume trước LLVM
- Pipeline của Jam được thiết kế ngắn hơn
tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code- Dùng một typed IR là JIR
- JIR đã có type ngay từ khi AstGen tạo ra
- Jam được xem là không có comptime-as-values buộc phải lowering chưa typed
- Drop placement, init-before-use check và call-site exclusivity rule được thực hiện bằng local dataflow pass trên JIR
- Vì mỗi binding đều có type annotation, gánh nặng global type inference và open-ended trait search được xem là thấp hơn
- AST và JIR là flat data structure
- Đóng gói node nhỏ kích thước cố định vào contiguous array
- Dùng index thay vì pointer, payload quá lớn được lưu trong side pool
- Giúp compiler duyệt cache-friendly array thay vì theo dõi cây heap-allocated
- Ở backend, LLVM chi phối thời gian tối ưu hóa release build
- Có kế hoạch split: dùng Cranelift cho debug build, LLVM cho release build
- Cranelift nằm trong roadmap và chưa hoàn tất
- Hiện compiler đang ở giai đoạn bootstrap ngôn ngữ bằng implementation C++, và chưa có build-time benchmark đáng trích dẫn
- Các claim liên quan compile-time là design claim, không phải kết quả đo đạc
Hiệu năng runtime và ví dụ
- Mục tiêu là Jam đạt hiệu năng ngang Rust và Zig
- Jam không có GC, managed-memory runtime hay per-allocation header
- codegen là LLVM IR straightforward
- Chưa xem là đã đạt mức của Rust và Zig
- Rust và Zig đã làm việc lâu dài với target-specific intrinsic trong standard library, auto-vectorization hint, allocator-aware container, hot path tuning, LLVM pass tuning
- Jam cũng cần các công việc tương tự để thu hẹp 10–30% cuối cùng
- Với workload đã đo hiện tại, gap được xem là nằm trong small constant factor, không phải “khác class”
- Demo Tetris chạy trong terminal được viết bằng Jam
Kế hoạch công khai và việc còn lại
- Jam vẫn chưa public
- compiler tồn tại và hoạt động, nhưng chưa phát hành rộng rãi
- Đang thực hiện các công việc sau cho khả năng sử dụng hằng ngày
- stable surface
- package manager
- LSP
- formatter
- phần tooling còn lại
- Vẫn còn các chủ đề dự kiến được trình bày trong bài riêng
- parameter mode system
- exclusivity rule
- generics
- comptime của Jam
- standard library
- allocator systems
- panic model
- MLIR exploration cho GPU codegen pipeline
- Rust ABI work cho FFI
- Cranelift
- lộ trình self-hosted compiler
- Kế hoạch open source là công khai sau khi tạo 108 project riêng biệt bằng Jam
- Con số 108 là arbitrary milestone lấy từ 108 Stars of Destiny trong Suikoden 2
- Hiện đã được đưa cho một small group of users, và sẽ mở rộng phạm vi khi tooling bắt kịp
- Có thể đăng ký early access qua beta list tại jamlang.org
1 bình luận
Các ý kiến trên Lobste.rs
Những bài viết do LLM tạo ra như thế này đang làm một việc mà các kỹ sư, đặc biệt là kỹ sư trẻ, cần cẩn trọng: thay thế dữ liệu định lượng bằng văn xuôi định tính nghe có vẻ hợp lý
Thuyết phục bằng câu chuyện thì dễ hơn cho cả người viết lẫn người đọc so với việc thu thập và phân tích những con số chắc chắn. Não người thích câu chuyện, và câu chuyện hiệu quả nhất khi đơn giản và gọn gàng. Dữ liệu thực tế thường phản ánh một thế giới phức tạp, với nhiều sắc thái đúng bằng mức ta chịu đào sâu vào nó
Chỉ cần so sánh với bài blog định lượng về profiling trình biên dịch Rust do một người đóng góp cho rustc viết
Một bài viết kỹ thuật tốt có thể và nên chứa cả hai nếu phù hợp, nhưng không được bỏ lỡ điều thực sự cần truyền đạt. Sau khi vận hành một tổ chức lớn làm công việc bảo đảm, tôi mới thấy viết kỹ thuật khó đến mức nào, và khi khả năng tiếp cận LLM trở nên dễ dàng hơn, cần cẩn trọng rằng vấn đề này có thể tệ hơn đến đâu
Khác biệt cốt lõi với Zig có phải là có
drop, và không có một thành phần cụ thể rất dễ bị dùng sai làundefinedkhông?Không có
undefinedvà mọi giá trị đều phải được khởi tạo, nhưngMaybe(T).empty()trả về một giá trị có nội dung “chưa có ý nghĩa”, và nếu gọiunsafeAssumeInit()ngay sau đó thì có vẻ sẽ trả về giá trị rác. Vậy thì đây không phải là an toàn theo nghĩa như Rust, nơi trình biên dịch coiunsafelà sự nhiễm bẩn cần khốiunsafe { .. }tường minhVí dụ minh họa tính an toàn và chức năng
droplà đoạn mã này:Nếu tôi không nhìn nhầm thì cái này không an toàn phải không? Tạm bỏ qua việc cấp phát thủ công file descriptor, nó gọi
close(7)rồi trả về7. Vì không có theo dõi vòng đời, người dùng không có cách nào biểu đạt rằng vòng đời của file descriptor đã kết thúc trước khiuseFile()trả vềTrong ví dụ ABI, khi
export fn counterAdd(c: mut Counter, n: i64) i64 { .. }trở thànhint64_t counterAdd(Counter *c, int64_t n);, làm sao biểu đạt đượcccó được phép làNULLhay không? Rust có ABI được định nghĩa cho phần này, có thể viếtextern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64, và cũng có thể viếtextern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64Phiên bản Rust cũng không cần
unsafe. Có thể định nghĩa API bằng reference. Trớ trêu là nơi duy nhất có thể cầnunsafetrong Rust hiện đại chỉ khoảng#[no_mangle], vốn là#[unsafe(no_mangle)], nhưng ví dụ không hiểu sao lại được dựng để phía Rust dùng raw pointerVí dụ phía sau này cũng vậy:
Chẳng phải ở đâu đó trong này phải có
unsafesao? Vìsnprintfnhận raw pointer, theo chỉ dẫn đã nói trước đó rằng các thao tácunsafephải có thể tìm được qua tên, có lẽ phải có thứ gì đó nhưunsafeSnprintfvà định nghĩa lại symbol“Một manh mối trung thực: ở dòng
extern, bạn đang nói chuyện với C, và luật của C thắng” à, hừm.as_raw_fd()của Rust, và ở đó cũng có cùng vấn đề an toànĐây là hiểu nhầm về độ ổn định FFI của thư viện chuẩn Rust. Shared reference, mutable reference,
Box, vàOptioncủa chúng đều có ABI được định nghĩa và ổn định. Vì vậy toàn bộ quy trìnhBox::into_raw/from_rawtrong ví dụ là không cần thiếtLifetime hoàn toàn không tồn tại ở cấp nhị phân. Nếu chọn định nghĩa ABI ổn định cho enum, tối ưu hóa niche sẽ bị vô hiệu hóa
Lý do phần lớn type không định nghĩa ABI ổn định là vì thường người ta không muốn ABI ổn định, bởi khi đó không thể thay đổi phần bên trong của type nữa
Tôi không hiểu lựa chọn này. Có sự khác biệt lớn giữa việc “phát hành” một thứ chưa hoàn chỉnh và chỉ công khai mã nguồn. Nếu dù sao sau này cũng làm, thì công khai trong lúc xây dựng dự án có hại gì?
Ưu điểm là những người thích hướng đi đó có thể tự dùng thử và có khi còn đóng góp. Tất nhiên, vì đang ở “thời đại AI”, không rõ những đóng góp đó có phải là lợi ích ròng hay không. Nó cũng giúp mọi người hiểu rõ hơn bạn đang xây dựng gì, và đánh giá các tuyên bố về lý do nó tuyệt vời. Nếu không làm được điều đó, dự án trở nên kém thú vị hơn nhiều
Hơn nữa cũng có những người không dùng bất kỳ công cụ nào kiểu này. Ngay cả nhóm của tôi hiện còn chưa thống nhất được việc áp dụng auto formatter, nhưng ngoài chuyện đó thì rất tuyệt. Vì vậy trì hoãn công khai trong lúc làm các công cụ như vậy cũng chẳng tạo ra khác biệt mấy
Mọi người cứ cố tạo ra “Rust không có lifetime phiền phức” và cứ tiếp tục thất bại. Một bình luận khác đã đề cập đến một dạng thất bại: vấn đề trả về một phần của giá trị đã bị
dropphát sinh vì không thể trả về tham chiếu. Một vấn đề kinh điển khác là thế này:Có ba câu trả lời:
Chọn phương án nào trong ba phương án này cũng đều có lý do hợp lý riêng, nhưng Jam dường như muốn giống Rust ở phương án 1, trong khi thực tế lại giống phương án 2 vì ngữ nghĩa giá trị. Nếu điều đó có nghĩa là mọi thứ đều được sao chép, nó có khả năng ngăn cản việc viết các cấu trúc dữ liệu vừa an toàn vừa hiệu quả
Đặc biệt, nếu bỏ borrow checker, việc hỗ trợ kiểu cấp phát trên stack sẽ khó hơn nhiều nếu không đưa vào nhiều manh mối khác nhau. Ví dụ là cách sao chép khi vay mượn, và cả Inko lẫn Swift đều làm như vậy
Nhìn vào tài liệu tham chiếu của ngôn ngữ thì không có tham chiếu, nhưng có con trỏ
mutvàconst, và tôi không tìm thấy nội dung nào nói về tính an toàn của chúngMột yếu tố lớn khiến Zig là Zig chính là không có RAII, còn Rust là borrow checker. Nhưng tôi không rõ rốt cuộc ai thực sự cần “RAII không có tham chiếu”, điểm đến của các lựa chọn thiết kế này
Dù vậy tôi nghĩ vẫn có không gian để thử nghiệm trong ngách này, và tôi đánh giá cao những nỗ lực như vậy. Chỉ là tôi không nghĩ cách tiếp cận này là đúng
Hướng mà gần đây tôi cứ nghĩ tới là sự kết hợp giữa
comptimecủa Zig, quyền tham chiếu tương tự Pony, xem lifetime như giá trị tại thời điểm biên dịch, và branding lifetime vào allocatorĐiều tôi kỳ vọng là thêm an toàn tham chiếu vào chiến lược allocator của Zig, đồng thời có được lifetime gần như không cần ghi chú
Ngôn ngữ mới thì tốt, nhưng tôi không thích việc mọi thứ đều trở thành frontend của LLVM. Tôi biết backend khó, nhưng đôi khi tôi cũng muốn có những lựa chọn khác
Nghe gần như giống Swift