[Swift] Generics
👨🏻‍💻iOS 공부/iOS & Swift

[Swift] Generics

728x90
반응형

Generics

Generic 코드는 유연하고, 재사용가능한 함수와 타입을 사용할 수 있게 하여, 어느 타입이건 작업을 수행할 수 있고, 작업자가 정의한 대로 수행할 수 있다. 이를 통해 중복을 방지하고, 의도를 명확하고 추상적으로 표현하는 코드를 작성할 수 있다. 

 

Generics는 swift가 지원하는 가장 파워풀한 기능 중 하나이다. 실제로 사용되고 있는지는 보지 못했겠지만, 자주 사용하고 있는 Array나 Dictionary 타입들은 모두 generic collection이다. Array은 Int형으로 만들 수도 있고, String 타입으로 만들 수도 있고 더 나아가 다른 타입으로도 구성할 수 있다. 유사하게 딕셔너리의 경우도 타입을 지정하고 값을 저장할 수 있으며, 그 타입에는 제한이 없다. 

 

즉, 한 줄로 정리하자면 다음과 같다. 

 

Generic은 타입을 추상화하여 불필요한 중복을 방지하고, 재사용성을 높일 수 있는 표현법이다. 

 

차근차근 예시를 보면서 이해해보자. 

 


The Problem That Generics Solve

Generics가 해결할 수 있는 문제들에 대해서 볼 것이다. 

 

아래의 nongeneric한 swapTwoInts(_ : _ :)는 두 개의 Int 값들을 바꿔주는 역할을 한다. 

 

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

in-out 파라미터를 사용하여 값들을 교환하고 있는 것을 볼 수 있다. 

in-out 파라미터란 주어진 함수의 파라미터를 함수 내부에서 변경하고 값으로 반환할 수 있도록 해주는 역할을 한다. 그래서 return이 없게 되는 것이다. 

 

다시 돌아와서, swapTwoInts(_ : _ :) 함수는 b의 값을 a와, a의 값을 b와 바꿔주는 역할을 한다. Int형에 대해 지원하기에 두 개의 Int 타입 데이터를 넣어 확인해볼 수 있다. 

 

var someInt = 3
var anotherInt = 107
swapTwoInts(&someInt, &anotherInt)
print("someInt is now \(someInt), and anotherInt is now \(anotherInt)")
// Prints "someInt is now 107, and anotherInt is now 3"

swapTwoInts(_ : _ :) 함수는 두 개의 Int 값을 바꾸는데 유용하지만, 오직 Int 타입에 대해서만 사용할 수 있다. 만약에 Int가 아닌 String이나 Double인 두 개의 값을 바꿔주고 싶다면 위 함수를 사용하지 못하고 2개의 함수를 더 만들어줘야 할 것이다. swapTwoStrings(_ : _ :) 나 swapTwoDoubles(_ : _ :) 처럼 내부 로직은 동일하지만 다른 함수들을 만들어줘야 한다.

 

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
}

swapTwoInts(_ : _ :) 와 swapTwoStrings(_ : _ :) 와 swapTwoDoubles(_ : _ :)는 어떤 차이가 있을까?

 

입력값의 "타입"에만 그 차이가 있다. 

 

위 처럼 3개를 만들어서 사용하는 것은 비효율적이기에, 하나의 함수로 유연하게 지원해주는 것이 보다 더 효율적이다.

즉, 처음에 봤었던 것 처럼 타입을 추상화하여 하나의 함수로 다양한 타입의 입력값을 받을 수 있도록 하는 것이 바람직하다. 

 

a와 b는 같은 타입이어야 한다. 그래야 비교가 가능하고, 그렇지 않다면 컴파일 에러를 불러일으킨다

 

자 이제 Generic을 사용하여 위 함수를 다시 표현해보자. 

 

Generic Functions

Generic 함수는 어느 타입이 들어와도 작업을 수행해낼 수 있다. 아래의 버전은 Generic을 사용한 것으로 swapTwoValues(_ : _ : )라고 부르자. 

 

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

 

함수의 body부분은 이전에 구현했던 swapTwoInts(_ : _ : )와 동일하다. 하지만 첫 줄이 다른 것을 알 수 있다. 첫 줄만 떼어서 봐보자.

 

// Int, nongeneric
func swapTwoInts(_ a: inout Int, _ b: inout Int)

// Generic
func swapTwoValues<T>(_ a: inout T, _ b: inout T)

Generic 버전의 함수는 실제 타입을 대신한여 placeholder 타입(여기서는 T)의 이름을 사용한다. placeholder 타입의 이름은 T가 무엇이어야 하는지 말하지 않지만, a와 b는 동일한 T 타입이어야 한다는 것을 말하고 있다. swapTwoValues(_ : _ : ) 함수가 호출될 때 실제 타입이 T를 대체하여 들어가며, 각 호출 시점마다 실제 타입이 결정되어 실행된다. 

 

또 다른 차이점은 함수 이름 옆에 <T>라고 표기되어있는 부분이다. ("<", ">"는 angle bracket이라고 한다) placeholder의 이름은 이 angle bracket 사이에 적어주게 되며, 해당 함수에 정의된다. 여기서는 T가 placeholder이기 때문에 Swift는 T를 실제 타입으로 보지 않는다. 

 

swapTwoValues(_ : _ : )는 타입에 상관없이 두 값을 넣는다는 것만 빼고 swapTwoInts(_ : _ : )와 동일한 방식으로 호출된다. swapTwoValues(_ : _ : ) 함수가 호출되면, 함수에 주어진 값들의 타입을 추론하여 T에 반영된다!

 

아래의 두 예시는 T가 Int와 String으로 추론되는 것을 각각 보여준다. 

var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt)
// someInt is now 107, and anotherInt is now 3

var someString = "hello"
var anotherString = "world"
swapTwoValues(&someString, &anotherString)
// someString is now "world", and anotherString is now "hello"

 

계속 swapTwoValues(_ : _ : )라는 함수를 만들어서 사용하고 있는데, 실제로 swap(_: _:)이라는 함수가 있으니 이걸 사용하도록 하자!

 

Type Parameters

위의 swapTwoValues(_ : _ : ) 의 예시를 보면, placeholder 타입인 T가 타입 파라미터(type parameter)의 예시이다. 타입 파라미터는 placeholder 타입에 이름을 붙이고 명확하게 하고, 함수 이름 바로 뒤에 적고 angel bracket 사이에 적어준다 (ex. <T>)

 

타입 파라미터를 구체화했다면, 이제 함수의 파라미터들의 타입이나 함수의 리턴값, 함수 내부의 타입이 들어가는 부분을 정의하는데도 사용할 수 있다. 각각의 케이스들에서 타입 파라미터는 함수가 호출될 때 실제 타입으로 대체된다. 즉 T에 Int형 데이터를 넣었다면 T는 Int 타입으로 대체되여 실행되는 것이다. 

 

하나 보다 더 많은 타입 파라미터를 만들 수 있으며, angle bracket 안에 넣고 콤마로 구분해줘야 한다. (<T, U>)

 

Naming Type Parameters

모든 경우에, 타입 파라미터들은 Dictionary<Key, Value>의 Key, Value나 Array<Element>의 Element같이 기술적인 이름을 가지고 있다. 즉 읽는 사람이 타입 파라미터의 관계를 쉽게 알 수 있고 함수에 사용할 수 있게 적어두어야 한다는 것이다. 하지만 관계 자체가 없는 타입 파라미터의 경우 전통적으로 T, U, V와 같은 단일 문자를 적어주어 표현한다.

 

Generic Types

generic 함수 외에도, Swift를 사용하여 고유한 generic 타입을 정의할 수 있다. 이는 Array나 Dictionary처럼 어느 타입과도 작업을 수행할 수 있는 커스텀한 class, struct, enum을 말한다. 

 

예시로 Stack이라는 generic collection 타입을 활용해서 이해해보자. stack은 정렬된 값들의 집합으로 배열(array)과도 유사하지만 더 많은 제약들이 있다. 배열은 새 아이템을 넣을 때나, 아이템을 제거할 때 원하는 위치에서 할 수 있지만 stack의 경우 LIFO(Last In First Out) 형태이기 때문에 가장 마지막에 값을 추가하고, 마지막 값만 추출할 수 있다. 

( Stack에 대해 더 궁금하다면 -> Stack이란? )

 

아래의 그림은 stack의 push와 pop을 그림으로 나타낸 것이다. 

 

1. 3개의 값을 가지는 Stack
2. 4번째 값이 push되어 stack의 상단에 자리잡는다
3. stack은 4번째 값을 홀드하고 가장 마지막 값으로 가진다
4. stack의 가장 상단의 아이템이 popped된다.
5. popping 이후, 다시 세 번째 아이템을 가장 마지막 값으로 한다. 

여기까지 기본적인 Stack의 동작 방식을 본 것이다. 우선 nongeneric한 방식으로 구현된 stack을 볼 건데, Int형으로 구현해보자.

 

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

아이템을 push하거나 pop할 수 있는 IntStack이라는 타입을 만들었다. mutating 키워드는 struct 내부의 인스턴스 값을 변경해주기 위해서 선언하였다. 

 

IntStack은 Int 타입의 값만을 받을 수 있지만 generic한 Stack을 정의하면 훨씬 더 유용하게 사용할 수 있다. 

generic한 버전을 봐보자. 

 

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

nongeneric 버전과는 <Element> 부분(타입 파라미터)이 다르다. Element는 placeholder 이름을 정의하고 추후 타입을 전달받는다. 그리고 이 Element는 세 군데에서 사용되고 있다. 

 

  • items라는 프로퍼티를 정의하는데 Element 타입의 값들로 이루어진 배열로 초기화해준다.
  • push(_ :) 메서드의 파라미터인 item이 Element 타입이어야 함을 나타낸다.
  • pop(_ :) 메서드의 리턴값이 Element 타입이어야 함을 나타낸다.

generic 타입이기 때문에, swift에서 유효한 타입이기만 하면 모두 적용하여 사용할 수 있다. 

 

타입은 angle bracket 내에 정의하여 사용하면 되고, 메서드 또한 데이터 타입에 맞게 사용하면 된다. 

 

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

위 코드를 도식화 하면 다음과 같다. 

 

여기서 popping을 하면 "cuatro"가 나오게 된다. 

 

let fromTheTop = stackOfStrings.pop()
// fromTheTop is equal to "cuatro", and the stack now contains 3 strings

Extending a Generic Type

Generic 타입을 확장할 때는 확장하는 부분에 타입 파라미터 리스트를 제공하지 않아도 된다. 대신 기존 타입 정의에서의 타입 파라미터 리스트는 확장의 바디 부분에서 사용가능하며 오리지날 타입 파라미터 이름들은 원래 정의에서 추론된다. 

 

아래는 generic한 Stack 타입에 읽기 전용 연산 프로퍼티인 topItem을 만드는 예시이다. (stack의 상단 아이템을 제거하지 않고 인덱싱하여 값을 반환)

 

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

items가 비어있지 않다면 값을 리턴하고, 그렇지 않다면 nil을 리턴하도록 했다. 

 

아까 말했던 것처럼, 확장에는 타입 파라미터 리스트를 정의할 필요가 없다. 대신에, Stack의 타입은 Element라는 이름으로 있고, 이는 topItem 연산 프로퍼티의 옵셔널 타입을 가리킨다. 

 

topItem 연산 프로퍼티는 어떠한 Stack의 인스턴스에도 접근할 수 있다. 

 

if let topItem = stackOfStrings.topItem {
    print("The top item on the stack is \(topItem).")
}
// Prints "The top item on the stack is tres."

generic 타입의 확장은 인스턴스들이 지켜야 할 요구 조건들을 포함할 수 있다. 이는 아래에서 Extensions with a Generic Where Clause 챕터에서 다뤄보자. 

 

Type Constraints

swapTwoValues(_ : _ : ) 함수와 Stack 타입은 어느 타입과도 작업을 수행할 수 있다. 하지만 때로는 generic 타입, 함수에 대해 특정 타입을 강제하는 것이 유용할 때가 있다. 타입 제약(type constraints)는 타입 파라미터가 무조건 특정 클래스를 상속해야 하거나, 특정 프로토콜이나 프로토콜 조합을 준수하여야 한다는 것을 명시한다. 

 

예시로, Swift의 딕셔너리 타입의 Key 타입이 있는데, 이는 Hashble이라는 프로토콜을 무조건 준수해야한다. 왜냐? key가 기존에 값을 가지고 있는지 확인을 해야할 필요가 있기 때문이다. Hashble을 채택하지 않는다면 딕셔너리는 특정 Key로 값을 추가하거나 변경할 수 없고 기존에 딕셔너리에 있는 값 조차 찾을 수 없게 된다. 즉 무조건 지켜야 하는 프로토콜인 것이며, "타입 제약"인 것이다. 

 

사실 Swift의 기본적인 타입들(String, Int, Double, Bool)은 기본적으로 Hashble 프로토콜을 채택하고 있다. 

 

아무튼, 커스텀한 generic 타입을 만들 때 타입 제약을 줄 수 있고, 이 제약들은 generic 프로그래밍에게 힘을 줄 수 있다. 

 

class, protocol로 타입제약을 줄 수 있으나, struct, enum으로는 타입 제약을 주기 어렵다!

 

Type Constraint Syntax

타입 제약은 단일 클래스나 프로토콜을 타입 파라미터 뒤에 콜론( : )으로 구분하여 써준다. 타입 제약의 기본 문법은 아래 generic 함수에서 볼 수 있다. 

 

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

위에 가정한 함수는 두 개의 파라미터를 받고 있다. 첫 번째 타입 파라미터(T)는 SomeClass의 하위 클래스이어야 하며, 두 번째 타입 파라미터(U)는 SomeProtocol을 준수해야 한다.

 

Type Constraints in Action 

 

String 값을 전달하여 String값들로 이루어진 배열에서 원하는 값을 찾는 findIndex(ofString: in: )이라는 nongeneric 함수를 봐보자. findIndex(ofString: in: )함수는 Int형의 옵셔널 값(찾는 값이 있으면 해당하는 인덱스를, 없다면 nil)을 리턴한다. 

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

findIndex(ofString: in: ) 함수는 string 배열에서 원하는 string을 찾고 싶을 때 사용할 수 있다. 

 

let strings = ["cat", "dog", "llama", "parakeet", "terrapin"]
if let foundIndex = findIndex(ofString: "llama", in: strings) {
    print("The index of llama is \(foundIndex)")
}
// Prints "The index of llama is 2"

 

보다 싶이 nongeneric한 함수이기 때문에, String 타입에 대해서만 사용할 수 있다. 이제 generic한 findIndex(of: in: )를 선언해보자. 

 

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

이 대로 컴파일하면 잘 실행될까? 

 

아니다!!! 실행되지 않고 컴파일 에러를 불러일으킨다!!

 

왜냐 "value == valueToFind"를 할 수 없기 때문이다. Swift의 모든 타입들이 (==) 연산자를 사용할 수 있지는 않다. 만약 커스텀한 클래스나 구조체를 만들어 사용하고자 한다면 "동등한지"를 판단하는 것은 사용자의 몫으로 남겨둔다. 즉 프로토콜을 채택해줘야 한다.

 

값들을 비교하기 위해서는 Equatable 프로토콜을 채택해야 한다. 이는 (==)와 (!=) 연산자를 제공해준다. Swift의 모든 타입들은 자동적으로 Equatable 프로토콜을 채택하고 있다. 

 

아래와 같이 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
}

 

findIndex(ofString: in: )의 단일 타입 파라미터는 T: Equatable과 같이 쓰여지고 이는, "아무 타입인 T가 Equatable 프로토콜을 준수한다"라는 의미를 갖는다. 

 

findIndex(ofString: in: ) 함수는 이제 정상적으로 컴파일 되며 다른 타입들에 대해서도 비교할 수 있게 된다. 

let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
// doubleIndex is an optional Int with no value, because 9.3 isn't in the array
let stringIndex = findIndex(of: "Andrea", in: ["Mike", "Malcolm", "Andrea"])
// stringIndex is an optional Int containing a value of 2

 

Associated Types

프로토콜을 정의할 때, 하나 혹은 그 이상의 associated type을 선언하는 것은 프로토콜 정의하는 부분에서 종종 유용하게 사용된다. associated 타입은 프로토콜의 일부에 사용되는 타입에 placeholder 이름을 준다. associated type에 사용될 실제 타입은 프로토콜이 채택되기 전까지 구체화되지 않는다. associated type은 "associated" 키워드로 명명할 수 있다. 

 

Associated Types in Action

여기 Item이라는 associated type을 가지는 Container라고 하는 프로토콜이 있다.

 

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

 

Container 프로토콜은 기능을 제공하기 위해 세 가지 요구사항을 정의하고 있다. 

 

  • append(_ :) 메서드를 통해 새로운 item을 contaner에 추가할 수 있어야 한다.
  • container내의 items의 개수에 접근하여 Int형인 count 프로퍼티로 반환할 수 있어야 한다
  • Int 인덱스 값으로 container내의 각 아이템에 접근하여 값을 가져올 수 있어야 한다. 

이 프로토콜은 container의 items가 어떻게 저장되어야 하는지, 어떤 타입이 허용되는지를 명확히 하고 있지 않다. 프로토콜은 정의만 할 뿐, 채택하는 곳에서 주어진 제약을 지키며 기능을 구현하여 사용해줘야 한다.

 

Container 프로토콜을 준수한다면 값을 저장할 수 있어야 하고, subscript에 맞는 값을 반환할 수 있어야 한다. 즉 1,3번의 내용인 것

 

이러한 요구사항들을 정의하려면 Container 프로토콜이 특정 Container의 타입을 알지 못한 채 container가 보유할 요소의 타입을 참조할 수 있는 방법이 필요하다. Container 프로토콜은 append(_ :) 메서드에 전달되는 모든 값의 타입이 Container 요소 타입과 동일해야 하며 반환값 또한 그 요소 타입과 동일하도록 지정해야한다. 즉 다시 말해 추가되는 값, 반환되는 값이 기존의 타입을 따라야한다는 것이다. 

 

이를 해결하기 위해, associated type인 Item을 프로토콜 내에 선언해주는 것이다. 프로토콜은 Item이 무엇인지 선언해주지 않는다. 그럼에도 불구하고 Item은 Container의 items 타입을 참조할 방법을 제공하고, Container append(_ : ) 메서드와 subscript에 사용하기 위한 타입을 정의하고, Container가 예상대로 동작하도록 한다. 

 

nongeneric한 IntStack 타입에 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]
    }
}

IntStack 타입은 프토토콜의 요구 조건을 다 지키고 있다. 더 나아가 IntStack은 "typealias Item = Int"를 통해 추상적인 타입인 Item을 실제 타입인 Int로 바꿔주게 된다. 

 

Swift에는 고맙게도 타입 추론이 있어서, IntStack를 정의하는 부분에 Item을 Int로 정의하지 않아도 된다. 왜냐하면 append의 추가 요소나 반환값의 타입을 통해 타입 추론을 할 수 있기 때문이다. 그렇기에 "typealias Item = Int"를 지워도 정상적으로 작동할 수 있는 것이다. 

 

generic한 Stack 타입에도 Container 프로토콜을 채택할 수 있다. 

 

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

이번에는 타입 파라미터인 Element가 append(_ :) 메서드와 반환값에 사용되고 있는 것을 볼 수 있다. 따라서 Swift는 Element가 특정 Container의 Item으로 사용하기에 적절한 타입이라고 추론할 수 있다. 

 

 

Extending an Existing Type to Specify an Associated Type

프로토콜 요구사항을 추가하기 위해 기존 타입을 확장할 수 있다. 이는 프로토콜과 함께 associated type을 가진다. 

 

Swift의 배열(Array) 타입은 이미 append(_ :) 메서드나, count 프로퍼티, subscript를 제공한다. 이 세 가지 기능은 Container 프로토콜의 요구사항과 맞아떨어진다. 즉, Array를 확장하여 프로토콜을 채택함으로써 쉽게 Container 프로토콜을 준수할 수 있다. 그냥 빈 확장으로도 가능하다. (이미 다 준수하고 있기 때문!)

 

extension Array: Container {}

 

Adding Constraints to an Associated Type

제약들을 안전하게 지키기 위해 프로토콜 내의 associated type에 타입 제약을 추가할 수 있다. 예를 들어, 다음 코드는 Container의 items가 equatable함을 준수하도록 정의하고 있음을 알 수 있다. 

 

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

Container의 Item 타입은 Equatable 프로토콜을 준수하게 된다.

 

Using a Protocol in Its Associated Type's Constraints

프로토콜은 자체 요구 사항의 일부로 나타날 수 있다. 예를 들어, Container 프로토콜을 수정하여 suffix(_ : )메서드를 만들었다고 하자. suffix(_ : ) 메서드는 뒤에서 부터 n개의 원소를 Suffix 타입의 인스턴스로 저장한다. 

 

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

이 프로토콜에서, Suffix는 아까 본 Container의 Item과 같은 associated type이다. Suffix는 SuffixableContainer 프로토콜을 준수해야하고, Suffix의 Item 타입은 container의 Item 타입과 같아야 하는 두 가지 제약 조건이 있다. 여기서 where절을 통해 제약을 주었는데 이는 아래에서 더 자세하게 살펴보자. 

 

Stack타입을 확장하여 SuffixableContainer 프로토콜을 채택하는 것을 봐보자. 

 

extension Stack: SuffixableContainer {
    func suffix(_ size: Int) -> Stack {
        var result = Stack()
        for index in (count-size)..<count {
            result.append(self[index])
        }
        return result
    }
    // Inferred that Suffix is Stack.
}
var stackOfInts = Stack<Int>()
stackOfInts.append(10)
stackOfInts.append(20)
stackOfInts.append(30)
let suffix = stackOfInts.suffix(2)
// suffix contains 20 and 30

여기서 associated type인 Suffix는 Stack타입이 되고, suffix 메서드는 또 다른 Stack을 반환하고 있다. SuffixableContainer 프로토콜은 Stack 타입 뿐만 아니라 다른 타입의 값을 반환할 수 있다. 아래는 IntStack이 SuffixableContainer을 채택하여 Stack<Int> 타입을 반환하는 것의 예다. 

 

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

 

Generic Where Clauses

타입 제약은 generic 함수, subscript, 타입과 관련된 타입 파라미터에 대한 요구 사항을 정의할 수 있었다. 

 

associated types에 대해 요구사항을 정의할 때도 유용하게 사용된다. 이는 generic where절을 정의하여 수행할 수 있다. generic where 절은 associated type이 준수해야하는 프로토콜이나, 특정 타입 파라미터와 associated type이 같아야 함을 요구할 수 있다. 

where을 키워드로 하며, 함수의 바디가 시작되는 중괄호 이전에 적어줘야 한다. 

 

alItemsMatch의 이름을 가지는 generic 함수를 통해 두 개의 Container 인스턴스가 같은 Items를 동일한 순서로 가지고 있는지 살펴볼 것이다. 이 함수는 Bool 형태로 값을 반환한다. 

 

두 개의 containers는 서로 같은 타입일 필요는 없지만 동일한 타입의 Items를 가져야 한다. 이 요구사항은 where절과 타입 제약을 사용하여 표현한다. 

 

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
}

 

 

  • C1은 Container 프로토콜을 준수해야 한다. (C1: Container)
  • C2는 Container 프로토콜을 준수해야 한다. (C2: Container)
  • C1의 Item은 C2의 Item과 같아야 한다. (C1.Item == C2.Item)
  • C1의 Item은 Equatable 프로토콜을 준수해야 한다. (C1.Item: Equatable)

1,2번째 요구사항은 함수의 파라미터 리스트에 정의되어 있고, 3,4번째 요구사항은 generic where절에 정의되어 있다. 

 

이 요구사항들은 alItemsMatch(_ : _ : ) 함수가 두 containers가 서로 다른 타입이어도 비교할 수 있게 해준다.

 

alItemsMatch(_ : _ : ) 함수가 실행되면 두 개의 containers가 동일한 갯수의 Items를 가지는지 먼저 확인한다. 같다면 true, 다르다면 false를 반환한다. 확인 이후, Items를 돌며 순서가 맞는지 확인해주는 차례를 거친다. 마찬가지로 같다면 true, 다르다면 false를 반환한다. 반복문이 불일치 없이 종료되면 함수는 true를 반환하게 된다.

 

실제 alItemsMatch(_ : _ : ) 함수의 사용 사례를 보자. 

 

var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")

var arrayOfStrings = ["uno", "dos", "tres"]

if allItemsMatch(stackOfStrings, arrayOfStrings) {
    print("All items match.")
} else {
    print("Not all items match.")
}
// Prints "All items match."

서로 다른 타입이지만, 보유하고 있는 아이템들을 잘 비교하여 결과를 리턴한 것을 볼 수 있다. 

 

Extensions with a Generic Where Clause

확장시에도 generic where 절을 사용할 수 있다. Stack에 isTop(: _) 메서드를 추가하는 예를 보자. 

 

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

 

extension의 generic where절을 통해 == 연산이 가능해지는 것을 볼 수 있다. 

 

if stackOfStrings.isTop("tres") {
    print("Top element is tres.")
} else {
    print("Top element is something else.")
}
// Prints "Top element is tres."

만약 stack의 elements들이 equatable하지 않다면 컴파일 에러를 불러일으킨다. 

 

struct NotEquatable { }
var notEquatableStack = Stack<NotEquatable>()
let notEquatableValue = NotEquatable()
notEquatableStack.push(notEquatableValue)
notEquatableStack.isTop(notEquatableValue)  // Error

 

물론 프로토콜을 확장할 때에도 generic where절을 사용할 수 있다. 

 

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

 

startsWith(_ : ) 메서드는 값이 한 개 이상인지, 원하는 값인지 비교하여 반환해준다. 

 

if [9, 9, 9].startsWith(42) {
    print("Starts with 42.")
} else {
    print("Starts with something else.")
}
// Prints "Starts with something else."

 

generic where 절은 Item이 특정한 타입과 같도록 사용할 수도 있다. 

 

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"

 

앞에서도 봤겠지만 where절에 콤마로 구분하여 여러 요구사항들을 나열할 수 있다. 

 

Contextual Where Clauses

generic where절은 타입 제약이 아닌 이미 작동하고 있는 타입에 대해서도 사용할 수 있다. 

 

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
    }
}
let numbers = [1260, 1200, 98, 37]
print(numbers.average())
// Prints "648.75"
print(numbers.endsWith(37))
// Prints "true"

average() 메서드는 Container의 아이템이 int일 때, endsWith(_ :) 메서드는 아이템이 Equatable할 때를 요구사항을 주고 있음을 알 수 있다. 

 

만약 위 코드를 contextual where절 없이 사용하고 싶다면 두 개의 확장을 해주면 된다. 

 

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

generic where절을 활용한 방식으로, 동작에는 차이가 없다!

 

Associated Types with a Generic Where Clause

물론 associated type에도 generic where절을 포함시킬 수 있다. Container에 iterator를 포함시키고자 할 경우 이에 맞는 프로토콜을 채택해주면 된다. 

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
}

Iterator에 구현된 generic where절은 iterator가 iterator의 타입과 상관없이, 컨테이너 내의 아이템이 동일한 타입일 때, 모든 원소들을 넘어다닐 수 있어야 한다는 것을 요구사항으로 적어두었다. makeIterator() 함수는 컨테이너의 iterator에 접근할 수 있도록 해준다. 

 

프로토콜에 다른 프로토콜을 상속할 때 generic where절을 사용하여 상속된 associated type에 제약을 걸 수 있다. 아래의 코드는 Item이 Comparable을 준수하도록 하는 ComparableContainer 프로토콜이다. 

 

protocol ComparableContainer: Container where Item: Comparable { }

 

Generic Subscripts

subscripts는 generic일 수 있고, generic where절 또한 포함할 수 있다. angle bracket내 subscript 옆에 placeholder를 넣고 generic where절은 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
    }
}

주어진 인덱스에 해당하는 값을 가지는 배열을 반환하도록하는 subscript를 Container 프로토콜을 확장하여 구현하였다. generic subscript는 아래 제약사항을 따른다. 

 

  • generic 파라미터인 Indices는 Sequence 프로토콜을 준수해야한다. 
  • subscript는 단일 파라미터(indices)를 받으며 이는 Indices 타입이어야 한다. 
  • generic where절은 sequence의 iterator가 Int타입의 원소들은 넘나들 수 있어야 한다는 제약을 주고 있다. 이는 sequence에서의 indices가 container에서의 indices와 동일한 타입임을 보장해준다. 


다시 Generic에 대해 마지막으로 정리해보자면, 

 

Generic은 불필요한 반복을 줄여, 유연하고 재사용성을 좋게 하고, 의도를 명확하게 하여 추상적으로 코드를 표현하여 효울성을 증대시키는 방식이다. 

 

 

Ref : https://docs.swift.org/swift-book/LanguageGuide/Generics.html#ID553

 

Generics — The Swift Programming Language (Swift 5.5)

Generics Generic code enables you to write flexible, reusable functions and types that can work with any type, subject to requirements that you define. You can write code that avoids duplication and expresses its intent in a clear, abstracted manner. Gener

docs.swift.org

 

728x90
반응형