옵셔널

옵셔널 (Optional)

Zig가 효율성이나 가독성을 해치지 않으면서 안전성을 챙기는 대표적인 영역이 바로 옵셔널 타입(optional type) 이에요. 값이 있거나 없을 수 있는 상황을 컴파일러가 검사해 주기 때문에, 실행 시간에야 터지는 버그를 미리 잡아줘요.

출처: Zig Documentation

본문

옵셔널 타입을 상징하는 기호는 물음표(?)예요. 타입 앞에 물음표를 붙이면 그 타입을 옵셔널 타입으로 바꿀 수 있어요. 이렇게요.

// normal integer
const normal_int: i32 = 1234;

// optional integer
const optional_int: ?i32 = 5678;

이제 optional_int 변수는 i32 값이 될 수도 있고, null이 될 수도 있어요.

정수 말고 포인터를 얘기해 볼게요. 널 참조(null reference) 는 많은 런타임 예외의 원인이 되고, 심지어 컴퓨터 과학 최악의 실수라는 말까지 들을 정도로 골칫거리로 여겨져요. Zig에는 이 널 참조가 없어요.

대신 옵셔널 포인터(optional pointer) 를 쓸 수 있어요. 이건 내부적으로 그냥 평범한 포인터로 컴파일되는데, 옵셔널 타입의 널 값으로 주소 0을 쓰기 때문이에요. 그런데도 컴파일러가 당신의 코드를 검사해서, 널일 수 없는 곳에 널을 대입하지 못하게 막아줘요. 그러니 안전성은 챙기면서 성능 손실은 거의 없는 셈이에요.

널이 없다는 게 보통은 코드를 더 장황하게 만드는 단점이 있겠죠? 그런데 실제 C 코드와 Zig 코드를 한번 비교해 볼게요.

작업: malloc을 호출하고, 결과가 null이면 null을 반환한다.

C 코드

// malloc prototype included for reference
void *malloc(size_t size);

struct Foo *do_a_thing(void) {
    char *ptr = malloc(1234);
    if (!ptr) return NULL;
    // ...
}

Zig 코드

// malloc prototype included for reference
extern fn malloc(size: usize) ?[*]u8;

fn doAThing() ?*Foo {
    const ptr = malloc(1234) orelse return null;
    _ = ptr; // ...
}

여기서 Zig는 C보다 최소한 불편하지 않아요. 오히려 더 편할 수도 있어요. 그리고 ptr의 타입은 [*]u8이지 ?[*]u8이 아니라는 점을 눈여겨보세요. orelse 키워드가 옵셔널 타입을 벗겨냈기 때문에, ptr은 함수 안 어디에서든 널이 아님이 보장돼요.

널을 검사하는 흔한 또 하나의 패턴은 이렇게 생겼어요.

C 코드

void do_a_thing(struct Foo *foo) {
    // do some stuff

    if (foo) {
        do_something_with_foo(foo);
    }

    // do some stuff
}

Zig에서도 똑같은 일을 할 수 있어요.

const Foo = struct {};
fn doSomethingWithFoo(foo: *Foo) void {
    _ = foo;
}

fn doAThing(optional_foo: ?*Foo) void {
    // do some stuff

    if (optional_foo) |foo| {
        doSomethingWithFoo(foo);
    }

    // do some stuff
}

다시 강조할 점은, if 블록 안에서 foo는 더 이상 옵셔널 포인터가 아니라는 거예요. 이미 옵셔널이 벗겨진, 널일 수 없는 순수 포인터가 된 상태예요.

이 점이 주는 이점 중 하나는, 포인터를 인자로 받는 함수에 GCC의 __attribute__((nonnull)) 같은 "nonnull" 속성을 붙일 수 있다는 거예요. 최적화기가 포인터 인자가 널이 될 수 없다는 사실을 알면 더 나은 결정을 내릴 수 있는 경우가 있거든요.

옵셔널 타입 (Optional Type)

옵셔널은 타입 앞에 ?를 붙여서 만들어요. 컴파일 타임 반영(compile-time reflection)으로 옵셔널의 하위 타입(child type)에 접근할 수도 있어요.

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

test "optional type" {
    // Declare an optional and coerce from null:
    var foo: ?i32 = null;

    // Coerce from child type of an optional
    foo = 1234;

    // Use compile-time reflection to access the child type of the optional:
    try comptime expectEqual(i32, @typeInfo(@TypeOf(foo)).optional.child);
}

// Shell$ zig test test_optional_type.zig
// 1/1 test_optional_type.test.optional type...OK
// All 1 tests passed.

null

undefined와 마찬가지로 null도 자기만의 타입을 가져요. 그렇기 때문에 null을 쓰는 유일한 방법은 다른 타입으로 캐스팅하는 거예요.

const optional_value: ?i32 = null;

옵셔널 포인터 (Optional Pointers)

옵셔널 포인터는 일반 포인터와 크기가 같다는 것이 보장돼요. 옵셔널의 널 값은 주소 0으로 보장되고요.

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

test "optional pointers" {
    // Pointers cannot be null. If you want a null pointer, use the optional
    // prefix `?` to make the pointer type optional.
    var ptr: ?*i32 = null;

    var x: i32 = 1;
    ptr = &x;

    try expectEqual(1, ptr.?.*);

    // Optional pointers are the same size as normal pointers, because pointer
    // value 0 is used as the null value.
    try expectEqual(@sizeOf(?*i32), @sizeOf(*i32));
}

// Shell$ zig test test_optional_pointer.zig
// 1/1 test_optional_pointer.test.optional pointers...OK
// All 1 tests passed.

더 알아보기 (Learn more)