옵셔널
옵셔널 (Optional)
Zig가 효율성이나 가독성을 해치지 않으면서 안전성을 챙기는 대표적인 영역이 바로 옵셔널 타입(optional type) 이에요. 값이 있거나 없을 수 있는 상황을 컴파일러가 검사해 주기 때문에, 실행 시간에야 터지는 버그를 미리 잡아줘요.
본문
옵셔널 타입을 상징하는 기호는 물음표(?)예요. 타입 앞에 물음표를 붙이면 그 타입을 옵셔널 타입으로 바꿀 수 있어요. 이렇게요.
// 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.