Conditionally Disabling Code with Comptime in Zig

Mitchell Hashimoto

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

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

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

솔직히 고백하자면, 처음 Zig을 살펴볼 때는 이 기능이 그저 눈속임이라고 생각했습니다. “어차피 comptime에 실제로 하고 싶은 게 얼마나 되겠어?”라고 생각했죠. 제법 규모 있는 프로젝트에 Zig을 사용한 지 2년이 지난 지금, 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 조건과 그렇지 않은 조건을 혼합하고 있습니다. 첫 번째 조건은 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 조건과 그렇지 않은 조건을 혼합하고 있다면 이 방법은 동작하지 않습니다. 이럴 때는 함수를 인라인해야 합니다.

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을 사용해 보시길 바랍니다!

원문은 Mitchell Hashimoto님이 에 게재했습니다.

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