스위프트 5.9 이전, 개발자들은 이질적인 타입 컬렉션에서 작동하는 제네릭 코드를 작성할 때 상당한 표현 부족에 직면했습니다. 유형이 구별되고 보존된 가변 인수를 요구하는 함수들은 타입 지워짐을 통해 Any 또는 존재적 컨테이너(any P)를 사용해야 했으며, 이는 컴파일 타임 안전성을 희생하고 힙 할당 오버헤드를 초래했습니다. 매개변수 팩(SE-0393, SE-0398 및 SE-0399)의 도입은 스위프트에 가변 제네릭을 가져오면서 이전에 C++ 템플릿 메타 프로그래밍이나 Rust 가변 특성이 필요했던 패턴을 표현할 수 있게 했습니다. 이 발전은 제네릭 프로그래밍의 기본적인 격차를 해소하여 수동 오버로드 생성을 필요로 하지 않고 이질적인 데이터에 대해 타입 안전하고 비용이 없는 추상화를 가능하게 했습니다.
주요 도전 과제는 각기 다른 타입이 될 수 있는 임의의 수의 제네릭 인수를 수용할 수 있으면서도 호출 체인에서 정적 타입 정보를 유지할 수 있는 메커니즘을 구현하는 것이었습니다. [Any]를 사용하는 매개변수 팩 이전의 솔루션은 런타임 캐스팅을 필요로 하였으며 타입 관계를 유지하지 못해 인라인화 및 특수 디스패치와 같은 컴파일러 최적화를 방해했습니다. 대안으로, 1에서 N까지의 아르기수에 대한 수동 오버로드 생성을 하게 되면 바이너리 부풀음이 생기고 인수를 임의로 제한하게 됩니다. 이 솔루션은 컴파일 타임 팩 반복을 지원해야 하며, 컴파일러가 각 호출 위치의 타입 시그니처에 맞게 전용 코드를 생성할 수 있어야 하며, 단순 값 타입에 대한 런타임 박싱 또는 위트니스 테이블 간접 호출을 도입하지 않아야 했습니다.
스위프트는 팩 확장을 통해 매개변수 팩을 구현하고 있습니다. repeat each T 패턴을 코드 생성을 위한 컴파일 타임 템플릿으로 취급합니다. 함수가 타입 매개변수 팩 <each T>를 선언하고 값 팩 repeat each T를 수용할 때, 컴파일러는 호출 위치에서 모노모르파이제이션을 수행하며, 제네릭 본문을 팩의 각 요소에 대한 구체적인 코드로 확장합니다. 이는 각 요소가 고유한 타입 정체성을 유지하기 때문에 동형 가변 변수와는 다릅니다. repeat 키워드는 후속 표현이 각 팩 요소에 대해 복제되어야 함을 SIL(스위프트 중간 언어) 생성 단계에 신호를 보냅니다. 이 변환은 값 타입이 구체적인 레이아웃을 유지하므로 박싱을 제거하며, 함수 호출은 존재적 컨테이너 오버헤드 없이 정적으로 디스패치됩니다.
// 이질적 매개변수 팩을 수용하는 함수 func describeValues<each T>(_ values: repeat each T) { // 컴파일러는 이 루프를 컴파일 타임에 확장합니다. repeat print("Type: \(type(of: each values)), Value: \(each values)") } // 사용 예 // describeValues(Int, String, Double)와 동등한 전문화된 코드가 생성됩니다. describeValues(42, "Swift", 3.14)
우리 팀은 iOS를 위한 고성능 데이터 파이프라인 프레임워크를 설계하고 있었습니다. 사용자들은 이질적인 변환 단계를 체인으로 연결할 필요가 있었습니다(예: DecodeJSON<T>, Validate<U>, Map<V>), 이를 단일 실행 그래프로 변환해야 했습니다. API는 다양한 입력 및 출력 타입을 가지는 이런 단계를 수용하는 pipeline 함수를 요구하며, 데이터 흐름에 대한 컴파일 타임 정보를 유지하여 최적화 패스를 가능하게 해야 했습니다.
우리는 처음에 1에서 6까지의 제네릭 인수에 대한 오버로드를 구현했습니다(예: func pipeline<T1, T2>(_: T1, _: T2)). 이렇게 하면 정적 타입이 유지되고 LLVM이 전체 체인을 인라인화할 수 있었습니다. 그러나 이 방법은 장황하고 유지 관리가 어렵기 때문에 거의 동일한 코드의 수백 줄이 필요했습니다. 이는 사용자를 여섯 단계로 인위적으로 제한했고, 추가 아르기수가 증가할 때마다 코드 중복으로 인해 바이너리 크기가 기하급수적으로 증가했습니다. 여덟 단계를 지원해야 하며 요구 사항이 변경되었을 때 리팩토링 노력은 상당했습니다.
그 다음, 우리는 연관 타입을 가진 AnyPipelineStep 프로토콜을 정의하고 [any AnyPipelineStep]를 매개변수로 사용하려고 시도했습니다. 이 방법은 무제한 단계를 지원했지만 모든 값 타입(디코딩된 데이터를 포함하는 구조체)을 힙 할당된 존재적 컨테이너로 강제했습니다. 성능 프로파일링에 따르면 CPU 시간의 30%가 이러한 박스에서 swift_retain 및 swift_release 작업에 소비되었습니다. 또한, 연관 타입이 지워졌기 때문에 컴파일러는 단계 간 최적화가 불가능해졌으며 각 접합점에서 동적 캐스팅을 요구했습니다.
스위프트 5.9에서, 우리는 API를 func pipeline<each Step: PipelineStep>(steps: repeat each Step)를 사용하도록 리팩토링했습니다. 이렇게 하면 컴파일러가 코드베이스에서 만나는 모든 이질적인 파이프라인 조합에 대해 고유한 전문화를 생성할 수 있었습니다. 각 단계는 구체적인 타입을 유지하게 되어, 과감한 인라인화 및 전이 데이터 구조에 대한 스택 할당이 가능해졌습니다. repeat 키워드는 컴파일 타임에 인접한 단계 간의 타입 호환성을 검증하기 위해 반복할 수 있게 해주었습니다.
우리는 매개변수 팩을 채택하여 성능을 희생하지 않으면서 아르기수 제한을 제거했습니다. 존재적 타입과 달리, 팩은 스위프트의 최적화 프로그램에 대한 제네릭 시그니처를 유지하여 비용이 없는 추상을 생성했습니다. 리팩토링은 오버로드 방법에 비해 프레임워크의 바이너리 크기를 35% 줄였고, 존재적 방법에 비해 처리량을 4배 향상시켰습니다. 개발자들은 이제 각 단계의 특정 입력/출력 타입에 대한 전체 자동 완성 지원으로 임의 길이의 파이프라인을 구성할 수 있었으며, 데이터 불일치는 통합 테스트가 아니라 빌드 타임에 포착되었습니다.
후보자들은 종종 팩 제약이 단일 제네릭 제약처럼 작동한다고 가정하지만, 스위프트는 where 절에서 명시적 repeat 패턴을 요구합니다. 각 팩 T의 요소를 서로 다른 Item 연관 타입을 만족하도록 제한하길 원할 때, 구문은 func process<each T: Container>(_ items: repeat each T) where repeat each T.Item: Equatable이 됩니다. 컴파일러는 구조적 제약 해결을 수행하여 where 절을 팩 전반에 걸쳐 요소 단위로 확장합니다. 일반적인 실패 경우는 전체 팩에 대해 단일 연관 타입 제약을 사용하려고 시도하는 것으로, 각 T.Item이 고유한 타입이기 때문에 실패합니다. 팩 제약이 각 요소 요구의 논리합을 생성한다는 것을 이해하는 것은 추론 오류를 디버깅하는 데 필수적입니다.
개발자들은 종종 매개변수 팩이 모든 컨텍스트에서 비용이 없는 추상을 보장한다고 믿지만, ABI 경계를 넘거나 불투명한 결과 타입을 사용하면 박싱이 강제될 수 있습니다. 특히, 매개변수 팩이 다른 내구성 도메인에 있는 함수에 전달된 탈출 클로저에 캡처되면 스위프트는 정적 전문화 대신 런타임 제네릭 인스턴스를 생성할 수 있습니다. 마찬가지로, 팩 반복 내에서 some Collection을 반환하면 각 팩 요소에 따라 구체적인 반환 타입이 달라지기 때문에 컴파일러는 존재적 컨테이너를 사용해야 합니다. 이는 존재적 타입의 인라인 버퍼를 위한 힙 할당을 도입하고 프로토콜 위트니스 테이블을 통한 간접 호출을 추가하여 메모리 레이아웃에 영향을 미칩니다. 호출 위치에서 전체 팩의 정적 가시성이 필요하다는 사실을 인식하는 것은 성능 유지를 위해 중요합니다.
이 제한은 후보자들이 struct Storage<each T> { repeat var item: each T }가 각 팩 요소에 대해distinct stored properties로 선언될 것이라고 기대할 때 혼란을 초래합니다. 스위프트는 저장 프로퍼티가 메모리 관리를 위한 고정 오프셋과 크기를 요구하기 때문에 이를 금지합니다. 가변 개수의 프로퍼티는 크기 가변 구조체를 생성하게 되어 제네릭 타입의 ABI 안정성 요구 사항을 위반하게 됩니다. 값 위트니스 테이블은 인스턴스를 복사, 이동 및 파괴하는 데 필요한 정적 레이아웃을 기대합니다. (repeat each T)로 집계를 요구함으로써 컴파일러는 팩을 단일 복합 값으로 취급하며, 이는 각 요소의 카르테시안 곱에서 파생된 레이아웃을 유도합니다. 이는 Storage의 각 전문화가 결정론적인 바이너리 레이아웃을 가지도록 하여 런타임이 동적 메타데이터 조회 없이 적절한 값 위트니스 함수를 선택할 수 있게 합니다. 매개변수 팩(함수 인수)과 지속적인 저장소(구조체 필드) 간의 이러한 구별을 이해하는 것은 팩이 지속적인 저장소를 위해 튜플로 "동결"되어야 하는 이유를 명확하게 합니다.