🔥 연관 타입

1440자
17분

프로토콜을 정의할 때, 프로토콜의 정의의 일부로 하나 이상의 연관 타입을 선언하면 유용할 때가 있어요. 연관 타입은 프로토콜에서 사용되는 타입에 대한 플레이스홀더 이름을 제공하죠. 해당 연관 타입에 사용할 실제 타입은 프로토콜이 채택될 때까지 지정되지 않습니다. 연관 타입은 associatedtype 키워드로 지정하면 돼요.

연관 타입의 실제 사용

다음은 Container라는 프로토콜의 예시인데요, Item이라는 연관 타입을 선언하고 있어요:

protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
swift

Container 프로토콜은 모든 컨테이너가 제공해야 하는 세 가지 필수 기능을 정의하고 있죠:

  • append(_:) 메서드로 컨테이너에 새 항목을 추가할 수 있어야 해요.
  • Int 값을 반환하는 count 속성을 통해 컨테이너의 항목 수에 접근할 수 있어야 하구요.
  • Int 인덱스 값을 사용하는 서브스크립트로 컨테이너의 각 항목을 검색할 수 있어야 합니다.

이 프로토콜은 컨테이너에서 항목을 어떻게 저장해야 하는지 또는 허용되는 타입이 무엇인지 지정하지 않아요. 프로토콜은 Container로 간주되기 위해 모든 타입이 제공해야 하는 세 가지 기능만 지정하죠. 준수하는 타입은 이 세 가지 요구 사항을 충족하는 한 추가 기능을 제공할 수 있어요.

Container 프로토콜을 준수하는 모든 타입은 저장하는 값의 타입을 지정할 수 있어야 해요. 특히, 올바른 타입의 항목만 컨테이너에 추가되도록 해야 하며, 서브스크립트에서 반환하는 항목의 타입이 명확해야 하죠.

이러한 요구 사항을 정의하기 위해 Container 프로토콜은 특정 컨테이너에 대해 그 타입이 무엇인지 알지 못한 채 컨테이너가 보유할 요소의 타입을 참조할 수 있는 방법이 필요해요. Container 프로토콜은 append(_:) 메서드에 전달되는 모든 값이 컨테이너의 요소 타입과 동일한 타입이어야 하고, 컨테이너의 서브스크립트에서 반환되는 값이 컨테이너의 요소 타입과 동일한 타입이어야 한다는 것을 지정해야 하죠.

이를 위해 Container 프로토콜은 associatedtype Item으로 작성된 Item이라는 연관 타입을 선언합니다. 프로토콜은 Item이 무엇인지 정의하지 않으며, 해당 정보는 준수하는 타입이 제공하도록 남겨두죠. 그럼에도 불구하고 Item 별칭은 Container의 항목 타입을 참조하고 append(_:) 메서드 및 서브스크립트에서 사용할 타입을 정의하는 방법을 제공하여 모든 Container에 기대되는 동작이 적용되도록 해요.

다음은 Container 프로토콜을 준수하도록 수정된 Generic Types의 비제네릭 IntStack 타입 버전이에요:

struct IntStack: Container {
    // 기존 IntStack 구현
    var items: [Int] = []
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }
 
    // Container 프로토콜 준수
    typealias Item = Int
    mutating func append(_ item: Int) {
        self.push(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Int {
        return items[i]
    }
}
swift

IntStack 타입은 Container 프로토콜의 세 가지 요구 사항을 모두 구현하며, 각 경우 IntStack 타입의 기존 기능 일부를 래핑하여 이러한 요구 사항을 충족시키죠.

또한 IntStack은 이 Container 구현에 대해 적절한 ItemInt 타입임을 지정하네요. typealias Item = Int의 정의는 추상 타입 Item을 이 Container 프로토콜 구현을 위한 구체적인 Int 타입으로 변환합니다.

Swift의 타입 추론 덕분에 IntStack의 정의의 일부로 구체적인 ItemInt로 선언할 필요가 없어요. IntStackContainer 프로토콜의 모든 요구 사항을 준수하기 때문에, Swift는 append(_:) 메서드의 item 매개변수 타입과 서브스크립트의 반환 타입을 살펴보기만 하면 사용할 적절한 Item을 추론할 수 있거든요. 실제로 위의 코드에서 typealias Item = Int 줄을 삭제하면 Item에 사용해야 할 타입이 명확하기 때문에 모든 것이 여전히 작동한답니다.

제네릭 Stack 타입을 Container 프로토콜을 준수하도록 만들 수도 있어요:

struct Stack<Element>: Container {
    // 기존 Stack<Element> 구현
    var items: [Element] = []
    mutating func push(_ item: Element) {
        items.append(item)
    }
    mutating func pop() -> Element {
        return items.removeLast()
    }
 
    // Container 프로토콜 준수
    mutating func append(_ item: Element) {
        self.push(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Element {
        return items[i]
    }
}
swift

이번에는 타입 매개변수 Elementappend(_:) 메서드의 item 매개변수 타입과 서브스크립트의 반환 타입으로 사용되네요. 따라서 Swift는 Element가 이 특정 컨테이너에 대해 Item으로 사용할 적절한 타입임을 추론할 수 있죠.

연관 타입을 지정하기 위해 기존 타입 확장하기

Adding Protocol Conformance with an Extension에 설명된 대로 기존 타입을 확장하여 프로토콜 준수를 추가할 수 있어요. 여기에는 연관 타입이 있는 프로토콜이 포함되죠.

Swift의 Array 타입은 이미 append(_:) 메서드, count 속성 및 요소를 검색하기 위한 Int 인덱스가 있는 서브스크립트를 제공해요. 이 세 가지 기능은 Container 프로토콜의 요구 사항과 일치하죠. 즉, Array가 프로토콜을 채택한다고 선언하기만 하면 Container 프로토콜을 준수하도록 Array를 확장할 수 있어요. Declaring Protocol Adoption with an Extension에 설명된 대로 빈 익스텐션을 사용하여 이 작업을 수행하죠:

extension Array: Container {}
swift

Array의 기존 append(_:) 메서드와 서브스크립트를 통해 Swift는 위의 제네릭 Stack 타입과 마찬가지로 Item에 사용할 적절한 타입을 추론할 수 있어요. 이 확장을 정의한 후에는 모든 ArrayContainer로 사용할 수 있답니다.

연관 타입에 제약 조건 추가하기

프로토콜의 연관 타입에 타입 제약 조건을 추가하여 준수하는 타입이 해당 제약 조건을 충족하도록 요구할 수 있어요. 예를 들어, 다음 코드는 컨테이너의 항목이 equatable할 것을 요구하는 Container의 버전을 정의하죠.

protocol Container {
    associatedtype Item: Equatable
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
swift

이 버전의 Container를 준수하려면 컨테이너의 Item 타입이 Equatable 프로토콜을 준수해야 해요.

연관 타입의 제약 조건에서 프로토콜 사용하기

프로토콜은 자체 요구 사항의 일부로 나타날 수 있어요. 예를 들어, 다음은 suffix(_:) 메서드의 요구 사항을 추가하여 Container 프로토콜을 개선한 프로토콜이에요. suffix(_:) 메서드는 컨테이너 끝에서 주어진 수의 요소를 반환하여 Suffix 타입의 인스턴스에 저장하죠.

protocol SuffixableContainer: Container {
    associatedtype Suffix: SuffixableContainer where Suffix.Item == Item
    func suffix(_ size: Int) -> Suffix
}
swift

이 프로토콜에서 Suffix는 위의 Container 예제의 Item 타입과 같은 연관 타입이에요. Suffix에는 두 가지 제약 조건이 있는데요: SuffixableContainer 프로토콜(현재 정의되고 있는 프로토콜)을 준수해야 하며, 그 Item 타입은 컨테이너의 Item 타입과 동일해야 해요. Item에 대한 제약 조건은 아래의 Associated Types with a Generic Where Clause에서 설명하는 제네릭 where 절이죠.

다음은 위의 Generic TypesStack 타입을 확장하여 SuffixableContainer 프로토콜을 준수하도록 만드는 예시예요:

extension Stack: SuffixableContainer {
    func suffix(_ size: Int) -> Stack {
        var result = Stack()
        for index in (count-size)..<count {
            result.append(self[index])
        }
        return result
    }
    // Suffix가 Stack임을 추론함
}
 
var stackOfInts = Stack<Int>()
stackOfInts.append(10)
stackOfInts.append(20)
stackOfInts.append(30)
let suffix = stackOfInts.suffix(2)
// suffix에는 20과 30이 포함됨
swift

위의 예에서 Stack에 대한 Suffix 연관 타입도 Stack이므로 Stack에 대한 접미사 연산은 또 다른 Stack을 반환하네요. 또는 SuffixableContainer를 준수하는 타입은 자신과 다른 Suffix 타입을 가질 수 있어요. 즉, 접미사 연산이 다른 타입을 반환할 수 있죠. 예를 들어, 다음은 IntStack 대신 Stack<Int>를 접미사 타입으로 사용하여 SuffixableContainer 준수를 추가하는 비제네릭 IntStack 타입에 대한 확장이에요:

extension IntStack: SuffixableContainer {
    func suffix(_ size: Int) -> Stack<Int> {
        var result = Stack<Int>()
        for index in (count-size)..<count {
            result.append(self[index])
        }
        return result
    }
    // Suffix가 Stack<Int>임을 추론함
}
swift

이 예제에서IntStackSuffix 연관 타입은 IntStack 자체가 아닌 Stack<Int>예요. 이는 IntStacksuffix(_:) 메서드가 IntStack 대신 Stack<Int> 인스턴스를 반환한다는 것을 의미하죠.

제네릭 Where 절이 있는 연관 타입

연관 타입에 대한 제약 조건에 제네릭 where 절을 포함할 수 있어요. 제네릭 where 절을 사용하면 연관 타입이 프로토콜을 준수하도록 요구하거나 특정 타입 매개변수와 연관 타입이 같도록 지정할 수 있죠.

다음은 제네릭 where 절이 있는 제약 조건을 사용하는 예시예요:

protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
 
    associatedtype Iterator: IteratorProtocol where Iterator.Element == Item
    func makeIterator() -> Iterator
}
swift

이 버전의 Container 프로토콜은 기존 요구 사항에 두 가지를 추가하네요. 첫 번째로, 이 프로토콜은 IteratorProtocol 프로토콜을 준수하는 Iterator라는 연관 타입을 선언해요. 두 번째로, makeIterator()라는 새로운 메서드를 정의하는데, 이 메서드는 컨테이너의 Iterator 타입 인스턴스를 반환하죠.

제네릭 where 절은 Iterator에 두 가지 제약 조건을 둬요: IteratorProtocol을 준수해야 하며, 반복자의 Element 타입이 컨테이너의 Item 타입과 같아야 해요. makeIterator() 메서드는 이러한 요구 사항을 만족하는 반복자를 반환하죠.

다음은 위의 제약 조건을 충족하는 IntStack 타입에 대한 확장이에요:

extension IntStack: Container {
    typealias Item = Int
 
    typealias Iterator = IndexingIterator<[Int]>
    func makeIterator() -> Iterator {
        return items.makeIterator()
    }
}
swift

이 확장은 IntStackContainer 프로토콜을 따르도록 만들어요. Item 연관 타입에 대한 typealias는 이전 IntStack 확장과 동일하지만, 이번에는 Container 프로토콜의 요구 사항 때문에 Item을 지정해야 하죠.

또한 확장은 Iterator 연관 타입을 IndexingIterator<[Int]>로 정의하네요. 이는 makeIterator() 메서드 구현에서 사용되는데, items 배열의 반복자를 반환하죠.

이 예제는 제네릭 where 절이 복잡한 연관 타입 제약 조건을 표현하는 데 어떻게 사용될 수 있는지 보여줘요. 이를 통해 연관 타입 간의 관계를 지정하고, 이러한 관계가 프로토콜 요구 사항을 준수하도록 보장할 수 있죠.

요컨대, 프로토콜은 associatedtype 키워드를 사용하여 하나 이상의 연관 타입을 선언할 수 있어요. 연관 타입은 프로토콜의 일부로 사용되는 타입에 대한 플레이스홀더 이름을 제공하며, 프로토콜이 채택될 때까지 실제 타입이 지정되지 않죠.

연관 타입은 제네릭과 밀접한 관련이 있어요. 프로토콜에서 연관 타입을 사용하여 요구 사항 내에서 타입 간의 관계를 정의할 수 있죠.

프로토콜을 준수하는 타입은 해당 프로토콜에서 정의된 연관 타입을 실제 타입으로 제공해야 해요. 이는 typealias를 사용하여 명시적으로 수행하거나, 프로토콜 요구 사항의 구현에서 타입을 추론하게 할 수 있죠.

연관 타입에 대해 제약 조건을 지정할 수 있어요. 예를 들어, 특정 프로토콜을 준수하도록 요구하거나 특정 타입과 같도록 지정할 수 있죠. 이는 제네릭 where 절을 사용하여 수행할 수 있어요.

마지막으로, 프로토콜 자체를 연관 타입의 제약 조건으로 사용할 수 있어요. 이를 통해 재귀적인 프로토콜 정의를 생성할 수 있으며, 연관 타입 간의 복잡한 관계를 표현할 수 있죠.

연관 타입은 Swift의 프로토콜과 제네릭 시스템의 강력한 기능으로, 유연하고 표현력이 뛰어난 추상화를 만들 수 있게 해줘요. 이들은 코드의 재사용성과 일반화를 촉진하여, 더 간결하고 유지 관리하기 쉬운 코드를 작성할 수 있도록 도와주죠.