1P by GN⁺ | ★ favorite | 댓글 1개
  • Jam은 C 계열 언어의 즉각적인 사용감을 유지하면서, GC 없이 안전성·낮은 학습 곡선·고성능을 함께 노리는 v1.0 이전 단계의 언어임
  • 핵심은 mutable value semantics와 Rust식 drop 시스템으로, 사용자 코드에 참조나 lifetime 문법을 노출하지 않고 소유권·borrow·자동 정리를 컴파일러가 처리함
  • 초기화 모델은 undefined와 암묵적 zero 초기화를 모두 피하고, 지연 초기화와 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를 줄이는 안전한 언어를 만드는 것임
  • 설계의 중심축은 두 가지임
    • 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를 벗어나면 컴파일러가 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 모델을 채택함
    • C++의 rule of 0/3/5, virtual destructor, constructor exception, destructor exception, std::exit, std::abort, longjmp, signal 같은 복잡성을 피하려는 방향임
    • Jam은 type당 하나의 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함
    • if, else, match arm, while, for 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 all the way down이라고 봄
    • 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 기반의 결정 트리 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 하나를 사용함
  • 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로 받을 수 있음

댓글과 토론

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`를 사용하면 어떻게 되나?  
    

    답은 셋임:

    1. 거부한다. 그러려면 어떤 형태의 빌림 개념이 필요함. 보통 공유 XOR 가변인데, 가변만 있으면 불편하고 공유만 있으면 안전하지 않음
    2. 허용한다. 다른 변수를 통한 참조가 존재하지 않고 모든 것이 GC 또는 참조 카운트 포인터이기 때문임
    3. 허용하고 런타임에 정의되지 않은 동작을 일으킨다

    이 셋 중 어느 쪽을 택해도 나름의 좋은 이유는 있지만, Jam은 Rust처럼 1번이 되고 싶어 하면서 실제로는 값 의미론 때문에 2번인 듯함. 모든 것이 복사된다는 뜻이라면, 안전하면서도 효율적인 자료구조를 작성하는 걸 막을 가능성이 큼

    • Inko는 꽤 잘하고 있다고 봄. 물론 내 명백한 편향은 빼고 봐야 하지만, 그 자체의 절충도 분명 있음
      특히 빌림 검사기를 버리면, 여러 단서를 도입하지 않고는 스택 할당 타입을 지원하기가 훨씬 어려워짐. 예를 들어 빌릴 때 복사하는 방식인데, Inko와 Swift가 둘 다 이렇게 함
    • Jam은 잘 모르겠지만, Hylo식 가변 값 의미론에는 subscripts라는 빌림 형태가 있음. 그래서 좀 더 중간 지대에 가까움
    • 그 부분을 읽었을 때 가장 먼저 든 질문은 “참조도 수명 표기도 없으면 구조체 안에 참조를 어떻게 저장하지?”였음
      언어 참조를 보니 참조는 없지만 mutconst 포인터는 있고, 그 안전성에 관한 내용은 찾지 못했음
  • Zig를 Zig답게 만드는 큰 요소는 RAII가 없다는 점이고, Rust는 빌림 검사임. 그런데 이 설계 선택들이 도달한 지점인 “참조 없는 RAII”는 실제로 누가 필요로 하는 건지 잘 모르겠음
    그래도 이 틈새에서 실험할 공간은 있다고 보고, 그런 시도는 좋게 생각함. 다만 이 접근은 아닌 것 같음

    요즘 계속 생각하는 방향은 Zig의 comptime, Pony 비슷한 참조 권한, 수명을 컴파일 타임 값으로 다루는 것, 그리고 수명을 할당자에 브랜딩하는 것의 조합임
    기대하는 건 Zig의 할당자 전략에 참조 안전성을 더하고, 거의 표기할 필요 없는 수명을 얻는 것임

  • 새 언어는 좋지만 모든 것이 LLVM의 프런트엔드가 되는 건 싫음. 백엔드가 어렵다는 건 알지만, 가끔은 다른 선택지도 좀 있었으면 함

  • 거의 Swift처럼 들림