Jam 프로그래밍 언어

1 week ago 8
  • 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을 값 없이 선언할 수 없음
    • 모든 var와 const는 실제 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함
    • break와 continue는 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] = x는 v.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)는 p와 p.x가 overlap하므로 error임

C ABI와 FFI

  • Rust의 native ABI는 unstable이라 distribution boundary를 넘으면 C 형태로 다시 encoding해야 함
    • raw pointer dereference는 unsafe
    • ownership은 Box::into_raw와 Box::from_raw로 수동 전달됨
    • struct를 by value로 넘길 때는 #[repr(C)] 같은 별도 annotation이 필요함
    • cbindgen과 abi_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의 match는 Pattern 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로 받을 수 있음
Read Entire Article