컴파일타임
컴파일타임 (comptime)
Zig는 표현식이 컴파일타임에 알려져 있는지의 여부를 중요하게 여겨요. 이 개념이 쓰이는 곳은 몇 군데 있으며, 이런 기본 구성 요소들이 언어를 작고, 읽기 쉽고, 강력하게 유지하는 데 사용돼요.
본문
컴파일타임 개념 소개
컴파일타임 매개변수 (Compile-Time Parameters)
컴파일타임 매개변수는 Zig에서 제네릭(generics)을 구현하는 방식이에요. 이것이 바로 컴파일타임 덕 타이핑(compile-time duck typing)이에요.
compile-time_duck_typing.zig
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
fn gimmeTheBiggerFloat(a: f32, b: f32) f32 {
return max(f32, a, b);
}
fn gimmeTheBiggerInteger(a: u64, b: u64) u64 {
return max(u64, a, b);
}
Zig에서 타입은 일급 시민(first-class citizen)이에요. 타입은 변수에 할당할 수 있고, 함수의 매개변수로 넘길 수 있으며, 함수에서 반환할 수도 있어요. 다만 타입은 컴파일타임에 알려진 표현식에서만 쓸 수 있기 때문에, 위 코드에서 매개변수 T에는 반드시 comptime을 표시해야 해요. 컴파일타임 매개변수가 가진 의미는 다음과 같아요.
-
호출 지점(callsite)에서 값이 컴파일타임에 알려져야 하며, 그렇지 않으면 컴파일 오류가 돼요.
-
함수 정의 안에서는 그 값이 컴파일타임에 알려진 것으로 취급돼요.
예를 들어 위 코드에 함수를 하나 더 추가해 볼게요.
test_unresolved_comptime_value.zig
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
test "try to pass a runtime type" {
foo(false);
}
fn foo(condition: bool) void {
const result = max(if (condition) f32 else u64, 1234, 5678);
_ = result;
}
$ zig test test_unresolved_comptime_value.zig
/home/ci/work/zig-bootstrap/zig/doc/langref/test_unresolved_comptime_value.zig:8:28: error: unable to resolve comptime value
const result = max(if (condition) f32 else u64, 1234, 5678);
^~~~~~~~~
/home/ci/work/zig-bootstrap/zig/doc/langref/test_unresolved_comptime_value.zig:8:24: note: argument to comptime parameter must be comptime-known
const result = max(if (condition) f32 else u64, 1234, 5678);
^~~~~~~~~~~~~~~~~~~~~~~~~~~
/home/ci/work/zig-bootstrap/zig/doc/langref/test_unresolved_comptime_value.zig:1:8: note: parameter declared comptime here
fn max(comptime T: type, a: T, b: T) T {
^~~~~~~~
referenced by:
test.try to pass a runtime type: /home/ci/work/zig-bootstrap/zig/doc/langref/test_unresolved_comptime_value.zig:5:8
이건 오류가 돼요. 프로그래머가 런타임에만 알려지는 값을 컴파일타임에 알려져야 하는 함수에 넘기려 했기 때문이에요. 오류를 얻는 또 다른 방법은, 함수를 분석(analysis)할 때 타입 검사기(type checker)를 위반하는 타입을 넘기는 거예요. 이것이 바로 컴파일타임 덕 타이핑의 의미예요. 예를 들어 볼게요.
test_comptime_mismatched_type.zig
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
test "try to compare bools" {
_ = max(bool, true, false);
}
$ zig test test_comptime_mismatched_type.zig
/home/ci/work/zig-bootstrap/zig/doc/langref/test_comptime_mismatched_type.zig:2:18: error: operator > not allowed for type 'bool'
return if (a > b) a else b;
~~^~~
referenced by:
test.try to compare bools: /home/ci/work/zig-bootstrap/zig/doc/langref/test_comptime_mismatched_type.zig:5:12
반대로, 컴파일타임 매개변수가 있는 함수 정의 안에서는 그 값이 컴파일타임에 알려진 값이에요. 즉 원한다면 bool 타입에서도 동작하게 만들 수 있다는 뜻이에요.
test_comptime_max_with_bool.zig
fn max(comptime T: type, a: T, b: T) T {
if (T == bool) {
return a or b;
} else if (a > b) {
return a;
} else {
return b;
}
}
test "try to compare bools" {
try @import("std").testing.expectEqual(true, max(bool, false, true));
}
$ zig test test_comptime_max_with_bool.zig
1/1 test_comptime_max_with_bool.test.try to compare bools...OK
All 1 tests passed.
이게 동작하는 이유는, 조건이 컴파일타임에 알려져 있으면 Zig가 if 표현식을 암묵적으로 인라인 처리하고, 컴파일러가 선택되지 않은 분기(branch)의 분석을 건너뛰는 것을 보장하기 때문이에요. 즉 이 상황에서 max를 위해 실제로 생성되는 함수는 다음과 같은 모습이에요.
compiler_generated_function.zig
fn max(a: bool, b: bool) bool {
{
return a or b;
}
}
컴파일타임에 알려진 값을 다루는 모든 코드는 제거되고, 작업을 수행하는 데 필요한 런타임 코드만 남아요. switch 표현식도 마찬가지로 동작해요. 대상 표현식이 컴파일타임에 알려져 있으면 암묵적으로 인라인 처리돼요.
컴파일타임 변수 (Compile-Time Variables)
Zig에서 프로그래머는 변수를 comptime으로 표시할 수 있어요. 이렇게 하면 그 변수의 모든 로드(load)와 스토어(store)가 컴파일타임에 수행된다는 것을 컴파일러에게 보장하게 돼요. 이 보장을 위반하면 컴파일 오류가 나요. 여기에 루프를 인라인할 수 있다는 점이 더해지면, 컴파일타임에 일부는 평가되고 런타임에 일부는 평가되는 함수를 작성할 수 있어요. 예를 들어 볼게요.
test_comptime_evaluation.zig
const expectEqual = @import("std").testing.expectEqual;
const CmdFn = struct {
name: []const u8,
func: fn (i32) i32,
};
const cmd_fns = [_]CmdFn{
CmdFn{ .name = "one", .func = one },
CmdFn{ .name = "two", .func = two },
CmdFn{ .name = "three", .func = three },
};
fn one(value: i32) i32 {
return value + 1;
}
fn two(value: i32) i32 {
return value + 2;
}
fn three(value: i32) i32 {
return value + 3;
}
fn performFn(comptime prefix_char: u8, start_value: i32) i32 {
var result: i32 = start_value;
comptime var i = 0;
inline while (i < cmd_fns.len) : (i += 1) {
if (cmd_fns[i].name[0] == prefix_char) {
result = cmd_fns[i].func(result);
}
}
return result;
}
test "perform fn" {
try expectEqual(6, performFn('t', 1));
try expectEqual(1, performFn('o', 0));
try expectEqual(99, performFn('w', 99));
}
$ zig test test_comptime_evaluation.zig
1/1 test_comptime_evaluation.test.perform fn...OK
All 1 tests passed.
이 예제는 조금 억지스러운데, 컴파일타임 평가 부분이 꼭 필요하지 않기 때문이에요. 이 코드는 전부 런타임에 처리해도 잘 동작해요. 하지만 실제로는 서로 다른 코드를 생성해 내요. 이 예제에서 performFn 함수는 제공된 prefix_char 값에 따라 세 번 생성돼요.
performFn_1
// From the line:
// expect(performFn('t', 1) == 6);
fn performFn(start_value: i32) i32 {
var result: i32 = start_value;
result = two(result);
result = three(result);
return result;
}
performFn_2
// From the line:
// expect(performFn('o', 0) == 1);
fn performFn(start_value: i32) i32 {
var result: i32 = start_value;
result = one(result);
return result;
}
performFn_3
// From the line:
// expect(performFn('w', 99) == 99);
fn performFn(start_value: i32) i32 {
var result: i32 = start_value;
_ = &result;
return result;
}
이런 일은 디버그 빌드에서도 일어난다는 점을 기억해 두세요. 이건 더 최적화된 코드를 쓰는 방법이 아니라, 컴파일타임에 처리되어야 할 일이 실제로 컴파일타임에 처리되도록 보장하는 방법이에요. 이렇게 하면 더 많은 오류를 잡아낼 수 있고, 다른 언어에서는 매크로(macros)나 생성 코드(generated code), 전처리기(preprocessor)가 있어야 가능한 표현력을 얻을 수 있어요.
컴파일타임 표현식 (Compile-Time Expressions)
Zig에서는 어떤 표현식이 컴파일타임에 알려진 것인지, 런타임에 알려진 것인지가 중요해요. 프로그래머는 comptime 표현식을 사용해 그 표현식이 컴파일타임에 평가된다는 것을 보장할 수 있어요. 이것이 불가능하면 컴파일러는 오류를 내요. 예를 들어 볼게요.
test_comptime_call_extern_function.zig
extern fn exit() noreturn;
test "foo" {
comptime {
exit();
}
}
$ zig test test_comptime_call_extern_function.zig
/home/ci/work/zig-bootstrap/zig/doc/langref/test_comptime_call_extern_function.zig:5:13: error: comptime call of extern function
exit();
~~~~^~
/home/ci/work/zig-bootstrap/zig/doc/langref/test_comptime_call_extern_function.zig:4:5: note: 'comptime' keyword forces comptime evaluation
comptime {
^~~~~~~~
프로그램이 컴파일타임에 exit()(또는 다른 어떤 외부 함수)를 호출하는 것은 말이 안 되기 때문에, 이것은 컴파일 오류예요.
하지만 comptime 표현식은 가끔 컴파일 오류를 일으키는 것 이상의 일을 해요. comptime 표현식 안에서는 다음과 같은 규칙이 적용돼요.
-
모든 변수는 컴파일타임 변수예요.
-
모든
if,while,for,switch표현식은 컴파일타임에 평가되며, 불가능하면 컴파일 오류를 내요. -
모든
return과try표현식은 (함수 자체가 컴파일타임에 호출되지 않는 한) 유효하지 않아요. -
런타임 부수 효과(side effects)가 있거나 런타임 값에 의존하는 모든 코드는 컴파일 오류를 내요.
-
모든 함수 호출은 컴파일러가 그 함수를 컴파일타임에 해석(interpret)하게 하며, 함수가 전역 런타임 부수 효과를 가진 무언가를 하려 하면 컴파일 오류를 내요.
이것은 프로그래머가 함수를 수정하지 않고도 컴파일타임과 런타임 양쪽에서 호출할 수 있는 함수를 만들 수 있다는 뜻이에요. 예제를 살펴볼게요.
test_fibonacci_recursion.zig
const expectEqual = @import("std").testing.expectEqual;
fn fibonacci(index: u32) u32 {
if (index < 2) return index;
return fibonacci(index - 1) + fibonacci(index - 2);
}
test "fibonacci" {
// test fibonacci at run-time
try expectEqual(13, fibonacci(7));
// test fibonacci at compile-time
try comptime expectEqual(13, fibonacci(7));
}
$ zig test test_fibonacci_recursion.zig
1/1 test_fibonacci_recursion.test.fibonacci...OK
All 1 tests passed.
재귀 함수의 기저 사례(base case)를 잊어버리고 테스트를 돌렸다고 상상해 볼게요.
test_fibonacci_comptime_overflow.zig
const expectEqual = @import("std").testing.expectEqual;
fn fibonacci(index: u32) u32 {
//if (index < 2) return index;
return fibonacci(index - 1) + fibonacci(index - 2);
}
test "fibonacci" {
try comptime expectEqual(13, fibonacci(7));
}
$ zig test test_fibonacci_comptime_overflow.zig
/home/ci/work/zig-bootstrap/zig/doc/langref/test_fibonacci_comptime_overflow.zig:5:28: error: overflow of integer type 'u32' with value '-1'
return fibonacci(index - 1) + fibonacci(index - 2);
~~~~~~^~~
/home/ci/work/zig-bootstrap/zig/doc/langref/test_fibonacci_comptime_overflow.zig:5:21: note: called at comptime here (7 times)
return fibonacci(index - 1) + fibonacci(index - 2);
~~~~~~~~~^~~~~~~~~~~
/home/ci/work/zig-bootstrap/zig/doc/langref/test_fibonacci_comptime_overflow.zig:9:43: note: called at comptime here
try comptime expectEqual(13, fibonacci(7));
~~~~~~~~~^~~
컴파일러는 함수를 컴파일타임에 평가하려다 실패한 스택 트레이스(stack trace)를 담은 오류를 만들어 내요. 다행히도 우리는 부호 없는 정수(unsigned integer)를 사용했기 때문에, 0에서 1을 빼려고 하자 불법 동작(Illegal Behavior)이 발생했고, 컴파일러가 그 사실을 알면 이는 항상 컴파일 오류가 돼요. 그런데 만약 부호 있는 정수(signed integer)를 사용했다면 어떻게 됐을까요?
fibonacci_comptime_infinite_recursion.zig
const assert = @import("std").debug.assert;
fn fibonacci(index: i32) i32 {
//if (index < 2) return index;
return fibonacci(index - 1) + fibonacci(index - 2);
}
test "fibonacci" {
try comptime assert(fibonacci(7) == 13);
}
컴파일러는 이 함수를 컴파일타임에 평가하는 데 1000개 이상의 분기(branch)가 걸렸다는 것을 감지하고 오류를 내며 포기해야 해요. 프로그래머가 컴파일타임 계산의 예산을 늘리고 싶다면, @setEvalBranchQuota라는 내장 함수(built-in function)를 사용해 기본값 1000을 다른 값으로 바꿀 수 있어요. 다만 현재 컴파일러에는 설계 결함이 있어서, 제대로 된 동작 대신 스택 오버플로(stack overflow)가 발생해요. 정말 죄송해요. 다음 릴리스 전에 이 문제를 해결할 수 있기를 바라요.
기저 사례를 고쳤지만 expect 줄에 잘못된 값을 넣으면 어떻게 될까요?
test_fibonacci_comptime_unreachable.zig
const assert = @import("std").debug.assert;
fn fibonacci(index: i32) i32 {
if (index < 2) return index;
return fibonacci(index - 1) + fibonacci(index - 2);
}
test "fibonacci" {
try comptime assert(fibonacci(7) == 99999);
}
$ zig test test_fibonacci_comptime_unreachable.zig
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/debug.zig:442:14: error: reached unreachable code
if (!ok) unreachable; // assertion failure
^~~~~~~~~~~
/home/ci/work/zig-bootstrap/zig/doc/langref/test_fibonacci_comptime_unreachable.zig:9:24: note: called at comptime here
try comptime assert(fibonacci(7) == 99999);
~~~~~~^~~~~~~~~~~~~~~~~~~~~~~
네임스페이스 레벨 컴파일타임 표현식
네임스페이스 레벨(어떤 함수 밖)에서는 모든 표현식이 암묵적으로 comptime 표현식이에요. 즉 함수를 사용해 복잡한 상수 데이터를 초기화할 수 있다는 뜻이에요. 예를 들어 볼게요.
test_namespace-level_comptime_expressions.zig
const first_25_primes = firstNPrimes(25);
const sum_of_first_25_primes = sum(&first_25_primes);
fn firstNPrimes(comptime n: usize) [n]i32 {
var prime_list: [n]i32 = undefined;
var next_index: usize = 0;
var test_number: i32 = 2;
while (next_index < prime_list.len) : (test_number += 1) {
var test_prime_index: usize = 0;
var is_prime = true;
while (test_prime_index < next_index) : (test_prime_index += 1) {
if (test_number % prime_list[test_prime_index] == 0) {
is_prime = false;
break;
}
}
if (is_prime) {
prime_list[next_index] = test_number;
next_index += 1;
}
}
return prime_list;
}
fn sum(numbers: []const i32) i32 {
var result: i32 = 0;
for (numbers) |x| {
result += x;
}
return result;
}
test "variable values" {
try @import("std").testing.expectEqual(1060, sum_of_first_25_primes);
}
$ zig test test_namespace-level_comptime_expressions.zig
1/1 test_namespace-level_comptime_expressions.test.variable values...OK
All 1 tests passed.
이 프로그램을 컴파일하면 Zig는 정답이 미리 계산된 채로 상수들을 생성해요. 다음은 생성된 LLVM IR의 일부예요.
@0 = internal unnamed_addr constant [25 x i32] [i32 2, i32 3, i32 5, i32 7, i32 11, i32 13, i32 17, i32 19, i32 23, i32 29, i32 31, i32 37, i32 41, i32 43, i32 47, i32 53, i32 59, i32 61, i32 67, i32 71, i32 73, i32 79, i32 83, i32 89, i32 97]
@1 = internal unnamed_addr constant i32 1060
이 함수들의 문법에 특별한 조치를 취할 필요가 없었다는 점을 주목하세요. 예를 들어 sum 함수는 길이와 값이 런타임에만 알려지는 숫자 슬라이스에 그대로 호출할 수 있어요.
제네릭 데이터 구조 (Generic Data Structures)
Zig는 특별한 케이스 문법을 도입하지 않고 comptime 기능을 사용해 제네릭 데이터 구조를 구현해요. 다음은 제네릭 List 데이터 구조의 예시예요.
generic_data_structure.zig
fn List(comptime T: type) type {
return struct {
items: []T,
len: usize,
};
}
// The generic List data structure can be instantiated by passing in a type:
var buffer: [10]i32 = undefined;
var list = List(i32){
.items = &buffer,
.len = 0,
};
이게 전부예요. 익명 구조체(anonymous struct)를 반환하는 함수일 뿐이에요. 오류 메시지와 디버깅을 위해, Zig는 익명 구조체를 만들 때 호출된 함수 이름과 매개변수로부터 List(i32)라는 이름을 추론해요. 타입에 이름을 명시적으로 주고 싶다면 상수에 할당하면 돼요.
anonymous_struct_name.zig
const Node = struct {
next: ?*Node,
name: []const u8,
};
var node_a = Node{
.next = null,
.name = "Node A",
};
var node_b = Node{
.next = &node_a,
.name = "Node B",
};
이 예제에서 Node 구조체는 자기 자신을 참조해요. 이게 가능한 이유는 모든 최상위(top-level) 선언이 순서에 무관하기 때문이에요. 컴파일러가 구조체의 크기를 결정할 수 있다면 자기 자신을 자유롭게 참조할 수 있어요. 여기서 Node는 포인터로 자기 자신을 참조하는데, 포인터는 컴파일타임에 크기가 명확히 정의되므로 문제없이 동작해요.
사례 연구: Zig에서의 print (Case Study: print in Zig)
이 모든 것을 종합해서, Zig에서 print가 어떻게 동작하는지 살펴볼게요.
print.zig
const print = @import("std").debug.print;
const a_number: i32 = 1234;
const a_string = "foobar";
pub fn main() void {
print("here is a string: '{s}' here is a number: {}\n", .{ a_string, a_number });
}
$ zig build-exe print.zig
$ ./print
here is a string: 'foobar' here is a number: 1234
이것의 구현을 뜯어보면서 어떤 식으로 동작하는지 확인해 볼게요.
poc_print_fn.zig
const Writer = struct {
/// Calls print and then flushes the buffer.
pub fn print(self: *Writer, comptime format: []const u8, args: anytype) anyerror!void {
const State = enum {
start,
open_brace,
close_brace,
};
comptime var start_index: usize = 0;
comptime var state = State.start;
comptime var next_arg: usize = 0;
inline for (format, 0..) |c, i| {
switch (state) {
State.start => switch (c) {
'{' => {
if (start_index < i) try self.write(format[start_index..i]);
state = State.open_brace;
},
'}' => {
if (start_index < i) try self.write(format[start_index..i]);
state = State.close_brace;
},
else => {},
},
State.open_brace => switch (c) {
'{' => {
state = State.start;
start_index = i;
},
'}' => {
try self.printValue(args[next_arg]);
next_arg += 1;
state = State.start;
start_index = i + 1;
},
's' => {
continue;
},
else => @compileError("Unknown format character: " ++ [1]u8{c}),
},
State.close_brace => switch (c) {
'}' => {
state = State.start;
start_index = i;
},
else => @compileError("Single '}' encountered in format string"),
},
}
}
comptime {
if (args.len != next_arg) {
@compileError("Unused arguments");
}
if (state != State.start) {
@compileError("Incomplete format string: " ++ format);
}
}
if (start_index < format.len) {
try self.write(format[start_index..format.len]);
}
try self.flush();
}
fn write(self: *Writer, value: []const u8) !void {
_ = self;
_ = value;
}
pub fn printValue(self: *Writer, value: anytype) !void {
_ = self;
_ = value;
}
fn flush(self: *Writer) !void {
_ = self;
}
};
이것은 개념 증명(proof of concept) 구현이에요. 표준 라이브러리의 실제 함수에는 더 많은 포맷팅 기능이 있어요. 참고로 이것은 Zig 컴파일러에 하드코딩된 것이 아니라, 표준 라이브러리의 유저랜드(userland) 코드예요. 이 함수가 위의 예제 코드에서 분석될 때, Zig는 함수를 부분 평가(partial evaluation)하고 실제로 다음과 같은 모습의 함수를 만들어 내요.
실제로 생성되는 print 함수
pub fn print(self: *Writer, arg0: []const u8, arg1: i32) !void {
try self.write("here is a string: '");
try self.printValue(arg0);
try self.write("' here is a number: ");
try self.printValue(arg1);
try self.write("\n");
try self.flush();
}
printValue는 어떤 타입의 매개변수든 받아들이고, 타입에 따라 서로 다른 일을 하는 함수예요.
poc_printValue_fn.zig
const Writer = struct {
pub fn printValue(self: *Writer, value: anytype) !void {
switch (@typeInfo(@TypeOf(value))) {
.int => {
return self.writeInt(value);
},
.float => {
return self.writeFloat(value);
},
.pointer => {
return self.write(value);
},
else => {
@compileError("Unable to print type '" ++ @typeName(@TypeOf(value)) ++ "'");
},
}
}
fn write(self: *Writer, value: []const u8) !void {
_ = self;
_ = value;
}
fn writeInt(self: *Writer, value: anytype) !void {
_ = self;
_ = value;
}
fn writeFloat(self: *Writer, value: anytype) !void {
_ = self;
_ = value;
}
};
그럼 print에 인자를 너무 많이 넘기면 어떻게 될까요?
test_print_too_many_args.zig
const print = @import("std").debug.print;
const a_number: i32 = 1234;
const a_string = "foobar";
test "print too many arguments" {
print("here is a string: '{s}' here is a number: {}\n", .{
a_string,
a_number,
a_number,
});
}
$ zig test test_print_too_many_args.zig
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/Io/Writer.zig:821:18: error: unused argument in 'here is a string: '{s}' here is a number: {}
'
1 => @compileError("unused argument in '" ++ fmt ++ "'"),
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
referenced by:
print__func_955: /home/ci/work/zig-bootstrap/out/host/lib/zig/std/debug.zig:330:39
test.print too many arguments: /home/ci/work/zig-bootstrap/zig/doc/langref/test_print_too_many_args.zig:7:10
Zig는 프로그래머가 자기 자신의 실수로부터 스스로를 보호할 수 있는 도구를 제공해요. Zig는 포맷 인자가 문자열 리터럴인지 여부는 신경 쓰지 않고, 단지 []const u8로 강제 변환(coerce)될 수 있는 컴파일타임에 알려진 값인지만 확인해요.
print_comptime-known_format.zig
const print = @import("std").debug.print;
const a_number: i32 = 1234;
const a_string = "foobar";
const fmt = "here is a string: '{s}' here is a number: {}\n";
pub fn main() void {
print(fmt, .{ a_string, a_number });
}
$ zig build-exe print_comptime-known_format.zig
$ ./print_comptime-known_format
here is a string: 'foobar' here is a number: 1234
이것도 문제없이 동작해요. Zig는 컴파일러에서 문자열 포매팅을 특별 취급하지 않고, 대신 유저랜드에서 이 작업을 해낼 수 있을 만큼 충분한 힘을 노출해요. 그 과정에서 Zig 위에 매크로 언어나 전처리기 언어 같은 또 다른 언어를 얹지도 않아요. 끝까지 Zig예요.
더 알아보기
더 자세한 내용은 다음 섹션을 참고하세요.
-
inline while (인라인 while)
-
inline for (인라인 for)