Pointers

Pointers (포인터)

Zig의 포인터는 딱 두 가지 종류로 나뉘어요. **단일 항목 포인터(single-item pointer)**와 **다중 항목 포인터(many-item pointer)**죠. 이 둘을 구분하는 기준은 "가리키는 대상이 정확히 하나인가, 아니면 여러 개인가"예요. 각자 할 수 있는 일과 제약이 다르니, 이 차이부터 짚고 넘어갈게요.

출처: Zig Documentation

본문

포인터의 두 종류

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 센티널이 필요해요"라고 알려줍니다.

더 알아보기

포인터는 여기서 다룬 것보다 훨씬 깊은 영역이에요. 좀 더 들여다보고 싶다면 아래 문서를 이어서 보면 좋아요.