오류(Errors)

오류(Errors)

프로그램을 짜다 보면 '이 작업이 꼭 성공하리라' 장담할 수 없는 순간이 많아요. 파일을 열거나, 메모리를 할당하거나, 숫자를 파싱할 때처럼요. Zig는 이런 실패 가능성을 타입으로 명확히 표현하고, 놓치지 않고 처리하도록 강제합니다. 이번 섹션에서는 오류 집합(Error Set)과 오류 유니온(Error Union), 그리고 try·catch·errdefer 같은 오류 처리 도구들을 하나씩 살펴볼게요.

출처: Zig Documentation

본문

오류 집합 타입(Error Set Type)

오류 집합(Error Set)은 enum과 비슷해요. 다만 한 가지 차이가 있는데, 컴파일 단위 전체에서 오류 이름 하나하나에 0보다 큰 부호 없는 정수가 배정된다는 점이에요. 같은 오류 이름을 여러 번 선언해도 괜찮고, 그럴 경우 똑같은 정수 값을 갖게 돼요.

오류 집합 타입은 기본적으로 u16으로 잡혀요. 다만 --error-limit [num] 명령줄 인자로 최대 오류 개수를 지정해 주면, 그 개수를 표현하는 데 필요한 최소 비트 수의 정수 타입으로 바뀝니다.

오류는 부분집합에서 상위집합으로 coerce할 수 있어요:

const std = @import("std");

const FileOpenError = error{
    AccessDenied,
    OutOfMemory,
    FileNotFound,
};

const AllocationError = error{
    OutOfMemory,
};

test "coerce subset to superset" {
    const err = foo(AllocationError.OutOfMemory);
    try std.testing.expectEqual(FileOpenError.OutOfMemory, err);
}

fn foo(err: AllocationError) FileOpenError {
    return err;
}
$ zig test test_coerce_error_subset_to_superset.zig
1/1 test_coerce_error_subset_to_superset.test.coerce subset to superset...OK
All 1 tests passed.

하지만 반대로 상위집합에서 부분집합으로는 coerce할 수 없어요:

const FileOpenError = error{
    AccessDenied,
    OutOfMemory,
    FileNotFound,
};

const AllocationError = error{
    OutOfMemory,
};

test "coerce superset to subset" {
    foo(FileOpenError.OutOfMemory) catch {};
}

fn foo(err: FileOpenError) AllocationError {
    return err;
}
$ zig test test_coerce_error_superset_to_subset.zig
/home/ci/work/zig-bootstrap/zig/doc/langref/test_coerce_error_superset_to_subset.zig:16:12: error: expected type 'error{OutOfMemory}', found 'error{AccessDenied,FileNotFound,OutOfMemory}'
    return err;
           ^~~
/home/ci/work/zig-bootstrap/zig/doc/langref/test_coerce_error_superset_to_subset.zig:16:12: note: 'error.AccessDenied' not a member of destination error set
/home/ci/work/zig-bootstrap/zig/doc/langref/test_coerce_error_superset_to_subset.zig:16:12: note: 'error.FileNotFound' not a member of destination error set
/home/ci/work/zig-bootstrap/zig/doc/langref/test_coerce_error_superset_to_subset.zig:15:28: note: function return type declared here
fn foo(err: FileOpenError) AllocationError {
                           ^~~~~~~~~~~~~~~
referenced by:
    test.coerce superset to subset: /home/ci/work/zig-bootstrap/zig/doc/langref/test_coerce_error_superset_to_subset.zig:12:8

위 출력에서 보듯 error{OutOfMemory}라는 반환 타입에 AccessDeniedFileNotFound를 넘기려 하자 "destination error set의 멤버가 아니다"라는 컴파일 오류가 나요. 목적지 오류 집합에 없는 오류를 반환할 수 없도록 막아주는 거예요.

값이 딱 하나뿐인 오류 집합을 선언하면서 그 값을 바로 얻는 축약 문법도 있어요:

const err = error.FileNotFound;

위 코드는 다음 코드와 완전히 같아요:

const err = (error{FileNotFound}).FileNotFound;

이 축약은 추론 오류 집합(Inferred Error Sets)을 쓸 때 특히 유용해요.

전역 오류 집합(The Global Error Set)

anyerror는 전역 오류 집합을 가리켜요. 이는 현재 컴파일 단위에 존재하는 모든 오류를 담는 집합, 다시 말해 모든 오류 집합의 합집합이에요.

어떤 오류 집합이든 anyerror로 coerce할 수 있고, 반대로 anyerror의 오류를 특정(비전역) 오류 집합으로 명시적으로 캐스팅할 수도 있어요. 이때는 그 오류 값이 실제로 목적지 오류 집합에 속해 있는지 확인하는 언어 차원의 assert가 들어가요.

전역 오류 집합은 되도록 피하는 게 좋아요. 컴파일러가 어떤 오류가 가능한지 컴파일 타임에 알 수 없게 되거든요. 오류 집합을 컴파일 타임에 아는 편이 문서 생성이나 오류 메시지에 유리해요. 예를 들어 switch에서 가능한 오류 값을 하나라도 빠뜨리면 바로 알려줄 수 있어요.

오류 유니온 타입(Error Union Type)

오류 집합 타입과 일반 타입은 ! 이항 연산자로 결합해 오류 유니온(Error Union) 타입을 만들 수 있어요. 실전에서는 오류 집합 타입 단독보다 오류 유니온 타입을 훨씬 자주 쓰게 될 거예요.

문자열을 64비트 정수로 파싱하는 함수를 예로 들어볼게요:

const std = @import("std");
const maxInt = std.math.maxInt;

pub fn parseU64(buf: []const u8, radix: u8) !u64 {
    var x: u64 = 0;

    for (buf) |c| {
        const digit = charToDigit(c);

        if (digit >= radix) {
            return error.InvalidChar;
        }

        // x *= radix
        var ov = @mulWithOverflow(x, radix);
        if (ov[1] != 0) return error.OverFlow;

        // x += digit
        ov = @addWithOverflow(ov[0], digit);
        if (ov[1] != 0) return error.OverFlow;
        x = ov[0];
    }

    return x;
}

fn charToDigit(c: u8) u8 {
    return switch (c) {
        '0'...'9' => c - '0',
        'A'...'Z' => c - 'A' + 10,
        'a'...'z' => c - 'a' + 10,
        else => maxInt(u8),
    };
}

test "parse u64" {
    const result = try parseU64("1234", 10);
    try std.testing.expectEqual(1234, result);
}
$ zig test error_union_parsing_u64.zig
1/1 error_union_parsing_u64.test.parse u64...OK
All 1 tests passed.

반환 타입이 !u64인 걸 눈여겨보세요. 이 함수는 부호 없는 64비트 정수를 반환하거나, 아니면 오류를 반환한다는 뜻이에요. 여기서는 ! 왼쪽의 오류 집합을 비워뒀기 때문에 오류 집합이 추론됩니다.

함수 본문을 보면 중간에 오류를 반환하는 return 문들이 있고, 맨 아래에는 u64 값을 반환하는 return이 있어요. 두 종류 모두 anyerror!u64coerce됩니다.

이 함수를 어떻게 쓰느냐는 목적에 따라 달라져요. 크게 다음 네 가지 경우가 있어요:

  • 오류가 나면 기본값을 쓰고 싶다.
  • 오류가 나면 같은 오류를 그대로 되돌려보내고 싶다.
  • 절대 오류가 나지 않는다는 게 확실해서, 무조건 값을 꺼내고 싶다.
  • 오류 종류마다 서로 다른 처리를 하고 싶다.

catch

기본값을 쓰고 싶다면 catch 이항 연산자를 사용해요:

const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) void {
    const number = parseU64(str, 10) catch 13;
    _ = number; // ...
}

이 코드에서 number는 파싱에 성공한 값이 되거나, 실패했을 때 기본값인 13이 돼요. catch 이항 연산자 오른쪽에 오는 값의 타입은 풀려진(unwrapped) 오류 유니온 타입과 일치해야 하거나, noreturn 타입이어야 해요.

어떤 로직을 먼저 수행한 뒤 catch로 기본값을 만들고 싶다면, catch를 이름 붙은 블록(Blocks)과 조합할 수 있어요:

const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) void {
    const number = parseU64(str, 10) catch blk: {
        // do things
        break :blk 13;
    };
    _ = number; // number is now initialized
}

try

이번에는 오류가 나면 그 오류를 돌려주고, 아니면 계속 진행하고 싶다고 해볼게요:

const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) !void {
    const number = parseU64(str, 10) catch |err| return err;
    _ = number; // ...
}

이 패턴의 축약이 바로 try 표현식이에요:

const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) !void {
    const number = try parseU64(str, 10);
    _ = number; // ...
}

try는 오류 유니온 표현식을 계산해요. 만약 오류라면 같은 오류를 들고 현재 함수에서 return하고, 그렇지 않다면 풀려진 값을 결과로 냅니다.

어쩌면 어떤 표현식이 절대 오류가 되지 않는다는 걸 100% 확신할 수도 있어요. 그럴 땐 이렇게 하면 돼요:

const number = parseU64("1234", 10) catch unreachable;

여기서는 "1234"가 반드시 성공적으로 파싱될 거라고 알고 있으니 오른쪽에 unreachable을 뒀어요. unreachable은 안전 검사가 들어간 불법 동작(Illegal Behavior)을 호출해요. 그래서 DebugReleaseSafe 모드에서는 기본적으로 안전 패닉을 일으켜요. 즉 앱을 디버깅하는 동안 여기서 뜻밖의 오류가 생기기라도 하면 앱이 적절히 크래시합니다.

상황마다 서로 다른 동작을 하고 싶다면 ifswitch 표현식을 조합해요:

fn doAThing(str: []u8) void {
    if (parseU64(str, 10)) |number| {
        doSomethingWithNumber(number);
    } else |err| switch (err) {
        error.Overflow => {
            // handle overflow...
        },
        // we promise that InvalidChar won't happen (or crash in debug mode if it does)
        error.InvalidChar => unreachable,
    }
}

마지막으로, 일부 오류만 처리하고 싶을 수도 있어요. 그럴 땐 처리하지 않은 나머지 오류를 else 케이스에서 잡을 수 있는데, 이때 else에는 더 좁아진 오류 집합이 담겨요:

fn doAnotherThing(str: []u8) error{InvalidChar}!void {
    if (parseU64(str, 10)) |number| {
        doSomethingWithNumber(number);
    } else |err| switch (err) {
        error.Overflow => {
            // handle overflow...
        },
        else => |leftover_err| return leftover_err,
    }
}

변수 캡처 문법을 반드시 써야 해요. 변수가 필요 없다면 _로 캡처하면 되고, 그 경우 switch를 아예 쓰지 않아도 돼요.

fn doADifferentThing(str: []u8) void {
    if (parseU64(str, 10)) |number| {
        doSomethingWithNumber(number);
    } else |_| {
        // do as you'd like
    }
}

errdefer

오류 처리의 또 다른 구성 요소는 defer 문이에요. 무조건 실행되는 defer에 더해, Zig에는 errdefer가 있어요. 이는 블록을 나가는 경로에서, 그 블록을 오류와 함께 빠져나갔을 때에만 지연된 표현식을 평가해요.

예시를 볼게요:

fn createFoo(param: i32) !Foo {
    const foo = try tryToAllocateFoo();
    // now we have allocated foo. we need to free it if the function fails.
    // but we want to return it if the function succeeds.
    errdefer deallocateFoo(foo);

    const tmp_buf = allocateTmpBuffer() orelse return error.OutOfMemory;
    // tmp_buf is truly a temporary resource, and we for sure want to clean it up
    // before this block leaves scope
    defer deallocateTmpBuffer(tmp_buf);

    if (param > 1337) return error.InvalidParam;

    // here the errdefer will not run since we're returning success from the function.
    // but the defer will run!
    return foo;
}

이 방식의 장점은 모든 탈출 경로를 일일이 챙기느라 장황해지고 머리가 아플 일 없이 견고한 오류 처리를 얻는다는 거예요. 해제 코드는 항상 할당 코드 바로 뒤에 붙어 있으니까요.

오류 처리에 관한 몇 가지 더 알아둘 점이 있어요:

  • 이 원시 요소들 덕분에 오류 확인을 빠뜨리는 것 자체가 컴파일 오류가 되도록 하는 게 완전히 실용적이에요. 정말 오류를 무시하고 싶다면 catch unreachable을 붙이면 되고, 그러면 자신의 가정이 틀렸을 때 Debug·ReleaseSafe 모드에서 크래시한다는 추가 이득도 얻어요.
  • Zig는 오류 타입을 이해하기 때문에 오류가 발생하지 않는 쪽에 가중치를 두고 가지치기를 미리 할 수 있어요. 다른 언어에서는 쓸 수 없는 아주 작은 최적화 이득이에요.

관련 내용: defer, if, switch

오류 유니온은 ! 이항 연산자로 만들어져요. 컴파일 타임 리플렉션으로 오류 유니온의 자식 타입에 접근할 수도 있어요:

const expectEqual = @import("std").testing.expectEqual;

test "error union" {
    var foo: anyerror!i32 = undefined;

    // Coerce from child type of an error union:
    foo = 1234;

    // Coerce from an error set:
    foo = error.SomeError;

    // Use compile-time reflection to access the payload type of an error union:
    try comptime expectEqual(i32, @typeInfo(@TypeOf(foo)).error_union.payload);

    // Use compile-time reflection to access the error set type of an error union:
    try comptime expectEqual(anyerror, @typeInfo(@TypeOf(foo)).error_union.error_set);
}
$ zig test test_error_union.zig
1/1 test_error_union.test.error union...OK
All 1 tests passed.

오류 집합 합치기(Merging Error Sets)

|| 연산자로 두 오류 집합을 합칠 수 있어요. 결과 오류 집합은 두 집합의 오류를 모두 담아요. 이때 왼쪽의 doc comment가 오른쪽의 doc comment를 덮어써요. 아래 예시에서 C.PathNotFound의 doc comment는 A doc comment가 돼요.

이 기능은 comptime 분기에 따라 반환 오류 집합이 달라지는 함수에 특히 유용해요. 예를 들어 Zig 표준 라이브러리는 파일을 여는 오류 집합으로 LinuxFileOpenError || WindowsFileOpenError를 사용해요.

const A = error{
    NotDir,

    /// A doc comment
    PathNotFound,
};
const B = error{
    OutOfMemory,

    /// B doc comment
    PathNotFound,
};

const C = A || B;

fn foo() C!void {
    return error.NotDir;
}

test "merge error sets" {
    if (foo()) {
        @panic("unexpected");
    } else |err| switch (err) {
        error.OutOfMemory => @panic("unexpected"),
        error.PathNotFound => @panic("unexpected"),
        error.NotDir => {},
    }
}
$ zig test test_merging_error_sets.zig
1/1 test_merging_error_sets.test.merge error sets...OK
All 1 tests passed.

추론 오류 집합(Inferred Error Sets)

Zig의 많은 함수가 오류를 반환할 수 있기 때문에, Zig는 오류 집합의 추론을 지원해요. 함수의 오류 집합을 추론하게 하려면 반환 타입 앞에 ! 연산자를 붙이면 돼요. !T처럼요:

// With an inferred error set
pub fn add_inferred(comptime T: type, a: T, b: T) !T {
    const ov = @addWithOverflow(a, b);
    if (ov[1] != 0) return error.Overflow;
    return ov[0];
}

// With an explicit error set
pub fn add_explicit(comptime T: type, a: T, b: T) Error!T {
    const ov = @addWithOverflow(a, b);
    if (ov[1] != 0) return error.Overflow;
    return ov[0];
}

const Error = error{
    Overflow,
};

const std = @import("std");

test "inferred error set" {
    if (add_inferred(u8, 255, 1)) |_| unreachable else |err| switch (err) {
        error.Overflow => {}, // ok
    }
}
$ zig test test_inferred_error_sets.zig
1/1 test_inferred_error_sets.test.inferred error set...OK
All 1 tests passed.

추론 오류 집합을 가진 함수는 제네릭이 되기 때문에, 몇 가지 일이 까다로워져요. 예를 들어 함수 포인터를 얻거나, 빌드 대상이 달라져도 일관된 오류 집합을 유지하기가 어려워집니다. 게다가 추론 오류 집합은 재귀(recursion)와도 호환되지 않아요.

그런 상황에서는 명시적 오류 집합을 쓰는 걸 권장해요. 보통 빈 오류 집합으로 시작해서, 컴파일 오류가 알려주는 대로 오류를 추가해 나가면 돼요.

이런 제약은 미래의 Zig 버전에서 해소될 수도 있어요.

오류 반환 추적(Error Return Traces)

오류 반환 추적(Error Return Trace)은 오류가 호출 함수로 반환될 때 거쳐 간 코드의 모든 지점을 보여줘요. 덕분에 어디서나 try를 쓰고도, 오류가 애플리케이션 끝까지 거품처럼 올라오면 무슨 일이 있었는지 알 수 있게 돼요.

pub fn main() !void {
    try foo(12);
}

fn foo(x: i32) !void {
    if (x >= 5) {
        try bar();
    } else {
        try bang2();
    }
}

fn bar() !void {
    if (baz()) {
        try quux();
    } else |err| switch (err) {
        error.FileNotFound => try hello(),
    }
}

fn baz() !void {
    try bang1();
}

fn quux() !void {
    try bang2();
}

fn hello() !void {
    try bang2();
}

fn bang1() !void {
    return error.FileNotFound;
}

fn bang2() !void {
    return error.PermissionDenied;
}
$ zig build-exe error_return_trace.zig
$ ./error_return_trace
error: PermissionDenied
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:34:5: 0x11e75fc in bang1 (error_return_trace.zig)
    return error.FileNotFound;
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:22:5: 0x11e7657 in baz (error_return_trace.zig)
    try bang1();
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:38:5: 0x11e769c in bang2 (error_return_trace.zig)
    return error.PermissionDenied;
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:30:5: 0x11e7777 in hello (error_return_trace.zig)
    try bang2();
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:17:31: 0x11e7885 in bar (error_return_trace.zig)
        error.FileNotFound => try hello(),
                              ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:7:9: 0x11e7965 in foo (error_return_trace.zig)
        try bar();
        ^
/home/ci/work/zig-bootstrap/zig/doc/langref/error_return_trace.zig:2:5: 0x11e7a6c in main (error_return_trace.zig)
    try foo(12);
    ^

이 예시를 자세히 보세요. 이것은 스택 트레이스(stack trace)가 아니에요.

최종적으로 올라온 오류가 PermissionDenied인 걸 볼 수 있어요. 하지만 이 모든 것을 시작한 최초의 오류는 FileNotFound였어요. bar 함수에서 코드가 맨 처음 오류를 처리한 뒤, switch 문에서 다른 오류를 반환했죠. 오류 반환 추적은 이런 흐름을 명확히 보여줘요. 반면 스택 트레이스는 다음과 같은 모습이 될 거예요:

pub fn main() void {
    foo(12);
}

fn foo(x: i32) void {
    if (x >= 5) {
        bar();
    } else {
        bang2();
    }
}

fn bar() void {
    if (baz()) {
        quux();
    } else {
        hello();
    }
}

fn baz() bool {
    return bang1();
}

fn quux() void {
    bang2();
}

fn hello() void {
    bang2();
}

fn bang1() bool {
    return false;
}

fn bang2() void {
    @panic("PermissionDenied");
}
$ zig build-exe stack_trace.zig
$ ./stack_trace
thread 974266 panic: PermissionDenied
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:38:5: 0x11e890a in bang2 (stack_trace.zig)
    @panic("PermissionDenied");
    ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:30:10: 0x11e898c in hello (stack_trace.zig)
    bang2();
         ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:17:14: 0x11e8943 in bar (stack_trace.zig)
        hello();
             ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:7:12: 0x11e8328 in foo (stack_trace.zig)
        bar();
           ^
/home/ci/work/zig-bootstrap/zig/doc/langref/stack_trace.zig:2:8: 0x11e8291 in main (stack_trace.zig)
    foo(12);
       ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:788:64: 0x11e7bbb in callMain (std.zig)
    if (fn_info.param_types.len == 0) return wrapMain(root.main());
                                                               ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:248:5: 0x11e75e1 in _start (std.zig)
    asm volatile (switch (native_arch) {
    ^
(process terminated by signal)

여기서 스택 트레이스는 bar 안의 제어 흐름이 어떻게 hello() 호출에 도달했는지를 설명해 주지 못해요. 알려면 디버거를 열거나 앱을 더 계측해야 하죠. 반면 오류 반환 추적은 오류가 어떻게 거품처럼 올라왔는지를 정확히 보여줘요.

이 디버깅 기능 덕분에 모든 오류 조건을 견고하게 처리하는 코드를 빠르게 반복할 수 있어요. 다시 말해 Zig 개발자는 개발 속도를 높이기 위해 자연스럽게 정확하고 견고한 코드를 쓰게 된다는 뜻이에요.

오류 반환 추적은 Debug 빌드에서는 기본적으로 켜지고, ReleaseFast·ReleaseSafe·ReleaseSmall 빌드에서는 기본적으로 꺼져요.

이 오류 반환 추적 기능을 활성화하는 방법은 몇 가지가 있어요:

  • main에서 오류를 반환한다.
  • 오류가 catch unreachable까지 도달했는데 기본 panic 핸들러를 오버라이드하지 않았다.
  • errorReturnTrace로 현재 반환 추적에 접근한다. std.debug.dumpErrorReturnTrace로 출력할 수 있어요. 이 함수는 오류 반환 추적 지원 없이 빌드하면 컴파일 타임에 알려지는 null을 반환해요.

구현 세부사항(Implementation Details)

성능 비용을 분석하려면 두 경우를 나눠봐야 해요:

  • 오류가 반환되지 않을 때
  • 오류가 반환될 때

오류가 반환되지 않는 경우의 비용은 메모리 쓰기 연산 한 번뿐이에요. 그것도 호출 그래프에서 실패 가능한 함수를 호출하는 첫 번째(그 자체로는 실패 불가능한) 함수, 즉 void를 반환하는 함수가 error를 반환하는 함수를 호출하는 지점에서만 발생해요. 스택 메모리에 이 구조체를 초기화하기 위함이에요:

pub const StackTrace = struct {
    index: usize,
    instruction_addresses: [N]usize,
};

여기서 N은 호출 그래프 분석으로 결정된 최대 함수 호출 깊이예요. 재귀는 무시되고 2로 계산됩니다.

StackTrace에 대한 포인터는 오류를 반환할 수 있는 모든 함수에 비밀 매개변수처럼 전달돼요. 항상 첫 번째 매개변수라서 레지스터에 머물 가능성이 커요.

오류가 발생하지 않는 경로는 여기까지예요. 성능상 실질적으로 공짜나 다름없습니다.

오류를 반환하는 함수의 코드를 생성할 때, Zig는 return 문(오류를 반환하는 return 문에서만) 바로 앞에 다음 함수 호출을 생성해요:

// marked as "no-inline" in LLVM IR
fn __zig_return_error(stack_trace: *StackTrace) void {
    stack_trace.instruction_addresses[stack_trace.index] = @returnAddress();
    stack_trace.index = (stack_trace.index + 1) % N;
}

비용은 수학 연산 2개에 메모리 읽기·쓰기가 몇 번 더해진 정도예요. 접근하는 메모리는 한정적이라 오류가 거품처럼 올라가는 동안 캐시에 머물러 있을 거예요.

코드 크기 비용도 return 문 앞의 함수 호출 하나라 부담이 없어요. 그런데도 저는 __zig_return_error 호출을 꼬리 호출(tail call)로 만들어 코드 크기 비용을 사실상 0으로 만들 계획이 있어요. 오류 반환 추적이 없는 코드에서 return 문이던 것이, 추적이 있는 코드에서는 점프(jump) 명령이 될 수 있거든요.

더 알아보기

오류 처리를 더 깊이 이해하려면 아래 주제들을 함께 보면 좋아요.