이번 포스트에서는 Generic에 대해서 알아보고자 합니다. Generic은 Swift의 강력한 기능들 중 하나로, 유연하고 재사용성이 높은 코드를 작성하게 돕습니다.

이 포스트는 다음 공식 문서를 참조하여 작성되었습니다. Language Guide - Generics

Generic을 간단하게 표현하면, 타입을 인자로 받아 새로운 타입이나 함수를 만들어 내는 것입니다. 이를 이용해서 타입은 달라도 동작이 비슷한 타입이나 함수를 추상화시킬 수 있습니다.

예를 들어 다음과 같은 함수가 있다고 생각해봅시다.

// 두 Int 타입의 값을 바꾸는 함수
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA = a
    a = b
    b = temporaryA
}

하지만 만약 Int 이외의 다른 타입에 대해서도 같은 기능이 필요하다면 별도로 정의해야 합니다.

func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let temporaryA = a
    a = b
    b = temporaryA
}

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

위의 함수들의 몸체는 보다시피 완전히 동일합니다. 이렇게 같은 매커니즘을 가진 함수들은 타입을 인자로 받도록 하여 다음과 같이 정의할 수 있습니다.

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

var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt) // 실제 호출 할 때는 타입 추론으로 자동으로 타입이 결정됩니다.

Generic을 쓸 때는 실제로 쓸 타입을 대신하여 플레이스홀더 타입명(위 예제에서 T로 써있는 것입니다.) 위 정의에서 T는 실제 타입에 대한 정보는 아무것도 가지지 않으며, 단지 a와 b가 같은 타입이여야 함을 나타냅니다. T가 실제 타입으로 결정되는 순간은 함수가 호출될 때 입니다.

C++을 아시는 분들은 C++의 템플릿이 프로그램에서 사용된 타입에 맞는 함수 정의를 컴파일 타임에 컴파일러가 끼워넣는다는 사실을 알 것입니다. 하지만 swift의 제네릭은 런타임에 동적으로 결정되는 것입니다. 대신 최적화 옵션을 키면, c++처럼 컴파일러가 사용된 타입에 맞는 타입을 끼워넣고 이를 호출하게 됩니다.

위에서 플레이스홀더로 사용한 T를 가리키는 정식명칭은 타입 매개변수(Type Parameter)입니다. 타입 매개변수는 함수나 타입 이름 바로 뒤에 나와야만하면 <T>와 같이 사용합니다. 또한 이 타입 매개변수는 2개 이상 쓰는 것도 가능합니다. 이때는 <T1,T2>등과 같이 콤마로 구분합니다.

타입 매개변수 이름을 지을 때는 해당 타입을 사용할때의 의미가 잘 드러나도록 지어야 합니다. 하지만 위에서 제시한 swap 함수와 같이 타입에 특별한 의미가 담겨있지 않는 경우에는 관례적으로 쓰는 T,U,V 등의 이름을 쓰는 경우도 있습니다. 또한 swift 코딩 규칙을 따라 대문자 카멜 케이스로 작성하도록 합니다.

타입 매개변수를 쓰게 되면 함수의 시그니쳐에 해당 타입이 반드시 사용되어야 합니다. 따라서 해당 타입의 인자를 받지 않지만, 내부적으로 타입이 필요하다면 타입 매개변수가 아니라 일반 매개변수로 타입 자체를 넘겨야 합니다.

함수 뿐 아니라, 구조체, 클래스 등의 타입에도 같은 룰을 적용할 수 있습니다. 내부 메소드의 경우도 마찬가지입니다.

struct Stack<Element> {
    var items = [Element]()
    mutating func push(_ item: Element) { //값 타입에서 프로퍼티를 변경하는 인스턴스 메소드는 mutating 키워드를 붙여야만 합니다.
        items.append(item)
    }
    mutating func pop() -> Element {
        return items.removeLast()
    }
}

Generic을 적용한 타입을 확장할 때는 타입 매개변수를 별도로 입력하지 않아도, 본래 정의에서 사용했던 타입 매개 변수를 그대로 사용할 수 있습니다.

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

Generic을 사용하면 어떠한 타입에도 대응 가능하다지만, 때로는 이를 특정 타입만 사용할 수 있도록 제한할 필요도 있습니다. 이를 타입 제한(Type Constraint)라고 합니다. 이를 이용하면 특정 타입의 서브클래스, 특정 프로토콜의 채택 여부 등을 만족해야만 Generic으로 된 기능을 사용할 수 있게 만들 수 있습니다.

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // T : SomeClass 또는 그 서브 클래스
    // U : SomeProtocol을 채택한 타입
}

// 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
}

프로토콜에서도 Generic은 적용이 가능합니다. 다만 프로토콜의 경우는 프로토콜 이름 뒤에 처럼 쓰지 않고, 프로토콜 정의 내부에 **associatedtype** 을 사용합니다.

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

타입 매개변수와 마찬가지로 Item 에는 타입에 대한 아무런 정보가 없습니다. 실제 타입은 프로토콜을 채택하고 정의할 때에야 비로소 결정됩니다.

struct IntStack: Container {
    // 본래 Intstack 부분
    // 여기의 Int를 모두 Item으로 바꿔도 됩니다.
    var items = [Int]()
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }

    // 프로토콜 구현부
    typealias Item = Int //associatedtype은 typealias로 실제 타입으로 바뀌게 됩니다.
    mutating func append(_ item: Item) {
        self.push(item)
    }
    var count: Item {
        return items.count
    }
    subscript(i: Int) -> Item {
        return items[i]
    }
}

associatedtype은 프로토콜의 필수 구현 만으로도 타입 추론이 가능하면 명시적으로 써주지 않아도 됩니다.

프로토콜에서도 타입 제한을 걸 수가 있는데, 사용법도 거의 동일합니다.

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

Generic은 타입 자체 뿐 아니라 해당 타입과 연관된 타입에도 제한을 걸 수 있습니다. 이는 where 키워드를 사용한다고 해서 Generic Where 구문이라고 합니다. where 구문의 위치는 함수나 타입의 중괄호 바로 앞입니다.

func allItemsMatch<C1: Container, C2: Container>
    (_ someContainer: C1, _ anotherContainer: C2) -> Bool
    where C1.Item == C2.Item, C1.Item: Equatable { 
        // 컨테이너 타입 자체를 비교하지 않고 
        // 컨테이너가 가지고 있는 원소의 타입이 같은지 비교합니다.
        // 이렇게 하면 다른 컨테이너라도 원소 비교가 가능해집니다.

        if someContainer.count != anotherContainer.count {
            return false
        }

        for i in 0..<someContainer.count {
            if someContainer[i] != anotherContainer[i] {
                return false
            }
        }

        return true
}

이 Generic Where 구문은 타입을 확장할 때도 똑같이 적용이 가능합니다.

// 원래 정의에는 Element에 제한이 없었습니다.
extension Stack where Element: Equatable {
    func isTop(_ item: Element) -> Bool {
        guard let topItem = items.last else {
            return false
        }
        return topItem == item // 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
}

Generic은 subscript에도 적용이 가능합니다. 사용법은 함수에서 적용할 때와 같습니다.

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
    }
}

기초란 것도 파고들면 파고들수록 흥미로운 것들이 계속해서 나오는 것 같습니다. 다음에도 좀 더 흥미로운 주제를 가지고 살펴보도록 하겠습니다!