Zig의 컴파일 타임 구조체 에 특정 타입의 관련 함수를 묶으면, 별도 라이브러리나 언어 기능 없이 명시적인 타입 계약을 갖춘 Trait 패턴 을 구현할 수 있음
기존 동적 인터페이스는 메서드 시그니처를 보장하지만 최적화를 제한하고, comptime 덕 타이핑 은 정적 최적화에 유리하지만 호출자와 구현자가 타입 요구사항을 문서화하고 지켜야 함
Trait는 필요한 연산을 컴파일 타임에 전달하는 의존성 주입 방식으로, 계약을 명확히 하면서 컴파일러의 단형화와 인라이닝을 허용함
impl
, getTrait
같은 도우미와 Trait 펑터 로 구현 생성과 전달을 간소화할 수 있지만, 대상 타입을 미리 알아야 하므로 타입 소거 와는 잘 맞지 않음
핫 루프의 정적 추상화에 유용하지만 모든 코드를 바꿀 패턴은 아님. 컴파일 타임 매개변수의 조합이 늘면 바이너리 크기와 캐시 부담 때문에 동적 디스패치보다 느려질 수도 있음
Zig의 두 가지 다형성과 Trait의 위치
Zig 0.17 을 기준으로, 일반적인 다형성 구현은 두 가지 방식으로 나뉨
동적 인터페이스 는 불투명 포인터, 가상 함수 테이블, 동적 디스패치로 런타임 다형성을 구현함
comptime 덕 타이핑 은 타입 리플렉션, switch
, 컴파일 타임 단형화로 정적 다형성을 구현함
인터페이스 는 Zig의 언어 기능은 아니지만 런타임 다형성을 구현하는 사실상의 방식임
함수는 메서드 시그니처를 보장받지만 구현 자체는 알지 못함
런타임 모듈성과 간접 참조가 컴파일러의 최적화를 제한하고 성능 비용을 발생시킴
comptime 덕 타이핑 은 anytype
이나 타입 매개변수 T
를 받고, 리플렉션 또는 필요한 메서드에 대한 가정으로 구현함
C++ 템플릿이나 C 매크로와 유사하게 코드를 단형화할 수 있음
계약 위반은 런타임이 아니라 컴파일 타임에 발견되지만, 허용하는 타입의 계약을 문서화하고 따를 책임은 남음
Trait 는 특정 타입에 대한 관련 함수를 담은 컴파일 타임 구조체임
Rust의 Trait나 Haskell의 타입클래스와 유사한 추상화를 Zig 사용자 코드의 관례로 구현함
직접적인 영감은 OCaml 모듈 에서 얻었으며, Zig에서 모듈은 이미 다른 의미이므로 Trait라는 이름을 사용함
가상 함수 테이블처럼 하위 작업의 실행 방법을 전달하되 런타임이 아닌 컴파일 타임에 평가함
Reducer로 보는 기본 형태
Reducer(T)
는 초기값 empty: T
와 결합 함수 combine: fn(T, T) T
를 필드로 갖고, 슬라이스를 순회하는 reduce
메서드를 제공함
같은 i32
타입에도 서로 다른 구현 을 둘 수 있음
합산용 sum_trait
는 초기값 0
과 덧셈을 사용함
곱셈용 product_trait
는 초기값 1
과 곱셈을 사용함
이 구조는 계약을 명확히 드러내고 LSP 자동 완성이 인식할 수 있으며, 예제에서 컴파일러는 인라이닝과 루프 펼치기도 수행할 수 있음
산술 연산으로 비교하는 구현 방식
Zig는 명시적인 제어 흐름 을 추구하며 연산자 오버로딩을 제공하지 않으므로, Rational
과 Complex
같은 사용자 정의 타입에는 add
, sub
, mul
, div
함수를 직접 정의함
비교 예제는 모든 인자가 같은 타입인 뫼비우스 변환 (a * x + b) / (c * x + d)
임
기본 연산자로 구현하면 같은 부동소수점 타입에서는 동작하지만 정수 나눗셈과 사용자 정의 타입에서는 문제가 생김
a.mul(x).add(b)
처럼 메서드를 호출하면 사용자 정의 타입에서는 동작하지만 기본 타입에는 별도 래퍼가 필요함
메서드 기반 덕 타이핑은 구조체에 메서드를 정의하는 객체 지향적 형태에 의존하지만, 컴파일 타임 단형화와 인라이닝은 가능함
Num
인터페이스와 VTable 로 런타임 디스패치를 구현할 수도 있음
예제는 32바이트 값 저장 공간과 산술 연산의 함수 포인터 테이블을 두고, Complex
와 Rational
을 감싸는 연결 코드를 요구함
이 예제 구현은 서로 다른 숫자 타입을 섞는 호출을 막지 못하므로 정의되지 않은 동작을 일으킬 수 있으며, 이를 막으려면 별도 검사가 필요함
산술 연산마다 동적 디스패치를 요구하므로 계산량이 많은 경우 성능에 상당한 영향을 줄 수 있음
연산을 컴파일 타임에 주입하기
필요한 add
, mul
, div
를 컴파일 타임 함수 인자 로 넘기면, 대상 타입의 구체적인 구현을 몰라도 산술 연산을 수행할 수 있음
세밀한 단위의 의존성 주입이며, 시그니처는 길어지지만 런타임 인터페이스보다 연결 코드가 적음
함수들을 Num(T)
구조체 의 필드로 묶으면 긴 시그니처를 정리하면서 타입 계약을 유지할 수 있음
mobius
는 T
와 Num(T)
를 받고 num.add
, num.mul
, num.div
를 호출함
Complex.num
처럼 타입 내부 상수로 구현을 선언하고 명시적으로 전달할 수 있음
builtinNum(T)
는 기본 타입에 맞는 구현을 생성함
덧셈, 뺄셈, 곱셈은 기본 연산자를 사용함
정수와 comptime_int
의 나눗셈에는 @divTrunc
, 부동소수점과 comptime_float
에는 /
를 사용함
지원하지 않는 타입은 @compileError
로 거부함
이 패턴은 컴파일러가 구현을 볼 수 있어 단형화와 인라이닝 이 가능하며, 특히 핫 루프나 데이터 배치를 강제하지 않는 라이브러리 함수에 적합함
예제의 숫자 계약은 단순화한 형태이며, 실제 설계에는 Rust의 std::ops 나 Haskell의 Num 을 참고할 수 있음
연관 타입과 타입 소거의 한계
Trait는 알려진 타입 을 명시적으로 매개변수화해야 하므로, 인터페이스처럼 구체적인 종류를 모르는 숫자를 받는 방식으로 사용할 수 없음
구조체 내부에 T: type
필드를 두고 다른 필드의 next: fn(T) ?Elem
에서 참조하는 연관 타입 형태 는 유효한 Zig 코드가 아님
대신 Iter(T, Elem)
처럼 대상 타입과 요소 타입을 미리 전달해야 함
편리한 우회 방법은 찾지 못했으며, 이 패턴은 타입 소거 와 잘 맞지 않음
구현과 검색을 돕는 함수
impl(Trait, Impl)
은 Trait의 필드 이름을 컴파일 타임에 순회하고, 구현 타입의 같은 이름 선언을 복사해 Trait 값을 만듦
함수를 꺼내기 위해 임시 내부 구조체를 반복해서 작성하는 수고를 줄임
익명 구조체에서 구현을 생성할 수 있으며, 리플렉션이 접근하려면 구현 선언이 pub
이어야 함
getTrait(Trait, Impl)
은 구현 타입의 선언을 검색해 타입이 요청한 Trait와 일치하는 값을 반환함
찾지 못하면 컴파일 오류를 발생시킴
사용자 정의 구조체에 Trait 구현을 상수로 두는 경우 명시적 전달을 간소화할 수 있음
getTrait
로 Num(T)
를 찾고 makeMobius
에 전달하는 duckMobius
처럼, 명시적인 계약 위에 덕 타이핑 방식의 편의 기능을 다시 얹을 수 있음
Trait 펑터와 타입 인자의 부분 적용
Rust와 Haskell은 컴파일러가 관리하는 전역 레지스트리 에서 Trait나 타입클래스 구현을 찾는 방식임
편리하지만 Zig의 명시적인 제어 흐름 원칙과 맞지 않으며, LSP가 제대로 해석하지 못하면 구현 위치를 찾는 수고가 커질 수 있음
제네릭 Trait 해석은 종종 증명 평가에 해당하므로 긴 컴파일 시간의 한 요인이 될 수 있음
OCaml 모듈 은 사용할 구현을 직접 찾아 인자로 전달하며, 모듈 펑터는 모듈을 받아 새 모듈을 반환함
MakeMobius(Float)
처럼 숫자 연산 모듈을 먼저 적용하고 실제 값은 나중에 전달할 수 있음
커링과 같은 방식으로 매번 타입 관련 인자를 넘기지 않아도 되며, 생성한 모듈을 이후 구성에 다시 사용할 수 있음
Zig에서는 Trait 펑터 를 여러 형태로 구현할 수 있음
makeMobius(T, num)
이 Mobius(T)
Trait 구현을 반환함
생성 함수를 Trait 내부로 옮겨 Mobius(T).make(num)
으로 호출함
다른 함수가 Trait로 소비할 필요가 없는 단일 함수라면, 타입 관련 인자를 먼저 적용하고 함수 자체를 반환함
이 형태들은 Zig의 comptime 덕분에 컴파일러 관점에서 대체로 동등하며, 상황에 따른 스타일 선택에 가까움
앞서 사용한 builtinNum
도 Trait 구현을 생성하는 함수이므로 Trait 펑터에 해당함
반복해서 활용할 수 있는 Trait
Into(T, Out)
와 From(T, In)
은 타입 변환 방법을 일정한 계약으로 묶음
각각 into: fn(T) Out
, from: fn(In) T
를 제공함
원본 타입을 해석하는 방법에 따라 여러 변환 구현을 둘 수 있음
Index(T, Key, Value)
는 get: fn(T, Key) ?Value
로 키 기반 접근을 추상화함
소비하는 함수는 컨테이너가 슬라이스인지 해시 테이블인지 알 필요가 없음
Iterator
와 Iterable
은 순회 상태와 컬렉션을 분리함
Iterator(T, Elem)
은 next: fn(*T) ?Elem
로 진행 상태를 갱신하며 요소를 반환함
Iterable(T, Elem, Iter)
은 반복자 구현 값과 Iterator(Iter, Elem)
Trait를 묶은 IterPair
를 반환함
소비자가 보통 관심 없는 반복자 구현 타입 Iter
까지 명시해야 하는 불편함은 그대로 남음
필요한 곳에만 적용해야 하는 이유
Trait는 코드를 전면 전환할 패턴이 아니라, 구현 세부사항을 열어 둔 함수가 필요로 할 때 도입하는 선택적 추상화 임
단형화와 정적 디스패치 가 필요한 핫 루프에서 유용하며, 읽기 쉬운 메타프로그래밍 구성 요소로도 활용할 수 있음
Zig 코드를 Rust나 Haskell처럼 만드는 것이 목표는 아님
잘못 사용하면 바이너리 크기 증가와 성능 저하 가 발생할 수 있음
컴파일 타임 매개변수로 특수화한 함수마다 별도 기계어가 생성됨
여러 층으로 중첩하면 조합이 폭발하고 CPU 캐시에 부담을 줄 수 있어, 동적 디스패치와 VTable이 더 빠른 상황도 생김
Trait는 라이브러리보다는 설계 패턴과 관례 에 가까우며, 사용 시점과 방식을 명시적으로 통제할 수 있음
모든 코드에 강제하기보다 적절한 작업에 선택해서 적용하는 것이 핵심임
댓글과 토론