라벨이 붙은 switch
라벨이 붙은 switch (Labeled switch)
switch 문에 라벨을 붙일 수 있다는 걸 아시나요? 라벨이 붙은 switch는 break나 continue로 참조할 수 있어요. 특히 continue는 기존 스위치를 다시 실행하는 것처럼 동작해서, 상태 기계 같은 걸 작성할 때 코드를 훨씬 명확하게 만들어 줘요. 어떤 식으로 동작하는지 바로 확인해 볼게요.
본문
switch 문에 라벨을 붙이면 그 라벨을 break나 continue에서 참조할 수 있어요. 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 2나 continue :sw 1로 다시 점프하고, v == 3일 때는 break :sw로 스위치를 끝내요. 단일 prong 안에서 continue가 여러 번 나타날 수 있다는 점, 그리고 break는 switch를 끝내는 용도로도 쓰인다는 점이 핵심이에요.
이 동작은 의미적으로 다음과 같은 루프와 동등해요.
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 섹션에서 다뤄요.switchprong을inline으로 표시해 컴파일 타임 값을 다루는 방법은 메뉴얼의 Inline Switch Prongs 섹션에서 이어서 볼 수 있어요.comptime값으로 분기를 최적화하는 내용은 메뉴얼의 comptime 섹션에서 다뤄요.