Conditionally Disabling Code with Comptime in Zig

Mitchell Hashimoto

Zig에서 comptime으로 코드를 조건부로 비활성화하기

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

이 글은 Zig comptime 활용 사례 시리즈 중 하나입니다.

Zig에는 comptime이라는 매우 강력한 기능이 있습니다. comptime을 이용하면 컴파일 타임에 Zig 코드를 실행할 수 있습니다. 이는 별도의 매크로 언어나 AST 조작이 아니라, 컴파일 타임에 실행되는 일반적인 Zig 코드일 뿐입니다. 유일한 실질적인 제약은 comptime 코드가 사이드 이펙트를 가질 수 없다는 점입니다(시스템 콜 불가, IO 불가 등).

솔직히 처음 Zig을 살펴볼 때는 이 기능이 그저 과장된 기능이라고 생각했습니다. “어차피 컴파일 타임에 뭘 얼마나 정말로 하고 싶을까?”라고 생각했죠. 하지만 규모 있는 프로젝트에 2년간 Zig을 사용해 보니, comptime은 온갖 곳에 쓰이고 있으며 Zig에서 단연 가장 마음에 드는 기능이 되었습니다.

comptime 자체는 방대한 주제입니다. 그래서 comptime의 모든 것을 파고드는 대신, 특히 유용한 패턴 하나를 보여드리려고 합니다. 바로 comptime으로 코드를 조건부로 비활성화하는 방법입니다.


코드를 왜 조건부로 비활성화할까?

코드를 조건부로 비활성화하는 것은 소프트웨어 개발에서 흔한 패턴입니다. 코드를 조건부로 비활성화하고 싶어지는 매우 흔한 이유는 다음과 같습니다:

  1. 플랫폼별 코드: macOS와 Linux에서 구현이 다른 함수를 가지고 있을 수 있습니다.

  2. 디버깅 코드: 디버깅에만 유용한 코드가 있어서 프로덕션 빌드에서는 비활성화하고 싶을 수 있습니다.

  3. 빌드 설정: 빌드 타임 설정에 따라 생략하거나 수정하고 싶은 기능이 있을 수 있습니다.

Python이나 JavaScript 같은 동적 언어에서는 보통 런타임에 간단한 if 문을 사용해 적절한 코드 경로를 선택할 수 있습니다. 동적 언어는 런타임에만 코드를 평가하므로, 동작하지 않을 수 있는 코드 경로를 실행하지 않고 피할 수 있습니다.

하지만 컴파일 언어에서는 컴파일러가 런타임에 실행될 가능성이 있는 모든 코드 경로를 컴파일하고 링크해야 하므로, if 문으로 코드를 조건부로 비활성화할 수 없습니다.

컴파일이 되게 하는 것 외에도, 컴파일 타임에 코드를 생략하면 바이너리 크기를 줄이고 런타임 조건문으로 인한 성능 비용을 피할 수 있다는 이점도 있습니다.


Zig 외 언어들의 접근 방식

다른 컴파일 언어들이 이 문제를 어떻게 다루는지 살펴보겠습니다. 관심이 없다면 이 부분은 건너뛰고 Zig의 comptime으로 이 문제를 어떻게 해결하는지 보여주는 다음 섹션으로 넘어가셔도 됩니다.

C 계열 언어

C 계열 언어에서는 전처리기를 이용해 코드를 조건부로 비활성화하는 경우를 흔히 볼 수 있습니다. 예시는 다음과 같습니다:

#define FLAG

void my_function() {
    #ifdef FLAG
    // This code will only be included if FLAG is defined.
    #else
    // This code will only be included otherwise.
    #endif
}

이는 사실상 컴파일 타임에 코드를 템플릿화하는 것입니다. 전처리기가 먼저 실행되어 코드를 (텍스트 수준에서) 변환한 뒤, 컴파일러가 코드를 컴파일합니다. 첫 번째 큰 단점은 전처리기가 자체 문법과 규칙을 가진 별도의 언어라는 점입니다.

두 번째 큰 단점은 전처리기의 기능이 극히 제한적이라는 점입니다. C 프로그래머들은 보통 올바른 전처리기 정의를 생성하기 위해 제대로 된 빌드 시스템에 의존합니다. 빌드 시스템은 보통 이러한 정의를 생성하기 위해 자체 언어를 사용합니다.

Go

Go에서는 파일 단위로 빌드 태그를 이용해 컴파일 타임에 코드를 생략합니다. 예시는 다음과 같습니다:

// +build mytag

func myFunction() {
    // This code will only be included if the file is built with the "mytag"
    // build tag.
}

플랫폼별 코드의 경우 파일 이름 접미사를 사용할 수도 있습니다. Go 튜토리얼이 아니므로 자세히는 다루지 않겠지만, 요점은 조건부 컴파일이 파일 단위로 이루어진다는 것입니다.

이 접근 방식에 대해서는 주관적인 의견이 다양합니다. 객관적으로 좋지 않은 부분은 태그 포함 여부를 결정하기 위해 여전히 일종의 전처리기 언어를 사용해야 하고, 이는 보통 빌드 시스템이나 Makefile 등으로 떠넘겨진다는 점이라고 생각합니다.


Zig Comptime

Zig의 comptime을 이용하면 Zig 자체를 사용해 코드를 조건부로 비활성화할 수 있습니다. 플랫폼별 코드의 가장 간단한 예시는 다음과 같습니다:

const builtin = @import("builtin");

fn myFunction() void {
    if (comptime builtin.os.tag == .macos) {
        // This code will only be included if the target OS is macOS.
        return;
    }

    // This code will be included for all other operating systems.
}

가장 먼저 주목할 점은 코드가 일반적인 Zig 코드라는 것입니다. 전처리기 언어나 특수 문법이 없습니다. 두 번째로, Zig 컴파일러는 if 문이 종료 조건으로 끝난다는 것을 알 만큼 똑똑해서, 조건이 자명하게 참인 경우 그 이후의 어떤 것도 컴파일할 필요가 없다는 것을 안다는 점입니다. 이 경우 타깃 OS가 macOS라면 컴파일러는 첫 번째 블록만 컴파일합니다.

comptime 키워드에 대한 참고: 위의 comptime 키워드는 사실 필요하지 않습니다. 모든 가용 정보가 컴파일 타임에 알려져 있다면 Zig는 자동으로 조건을 컴파일 타임에 평가합니다. builtin은 모두 컴파일 타임 상수이므로 comptime 키워드는 중복됩니다. 예시를 더 명확하게 만들기 위해 넣었습니다.

다음은 제가 작업 중인 실제 프로젝트에서 가져온 더 복잡한 예시입니다:

if ((comptime adwaita.versionAtLeast(1, 4, 0)) and
    adwaita.enabled(&config) and
    adwaita.versionAtLeast(1, 4, 0)) {
    // This code will only be included if we're building with
    // libadwaita available and the version is at least 1.4.0
    // at both build and runtime.
}

이 예시는 comptime이 일반적인 Zig 코드라는 점의 강력함을 보여줍니다. 이 경우 comptime 조건과 non-comptime 조건을 섞어 쓰고 있습니다. 첫 번째 조건은 comptime이고 나머지는 런타임 조건입니다.

첫 번째 comptime 조건은 빌드 설정에서 libadwaita가 활성화되어 있고, 우리가 가지고 있는 버전이 빌드 타임에 최소 1.4.0 이상인지 확인합니다. 이 조건이 거짓이면 Zig 컴파일러는 나머지 조건들이 블록을 포함시키는 결과로 이어질 수 없다는 것을 알기 때문에, 나머지 블록을 전혀 컴파일하지 않습니다.

중요한 점은, 라이브러리를 사용할 수 없거나 버전이 맞지 않거나, 더 새로운 버전의 라이브러리에만 존재하는 타입을 참조하더라도, 런타임 조건과 코드 블록 안에서 adwaita 라이브러리를 사용해도 컴파일러 오류가 발생하지 않는다는 것입니다.

왜 두 번의 adwaita.versionAtLeast(1, 4, 0) 호출이 있을까요? 첫 번째는 빌드 타임에 libadwaita를 사용할 수 있고 버전이 최소 1.4.0 이상인지 확인하는 빌드 타임 검사입니다. 두 번째는 동적으로 링크된 라이브러리가 최소 1.4.0 버전인지 확인하는 런타임 검사입니다. 이를 통해 실행될 시스템보다 더 새로운 버전의 라이브러리가 있는 시스템에서 코드를 컴파일하는 등 다양한 조합이 가능해집니다.

핵심을 더 분명히 하기 위해, 이를 C에서는 어떻게 했을지 생각해 보겠습니다. C에서는 아마 CMake 같은 빌드 시스템을 사용해 일회성 프로그램을 만들고 컴파일을 시도해 전처리기 플래그를 정의할지 말지 결정했을 것입니다. 정말 끔찍하죠. 🤮


Comptime 사용 시 주의할 점

comptime으로 코드를 조건부로 비활성화할 때 주의해야 할 함정이 몇 가지 있습니다. 첫째, comptime 키워드를 사용하지 않으면 comptime은 함수 경계를 넘지 않습니다. 예를 들어, 위 조건문을 함수로 빼면 동작하지 않습니다:

fn myFunction() void {
    if (hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

fn hasFeature() bool {
    return false;
}

위 예시에서 컴파일러는 hasFeature가 자명하게 false임에도 조건문의 두 분기 코드를 모두 빌드합니다. 이를 고치려면 함수 호출 앞에 comptime을 붙이면 됩니다:

fn myFunction() void {
    if (comptime hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

안타깝게도 hasFeature가 comptime 조건과 non-comptime 조건을 섞어 쓰고 있다면 이 방법은 동작하지 않습니다. 그런 경우에는 함수를 인라인해야 합니다:

fn myFunction() void {
    if (hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

inline fn hasFeature() bool {
    return (comptime comptimeCheck()) and runtimeCheck();
}

이제 예상대로 동작합니다. comptime 검사가 거짓이면 컴파일러는 기능별 코드를 포함하지 않습니다.

이 미묘한 동작은 놓치기 매우 쉽습니다. 저는 comptime 검사를 이렇게 할 때마다 프로그램이 두 환경 모두에서 빌드될 수 있도록 항상 CI에 두 분기를 모두 추가합니다.


Comptime은 훌륭합니다

저는 comptime을 좋아합니다. 갖기 전까지는 필요하다는 사실조차 몰랐던 기능이고, 다른 언어로 작업할 때면 그리워지는 기능입니다. 이 블로그 글에서는 comptime의 단 하나의 특정 사용 사례만 보여줬지만, comptime이 이것만 할 수 있다고 해도 여전히 최고의 기능일 것입니다.

comptime을 이용한 조건부 컴파일 덕분에 동일한 파일을 공유하고 많은 코드를 공유하면서도 크로스 플랫폼 코드를 작성할 수 있습니다. 플랫폼별 코드를 관리하기 위해 복잡한 빌드 시스템이나 외부 도구가 필요하지 않습니다. 그냥 Zig 코드를 작성하면 컴파일러가 나머지를 알아서 처리해 줍니다.

다시 말하지만, comptime으로 할 수 있는 일은 이 외에도 정말 많고 comptime에는 그 외에도 강력한 특성이 많습니다. 예를 들어, comptime의 타입은 타깃 시스템에 맞게 일치합니다(따라서 포인터 크기 같은 것이 정확합니다). 더 많은 내용이 있습니다! Zig 문서를 비롯한 다른 블로그 글을 확인해 더 배워 보시고, Zig을 한번 사용해 보시길 권합니다!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글