오류 유니언 타입

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

에러 세트 타입은 사실 단독으로 쓰이는 경우보다, 정상 타입과 묶어서 오류 유니언 타입(error union type)으로 쓰는 일이 훨씬 많아요. 에러 세트 타입과 일반 타입을 ! 이진 연산자로 결합하면 오류 유니언 타입이 됩니다.

출처: Zig Documentation

본문

문자열을 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인 게 눈에 띄죠. 이건 이 함수가 unsigned 64비트 정수를 반환하거나, 아니면 에러를 반환한다는 뜻이에요. ! 왼쪽에 에러 세트를 적지 않았으니, 에러 세트는 추론됩니다.

함수 정의 안쪽을 살펴보면 에러를 반환하는 return 문들도 있고, 맨 아래에는 u64를 반환하는 return 문도 있어요. 두 타입 모두 coerce를 거쳐 anyerror!u64로 변합니다.

이 함수를 실제로 어떻게 쓰느냐는 목적에 따라 달라져요. 대략 이런 경우들이 있죠.

  • 에러가 나면 기본값을 제공하고 싶을 때
  • 에러가 나면 같은 에러를 그대로 돌려주고 싶을 때
  • 절대 에러가 나지 않을 거라는 확신이 있어 무조건 풀고(unwrap) 싶을 때
  • 가능한 에러마다 서로 다른 처리를 하고 싶을 때

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 이진 연산자 오른쪽에 오는 값의 타입은 unwrap된 오류 유니언 타입과 맞아야 해요. 아니면 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
}

에러가 나면 그 에러를 그대로 돌려주고, 아니면 함수 로직을 이어가고 싶을 땐 이렇게 하죠.

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는 오류 유니언 표현식을 평가해요. 만약 그 값이 에러라면, 현재 함수에서 같은 에러로 반환하고요. 아니라면 unwrap된 값을 결과로 만들어요.

어떤 표현식이 절대 에러가 되지 않을 거라는 걸 완전히 확신하는 경우도 있을 거예요. 그럴 땐 이렇게 할 수 있어요.

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

여기서는 "1234"가 반드시 잘 파싱된다는 걸 알고 있으니, 오른쪽에 unreachable 값을 넣었어요. unreachable은 safety-checked 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 케이스에서 잡을 수 있는데, 이때 에러 세트는 더 좁아져요.

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
    }
}

에러 처리를 이야기할 때 빼놓을 수 없는 게 defer 문이에요. 무조건 실행되는 defer와 달리, Zig에는 errdefer가 있어요. 이건 함수가 블록에서 에러와 함께 반환될 때에만, 블록을 벗어나는 경로에서 deferred 표현식을 평가해요.

예시를 볼게요.

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;
}

이 방식의 멋진 점은, 모든 반환 경로를 하나하나 확인하느라 장황해지고 머리가 복잡해지는 일 없이도 견고한 에러 처리를 얻을 수 있다는 거예요. 해제(deallocation) 코드는 언제나 할당 코드 바로 뒤에 따라와요.

에러 처리에 관한 다른 몇 가지 팁도 있어요.

  • 이 원시 기능들은 충분히 표현력이 좋아서, 에러 확인을 빠뜨리는 것 자체를 컴파일 에러로 만드는 게 완전히 실용적이에요. 정말로 에러를 무시하고 싶다면 catch unreachable을 붙이면 되는데, 가정이 틀렸을 때 Debug와 ReleaseSafe 모드에서 크래시가 나는 추가 이득도 얻어요.
  • Zig는 에러 타입을 이해하기 때문에, 에러가 일어나지 않는 쪽을 유리하게 분기(branch)에 가중치를 미리 줄 수 있어요. 다른 언어에서는 볼 수 없는 작은 최적화 이점이에요.

함께 보기: defer · if · switch

오류 유니언은 ! 이진 연산자로 만들어요. 컴파일 타임 리플렉션을 사용하면 오류 유니언의 자식(child) 타입에도 접근할 수 있어요.

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.

|| 연산자로 두 에러 세트를 합칠 수 있어요. 합쳐진 에러 세트는 양쪽 에러 세트의 에러를 모두 담아요. 왼쪽에 있는 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.

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.

함수가 추론된 에러 세트를 가지면 그 함수는 제네릭이 되어서, 함수 포인터를 얻는다거나 빌드 대상마다 일관된 에러 세트를 가진다거나 하는 일이 까다로워져요. 게다가 추론된 에러 세트는 재귀와도 호환되지 않아요.

이런 상황에서는 명시적 에러 세트를 쓰는 게 권장돼요. 보통 빈 에러 세트에서 시작해서, 컴파일 에러가 그 세트를 완성하도록 이끌게 하면 돼요.

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

더 알아보기