Generics

Jake Yeon·2021년 2월 2일
0

Swift

목록 보기
3/6
post-thumbnail

Generics

제네릭 코드을 사용하게 되면 코드를 유연하고 재 사용성있게 작성할 수 있다.
즉, 재 사용가능한 함수와 타입이 어떤 타입과도 작업할 수 있도록 요구사항을 정의한다.
따라서 타입 별로 작성해야하는 중복을 피할 수 있고, 추상적인 방법으로 코드를 작성할 수 있다.
실제 Swift 표준 라이브러리 대부분은 Generic으로 작성되어 있으며, 예시로 array, dictionary 가 있다.

문제 발생 예시 코드

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

다음과 같이 Int형 변수 두개를 받아서 스왑해주는 함수가 있다고 할 때
Int가 아닌 다른 타입에 대해서 swap을 하려면 타입 마다 함수를 만들어주어야한다.

이때 스왑해주는 함수의 경우에는 a와 b의 타입이 같아야만 한다.
Swift는 type-safe한 언어이기 때문에 다른 타입의 변수들이 서로 스왑되는 것을 허락하지 않기 때문이다.
따라서 다른 값을 스왑하려고 한다면 complie-time error가 발생한다.

제너릭 함수(Generic Function)

이러한 문제를 Generic을 사용해서 해결할 수 있다.

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

Type Parameters

위의 함수에서 T타입 파라미터 라고 한다.

그리고 T 는 Placeholder 타입의 "이름"을 뜻한다.
Generic 함수의 함수 이름 옆에는 <>(대괄호)가 오게 되는데,
이는 Swift에게 함수 정의 내에 Placeholder 타입의 이름임을 알려주는 것이다.
따라서 T 말고 다른 문자를 사용해도 된다.

하나 이상의 타입을 사용할 수 있으며 이때 대괄호 안에 타입 파라미터 이름을 콤마로 분리하여 작성한다. e.g. <T, V>

Naming Type Parameters

Dictionary<Key, Value> 에서 Key, Value도 바로 타입 파라미터인데,
이와 같이 key, value로 이름을 지은 것은 Dictionary가 key와 value로 이루어진 쌍들의 모임이기도하고,
실제로 key와 value라는 이름을 가지고도 어떠한 관계를 가지는지 알 수 있기 때문이다.
즉, Dictionary의 타입 파라미터의 네이밍은 Dictionary와 관계가 있는 이름이다. (자신의 역할이 담겨있다.)

그러나 위의 예시처럼 구현된 Generic 함수의 경우에는 타입 파라미터와 관계가 있지 않다.
따라서 위와 같이 TV 와 같은 단일 문자를 사용하는 것이 일반적이라고 한다.

Type Parameter의 네이밍은 upper camel case를 사용한다고 한다.

제네릭 타입(Generic Type)

제네릭 함수를 사용하면 Swift에서 Generic Type을 정의할 수 있다.
사용자 클래스, 구조체 그리고 열거형은 어떤 타입으로도 작업할 수 있으며 유사하게 배열과 딕셔너리가 있다.

예를 들어 stack 이라는 제네릭 컬렉션 타입이 있다고 하자.
stack은 배열과 유사하지만 들어오는 순서대로 값이 저장되고 삭제도 모든 인덱스가 아닌
가장 마지막 인덱스에서만 가능하다.

// Int 버전
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()
    }
}

위의 스왑함수와 동일하게 처음에는 Int 형 원소들만 저장할 수 있는 IntStack이 있고
이에 대해서 다른 타입들도 저장할 수 있는 제네릭 타입의 stack은 다음과 같다.

제네릭 타입 확장 (Extending a Generic Type)

제네릭 타입을 확장할 때는, 확장의 정의 한 부분으로서 타입 파라미터 목록을 제공하지 않는다.
대신, 기존 타입 정의로부터 타입 파라미터 목록은 확장의 본문 내에서 가능하며,
기존 타입 파라미터 이름기존 정의로부터 타입 파라미터를 참조하는데 사용된다.

위에서 예시로 들었던 제네릭 stack 타입에 topItem이라는 연산프로퍼티가 추가된다.

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

그 결과 topItem이라는 연산 프로퍼티는 items가 비었으면 nil, 그렇지 않으면 가장 마지막 원소를 반환하게 된다.
이때 확장은 타입 파라미터 목록을 정의하지 않음에 유의해야한다.
대신 스택 타입의 기존 타입 프로퍼티 이름인 Element는 topItem 계산 속성의 옵셔널 타입을 나타내는 확장에 사용된다.

타입 제약 (Type Constraints)

기존의 위에서 언급했던 swapTwoValues은 제네릭 함수로 구현되어서 모든 타입에서 정상적으로 작동할 것이다.
그러나 제네릭 함수와 제네릭 타입을 사용할 때 특정 타입으로 강제하면 유용한 경우가 있다.
이러한 것을 바로 타입 제약이라고 한다.

이를 통해서 타입 파라미터특정 클래스로부터 상속되거나,
특정 프로토콜을 준수해야만 하는 Generic 함수를 쓸 수 있도록 제약을 걸어줄 수 있다.

위에서 언급했던 또 다른 제네릭 타입인 Swift의 Dictionary도 사실 타입 제약이 걸려있다.
바로 Dictionary의 key들은 hashable 프로토콜을 따라야만 한다는 것이다.

@frozen public struct Dictionary<Key, Value> where Key : Hashable {

    /// The element type of a dictionary: a tuple containing an individual
    /// key-value pair.

따라서 Dictionary를 타고 들어가보면
where 절을 사용해서 Key가 Hashable 프로토콜을 준수해야한다고 타입 제약이 걸려있는 것을 볼 수 있다.

Swift의 기본 타입들인 Int, String, Double, Bool과 같은 타입들은 전부
Hashable protocol 준수하고 있기 때문에 Dictionary의 Key로 사용할 수 있었던 것이다.

타입 제약 문법 (Type Constraint Syntax)

배열에서 문자열을 찾는 예제를 보자.

// 배열에서 문자열의 index를 찾는 함수
func findStringIndex(array: [String], valueToFind: String) -> Int? {
    for (index, value) in enumerate(array) {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

// 제네릭 버전 - 배열에서 다른 타입의 Index를 찾는 함수
func findIndex<T>(array: [T], valueToFind: T) -> Int? {
    for (index, value) in enumerate(array) {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

이때 index를 찾아야하므로 제네릭 버전이어도 반환값은 Int? 이다.
그러나 위의 제네릭 함수는 컴파일 타임에 에러가 발생하게 된다.

왜냐하면 Swift의 모든 타입이 동등 연산자(==)로 비교될 수 없기 때문이다.
따라서 이를 해결하기 위해서는 아래와 같이 Equatable 타입 제약을 걸어주어야한다.

// 제네릭 버전 - 배열에서 다른 타입의 Index를 찾는 함수
func findIndex<T: Equatable>(array: [T], valueToFind: T) -> Int? {
    for (index, value) in enumerate(array) {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

Equatable은 protocol로서 값이 동일한지 어떤지를 비교할 수 있는 타입이다.
즉, Equatable protocol을 채택한 타입은
동등 연산자(==) 또는 같지 않음 연산자(!=)를 사용하여 동등성을 비교할 수 있게 된다.

Swift의 표준 라이브러리의 대부분 기본 데이터 타입은 Equatable을 채택하고 있다.

따라서 위의 제네릭 버전의 findIndex에서 T 타입 파라미터가
Equatable protocol을 준수하게 되면 컴파일 타임 에러 없이 작동하게 된다.

연관 타입 (Associated Type)

프로토콜을 정의할 때, 프로토콜 정의의 한 부분으로서 하나 이상의 연관 타입을 선언하면 유용하다.
연관 타입은 자리표시 이름을 타입에 주며 이는 프로토콜의 부분으로서 사용된다.

즉, 연관 타입을 위한 실제 타입 사용은 프로토콜이 채택될 때까지 정해지지 않는다.
연관타입은 typealias 키워드로 지정되게 된다.

protocol JakeProtocol {
    var height : Double { get }
}

즉 위와 같은 프로토콜이 있을 때 height 프로퍼티가 Double 뿐만 아니라
다른 타입인 Int, String 등이 될 수 있다면 이러한 경우에 연관 타입을 사용하면된다.

아래와 같이 사용하면 된다.

protocol JakeProtocol {
    typealias MyType
    var height : MyType { get }
}

Associated Types in Action

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

Container라는 Item 이라는 연관 타입을 가지는 프로토콜이 있다.
이때 Container 프로토콜은 세 가지 요구사항에 대해서 정의한다.

  • append method를 통해서 새로운 아이템을 컨테이너에 추가할 수 있다.
  • count 속성은 아이템의 갯수를 Int 타입으로 반환해준다.
  • 컨테이너에 서브스크립트는 Int 인덱스 값을 가지고 각 요소를 받을 수 있다.

프로토콜은 구현을 하지 않으므로, 실제 컨테이너 내에 저장하는 방법이나 Item의 타입을 지정하지 않는다.

그렇다면 이러한 Container protocol을 채택하는 구조체를 살펴보자.

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

위의 코드는 IntStack으로 Container 프로토콜을 채택하게 된다.
따라서 protocol의 요구사항 세가지를 구현하게 되며 여기서 Item = Int 로정의를 해주므로써
Item의 추상 타입을 Container 프로토콜의 구현을 위해서 Int라는 구체화된 타입으로 바뀌게 된다.

사실 이때 typealias Item = Int 이 부분은 작성해 주지 않아도 오류 없이 작동하게 된다.
그 이유는 바로 Swift의 타입 추론 덕분이다.

그렇다면 이번에는 IntStack이 아닌 제네릭 타입의 Stack에 Container Protocol을 채택해보자.

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

다음과 같이 작성할 수 있으며 이때도 역시 typealias를 생략할 수 있다.

기존 타입에 연관 타입을 지정하여 확장 (Extending an Exisiting Type To Specify an Associated Type)

프로토콜에 일치하도록 추가하여 기존 타입을 확장할 수 있다.

Swift 배열 타입은 이미 append 메소드, count 속성
그리고 요소를 반환받기 위한 Int 인덱스를 사용하는 subscript를 제공한다.
즉, 위의 세가지 기능은 Container 프로토콜의 요구사항과 일치하므로
Swift의 배열 타입은 Container 프로토콜을 도입하도록 선언하는 것만으로도 일치하여 확장이 가능하다는 것이다.

extension Array: Container {}

이때 배열의 기존 append 메서드와 subscript는 Swift가 Item을 위한 적절한 타입을 추론하여 사용하도록 한다.

연관 타입에 타입 제약 걸기 (Adding Constraints To an Associated Type)

연관 프로퍼티에도 타입 제약을 걸어줄 수 있다.

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

위의 코드와 같이 걸어줄 수 있고,
Container 프로토콜을 채택하기 위해서는 Container의 Item 타입이 Equatable protocol을 채택해야만 한다.

Where 절

타입 제약은 타입 파라미터의 요구사항에 제네릭 함수나 타입을 연관하여 정의할 수 있게 한다.
< 추가 작성 필요,,>

참고

  1. Apple Document
  2. ZeddiOS 블로그 - Generics
  3. ZeddiOS 블로그 - Equatable
  4. ZeddiOS 블로그 - Associated Type
  5. Minsone 블로그 - Generics
profile
Hope to become an iOS Developer

0개의 댓글