Jam 프로그래밍 언어의 설계 목표와 드롭 시스템, 초기화 안전성, 패턴 매칭, C ABI 호환성, 컴파일 시간 및 성능에 대한 개요.
시작하기 전에 먼저 말해두자면: 저는 어떤 언어도 깎아내리려는 것이 아닙니다. 저는 그 모든 언어를 진심으로 존중합니다. 모든 언어에는 다른 언어에 없는 장점이 있고, 완벽한 언어 같은 것은 없습니다. Rio Terminal의 제작자로서 저는 다른 터미널에 대해 부정적인 말을 한 적이 없고, 여기서도 다른 언어에 대해 그렇게 하지 않을 것입니다. 자신이 믿는 무언가를 만들기 위해 시간을 쓰는 사람이라면 누구든 제 존경을 받습니다. 아래 내용은 작업 뒤에 있는 사람들에 대한 평가가 아니라, 트레이드오프에 대한 관점입니다.
한 가지 더: Jam은 아직 v1.0으로 가는 중입니다. 아래의 메커니즘은 실제이며 오늘 컴파일러에서 돌아가고 있지만, 언어가 안정화되기 전에는 세부 사항이 바뀔 수 있으니, 여기 적힌 내용은 최종 형태가 아니라 현재 상태로 받아들여 주세요.
또 한 가지: LLM 시대에 프로그래밍 언어를 배우는 것이 시간 낭비라고 생각한다면, 이 글은 당신을 위한 것이 아닙니다. 미리 말해두면 당신 시간을 아낄 수 있으니까요.
좋습니다, 할 말은 다 했으니 시작하겠습니다.
–
저는 Rust를 사랑합니다. 정말입니다. 저는 Rust 워크숍도 진행했습니다. 제가 일했던 몇몇 회사에서는 Rust 도입을 가장 먼저 밀어붙인 사람이기도 했습니다. 팀을 함께 끌고 가기 위해 많은 에너지를 쏟았습니다.
문제는 Rust가 계속 더 복잡해지고 있다는 점입니다. 오해하지는 말아 주세요. Rust는 분명한 철학을 가지고 있고, 그 철학이 요구하는 선택을 하고 있습니다. 하지만 실제 업무, 실제 팀에서는 물건을 빨리 출하하고 싶고, 팀이 그 언어에 헌신하길 원합니다. “Rust를 조금 쓸 수 있다”와 “Rust로 생산적이다” 사이의 절벽은 너무 가파라서, 실력 있는 엔지니어도 그 앞에서 멈춰 서곤 하고, 그들을 끌어올리는 데 몇 달을 쓰게 됩니다.
이것이 최근 Zig가 힘을 얻고 있는 이유입니다. Zig는 프로그래머를 C 계열 언어에 가깝게 붙잡아 둡니다. 작은 표면적, 즉각적인 정신 모델, 문법적 잡음의 부재. C의 즐거움에서 C의 대부분의 자해성 함정을 덜어낸 느낌입니다. 문제는 Zig가 안전한 언어가 아니라는 점입니다. 초기화되지 않은 값 읽기, 수동 정리, use-after-free를 언어 차원에서 막아주는 것도 없습니다. Zig는 그 모든 것을 당신 손에 맡기고, 당신이 조심할 것이라 믿습니다.
그게 괜찮다고 주장할 수도 있습니다. 혼자 일하는 숙련된 프로그래머 한 명이라면 필요한 불변식을 머릿속에 유지할 수 있고, use-after-free를 출하할 가능성도 낮습니다. 문제는 실제 소프트웨어는 거의 절대 그런 형태가 아니라는 점입니다. 실제 소프트웨어에는 의존성이 있고, 그중 하나에라도 CVE가 생기면 그 책임은 당신에게 돌아옵니다. 실제 팀은 숙련도 수준이 섞여 있고, 덜 숙련된 쪽일수록 실수를 더 자주 합니다. 운영 중인 큰 Zig 또는 C++ 프로젝트를 보세요. Valgrind, AddressSanitizer, 퍼징에 크게 의존하며, 같은 흐름 위에서 같은 검사를 계속 반복합니다. 때로는 팀이 소유하지도 않은 의존성 내부에서까지 그렇습니다. 이것이 기본 비안전성의 비용입니다. 검증 작업은 사라지지 않습니다. 그저 언어 밖으로 밀려나 도구, CI, 사후 분석으로 옮겨갈 뿐입니다. 결과적으로 소프트웨어는 운영 환경에서 덜 신뢰할 수 있게 되고, 시간이 지날수록 유지보수도 더 어려워집니다.

Bun의 제작자 Jarred Sumner가 Bun을 Zig에서 Rust로 다시 쓰는 일에 대해 한 말입니다. 위에서 설명한 세금이 정확히 드러납니다.
AI 시대에는 안전성이 필수가 되었거나, 적어도 매우 바람직한 것이 되었습니다. 오늘날 운영 환경의 많은 코드는 사람이 아닌 무언가에 의해 작성되거나, 적어도 초안이 만들어집니다. 저는 가치 판단을 하는 것이 아닙니다. 그냥 지금 우리가 있는 위치가 그렇다는 뜻입니다. 병목의 형태가 바뀌었습니다. 10년 전에는 대부분의 사람이 코드를 작성했지만, 이제는 대부분이 코드를 검토합니다. 10년 전에는 컴파일러가 버그의 절반을 잡고 사람이 나머지 절반을 잡았지만, 이제는 컴파일러가 전부 잡아야 합니다. 줄 단위로 완전한 의도를 가지고 코드를 읽는 인간은 사라졌거나, 다른 데 정신이 팔려 있기 때문입니다. 코드 양은 늘어나는데 검토 표면적은 그대로인 상황에서, 정직함을 지키게 만드는 쪽은 언어여야 합니다.
안전성, 낮은 학습 곡선, 높은 성능이 제가 Jam 프로그래밍 언어를 시작한 가장 큰 이유였습니다.
제가 붙잡고 있던 질문은 이것입니다. 가비지 컬렉터 없이, C 계열 언어(Go, Zig, 현대 C)의 즐겁고 즉각적인 감각을 유지하면서 언어를 어떻게 안전하게 만들 것인가? 사람들에게 C의 사용성을 주면서도 C의 버그 범주는 어떻게 없앨 것인가? 그렇게 해서 나온 타협은 네 곳에서 영감을 끌어온 언어였습니다. 오늘은 그중 두 가지에 집중하고, 나머지 두 가지는 별도의 글에서 다루겠습니다.
drop 함수를 선언하고, 컴파일러는 모든 스코프 종료 지점에서 그 호출을 합성하며, 작은 데이터플로 분석이 컴파일 시점에 초기화되지 않은 값 사용을 잡아냅니다.그 결과는 다음과 같은 언어입니다. 바인딩은 자신의 값을 소유하고, 리소스는 스스로 정리되므로, drop을 가진 타입의 모든 바인딩은 스코프를 벗어날 때마다 자동으로 자신의 drop 함수를 실행합니다.
Jam에서는 이렇게 작성합니다:
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;
}
명시적인 정리는 없습니다. defer도 없고, 수동 호출도 없고, f의 lifetime이 끝나는 지점을 표시하는 문법도 없습니다. 컴파일러가 drop 호출을 합성합니다.
다음은 Jam이 useFile에 대해 생성하는 LLVM IR입니다:
define i32 @useFile() #0 {
%1 = alloca %File, align 4
store %File { i32 7 }, ptr %1, align 4
%2 = getelementptr inbounds nuw %File, ptr %1, i32 0, i32 0
%3 = load i32, ptr %2, align 4
call void @__drop_File(ptr %1)
ret i32 %3
}
ret 바로 한 줄 전에 있는 call void @__drop_File(ptr %1)이 이야기의 전부입니다(여기서 %1은 소스 수준 바인딩 f에 대한 alloca입니다). 컴파일러는 f가 drop을 가진 타입의 바인딩임을 추적하고, 종료 시 함수의 drop 스코프를 순회한 다음, 그 호출을 자동으로 생성했습니다. 맹글링된 이름(drop이 아니라 __drop_File) 덕분에 여러 타입이 각자 자기 drop fn을 LLVM 수준에서 충돌 없이 가질 수 있습니다. 포인터로 전달되는 self는 모드 인지 ABI가 제 역할을 하고 있다는 뜻입니다. drop 함수는 self: mut File을 받으며, 이것은 ptr 매개변수로 낮아지므로 호출 지점은 바인딩의 주소를 직접 넘기고, drop은 호출자 저장소를 실제로 변경합니다.
같은 프로그램을 Zig로 쓰면 프로그래머가 정리를 기억해야 합니다:
const File = struct {
fd: i32,
pub fn deinit(self: *File) void {
_ = close(self.fd);
}
};
export fn useFile() i32 {
var f: File = .{ .fd = 7 };
defer f.deinit(); // <-- 반드시 기억해야 하는 부분
return f.fd;
}
Zig 컴파일러는 defer를 대신 합성해주지 않습니다. 그 줄을 잊으면 파일 디스크립터가 누수됩니다. IR에 deinit 호출이 보이는 것도 소스에 defer f.deinit()가 쓰여 있기 때문입니다:
define dso_local i32 @useFile() #0 {
Entry:
%0 = alloca %zig_demo.File, align 4
...
store i32 7, ptr %8, align 4
%10 = load i32, ptr %9, align 4
call fastcc void @zig_demo.File.deinit(ptr %1, ptr nonnull align 4 %0)
ret i32 %10
}
defer 줄을 제거하면 호출도 함께 사라집니다. 최종적인 사용성은 정직합니다. Zig에서는 당신이 쓴 것이 곧 당신이 얻는 것입니다.
C++도 Jam과 같은 일을 합니다. 소멸자는 스코프 종료 시 자동으로 실행됩니다(RAII):
struct File {
int fd;
~File() { close(fd); }
};
extern "C" int useFile() {
File f{7};
return f.fd;
}
define i32 @useFile() #0 {
%1 = alloca %struct.File, align 4
%3 = getelementptr inbounds nuw %struct.File, ptr %1, i32 0, i32 0
store i32 7, ptr %3, align 4
%4 = getelementptr inbounds nuw %struct.File, ptr %1, i32 0, i32 0
%5 = load i32, ptr %4, align 4
%6 = call noundef ptr @_ZN4FileD1Ev(ptr noundef nonnull align 4 dereferenceable(4) %1) #3
ret i32 %5
}
소멸자 _ZN4FileD1Ev는 ret 전에 무조건 실행됩니다. 이 축에서는 지금까지 Jam과 C++가 같은 진영에 있습니다.
차이는 C++가 그 소멸자 주변에 쌓아 올린 나머지 모든 것입니다. rule of 0/3/5는 소멸자를 작성하는 순간 복사와 이동까지 함께 생각하게 만듭니다. 가상 소멸자는 다형적 삭제에 필요하고, 잊기 쉽고, 실제로 잊으면 누수가 생깁니다. 예외를 던지는 생성자는 부분적으로만 객체를 생성하며, 그 객체 자신의 소멸자는 실행되지 않습니다. 소멸자에서 예외를 던지면 std::terminate가 호출됩니다(소멸자는 C++11 이후 암묵적으로 noexcept(true)이므로 어떤 예외라도 그것을 유발합니다. noexcept(false)로 표시하더라도 이미 진행 중인 언와인드 중에 예외를 던지면 여전히 종료됩니다).
게다가 C++ 소멸자는 보장조차 아닙니다. std::exit는 스택에 있는 객체의 소멸자를 실행하지 않습니다(정적 저장 영역 객체만 실행합니다). std::abort는 아무것도 실행하지 않습니다. 비자명한 소멸자가 있는 스코프를 longjmp로 건너뛰면 그것들도 건너뛰어집니다. 처리되지 않은 시그널도 마찬가지입니다. 생성자가 예외를 던지면 그 객체 자신의 소멸자는 실행되지 않습니다.
Rust는 이 대부분을 버렸습니다. 타입당 하나의 Drop::drop(&mut self), 복사 또는 이동 생성자는 없음(이동은 바이트 복사이며 사용자 코드는 실행되지 않음), 관리해야 할 가상 마커도 없음(trait object는 vtable의 drop_in_place를 통해 자동으로 다형적 drop을 처리함), 상속이 없으므로 slicing도 없습니다. Jam은 바로 그 모델을 그대로 채택합니다. 타입에는 하나의 drop 함수가 있습니다. 그것은 모든 스코프 종료에서 실행됩니다. 배워야 할 rule of five도 없고, 잊어버릴 가상 마커도 없고, 예외 안전성 춤도 없습니다.
Jam 예제는 구조체 리터럴로 f를 만들었습니다: const f: File = { fd: 7 };. Zig 예제는 .{ .fd = 7 }를 썼습니다. 두 바인딩 모두 존재하는 순간 실제 값을 가집니다. 이것은 스타일의 문제가 아닙니다. Jam에서는 이것이 유일한 방법이며, undefined라는 키워드는 존재하지 않습니다.
Zig는 다릅니다. 값 없이 바인딩을 선언할 수 있습니다:
var f: File = undefined;
return f.fd; // 런타임에서 UB; 스택 쓰레기 읽기
undefined는 Zig에서 어떤 타입으로도 될 수 있는 값입니다. 정적으로는 f가 File 타입이므로, 컴파일러는 f.fd 읽기를 허용합니다. 런타임에서는 저장소에 스택에 있던 아무 바이트나 들어 있습니다. Debug 모드에서는 0xaa(Zig의 디버그 채우기가 디버거에서 오용을 드러내 줌), Release 모드에서는 임의의 바이트입니다. undefined 읽기를 거친 운영 코드라면 그냥 잘못된 코드입니다. 이것이 Zig가 의도적으로 택한 트레이드오프입니다. 최대의 힘, 그리고 프로그래머의 책임.
Go는 정반대 끝에 있습니다. 모든 var는 기본적으로 0으로 초기화됩니다. var x int는 0을 쓰고, var p *T는 nil을 쓰고, var s SomeStruct는 모든 필드를 0으로 채웁니다. 안전성은 훌륭합니다(쓰레기 읽기가 없음). 하지만 nil과 0 패턴도 결국 메모리에 실제로 쓰이는 바이트이며, 다음 줄에서 모든 필드를 덮어쓸 예정이더라도 선언 시마다 기록됩니다. Zig는 안전성을 사이클과 맞바꾸고, Go는 사이클을 안전성과 맞바꾸며, 둘 다 프로그램이 실제로 지불할 필요가 없었던 무언가를 지불합니다.
Jam은 그 선택 자체를 거부합니다. undefined 값도 없고, 암묵적인 0도 없고, 실제 초기화값 없이 바인딩을 선언하는 방법도 없습니다. 모든 var와 const는 초기화값을 가져야 합니다. 무언가를 점진적으로 만들어야 한다면 구조체 리터럴을 씁니다. 먼저 필드 값을 계산하고, 둘째 구조체를 구성하고, 셋째 그것을 바인딩합니다. 그 사이 어디에도 불명확한 값이나 자리 채움 값은 없고, 덮어쓰려고 0으로 memset되는 일도 없습니다.
그렇지 않으면 불편해질 수 있는 두 패턴, 즉 지연 초기화 (슬롯은 지금 존재하지만 값은 나중에 들어옴)와 출력 매개변수 (피호출자가 호출자 소유 저장소를 채움)에는 다른 메커니즘이 있습니다. Maybe(T)라는 래퍼 타입입니다.
var slot: Maybe(File) = Maybe(File).empty();
slot.write(makeFile(...));
const f: File = slot.unsafeAssumeInit();
Maybe(T)는 세 가지 연산을 갖는 일반적인 제네릭 구조체입니다. empty()는 아직 내용이 의미를 갖지 않는 슬롯을 만들고, write()는 그것을 채우며, unsafeAssumeInit()는 값을 꺼냅니다. 이름 자체가 안전성 이야기를 담고 있습니다. 모든 소비 지점에 unsafe라는 단어가 들어 있으므로, 검토자(사람과 AI 모두)는 그것을 grep하여 런타임 불변식을 주장하는 모든 위치를 찾을 수 있습니다.
린트 패스는 어떤 슬롯이 쓰였는지를 추적하고, 분석기가 초기화되었다고 증명할 수 없는 슬롯에 대한 unsafeAssumeInit 호출을 거부합니다. API를 잘못 쓰면 컴파일러 오류가 나며, 컴파일되지 않습니다. unsafe 접두사는 검토자(사람과 AI 모두)가 런타임 불변식을 주장하는 모든 위치를 찾을 수 있도록 grep 기준점으로 남아 있지만, 뻔한 실수는 분석기를 통과하지 못합니다.
함수 수준에서 자동 drop이 동작하면, 나머지는 기계적으로 따라옵니다. 컴파일러는 drop 스코프 스택을 추적하고, 각 렉시컬 블록 경계에서 새 스코프를 푸시하고, 블록이 끝날 때 팝하면서, 분기 직전에 그 스코프의 바인딩들에 대한 drop을 생성합니다.
if 본문 안에서 선언된 바인딩은 if 이후의 어떤 코드가 실행되기 전에, 그 본문의 끝에서 drop됩니다. else, match arm, while 및 for 루프 본문도 마찬가지입니다.return은 실제 ret 명령 전에, 가장 안쪽부터 시작해 모든 활성 스코프를 drop합니다.break / continue. 둘 다 분기하기 전에, 둘러싼 루프 본문 안에서 열린 모든 스코프(그 시점에 열려 있던 중첩 if 스코프 포함)를 drop합니다. break는 루프 종료 지점으로, continue는 다음 반복으로 갑니다.이 규칙들은 잘 구현된 RAII에서 기대하는 바로 그 규칙입니다. 그리고 컴파일러가 그것들을 강제합니다. IR에는 그 규칙이 요구하는 정확한 drop 호출만 들어가고, 그 외의 것은 없습니다.
중첩 break 예제를 Jam으로 표현하면 다음과 같습니다:
const std = import("std");
const Bumper = struct {
label: str,
fn drop(self: mut Bumper) {
std.fmt.println(self.label);
}
};
fn nestedBreak() {
for i in 0:10 {
const outer: Bumper = { label: "outer" };
if (i == 1) {
const inner: Bumper = { label: "inner" };
// break는 먼저 inner를 drop하고, 그다음 outer를 drop한 뒤 종료한다.
break;
}
// break가 아닌 반복에서는 outer가 본문 끝에서 drop된다.
}
}
이 코드를 실행하면 다음 순서로 세 줄이 출력됩니다: outer(0번째 반복의 outer가 루프 본문 끝에서 drop됨), 그다음 inner, 그리고 outer(1번째 반복의 break 경로가 가장 안쪽부터 drop함).
여기까지 읽으면 자연스러운 질문이 하나 생깁니다. 모든 바인딩이 스코프를 벗어날 때 drop된다면, 그것을 함수에 전달하면 무슨 일이 일어날까요? 호출 지점에서 drop되나요? 아니면 피호출자에서?
답은 이렇습니다. 매개변수 모드에 따라 다르며, 네 가지 모드 중 실제로 drop되는 것은 단 하나뿐입니다.
fn distance(a: Point, b: Point) f64 {
const dx: f64 = a.x - b.x;
const dy: f64 = a.y - b.y;
return sqrt(dx * dx + dy * dy);
}
fn caller() {
const p: Point = origin();
const q: Point = elsewhere();
const d: f64 = distance(p, q);
// 여기서도 p와 q는 여전히 init 상태다. 호출 지점에서는 drop이 실행되지 않았다.
}
기본 모드는 읽기 전용 대여입니다. 피호출자는 매개변수를 통해 값을 읽을 수 있고, 호출자의 바인딩은 init 상태로 남으며, 호출이 반환될 때 drop도 일어나지 않습니다. mut는 같은 아이디어에 쓰기 권한을 더한 것입니다. 배타적 읽기-쓰기 대여입니다. 호출자의 바인딩은 호출 후에도 init 상태로 살아남습니다.
소비하는 것은 move뿐입니다:
fn consume(buf: move []u8) {
// 여기서는 buf를 소유한다. consume 끝에서 drop된다.
}
fn caller() {
var data: []u8 = makeBuffer();
consume(data);
// 이제 data는 Uninit 상태다. 읽으려 하면 컴파일 오류다.
// drop은 여기서가 아니라 consume 내부에서 실행되었다.
}
시그니처의 매개변수 모드가 각 호출 지점에서 무슨 일이 일어나는지를 결정합니다. 호출 지점 마커는 없습니다. 호출 형태는 모든 모드에서 동일합니다:
| Mode | Callsite | Caller’s binding after the call |
|---|---|---|
| (기본) 읽기 전용 대여 | f(x) | 변경 없음, 여전히 init |
mut 배타적 대여 | f(x) | 변경 없음, 여전히 init |
move 소비 | f(x) | Uninit가 됨 |
이 어떤 것도 일급 참조를 노출하지 않습니다. Jam에는 참조 타입이 없습니다. 대여가 붙을 수 있는 값도 없고, 변수에 저장하거나 함수에서 반환하거나 구조체 필드에 둘 수도 없습니다. 매개변수 대여는 호출 프레임의 일시적 현상일 뿐입니다. 피호출자는 매개변수를 통해 읽기 또는 읽기-쓰기를 할 수 있고, 그 접근은 호출이 반환되면 만료되며, 그게 전부입니다. Jam은 그 어떤 것을 안전하게 만들기 위해 lifetime 주석이 필요하지 않습니다. 붙일 lifetime 자체가 없기 때문입니다.
같은 규칙은 컬렉션 API도 끝까지 값 형태로 강제합니다. 호출자가 붙잡을 수 있는 place expression도 없고, 요소의 주소를 가져갈 방법도 없습니다. 인덱스 대입 v[i] = x는 setter 호출(v.setAt(i, x))로 디슈거링되며, 새 값은 값으로 흘러 들어가고, 컬렉션은 자기의 사적 저장소를 통해 씁니다. 인덱스 읽기 let y = v[i]는 getter(v.at(i))를 통해 이를 반영하며, 요소를 값으로 반환합니다. 아래로 내려가는 모든 층에서 같은 제약이 유지됩니다.
Rust의 borrow checker를 가능하게 한 같은 통찰(shared XOR mutable)은 일급 참조 값이 아니라 호출 지점의 경로 에 적용됩니다:
fn moveX(p: mut Point, dx: f64) { p.x = p.x + dx; }
fn swap(a: mut f64, b: mut f64) { /* ... */ }
fn ok() {
var p: Point = origin();
swap(p.x, p.y); // OK: 서로 겹치지 않는 하위 경로
}
fn err() {
var p: Point = origin();
moveX(p, p.x); // ERROR: `p`와 `p.x`가 겹친다
}
배타성 검사는 각 호출 지점이 인수들로부터 만들어내는 borrow 집합 위에서 실행됩니다. lifetime이 붙을 수 있는 일급 참조가 없으므로, 추론할 lifetime도 없습니다.
지금까지의 내용은 모두 Jam 프로그램 내부에 관한 것입니다. 실제 시스템 작업의 다른 절반은 경계입니다. 다른 사람이 링크하는 라이브러리를 배포하고, 런타임에 플러그인을 로드하고, Python이나 Go나 C 코드베이스가 호출할 API를 노출하는 일입니다. 바로 여기서 Rust의 안전성 이야기는 비싸집니다.
Rust의 네이티브 ABI는 의도적으로 불안정합니다. struct의 레이아웃, enum 판별자의 순서, Vec나 &str이 전달되는 방식, 이 모든 것이 컴파일러 버전마다 바뀔 수 있고, 다른 언어는 물론 독립적으로 빌드된 다른 Rust 바이너리조차 그것들에 의존할 수 없습니다. 그래서 배포 경계를 넘는 순간 안전한 Rust를 떠나, 모든 것을 C의 용어로 수동 재인코딩해야 합니다:
pub struct Counter { value: i64 }
#[no_mangle]
pub extern "C" fn counter_new() -> *mut Counter {
Box::into_raw(Box::new(Counter { value: 0 }))
}
#[no_mangle]
pub unsafe extern "C" fn counter_add(c: *mut Counter, n: i64) -> i64 {
let c = &mut *c; // unsafe: 컴파일러가 보증할 수 없는 raw pointer
c.value += n;
c.value
}
#[no_mangle]
pub unsafe extern "C" fn counter_free(c: *mut Counter) {
drop(Box::from_raw(c)); // 수동 소유권 반환, 이것도 unsafe
}
안전성 모델이 당신에게 무엇을 줬다가 다시 무엇을 돌려달라고 하는지 보세요. borrow checker, lifetime, Drop, 이 모든 것은 extern "C" 줄에서 멈춥니다. 포인터는 raw이고, 역참조는 unsafe이며, 소유권은 Box::into_raw / Box::from_raw를 통해 수동으로 경계를 넘고, #[repr(C)](여기서는 생략됐지만, 구조체가 값으로 경계를 넘는 순간 필수임)는 모든 타입에 대해 따로 기억해야 하는 별도 주석입니다. cbindgen이나 abi_stable 크레이트 같은 도구들이 존재하는 이유가 정확히 이 경계가 너무 많은 수작업이기 때문입니다. 그 마찰은 현실적이며, 내보내는 모든 타입과 함수마다 커집니다.
Jam에는 이 이음새가 없습니다. Rust의 ABI를 불안정하게 만드는 것이 Jam에는 존재하지 않기 때문입니다. 일급 참조도 없고, lifetime도 없고, niche-packed layout을 지워야 할 일도 없습니다. Jam 값은 끝까지 값 형태이며, 앞서 컬렉션 API를 값 기반으로 만들었던 바로 그 성질 덕분에 Jam 구조체는 이미 C 호환 레이아웃을 가지고 있습니다. 번역할 것이 없습니다.
같은 Counter를 Jam에서 내보내면 다음과 같습니다:
const Counter = struct {
value: i64,
fn add(self: mut Counter, n: i64) i64 {
self.value = self.value + n;
return self.value;
}
};
export fn counterAdd(c: mut Counter, n: i64) i64 {
return c.add(n);
}
export는 counterAdd를 평범하고 맹글링되지 않은 이름으로 C 호출 규약 위에 올려놓고, C에서는 다음처럼 호출할 수 있습니다:
int64_t counterAdd(Counter *c, int64_t n);
mut Counter 매개변수는 정확히 그 Counter *로 낮아집니다. 모드 인지 ABI는 mut를 호출자 소유 저장소에 대한 포인터로 전달하는데, 그것이 바로 C 호출자가 이미 들고 있는 것입니다. repr 주석도 없고, raw pointer 캐스트도 없으며, 결정적으로 unsafe도 없습니다. counterAdd의 본문은 평범한 Jam이므로, drop도 계속 동작하고, 초기화 분석도 계속 실행되며, 호출 지점 배타성 규칙도 계속 유지됩니다. 안전성은 경계에서 멈추지 않습니다. 그 경계는 단지 C에서 도달 가능한 또 하나의 Jam 함수일 뿐입니다.
C로 들어가는 호출은 그 거울상입니다. extern이 C 시그니처를 선언하고, 당신은 그것을 다른 함수처럼 호출합니다:
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);
}
정직하게 말해야 할 한 가지 단서는 있습니다. extern 줄에서는 당신이 C와 이야기하고 있고, 그때는 C의 규칙이 이깁니다. extern 함수는 C ABI를 문자 그대로 따르므로, 앞 절의 매개변수 모드 기계는 그 경계 너머에는 적용되지 않고, raw pointer(*const[] u8, *mut[] u8)가 C에 버퍼를 건네는 방식이며, C가 그 포인터로 무엇을 하느냐는 C의 문제입니다. Jam은 FFI 호출의 저편까지 검증한다고 가장하지 않습니다. Jam이 주는 것은 당신 쪽 이 기본적으로 안전하게 유지된다는 점, 그리고 C ABI로 Jam 라이브러리를 노출하는 데 추가 비용이 전혀 없다는 점입니다. shim 계층도 없고, 안전한 API를 다시 비추는 두 번째 unsafe API도 없고, 모든 타입에 붙는 주석 세금도 없습니다. 함수를 한 번, 안전하게 작성하고 export하면 됩니다.
이 점이 가장 중요해지는 곳은 정확히 Rust가 가장 아픈 곳, 배포입니다. Jam 라이브러리는 어떤 언어에서든 링크할 수 있는 C ABI .so / .dylib / .a로 배포될 수 있고, 그것을 만들기 위해 작성한 코드는 내부 용도로 썼을 동일한 안전한 코드입니다.
Jam에서 match는 이렇게 생겼습니다:
match (opcode) {
0x00 { /* NOP */ }
0x01 | 0x11 | 0x21 { ld16Imm(cpu, opcode); }
0x40..=0x7F { ldR8R8(cpu, opcode); }
0x80..=0xBF { aluR8(cpu, opcode); }
0xCB { decodeCb(cpu); }
_ { unimplemented(opcode); }
}
이 기능을 떠올리게 한 사용 사례는 제가 Jam으로 작성 중인 Game Boy 에뮬레이터의 opcode 디스패처였습니다. 기본 opcode 256개에 prefix opcode 256개가 더 있습니다.
짚고 넘어갈 만한 문법 결정 몇 가지가 있습니다:
=> 없음. 각 arm은 Pattern Block 형태이고, 중괄호가 본문 구분자 역할을 합니다. Rust가 아니라 V의 표기입니다.if (...), while (...) 형태와 맞춥니다._를 catch-all로 사용. 맨 아래의 와일드카드 arm은 위에서 처리되지 않은 모든 것을 매칭합니다.같은 형태는 payload가 있는 enum에도 확장됩니다:
const Op = enum {
Nop,
LdR8R8(u8, u8),
Jp(u16),
};
match (op) {
Op.Nop { /* nothing */ }
Op.LdR8R8(dst, src) { regs[dst] = regs[src]; }
Op.Jp(addr) { cpu.pc = addr; }
}
variant 패턴은 tag로 매칭하고, payload 필드를 그 arm 내부의 새로운 로컬에 바인딩합니다. catch-all은 필요하지 않습니다. 컴파일러가 variant 집합에 대한 완전성을 검사하고, 어떤 variant라도 빠진 match는 거부합니다. 나중에 새 variant를 추가하면, 그것을 다루지 않는 모든 match 지점은 컴파일이 멈추며, 타입 검사기는 정확한 줄을 가리킵니다. 이것이 패턴 매칭이 존재하는 안전성 속성입니다.
match는 표현식으로도 동작합니다:
const name: []u8 = match (color) {
Color.Red { "red" }
Color.Green { "green" }
Color.Blue { "blue" }
};
각 arm의 블록은 값을 만들어내고(맨 마지막 표현식의 값), 모든 arm은 같은 타입을 만들어내야 하며, match는 완전해야 합니다.
내부적으로 모든 match는 Maranget 2008.2에 기반한 단일 decision-tree 파이프라인을 통해 컴파일됩니다. 정수 리터럴 연쇄의 경우 LLVM의 simplifycfg 패스가, 이득이 있을 때 그 연쇄를 switch와 점프 테이블로 접습니다. 위 opcode 디스패처는 프런트엔드가 직접 switch를 내보낼 필요 없이 점프 테이블로 귀결됩니다.
속도에는 두 종류가 있고, 아래 절은 그중 하나에 대한 이야기입니다. 다른 하나는 컴파일러를 얼마나 기다리느냐입니다. 사람들이 실제로 일하는 방식, 즉 편집하고, 빌드하고, 실행하고, 다시 편집하는 일을 한 시간에 수십 번 반복하는 관점에서, 빌드 지연은 가장 크게 체감되는 숫자이며, Rust는 존재 내내 그 숫자와 싸워 왔습니다.
Rust가 왜 느리게 컴파일되는지를 정확히 짚어보는 것은 가치가 있습니다. 그것은 사고도 아니고, 최적화가 빠져서도 아닙니다. 파이프라인의 문제입니다. 기계어로 가는 길에 함수는 전체 중간 표현 시퀀스를 통과합니다:
tokens → AST → HIR → (type inference + trait solving)
→ THIR → MIR → (borrow check) → monomorphization → LLVM IR → machine code
각 화살표는 실제 lowering 패스이며, 새 표현을 할당하고 전체 프로그램을 순회합니다. HIR은 타입 추론과 trait solving이 돌아가는 디슈거링된 형태이고(이름 해석은 그보다 앞서 AST에서 이미 끝남), THIR은 완전히 타입이 붙고 더 디슈거링된 형태로, MIR를 만들기 위해 특별히 구성됩니다. MIR은 borrow checking, 초기화되지 않은 값 검사, 중간 수준 최적화가 일어나는 곳입니다. 그 다음 모든 제네릭이 monomorphization되고(구체 타입마다 새 복사본이 찍혀 나옴), LLVM에 넘어가며, LLVM은 다시 그 전체를 최적화합니다. trait solving은 탐색 문제입니다. borrow checking은 함수 전체에 대한 region 분석입니다. monomorphization은 LLVM, 즉 가장 느린 단계가 그것을 보기 전에 코드 양을 증폭시킵니다. Rust 관점에서는 이 중 어느 것도 낭비가 아닙니다. 각 패스는 하나의 보장을 사옵니다. 하지만 매 빌드마다 그 전부의 비용을 지불합니다.
Jam의 파이프라인은 구조적으로 더 짧습니다:
tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code
세 개(HIR, THIR, MIR)가 아니라 하나의 타입된 중간 표현(JIR)만 있습니다. JIR은 AstGen이 그것을 만드는 순간부터 타입이 붙습니다. Jam에는 먼저 비타입 lowering을 강제하는 comptime-as-values가 없기 때문에, “해결하고, 다시 타입 붙이기” 왕복이 없습니다. Rust가 HIR과 MIR에 나누어 두는 분석들, 즉 drop 배치, 사용 전 초기화 검사, 앞 절의 호출 지점 배타성 규칙은 모두 JIR 위의 지역 데이터플로 패스로 돌아가며, 프로그램 전체 lifetime 및 region 추론으로 가지 않습니다. 그리고 Jam은 모든 바인딩에 타입을 주석으로 달기 때문에, 프런트엔드는 rustc의 중간부를 지배하는 전역 타입 추론과 열린 trait 탐색을 훨씬 덜 수행합니다.
구현도 같은 방향을 따릅니다. AST와 JIR은 평평합니다. 작은 고정 크기 노드들이 하나의 연속 배열에 들어 있고, 포인터 대신 인덱스를 쓰며, 큰 payload는 별도 풀로 흘려보냅니다. 따라서 컴파일러는 힙에 할당된 노드 트리를 메모리 여기저기 따라다니는 대신, 캐시 친화적인 배열을 switch-on-tag 디스패치로 순회합니다. Zig와 Carbon이 같은 이유로 채택한 데이터 지향 설계입니다. 컴파일러 자체가 성능 민감한 프로그램이기 때문이며, 데이터를 잘 배치하는 것이 전투의 대부분입니다.
아무도 공짜로 피하지 못하는 한 단계는 백엔드입니다. LLVM은 훌륭한 코드를 만들고, 그만큼 시간을 들입니다. 릴리스 빌드에서는 그 최적화 작업이 시계를 지배합니다. 계획은 업계의 나머지가 수렴하고 있는 분할과 같습니다. 디버그 빌드에는 Cranelift, 목표가 가능한 한 빨리 실행 가능한 바이너리에 도달하는 것뿐인 경우입니다. 릴리스 빌드에는 LLVM, 마지막 최적화 한 조각까지 원할 때입니다. (Cranelift는 로드맵에 있고, 아직 끝나지 않았습니다.)
지금 상황을 솔직히 말하자면, 오늘의 컴파일러는 언어를 부트스트랩하는 C++ 구현이고, 아직 인용할 만한 빌드 시간 벤치마크는 없습니다. 여기서의 주장은 완성된 숫자에 대한 것이 아니라 설계에 대한 것입니다. 하지만 설계야말로 나중에 덧붙일 수 없는 부분입니다. 하나의 IR, 지역적 안전성 패스, trait solver 없음이라는 파이프라인은, 세 개의 IR, monomorphization, region 추론 borrow checker가 있는 파이프라인보다 구조적으로 바닥 비용이 낮고, 빠른 빌드는 결국 그 바닥 위에 세워집니다.
목표는 Jam이 Rust와 Zig의 성능에 맞먹는 것입니다. 가비지 컬렉터도 없고, 관리형 메모리 런타임도 없고, 할당마다 따라다녀야 하는 헤더도 없으며, codegen은 단순한 LLVM IR이므로, 구조적으로 성능 상한선은 올바른 근처에 있습니다.
하지만 Jam은 아직 거기 도달하지 못했습니다. Rust와 Zig는 둘 다 마지막 10~30퍼센트를 움직이는 요소들에 수년을 썼습니다. 표준 라이브러리의 타깃 특화 intrinsic, 신중한 auto-vectorization 힌트, allocator를 인지하는 컨테이너 타입, 핵심 자료구조의 손으로 다듬은 hot path, LLVM 패스 튜닝, 그리고 실제 사용자 기반이 언어를 밀어붙이기 시작해야만 드러나는 작은 이득들의 긴 꼬리 말입니다. Jam도 그 격차를 완전히 메우려면 같은 작업이 필요하며, 이제 막 시작했을 뿐입니다. 지금까지 측정한 작업 부하에서는 Jam이 “다른 급”이 아니라 “작은 상수 배 이내”라는 정도로 충분히 가깝습니다. 출발점으로는 맞지만, 이미 성능이 같다고 과장하고 싶지는 않습니다.
여기 아주 단순한 데모가 있습니다. 터미널용 테트리스 게임인데, 전부 Jam으로 만들었습니다.
Jam으로 작성된 작은 테트리스 구현이 터미널에서 실행되고 있습니다. Tangled의 소스.
이 글은 안내서라기보다 첫 대화에 가깝습니다. 다루지 못한 것이 많습니다. 매개변수 모드 시스템을 실제 깊이로 설명하는 일, 배타성 규칙, 제네릭, Jam 자체의 comptime, 표준 라이브러리, allocator 시스템, panic 모델, GPU codegen 파이프라인을 위한 MLIR 탐색(혹시라도 Chris Lattner가 이 글을 읽고 있다면, 저는 정말 큰 팬입니다), FFI를 위한 Rust ABI 작업, Cranelift, self-hosted 컴파일러로 가는 길, 그리고 그 밖의 여러 가지입니다. 언어가 자리를 잡아가면 각각 별도의 글을 갖게 될 것입니다.
Jam은 아직 공개되지 않았습니다. 컴파일러는 존재하고 동작하지만, 일상적으로 쓰기 좋게 만드는 요소들을 작업하는 동안 더 넓은 공개는 미루고 있습니다. 안정된 표면, 패키지 매니저, LSP, 포매터, 그리고 없을 때만 존재감이 드러나는 나머지 도구들 말입니다. 그것 없이 언어를 내놓는 것은 날 선 모서리를 출하하는 일이고, 저는 차라리 시간을 더 들이고 싶습니다.
오픈 소스로 공개하는 시점에 대한 계획은 이렇습니다. Jam으로 서로 다른 프로젝트 108개를 만드는 데 사용된 뒤입니다. Suikoden 2는 제가 가장 좋아하는 게임이고, 108이라는 숫자는 거기서 왔습니다(스토리 동안 모으는 108명의 운명의 별). 자의적인 이정표이기는 하지만, 저는 마음에 듭니다. 지금은 소수의 사용자 그룹에게만 나가 있고, 도구가 따라오면서 점점 그 범위를 넓혀갈 예정입니다. Jam은 오픈 소스가 될 것입니다. 거기에는 아무 의문도 없습니다. 다만 충분한 실제 사용을 거쳐 스스로 설 수 있기 전에 열고 싶지 않을 뿐입니다.
일찍 시험해보고 싶다면 jamlang.org에 베타 목록이 있습니다. 다음 조각들이 자리 잡는 대로 더 많은 업데이트를 올리겠습니다.