생성 대상을 C에서 C++로 바꿀지 검토한 내용과 실험 결과를 모았다. 결정은 아직 보류 상태다.
- 측정 환경: clang 23.1.1(Homebrew LLVM, libc++ 포함), 32코어, Linux(glibc 동적 링크).
- 시스템에는 libstdc++ 개발 파일이 없어서, C++ 표준 라이브러리는 brew LLVM의 libc++로 실험했다.
- TurboPython(https://tpy-lang.org): 타입을 명시한 Python 문법을 C++로 변환해 네이티브 바이너리를 만드는 프로젝트다.
- 정수 기본 32비트, 넘치면 실행 중단, GC 없음, CPython 패키지 비호환,
@native로 C/C++ 연결 등 우리와 방향이 비슷하다. - 메모리 모델은 소유권과 빌림(
Own[T])이고, RAII, move, 템플릿과 1:1로 대응해서 C++을 고른 것으로 보인다.
- 정수 기본 32비트, 넘치면 실행 중단, GC 없음, CPython 패키지 비호환,
- 처음 걱정은 "C++은 C의 추상화라 이중 백엔드가 아닌가"였다. 실제로는 C와 C++ 모두 clang 프런트엔드에서 LLVM IR로 바로 내려가므로 백엔드는 하나다. 차이는 프런트엔드 비용과 언어 기능에 있다.
| 측정 | C | C++ |
|---|---|---|
작은 TU 하나, -O1 |
54.8ms | 55.7ms |
큰 TU(모듈 300개 분량), -O1 |
491ms | 538ms (+10%) |
같은 TU, -fsyntax-only |
83ms | 96ms (+16%) |
이 비교는 언어 차이와 코드 차이가 섞여 있었다. 그래서 아래에서 같은 소스로 다시 쟀다.
C23 정수 접미사(uwb)만 바꿔서 같은 파일을 두 모드로 컴파일했다.
| 최적화 | C | C++ | 차이 |
|---|---|---|---|
-O0 |
358ms | 442ms | +84ms (+23.5%) |
-O1 |
1419ms | 1504ms | +85ms (+6.0%) |
-O2 |
1177ms | 1271ms | +95ms (+8.1%) |
- 늘어나는 절대 시간(85~95ms)은 최적화 수준과 무관하게 거의 일정하다. 전부 프런트엔드 비용이다.
-ftime-trace결과, 최적화와 기계어 생성 시간은 같고 차이는 함수 본문 해석(ParseFunctionDefinition, 96ms → 133ms)에 몰려 있다.- 규모별(syntax-only, 프로세스 시작 약 46ms 제외)로 보면, C++의 파싱·의미 분석 작업량이 C의 약 1.5배다.
- 외부 측정(Clang 19)도
-O0약 +11%,-O2약 +3%로 "거의 항상 C가 빠르다"는 결론이다. 우리는 생성 코드가 선언과 호출 위주라 더 크게 나온다.
| 구문 | 추가 비용 |
|---|---|
함수 프로토타입 int f(int x); |
+33% |
static 함수 정의 |
+27% |
함수 호출 a = f(a) |
+28% |
| 지역 변수 선언(초기화 유무 무관) | +19% |
대입 b = a |
+8% |
산술 a = a + 1 |
+6% |
- 선언 하나하나를 처리하는 규칙(재선언 조회, 링키지, 오버로드 가능성 검사)이 C보다 많다.
- 한정자 없는 호출은 ADL 때문에 호출마다
UnresolvedLookupExpr가 생긴다(-print-stats로 확인, 메모리 +31%).::f(a)처럼 한정하면 이 노드가 사라지고 차이가 약 1/3 줄어든다. - 효과 없음:
-fno-exceptions -fno-rtti,-fno-spell-checking, C 스타일 캐스트 제거. - AST는 C와 C++이 거의 같다. 차이는 의미 분석(Sema) 과정의 작업량이다.
구성:
- 파일 200개, 파일당 약 2만 줄. 생성 코드 총량은 C 410만 줄, C++ 440만 줄이다.
- 각 파일은 다른 파일 3개를 import해서 공개 함수를 호출한다.
- C: 지금 lower가 생성한 실제 코드를 복제했다.
- C++: 전환 후 형태다. 파일마다 모듈 인터페이스(공개 선언만)와 구현을 두고, prelude를 모듈로 빌드하고, namespace와 템플릿을 쓴다.
- 32코어 병렬 빌드, 결과 바이너리 실행으로 정상 동작을 확인했다(exit 0).
| 빌드 | C | C++ | 차이 |
|---|---|---|---|
Release -O1 -flto=thin, 파일마다 프로세스(인터페이스 BMI와 오브젝트를 따로) |
9.79s | 12.58s | +28% |
Release, 인터페이스를 BMI+오브젝트 한 번에(-fmodule-output) |
9.79s | 11.77s | +20% |
| Release, 프로세스당 여러 파일 묶음 | 9.45s | 10.61s | +12% |
Debug -O0, 파일마다 프로세스 |
3.90s | 5.58s | +43% |
| Debug, 프로세스당 여러 파일 묶음 | 3.35s | 4.48s | +34% |
- 처음 차이의 상당 부분은 인터페이스용 clang 프로세스 200~400개를 더 띄우는 빌드 구성에서 왔다. 인터페이스 하나는 48ms(빈 C 파일 46ms)지만, 동시에 많이 띄우면 경합으로 느려진다. 묶어서 컴파일하면 0.97s → 0.28s.
- 구성을 맞춘 뒤 남는 순수 차이는 컴파일 단계 Release +9.5%, Debug +23%로, 2.2의 언어 비용과 같다.
- TypeScript 7(Go 포팅,
tsc/internal555개 파일, 약 32만 줄) 규모로 환산하면 차이는 Release와 Debug 모두 약 0.1초다. - 참고: 포크
tsc를 Go로 빌드하면 캐시 없이 35.5초(CPU 238초), 캐시 있으면 0.95초다.
- TU당 고정 비용(clang 프로세스 시작 약 46ms)이 C++ 추가 비용보다 훨씬 크다. 파일 1000개면 시작 비용만 약 46초(CPU)다.
- 지금 빌드 매니저는 TU를 순차로 컴파일한다(
build.ts의 반복문). 병렬 컴파일과 증분 캐시가 C/C++ 선택보다 큰 차이를 만든다. - 생성 TU에 std 헤더를 텍스트로 include하면 TU마다 수백 ms가 추가된다(2.6). 언어 차이보다 훨씬 크다.
8개 헤더: string, vector, unordered_map, memory, algorithm, optional, variant, functional.
| 방식 | 시간 | std 없는 TU 대비 |
|---|---|---|
| std 없음 | 49.0ms | - |
텍스트 #include |
314.4ms | +265ms |
#include + -fmodules 캐시 |
60.6ms | +12ms |
import std; (미리 빌드한 BMI) |
50.1ms | +1ms |
import std + 실제 사용(템플릿 인스턴스화) |
147.3ms | +98ms |
- 헤더를 읽는 비용은 모듈 캐시나
import std로 거의 없어진다. 남는 비용은 실제로 쓰는 템플릿의 인스턴스화다. - 무거운 std 사용은 런타임(한 번만 컴파일)에 두고, 생성 코드는 런타임의 구체 함수를 부르게 한다.
- brew LLVM에
include/c++/v1/module.modulemap과share/libc++/v1/std.cppm이 있다.
C++ 언어 자체에는 증분 빌드 기능이 없다. 빌드 시스템(우리는 ssc 빌드 매니저)이 맡는다. 최근 clang의 모듈 기능이 이를 돕는다.
- Reduced BMI (clang 22부터 기본값, 우리 clang 23도 해당)
- BMI에 inline이 아닌 함수 본문, inline이 아닌 변수 정의, 쓰이지 않는 전역 모듈 영역 선언을 넣지 않는다.
- 함수 본문만 바꾸면 BMI가 바뀌지 않아, import하는 쪽을 다시 컴파일할 필요가 없다.
- 플래그는
-fmodules-reduced-bmi,--precompile-reduced-bmi이고, 되돌릴 때는-fno-modules-reduced-bmi.
- 연쇄되지 않는 변경 (clang 19부터 실험적)
- clang은 BMI 안에 영향을 줄 수 있는 BMI들의 해시를 기록한다.
- 빌드 시스템은 직접 import한 모듈의 BMI가 실제로 바뀌었을 때만 다시 컴파일한다. 간접 의존이 바뀌어도 직접 의존의 BMI 내용이 같으면 넘어간다("바뀐 경우에만 덮어쓰기").
- 컴파일러 버그 가능성 때문에 끌 수 있는 옵션으로 두라고 권장한다.
- 모듈 의존성 스캔(P1689): CMake 4.0과 Ninja가 컴파일러에게 의존성을 묻는다. 우리는 import 그래프를 이미 알아서 필요 없다.
- 1단계(
-fmodule-output)와 2단계(--precompile→ 오브젝트): 2단계가 병렬성이 높고, 1단계가 구현이 단순하다. - ThinLTO 캐시: Release는 링크 단계에서 기계어를 만든다.
-Wl,--thinlto-cache-dir=...로 바뀌지 않은 모듈의 결과를 재사용한다. C와 C++ 모두 해당한다.
우리 구조(인터페이스는 선언만, 구현은 본문)에 적용하면 다음과 같다.
| 변경 | 다시 컴파일할 것 |
|---|---|
| 함수 본문만 수정 | 그 파일의 구현 TU 하나 |
| 비공개 함수 추가/수정 | 그 파일의 구현 TU 하나 |
| 공개 함수 시그니처 수정 | 그 파일의 인터페이스 + 그 모듈을 직접 import하는 구현 TU들 |
| 바뀌지 않은 파일 | 없음(생성 코드 해시가 같으면 건너뜀) |
- C 백엔드에서도 "생성 코드 해시 + 선언 부분 분리"로 비슷하게 할 수 있다. C++ 모듈은 이 구조를 컴파일러(reduced BMI, BMI 해시)가 받쳐 준다는 점이 다르다.
- 초기 버전은 다음 세 가지와 병렬 컴파일 정도로 충분히 실용적이다.
- 생성 코드가 바뀐 TU만 재컴파일
- 인터페이스 BMI가 바뀌었을 때만 import하는 쪽 재컴파일
- ThinLTO 캐시
- 참고 자료
- https://clang.llvm.org/docs/StandardCPlusPlusModules.html
- https://releases.llvm.org/22.1.0/tools/clang/docs/ReleaseNotes.html
- https://holyblackcat.github.io/blog/2026/03/09/compiling-modules.html
- https://cmake.org/cmake/help/latest/manual/cmake-cxxmodules.7.html
- https://www.kitware.com/import-cmake-the-experiment-is-over/
| 방식 | Release | Debug |
|---|---|---|
| (a) 클래스 밖 정의, 암묵 인스턴스화(일반 C++) | 5.12s (컴파일 2.23) | 0.86s |
| (e) 클래스 안 정의, 암묵 인스턴스화 | 5.17s (컴파일 2.24) | 0.84s |
(d) 클래스 안 정의 + extern template |
5.26s (컴파일 2.30) | 0.66s |
(b) 클래스 밖 정의 + 한 TU 명시 인스턴스화 + extern template |
4.45s (컴파일 0.54) | 0.64s |
| (c) 직접 단형화(템플릿 없이 타입별 함수, C 방식 기준선) | 4.27s (컴파일 0.54) | 0.61s |
- 일반 C++은 TU마다 같은 인스턴스를 만들고 링커가 중복을 버린다. 컴파일 작업이 TU 수만큼 반복된다.
- ssc는 TS checker로 프로그램 전체의 인스턴스 집합을 안다. 그래서 제네릭을 정의한 모듈의 구현 TU에서 명시 인스턴스화하고, 사용하는 쪽에는
extern template만 내면 직접 단형화와 같은 효율이 된다. - 조건: 메서드 본문은 클래스 밖(최상위)에 정의한다. 클래스 안에서 정의하면 자동으로
inline이 되어, 최적화 빌드에서 clang이 인라인하려고 TU마다 다시 인스턴스화한다((d)의 Release 결과).
| 언어 | 방식 |
|---|---|
| C++ (일반) | TU마다 암묵 인스턴스화, 링커가 중복 제거 |
| Rust | 크레이트 단위 단형화, share-generics로 재사용 |
| Go | GC shape별로만 인스턴스화 + 딕셔너리 |
| Swift | 제네릭 코드 하나 + 필요한 곳만 특수화 |
| syscript (제안) | 프로그램 전체를 보고 인스턴스마다 한 번만 생성 |
판단 기준은 "JS 모듈이 허용하는 동작을 그대로 표현할 수 있는가"다. JS에서도 안 되는 것은 문제로 보지 않는다.
| # | 내용 | 결과 |
|---|---|---|
| E1 | 두 모듈의 같은 이름 비공개 함수 helper |
✅ 충돌 없음. 심볼 _ZW1a6helperi / _ZW1b6helperi |
| E5 | 두 모듈의 같은 이름 전역 변수 | ✅ _ZW1a5total / _ZW1b5total |
| E2 | 같은 이름 공개 함수를 가진 두 모듈을 import만 함 | ✅ 에러 없음 |
| E2 | 두 모듈을 import하고 한정자 없이 run 사용 |
에러. JS에서 같은 이름을 두 번 가져오는 것과 같음 |
| E2c | 모듈 + export namespace m_a로 m_a::run, m_b::run |
✅ JS의 as 별칭에 대응 |
| E3 | 전역 모듈 영역과 모듈 본문의 매크로 | ✅ import한 쪽으로 새지 않음 |
| E6 | export extern "C" |
✅ 맹글링 없는 심볼로 C에서 호출 가능 |
| E7a | 전역 모듈 영역에 C 헤더 include | ✅ 사용 가능, 매크로 비노출 |
| E7b | restrict 같은 C 전용 문법 헤더 |
❌ C++ TU에서 컴파일 에러. C++ 백엔드면 연동 대상이 C++이므로 지시문을 C++ 통합으로 바꾸면 된다 |
| E4 | 모듈끼리 또는 파티션끼리 순환 import | ❌ 서로의 BMI가 먼저 필요해서 빌드 불가 |
| W1 | 순환 함수 호출: 인터페이스(선언만)와 구현을 분리, 구현에서만 import | ✅ C의 전방 선언과 같은 방식 |
| W2 | 순환 클래스 참조: 한 모듈 + 파일마다 파티션 + 서로 전방 선언 | ✅ C의 구조체 전방 선언과 같은 방식 |
| E8 | 모듈 단위 컴파일 시간 | ✅ 일반 TU 149.2ms, 모듈 148.9ms. BMI만 약 60ms |
- 지금 C에서 직접 하는 모듈 맹글링, 전역 이름 격리, 매크로 누출 방지는 모듈 + namespace가 컴파일러 차원에서 대신한다.
- 순환 import는 C와 같은 문제이고, 같은 방식(선언과 정의 분리)으로 푼다. 차이는 C++ 모듈에서는 다른 모듈의 이름을 그 모듈 밖에서 선언할 수 없어서, 선언을 인터페이스 유닛 한 곳에 모아야 한다는 점이다.
- 생성 구조: 소스 파일마다 인터페이스 유닛(공개 선언만, import 없음)과 구현 유닛(본문, 다른 모듈 import)을 둔다. 빌드는 인터페이스 BMI → 구현 2단계다.
- C++ 키워드가 C보다 많다(
template,typename,namespace,operator,friend,virtual,explicit,mutable,concept,requires등). 식별자 충돌 처리는 계속 필요하다.
- "맹글링이 형편없다"는 불만은 읽기 어려움, 다른 언어에서 호출하기 어려움, 컴파일러마다 다름, 템플릿 이름이 길어짐에 대한 것이다.
- clang만 쓰고 매번 전체를 생성하는 우리에게는 영향이 작다. 사용자에게 내보내는 함수만
extern "C"로 두면 된다. - 우리의
ssc__module_<id>_fn__name규칙도 사실상 직접 만든 맹글링이다.
- 고수준 기능(자료구조, 문자열·유니코드, 숫자 변환, 정규식, JSON, 병렬 처리)은 C++이 압도적이다.
- 예: simdutf, fast_float, dragonbox, ryu, re2, abseil, fmt.
- 시스템 라이브러리(OS API, zlib, OpenSSL, SQLite, curl, libuv, SDL)는 C ABI 중심이고, C++에서도 그대로 쓴다.
- 그래서 사용 가능한 범위로 보면 C++ 생태계는 C를 포함한다.
- 약점은 패키지 관리다. 공식 표준이 없고(vcpkg, Conan 등), 빌드 방식도 제각각이다. 런타임에 넣을 라이브러리는 직접 골라 소스째 가져오는 쪽이 현실적이다.
| C++ 개념 | .d.ts |
난이도 |
|---|---|---|
| 자유 함수, namespace | 지금 C와 같음, declare namespace |
쉬움 |
| 오버로드 | TS 오버로드 시그니처, 선택은 C++ 컴파일러에 맡김 | 쉬움 |
| 클래스, 메서드, 생성자 | declare class |
중간 |
| 템플릿 클래스 | TS 제네릭 | 중간 |
참조 인자, 연산자 오버로드, std::string |
규칙과 변환 필요 | 중간 |
메모리 모델과 맞출 것:
- 소멸자가 있는 C++ 객체는 아레나에 finalizer(해제할 때 실행할 소멸자 목록)로 등록한다. V8 Zone, protobuf
Arena와 같은 방식이다. - 라이브러리 내부 할당은 막지 않는다(C 라이브러리와 같다).
- 라이브러리 예외는 예외를 켠 얇은 shim에서
try/catch로 감싸 우리 에러로 바꾼다.
선례: Nim importcpp 패턴({.importcpp: "#.push_back(@)".}), Swift(clang 내장), D extern(C++), Rust cxx/bindgen.
| 충돌 | C 백엔드 | C++ 백엔드 |
|---|---|---|
| 우리 생성 코드 ↔ 라이브러리 | 직접 맹글링 | namespace + 모듈로 해결 |
| 라이브러리 매크로가 다른 모듈로 퍼짐 | TU 안에서 보임 | 헤더를 쓰는 모듈 안에만 머묾 |
| C++ 라이브러리끼리 | C++ 라이브러리 사용 불가 | namespace로 대부분 해결 |
| 한 TU 안 두 헤더의 선언 충돌 | 컴파일 에러 | 같음. 라이브러리별 래퍼 모듈로 분리 가능 |
| C 라이브러리끼리 같은 전역 심볼 | 링크 충돌 | 같음 |
- 바이너리 심볼 이름 바꾸기(
objcopy --redefine-syms)는dlsym, 어셈블리 참조, weak/별칭/버전 심볼 때문에 호환을 보장하지 못해 채택하지 않는다. - 라이브러리를 고치지 않고 Linux에서 같은 이름의 전역 심볼을 살리는 방법은 런타임 격리(
dlopen(RTLD_LOCAL),dlmopen)뿐이다. LTO와 인라인이 막힌다. - macOS의 2단계 이름 공간과 Windows DLL 가져오기 표는 이 문제가 없다. ELF 정적 링크에는 이런 장치가 없다.
- 이름 있는 모듈에 속한 선언은 심볼에 모듈 이름이 붙는다(strong ownership, Itanium ABI 확장, clang과 GCC 구현).
| 경우 | 결과 |
|---|---|
libA, libB가 똑같이 util::hash와 전역 state를 가짐 |
심볼 util::hash@libA, util::hash@libB, state@libA, state@libB로 분리 |
| 모듈 x는 libA를, 모듈 y는 libB를 사용(인터페이스/구현 분리) | ✅ 둘 다 링크되고 각자 올바른 함수 호출(-O1, -O1 -flto=thin) |
| x, y의 인터페이스에 함수 본문이 있음 | main이 x, y만 import해도 libA, libB 선언이 끌려와 에러 |
한 TU에서 libA와 libB를 import하고 util::hash 사용 |
에러(다른 모듈에 붙은 같은 선언). JS에서 같은 이름을 두 번 가져오는 것과 같음 |
- 조건: 라이브러리가 이름 있는 모듈로 빌드되어 있어야 한다. 헤더 방식 라이브러리를
export using으로 감싼 래퍼는 원래 심볼(전역 모듈 소속)을 유지해서 이 보호를 받지 못한다. - 헤더 방식 라이브러리끼리는 기존 관행(namespace, 버전별 inline namespace:
absl::lts_*,std::__1)에 기댄다. - 모듈은 헤더 방식에서 조용히 넘어가던 ODR 위반을 컴파일 에러로 잡는다.
- 참고 자료
원칙: 명시하지 않은 할당이 생성 코드와 런타임에 있어서는 안 된다.
| 기능 | 할당 |
|---|---|
| 람다(캡처 포함), RAII 소멸자, 클래스/상속/가상 함수 | 없음 |
optional, variant, array, span, string_view, sort |
없음 |
throw |
있음(__cxa_allocate_exception) |
| 코루틴(프레임이 밖으로 새는 경우) | 있음(operator new). 새지 않으면 LLVM CoroElide가 없앰 |
vector, string 등 컨테이너 |
있음(operator new) |
stable_sort |
있음(operator new(nothrow), 임시 버퍼) |
언어 자체의 숨은 할당은 예외와 코루틴 두 곳이고, 나머지는 표준 라이브러리다.
| 대상 | 방법 | 결과 |
|---|---|---|
| 예외 | -fno-exceptions |
throw가 컴파일 에러 ✅ |
| 코루틴 프레임 | promise 타입에 operator new 정의 → 아레나 할당 |
외부 참조가 _Znwm → ssc_arena_alloc ✅ |
| 표준 라이브러리 할당 | 빌드 후 오브젝트 심볼 검사(_Znwm, _Znam, malloc, calloc, realloc, __cxa_allocate_exception 등이 있으면 실패). path-leak 검사와 같은 방식 |
✅ |
operator new에 [[gnu::error]] |
컴파일 에러로 막으려 함 | ❌ 컴파일러가 미리 선언하는 함수라 속성을 붙일 수 없음 |
사용자 코드는 lower를 거치므로 숨은 할당을 직접 만들 수 없다. 위험은 우리가 작성하는 코드(생성 형태, 런타임)에만 있고, 빌드 검사가 잡는다.
| 코드 | 외부 참조(할당 관련) |
|---|---|
기본 std::vector<int> |
_Znwm, _ZdlPvm |
std::vector<int, ArenaAllocator<int>> |
ssc_arena_alloc만 |
std::pmr::vector<std::pmr::string> + 아레나 리소스 |
할당 함수 참조 없음(가상 호출로 위임) |
- 할당자 템플릿 인자: 호출이 정적으로 결정돼 인라인이 잘 된다.
std::pmr: 요소(pmr::string)까지 같은 아레나를 자동으로 쓴다. 아레나가 달라도 타입이 같아 인스턴스가 늘지 않는다. 할당마다 가상 호출 하나.- 직접 만든 컨테이너(LLVM
SmallVector, V8ZoneVector방식): 마지막 할당이면 제자리 확장, 인라인 저장 등 아레나에 맞출 수 있다. - 주의할 점
- 늘어날 때 옛 공간이 아레나에 남는다(두 배씩 늘리면 최악의 경우 약 2배 사용).
- 소멸자 없는 요소 타입이면 소멸자를 부르지 않아도 된다.
- 참조 요소의 write barrier는 요소 타입(우리 참조 래퍼)의 대입 연산자에 넣는다.
- 추천
- 런타임 내부에는
std::pmr를 쓴다. - JS
Array처럼 사용자에게 보이는 타입은 직접 만든 컨테이너로 한다(8바이트 참조, 제자리 확장, barrier).
- 런타임 내부에는
C++ 클래스는 힙을 전제로 하지 않는다. 스택, 정적 영역, 아레나 메모리 위(placement new, 또는 C++20 implicit-lifetime 규칙) 어디에나 둘 수 있다. 탈출하지 않는 값은 스택에, 탈출하는 값은 아레나에 두는 원래 계획이 그대로 된다.
- libc(glibc)는 지금처럼 동적 링크한다.
- libc++(C++ 표준 라이브러리)는 정적 링크로 바이너리에 넣는다(brew LLVM에
libc++.a,libc++abi.a). - 그래서 실행에 필요한 것은 지금과 같이 libc 하나다.
- 완전한 정적 바이너리는 백엔드와 무관하게 musl을 쓸지의 문제다. glibc 정적 라이브러리(
libc.a)는 이 시스템에 없고, glibc는 NSS/dlopen때문에 정적 링크와 잘 맞지 않는다. - libc++가 제공하는 것
- 파일 I/O(
<fstream>,<print>),<filesystem>, 스레드/원자 연산,<chrono>
- 파일 I/O(
- libc++가 제공하지 않는 것
- 네트워크(Networking TS 미채택, C++26에도 없음), 프로세스 실행, 비동기 I/O
- 대안: POSIX 소켓, asio, libuv
- 최신 릴리스는 C++26이다. WG21이 2026년 3월 런던 회의에서 기술적으로 확정했고, ISO 발행은 진행 중(DIS)이다.
- 마지막으로 ISO 발행까지 끝난 버전은 C++23(ISO/IEC 14882:2024)이다.
- clang은
-std=c++2c,__cplusplus=202400L.
| C++26 기능 | 로컬 clang 23 |
|---|---|
팩 인덱싱 T...[0] |
✅ |
#embed |
✅ (공식 현황 페이지 요약과 다름. 직접 확인 필요) |
이름 없는 변수 _ |
✅ |
static_assert 사용자 메시지 |
✅ |
= delete("이유") |
✅ |
constexpr placement new |
✅ (libc++ 헤더 필요) |
| 구조적 바인딩 팩 | ✅ |
정적 리플렉션 ^^ |
-Xclang -freflection으로 파싱은 되나 상수 평가 미지원 |
| contracts | ❌ |
전환한다면 -std=c++2c로 두고 확인된 기능만 쓴다.
모든 C++ 기능이 LLVM IR로 내려가서 최적화 대상이 된다.
| 분류 | 기능 | 최적화 |
|---|---|---|
| 컴파일 타임에 사라짐 | 템플릿, constexpr/consteval, 팩 인덱싱, 구조적 바인딩 |
런타임 비용 없음 |
| 평범한 코드가 됨 | 클래스, 멤버 함수, 연산자 오버로딩, 참조, 람다, RAII | C와 같은 수준으로 인라인·최적화 |
| 제한될 수 있음 | 가상 함수 | 탈가상화는 LTO + -fwhole-program-vtables |
std::function |
타입 소거로 인라인 어려움 | |
| 예외 | 정상 경로 비용 0, 일부 최적화가 보수적 | |
| 코루틴 | CoroSplit/CoroElide 패스, 새는 프레임은 아레나 할당으로 | |
| RTTI | 끄거나 우리 타입 id로 대체 |
| 기능 | C++에서 |
|---|---|
| 모듈 격리, 이름 맹글링 | 모듈 + namespace. 접두사가 사라짐 |
| 정수 연산, 넘침 검사 | 템플릿 하나, 타입 접미사 제거 |
| 숫자 생성자 | 템플릿 또는 inline 함수 |
| char | enum class 같은 별도 타입 필요(오버로딩에서 u32와 구분) |
| union | any::of<T>, is<T>(), get<T>(). prelude 매크로가 크게 줄어듦 |
| 문자열 | 같은 메모리 배치에 연산자. lower는 a == b 그대로 생성 |
| 문자열 리터럴 | 문자열을 템플릿 인자로 받는 inline 변수 템플릿 → 링커가 프로그램 전체 중복 제거(TU 간 병합 todo 해결) |
| TDZ | 검사가 들어간 래퍼 타입 |
| 모듈 초기화 순서 | 순환 import 때문에 지금처럼 명시 초기화 함수 유지 |
| 아레나 할당기 | 그대로 사용 |
| 빌드 | 인터페이스 BMI → 구현 2단계 빌드, 묶음 컴파일. 새로 작업 필요 |
바뀌는 범위는 lower, c-builder, prelude, 빌드 매니저다. TS 포크, 파서, 동작 테스트는 그대로 쓴다. snapshot 테스트만 다시 만든다.
| 기능 | C | C++ |
|---|---|---|
아레나 스코프(함수, 반복문, using) |
모든 탈출 경로에 정리 코드 생성 | RAII 가드 |
| 아레나 간 RC + write barrier | 모든 포인터 저장에 barrier 호출 생성 | 참조 타입 대입 연산자에 barrier |
| 클로저, 콜백 | 환경 구조체 + 함수 포인터 생성 | 람다(캡처 메모리는 아레나용 클로저 타입 필요) |
| 제네릭 | 타입별 인스턴스 생성 | 템플릿(3장의 인스턴스화 제어) |
| 클래스, 메서드, 상속 | 구조체 + vtable 직접 생성 | C++ 클래스 |
| 예외 | 반환값 전파 + 정리 코드 | C++ 예외 + RAII(단, 숨은 할당과 런타임 의존성 검토) |
for..of, 제너레이터 |
상태 기계 변환 직접 구현 | C++20 코루틴, std::generator |
async/await |
상태 기계 + 이벤트 루프 | 코루틴 + 이벤트 루프(libuv 등) |
Map, Set |
직접 구현 | abseil, std::pmr |
| 문자열 연산 | 직접 구현 | simdutf, fast_float, dragonbox(JS Number → 문자열의 최단 표현) |
| 대상 | 장점 | 걸리는 점 |
|---|---|---|
| LLVM IR 직접 | 최대 통제, 프런트엔드 비용 0 | C ABI 호출 규약, 디버그 정보 등 전부 직접 구현 |
| Zig | 숨은 할당 없음, defer, comptime, C 헤더 직접 가져오기 |
1.0 전 불안정, C++ 생태계 사용 불가, 클로저 없음, 프로그램 전체가 한 컴파일 단위 |
D (-betterC, LDC) |
템플릿, 모듈, 람다, scope(exit), C++ 일부 연동 |
생태계 작음 |
| C3 | 모듈, 제네릭 모듈, defer |
매우 젊음 |
| Rust | 기능 풍부, 생태계 큼 | 생성 코드가 대부분 unsafe, 컴파일 느림 |
| MLIR | 다단계 IR | 언어 하나에 과한 인프라 |
생태계를 원하면 C++ 외 대안이 없다. 철학(숨은 비용 없음)만 보면 Zig가 가장 가깝다.
- 생성 TU에는 std 헤더를 텍스트로 include하지 않는다. 필요하면
import std또는 모듈 캐시를 쓴다. - 무거운 템플릿과 std 사용은 런타임에 두고, 생성 코드는 런타임의 구체 함수를 호출한다.
- 사용자 제네릭은 템플릿으로 생성하되, 인스턴스화는 정의한 모듈의 구현 TU에서 명시적으로 하고 사용하는 쪽은
extern template. 메서드 본문은 클래스 밖에 정의한다. - 소스 파일마다 인터페이스 유닛(공개 선언만, import 없음)과 구현 유닛으로 나눈다. 순환 import와 라이브러리 충돌 격리가 이 구조로 해결된다.
- 인터페이스는 묶어서 컴파일하고, 구현은 병렬로 컴파일한다.
- 호출은 완전히 한정(
::ssc::f(...))해서 ADL 비용을 뺀다. 전방 선언은 필요한 것만 낸다. -fno-exceptions, 코루틴 프레임은 아레나 할당, 빌드 후 할당 심볼 검사로 숨은 할당을 막는다.- libc++는 정적 링크한다.
- 증분 빌드: 생성 코드 해시로 바뀐 TU만 재컴파일하고, 인터페이스 BMI가 바뀌었을 때만 import하는 쪽을 재컴파일하고, ThinLTO 캐시를 켠다.
- prelude를 C++ 모듈로 옮기고 2단계 빌드를 구성한다.
- 이름 규칙(모듈 + namespace)을 바꾼다.
- 숫자, 생성자를 옮긴다.
- union을 옮긴다.
- 문자열과 리터럴을 옮긴다.
- TDZ를 옮긴다.
각 단계는 기존 동작 테스트가 그대로 통과하는지로 확인한다.
- 숨은 동작(예외, 코루틴, std 할당)을 규칙과 빌드 검사로 막는 방식이 충분히 신뢰할 만한가.
- 예외를 쓸지(RAII + 예외), 반환값 기반 에러로 갈지. 아레나 스코프 설계 때 같이 정한다.
- Debug 빌드에서 C++ 프런트엔드 비용(같은 코드 +23~34%)을 받아들일 수 있는가.
실험 파일은 세션 scratchpad에 있었고, 영구 보관하지 않는다. 재현 방법은 다음과 같다.
- 같은 소스 비교: lower가 생성한 C에서
uwb/wb접미사를ull/ll로 바꾸고clang -std=gnu23/clang -x c++ -std=gnu++23로 각각 컴파일한다. - 대형 프로젝트: 대표 프로그램(정수 반복문, char, union, 문자열 비교, 생성자)의 생성 C와 대응 C++을 블록으로 복제해 파일 200개 × 블록 500개를 만든다. 32개 스레드로 병렬 빌드한다.
- 제네릭: 메서드 30개짜리 템플릿 컨테이너를 타입 10개, TU 100개에서 사용한다. (a)~(e) 변형을 빌드한다.
- 모듈 격리:
--precompile로 BMI를 만들고-fmodule-file=또는-fprebuilt-module-path=로 import한다.nm으로 심볼을 확인한다. - 숨은 할당: 기능별 작은 TU를
-O1로 컴파일하고nm -u로 할당 함수 참조를 확인한다.