슬라이스

슬라이스 (Slices)

배열은 타입의 일부로 길이를 들고 있어서 컴파일 타임에 그 길이를 알 수 있어요. 그런데 실행 중에 그때그때 길이가 달라지는 연속된 데이터를 다뤄야 하는 경우라면 어떨까요? 이럴 때 쓰는 게 바로 슬라이스예요. 슬라이스는 포인터와 길이(length) 를 함께 묶어 놓은 타입이에요. 배열과 슬라이스의 차이는, 배열은 길이가 타입의 일부로 컴파일 타임에 정해져 있고 슬라이스의 길이는 런타임에 결정된다는 점이죠. 그래도 둘 다 len 필드로 길이에 접근할 수 있어요.

출처: Zig Documentation

본문

기본 슬라이스

먼저 슬라이스가 실제로 어떻게 생겼는지, 배열과 어떻게 어울려 쓰이는지 볼게요.

test_basic_slices.zig:

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

test "basic slices" {
    var array = [_]i32{ 1, 2, 3, 4 };
    var known_at_runtime_zero: usize = 0;
    _ = &known_at_runtime_zero;
    const slice = array[known_at_runtime_zero..array.len];

    // alternative initialization using result location
    const alt_slice: []const i32 = &.{ 1, 2, 3, 4 };

    try expectEqualSlices(i32, slice, alt_slice);

    try expectEqual([]i32, @TypeOf(slice));
    try expectEqual(&array[0], &slice[0]);
    try expectEqual(array.len, slice.len);

    // If you slice with comptime-known start and end positions, the result is
    // a pointer to an array, rather than a slice.
    const array_ptr = array[0..array.len];
    try expectEqual(*[array.len]i32, @TypeOf(array_ptr));

    // Using the address-of operator on a slice gives a single-item pointer.
    try expectEqual(*i32, @TypeOf(&slice[0]));
    // Using the `ptr` field gives a many-item pointer.
    try expectEqual([*]i32, @TypeOf(slice.ptr));
    try expectEqual(@intFromPtr(slice.ptr), @intFromPtr(&slice[0]));

    // Slices have array bounds checking. If you try to access something out
    // of bounds, you'll get a safety check failure:
    slice[10] += 1;

    // Note that `slice.ptr` does not invoke safety checking, while `&slice[0]`
    // asserts that the slice has len > 0.

    // Empty slices can be created like this:
    const empty1 = &[0]u8{};
    // If the type is known you can use this short hand:
    const empty2: []u8 = &.{};
    try expectEqual(0, empty1.len);
    try expectEqual(0, empty2.len);

    // A zero-length initialization can always be used to create an empty slice, even if the slice is mutable.
    // This is because the pointed-to data is zero bits long, so its immutability is irrelevant.
}
Shell$ zig test test_basic_slices.zig
1/1 test_basic_slices.test.basic slices...thread 974470 panic: index out of bounds: index 10, len 4
/home/ci/work/zig-bootstrap/zig/doc/langref/test_basic_slices.zig:32:10: 0x1255a78 in test.basic slices (test_basic_slices.zig)
    slice[10] += 1;
         ^
/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/8010dfba955e800d4901cafc1ba8c88c/test --seed=0xee00e6e8

슬라이스에는 배열 경계 검사(array bounds checking) 가 적용돼요. 범위를 벗어난 요소에 접근하려 하면 위처럼 안전 검사(safety check) 실패가 나서 패닉이 발생하죠. slice.ptr은 안전 검사를 거치지 않는 반면, &slice[0]은 슬라이스의 len > 0을 단언한다는 점도 기억해 두면 좋아요. 이렇게 슬라이스가 길이 정보까지 스스로 들고 있기 때문에, 우리가 슬라이스를 포인터보다 선호하는 이유가 여기서 나와요.

슬라이스는 문자열을 다룰 때도 핵심 역할을 해요. Zig에는 문자열(string)이라는 개념이 따로 없거든요.

test_slices.zig:

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

test "using slices for strings" {
    // Zig has no concept of strings. String literals are const pointers
    // to null-terminated arrays of u8, and by convention parameters
    // that are "strings" are expected to be UTF-8 encoded slices of u8.
    // Here we coerce *const [5:0]u8 and *const [6:0]u8 to []const u8
    const hello: []const u8 = "hello";
    const world: []const u8 = "世界";

    var all_together: [100]u8 = undefined;
    // You can use slice syntax with at least one runtime-known index on an
    // array to convert an array into a slice.
    var start: usize = 0;
    _ = &start;
    const all_together_slice = all_together[start..];
    // String concatenation example.
    const hello_world = try mem.print(all_together_slice, "{s} {s}", .{ hello, world });

    // Generally, you can use UTF-8 and not worry about whether something is a
    // string. If you don't need to deal with individual characters, no need
    // to decode.
    try expectEqualStrings("hello 世界", hello_world);
}

test "slice pointer" {
    var array: [10]u8 = undefined;
    const ptr = &array;
    try expectEqual(*[10]u8, @TypeOf(ptr));

    // A pointer to an array can be sliced just like an array:
    var start: usize = 0;
    var end: usize = 5;
    _ = .{ &start, &end };
    const slice = ptr[start..end];
    // The slice is mutable because we sliced a mutable pointer.
    try expectEqual([]u8, @TypeOf(slice));
    slice[2] = 3;
    try expectEqual(3, array[2]);

    // Again, slicing with comptime-known indexes will produce another pointer
    // to an array:
    const ptr2 = slice[2..3];
    try expectEqual(1, ptr2.len);
    try expectEqual(3, ptr2[0]);
    try expectEqual(*[1]u8, @TypeOf(ptr2));
}
Shell$ zig test test_slices.zig
1/2 test_slices.test.using slices for strings...OK
2/2 test_slices.test.slice pointer...OK
All 2 tests passed.

문자열 리터럴은 널 종료 u8 배열에 대한 const 포인터이고, 관례적으로 "문자열"이라 부르는 파라미터는 UTF-8로 인코딩된 u8 슬라이스로 받는 게 약속이에요. 위에서 *const [5:0]u8*const [6:0]u8[]const u8로 강제 변환(coerce)되는 걸 확인할 수 있어요. 그리고 배열에 런타임으로 알려진 인덱스가 하나라도 섞인 슬라이스 문법을 쓰면, 배열을 슬라이스로 바꿀 수 있답니다.

배열 포인터도 배열처럼 슬라이스 할 수 있어요. 이때 슬라이스한 원본 포인터가 가변(mutable)이면 결과 슬라이스도 가변이고요. 또 ptr[start..end]처럼 컴파일 타임에 알려진 인덱스로 슬라이스하면, 슬라이스가 아니라 배열 포인터가 나오는 점도 볼게요.

길이로 슬라이스하기 (Slicing by Length)

Zig의 슬라이스 문법은 시작과 끝 인덱스 기준으로만 자르는 걸 지원해요. 그런데 두 번 연속 슬라이스하면, "길이(length)"만큼 자른다는 뜻을 표현할 수 있어요.

[a .. a + b]라는 패턴은 항상 [a..][0..b]로 표현하는 게 더 좋아요. 이유가 두 가지예요:

  • 슬라이스는 메모리에서 포인터와 길이로 표현돼요. 겉보기엔 두 배로 작업하는 것처럼 보이지만, 실제 기계 코드에서는 뺄셈(subtraction)이 오히려 한 번 줄어들어요.
  • a가 런타임에 알려지고 bcomptime에 알려져 있다면, 전자의 [a..a+b]는 슬라이스가 나오지만 후자의 [a..][0..b]배열에 대한 단일 항목 포인터가 나와요. 길이가 컴파일 타임에 정해져 있어서 일반적으로 더 안전한 타입이죠.

slicing_by_length.zig:

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

test "example" {
    var array = [_]i32{ 1, 2, 3, 4 };
    var runtime_start: usize = 1;
    _ = &runtime_start;
    const length = 2;
    const array_ptr_len = array[runtime_start..][0..length];
    try expectEqual(*[length]i32, @TypeOf(array_ptr_len));
}
Shell$ zig test slicing_by_length.zig
1/1 slicing_by_length.test.example...OK
All 1 tests passed.

runtime_start는 런타임 값이라 처음 슬라이스로 나오지만, 그 다음 [0..length]에서 length가 컴파일 타임 상수라서 결과 타입이 배열 포인터 *[length]i32가 되는 모습이에요.

센티널 종료 슬라이스 (Sentinel-Terminated Slices)

[:x]T 문법은 런타임에 알려진 길이를 가지면서, 동시에 그 길이로 인덱스 되는 위치에 센티널(sentinel) 값이 있음을 보장하는 슬라이스예요. 이 타입이 보장하는 건 그 지점에 센티널이 있다는 것뿐, 그 전에 센티널이 없다는 건 보장하지 않아요. 센티널 종료 슬라이스는 len 인덱스까지 요소 접근이 가능하다는 특징이 있어요.

test_null_terminated_slice.zig:

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

test "0-terminated slice" {
    const slice: [:0]const u8 = "hello";

    try expectEqual(5, slice.len);
    try expectEqual(0, slice[5]);
}
Shell$ zig test test_null_terminated_slice.zig
1/1 test_null_terminated_slice.test.0-terminated slice...OK
All 1 tests passed.

"hello"는 길이 5의 문자열이지만, [:0]const u8로 선언하면 slice[5]에 접근해 센티널 값 0을 읽을 수 있어요.

센티널 종료 슬라이스는 슬라이스 문법의 변형 data[start..end :x]로도 만들 수 있어요. 여기서 data는 다중 항목(many-item) 포인터, 배열, 또는 슬라이스이고 x가 센티널 값이에요.

test_null_terminated_slicing.zig:

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

test "0-terminated slicing" {
    var array = [_]u8{ 3, 2, 1, 0, 3, 2, 1, 0 };
    var runtime_length: usize = 3;
    _ = &runtime_length;
    const slice = array[0..runtime_length :0];

    try expectEqual([:0]u8, @TypeOf(slice));
    try expectEqual(3, slice.len);
}
Shell$ zig test test_null_terminated_slicing.zig
1/1 test_null_terminated_slicing.test.0-terminated slicing...OK
All 1 tests passed.

센티널 종료 슬라이싱은 뒤에 있는(backing) 데이터의 센티널 위치에 실제로 그 센티널 값이 있는지를 단언해요. 만약 그렇지 않다면, 안전 검사되는 Illegal Behavior가 발생하죠.

test_sentinel_mismatch.zig:

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

test "sentinel mismatch" {
    var array = [_]u8{ 3, 2, 1, 0 };

    // Creating a sentinel-terminated slice from the array with a length of 2
    // will result in the value `1` occupying the sentinel element position.
    // This does not match the indicated sentinel value of `0` and will lead
    // to a runtime panic.
    var runtime_length: usize = 2;
    _ = &runtime_length;
    const slice = array[0..runtime_length :0];

    _ = slice;
}
Shell$ zig test test_sentinel_mismatch.zig
1/1 test_sentinel_mismatch.test.sentinel mismatch...thread 973811 panic: sentinel mismatch: expected 0, found 1
/home/ci/work/zig-bootstrap/zig/doc/langref/test_sentinel_mismatch.zig:13:24: 0x12539bf in test.sentinel mismatch (test_sentinel_mismatch.zig)
    const slice = array[0..runtime_length :0];
                       ^
/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/381727c70fda1c5b044720a71bb7bf0b/test --seed=0x6bf59229

위 예시는 길이 2로 센티널 위치에 1이 오게 하는데, 센티널 값 0과 맞지 않아 런타임 패닉으로 이어져요.

더 알아보기