Tagged Union Subsets with Comptime in Zig

Mitchell Hashimoto

Zig의 comptime으로 만드는 Tagged Union 부분 집합

이 글은 Zig comptime 활용 사례 시리즈 중 하나입니다.

Zig는 tagged union을 지원하며1 모든 경우를 처리하지 않고 tagged union에 대해 switch를 사용하면 Zig 컴파일러가 에러를 발생시킵니다. union에 새로운 케이스가 추가될 때 버그를 방지하는 데 도움이 되므로 매우 유용한 기능입니다. 많은 언어들이 유사한 기능을 지원합니다.

하지만 때로는 tagged union의 부분 집합만으로 작업하고 싶을 때가 있습니다. switch 문에서 catch-all 케이스를 사용해 컴파일러의 안전성을 잃고 싶지도 않고, 그렇다고 모든 케이스를 noop으로 처리하고 싶지도 않습니다.

이 글에서는 이러한 상황을 보여주는 실제 사례와 Zig의 comptime을 활용해 이를 우아하게 해결하는 방법을 설명하겠습니다. 실제 사례는 제 터미널 프로젝트에서 가져왔습니다.

실제 사례: 키바인드 액션

다음은 Ghostty에서 키바인드 액션이 표현되는 방식입니다. 키바인드 액션이란 키보드 단축키가 트리거하는 동작을 말합니다. 예를 들어 ctrl+n은 새 창을 여는 액션을 트리거할 수 있습니다.

pub const Action = union(enum) {
  quit: void,
  new_window: void,
  close_window: void,
  close_all_windows: void,
  open_config: void,
  reload_config: void,
  scroll_lines: i16,

  // Many more in reality, but we'll use the above for this blog post.
};

대부분의 키바인딩 액션에는 연결된 데이터가 없지만(void), 일부는 있습니다. 예를 들어 scroll_lines 액션에는 위쪽(음수) 또는 아래쪽(양수)으로 스크롤할 줄 수를 지정하는 i16 값이 연결되어 있습니다.

이 tagged union 방식이 좋은 이유는 키바인드 액션을 실행하는 코드에서 Action union에 대해 switch를 사용할 수 있고, 모든 케이스를 처리하지 않으면 컴파일러가 에러를 내주기 때문입니다. 덕분에 새로운 키바인드 액션을 추가할 때 이를 처리하는 코드도 함께 추가하도록 보장할 수 있습니다.

pub fn performAction(action: Action) void {
  switch (action) {
    .quit => ...,
    .new_window => ...,
    .close_window => ...,
    .close_all_windows => ...,
    .open_config => ...,
    .reload_config => ...,
    .scroll_lines => |amount| ...,
  }
}

이것이 Ghostty가 초기에 키바인드 액션을 구현한 방식입니다.

Union 부분 집합이 필요한 이유

최근 특정 터미널이 아니라 Ghostty 애플리케이션 전체에 적용되는 새로운 키바인드 액션을 추가해야 했습니다. 그 과정에서 performAction을 리팩터링하여 터미널 전용 액션은 터미널 구조체에서, 애플리케이션 전역 액션은 애플리케이션 구조체에서 처리하도록 하고 싶었습니다.

문제는 Action union이 터미널 전용 액션과 애플리케이션 전역 액션을 모두 포함하고 있다는 점이었습니다. 처음에는 이렇게 작성했습니다:

pub fn performAppAction(action: Action) void {
  switch (action) {
    .quit => ...,
    .close_all_windows => ...,
    .open_config => ...,
    .reload_config => ...,
    else => {},
  }
}

이 코드는 동작하지만, 저기 있는 else가 보이시나요? 저것은 catch-all 케이스이며, catch-all 케이스가 있으면 Action union에 새로운 액션을 추가하고 performAppAction에서 처리를 빠뜨려도 컴파일러가 에러를 낼 수 없습니다.

다음으로는 관심 없는 모든 액션을 일일이 나열하고 아무 것도 하지 않도록 했습니다:

pub fn performAppAction(action: Action) void {
  switch (action) {
    .quit => ...,
    .close_all_windows => ...,
    .open_config => ...,
    .reload_config => ...,

    // Do nothing for terminal-specific actions.
    .new_window,
    .close_window,
    .scroll_lines,
    => {},
  }
}

이 방법도 동작은 하지만 몇 가지 문제가 있습니다:

  1. 장황합니다. 관심도 없는 터미널 전용 액션을 모두 나열해야 합니다. 이 블로그 글의 예시에는 6개 정도의 액션만 나오지만 실제로는 훨씬 더 많습니다.

  2. 타입 관점에서 performAppAction이 터미널 전용 액션까지 받을 수 있다는 점이 마음에 들지 않습니다. 타입 시그니처 자체가 애플리케이션 전역 액션만 받는다는 사실과 그 액션이 무엇인지 스스로 설명해주면 더 좋을 것입니다.

  3. 동일한 문제는 (여기서는 보여드리지 않은) performTerminalAction 함수에서도 그대로 나타납니다.

제가 정말로 원하는 것은 다음과 같은 형태입니다:

// NOT REAL ZIG CODE! THIS IS PSUEDO CODE!
const AppAction = union(enum) {
  quit: void,
  // ... other app actions ...
};

const TerminalAction = union(enum) {
  new_window: void,
  // ... other terminal actions ...
};

// Union of both. NOTE THIS IS NOT REAL CODE. THIS IS CONCEPTUAL.
const Action = AppAction | TerminalAction;

“그냥 두 개의 별도 union을 쓰면 되지 않나?”라고 물을 수도 있습니다. 답은 코드베이스의 다른 부분에서는 단일 Action으로 작업해야 하기 때문입니다. 예를 들어 설정 파서는 키바인드에 대한 모든 액션을 파싱할 수 있어야 합니다.

Comptime으로 만드는 Union 부분 집합

Zig의 comptime을 이용해 부분 집합 타입을 만들 수 있습니다.

부분 집합 타입을 먼저 만들고 comptime을 이용해 전체 union 타입을 만드는 방법도 충분히 가능합니다. 독자 여러분의 연습 문제로 한번 시도해 보시길 권합니다. 이 방법도 동일하게 잘 동작하지만, 엣지 케이스와 사용성 측면에서 트레이드오프가 다릅니다.

가장 먼저 알아야 할 것은 각 액션이 어떤 scope에 속하는지입니다. 이를 위해 알려주는 새로운 함수를 하나 만들 수 있습니다:

pub const Scope = enum { app, terminal };

pub fn scope(action: Action) Scope {
  return switch (action) {
    .quit, .close_all_windows, .open_config, .reload_config => .app,
    .new_window, .close_window, .scroll_lines => .terminal,
  };
}

매우 직관적인 함수입니다. 모든 케이스를 포함해야 하므로 장황하지만, 한곳에 집중되어 있어 유지보수하기 쉽습니다.

다음으로 이 함수와 comptime을 이용해 부분 집합 타입을 만들 수 있습니다:

/// Returns a union type that only contains actions that are scoped to
/// the given scope.
pub fn ScopedAction(comptime s: Scope) type {
  const all_fields = @typeInfo(Action).Union.fields;

  // Find all fields that are scoped to s
  var i: usize = 0;
  var fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
  for (all_fields) |field| {
    const action = @unionInit(Action, field.name, undefined);
    if (action.scope() == s) {
      fields[i] = field;
      i += 1;
    }
  }

  // Build our union
  return @Type(.{ .Union = .{
    .layout = .auto,
    .tag_type = null,
    .fields = fields[0..i],
    .decls = &. {},
  } });
}

죄송합니다, 이 부분이 소위 “나머지 부엉이는 알아서 그려라” 순간이라는 걸 압니다2. 하나씩 차근차근 살펴보겠습니다.

pub fn ScopedAction(comptime s: Scope) type {

먼저, Zig에서는 대문자로 시작하는 식별자는 관용적으로 타입을 의미합니다. 컴파일러가 강제하는 규칙은 아니고 그저 관용적인 스타일일 뿐입니다. 함수 이름이 대문자로 시작하면 호출자에게 타입을 반환한다는 신호가 됩니다.

이 함수는 타입을 반환하는 함수입니다. Zig가 제네릭을 구현하는 방식이 바로 이것입니다. Zig는 comptime 코드 실행을 지원하고, comptime에서 실행되는 코드는 언어 전체를 그대로 사용할 수 있기 때문에, comptime에 파라미터를 받아 타입 같은 것을 반환하는 함수를 활용할 수 있습니다.

여기서는 Scope를 받아 해당 scope에 속하는 액션만 포함하는 새로운 액션 union을 생성합니다.

  const all_fields = @typeInfo(Action).Union.fields;

@typeInfo 내장 함수3는 comptime에 타입에 대한 구조화된 정보를 반환합니다. Action에 대해 호출하여 union의 모든 필드를 추출합니다.

  // Find all fields that are scoped to s
  var i: usize = 0;
  var fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
  for (all_fields) |field| {
    const action = @unionInit(Action, field.name, undefined);
    if (action.scope() == s) {
      fields[i] = field;
      i += 1;
    }
  }

이제 Action union의 모든 필드를 순회합니다. 각 필드에 대해 우리가 관심 있는 scope에 속하는지 판단하고, 속한다면 저장합니다.

@unionInit 함수는 comptime에 알려진 필드 이름과 값으로 union 값을 생성하는 내장 함수입니다. 여기서는 undefined(초기화되지 않은 메모리)를 값으로 사용합니다. 값 자체에는 관심이 없고 scope() 함수가 태그만 보기 때문입니다. comptime에 undefined 메모리에 접근하면 컴파일러 에러가 발생하므로 이 방식은 안전합니다.

주어진 타입의 값을 만들어야 scope()를 호출할 수 있습니다. 위의 scope() 함수는 자신이 comptime에 실행되는지 런타임에 실행되는지 전혀 알지 못한다는 점에 주목하세요. 신경 쓰지 않습니다. 그냥 일반적인 함수일 뿐입니다. comptime이 바로 이런 면에서 정말 미치게 멋집니다.

  // Build our union
  return @Type(.{ .Union = .{
    .layout = .auto,
    .tag_type = null,
    .fields = fields[0..i],
    .decls = &. {},
  } });

@Type 내장 함수는 타입을 구체화합니다(새로운 타입을 생성합니다). 여기서는 모아둔 필드들, 즉 우리가 찾는 scope에 해당하는 필드들로 union을 생성합니다.

재미있는 사실 하나, 저는 Zig 컴파일러에서 타입 구체화 로직의 상당 부분을 구현했습니다. @Type을 사용한다면 아마도 제가 작성한 코드 한 줄은 지나치게 될 겁니다.

마침내, 이 모든 과정을 거치면 원래 union의 부분 집합만을 포함하는 새로운 타입을 얻게 됩니다. 다음과 같이 생겼습니다:

pub fn performAppAction(action: ScopedAction(.app)) void {
  switch (action) {
    .quit => ...,
    .close_all_windows => ...,
    .open_config => ...,
    .reload_config => ...,
  }
}

아름답습니다. 컴파일러가 완전성을 강제하는 단순한 switch를 갖게 되었고, 함수 시그니처 자체가 애플리케이션 전역 액션만 받는다는 것을 스스로 설명합니다. Action union에 새로운 액션을 추가하면 scope() 함수를 업데이트하라는 컴파일러 에러가 발생하고, 그 이후에는 다른 모든 것이 그냥 동작합니다.

그리고 혹시 명확하지 않을까 싶어 덧붙이자면, 이 모든 로직은 컴파일 타임에 일어납니다. 이 중 어떤 것도 런타임 비용이 없습니다. 이 로직을 컴파일 타임에만 실행하므로 새로운 런타임 코드도 생성되지 않습니다.

부분 집합으로 변환하기

마지막 과제: 완전한 Action이 주어졌을 때, 이를 ScopedAction(.app)과 같은 타입이 지정된 부분 집합으로 어떻게 변환할 수 있을까요? 아래 함수가 바로 그 역할을 합니다:

pub fn scoped(self: Action, comptime s: Scope) ?ScopedAction(s) {
  switch (self) {
    inline else => |v, tag| {
      // Use comptime to prune out invalid actions
      if (comptime @unionInit(
        Action,
        @tagName(tag),
        undefined,
      ).scope() != s) return null;

      // Initialize our app action
      return @unionInit(
        ScopedAction(s),
        @tagName(tag),
        v,
      );
    },
  }
}

이 함수도 하나씩 살펴보겠습니다.

pub fn scoped(self: Action, comptime s: Scope) ?ScopedAction(s) {

여기서 주목할 점이 몇 가지 있습니다. 첫째, 이 함수는 런타임 파라미터와 comptime 파라미터를 모두 받습니다. 컴파일러는 comptime 파라미터가 컴파일 타임에 알려져 있도록 강제합니다.

scope 파라미터는 반환 값을 결정하는 데 사용되므로 반드시 comptime에 알려져 있어야 합니다. 반환 타입인 ?ScopedAction(s)에서 s를 사용하는 것을 보세요. scomptime 접두사가 없었다면, 컴파일 타임에 알려져야 하는 정보(타입)를 생성하는 데 런타임 값을 사용할 수 없으므로 컴파일러가 에러를 냈을 것입니다.

반환 타입의 ?는 옵셔널 반환 값을 나타냅니다. 즉 반환 값이 null일 수도 있다는 뜻입니다. 이 함수에서는 self 액션이 우리가 찾는 scope에 속하지 않으면 null을 반환합니다.

  switch (self) {
    inline else => |v, tag| {

inline else는 Zig의 특수한 케이스입니다. 명시적으로 처리되지 않은 각 케이스에 대해 런타임 코드를 생성하는 catch-all 케이스입니다. inline이기 때문에 컴파일러는 tag를 컴파일 타임에 알려진 값으로 제공합니다. 액션의 scope를 판단하는 데 이를 사용해야 하므로 이 점이 중요합니다.

inline else는 처음에는 이해하기 어려울 수 있습니다. inline 키워드를 지워보고 컴파일러가 뭐라고 하는지 확인해 보시길 권합니다. 그러고 나서 컴파일러가 하려는 일을 곰곰이 생각해보면 왜 inline이 필요한지 더 명확해질 것입니다.

      // Use comptime to prune out invalid actions
      if (comptime @unionInit(
        Action,
        @tagName(tag),
        undefined,
      ).scope() != s) return null;

여기서도 @unionInit을 사용해 comptime에 알려진 tag로 union 값을 생성합니다. 그런 다음 scope()를 호출해 우리가 찾는 scope에 속하는지 판단합니다. 속하지 않으면 null을 반환합니다. 이 조건문이 comptime이므로, 유효하지 않은 태그로 scoped action을 초기화할 때 타입 에러가 나지 않도록 나머지 블록이 조건부로 컴파일에서 제외됩니다.

저는 comptime으로 조건부로 코드를 비활성화하기에 대해 다룬 블로그 글을 따로 작성했는데, 이 패턴을 설명합니다.

      // Initialize our app action
      return @unionInit(
        ScopedAction(s),
        @tagName(tag),
        v,
      );

마지막으로 초기화된 scoped action을 반환합니다.

이전 섹션과 달리 이 함수는 약간의 런타임 비용이 있습니다. 이 함수는 우리가 찾는 scope에 속하지 않는 액션을 걸러내기 위해 런타임 switch 문을 사용합니다. 이 코드가 어떻게 로우레벨로 변환되는지 디스어셈블해서 확인하지는 않았지만, 변환된 union들이 동일한 메모리 레이아웃을 가지므로 컴파일러가 상당 부분을 최적화해 없앨 것으로 추측합니다. Ghostty에서 비용이 저렴한 키보드 단축키를 무한 루프시키는 합성 케이스로 이 함수를 프로파일링해 봤는데 프로파일에 나타나지 않았으므로 걱정하지 않습니다.

결론

Zig의 comptime은 정말 놀라운 일들을 가능하게 하는 강력한 기능입니다. 이 글에서는 comptime에 타입 생성하기, comptime 여부를 의식하지 않는 함수를 comptime에 호출하기, inline else, @unionInit 등 Zig의 여러 comptime 기능을 조합해 실제 문제를 해결했습니다.

이 글은 솔직히 말해 Zig comptime의 꽤 고급 활용법을 보여줍니다. Zig를 처음 접하는 분들에게는 여기 사용된 기법과 언어 기능이 압도적으로 느껴질 수 있습니다. 저도 이러한 기능에 익숙해지는 데 어느 정도 시간이 걸렸지만, 일단 익숙해지면 믿을 수 없을 정도로 강력합니다.

저는 타입 실용주의자입니다. 정확성을 강제하기 위해 타입 시스템에 기대는 것이 강력한 도구라고 믿습니다. 동시에 일부 언어는 이를 지나치게 밀어붙여 타입 시스템을 부담으로 만든다고 생각합니다. Zig는 제게 적절한 균형을 제공하며, 이 글이 그 점을 잘 보여준다고 생각합니다.

각주

  1. https://ziglang.org/documentation/0.13.0/#Tagged-union

  2. https://knowyourmeme.com/memes/how-to-draw-an-owl

  3. Zig에서 @로 시작하는 함수는 컴파일러에 내장된 함수입니다.

원문은 Mitchell Hashimoto님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.