Pointers
Pointers (포인터)
Zig의 포인터는 딱 두 가지 종류로 나뉘어요. **단일 항목 포인터(single-item pointer)**와 **다중 항목 포인터(many-item pointer)**죠. 이 둘을 구분하는 기준은 "가리키는 대상이 정확히 하나인가, 아니면 여러 개인가"예요. 각자 할 수 있는 일과 제약이 다르니, 이 차이부터 짚고 넘어갈게요.
본문
포인터의 두 종류
Zig에는 포인터가 두 종류 있습니다. 단일 항목(single-item) 포인터와 다중 항목(many-item) 포인터예요.
*T- 정확히 하나의 항목을 가리키는 단일 항목 포인터.- 역참조 구문 지원:
ptr.* - 슬라이스 구문 지원:
ptr[0..1] - 포인터 뺄셈 지원:
ptr - ptr
- 역참조 구문 지원:
[*]T- 항목 개수를 모르는 다중 항목 포인터.- 인덱스 구문 지원:
ptr[i] - 슬라이스 구문 지원:
ptr[start..end],ptr[start..] - 포인터-정수 산술 지원:
ptr + int,ptr - int - 포인터 뺄셈 지원:
ptr - ptr T는 반드시 크기를 알아야 해서,anyopaque같은 불투명 타입은 쓸 수 없어요.
- 인덱스 구문 지원:
이 타입들은 **배열(Array)**과 **슬라이스(Slice)**와도 밀접하게 연결됩니다.
*[N]T-N개 항목을 가리키는 포인터. 배열을 가리키는 단일 항목 포인터와 같아요.- 인덱스 구문 지원:
array_ptr[i] - 슬라이스 구문 지원:
array_ptr[start..end] len프로퍼티 지원:array_ptr.len- 포인터 뺄셈 지원:
array_ptr - array_ptr
- 인덱스 구문 지원:
[]T- 슬라이스입니다. 내부에[*]T타입 포인터와 길이를 함께 담고 있는 **팻 포인터(fat pointer)**죠.- 인덱스 구문 지원:
slice[i] - 슬라이스 구문 지원:
slice[start..end] len프로퍼티 지원:slice.len
- 인덱스 구문 지원:
단일 항목 포인터는 &x 구문으로 얻습니다.
const expectEqual = @import("std").testing.expectEqual;
test "address of syntax" {
// Get the address of a variable:
const x: i32 = 1234;
const x_ptr = &x;
// Dereference a pointer:
try expectEqual(1234, x_ptr.*);
// When you get the address of a const variable, you get a const single-item pointer.
try expectEqual(*const i32, @TypeOf(x_ptr));
// If you want to mutate the value, you'd need an address of a mutable variable:
var y: i32 = 5678;
const y_ptr = &y;
try expectEqual(*i32, @TypeOf(y_ptr));
y_ptr.* += 1;
try expectEqual(5679, y_ptr.*);
}
test "pointer array access" {
// Taking an address of an individual element gives a
// single-item pointer. This kind of pointer
// does not support pointer arithmetic.
var array = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
const ptr = &array[2];
try expectEqual(*u8, @TypeOf(ptr));
try expectEqual(3, array[2]);
ptr.* += 1;
try expectEqual(4, array[2]);
}
test "slice syntax" {
// Get a pointer to a variable:
var x: i32 = 1234;
const x_ptr = &x;
// Convert to array pointer using slice syntax:
const x_array_ptr = x_ptr[0..1];
try expectEqual(*[1]i32, @TypeOf(x_array_ptr));
// Coerce to many-item pointer:
const x_many_ptr: [*]i32 = x_array_ptr;
try expectEqual(1234, x_many_ptr[0]);
}
$ zig test test_single_item_pointer.zig
1/3 test_single_item_pointer.test.address of syntax...OK
2/3 test_single_item_pointer.test.pointer array access...OK
3/3 test_single_item_pointer.test.slice syntax...OK
All 3 tests passed.
&x를 쓰면 x의 주소를 얻고, x_ptr.*처럼 .*로 역참조해서 값을 읽거나 바꿔요. 여기서 const 변수의 주소를 얻으면 *const i32처럼 const 단일 항목 포인터가 되고, 값을 바꾸고 싶으면 var 변수의 주소를 얻어야 해요 (*i32). 배열의 원소 하나의 주소를 얻는 것도 단일 항목 포인터를 만들며, 이 포인터는 포인터 산술을 지원하지 않아요.
포인터 산술
const expectEqual = @import("std").testing.expectEqual;
test "pointer arithmetic with many-item pointer" {
const array = [_]i32{ 1, 2, 3, 4 };
var ptr: [*]const i32 = &array;
try expectEqual(1, ptr[0]);
ptr += 1;
try expectEqual(2, ptr[0]);
// slicing a many-item pointer without an end is equivalent to
// pointer arithmetic: `ptr[start..] == ptr + start`
try expectEqual(ptr[1..], ptr + 1);
// subtraction between any two pointers except slices based on element size is supported
try expectEqual(1, &ptr[1] - &ptr[0]);
}
test "pointer arithmetic with slices" {
var array = [_]i32{ 1, 2, 3, 4 };
var length: usize = 0; // var to make it runtime-known
_ = &length; // suppress 'var is never mutated' error
var slice = array[length..array.len];
try expectEqual(1, slice[0]);
try expectEqual(4, slice.len);
slice.ptr += 1;
// now the slice is in an bad state since len has not been updated
try expectEqual(2, slice[0]);
try expectEqual(4, slice.len);
}
$ zig test test_pointer_arithmetic.zig
1/2 test_pointer_arithmetic.test.pointer arithmetic with many-item pointer...OK
2/2 test_pointer_arithmetic.test.pointer arithmetic with slices...OK
All 2 tests passed.
Zig는 포인터 산술을 지원합니다. 다만 산술을 할 때는 포인터를 [*]T 타입으로 만들어 그 변수를 증가시키는 게 좋아요. 예를 들어 슬라이스에서 얻은 포인터를 직접 증가시키면 슬라이스가 망가질 수 있거든요. 두 번째 테스트가 바로 그 상황을 보여줘요 — slice.ptr만 1만큼 옮기고 len은 그대로 둬서, 슬라이스가 "나쁜 상태(bad state)"에 빠지는데도 컴파일은 오류를 내지 않고 지나갑니다. 이런 실수를 막으려면 슬라이스의 포인터를 직접 건드리기보다는 처음부터 [*]T로 바꿔서 관리하는 편이 안전해요.
슬라이스가 포인터보다 나은 이유
Zig에서는 보통 센티널 종료 포인터보다 슬라이스를 더 선호해요. 배열이나 포인터는 슬라이스 구문으로 쉽게 슬라이스로 바꿀 수 있죠.
슬라이스는 **경계 검사(bounds checking)**를 해서, 메모리 범위를 벗어나는 Illegal Behavior로부터 보호해 줍니다. 그래서 포인터보다 슬라이스를 더 권하는 거예요.
const expectEqual = @import("std").testing.expectEqual;
test "pointer slicing" {
var array = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var start: usize = 2; // var to make it runtime-known
_ = &start; // suppress 'var is never mutated' error
const slice = array[start..4];
try expectEqual(2, slice.len);
try expectEqual(4, array[3]);
slice[1] += 1;
try expectEqual(5, array[3]);
}
$ zig test test_slice_bounds.zig
1/1 test_slice_bounds.test.pointer slicing...OK
All 1 tests passed.
array[start..4]처럼 범위를 지정해 슬라이스를 만들면, 그 슬라이스는 길이를 스스로 알고 있어요. 그래서 slice[1]처럼 써도 그 위치가 유효한 범위인지 컴파일·런타임에 검사합니다. 이 예시에서 slice[1] += 1은 실제로 원본 배열의 array[3] 값을 바꿔서, 슬라이스가 원본 데이터를 가리킨다는 것도 확인할 수 있어요.
컴파일 타임의 포인터
포인터는 코드가 정의되지 않은 메모리 레이아웃에 의존하지 않는 한 컴파일 타임에도 동작합니다.
const expectEqual = @import("std").testing.expectEqual;
test "comptime pointers" {
comptime {
var x: i32 = 1;
const ptr = &x;
ptr.* += 1;
x += 1;
try expectEqual(3, ptr.*);
}
}
$ zig test test_comptime_pointers.zig
1/1 test_comptime_pointers.test.comptime pointers...OK
All 1 tests passed.
comptime 블록 안에서도 &x로 주소를 얻고 ptr.*로 값을 바꾸는 게 그대로 동작해요. ptr.* += 1로 1을 더하고 x += 1로 또 더하니 최종 값은 3이 되죠.
정수와 포인터 사이 변환
정수 주소를 포인터로 바꾸려면 @ptrFromInt을, 포인터를 정수로 바꾸려면 @intFromPtr을 씁니다.
const expectEqual = @import("std").testing.expectEqual;
test "@intFromPtr and @ptrFromInt" {
const ptr: *i32 = @ptrFromInt(0xdeadbee0);
const addr = @intFromPtr(ptr);
try expectEqual(usize, @TypeOf(addr));
try expectEqual(0xdeadbee0, addr);
}
$ zig test test_integer_pointer_conversion.zig
1/1 test_integer_pointer_conversion.test.@intFromPtr and @ptrFromInt...OK
All 1 tests passed.
Zig은 포인터를 절대 역참조하지 않는 한 컴파일 타임 코드 안에서도 메모리 주소를 보존할 수 있어요.
const expectEqual = @import("std").testing.expectEqual;
test "comptime @ptrFromInt" {
comptime {
// Zig is able to do this at compile-time, as long as
// ptr is never dereferenced.
const ptr: *i32 = @ptrFromInt(0xdeadbee0);
const addr = @intFromPtr(ptr);
try expectEqual(usize, @TypeOf(addr));
try expectEqual(0xdeadbee0, addr);
}
}
$ zig test test_comptime_pointer_conversion.zig
1/1 test_comptime_pointer_conversion.test.comptime @ptrFromInt...OK
All 1 tests passed.
@ptrCast
@ptrCast는 포인터의 **원소 타입(element type)**을 다른 타입으로 바꿉니다. 이때 만들어지는 새 포인터는, 그 포인터를 통과하는 load/store에 따라서 감지할 수 없는 Illegal Behavior를 일으킬 수 있어요. 그래서 가능하다면 다른 종류의 타입 변환을 @ptrCast보다 선호하는 편이 좋습니다.
const std = @import("std");
const native_endian = @import("builtin").target.cpu.arch.endian();
const expectEqual = std.testing.expectEqual;
test "pointer casting" {
const bytes: [4]u8 align(@alignOf(u32)) = .{ 0x10, 0x20, 0x30, 0x40 };
const u32_ptr: *const u32 = @ptrCast(&bytes);
// Because we directly reinterpreted bytes of memory, the `u32` value we
// load from `u32_ptr` depends on the target endian:
switch (native_endian) {
.little => try expectEqual(0x40302010, u32_ptr.*),
.big => try expectEqual(0x10203040, u32_ptr.*),
}
// To instead reinterpret the logical bit representation of `bytes` with no
// dependency on the target endian, use `@bitCast`, which always places
// earlier array elements into less-significant bits:
try expectEqual(0x40302010, @as(u32, @bitCast(bytes)));
}
test "pointer child type" {
// pointer types have a `child` field which tells you the type they point to.
try expectEqual(u32, @typeInfo(*u32).pointer.child);
}
$ zig test test_pointer_casting.zig
1/2 test_pointer_casting.test.pointer casting...OK
2/2 test_pointer_casting.test.pointer child type...OK
All 2 tests passed.
@ptrCast(&bytes)로 바이트 배열을 u32 포인터로 재해석하면, 그 메모리를 그대로 읽기 때문에 대상 CPU의 엔디언(endian)에 따라 값이 달라져요. little-endian이면 0x40302010, big-endian이면 0x10203040이 나오죠. 엔디언과 무관하게 논리적 비트 표현만 재해석하고 싶다면 @bitCast를 쓰면 되고, 이 경우 앞쪽 배열 원소가 항상 덜 중요한 비트(less-significant bits)에 놓입니다. 참고로 포인터 타입에는 child 필드가 있어서, 그 포인터가 가리키는 타입을 @typeInfo(*u32).pointer.child처럼 물어볼 수 있어요.
volatile
load와 store는 기본적으로 부작용(side effect)이 없다고 가정합니다. 그런데 Memory Mapped I/O(MMIO)처럼 특정 load/store에 실제로 부작용이 있어야 한다면 volatile을 써요. 아래 코드에서 mmio_ptr을 통한 load와 store는 모두 실제로 일어나고, 소스 코드에 적힌 순서 그대로 실행된다는 게 보장됩니다.
const expectEqual = @import("std").testing.expectEqual;
test "volatile" {
const mmio_ptr: *volatile u8 = @ptrFromInt(0x12345678);
try expectEqual(*volatile u8, @TypeOf(mmio_ptr));
}
$ zig test test_volatile.zig
1/1 test_volatile.test.volatile...OK
All 1 tests passed.
한 가지 주의할 점은 volatile이 동시성(concurrency)·원자성(Atomics)과는 무관하다는 거예요. Memory Mapped I/O가 아닌 다른 목적으로 volatile을 쓰는 코드를 본다면, 아마 버그일 가능성이 높습니다.
Alignment (정렬)
모든 타입에는 **정렬(alignment)**이 있어요. 타입의 값을 메모리에서 읽거나 쓸 때, 그 메모리 주소가 이 값으로 나누어떨어져야 한다는 뜻의 바이트 수죠. 어떤 타입의 정렬 값이 궁금하면 @alignOf로 알아낼 수 있습니다.
정렬은 CPU 아키텍처에 따라 달라지지만, 항상 2의 거듭제곱이고 1 << 29보다 작아요.
포인터 타입은 바이트 단위의 정렬을 명시적으로 지정할 수 있어요. 지정하지 않으면, 가리키는 타입(underlying type)의 정렬과 같다고 가정합니다.
const std = @import("std");
const builtin = @import("builtin");
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
test "variable alignment" {
var x: i32 = 1234;
try expectEqual(*i32, @TypeOf(&x));
try expect(@intFromPtr(&x) % @alignOf(i32) == 0);
// The implicitly-aligned pointer can be coerced to be explicitly-aligned to
// the alignment of the underlying type `i32`:
const ptr: *align(@alignOf(i32)) i32 = &x;
try expectEqual(1234, ptr.*);
}
$ zig test test_variable_alignment.zig
1/1 test_variable_alignment.test.variable alignment...OK
All 1 tests passed.
&x를 얻으면 기본적으로 *i32처럼 가리키는 타입의 정렬을 따르는 포인터가 돼요. 이를 *align(@alignOf(i32)) i32처럼 명시적으로 정렬을 적은 포인터 타입으로 강제(coerce)할 수도 있습니다. @intFromPtr(&x) % @alignOf(i32) == 0으로 실제 주소가 정렬 요구를 만족하는지도 확인하고 있죠.
*i32가 *const i32로 강제될 수 있는 것과 같은 이치로, 더 큰 정렬을 가진 포인터는 더 작은 정렬을 가진 포인터로 암묵적으로 변환할 수 있어요. 그 반대(작은 정렬 → 큰 정렬)는 안 되죠.
변수와 함수에도 정렬을 지정할 수 있어요. 그렇게 하면 그들을 가리키는 포인터가 지정된 정렬을 갖게 됩니다.
const expectEqual = @import("std").testing.expectEqual;
var foo: u8 align(4) = 100;
test "global variable alignment" {
try expectEqual(4, @typeInfo(@TypeOf(&foo)).pointer.attrs.@"align");
try expectEqual(*align(4) u8, @TypeOf(&foo));
const as_pointer_to_array: *align(4) [1]u8 = &foo;
const as_slice: []align(4) u8 = as_pointer_to_array;
const as_unaligned_slice: []u8 = as_slice;
try expectEqual(100, as_unaligned_slice[0]);
}
fn derp() align(@sizeOf(usize) * 2) i32 {
return 1234;
}
fn noop1() align(1) void {}
fn noop4() align(4) void {}
test "function alignment" {
try expectEqual(1234, derp());
try expectEqual(fn () i32, @TypeOf(derp));
try expectEqual(*align(@sizeOf(usize) * 2) const fn () i32, @TypeOf(&derp));
noop1();
try expectEqual(fn () void, @TypeOf(noop1));
try expectEqual(*align(1) const fn () void, @TypeOf(&noop1));
noop4();
try expectEqual(fn () void, @TypeOf(noop4));
try expectEqual(*align(4) const fn () void, @TypeOf(&noop4));
}
$ zig test test_variable_func_alignment.zig
1/2 test_variable_func_alignment.test.global variable alignment...OK
2/2 test_variable_func_alignment.test.function alignment...OK
All 2 tests passed.
var foo: u8 align(4) = 100;처럼 변수에 정렬을 붙이면 &foo가 *align(4) u8이 돼요. 정렬 4짜리 포인터는 정렬을 잊은 슬라이스 []u8로도 자연스럽게 강제되죠 (as_unaligned_slice). 함수도 fn derp() align(@sizeOf(usize) * 2) i32처럼 정렬을 지정하면, &derp가 그 정렬을 가진 함수 포인터가 됩니다.
가지고 있는 포인터나 슬라이스의 정렬은 작은데, 실제로는 더 큰 정렬을 갖고 있다는 걸 안다면 @alignCast로 더 크게 정렬된 포인터로 바꿀 수 있어요. 런타임에서는 아무것도 하지 않는(no-op) 연산이지만, 대신 **안전 검사(safety check)**를 넣어줍니다.
const std = @import("std");
test "pointer alignment safety" {
var array align(4) = [_]u32{ 0x11111111, 0x11111111 };
const bytes = std.mem.sliceAsBytes(array[0..]);
try std.testing.expectEqual(0x11111111, foo(bytes));
}
fn foo(bytes: []u8) u32 {
const slice4 = bytes[1..5];
const int_slice = std.mem.bytesAsSlice(u32, @as([]align(4) u8, @alignCast(slice4)));
return int_slice[0];
}
$ zig test test_incorrect_pointer_alignment.zig
1/1 test_incorrect_pointer_alignment.test.pointer alignment safety...thread 975727 panic: incorrect alignment
/home/ci/work/zig-bootstrap/zig/doc/langref/test_incorrect_pointer_alignment.zig:10:68: 0x1253b96 in foo (test_incorrect_pointer_alignment.zig)
const int_slice = std.mem.bytesAsSlice(u32, @as([]align(4) u8, @alignCast(slice4)));
^
/home/ci/work/zig-bootstrap/zig/doc/langref/test_incorrect_pointer_alignment.zig:6:48: 0x12539ce in test.pointer alignment safety (test_incorrect_pointer_alignment.zig)
try std.testing.expectEqual(0x11111111, foo(bytes));
^
/home/ci/work/zig-bootstrap/out/host/lib/zig/compiler/test_runner.zig:295:25: 0x1207381 in mainTerminal (test_runner.zig)
if (test_fn.func()) |_| {
^
/home/ci/work/zig-bootstrap/out/host/lib/zig/compiler/test_runner.zig:74:28: 0x1206b52 in main (test_runner.zig)
return mainTerminal(init);
^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:789:88: 0x12032af in callMain (std.zig)
if (fn_info.param_types[0].? == std.process.Init.Minimal) return wrapMain(root.main(.{
^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:248:5: 0x1202c81 in _start (std.zig)
asm volatile (switch (native_arch) {
^
error: the following test command terminated with signal ABRT:
/home/ci/work/zig-bootstrap/out/zig-local-cache/o/576c866ce90db702b7dc4eb04c165766/test --seed=0x7d8a90dd
이 예시는 @alignCast의 안전 검사가 정확히 언제 발동하는지를 보여줘요. bytes[1..5]로 만든 슬라이스는 정렬 1짜리인데, @alignCast로 정렬 4짜리인 척 u32 슬라이스로 바꿔서 읽으려 하죠. 실제 위치가 4의 배수가 아니니 panic: incorrect alignment가 터집니다. @alignCast는 "내가 아는 정보는 이렇다"고 컴파일러를 믿게 만드는 도구라서, 그 정보가 틀리면 안전 검사가 런타임에 잡아내는 거예요.
allowzero
이 포인터 속성은 포인터가 주소 0을 가질 수 있게 해줍니다. 주소 0이 매핑 가능한 freestanding OS 타깃에서만 필요한 경우가 있어요. null 포인터를 표현하고 싶다면 allowzero 대신 Optional Pointer를 쓰는 게 맞습니다. allowzero가 붙은 Optional Pointer는 일반 포인터와 크기가 같지 않아요. 아래 코드 예시에서 만약 포인터에 allowzero 속성이 없었다면, Pointer Cast Invalid Null 패닉이 발생했을 거예요.
const std = @import("std");
const expectEqual = std.testing.expectEqual;
test "allowzero" {
var zero: usize = 0; // var to make to runtime-known
_ = &zero; // suppress 'var is never mutated' error
const ptr: *allowzero i32 = @ptrFromInt(zero);
try expectEqual(0, @intFromPtr(ptr));
}
$ zig test test_allowzero.zig
1/1 test_allowzero.test.allowzero...OK
All 1 tests passed.
@ptrFromInt(0)으로 주소 0을 포인터로 바꿀 때, 타입에 allowzero가 붙어 있으면 그대로 허용됩니다. 일반 포인터라면 주소 0은 null로 해석돼서 이 변환이 금지되는데, *allowzero i32라서 통과하는 거예요.
Sentinel-Terminated Pointers (센티널 종료 포인터)
구문 [*:x]T는 센티널 값에 의해 길이가 결정되는 포인터를 나타냅니다. 이렇게 함으로써 버퍼 오버플로(buffer overflow, 할당된 공간을 넘어 쓰는 것)와 오버리드(overread, 할당된 공간을 넘어 읽는 것)를 막아줍니다.
const std = @import("std");
// This is also available as `std.c.printf`.
pub extern "c" fn printf(format: [*:0]const u8, ...) c_int;
pub fn main() anyerror!void {
_ = printf("Hello, world!\n"); // OK
const msg = "Hello, world!\n";
const non_null_terminated_msg: [msg.len]u8 = msg.*;
_ = printf(&non_null_terminated_msg);
}
$ zig build-exe sentinel-terminated_pointer.zig -lc
/home/ci/work/zig-bootstrap/zig/doc/langref/sentinel-terminated_pointer.zig:11:16: error: expected type '[*:0]const u8', found '*const [14]u8'
_ = printf(&non_null_terminated_msg);
^~~~~~~~~~~~~~~~~~~~~~~~
/home/ci/work/zig-bootstrap/zig/doc/langref/sentinel-terminated_pointer.zig:11:16: note: destination pointer requires '0' sentinel
/home/ci/work/zig-bootstrap/zig/doc/langref/sentinel-terminated_pointer.zig:4:34: note: parameter type declared here
pub extern "c" fn printf(format: [*:0]const u8, ...) c_int;
^~~~~~~~~~~~~
referenced by:
callMain [inlined]: /home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:788:64
callMainWithArgs [inlined]: /home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:729:20
main: /home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:754:28
1 reference(s) hidden; use '-freference-trace=4' to see all references
여기서 msg는 처음부터 "Hello, world!\n"이라는 문자열 리터럴이라서 끝에 0이 붙어 있어요. 그래서 printf("Hello, world!\n")처럼 바로 넘기는 건 문제없이 컴파일됩니다 (첫 번째 호출의 // OK). 반면 두 번째는 msg를 복사해 만든 non_null_terminated_msg라는 배열을 넘기는데, 이 배열은 길이만큼만 있는 배열이고 끝에 0 센티널이 없어요. 그래서 [*:0]const u8을 요구하는 printf에 넘기면 컴파일 에러가 나죠. 핵심 에러 메시지는 expected type '[*:0]const u8', found '*const [14]u8'이고, note: destination pointer requires '0' sentinel이 "여기 도착해야 할 포인터는 0 센티널이 필요해요"라고 알려줍니다.
더 알아보기
포인터는 여기서 다룬 것보다 훨씬 깊은 영역이에요. 좀 더 들여다보고 싶다면 아래 문서를 이어서 보면 좋아요.
- volatile — 부작용이 필요한 load/store에 쓰는 속성.
- Alignment — 정렬과
@alignOf·@alignCast에 대한 상세 내용. - allowzero — 주소 0을 허용하는 포인터 속성.
- Sentinel-Terminated Pointers — 센티널 값으로 길이가 정해지는 포인터.
- Sentinel-Terminated Slices — 길이 정보에 더해 센티널까지 갖는 슬라이스.
- Optional Pointers — null 포인터를 안전하게 표현하는 방법.
- @ptrFromInt · @intFromPtr — 정수와 포인터 사이 변환.
- C Pointers — C 코드와 상호작용할 때의 포인터.