1 điểm bởi GN⁺ 3 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • 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ả undefined lẫn zero initialization ngầm định; lazy initialization và out-parameter được xử lý bằng Maybe(T) cùng phân tích unsafeAssumeInit()
  • export phơ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 annotation repr
  • 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 File ví dụ khai báo fn drop(self: mut File), còn trong useFile() chỉ cần viết const 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ước ret
  • Tên đã mangle __drop_File giú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

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 varconst đề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
  • 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 ý
  • 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ả undefined lẫn implicit zero
  • Lazy initialization và out-parameter dùng Maybe(T)
    • empty() tạo một slot có contents chưa có ý nghĩa
    • write() điền vào slot
    • unsafeAssumeInit() 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 unsafe vẫn là anchor để human và AI reviewer có thể grep

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, match arm, while, for body được drop ở cuối block tương ứng
    • return trong nested block sẽ drop các active scope theo thứ tự innermost-first trước ret thực sự
    • breakcontinue drop các scope đang mở trong loop body rồi mới đi tới loop exit hoặc iteration tiếp theo
  • 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ự inner rồi outer

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
  • mut là exclusive read-write borrow
    • Binding của caller vẫn ở trạng thái initialized sau lời gọi
  • Chỉ move mớ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ành v.setAt(i, x)
    • let y = v[i] là getter v.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 nhau
    • moveX(p, p.x) lỗi vì pp.x overlap

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_rawBox::from_raw
    • Khi truyền struct by value cần annotation riêng như #[repr(C)]
    • Các công cụ như cbindgenabi_stable tồn tại để giảm thao tác thủ công ở ranh giới này
  • 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
  • export phơi bày hàm Jam bằng plain unmangled name theo C calling convention
    • export fn counterAdd(c: mut Counter, n: i64) i64 có thể được gọi từ C dưới dạng int64_t counterAdd(Counter *c, int64_t n);
    • Parameter mut Counter được lowering thành Counter * 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 extern tuâ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 đó
  • 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

  • match của Jam có dạng Pattern Block và 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
  • 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
  • match cũ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 simplifycfg fold thành switch và jump table khi có lợi

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
  • Giải quyết trait là một bài toán tìm kiếm. Kiểm tra borrow là phân tích phạm vi toàn hàm. Monomorphization làm tăng lượng mã trước cả khi LLVM, giai đoạn chậm nhất, kịp nhìn thấy nó…

    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

    • Tôi còn nghi ngờ ngay từ việc có nên coi dự án này là nghiêm túc hay không
    • Tôi đặc biệt thích cách diễn đạt “thay thế dữ liệu định lượng bằng văn xuôi định tính/gợi cảm”
      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à undefined không?

    Không có undefined và mọi giá trị đều phải được khởi tạo, nhưng Maybe(T).empty() trả về một giá trị có nội dung “chưa có ý nghĩa”, và nếu gọi unsafeAssumeInit() 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 coi unsafe là sự nhiễm bẩn cần khối unsafe { .. } tường minh

    Ví dụ minh họa tính an toàn và chức năng drop là đoạn mã này:

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

    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 khi useFile() trả về

    Trong ví dụ ABI, khi export fn counterAdd(c: mut Counter, n: i64) i64 { .. } trở thành int64_t counterAdd(Counter *c, int64_t n);, làm sao biểu đạt được c có được phép là NULL hay không? Rust có ABI được định nghĩa cho phần này, có thể viết extern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64, và cũng có thể viết extern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64

    Phiê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ần unsafe trong 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 pointer

    Ví dụ phía sau này cũng vậy:

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

    Chẳng phải ở đâu đó trong này phải có unsafe sao? Vì snprintf nhận raw pointer, theo chỉ dẫn đã nói trước đó rằng các thao tác unsafe phải có thể tìm được qua tên, có lẽ phải có thứ gì đó như unsafeSnprintf và đị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

    • Tôi cũng đọc như vậy. Tuy nhiên nó chẳng khác gì .as_raw_fd() của Rust, và ở đó cũng có cùng vấn đề an toàn
  • Thứ làm ABI của Rust bất ổn không tồn tại trong Jam. Không có reference hạng nhất, không có lifetime, không có layout niche-packed cần phải xóa bỏ

    Đây là hiểu nhầm về độ ổn định FFI của thư viện chuẩn Rust. Shared reference, mutable reference, Box, và Option của chúng đều có ABI được định nghĩa và ổn định. Vì vậy toàn bộ quy trình Box::into_raw/from_raw trong ví dụ là không cần thiết
    Lifetime 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

  • Jam vẫn chưa được công khai. Trình biên dịch tồn tại và chạy được, nhưng chúng tôi đang hoãn công bố rộng rãi trong lúc làm những thứ giúp nó dễ dùng hằng ngày—bề mặt ổn định, trình quản lý gói, LSP, formatter, và phần công cụ còn lại mà bạn chỉ nhận ra khi chúng vắng mặt…

    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ị drop phát sinh vì không thể trả về tham chiếu. Một vấn đề kinh điển khác là thế này:

    let mut arr = vec![1];  
    let x = &arr[0];  
    arr.push(2);  
    // Điều gì xảy ra nếu dùng `x`?  
    

    Có ba câu trả lời:

    1. Từ chối. Muốn vậy cần một khái niệm vay mượn dưới dạng nào đó. Thường là chia sẻ XOR khả biến; nếu chỉ có khả biến thì bất tiện, còn nếu chỉ có chia sẻ thì không an toàn
    2. Cho phép. Vì không tồn tại tham chiếu thông qua biến khác và mọi thứ đều là GC hoặc con trỏ đếm tham chiếu
    3. Cho phép và gây ra hành vi không xác định ở runtime

    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ả

    • Tôi nghĩ Inko đang làm khá tốt. Tất nhiên phải bỏ qua thiên kiến rõ ràng của tôi, nhưng bản thân nó cũng có những đánh đổi nhất định
      Đặ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
    • Tôi không rõ về Jam, nhưng ngữ nghĩa giá trị khả biến kiểu Hylo có một dạng vay mượn gọi là subscripts. Vì vậy nó gần với vùng trung gian hơn
    • Câu hỏi đầu tiên nảy ra khi tôi đọc phần đó là “nếu không có tham chiếu cũng không có ký hiệu lifetime thì lưu tham chiếu trong struct bằng cách nào?”
      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ỏ mutconst, 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úng
  • Mộ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 comptime củ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