본문 바로가기
프로그래밍/Swift

[Swift] Generics (제네릭)

by 별준 2022. 3. 7.

References

Contents

  • Generics Functions
  • Type Parameter
  • Associated Type
  • Generic Where Clause
  • Generic Subscripts

Generic code(제네릭 코드)는 요구 사항에 따라 모든 타입에서 동작할 수 있는 더 유연하고 재사용 가능한 함수와 타입을 작성할 수 있도록 해줍니다. 제네릭 코드를 사용하면 중복을 피할 수 있으며 명확하고 추상적인 방법으로 그 의도를 표현할 수 있는 코드를 작성할 수 있습니다.

 

제네릭은 Swift에서 가장 강력한 기능 중 하나이며, 대부분의 Swift 표준 라이브러리는 제네릭 코드로 빌드됩니다. 실제로 인지하지는 못하더라도 여기저기서 제네릭을 사용하고 있습니다. 예를 들어, Swift의 Array와 Dictionary 타입은 모두 Generic Collection 입니다. Int 값을 포함하는 배열, String 값을 포함하는 배열 등을 생성할 수 있습니다. 마찬가지로 지정된 타입의 값을 저장하는 딕셔너리를 만들 수 있으며, 이 타입에는 제한이 없습니다.

 

The Problem That Generics Solve

제네릭 코드를 사용하는 이유에 대해서 조금 더 알아보도록 하겠습니다.

다음의 함수 swapTwoInts(_:_:)는 nongeneric 함수이며, 두 Int 값을 서로 바꿔주는 역할을 합니다.

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

이 함수는 in-out 파라미터를 사용하여 a와 b의 값을 서로 바꿔줍니다.

 

swapTwoInts(_:_:) 함수는 다음과 같이 사용할 수 있습니다.

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 값에 대해서만 사용할 수 있습니다. 만약 두 개의 String 값이나 Double들을 서로 바꾸고자 한다면, swapTwoStrings(_:_:)나 swapTwoDoubles(_:_:)와 같은 함수들을 작성해주어야 합니다.

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

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

swapTwoInts, swapTwoStrings, swapTwoDoubles 함수를 살펴보면 함수의 내용이 모두 동일합니다. 단 한가지 차이점은 각 함수가 받아들이는 값의 타입일 뿐입니다.

 

모든 타입의 값들을 서로 바꾸는 함수를 하나만 작성하면 매우 유용하고 상당히 유연하며, 제네릭 코드는 이를 가능하게 해줍니다.

 


Generic Functions

제네릭 함수(generic functions)는 어떠한 타입에서도 동작가능한데, 위에서 살펴봤던 swapTwoInts의 제제릭 버전은 다음과 같습니다.

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

swapTwoValues 함수의 바디는 swapTwoInts 함수와 동일합니다.

제네릭 버전의 함수와 일반 함수의 차이점은 아래와 같습니다.

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

제네릭 버전의 함수는 placeholder 타입 이름(여기서는 T)를 실제 타입 이름 대신 사용합니다. placeholder 타입 이름에서 T가 무엇을 나타내는 지 알려주지는 않지만, a와 b가 모두 동일한 타입 T라는 것을 알려줍니다. T 대신 사용할 실제 타입은 swapTwoValues 함수가 호출될 때마다 결정됩니다.

 

제네릭 함수와 일반 함수의 다른 차이점은 제네릭 함수 이름 뒤에 꺽쇠 괄호(<>) 사이에 placeholder 타입 이름(T)가 따라온다는 것입니다. 이 괄호는 Swift에게 swapTwoValues 내에서 T가 placeholder 타입 이름이라는 것을 알려줍니다. T가 placeholder이기 때문에, swift는 T를 실제 타입으로 보지 않습니다.

 

swapTwoValues 함수는 이제 swapTwoInts를 사용했던 방식과 동일하게 사용할 수 있는데, 한 가지 차이점은 모든 타입을 전달할 수 있다는 것입니다. 아래 예제 코드를 살펴보면 Int 타입과 String 타입에 대해서 swapTwoValues를 호출하고 있으며, 매 호출때마다 함수에 전달된 값의 타입에 의해서 T에 사용할 타입이 추론됩니다.

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"
표준 라이브러리에 제네릭 함수인 swap이 정의되어 있습니다. swapTwoValues 함수와 같은 기능을 원한다면 새로 정의할 필요없이 이미 swift에서 제공하는 swap(_:_:)를 사용하면 됩니다.

 


Type Parameters

위의 swapTwoValues 예제에서 placeholder 타입 T는 타입 파라미터(type parameter)의 한 예입니다. 타입 파라미터는 placeholder 타입을 지정하고 명명하고, 함수 이름 바로 뒤에 꺽쇠 괄호와 함께 작성(<T>)됩니다.

 

타입 파라미터가 지정되면, 이 타입을 함수 파라미터의 타입으로 사용하거나 함수의 리턴 타입, 또는 함수 바디 내에서 타입 annotation으로 사용할 수 있습니다. 각각의 경우에서 타입 파라미터는 함수가 호출될 때마다 실제 타입으로 대체됩니다. 위 예에서 swapTwoValues의 T는 매 호출에서 Int, String으로 대체되었습니다.

 

하나 이상의 타입 파라미터를 사용할 수 있는데, 각 타입 파라미터는 꺽쇠 괄호 내에서 콤마(,)로 구분해 작성하면 됩니다.

 


Naming Type Parameters

대부분의 경우 타입 파라미터는 딕셔너리(Dictionary<Key, Value))의 Key와 Value나 배열(Array<Element>)의 Element처럼 타입 파라미터와 타입 파라미터가 사용되는 제네릭 타입 또는 함수 간의 관계를 내포하는 이름을 사용합니다. 하지만, 이들 간, 의미있는 관계가 없다면 T, U, V와 같은 영문자를 관례로 사용합니다.

타입 파라미터의 이름은 upper camel case로 명명하는 것을 권장합니다. Swift에서 기본 제공되는 타입은 모두 upper camel case 명명되어 있고 일반적으로 값이 아닌 타입이라는 것을 알려줍니다.

 


Generic Types

제네릭 함수 외에도 Swift에서는 자신만의 제네릭 타입(generic types)를 정의할 수 있습니다. Array나 Dictionary처럼 어떠한 타입과 함께 동작할 수 있는 커스텀 클래스/구조체/열거형 등을 정의할 수 있습니다.

 

여기서 Stack 이라는 제네릭 콜렉션을 작성하는 방법에 대해서 살펴보겠습니다. 스택에 대한 자세한 내용은 생략하도록 하겠습니다.

 

아래 코드는 nongeneric 버전의 스택입니다. 이 스택은 Int 값에 대해 동작합니다.

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

이 구조체는 items라는 Array 프로퍼티를 사용하여 값들을 스택에 저장합니다. 이 스택에서는 push와 pop이라는 두 개의 메소드를 제공합니다. 이 메소드들은 mutating으로 지정되어 있는데, 이는 구조체의 items 배열을 수정해야되기 때문입니다.

 

방금 살펴본 IntStack 타입은 오직 Int 값에 대해서만 사용할 수 있습니다. 여기서 제네릭 버전의 Stack 구조체를 사용하면 타입에 관계없이 스택을 관리할 수 있습니다.

다음은 제네릭 버전의 스택입니다.

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

제네릭 버전의 Stack은 본질적으로 nongeneric 버전과 동일한데, Int라는 실제 타입 대신 Element라는 타입 파라미터를 사용한 것만 다릅니다. 이 타입 파라미터는 꺽쇠 괄호 내에 작성되고, 함수 이름 바로 뒤에 작성합니다.

 

Element는 나중에 제공되는 타입에 대한 placeholder 이름을 정의합니다. 미래에 추론되는 타입인 Element는 구조체 정의 내 어디에서나 사용될 수 있으며, 위 코드에서는 3가지 위치에서 사용되고 있습니다.

  • items라는 프로퍼티를 생성하면서 Element 타입의 값을 갖는 배열을 초기화할 때 사용
  • push(_:) 메소드가 item이라는 파라미터를 갖는데 이 파라미터의 타입을 지정할 때 사용
  • pop() 메소드에서 리턴되는 값의 타입을 지정할 때 사용

위 Stack은 제네릭 타입이기 때문에 Swift에서 유효한 어떠한 타입의 스택을 생성하는데 사용할 수 있으며, Array나 Dictionary와 비슷한 방법으로 사용됩니다.

 

아래 코드는 String 타입에 대한 스택 인스턴스를 생성하고 있습니다.

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

아래 그림은 push된 후의 스택의 모습을 보여주고 있습니다.

 

여기서 pop() 메소드를 호출하면 top 위치에 있는 값 "cuatro"를 제거하면서 리턴합니다.

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

 

 


Extending a Generic Type

제네릭 타입을 확장할 때, 익스텐션(extension)의 정의의 일부로 타입 파라미터 리스트를 제공하지 않아도 됩니다. 대신 원본 타입 정의에서 타입 파라미터 리스트를 익스텐션의 바디 내에서 사용할 수 있고, 원본 타입 파라미터의 이름들은 원본 정의에서의 타입 파라미터를 참조하는데 사용됩니다.

 

다음 코드는 위에서 정의한 제네릭 Stack 타입을 확장하여 하나의 read-only 연산(computed) 프로퍼티 topItem을 추가합니다. 이 프로퍼티는 스택에서 pop하지 않고 top item을 리턴하는 역할을 합니다.

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

topItem 프로퍼티는 Element 타입의 옵셔널 값을 리턴합니다. 만약 스택이 비어있다면 topItem은 nil을 리턴하고, 비어있지 않다면 items 배열의 마지막 아이템을 리턴합니다.

 

이 익스텐션은 타입 파라미터 리스트를 정의하지 않습니다. 대신 Stack 타입의 존재하는 타입 파라미터 이름인 Element를 익스텐션 내에서 topItem 연산 프로퍼티의 옵셔널 타입을 가리키는데 사용합니다.

topItem 연산 프로퍼티는 이제 스택 인스턴스 내에서 top item을 제거하지 않고 쿼리하고 액세스하는데 사용할 수 있습니다.

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

제네릭 타입의 익스텐션에는 새로운 기능을 얻기 위해서 확장된 타입의 인스턴스가 만족해야할 요구사항도 포함될 수 있는데, 이에 관련된 내용은 아래 Extensions with a Generic Where Clause에서 살펴보겠습니다.

 


Type Constraints

위에서 살펴본 swapTwoValues(_:_:) 함수와 Stack 타입은 어떠한 타입과도 함께 동작할 수 있습니다. 그러나 때때로는 특정 타입에 대해서 타입 제약(type constraints)을 강제하는 것이 유용할 때가 있습니다. 타입 제약은 타입 파라미터가 특정 클래스를 상속하거나 특정 프로토콜 또는 프로토콜 composition을 준수하도록 지정합니다.

 

예를 들어, Swift의 딕셔너리 타입에서는 key로 사용되는 타입에 제약이 있습니다. 딕셔너리에 대한 내용은 아래 내용을 참조하셔도 좋을 것 같습니다 !

[Swift] Collection Type (Array, Set, Dictionary)

 

[Swift] Collection Type (Array, Set, Dictionary)

References https://docs.swift.org/swift-book/LanguageGuide/CollectionTypes.html Contents Mutability of Collections Arrays Sets Performing Set Operations Dictionaries Swift는 Arrays(배열), Sets(집합)..

junstar92.tistory.com

​딕셔너리 key의 타입은 반드시 hashable해야 합니다. 즉, 반드시 유니크한 표현값으로 만드는 방법을 제공해야 합니다. 딕셔너리는 이러한 key를 특정 key에 대해 이미 값을 포함하고 있는지 체크하는데 사용합니다.

 

이러한 요구사항은 딕셔너리의 key 타입은 반드시 Hashable 프로토콜을 순응하도록 제약됩니다. 이 프로토콜은 Swift 표준 라이브러리에 정의되어 있습니다. Swift의 모든 기본 타입(String, Int, Double, Bool 등)은 기본적으로 hashable 합니다. 커스텀 타입에 대해 Hashable 프로토콜을 준수하도록 만드는 방법은 링크를 참조하시길 바랍니다.

 

Type Constraint Syntax

타입 제약은 타입 파라미터 이름 뒤에 어떤 클래스나 프로토콜을 콜론과 함께 위치시켜 작성할 수 있습니다. 제네릭 함수에서 타입 제약을 위한 기본 문법은 다음과 같습니다.

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

위의 함수에는 두 가지 타입 파라미터가 있습니다. 첫 번째 타입 파라미터 T는 SomeClass의 서브클래스가 되어야 한다는 제약이 있습니다. 두 번째 타입 파라미터 U는 SomeProtocol이라는 프로토콜을 준수해야 하는 타입 제약 조건이 있습니다.

 

Type Constraints in Action

아래 코드는 nongeneric 함수 findIndex(ofString:in:)을 정의하고 있습니다. 이 함수는 String 배열에서 주어진 String 값을 찾고, 옵셔널 Int 값을 리턴합니다. 이 값은 배열에서 첫 번째로 일치하는 String의 인덱스이며, 찾지 못한다면 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:) 함수는 다음과 같이 사용할 수 있습니다.

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"

하지만, 여기서 오직 문자열에 대해서 동작하는 이 함수는 유용하지 않아 보입니다.

이때 String이 아닌 특정 타입 T를 사용하여 제네릭 함수를 정의해서 동일한 기능을 하도록 작성할 수 있습니다.

아래 코드는 위의 findIndex의 제네릭 버전인 findIndex(of:in:) 함수를 정의합니다. 여기서 이 함수의 리턴 값을 여전히 Int?인데, 배열의 인덱스를 리턴하기 때문입니다.

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

 

하지만, 위 코드는 컴파일이 되지 않습니다. 이 문제는 line 3의 동등 검사(equality check), "if value == valueToFind"에서 발생합니다. Swift의 모든 타입이 == operator로 비교할 수 있는 것은 아닙니다. 예를 들어, 만약 복잡한 데이터 모델을 표현하는 커스텀 클래스나 구조체를 만드는 경우, 이 타입에 대한 "=="의 의미는 Swift에서 추측할 수 없습니다. 이 때문에 이 코드가 모든 타입 T에 대해 동작한다고 보장할 수 없으며 코드를 컴파일할 때 에러가 발생하는 것입니다.

 

방법이 없는 것은 아닙니다. Swift 표준 라이브러리는 Equatable 이라는 프로토콜을 정의합니다. 이 프로토콜은 해당 타입의 두 값을 비교할 수 있도록 == 연산자와 != 연산자를 구현하도록 합니다. Swift의 모든 표준 타입은 자동으로 이 Equatable 프로토콜을 지원합니다.

Equatable을 준수하는 어떠한 타입은 안전하게 findIndex(of:in:) 함수에서 사용될 수 있는데, 이는 == 연산자가 제공되는 것을 보장하기 때문입니다. 따라서 다음과 같이 타입 파라미터 정의에서 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의 타입 파라미터는 T: Equatable로 수정되었고, 이는 T 타입은 Equatable 프로토콜을 준수한다라는 것을 의미합니다.

 

이제 이 함수는 다음과 같이 Double이나 String 타입에 대해 사용할 수 있습니다.

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 types)를 선언하는 것이 유용할 수 있습니다. 연관 타입은 프로토콜의 일부로 사용되는 타입의 placeholder 이름을 제공합니다. 이 연관 타입에 사용되는 실제 타입은 프로토콜이 적용될 때까지 구체화되지 않습니다. 연관 타입은 associatedtype 키워드로 지정됩니다.

 

Associated Types in Action

아래 코드는 Container라는 프로토콜을 정의하고 있습니다. 여기서 Item 이라는 연관 타입을 선언합니다.

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

Container 프로토콜은 컨테이너가 제공하야하는 3가지 필수 기능을 정의합니다.

  • 컨테이너에 새로운 아이템을 추가할 수 있는 append(_:) 메소드
  • 컨테이너의 아이템 개수에 액세스할 수 있는 count 프로퍼티, Int 값을 리턴함
  • Int 인덱스 값의 서브스크립트를 통해 컨테이너의 각 아이템에 접근할 수 있는 기능

이 프로토콜은 컨테이너에서 아이템이 저장되는 방법이나 어떤 타입이 허용되는지 구체적으로 지정하고 있지 않습니다. 이 프로토콜은 오직 Container로 사용되기 위해서 반드시 제공해야하는 3가지 기능만을 지정하고 있습니다. 이를 준수하는 타입은 이 3가지 요구사항을 만족하하는 한, 추가 기능을 제공할 수도 있습니다.

 

Container 프로토콜을 준수하는 어떤 타입은 저장하는 값의 타입을 지정해야만 합니다. 구체적으로 올바른 타입의 아이템만 컨테이너에 추가되도록 보장해야 하고, 서브스크립트로 리턴되는 아이템의 타입에 대해서 명확해야합니다.

이러한 요구사항들을 정의하기 위해서 Container 프로토콜은 특정 컨테이너에 대한 타입이 무엇인지 모른 채 컨테이너가 가질 수 있는 원소의 타입을 참조할 수 있는 방법이 필요합니다. Container 프로토콜은 append(_:) 메소드로 전달되는 어떤 값이 반드시 컨테이너의 원소 타입과 같은 타입이 되도록 지정할 필요가 있으며, 컨테이너의 서브스크립트에서 리턴되는 값의 타입도 컨테이너의 원소 타입과 같아야 합니다.

 

이를 달성하기 위해서 Container 프로토콜은 Item이라는 연관 타입을 선언하며, associatedtype Item으로 작성합니다. 이 프로토콜은 Item이 무엇인지 정의하지 않습니다. 그럼에도 불구하고, Item은 Container의 아이템의 타입을 참조할 수 있는 방법을 제공하고, append(_:) 메소드와 서브스크립트에서 사용할 수 있도록 타입을 정의하고 이를 통해 어떤 Container의 예상되는 동작이 적용되도록 보장합니다.

 

다음 코드는 Generic Types에서 살펴본 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 타입은 Container 프로토콜의 모든 요구사항을 구현하고 있으며, 이러한 요구사항을 충족하기 위해서 기존 기능의 일부를 래핑합니다. 또한 IntStack은 이 컨테이너를 구현하기 위해 사용되는 적절한 아이템이 Int 타입이라고 지정합니다. 'typealis Item = Int'는 Item의 추상 타입을 Int라는 구체적인 타입으로 바꿉니다.

 

Swift의 타입 추론 때문에 IntStack 정의에서 Int를 구체적인 Item으로 선언할 필요가 없습니다. IntStack은 Container 프로토콜의 모든 요구사항을 준수하기 때문에 Swift는 단순히 append(_:) 메소드의 item 파라미터의 타입과 서브스크립트의 리턴 타입을 보고 사용할 적절한 Item을 추론합니다. 따라서 line 11을 지워도 정상적으로 컴파일됩니다.

 

또한, 다음과 같이 제네릭 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(_:) 메소드의 item 파라미터의 타입과 서브스크립트의 리턴 타입으로 사용되었습니다. 그래서 Swift는 Element가 특정 컨테이너의 Item으로 사용하기에 적절한 타입이라고 추론할 수 있습니다.

 

Extending an Existing Type to Specify an Associated Type

이미 존재하는 타입에 프로토콜을 준수하도록 확장할 수 있습니다. 여기에는 연관 타입을 사용하는 프로토콜도 포함합니다.

 

Swift의 Array 타입은 이미 append(_:) 메소드, count 프로퍼티, 서브스크립트를 제공합니다. 이 3가지 기능은 Container 프로토콜 요구사항과 일치합니다. 이는 간단히 Array가 이 프로토콜을 준수하도록 선언만 해주면 Array가 Container를 준수하도록 확장할 수 있다는 것을 의미합니다. 이는 다음과 같이 빈 익스텐션을 정의하면 됩니다.

extension Array: Container {}

 

Adding Constraints to an Associated Type

프로토콜의 연관 타입에 타입 제약을 추가해서 이를 준수하는 타입이 이러한 제약을 만족하도록 제한할 수 있습니다. 예를 들어, 아래 코드는 컨테이너의 아이템이 equatable하도록 요구하도록 Container를 정의합니다.

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

이 Container 프로토콜을 준수하기 위해서는 컨테이너의 Item 타입은 반드시 Equatable 프로토콜을 준수해야 합니다.

 

Using a Protocal in Its Associated Type's Constraints

프로토콜은 자신 요구사항 중 일부를 표현할 수 있습니다. 예를 들어, 아래 프로토콜은 suffix(_:) 메소드 요구사항을 추가하여 Container 프로토콜을 개량(refine)합니다. suffix(_:) 메소드는 컨테이너 끝에서부터 주어진 수의 원소들을 리턴하며 Suffix 타입의 인스턴스에 이들을 저장합니다.

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

이 프로토콜에서 Suffix는 Container 프로토콜에서의 Item 타입처럼 연관 타입입니다. Suffix는 두 가지 제약을 가지고 있습니다. 하나는 SuffixableContainer 프로토콜을 준수해야하며, 다른 하나는 Item 타입은 반드시 컨테이너의 Item 타입과 일치해야 한다는 것입니다. Item에 대한 제약은 제네릭 where clause이며 이에 대해서는 아래에서 언급하도록 하겠습니다.

 

아래 코드는 위에서 정의한 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

 

위 예제 코드에서 Stack에서 Suffix 연관 타입은 Stack이 됩니다. 따라서, Stack에서 suffix 연산은 다른 Stack을 리턴합니다.

또는 SuffixableContainer를 준수하는 타입은 자신의 타입과 다른 Suffix 타입을 가질 수 있습니다. 예를 들어, 다음 코드는 IntStack 대신 Stack<Int>를 Suffix 타입으로 사용하여 SuffixableContainer를 준수하는 nongeneric 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
    }
    // Inferred that Suffix is Stack<Int>.
}

 


Generic Where Clauses

타입 제약은 제네릭 함수, 서브스크립트 또는 타입과 연관된 타입 파라미터에 요구사항을 정의하도록 할 수 있습니다.

이는 연관 타입에 요구사항을 정의하는데 유용할 수 있는데, 이는 generic where clause(제네릭 where절)를 정의하는 방법으로 적용할 수 있습니다. 제네릭 where절을 사용하면 연관 타입이 특정 프로토콜을 준수해야 한다거나 특정 타입 파라미터와 연관 타입이 반드시 같도록 요구할 수 있습니다. 제네릭 where절은 where 키워드로 시작하고, 그 뒤에 연관 타입에 대한 제약이나 타입과 연관 타입 간의 동등 관계가 옵니다. 제네릭 where절은 타입이나 함수의 바디의 여는 중괄호({) 앞에 작성합니다.

 

다음 예제 코드는 allItemsMatch라는 제네릭 함수를 정의합니다. 이 함수는 두 Container 인스턴스가 같은 순서로 같은 아이템을 포함하는지 체크합니다. 이 함수는 모든 아이템들이 같으면 true를 리턴하고, 그렇지 않다면 false를 리턴합니다.

검사되는 두 컨테이너는 동일한 타입의 컨테이너일 필요는 없지만, 동일한 타입의 아이템들을 가지고 있어야 합니다. 이 요구사항은 타입 제약과 제네릭 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
}

이 함수는 someContainer와 anotherContainer라는 두 인수를 받습니다. someContainer 인수는 C1타입이고 anotherContainer는 C2 타입입니다. C1과 C2는 두 컨테이너 타입에 대한 타입 파라미터이며 이는 함수가 호출될 때 결정됩니다.

 

함수의 두 타입 파라미터에는 다음의 요구사항들이 적용됩니다.

  • C1은 Container 프로토콜을 준수해야함 (as C1: Container)
  • C2 또한 Container 프로토콜을 준수해야함 (as C2: Container)
  • C1의 Item은 C2의 Item과 동일해야 함 (as C1.Item == C2.Item)
  • C1의 Item은 Equatable 프로토콜을 준수해야함 (as C1.Item: Equatable)

첫 번째와 두 번째 요구사항은 함수의 타입 파라미터 리스트에서 정의되며, 세 번째와 네 번째 요구사항은 함수의 제네릭 where절에서 정의됩니다.

 

이 요구사항들은 다음을 의미합니다.

  • someContainer는 타입 C1의 컨테이너
  • anotherContainer는 타입 C2의 컨테이너
  • someContainer와 anotherContainer는 동일한 타입의 아이템을 가지고 있음
  • someContainer의 아이템들은 != 연산자를 사용하여 각각이 다르다는 것을 체크할 수 있음

세 번째와 네 번째 요구사항의 조합은 anotherContainer의 아이템이 someContainer의 아이템과 완전히 동일한 타입이기 때문에 antherContainer의 아이템 또한 != 연산자로 비교할 수 있음을 의미합니다.

 

이 요구사항들은 allItemsMatch(_:_:)가 비록 다른 컨테이너 타입일지라도 두 컨테이너를 비교할 수 있도록 해줍니다.

 

allItemsMatch(_:_:) 함수는 두 컨테이너에 동일한 수의 아이템이 포함되어 있는지 확인하는 것으로 시작합니다. 만약의 개수가 다른 경우 일치할 방법이 없으며 함수는 false를 반환합니다.

이를 확인한 후에 함수는 for-in 루프와 ..<를 사용해 someContainer의 모든 아이템들을 순회합니다. 각 아이템에서 함수는 someContainer 아이템과 대응되는 anotherContainer의 아이템과 일치하지 않는지 확인합니다. 만약 두 아이템이 동일하지 않다면 두 컨테이너는 일치하지 않는다는 것이고 함수는 false 리턴합니다.

만약 mismatch를 발견하지 않고 루프가 끝난다면 두 컨테이너는 일치한다는 것으로 함수는 true를 리턴합니다.

 

allItemsMatch(_:_:) 함수는 다음과 같이 사용할 수 있습니다. (위에서 살펴봤던 코드들이 섞여 있어, 정리할 겸 프로토콜 정의부터 작성)

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

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: Container {}

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
}

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

위 코드에서 String 값들을 저장하는 Stack 인스턴스를 생성하고 3개의 문자열을 스택에 push합니다. 또한 스택과 똑같은 문자열 3개를 포함하는 문자열 리터럴로 초기화되는 Array 인스턴스를 생성합니다. 비록 Stack과 Array는 다른 타입이지만 두 타입은 Container 프로토콜을 준수하고 동일한 타입의 값을 포함하고 있습니다. 그러므로 이 두 컨테이너를 allItemsMatch(_:_:) 함수에 전달하여 호출할 수 있습니다.

 


Extensions with a Generic Where Clause

제네릭 where절을 익스텐션의 일부로 사용할 수도 있습니다. 아래 예제 코드는 제네릭 Stack 구조체를 확장하여 isTop(_:) 메소드를 추가하고 있습니다.

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

새로운 isTop(_:) 메소드는 먼저 스택이 비어있는지 확인한 후에 주어진 아이템과 스택의 top 아이템을 비교합니다. 만약 제네릭 where절을 사용하지 않고 구현한다면, 한 가지 문제가 발생합니다. isTop(_:)의 구현은 == 연산자를 사용하고 있지만, Stack의 정의에서는 equtable을 필수로 요구하지 않습니다. 그래서 == 연산자를 사용하는 부분은 컴파일 에러가 발생할 수 있습니다. (위 코드에서 where Element: Equatable을 지우면 컴파일 에러가 발생합니다.)

제네릭 where절을 사용하면 위와 같이 스택의 아이템이 오직 equatable할 때만 익스텐션이 isTop(_:) 메소드를 추가하도록 익스텐션에 새로운 요구사항을 추가할 수 있습니다.

 

isTop(_:) 메소드는 다음과 같이 사용할 수 있습니다.

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

만약 equatable하지 않은 항목들을 갖는 스택에 isTop(_:) 메소드를 호출한다면 컴파일 에러가 발생합니다.

 

제네릭 where절을 프로토콜의 익스텐션에 사용할 수도 있습니다. 아래 예제는 Container 프로토콜을 확장하여 startsWith(_:) 메소드를 추가합니다.

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

startsWith(_:) 메소드는 먼저 컨테이너가 최소한 하나의 항목을 가지고 있는지부터 확인한 다음, 컨테이너의 첫 번째 항목이 주어진 아이템과 동일한지 체크합니다. 이 startsWith(_:) 메소드는 Container 프로토콜을 준수하는 어떤 타입에서도 사용할 수 있습니다.

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

위 코드처럼 제네릭 where절은 Item이 프로토콜을 준수하도록 제한할 수도 있지만, 제네릭 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"

위 코드는 컨테이너에 average() 메소드를 추가하는데, Item 타입이 Double인 컨테이너에만 적용됩니다. 

 

익스텐션의 일부로 사용되는 제네릭 where절에 하나 이상의 요구사항들을 포함할 수도 있습니다. 각 요구사항들은 콤마(,)로 구분합니다.

 


Contextual Where Clauses

이미 제네릭 타입인 컨텍스트에서 작업 중일 때, 자신만의 제네릭 타입의 제약 조건을 가지지 않는 선언에서 제네릭 where절을 작성할 수 있습니다. 이해하기 쉽게 예를 들면, 제네릭 타입의 익스텐션에서 제네릭 타입의 서브스크립트나 메소드에 제네릭 where절을 작성할 수 있습니다. 아래 예제 코드는 Container 구조체는 제네릭이지만, 컨테이너에서 사용가능한 새로운 메소드는 타입 제약을 가지도록 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"

이 예제 코드는 Container의 아이템 타입이 정수일 때 average() 메소드를 추가하고, 아이템이 eqatable할 때 endsWith(_:) 메소드를 추가합니다. 두 함수는 모두 제네릭 where절을 사용하여 제네릭 Item 타입 파라미터에 타입 제약을 추가합니다.

 

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

 

contextual where절을 사용한 버전에서는 average()와 endWith(_:)를 하나의 익스텐션에서 구현하고 있습니다. 하지만 이 요구사항을 익스텐션의 제네릭 where절로 이동시키면 각 요구사항마다 하나의 익스텐션이 필요하게 됩니다.

 


Associated Types with a Generic Where Clause

연관 타입에 제네릭 where절을 포함시킬 수 있습니다. 예를 들어, 표준 라이브러리의 Sequence 프로토콜이 사용하는 것과 같은 iterator를 포함하는 Container 버전을 만들고 싶다고 가정해보겠습니다. 그렇다면 다음과 같이 작성할 수 있습니다.

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에 사용된 제네릭 where절은 iterator가 iterator의 타입과 관계없이 컨테이너의 항목과 동일한 타입의 항목들을 순회하도록 제한합니다. makeIterator() 함수는 컨테이너의 iterator에 대한 액세스를 제공합니다.

 

다른 프로토콜를 상속하는 프로토콜에서는 프로토콜 선언에서 제네릭 where절을 사용하여 상속된 연관 타입에 제약을 추가합니다. 예를 들어, 다음 코드는 ComparableContainer 프로토콜을 선언하는데, Item이 Comparable을 준수하도록 요구합니다.

protocol ComparableContainer: Container where Item: Comparable { }

 


Generic Subscripts

서브스크립트 또한 제네릭할 수 있고, 제네릭 where절을 포함할 수 있습니다. subscript 뒤에 꺽쇠 괄호 내부에 placeholder 타입 이름을 작성하고, 함수 바디의 여는 중괄호 바로 전에 제네릭 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
    }
}

Container 프로토콜에 대한 이 익스텐션은 인덱스들의 시퀀스를 전달받아서 주어진 각 인덱스에서의 항목들을 포함하는 배열을 리턴하는 서브스크립트를 추가합니다. 이 제네릭 서브스크립트는 다음과 같은 제약들을 가지고 있습니다.

  • 꺽쇠 괄호 안에 있는 제네릭 파라미터 Indices는 표준 라이브러리의 Sequence 프로토콜을 준수하는 타입이어야 함
  • 서브스크립트는 indices라는 하나의 파라미터를 받고, 이는 Indices 타입의 인스턴스임
  • 제네릭 where절은 시퀀스에서의 iterator가 Int 타입의 원소들을 순회하도록 요구함. 이는 시퀀스에서 인덱스들이 컨테이너에서 사용되는 인덱스와 동일한 타입을 갖도록 보장함

이 제약들을 합치면, indices 파라미터로 전달되는 값은 정수의 시퀀스라는 의미가 됩니다.

'프로그래밍 > Swift' 카테고리의 다른 글

[Swift] Opaque Types  (0) 2022.03.09
[Swift] Error Handling  (0) 2022.03.08
[Swift] Protocols (2)  (0) 2022.02.05
[Swift] Protocols (1)  (0) 2022.02.05
[Swift] Extensions  (0) 2022.02.03

댓글