라벨이 붙은 switch

라벨이 붙은 switch (Labeled switch)

switch 문에 라벨을 붙일 수 있다는 걸 아시나요? 라벨이 붙은 switchbreakcontinue로 참조할 수 있어요. 특히 continue는 기존 스위치를 다시 실행하는 것처럼 동작해서, 상태 기계 같은 걸 작성할 때 코드를 훨씬 명확하게 만들어 줘요. 어떤 식으로 동작하는지 바로 확인해 볼게요.

출처: Zig Documentation

본문

switch 문에 라벨을 붙이면 그 라벨을 breakcontinue에서 참조할 수 있어요. break는 해당 switch에서 값을 반환하게 되죠.

반면 switch를 대상으로 하는 continue는 반드시 피연산자(operand)를 가져야 해요. 이 continue가 실행되면, 원래의 switch 값 대신 continue의 피연산자를 사용해서 스위치를 다시 실행한 것처럼 일치하는 prong으로 점프해요.

const std = @import("std");

test "switch continue" {
    sw: switch (@as(i32, 5)) {
        5 => continue :sw 4,

        // `continue` can occur multiple times within a single switch prong.
        2...4 => |v| {
            if (v > 3) {
                continue :sw 2;
            } else if (v == 3) {

                // `break` can target labeled loops.
                break :sw;
            }

            continue :sw 1;
        },

        1 => return,

        else => unreachable,
    }
}
$ zig test test_switch_continue.zig
1/1 test_switch_continue.test.switch continue...OK
All 1 tests passed.

첫 번째 continue :sw 4를 보면, 첫 실행에서 초기 값이 5였죠. continue :sw 4가 실행되면서 값 4를 피연산자로 삼아 다시 스위치를 시작해요. 그러니 2...4 prong이 걸리네요. 그 prong 안에서는 v 값에 따라 continue :sw 2continue :sw 1로 다시 점프하고, v == 3일 때는 break :sw로 스위치를 끝내요. 단일 prong 안에서 continue가 여러 번 나타날 수 있다는 점, 그리고 breakswitch를 끝내는 용도로도 쓰인다는 점이 핵심이에요.

이 동작은 의미적으로 다음과 같은 루프와 동등해요.

const std = @import("std");

test "switch continue, equivalent loop" {
    var sw: i32 = 5;
    while (true) {
        switch (sw) {
            5 => {
                sw = 4;
                continue;
            },
            2...4 => |v| {
                if (v > 3) {
                    sw = 2;
                    continue;
                } else if (v == 3) {
                    break;
                }

                sw = 1;
                continue;
            },
            1 => return,
            else => unreachable,
        }
    }
}
$ zig test test_switch_continue_equivalent.zig
1/1 test_switch_continue_equivalent.test.switch continue, equivalent loop...OK
All 1 tests passed.

원래 예시가 continue :sw 4처럼 한 줄로 값을 바꾸고 다시 뛰어드는 걸 표현했다면, 루프 버전에서는 sw = 4; continue;처럼 상태 변수를 바꾸고 루프를 다시 도는 방식이 돼요. 둘은 결과적으로 똑같은 흐름이에요.

이런 방식은 특히 상태 기계(state machine)에서 코드를 명확하게 만들어 줘요. continue :sw .next_state 같은 문법은 모호함이 없고, 명시적이며, 한눈에 바로 이해할 수 있거든요.

하지만 이 기능의 본격적인 동기(motivating example)는 배열의 각 요소에 대해 switch를 거는 경우예요. 단일 switch로 처리하면 명확성과 성능을 함께 높일 수 있어요.

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

const Instruction = enum {
    add,
    mul,
    end,
};

fn evaluate(initial_stack: []const i32, code: []const Instruction) !i32 {
    var buffer: [8]i32 = undefined;
    var stack = std.ArrayList(i32).initBuffer(&buffer);
    try stack.appendSliceBounded(initial_stack);
    var ip: usize = 0;

    return vm: switch (code[ip]) {
        // Because all code after `continue` is unreachable, this branch does
        // not provide a result.
        .add => {
            try stack.appendBounded(stack.pop().? + stack.pop().?);

            ip += 1;
            continue :vm code[ip];
        },
        .mul => {
            try stack.appendBounded(stack.pop().? * stack.pop().?);

            ip += 1;
            continue :vm code[ip];
        },
        .end => stack.pop().?,
    };
}

test "evaluate" {
    const result = try evaluate(&.{ 7, 2, -3 }, &.{ .mul, .add, .end });
    try expectEqual(1, result);
}
$ zig test test_switch_dispatch_loop.zig
1/1 test_switch_dispatch_loop.test.evaluate...OK
All 1 tests passed.

여기선 가상 머신의 명령어를 인덱스로 따라가면서 switch (code[ip])continue :vm code[ip]로 다음 명령어를 계속 처리해요. switch가 하나의 루프처럼 동작하면서 배열의 요소를 하나씩 소비하는 셈이죠. code[ip].end에 도달하면 stack.pop().?가 반환되면서 스위치가 끝나요.

여기에 continue의 피연산자가 comptime으로 알려진 값이면, 관련 case로의 무조건 분기(unconditional branch)로 낮춰질 수 있어요. 이런 분기는 예측이 완벽해서 보통 매우 빠르게 실행돼요.

반대로 피연산자가 런타임에 결정되는 값이면, 각 continue가 조건부 분기를 인라인으로(이상적으로는 점프 테이블을 통해) 심을 수 있어요. 그러면 CPU가 다른 prong과 무관하게 대상을 예측할 수 있죠. 루프 기반으로 낮추면 모든 분기가 같은 디스패치 지점을 거쳐야 해서 분기 예측이 오히려 방해받아요. 라벨이 붙은 switch가 가상 머신 디스패치 같은 곳에서 성능상 이점을 주는 이유가 여기 있어요.

더 알아보기

  • continue가 대상으로 삼을 수 있는 또 다른 구조인 라벨이 붙은 루프는 Zig 메뉴얼의 labeled loops 섹션에서 다뤄요.
  • switch prong을 inline으로 표시해 컴파일 타임 값을 다루는 방법은 메뉴얼의 Inline Switch Prongs 섹션에서 이어서 볼 수 있어요.
  • comptime 값으로 분기를 최적화하는 내용은 메뉴얼의 comptime 섹션에서 다뤄요.