- 테스트 snapshot 마스킹 (2 이후)
- 절대 경로, id, 해시를 가려서 기계와 무관한 snapshot으로 만듭니다.
- id는 test/id.json으로 고정했습니다 ✅. snapshot의 소스 경로 주석에 절대 경로가 남아 있습니다.
- unsafe 추적 (2 이후, 패키지 경계 판정이 필요)
- 패키지 경계 판정, 호출 그래프를 따른 전파, 참조 자리 검사
- ssc:trust, ssc:unsafe, strictStd, allowUnhandledUnsafe
- 링크 단계 방어 (별도 컴파일을 구현할 때)
- -Wl,--as-needed, 입력 사이의 모듈 id 중복 검사
- C 연동 다시 설계 (C 백엔드와 함께 지움)
ssc:include,ssc:link로 C 헤더와 소스를 함께 빌드하던 기능. C++ 빌드에서는 아직 쓰지 않음- 헤더의
__FILE__등으로 사적 경로가 결과물에 남는지 검사하던 기능(5와 함께)
~/.ssc 도 있고 전역 설정같은거 적용가능하게. path.json, setting.json, config.json 등
[refactor]
- 소스 파일 가져오기 ✅
- 노드 분류(로컬) ✅ (prefetch 대상 목록, 지금은 비워 둔 상태)
- TS 정보 지연 조회 ✅: 노드 메서드(getType, getSymbol, getValueDeclaration, getReturnType)가 필요할 때 checker에 묻습니다. 같은 결과는 클라이언트 캐시가 재사용합니다.
- TS 연결 유지로 결정: 증분 빌드와 병렬 처리를 위해 API 연결을 닫지 않습니다.
- 자체 노드 트리 만들기 ✅: TS AST를 순회하며 ssc의 노드(문장, 선언, 식)로 바꿉니다. 이 단계에서 지원하지 않는 문법은 위치와 함께 에러로 걸러냅니다.
- 이름 연결(binding) ✅: TS 심볼 id를 키로 씁니다.
- 식의 타입 추론 ✅: 결정 변경. ssc가 직접 계산하지 않고 패치한 TS checker(ssc.go)가 연산 규칙과 타입 불일치를 판정합니다. lower는 TS 결과를 믿지 않고 같은 규칙(common-type.ts)으로 다시 검증합니다.
- 모듈 그래프: 의존 순서와 초기화 순서는 loadModules가 정합니다 ✅. 순환 import는 허용합니다 ✅. 모듈 사이 TDZ 검사가 남았습니다.
┌─────────────────┬──────────────────────────────────────────────────────────────────────────────────────────┐ │ 항목 │ 규칙 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 반환 타입 │ 여러 return과 조건식은 유니온 유지, C에서는 태그 구조체(타입 id + 8바이트 값)로 표현 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 숫자 연산 │ 같은 숫자 타입끼리만 (i32 + i32 → i32). 섞이면 컴파일 에러 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 오버로드 │ ssc가 시그니처를 고르고, 그 시그니처에 적힌 반환 타입을 씀 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 제네릭과 콜백 │ 선언의 모양만 TS에서 가져오고, 타입 인자는 ssc 타입으로 정함 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ number │ JS 숫자. 출력, 비트 연산, 배열 인덱스가 JS와 같음. C++에서는 double 하나를 담는 구조체 │ │ │ ssc__core__type__number(type_id 34)라 f64와 다른 타입 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ f64 │ 시스템 실수 타입(브랜드). 출력은 정확한 값(정수는 전체 자릿수), 비트 연산은 에러. C 함수 │ │ │ 선언에는 f64를 씀 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 리터럴 │ 항상 number. 다른 숫자 타입 자리에는 생성자로 (i32(0), f64(0.5)) │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 암묵적 변환 │ 없음. 서로 다른 숫자 타입 사이의 대입, 연산, 비교는 컴파일 에러 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 타입 표현 │ number & { readonly [ssc]: '<이름>' } 브랜드. ssc는 prelude 모듈 지역 심볼이라 사용자 │ │ │ 코드에서 만들 수 없음. 별칭이 아니라 브랜드 타입 리터럴의 심볼로 식별 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ char │ Unicode scalar value(0 ~ 0xD7FF, 0xE000 ~ 0x10FFFF)인 부호 없는 정수 브랜드. u32와 │ │ │ 같게 처리(C++에서 UINT32_TYPE 별칭)하고 출력은 숫자 │ ├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ │ 생성자 │ number | string을 받음. 리터럴은 컴파일 시점 범위 검사. 손실 없는 변환은 그대로, 손실 │ │ │ 가능한 변환은 실행 중 정확히 표현되는지 검사 후 RangeError. char()는 실행 중 │ │ │ ssc__core__fn__char가 검사하고 리터럴은 컴파일 시점에도 검사 │ └─────────────────┴──────────────────────────────────────────────────────────────────────────────────────────┘
[타입 규칙 전략] 원칙
- JS/TS 의미 호환은 100% 목표가 아님. 성능과 단순함, 컴파일 시간 정확성을 우선하고, 기존 JS 생태계 호환은 나중에 경계가 분명한 JS 엔진 연동으로 챙김(셀프호스팅 뒤. C 라이브러리 연동이 먼저)
- 다만 흔한 JS 코드는 그대로 동작하게 호환성을 높여 감(로드맵): 리터럴과 number는 JS와 같게 동작, number 배열 인덱스 지원. 이후 string도 JS처럼 처리하고 바이트 단위로 다루는 메서드를 따로 추가
- TS는 문법, 타입 추론, 좁히기, LSP, 린터 등 생태계를 위해 유지
- fork 패치: 생성자로 대응할 수 있는 편의 기능은 패치하지 않음. 남은 것은 같은 숫자 타입끼리 연산의 결과 타입 (i32 + i32 → i32, 섞이면 오류)뿐. 순정 TS에서는 산술 결과가 number가 됨(확인). 리터럴과 number는 number라는 하나의 숫자 종류로 취급. f32, f64 비트 연산과 부호 없는 타입 단항 -는 오류. 숫자 취급 훅 2개 (checkArithmeticOperandType, isTypeAssignableToKindEx)는 삭제(852줄)
- 우리 검사 층: TS보다 더 막는 규칙은 패치 대신 컴파일러에서 TS 타입 정보로 검사(값이 다른 타입 자리로 옮겨 가는 lowerValue 지점에 모음). 필요하면 LSP 진단으로도 노출(TS 언어 서비스 플러그인 또는 fork LSP의 작은 훅)
- scriptc와 대비: scriptc는 JS 호환이 목표라 막지 못하고 복사, 경고, 동적 저장소로 처리함(divergences). 우리는 막음
결정
- 숫자 암시적 넓히기와 관계 기반 대입 제거(fork에서 패치 삭제, 1554 → 867줄). 서로 다른 숫자 타입 사이의 대입, 연산, 비교는 오류이고 생성자로 명시적 변환(i64(x)). 여유가 생기면 편의 기능 재검토
- 리터럴은 문맥 타입을 따르지 않고 항상 number. 다른 숫자 타입 자리에는 생성자로(const x: i32 = i32(0), arr.push(i32(1)), f64(0.5)). char 리터럴도 char('A')
- number와 f64 분리: number는 JS 호환 100%(출력은 ECMAScript Number::toString, 비트 연산은 ToInt32/ToUint32, 배열 인덱스), f64는 우리가 정하는 정확한 표현(출력은 std::to_chars 기본, 2^63 → 9223372036854775808, 비트 연산 오류). f64는 다시 브랜드. 둘은 서로 손실 없는 변환(number(x), f64(n))이지만 다른 타입이라 섞으면 오류. C++에서 number는 double 하나를 담는 구조체(type_id 34, 연산자는 멤버, 비교는 == 와 <=> 기본 구현). 같은 C++ 타입이면 type_id와 오버로드로 구분할 수 없어서(배열 원소 출력, union 태그) 별도 타입으로 둠. -O1부터 double과 같은 기계어(직접 비교함). C 함수 선언에는 f64를 씀
- 생성자는 number | string을 받음. 리터럴 인자는 컴파일러가 범위 검사(i32(300)이 i8이면 오류), 다른 숫자 타입은 손실 없는 변환만 허용. 문자열 리터럴은 bignumber.js로 파싱(u64('18446744073709551615'), '0xff', '1e3')
- 정수 표기 리터럴이 f64로 정확하지 않거나(2^53 초과) 리터럴이 무한대가 되면 컴파일 오류, 문자열 생성자를 안내
- ===/!== 에서 숫자 타입이 다르면 오류(리터럴, number, union 멤버 포함). 브랜드끼리는 TS가, number와 섞인 경우는 컴파일러가 막음. "타입이 다르면 항상 false"는 count === 0 같은 비교가 조용히 거짓이 되어 채택하지 않음
- 배열 인덱스 타입은 number로 열어 둠(나머지 매개변수 원소 타입을 TS가 number 리터럴 인덱싱으로 구해서 u64만 있으면 검사가 꺼짐, relater.go tryGetTypeAtPosition). 컴파일러는 u64와 number만 허용. 0 이상 정수 리터럴은 u64로, number는 실행 중 정수이고 0 이상인지 확인(아니면 -1로 범위 밖). 읽기는 undefined(JS와 같음), 쓰기는 RangeError (JS는 속성을 조용히 만듦)
- number 비트 연산과 시프트는 JS와 같게 허용(ToInt32/ToUint32, 시프트 수는 & 31). prelude의 ssc__core__fn__number_to_i32/number_to_u32로 변환. 실제로 쓸 때만 비용이 듦. f32, f64는 오류
- char는 브랜드로 남기되 부호 없는 정수 그룹(SscUnsignedIntegerType)에 넣고 u32와 같게 처리. C++에서는 ssc__core__type__char = UINT32_TYPE 별칭이라 char 전용 분기가 없음. 출력과 문자열 이어 붙이기에서 숫자로 나옴 ('a' + char('A') → 'a65'). u32 ↔ char는 다른 숫자 타입처럼 생성자로 변환
- char 생성자: 생성 코드는 항상 실행 중 ssc__core__fn__char 호출이고 검사 규칙은 prelude 한 곳(is_code_point).
문자열은 UTF-8에서 코드 포인트 하나를 꺼낸 뒤 숫자와 같은 범위 검사(정수, 0 ~ 0x10FFFF, 서로게이트 제외),
실패하면 RangeError: Invalid code point. 다른 숫자 리터럴처럼 리터럴 타입 인자는 컴파일러도
parseSafeCodePoint로 검사해 컴파일 오류(
0xd800 is an invalid code point) - 생성자: 손실 없는 변환은 그대로 cast, 손실 가능한 변환은 실행 중 값이 정확히 표현되는지 검사 후 RangeError
(
3.7 can not be converted to i32). 소수가 있는 실수 → 정수도 오류(잘라 쓰려면 사용자가 직접 자름). Swift Int32(x)와 같은 방식 - 컴파일러의 공통 타입 계산(commonType)은 지움. 같은 타입끼리만 연산하므로 양쪽 피연산자 타입이 같은지만 확인
- 공유 컨테이너(배열 원소, 객체 속성)의 칸 표현이 바뀌는 대입은 컴파일 오류(i32[] → (i32 | string)[], { x: i32 } → { x?: i32 }). 복사하면 === 와 변경 공유가 조용히 달라져서 막음. 필요하면 사용자가 명시적으로 복사 (ints.map((x) => x), { x: p.x }). 오류 메시지에 해결 방법을 보여 줌. 새로 만든 리터럴은 예외(처음부터 대상 타입)
- 순정 TS로는 배열을 불변 변성으로 못 만듦: tsgo relater.go getVariances가 Array, ReadonlyArray, 튜플을 공변으로
고정해서
interface Array<in out T>가 무시됨(확인). 배열 엄격 변성 옵션도 없음(microsoft/TypeScript#58607)
남은 고민
- 복합 대입(a += b)은 지금 a = a + b로 내림(대상이 변수뿐이라 같음). 속성, 원소 대상을 지원할 때 C++ op= 로 내려 대상을 한 번만 평가(number 구조체에는 op= 연산자가 이미 있음)
- console.log(-0)은 node에서 -0이지만 지금은 0(to_string과 inspect가 같은 함수). JS 호환 100%면 inspect에서 구분
- char(string)은 첫 바이트로 길이만 보고 연속 바이트와 과잉 표현은 검사하지 않음(리터럴과 이어 붙이기로만 문자열을 만드는 지금은 잘못된 UTF-8이 없음). 바이트 단위 자르기, 외부 입력, FFI처럼 문자열을 만드는 경로가 생기면 그 경로에서 검증하거나 여기서 완전 검증(Unicode 표 3-7)
- prelude의 requires type_id 범위 조건(숫자 17
24, 3234)이 풀어 쓰여 있음. C++20 concept (ssc__core__concept__number 등)로 이름을 붙여 정리(보류) - union끼리 넓히기((i32 | string)[] → (i32 | string | undefined)[]): 메모리는 안전(union 칸 공유, 잘못 읽으면 TypeError)하지만 넓힌 쪽으로 쓰면 좁은 쪽 타입이 깨짐(TS 자체의 배열 공변 구멍). 쓰기 가능 컨테이너는 불변 변성, 읽기 전용만 공변으로 할지 검토(Kotlin MutableList/List, Rust, C#)
- 구조적 타이핑(속성이 더 많은 객체 대입): 후보는 shape 검사 + 이름으로 찾기(측정상 같은 모양이면 0.46 대 0.48ns), 관계없는 타입 사이 대입 금지 + extends만 허용(부모 필드를 앞에), 대입 시 복사(=== 와 변경 공유가 달라짐)
- 장기: 셀프호스팅 뒤 TS 파서(oxc, tsgo)는 그대로 두고 타입 검사기와 LSP를 직접 만드는 방안. 우리 문법의 부분집합만 다루면 됨. 그때까지의 결정이 타입 시스템 명세 초안이 됨
[다음 작업 순서]
- 메모리 할당 ✅ (prelude/arena.h, arena.c) → 객체별 RC로 전환하며 대체됨
- 문자열
- 아레나 스코프 관리 → 객체별 RC로 전환하며 필요 없어짐
[문자열] (메모리 모델을 객체별 RC로 바꿈. 아래의 아레나 전제 근거는 다시 봐야 함. 특히 StringView를 따로 둔 이유인 "원본 아레나 전체를 붙잡는 누수"는 RC에서는 원본 문자열 하나만 붙잡는 문제로 바뀜)
- 인코딩은 UTF-8.
.length는 바이트 길이 str[i]는 선언하지 않아 막힘. noLib라 prelude에 선언한 멤버만 존재for (const ch of str)는 코드 포인트 하나짜리 string을 돌려주는 iteratorstr.chars()는 char(코드 포인트) iterator,str.bytes()는 u8 iterator- 여러 바이트를 묶어 읽는 view API(u16/u32/u64, 엔디언 지정)는 나중에 byte view 위에 둠
- string은 유효성을 보장하지 않음. chars()와 기본 순회는 순회하면서 디코딩하고, 잘못된 바이트를 만나면
TypeError: The encoded data was not valid for encoding utf-8 str.isWellFormed(),str.toWellFormed()제공str + c(char)는 다른 숫자처럼 숫자로 이어 붙음(char는 u32와 같게 처리)- 메모리 표현:
{length, bytes..., NUL}을 가리키는 8바이트 포인터- 다른 참조와 같은 8바이트라 union 값 칸, write barrier, 아레나 RC를 그대로 씀
- 항상 NUL로 끝나 C에 바이트 시작 위치를 그대로 넘김
- 리터럴은 static const 데이터
- 부분 문자열은 결과가 놓일 아레나에 복사함. 원본을 공유하면 아레나 사이 참조가 생겨 원본 아레나 전체가 살아남음(반복문 아레나는 rollback도 못 함)
- 복사 없는 부분 참조는
str.view(start, end): StringView로 명시적으로 만듦. StringView는{ptr, len}16바이트 값이고 원본 아레나를 참조함. 기본을 복사로 둔 이유: 잘못 쓰면 1번(복사 기본)은 복사 비용, 2번(참조 기본)은 아레나 전체를 붙잡는 누수라 찾기 어려움. C++(substr + string_view), Java와 같은 쪽 - StringView의 멤버, string으로 되돌리는 방법(복사), 인자 형태는 아직 정하지 않음
- 리터럴 TU 간 병합(최적화): 지금은 모듈 안에서만 같은 값을 한 번 만듦. 필요하면
-fmerge-all-constants로 링크 시점에 합침. 문자열은 주소가 아니라 내용으로 비교하므로 C의 "다른 객체는 다른 주소" 규칙을 어겨도 됨 - 짝 없는 서로게이트가 있는 리터럴(
'\uD800'): UTF-8로 인코딩할 수 없어 지금은not implemented string with a lone surrogate로 막음. 영구 거부(Rust, Go와 같음)와 WTF-8로 담기 중 정하고 에러 메시지를 맞춰야 함. 거부 추천- 탈출하지 않는 부분 문자열은 컴파일러가 내부적으로
{ptr, len}뷰로 복사를 생략할 수 있음(최적화) s += x에서 s가 아레나의 마지막 할당이면 bump를 늘려 제자리에서 이어 붙일 수 있음(최적화)- 채택하지 않음:
{ptr, len}16바이트 값(Go, Zig, Rust &str 방식). 부분 문자열은 복사가 없지만 원본 아레나를 붙잡고, union 8바이트와 맞지 않고, NUL 종료가 보장되지 않음
- 탈출하지 않는 부분 문자열은 컴파일러가 내부적으로
[배열]
- 배열은 RC 객체(
ref, uniontype_id48). JS 배열은 참조로 공유되므로(const b = a; b.push(1)) 값 타입이 아님 - 메모리 표현:
header(16) | length(8) | capacity(8) | elements*(8), 40바이트- 원소는 배열 객체 하나만 소유하는 별도 malloc 버퍼(RC 없음). 문자열처럼 객체 뒤에 붙이면 커질 때 객체가
재할당되어 주소가 바뀌고 다른
ref가 무효가 됨. V8(JSArray + FixedArray), Java ArrayList와 같은 구조. Swift Array는 원소를 붙이지만 값 타입(COW)이라 가능함 - 용량이 차면 2배(Rust Vec과 같음. Go 2배→1.25배, Java 1.5배, V8 1.5배+16)
ref, union 원소는 포인터 하나만 들고 있어realloc으로 옮겨도 됨(trivial_abi)
- 원소는 배열 객체 하나만 소유하는 별도 malloc 버퍼(RC 없음). 문자열처럼 객체 뒤에 붙이면 커질 때 객체가
재할당되어 주소가 바뀌고 다른
- 원소 shape별 C++ 템플릿
ssc__core__struct__array<Element>: i8~u64, f32, f64, boolean, char, ref, union. shape stenciling이라 인스턴스 수가 이 이상 늘지 않음 - 해제: 서술자의 배열 전용 소멸 함수가 원소의 RC를 내리고 버퍼를
free - 읽기
a[i]:T | undefined(noUncheckedIndexedAccess강제). 범위 밖이면undefined - 쓰기
a[i] = x:0 <= i < length만 허용, 그 밖은RangeError.i === length도 오류(끝에 추가는push로만). JS는 길이를 늘리고 빈칸(hole)을 만들지만Array<i32>는 빈칸을 표현할 수 없음. Rust, Go, Swift, Java도 오류 - 인덱스:
[index: u64]: T. 일반 대입 규칙을 그대로 따라 u64에 손실 없이 들어가는 타입(u8~u64, 범위 안 리터럴)만 받음.a[1.5],a[-1], i32·f64 변수는 TS 오류이고u64(i)생성자로 명시적으로 바꿈(음수는 생성자가 오류) - 첫 단계:
T[]타입, 리터럴,length(u64), 인덱스 읽기·쓰기,push,pop,console.log출력 - 콜백 메서드(
forEach,map,filter,reduce,find,some,every,indexOf)는 지금 prelude C++로 구현. 제네릭이 생기면 TS 표준 라이브러리로 옮김. C++에는 할당,length, 원소 읽기·쓰기,push/pop,call같은 기본 연산만 남김 - 나중:
length대입(잘라내기),new Array(n)
[메모리 모델] 기본: 객체별 참조 카운트(RC) + RAII (아레나 모델을 대체함)
- 할당과 해제는 컴파일러가 자동으로 넣음. 사용자에게 노출하지 않음
- 참조 타입은 RC 핸들 클래스로 내림. 복사 생성자 = +1, 소멸자 = -1(0이면 해제), 이동 생성자 = 증감 없이 소유권만 넘김(마지막 사용에서 RC 연산 생략)
- 같은 계열: Swift ARC, Nim ARC, Koka Perceus
- 객체 헤더에 RC 8바이트
- 멀티스레드에서는 원자 연산 비용이 생김(biased RC 검토)
전환 이유
- C++의 복사·이동·소멸자가 RC 규칙과 1:1로 맞아 lower가 단순해짐
- 아레나 모델은 탈출 분석, write barrier, 아레나 풀, 주소 → 아레나 테이블이 필요해 복잡함
- 성능 차이 측정(glibc malloc, -O2, 16
24바이트 객체 1천만 개): bump 아레나 3.7ns, malloc + RC 증감 + free 6.6ns, malloc 전부 → free 전부 10.9ns(객체당). 23배 차이지만 절대값은 객체당 3~7ns라 할당이 지배적인 프로그램이 아니면 작음 - 할당이 많은 경우는 할당기 교체(크기별 풀, mimalloc 방식)와 명시적 아레나 API로 보완
스택과 힙
- 전체 프로그램 정적 탈출 분석으로 스택에 둘지 힙에 둘지 정함
- 콜백과 함수 값도 재귀적으로 분석함. 외부 C 호출은 보수적으로 탈출한 것으로 봄
- 스택 객체도 첫 필드가 오브젝트 헤더이고 RC를 0(불멸)으로 둠. union, 복사, 이동에서 RC 연산이 무시되어 힙 객체와 같은 코드로 다룸. 해제는 범위가 끝날 때 스택에서 일어남
- union에 담긴 값도 추적함. union이 탈출하면 담긴 객체도 탈출한 것으로 보고 힙에 둠(Go의 interface와 같음)
순환
- 우선순위 낮음. 셀프호스팅까지는 순환이 누수됨(컴파일러는 짧게 끝나는 프로세스라 문제없음)
- 나중에 순환 수집기 추가: Nim ORC 방식. RC가 줄었지만 0이 아닌 객체를 후보로 모았다가 trial deletion(Bacon–Rajan)으로 순환만 해제. 순환이 불가능한 타입은 후보에서 제외
- 사용자에게 weak 표시를 요구하지 않음(JS의 일반 참조는 모두 강한 참조)
참조
- 8바이트 포인터. 세대(generation) 검사 없음
명시적 아레나 API (선택 기능, 나중에)
- 할당이 많은 코드만 프렐류드의 아레나 객체나 함수로 대량 할당·일괄 해제
- 안전성 방식은 그때 정함: 아레나 자체에 RC(밖에서 가리키면 해제를 미룸), 해제 시점 검사 후 예외, 콜백 안에서만 쓰게 해서 탈출을 컴파일러가 막기
- Go의 arena 실험은 use-after-free 문제로 보류됨. Zig, Rust bumpalo, C++ pmr은 사용자 책임
채택하지 않은 방식
- 기본 모델로서의 아레나 + 아레나 단위 RC (C 백엔드 때의 설계. prelude/arena.h, arena.c)
- destination passing (이전 프로젝트의 결정은 반영하지 않음)
- 세대 검사용 fat pointer, tagged pointer
- 격리(quarantine) + GC
- 값 단위 수동 해제를 사용자에게 노출, ID 집합
[객체 표현] 객체 헤더
- rc 8바이트 + 서술자 포인터 8바이트 = 16바이트(CPython, Swift와 같은 수준. Rust는 헤더 0,
Rc는 16) - 모든 참조의 첫 8바이트가 rc라서 RC 처리는 실제 타입을 몰라도 됨
- 문자열을 포함한 모든 참조가 이 헤더를 가짐. 문자열은 헤더 뒤에 length, bytes(V8, CPython과 같음)
- TS는 문자열을
{ length: number }같은 구조적 타입 자리에 넣을 수 있어 서술자와 shape가 필요함 typeof x === 'string'은 서술자의 종류로 판별. union에 문자열 전용 id가 필요 없음- 정적으로 string 타입인 곳의 메서드 호출은 서술자 없이 직접 호출
- TS는 문자열을
shape
- (이름, 타입)을 정렬한 목록으로 레이아웃을 정함. TS 구조적 타입은 필드 순서를 보지 않음
- 같은 shape는 오프셋 표를 공유. class 인스턴스와 plain object도 shape가 같으면 레이아웃이 같음
- 정리 목록(ref 필드 오프셋, union 필드 오프셋)은 레이아웃에서 나오므로 shape가 가짐. 서술자 N개가 shape 하나를 공유
- 나중에 필드 정보(이름, 오프셋, 종류)를 추가. 이름은 TS의 원래 이름을 정적 바이트 + 길이(span)로 담음.
첫 소비자(구조적 타입 접근,
JSON.stringify등)와 함께 추가
서술자
- (shape, 키 순서) 조합마다 하나. 객체는 서술자 포인터만 가짐. 서술자는 shape를 가리킴
- 키 순서: JS는 열거·직렬화(
Object.keys,for...in, 스프레드,JSON.stringify)에서 삽입 순서를 드러냄 (정수 키는 오름차순으로 앞).{ a, b }와{ b, a }는 shape는 같고 서술자는 다름 - class는 필드 선언 순서가 고정이라 클래스당 서술자 하나. 클래스 표를 겸함(
instanceof, 메서드 등) - plain object의 서술자는 클래스가 없음(익명)
- V8의 Map(hidden class)과 같은 구조. 다른 점은 TS가 속성 추가를 막아 실행 중에 shape가 바뀌지 않는 것
필드 접근
- 정적 오프셋: shape가 같거나, 자식 클래스가 부모 필드를 앞에 그대로 둔 경우. id를 읽지 않음
- 동적 경로: 필드가 더 많은 객체가 들어온 경우(변수로 넘기면 초과 속성 허용) 서술자 → 오프셋 표 → 필드
private/protected멤버가 있는 클래스는 명목적으로 비교되어 plain object가 들어오지 않음
실행 중에 정해지는 서술자
- 스프레드(
{ ...a, ...b })는 입력의 실제 키 순서를 따름. 입력 서술자 조합별로 만들어 캐시(V8의 transition과 같음)
프렐류드 반영
ssc__core__struct__object_header: rc + 서술자 포인터 (16바이트)ssc__core__struct__descriptor: shape 포인터 (8바이트)ssc__core__struct__shape: ref 필드 오프셋 span, union 필드 오프셋 span (32바이트)ssc__core__struct__span<Type>: 길이 + 원소 포인터. 정적 배열을 길이와 함께 가리킴(std::span과 같은 구조). 프렐류드 내부의 몇 가지 원소 타입에만 써서 템플릿으로 둠drop_rc: RC가 0이 되면 shape의 두 목록을 돌며 각 위치의~ref(),~union()을 호출(하위 참조로 전파)한 뒤free. 범용 함수 하나라 클래스마다 소멸 함수를 만들지 않음(Go, Java GC의 포인터 지도와 같은 방식)- 클래스별 소멸 함수와 비교(ref 3, union 1, 숫자 2 필드 객체 500만 개 해제, free 포함): 클래스별 5.1
5.6ns, 범용 ref/union 목록 5.96.0ns, 범용 전체 필드 + 종류 분기 7.5ns, 목록 하나 + 항목마다 종류 분기 6.7~7.1ns - 정리가 재귀라 아주 긴 연결 리스트를 해제하면 스택이 넘칠 수 있음(Rust
Box리스트와 같은 문제)
- 클래스별 소멸 함수와 비교(ref 3, union 1, 숫자 2 필드 객체 500만 개 해제, free 포함): 클래스별 5.1
- 모든 객체는 서술자를 가져야 함(
drop_rc가 null 확인 없이 따라감). 문자열 서술자는 문자열 생성 함수를 만들 때 위치를 정함 - 대입 연산자(ref, union의 복사·이동)는
other의 값을drop_rc전에 꺼내 둠.other가 왼쪽 객체 안의 필드면 (node = node.next) 왼쪽 해제와 함께 사라지기 때문. 이 순서로 자기 대입도 분기 없이 안전함 - 이동된 union의
type_id는 0(uninitialized). undefined는 정상 값이라 실수로 쓰면 버그가 숨음 - union 템플릿 생성자는
requires(type_id<Type> != 0)로 등록된 프렐류드 타입만 받음(16바이트 구조체를 8바이트 칸에 복사하는 버그 방지) ssc__core__type__ref: 런타임의 논리적 참조(RC 관리). 내부에 C++ 포인터(object_header *) 하나를 담고 템플릿이 없음. 템플릿이면 클래스마다 인스턴스가 생겨 컴파일 +52%, -O0 크기 2.5배(클래스 5,000개 측정, -O1 이상 결과물은 같음)- union
type_id는 참조 타입 전체가 48(ref) 하나. 실제 종류는 서술자가 앎 - 필드 접근은 생성 코드가
object_header *를 실제 구조체로 캐스트
미정
- 동적 경로 비용 측정
- 내장 컨테이너(Array, Map, Set): 원소 수가 실행 중에 정해져 정적 목록으로 정리할 수 없음. 서술자에 내장 타입 전용 소멸 함수(null 가능)를 두고 std 컨테이너 소멸자에 정리를 맡기는 방향. 원소 shape별로 몇 개만 생김
- 힙 객체 생성: placement new 없이
ref필드를 가진 객체를 만드는 방법(테스트는__builtin_memcpy로 만듦)
[제네릭] 방향: shape별 단일 구현(Go 1.18+의 GC shape stenciling + dictionary 방식)
- 참조 타입은 모두 같은 shape(
ref8바이트)라 구현 하나를 공유.Array<User>,Array<string>이 같은 코드 - 기본 타입(i8~u64, f32, f64, boolean, char)은 크기와 연산이 달라 shape별로 따로 생성. 최대 십여 개로 고정
- 타입별 정보(서술자, 비교, 해시 등)는 객체의 서술자나 숨은 사전 인자로 넘김
- 증분 빌드: 제네릭 함수가 정의된 모듈의
.o에만 들어가, 본문을 고쳐도 그 모듈만 다시 컴파일함 - 성능이 필요한 곳은 LTO의 인라인·특수화로 보완
비교
- C++ 템플릿, Rust: 타입마다 생성. 실행 비용 0, 대신 인스턴스가 사용 타입 수만큼 늘고 본문 변경 시 쓰는 모듈 전부 재컴파일
- Java(타입 소거), OCaml: 단일 구현 + 박싱. 기본 타입까지 박싱되어
Array<i32>가 원소당 16바이트 이상 - Swift: 단일 구현 + witness table, 보이는 곳은 컴파일러가 특수화
채택하지 않음
- 기본 타입까지 하나로 합치기:
Array<i32>원소가 union(16바이트)이나 박싱 객체가 되어 메모리 4배, 숫자 연산이 느려짐
[C++ 백엔드] 언어 버전
-std=c++26. clang 23 기준 구현된 기능만 씀. reflection, contracts,#embed등은 clang 미구현- C++23과의 실제 차이:
_변수, pack indexing,= delete("이유"), 구조 분해 확장, 더 엄격한 검사. 무한 루프 UB 제거는 DR이라 C++23에도 적용됨
프렐류드 (prelude/prelude.cppm)
export module ssc;. 생성 코드는import ssc;로 가져옴. include는 쓰지 않음- 숫자 타입과 boolean은
using별칭이라 심볼을 만들지 않음 - char는
char32_t. C++ 내장 타입이라 u32와 다른 타입으로 구분됨(오버로드 가능) - 정적 검사: 64비트 플랫폼, 정수 최댓값, 타입 크기
- undefined, null은 빈 구조체. string과 객체는 RC 참조(
ssc__core__type__ref), union은type_id+ 8바이트 값
이름 규칙
- 프렐류드:
ssc__core__<종류>__<이름>(예:ssc__core__type__i32) - TS 소스:
ssc__ts__<종류>__<이름>(예:ssc__ts__fn__add).declare function은 C 이름 그대로 - 파일: namespace
ssc__ts__namespace__<파일id>, 최상위 문장 초기화 함수ssc__core__fn__<파일id>(namespace 안) - 종류:
var(변수, 매개변수),fn,class,type(인터페이스, 타입 별칭, 제네릭 타입 매개변수),member(필드, 메서드),label - 이유
- C++ 키워드는 위치와 상관없이 예약됨. 필드와 메서드 이름도 못 씀(
map.delete,promise.catch). C++에는 Rustr#, Swift 백틱 같은 탈출 문법이 없음 - TS는 타입과 값의 이름공간이 분리되어
type Point와const Point가 공존함 → 종류로 구분 - 멤버는 하나로 둠. TS는 구조적 타입이라 메서드
run()과 함수 값 필드run이 서로 대입됨 - 항상 붙이는 고정 접두사라 원래 이름을 바로 되돌림.
obj["key"],Object.keys, reflection도 같은 규칙
- C++ 키워드는 위치와 상관없이 예약됨. 필드와 메서드 이름도 못 씀(
__가 든 이름은 C++ 표준상 구현 예약이지만 clang은 기본으로 경고하지 않음 (-Wreserved-identifier에서만). clang 버전을 고정하므로 씀- 모듈 사이 격리는 C++ 모듈이 보장(strong ownership, 심볼에 모듈 이름 포함). 이름에 모듈 id를 넣지 않음
- 이름 길이에 따른 컴파일 시간 차이는 1~2%(함수 2만 개 기준)
tsgo 연결
- fork의
tsc/cmd/sscapi를 N-API 애드온(built/local/tsc.node)으로 빌드해 같은 프로세스에서 호출. 셀프호스팅 후에는 같은 패키지를 c-archive로 정적 링크 ApiClient.request(method, params):typescript7/proto의APIMethodInfo로 타입이 붙은 JSON 요청. 소스 파일도 JSON({ data: base64 })으로 받음- 동기 호출. 이벤트 루프와 async는 셀프호스팅 뒤에 언어 기능으로 따로 설계
- 지금 컴파일러는 라이브러리(
typescript7/sync) 수준으로 구현하고,TsApi·ProgramApi·CheckerApi메서드만 거침. 라이브러리 객체의 메서드(type.getTypes(),symbol.declarations등)는 쓰지 않음 - 원시 요청으로 바꿀 때 남은 일: 바이너리 AST 해석, Type·Symbol을 응답 데이터(
TypeResponse등)로 교체
예외
- C++ 예외(표 기반)를 그대로 씀. TS
throw/try/catch가 1:1로 내려가고 JS 의미가 보존됨 - 링크:
-fexceptions -nostdlib++ --rtlib=compiler-rt --unwindlib=none+ LLVM 배포판의libc++abi.a,libunwind.a정적 링크. 동적 의존은 libc, libm만 남음 - 지원 대상: 리눅스, 맥, 윈도우, 안드로이드. 임베디드는 보류(필요하면
-fno-exceptions+ throw 시 종료 모드) - 측정(x86-64, -O2, strip)
- 크기: 예외 없음 5.7KB, 배포판 그대로 +
--gc-sections231KB. 프로그램 크기와 무관한 고정 비용. 대부분 libc++abi의 demangler(약 115KB, 잡히지 않은 예외 메시지용).LIBCXXABI_NON_DEMANGLING_TERMINATE로 직접 빌드하면 약 56KB - 참고: Rust std 바이너리 약 306KB, musl 정적 printf 22KB
- 오류 없을 때 호출 비용: C++ 예외 +0
3%, 반환값 방식 +1275%, Swift 오류 레지스터 방식 +1~74% - throw 비용: 깊이 10에서 throw 후 catch 1회 약 10μs(반환값 방식 7.8ns). 예외를 흐름 제어로 많이 쓰는 코드는 느림
- 크기: 예외 없음 5.7KB, 배포판 그대로 +
- 반환값·변수(thread_local) 방식은 호출마다 검사가 필요해 식을 쪼개야 함 → 1:1 대응이 깨지고 숨은 동작이 생김. 문장 끝에서 한 번 검사하면 throw 뒤의 호출이 실행되어 JS 의미가 달라짐
- 범위 밖 배열 접근은 예외가 아니라
undefined반환(noUncheckedIndexedAccess강제). 정수 0 나누기 등 UB가 되는 연산은 검사 후 throw. 메모리 부족과 스택 넘침 처리는 미정
평가 순서 (미정, Windows 지원 시작 때 결정)
- JS는 함수 인자와 이항 연산 피연산자를 왼쪽부터 평가. C++은 인자 순서를 정하지 않음(C++17도 indeterminately
sequenced). 우리 산술도
ssc__core__fn__add(a, b, "i32")호출이라 같은 문제 - 실측
f(g(), h()): clang Linuxg → h, GCCh → g, clang MSVC 타깃h → g - clang 규칙(
clang/lib/CodeGenUtils/CallUtils.cppshouldEvaluateArgsLeftToRight): MS ABI만 오른쪽부터 (호출된 쪽이 인자를 소멸시키는 ABI라서), Itanium 계열(Linux, macOS, Android, iOS, WebAssembly, MinGW)은 왼쪽부터. 지금은 이 동작에 기대고test/fixture/order.ts가 회귀를 잡음 - P0145는 인자를 왼쪽부터로 정하자는 원안이었으나 미지정 대안이 채택됨. clang에 순서를 강제하는 옵션은 찾지 못함
- 후보와 단점
- 부수 효과를 추측해 필요한 곳만 임시 변수: 추측이 빗나가면 조용히 틀림. 채택 안 함
- 여러 값을 평가하는 모든 곳을 람다 + 임시 변수로 감쌈: 생성 코드와 컴파일 시간이 부풂
- 문장 단위 임시 변수(ANF, Zig C 백엔드·Cython·CHICKEN 방식): 확실하지만 lowering 전체와
&&·?:·루프 조건 구조를 바꾸는 큰 리팩터링 - 중괄호 초기화
{a, b}: [dcl.init.list]가 왼쪽부터를 보장하고 clang은 MSVC 타깃에서도 지킴(실측). 대신 연산자 함수는 집합체를 받고, TS 함수는 매개변수 집합체를 받고, C 함수는 래퍼가 필요해 호출 규약이 바뀜 - Windows를 MinGW ABI(
x86_64-w64-windows-gnu)로만 지원: 코드 변경 없음, 표준이 아니라 고정한 clang 동작에 의존. MSVC로 컴파일된 C++ 라이브러리와 섞을 수 없음
빌드 플래그 (SscBuildManager.clangCompileFlag, clangEmitFlag, clangCommonFlag)
- 공통(컴파일과 링크 모두):
-O{optLevel},-flto=thin. 링크에-flto가 없으면-O가 lld에 전달되지 않아 조용히 lld 기본값 -O2로 동작함(측정 확인). ThinLTO는 컴파일(파일 단위 정리)과 링크(합친 뒤 최적화, 기계어) 모두 최적화 수준을 씀. 사용자에게는 optLevel 하나로 둠(clang, Rust와 같음) - 컴파일:
-std=c++26,-fexceptions,-ffp-contract=off,-Wno-parentheses-equality, debug면-g - 링크:
-fuse-ld=lld,-nostdlib++,--rtlib=compiler-rt,--unwindlib=none,libc++abi.a,libunwind.a- lld, compiler-rt: 빌드할 때 시스템 binutils, GCC에 의존하지 않고 LLVM 하나로 완결(결과물 의존성은 같음)
--unwindlib=none명시: 생략하면 안드로이드, AIX, clang 설정 파일에서 libunwind가 자동으로 붙어 겹침
- clang 실행 파일과 런타임 경로는
PathResolver(clangPath,libcxxabiPath,libunwindPath)에서만 정함
모듈 구조 (생성 코드)
- 프렐류드는 모듈
ssc. 그 외 모든 코드(사용자 프로젝트 + 라이브러리)는 모듈ts하나 ts.cppm(기본 인터페이스)은 파일별 초기화 함수 선언만 export. TS 파일 하나 =.cpp구현 단위 하나(module ts;)- namespace: 파일마다
ssc__ts__namespace__<파일id>하나. 파일 실제 경로는 프로그램 안에서 유일해 이것만으로 격리됨 (같은 라이브러리의 다른 버전은 경로가 달라 공존, pnpm 링크로 공유되는 같은 파일은 실제 경로가 같아 하나). 패키지 namespace는 두지 않음. unsafe 추적도 모든 파일을 보고 unsafe 코드가 있는지만 판단하면 됨 id는SscIdManager의 고정 id(반복 빌드에서 같음). 경로 해시 등 결정적 id는 보류 - 다른 파일 참조는 항상 전체 namespace 접두사로 씀(같은 파일도 포함). 접두사 호출은 ADL이 일어나지 않음
- 다른 파일의 것은 import 없이 전방 선언으로 씀: 함수, 변수(
extern), 클래스 필드는 소유 파일의 접근 함수로. 같은 모듈 소속이라 전방 선언이 통함. 파일 간·패키지 간 순환도 같은 방식으로 처리됨- 확인: 함수 상호 호출, 변수 상호 참조, 접근 함수로 필드 상호 접근, 두 패키지 간 순환(모듈 하나 구조로는 함수만 재확인)
- 접근 함수는 ThinLTO가 링크 때 인라인해 직접 접근과 같은 기계어가 됨(-O1~-O3 확인). -O0에서는 호출이 남음
- 파일끼리 import가 없어 파일마다 BMI가 필요 없음. 모든 파일이
ts.pcm만 보고 바로.o로 컴파일되어 전부 병렬. TS 파일 하나를 고치면 그.o만 다시 만듦 - 확인한 사실
- C++20 모듈은 import 순환을 허용하지 않음(clang 문서도 위상 순서 컴파일 전제)
- 다른 모듈 소속 함수는 전방 선언할 수 없음(자기 모듈 소속의 다른 함수가 되어 링크 실패)
extern "C++"는 전역 소속으로 되돌리는 우회라 쓰지 않음- partition 인터페이스는 기본 인터페이스가 모두 export해야 해서, 파일마다
.cppm이면 한 파일 수정이 연쇄 재컴파일로 번짐 - 모듈 파일은 함수 본문만 바꿔도 BMI가 바뀜(Reduced BMI 포함, clang 23)
- 패키지마다 모듈을 나누면 라이브러리 → 사용자 코드 import가 있을 때 순환이 생겨 빌드 불가 → 나누지 않음
- 남은 일
- 최상위 초기화 순서와 TDZ: C++ 전역 초기화는 파일 간 순서가 없어, C 백엔드처럼 파일별 초기화 함수를 순서대로 호출
import * as ns를 값으로 쓰는 경우 모듈 namespace 객체, 재내보내기, 필드 쓰기·메서드·상속 확인, 동적import()- 증분 빌드: 처음에는 바뀐 파일과 영향받는 파일을 보수적으로 다시 컴파일하고, TS 수준 변경 분석으로 점점 줄임
최상위 변수 (아직 지원 안 함, 변수 선언은 not implemented로 멈춤)
- 정의는 값 없이 두고 파일 초기화 함수(
ssc__core__fn__<id>)에서 대입. C++ 전역 초기값(T x = f();)은main이전에 파일 간 순서 없이 실행되어 import 순서와 달라지고, 예외·에러 출력 준비 전에 돔 - TDZ 검사는 선언 전에 접근될 수 있는 변수에만 넣음(순환 import, 호이스팅된 함수에서 참조 등). C 백엔드도 같음
- 링키지는 함수와 같음: export면 namespace 안 정의, 아니면
static(다른 파일에서extern으로 선언해도 링크 실패 확인). 다른 파일의 export 변수는 쓰는 파일에namespace <소유 파일> { extern T x; }전방 선언 export declare const는extern "C" T x;(C 전역 변수, 예:environ). 함수와 같은 규칙으로export,declare명시 필요
std 사용
- 편해지는 부분은 적극 활용. 최적화가 필요해지면 그때 의존을 줄임
import std;로 가져옴(배포판에std.cppm있음). 측정: std.pcm 빌드 약 1초(한 번), import만 하면 컴파일 +0.001s, vector 사용 0.038s(#include <vector>는 0.137s). 실행 파일은 쓴 것만 들어감(import만 하면 크기 변화 없음)- vector는 예외 런타임 위에 +2KB.
<charconv>(숫자 ↔ 문자열, 가장 짧은 표현)는 +142KB - 피할 것:
<iostream>, locale,<format>(큼),std::regex(JS 의미와 다름),std::unordered_map(삽입 순서 없음). JSMap은 엔트리 배열(삽입 순서) + 해시 인덱스로 직접 구현(V8 OrderedHashMap, CPython dict 방식) - 아직 빌드 경로에 연결하지 않음. 처음 필요한 시점에 std.pcm 빌드와
libc++.a링크를 추가