본문으로 건너뛰기
SeungYeop Ryu
돌아가기

CppFront 맛보기

글 수정
목차

0. Intro

C++는 40년 가까이 하휘 호환성을 매우 중요하게 생각했다. 그대신 그 호환성을 지킨답시고 문법도 계속해서 복잡해졌다.

심지어 Memory Safety 문제도 매번 따라다녔다. Dangling Pointer, Use-after-free, Out-of-bounds같은 것들은 조금만 잘못 건드려도 Undefined Behavior로 이어진다. 포인터의 수명이나 배열 범위를 개발자가 매번 확인해야 하니 실수하기도 쉽다.

그래서 기존 C++ 코드베이스를 Rust 같은 Memory Safe 언어로 옮겨야 한다는 주장도 많이 들어봤을 거다.

cppfront는 이 문제를 어떻게든 해결해 보겠다는 실험이다. ISO C++ 표준 위원회 의장이었던 Herb Sutter가 기존 생태계는 그대로 두고, 문법만 새로 설계해 보자고 제안했다.

cppfront는 새로운 문법으로 작성한 코드를 C++ 문법으로 변환해준다. 그 이후는 그냥 g++, clang++같은 컴파일러가 컴파일한다.

그래서 cppfront는 Source-to-Source Compiler나 일종의 Transpiler에 가깝다. 비유하자면 TypeScript를 컴파일하면 JavaScript로 바뀌는 것과 비슷하다.

새로운 문법은 Cpp2라고도 부르고, 기존 C++ 문법은 Cpp1로 구분한다.

1. Philosophy

그럼 새로운 문법을 만든 이유가 무엇일까?

Zig 언어와 비슷한데, 불필요한 문법을 줄이고 안전 검사를 기본 기능으로 넣자는 것이다. 컴파일러나 IDE가 코드를 읽기 쉽게 만드는 것도 목표다.

하지만 C++에는 수십 년 동안 쌓인 코드와 라이브러리가 있기 때문에 불편한 문법을 고치더라도 이 생태계를 무시할 수 없다. 이미 있는 코드는 계속 쓸 수 있어야 하고, 이걸 갈아 엎기엔 시작부터 일이 너무 커진다.

기존 C++에서 권장 사항 정도였던 규칙을 언어의 기본 규칙으로 가져오려고도 한다. C++ Core Guidelines의 Safe Parameter Passing이나 초기화 방식이 대표적이다.

1.1. 하위 호환성을 포기하지 않는 진화

cppfront는 기존 C++ 코드와 라이브러리를 그대로 활용하면서 새로운 문법을 덧붙인다.

기존 C++ 표준 라이브러리나 서드파티 라이브러리도 사용할 수 있고 별도의 FFI나 Binding, Wrapping 없이 Cpp1 코드와 Cpp2 코드를 섞어 쓰는 것이 목표다.

프로젝트를 다 갈아엎을 필요까진 없고, 새로 만드는 부분부터 Cpp2로 차근차근 해보자는 느낌이다.

1.2. 올바른 행동을 기본값으로

기존 C++에는 하위 호환성의 흔적이 곳곳에 남아 있다.

초기화 규칙부터 implicit conversion, pointer, reference, const, move semantics 등등.. 전부 제대로 쓰려면 신경 써야 할 부분이 한두개가 아니다.

당장 함수 Parameter만 봐도 값으로 받을지, const T&로 받을지, T&나 T&&가 필요한지 직접 골라야 한다. 그냥 값을 하나 넘기려는데 생각할 게 너무 많다. 어떻게 쓸 값인지에 더해 전달 방식까지 챙겨야 한다.

Cpp2에서는 이 값을 어디에 쓸 건지부터 명시한다.

값을 읽기만 한다면 in, 수정한다면 inout, 함수에서 값을 만들어서 caller에게 전달한다면 out, 소유권을 넘긴다면 move, Perfect Forwarding이 필요하다면 forward를 사용한다.

그걸 실제 C++의 Parameter Passing 방식으로 옮기는 건 cppfront가 알아서 해준다.

함수 반환도 중요하다. 기존 C++에서 [[nodiscard]]를 일일이 붙이던 것과 비교하면 기본값이 반대다.

Parameter의 기본 모드인 in은 읽기 전용이다. Member Function의 this도 기본적으로 in이라 const처럼 취급된다. 하지만 지역변수까지 전부 immutable로 만들지는 않는다.

Rust에서 모든 변수를 못 바꾸게 해두고, 필요할 때 mut를 붙이는 방식과는 다르다. Cpp2에서는 함수가 값을 읽는지, 수정하는지, 넘겨받는지를 명시한다.

1.3. Memory Safety 강화

C++에서 포인터를 잘못 다루면 Dangling이나 Use-after-free를 발생시키기 쉽다. 배열 인덱스를 잘못 계산하면 Out-of-bounds가 나기도 한다.

잘못 쓰셨어요 하고 에러라도 친절하면 좋겠지만 전부 그냥 Undefined Behavior로 넘어가기도 한다.

CppFront는 이런 문제를 언어 차원에서 잡는다.

Uninitilaized 변수는 금지하고, 배열이나 컨테이너에 접근할 때 Bounds Checking을 적용한다. Null Pointer Dereference, Integer Division by Zero처럼 흔히 실수할 수 있는 동작 또한 검사 대상이 된다.

그렇다고 위험한 연산을 전부 막을 수도 없다. 하드웨어나 메모리를 직접 만지다 보면 부득이하게 필요한 순간이 있을 거다.

그래서 위험한 동작을 하고 있다는 사실을 코드에 드러나도록 설계했다. 예를 들어, 안전하지 않은 Casting을 하려면 프로그래머가 의도적으로 안전 규칙을 벗어났다는 점을 명시해야 한다.

Rust의 unsafe와는 거리가 멀다. CppFront는 그때그때 필요한 안전 규칙만 허용하고, 여러 규칙을 한꺼번에 풀지는 않는다.

1.4. Machine-friendly 문법 단순화

기존 C++ 문법은 우리가 보기에도 어렵지만, 파서 입장에서도 해석 난이도가 이만저만이 아니다.

Context-sensitive한 문법이 많아서 코드 일부만 보고는 직관적으로 해석할 수 없는 경우가 많다. 주변 선언이나 문맥까지 봐야 하는 번거로움이 생기기도 한다.

컴파일러야 뭐 어떻게든 처리한다고 해도 IDE, Formatter, Static Analyzer 같은 툴을 만드는 입장에서는 매우 골치 아픈 과제다.

CppFront는 이런 부분도 완화하려고 한다. 단순한 선언 문법부터 C++과는 많이 다르다.

C++의 함수 선언은 반환 타입부터 시작한다.

int add(int a, int b)

반환 타입인 int가 앞에 오고, 그 뒤에 함수 이름과 파라미터들이 붙는다.

반면 Cpp2에서는 함수 이름부터 적는다.

add: (a: int, b: int) -> int

add라는 이름을 먼저 보고, 받는 값과 리턴 타입을 순서대로 읽을 수 있다.

변수도 NAME: TYPE = VALUE 순서로 적는다.

x: int = 42

변수 이름 x를 앞에 쓰고, 타입과 초깃값이 뒤에 붙는다.

Left-to-right 스타일이라 사람이 읽기도 편하고, 파서 또한 다음에 나올 문법을 쉽게 예측할 수 있다.

Cpp2 문법에서는 Preprocessor나 Macro처럼 파싱을 복잡하게 만드는 기능도 최대한 피하려고 한다. 대신에 기존 C++의 모던한 기능들을 활용한다.

2. Syntax

처음 보면 C++보다 Rust, Swift, TypeScript 같은 언어가 먼저 떠오를 것 같다.

생긴 건 달라도 결국엔 C++로 바뀌는 코드라서 보다 보면 익숙한 개념이 여기저기 나온다.

2.1. 변수 및 객체

변수 선언은 name: type = value 순서로 적는다.

x: int = 42;
name: std::string = "Cpp2";

타입을 쓰기 귀찮으면 Go처럼 :=로 추론하게 할 수도 있다.

x := 42;
name := "Cpp2";

C++은 int x = 42, int x(42), int x{42}처럼 초기화 방법이 여러 개에 난잡하지만, cppfornt는 선언과 초기화 문법이 일관성있게 설계되었다.

2.2. 함수

함수도 변수처럼 이름부터 시작한다.

add: (a: int, b: int) -> int = {
    return a + b;
}

C++에서는 반환 타입부터 읽어야 하지만 Cpp2에서는 add라는 함수가 int 두 개를 받고 int를 반환한다는 순서로 읽을 수 있다.

반환 타입을 추론하게 만들 수도 있다.

square: (x: int) -> _ = {
    return x * x;
}

main도 특별한 건 없다.

main: () -> int = {
    std::println("Hello, CppFront!");
    return 0;
}

파라미터 없이 int를 반환하는 함수로 적으면 된다. 위 코드는 문자열을 출력하고 0을 반환한다.

2.3. 파라미터

파라미터는 Cpp2에서 특히 흥미로운 개념이다.

앞에서 봤듯이 기존 C++에서는 T, T&, const T&, T&&부터 직접 골라야 했다.

Cpp2에서는 포인터나 레퍼런스를 고르기 전에 Parameter의 용도를 먼저 표현한다.

읽기만 한다면 이렇게 작성할 수 있다.

print_val: (in` x: int) = {
    std::println("{}", x);
}

in이 기본값이라 굳이 적을 필요까진 없다.

값을 수정해야 한다면 그때 inout을 사용한다.

increment: (inout x: int) = {
    ++x;
}

함수가 파라미터를 어떻게 사용하는지가 기존 C++의 레퍼런스 표현보다 더 직관적이다.

2.4. 클래스 및 타입

새로운 타입은 type으로 정의한다.

point: type = {
    x: int = 0;
    y: int = 0;

    print: (this) = {
        std::println("({}, {})", this.x, this.y);
    }
}

여기서 봐야할 건 this다. Cpp2에서 멤버함수는 단순히 this라는 파라미터를 하나 더 받는 식이다. 그래서 this에도 in, inout, move 같은 파라미터 규칙을 그대로 적용할 수 있다.

객체를 수정하는 멤버함수는 아래처럼 작성한다.

move_x: (inout this, dx: int) = {
    this.x += dx;
}

C++에서 함수 뒤에 const를 붙이거나 Ref-qualifier를 붙이던 것에 비해 매우 간단하고 명확해졌다.

2.5. 조건문과 제어 흐름

조건문도 보면 if는 크게 따를 게 없다.

if x > 10 {
    std::println("Greater");
}

괄호 없이 조건을 바로 작성한다는 정도다.

C++의 switch보다 강력한 inspect도 있다.

inspect x -> std::println(_);

inspect는 값의 형태를 분석해서 비교하는 패턴 매칭 문법이다.

C++의 std::variant 같은 타입을 다룰 때도 쓸 수 있다.

value: std::variant<int, std::string> = 26;

inspect value {
    i: int => std::cout << "int: " << i;
    s: std::string => std::cout << "string: " << s;
}

기존의 std::visit 대신 inspect를 쓰는 방식이다.

3. Wrapping Up

간단한 소개부터 기본 문법까지 훑어봤다.

처음 보면 이게 C++가 맞나 싶을 정도로 문법이 다르지만, 이 코드도 결국 기존 C++ 코드로 변환되고 C++ 컴파일러와 STL, 다른 라이브러리도 그대로 사용한다.

cppfront는 기존 C++ 위에 새 문법을 올리는 실험이다.

문법 구조는 마음에 든다. 본격적으로 상용화하려면 개인적으론 트랜스파일링 시간보다 문법의 안정성과 코드의 추상화 품질이 어떻게 될지가 가장 중요한 기준인 것 같다.


글 수정
글 공유:

다음 글
C++ Compile Time Reflection