컴파일 모델

컴파일 모델 (Compilation Model)

Zig 컴파일이 어떻게 여러 파일을 하나로 묶고, 어떤 코드가 실제로 컴파일되고 어떤 코드가 무시되는지 궁금해진 적 있으신가요? 이 섹션에서는 그 규칙, 즉 컴파일 모델을 한 번에 잡아드릴게요. 모듈이라는 단위에서 시작해서 코드가 '발견'되는 방식, 그리고 표준 라이브러리가 파일에서 찾아내는 특별한 선언들까지 차근차근 살펴볼게요.

출처: Zig Documentation

본문

한 번의 Zig 컴파일은 여러 개의 **모듈(module)**로 나뉘어 있어요. 각 모듈은 여러 Zig 소스 파일의 묶음인데, 그중 하나가 모듈의 **루트 소스 파일(root source file)**이 돼요. 각 모듈은 다른 모듈들을 얼마든지 **의존(depend)**할 수 있고, 이렇게 해서 방향성이 있는 그래프(directed graph)가 만들어져요. 모듈 사이의 의존 순환이 허용된다는 점도 기억해 둘게요. 모듈 A가 모듈 B에 의존한다면, 모듈 A에 속한 어떤 Zig 소스 파일이든 그 모듈의 이름을 써서 @import로 모듈 B의 루트 소스 파일을 가져올 수 있어요. 실은 모듈이란 게 파일시스템의 완전히 다른 곳에 있을지도 모르는 Zig 소스 파일 하나를 임포트하기 위한 별명(alias) 역할을 한다고 보면 돼요.

zig build-exe로 컴파일하는 간단한 Zig 프로그램은 핵심 모듈 두 개를 가져요. 내 코드가 들어 있는 모듈(흔히 'main' 또는 'root' 모듈이라고 불러요)과 표준 라이브러리예요. 내 모듈은 'std'라는 이름으로 표준 라이브러리 모듈에 의존하는데, 그 덕분에 @import("std")라고 쓸 수 있는 거예요! 사실 Zig 컴파일에 참여하는 모든 모듈은 — 표준 라이브러리 자신까지도 — 'std'라는 이름으로 표준 라이브러리 모듈에 암묵적으로 의존한답니다.

'루트 모듈'(위 예시에서 내가 제공한 모듈)에는 특별한 성질이 하나 있어요. 표준 라이브러리처럼, 모든 모듈(자기 자신을 포함해서)에 암묵적으로 노출되는데, 이번엔 'root'라는 이름으로 노출돼요. 그래서 @import("root")는 항상 내 'main' 소스 파일을 @import한 것과 같아요. 그 파일 이름이 일반적으로 main.zig이지만 꼭 그래야 하는 건 아니에요.

소스 파일 구조체 (Source File Structs)

모든 Zig 소스 파일은 암묵적으로 하나의 struct 선언이에요. 파일 내용 전체가 말 그대로 struct { ... }로 감싸져 있다고 상상해 볼게요. 그 덕분에 파일 최상위에는 선언뿐 아니라 **필드(field)**도 둘 수 있어요:

//! Because this file contains fields, it is a type which is intended to be instantiated, and so
//! is named in TitleCase instead of snake_case by convention.

foo: u32,
bar: u64,

/// `@This()` can be used to refer to this struct type. In files with fields, it is quite common to
/// name the type here, so it can be easily referenced by other declarations in this file.
const TopLevelFields = @This();

pub fn init(val: u32) TopLevelFields {
    return .{
        .foo = val,
        .bar = val * 10,
    };
}

이런 파일은 다른 struct 타입과 똑같이 인스턴스화할 수 있어요. 파일 안에서는 그 파일의 '루트 구조체 타입'을 @This로 가리킬 수 있구요.

파일·선언 발견 방식 (File and Declaration Discovery)

Zig는 어떤 코드가 **의미 분석(semantically analyzed)**되는지, 말하자면 컴파일러가 그 코드를 '들여다보는지' 여부를 아주 중요하게 여겨요. 어떤 코드가 분석되는지는 어떤 파일과 선언이 특정 지점에서부터 '발견(discovered)'되는지에 따라 정해져요. 이 '발견' 과정은 아주 단순한 재귀 규칙 몇 가지로 이뤄져요:

  • @import 호출이 분석되면, 임포트되는 그 파일도 분석돼요.
  • 타입(파일 포함)이 분석되면, 그 안의 모든 comptime·export 선언도 분석돼요.
  • 타입(파일 포함)이 분석되는데, 컴파일이 테스트(test)용이고, 그 타입이 속한 모듈이 컴파일의 루트 모듈이라면, 그 안의 모든 test 선언도 함께 분석돼요.
  • 이름 붙은 선언에 대한 참조(즉 그 선언을 사용하는 코드)가 분석되면, 그 참조 대상 선언도 분석돼요. 선언은 순서와 무관하기에, 이 참조가 대상 선언보다 위나 아래에 있을 수 있고, 아예 다른 파일에 있을 수도 있어요.

다 됐어요! 이 규칙들이 바로 Zig 파일과 선언이 발견되는 방식을 정의해요. 이제 남은 건 이 과정이 어디에서 '시작'하는지 이해하는 것뿐이에요.

그 답은 표준 라이브러리의 루트예요. 모든 Zig 컴파일은 lib/std/std.zig 파일을 분석하는 것으로 시작해요. 이 파일에는 lib/std/start.zig를 임포트하는 comptime 선언이 들어 있고, 그 파일은 다시 @import("root")로 '루트 모듈'을 참조해요. 즉 내가 main 모듈의 루트 소스 파일로 제공한 파일도 실질적으로는 또 하나의 루트가 되는 셈이에요. 표준 라이브러리가 항상 그 파일을 참조하기 때문이에요.

특히 testexport 선언 같은 것들이 확실히 발견되도록 만들고 싶을 때가 많아요. 위 규칙들을 생각하면 자연스러운 전략이 하나 떠오르는데, 바로 comptime 또는 test 블록 안에서 @import를 쓰는 거예요:

comptime {
    // This will ensure that the file 'api.zig' is always discovered (as long as this file is discovered).
    // It is useful if 'api.zig' contains important exported declarations.
    _ = @import("api.zig");

    // We could also have a file which contains declarations we only want to export depending on a comptime
    // condition. In that case, we can use an `if` statement here:
    if (builtin.os.tag == .windows) {
        _ = @import("windows_api.zig");
    }
}

test {
    // This will ensure that the file 'tests.zig' is always discovered (as long as this file is discovered),
    // if this compilation is a test. It is useful if 'tests.zig' contains tests we want to ensure are run.
    _ = @import("tests.zig");

    // We could also have a file which contains tests we only want to run depending on a comptime condition.
    // In that case, we can use an `if` statement here:
    if (builtin.os.tag == .windows) {
        _ = @import("windows_tests.zig");
    }
}

const builtin = @import("builtin");

특별한 루트 선언들 (Special Root Declarations)

루트 모듈의 루트 소스 파일은 항상 @import("root")로 접근할 수 있어요. 그래서 라이브러리들 — Zig 표준 라이브러리를 포함해서 — 이 파일을 프로그램이 자신의 '전역' 정보를 그 라이브러리에 노출하는 자리로 쓰곤 해요. 표준 라이브러리는 이 파일에서 몇 가지 선언을 찾아낼 거예요.

진입점 (Entry Point)

실행 파일(executable)을 빌드할 때 이 파일에서 가장 먼저 찾는 것은 프로그램의 **진입점(entry point)**이에요. 대부분의 경우 main이라는 함수가 그 역할을 하는데, std.start는 중요한 초기화 작업을 끝낸 직후에 이 함수를 호출해요.

다른 방법으로, _start라는 이름의 선언(예를 들어 pub const _start = {};)이 있으면 기본 std.start 로직이 비활성화돼요. 그 대신 내 루트 소스 파일이 저수준 진입점을 필요에 따라 직접 내보낼 수 있게 되구요.

/// `std.start` imports this file using `@import("root")`, and uses this declaration as the program's
/// user-provided entry point. It can return any of the following types:
/// * `void`
/// * `E!void`, for any error set `E`
/// * `u8`
/// * `E!u8`, for any error set `E`
/// Returning a `void` value from this function will exit with code 0.
/// Returning a `u8` value from this function will exit with the given status code.
/// Returning an error value from this function will print an Error Return Trace and exit with code 1.
pub fn main() void {
    std.debug.print("Hello, World!\n", .{});
}

// If uncommented, this declaration would suppress the usual std.start logic, causing
// the `main` declaration above to be ignored.
//pub const _start = {};

const std = @import("std");
$ zig build-exe entry_point.zig
$ ./entry_point
Hello, World!

컴파일 대상이 libc를 링크한다면, main 함수를 선택적으로 C main 함수의 시그니처와 일치하는 export fn으로 만들 수도 있어요:

pub export fn main(argc: c_int, argv: [*]const [*:0]const u8) c_int {
    const args = argv[0..@intCast(argc)];
    std.debug.print("Hello! argv[0] is '{s}'\n", .{args[0]});
    return 0;
}

const std = @import("std");
$ zig build-exe libc_export_entry_point.zig -lc
$ ./libc_export_entry_point
Hello! argv[0] is './libc_export_entry_point'

std.start는 상황에 따라 wWinMain이나 EfiMain 같은 다른 진입점 선언을 쓰기도 해요. 이 선언들에 대한 자세한 내용은 lib/std/start.zig의 로직을 참고해 주세요.

표준 라이브러리 옵션 (Standard Library Options)

표준 라이브러리는 루트 모듈의 루트 소스 파일에서 std_options라는 선언도 찾아요. 이 선언이 있으면 std.Options 타입의 구조체여야 하는데, 이걸로 std.log 구현 같은 표준 라이브러리의 일부 기능을 프로그램이 조정할 수 있어요.

/// The presence of this declaration allows the program to override certain behaviors of the standard library.
/// For a full list of available options, see the documentation for `std.Options`.
pub const std_options: std.Options = .{
    // By default, in safe build modes, the standard library will attach a segfault handler to the program to
    // print a helpful stack trace if a segmentation fault occurs. Here, we can disable this, or even enable
    // it in unsafe build modes.
    .enable_segfault_handler = true,
    // This is the logging function used by `std.log`.
    .logFn = myLogFn,
};

fn myLogFn(
    comptime level: std.log.Level,
    comptime scope: @EnumLiteral(),
    comptime format: []const u8,
    args: anytype,
) void {
    // We could do anything we want here!
    // ...but actually, let's just call the default implementation.
    std.log.defaultLog(level, scope, format, args);
}

const std = @import("std");

패닉 핸들러 (Panic Handler)

표준 라이브러리는 루트 모듈의 루트 소스 파일에서 panic이라는 선언도 찾아요. 이 선언이 있으면 서로 다른 panic 핸들러를 제공하는 Namespace일 거라고 기대해요.

이 네임스페이스의 기본 구현은 std.debug.simple_panic에서 볼 수 있어요.

panic 핸들러가 메시지를 출력하는 방식을 바꾸되, 기본으로 켜져 있는 형식화된(formatted) 안전 패닉은 유지하고 싶다면 std.debug.FullPanic로 쉽게 해결할 수 있어요:

pub fn main() void {
    @setRuntimeSafety(true);
    var x: u8 = 255;
    // Let's overflow this integer!
    x += 1;
}

pub const panic = std.debug.FullPanic(myPanic);

fn myPanic(msg: []const u8, first_trace_addr: ?usize) noreturn {
    _ = first_trace_addr;
    std.debug.print("Panic! {s}\n", .{msg});
    std.process.exit(1);
}

const std = @import("std");
$ zig build-exe panic_handler.zig
$ ./panic_handler
Panic! integer overflow

더 알아보기