Swift Language Guide: Generics

출처 : https://docs.swift.org/swift-book/LanguageGuide/Generics.html


Remind

  • 제네릭 타입은 static stored 프로퍼티를 정의할 수 없다

🐶 서론

제네릭은 어떤 타입과도 작업할 수 있는 유연하고 재사용가능한 함수와 타입을 쓸 수 있게 해줍니다 ('어떤 타입'의 범위를 우리가 정의할 수 있습니다)
제네릭 코드를 통해, 반복코드를 피하고 명확하고 추상화된 방식으로 의도를 표현할 수 있습니다

제네릭은 Swift의 가장 강력한 기능 중 하나로, 많은 Swift 기본 라이브러리가 제네릭으로 만들어져 있습니다
예로, ArrayDictionary 타입은 모두 제네릭 Collection입니다
Array가 Int/String 등 어떤 타입이든 보관할 수 있도록 만들 수 있습니다
유사하게 Dictionary가 key와 value를 어떤 타입이로든 만들 수 있습니다


🐱 Generic으로 해결가능한 문제

먼저 두 개의 Int를 swap하는 함수를 non-Generic으로 구현한 예시입니다

func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA = a
    a = b
    b = temporaryA
}

이 함수는 유용하지만 Int 타입만 사용가능하다는 문제가 있습니다
만약 Double 타입 swap이 필요하다면 별도의 함수를 만들어주어야 합니다

func swapTwoDoubles(_ a: inout Double, _ b: inout Double) {
    let temporaryA = a
    a = b
    b = temporaryA
}

우리는 두 함수의 body가 동일함을 알아챌 수 있습니다
유일한 차이점은 parameter와 retun 타입만 다릅니다

따라서, Any 타입이든 swap해주는 하나의 함수를 정의하는 것이 더 유용하고 훨씬 유연합니다

Generic은 그런 함수를 정의할 수 있게 해줍니다


🐭 Generic 함수

Generic 함수는 어떤 타입과도 작업할 수 있습니다

예시를 봅시다
뭔가 첫 줄의 생김새가 다릅니다

func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}

기존의 non-Generic 함수와 비교해봅시다

func swapTwoInts(_ a: inout Int, _ b: inout Int)
func swapTwoValues<T>(_ a: inout T, _ b: inout T)

Generic 함수는 Double/Int같은 실제 타입대신 placeholder 타입을 사용합니다
placeholder는 위 예제처럼 T가 아니어도 되고, T가 어떤 타입인지에 대해선 아무말도 하지 않습니다
다만, 두 parameter가 동일한 타입일 것을 말하고 있습니다
T 대신 사용될 실제 타입은 Generic함수가 호출될 때 결정됩니다

swapTwoValues는 parameter로 Any 타입이든 올 수 있다는 점과, 두 parameter의 타입이 같아야 한다는 점을 제외하면 swapTwoInts와 같은 방식으로 호출됩니다

swapTowValues가 호출될 때마다 T로 사용될 타입은 추론과정을 거칩니다
(argument로 전달되는 값의 타입을 기반으로)


🐭 Type Parameter

위 예제에서 사용하던 placeholder, T에 대해 알아봅시다
'placeholder type T'는 type parameter의 한 예시입니다
type paramter는 placeholder type을 명시하고 이름을 부여합니다
그리고 함수 이름 바로 뒤에 '<>'과 함께 붙습니다

한번 type paramter를 명시하면, Generic 함수에서 다뤄질 paramter/return/기타(body에서다룰) 타입을 정의하기 위해 이를 사용할 수 있습니다
함수가 호출될 때, 각 type parameter는 실제 타입으로 대체됩니다

type paramter는 여러 개 제공될 수 있습니다

--

🐹 Type Parameter Naming

대부분의 경우, type parameter의 이름은 Generic과 type paramter 간 관계에 대한 '설명'이 포함되도록 지어집니다
(ex. Dictionary의 <Key, Value> 혹은 Array의 <Element>)

하지만, 둘 간에 유의미한 관계가 없다면 전통적으로 U, T, V를 사용합니다

type paramter의 이름은 upper camel case를 사용합니다

--

🐰 Generic 타입

Generic 함수에 더해 Generic 타입도 정의할 수 있습니다
예시로는 Array와 Dictionary가 있으며
어떤 타입과도 작업할 수 있는 custom class/struct/enum입니다

이번 section에서는 Stack을 예시로 Generic Collection을 정의하는 방법을 다룹니다


...Stack과 Array의 차이..중략...


non-Generic 버전
Int외에 다른 타입을 element로 사용하려면 별도 정의가 필요하다

struct IntStack {
    var items: [Int] = []
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }
}

Generic 버전

struct Stack<Element> {
    var items: [Element] = []
    mutating func push(_ item: Element) {
        items.append(item)
    }
    mutating func pop() -> Element {
        return items.removeLast()
    }
}

✔️ Generic 타입은 인스턴스 생성구문이 다르다

Generic타입의 인스턴스를 생성할 때는, type paramter를 대신할 '실제 타입'을 명시해주어야 합니다

var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")
stackOfStrings.push("cuatro")
// the stack now contains 4 strings

🦊 Generic 타입 Extension

Generic 타입을 extension할 때는

original 정의부에 명시된 type paramter를 그대로 가져오므로
type paramter를 별도로 적어주지 않습니다

extension Stack {
    var topItem: Element? {
        return items.isEmpty ? nil : items[items.count - 1]
    }
}

✔️ 조건부 extension

extension 할 때도 다뤘던 내용인데
extension을 선택적으로 추가할 수 있습니다

아래는 Element가 Equatable 프로토콜을 준수하는 Stack인 경우에만 extension합니다

extension Stack where Element: Equatable {
    func isTop(_ item: Element) -> Bool {
        guard let topItem = items.last else {
            return false
        }
        return topItem == item
    }
}

🐻 Type 제약걸기

위에서 다뤘던 예제의 swapTwoValues(_:_:) 함수와 Stack은 Any 타입과도 작업할 수 있습니다

하지만, 특정 조건을 만족하는 타입들만 사용하고 싶을 수도 있습니다

'Type 제약'은 type paramter가 특정 class를 상속받거나, 특정 프로토콜을 준수해야 함을 명시할 수 있습니다

예로, Swift의 Dictionary는 Key가 hashable해야 함을 명시하고 있습니다
즉, Key 간에 유일하게 구분할 방법을 제공해야한다는 뜻입니다
Dictionary는 어떤 key에 대해 이미 value를 가지고 있는 key인지 확인할 수 있도록 key가 hashable할 필요가 있습니다

✔️ Syntax

제약은 하나만 걸 수 있습니다
하나의 class 혹은 하나의 protocol을 걸 수 있습니다
다만, protocol은 composition을 이용하면 여러 개 걸 수도 있습니다

func someFunction<T: SomeClass, U: SomeProtocolA & SomeProtocolB>(someT: T, someU: U) {
    // function body goes here
}

✔️ 예제

1. non-Generic으로 우선
String말고도 광범위하게 사용될 수 있으므로 Generic으로 만들자

func findIndex(ofString valueToFind: String, in array: [String]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

2. Generic으로 만들긴 했는데..
컴파일이 안된다
value == valueToFind라는 '비교구문'이 있는데
이 비교 연산자는 아무 타입이나 갖고 있는 메서드가 아니므로
컴파일러 입장에서는 Swift의 모든 타입에 대해 허용할 수 없다

func findIndex<T>(of valueToFind: T, in array:[T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

3. 적절한 제약 추가
우리는 Equatable 프로토콜 제약을 추가함으로써
== 연산자 소유를 보장할 수 있다

func findIndex<T: Equatable>(of valueToFind: T, in array:[T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

🐼 Associated Types (in 프로토콜)

프로토콜을 정의할 때, 때때로 associated type을 정의하는 것이 유용합니다

Associated Type프로토콜에서 사용하는 type parameter입니다

기존에 다루던 Generic함수/타입에서처럼 구체적으로 '어떤 타입인지'는 정의하지 않습니다
반면, '이런저런 요구사항들은 서로 같은 타입을 사용해야 해'를 정의합니다

예제로 살펴봅시다

✔️ 예제

associatedtype이라는 키워드로 정의됩니다

아래 예제는 associated type과 이를 사용하는 3가지 요구사항을 정의합니다

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

Container 프로토콜의 요구사항을 정리하면

  • Item이 어떤 타입이든 상관없다
  • 단, 적어도 하나의 타입에 대해 append()/count/subscript를 전부 구현해야 한다

이 요구사항을 준수하는 것은, Generic/non-Generic 타입 모두 가능합니다

1. non-Generic 타입이 준수할 때

프로토콜에서 Item이 어떤 타입인지는 상관없고
적어도 하나의 타입에서 요구사항을 전부 구현하기만 하면 됩니다

그러므로 아래의 예제는 Container프로토콜을 준수합니다

struct IntStack: Container {
    // original IntStack implementation
    var items: [Int] = []
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }
    // conformance to the Container protocol
    typealias Item = Int
    mutating func append(_ item: Int) {
        self.push(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Int {
        return items[i]
    }
}

❗️주의❗️
여기서 typealias 구문은 정석적으로 명시해주는게 맞으나
'컴파일러가 추론 가능한 상황'이라면 생략할 수 있긴 합니다

위 예제처럼 Int에 대해서만 모든 요구사항을 맞췄다면
컴파일러는 Int가 Item임을 추론할 수 있습니다

2. Generic 타입이 준수할 때

아래는 Generic 타입 예시입니다

(여기서도 typealis Item = Element를 써주는게 정석이나 생략할 수 있습니다)

struct Stack<Element>: Container {
    // original Stack<Element> implementation
    var items: [Element] = []
    mutating func push(_ item: Element) {
        items.append(item)
    }
    mutating func pop() -> Element {
        return items.removeLast()
    }
    // conformance to the Container protocol
    mutating func append(_ item: Element) {
        self.push(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Element {
        return items[i]
    }
}

✔️ Extension

공식문서에서는 Array를 예시로 들었습니다
하지만, Array는 Container의 요구사항을 이미 준수하고 있기에
빈 extension에 프로토콜만 추가채택시키면 됩니다
(typealias도 추론가능한 상태)

extension Array: Container {}

✔️ 제약걸기

associatedtype을 정의할 때, 제약을 걸어주면 됩니다

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

Container 프로토콜을 준수하기 위해선,
Item이 Equatable해야 한다는 추가 조건이 생겼습니다

✔️ 자기자신 타입을 다루도록 하는 프로토콜

어떤 프로토콜은 자신의 요구사항의 일부로 나타날 수도 있습니다

예를 들면 아래와 같이,
SuffixableContainer의 요구사항에 SuffixableContainer이 등장합니다

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

위 프로토콜의 요구사항을 해석해보면,
func suffix()의 return타입인 Suffix는 2가지 룰이 있습니다

1. SuffixableContainer 프로토콜을 준수하는 타입이어야 합니다

이걸 준수하기 위한 대표적인 방법으론,
이 프로토콜을 채택하려는 타입, 자기자신 타입을 return하도록 만드는 것입니다.

물론 아래 예제와 같이,
SuffixableContainer을 채택하는 다른 타입이 와도 되긴 합니다

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
    }
    // Inferred that Suffix is Stack<Int>.
}

2. Container 프로토콜을 준수시키느라 결정한 Item 타입과 같아야 합니다

우선, 프로토콜 상속을 사용하므로
SuffixableContainer를 준수하려면, Container도 준수해야 합니다

Container를 준수시키느라 append() 등에 사용한 타입이 있을텐데
이 타입과 Suffix의 Item이 같은 타입이어야 합니다

물론, 위의 1번에서 대표적인 방법이라고 소개한 '자기자신 타입을 Suffix로 지정'한다면 이 조건은 신경쓸 필요가 없습니다 (같을 수 밖에 없기 때문)

하지만, '자기자신이 아닌 다른 타입을 Suffix로 지정'하고자 할 때는 조건이 맞지 않을 수 있습니다
Suffix로 쓰려는 다른 타입 또한 SuffixableContainer를 준수하므로
Container도 준수하려고 Item으로 지정한 타입이 있을텐데
그 타입과 현재 타입의 Item은 서로 다른 타입일 수 있기 때문입니다

역시 말로만 치면 힘드므로 예제를 만들어보았습니다

extension IntStack: SuffixableContainer {
    func suffix(_ size: Int) -> Stack<Double> {  //Error!
        var result = Stack<Double>()
        for index in (count-size)..<count {
            result.append(self[index])
        }
        return result
    }
    // Inferred that Suffix is Stack<Int>.
}

위에서 Stack<Double> 역시
SuffixableContainer를 준수하므로 1번 조건은 맞춰졌지만
Item 타입이 서로 다르므로 (Double / Int) 2번 조건이 맞춰지지 않아
에러를 유발합니다


🐻‍❄️ Generic Where 구문

Type 제약은 Generic과 관련한 type paramter에 요구사항을 정의할 수 있게 해줍니다

이는 associated type에 대한 요구사항을 정의할 때도 유용합니다
'Generic Where 구문'을 정의함으로써 그렇게 할 수 있습니다
(바로 위 section에서도 다룸)

Generic where 구문을 사용하여, associated type에 걸 수 있는 조건

  • 특정 프로토콜을 준수할 것
  • 특정 type parameter와 동일한 타입일 것

where 키워드로 시작해서 constraint 혹은 equality 관계를 명시하면 됩니다

✔️ 예제

아래는 Generic 함수에 where 구문을 추가한 예시입니다
where구문은 return 타입 뒤에 옵니다

func allItemsMatch<C1: Container, C2: Container>
    (_ someContainer: C1, _ anotherContainer: C2) -> Bool
    where C1.Item == C2.Item, C1.Item: Equatable {

        // Check that both containers contain the same number of items.
        if someContainer.count != anotherContainer.count {
            return false
        }

        // Check each pair of items to see if they're equivalent.
        for i in 0..<someContainer.count {
            if someContainer[i] != anotherContainer[i] {
                return false
            }
        }

        // All items match, so return true.
        return true
}

보이는 바와 같이, allItemsMatch()함수는 C1.Item과 C2.Item이 같으면서 Equatable해야 사용할 수 있습니다

🐨 Extension에서의 Generic Where 구문

Generic 타입/프로토콜에 어떤 기능을 추가하려는데
특정 조건에 부합하는 type parameter에 대해서만 추가하고 싶다면
extension에 where조건을 걸어 구현할 수 있습니다

extension에 Generic 조건을 걸 수 있습니다
(사실 위 section에서 이미 다뤘음)

아까봤던 예제를 다시 봅시다

extension Stack where Element: Equatable {
    func isTop(_ item: Element) -> Bool {
        guard let topItem = items.last else {
            return false
        }
        return topItem == item
    }
}

만약 이 예제에서 where 조건을 걸 수 없었다면,
==연산자를 코드에 넣는데 있어 문제를 겪었을 것입니다

Protocol의 extension에도 where 조건을 걸 수 있습니다
이것도 이미 본 것 같은데..

extension Container where Item: Equatable {
    func startsWith(_ item: Item) -> Bool {
        return count >= 1 && self[0] == item
    }
}

Item에 비교연산자를 사용하기 위해 Equatable해야 한다는 조건을 설정

조건은 프로토콜이 아닌 특정 타입인지 여부를 확인하는 것일 수 있습니다

extension Container where Item == Double {
    func average() -> Double {
        var sum = 0.0
        for index in 0..<count {
            sum += self[index]
        }
        return sum / Double(count)
    }
}
print([1260.0, 1200.0, 98.6, 37.0].average())
// Prints "648.9"

조건을 여러 개 걸 수도 있습니다
콤마로 구분하면 됩니다

extension temptemp where Item: Equatable, Item == Int {
    func printprint() {
        print("good")
    }
}

🐯 where 조건을 작은 단위로 걸 수도 있다 (contextual)

조건을 타입 통째로 걸지 않고

특정 subscript나 메서드에만 where 조건을 걸 수도 있습니다
(물론, 해당 type이 Generic type이어야 함)

이를 문서에선 contextual where clause라고 표현합니다

아래 예제에서 Container 구조체는 Generic이며
extension으로 추가하는 메서드 별로 조건을 걸어주고 있습니다

extension Container {
    func average() -> Double where Item == Int {
        var sum = 0.0
        for index in 0..<count {
            sum += Double(self[index])
        }
        return sum / Double(count)
    }
    func endsWith(_ item: Item) -> Bool where Item: Equatable {
        return count >= 1 && self[count-1] == item
    }
}

average()와 endsWith()는 원본 정의부에 있는 Item type paramter에 각각 type 제약을 걸고 있습니다

extension에 걸어서 분리시켜는 방식도 있다
위 예제에서 의도한 것처럼, 메서드별로 서로 다른 조건을 걸어주기 위한 방법으로
extension에 type 제약을 걸고 extension을 분리시켜버리는 방법도 있습니다

extension Container where Item == Int {
    func average() -> Double {
        var sum = 0.0
        for index in 0..<count {
            sum += Double(self[index])
        }
        return sum / Double(count)
    }
}
extension Container where Item: Equatable {
    func endsWith(_ item: Item) -> Bool {
        return count >= 1 && self[count-1] == item
    }
}

개인적으로 이게 가독성이 더 좋아보입니다


🦁 Associated Type에서의 Generic 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
}

위 예제를 해석해보면
Container 프로토콜을 준수하려면 Iterator의 타입을 정의해야 하는데
이 Itertor는 IteratorProtocol을 준수하면서 Element가 Item과 동일한 타입이어야 합니다

프로토콜 상속관계가 있는 경우
부모 프로토콜(아래 예제에선 Container)의 associated type(Item)으로 조건을 걸 수도 있습니다

protocol ComparableContainer: Container where Item: Comparable { }

🐮 Generic subscript

subscript 역시 generic으로 정의할 수 있고 where 조건을 걸 수 있습니다

extension Container {
    subscript<Indices: Sequence>(indices: Indices) -> [Item]
        where Indices.Iterator.Element == Int {
            var result: [Item] = []
            for index in indices {
                result.append(self[index])
            }
            return result
    }
}

위 예제를 해석하면
1. subscript의 index는 Sequence 프로토콜을 준수할 것
2. 또한, Sequence.Iterator.Element의 타입이 Int일 것
(self[index]result가 같은 index 타입을 사용하도록)
3. return 타입은 Container 원본 정의부에 정의한 Item과 같은 타입의 배열일 것

이 제약들을 종합해보면 indices로 전달되는 값은 integer의 sequence임을 의미합니다

좋은 웹페이지 즐겨찾기