[Swift] 제네릭 (Generic) 완전 정복하기

2024. 7. 3. 19:28Swift, iOS Foundation

1️⃣ 제네릭 (Generic)이 뭔데?

💡 제네릭(Generic)특정 타입에 종속되지 않고 다양한 타입에 대해 타입 안정성을 유지하면서, 다양한 타입에 대해 유연하게 동작할 수 있게 해주는 문법이다.

Generic은 직역하면 "일반적인"이라는 뜻을 갖고 있다.

그럼 Generic 함수는 일반적인 함수? Generic 타입은 일반적인 타입?
조금 더 풀어 이를 더 자세하게 말하면 "일반화된 함수나 타입"과 같은 의미라고 해석해 볼 수 있겠다.


그럼 다시 "일반화란 무엇인가?"라는 질문으로 넘어갈 것 같은데,   
일반화는 개별 사례들의 공통되는 속성을 일반적인 개념이나 주장으로 뽑아내는 개념이다. (추상화의 한 형태라고 볼 수 있겠다.)
뭐 예시를 들어보자면 개, 고양이, 사자, 호랑이와 같은 다양한 종류의 생명체들을 "동물"이라는 포괄적이고 공통되는 특성으로 묶는 것이 일반화에 해당한다고 볼 수 있다.

이를 프로그래밍에 적용해보면, 메서드(Swift에서는 클래스나 프로토콜도 해당할 수 있다)를 하나 만들었는데 int 타입도 지원하고 싶고, Float 타입도 지원하고 싶고, String 타입도 지원하고 싶을 때!  
int, Float, String 등의 다양한 타입을 <T>라는 포괄적이고 공통되는 키워드로 일반화시켜 사용할 수 있도록 한 거다. 이게 Generic의 기본이다.

이 기본에서 확장되어 Swift에는 Generic 함수, Generic 타입, Generic 프로토콜 등으로 다양하게 사용된다. 하나씩 차근차근 알아보자!

 

2️⃣ 제네릭 함수 (Generic Functions) 개념 정복하기

a와 b를 입력받아 두 값을 swap하는 함수를 만들고자 하는 경우를 생각해 보자.

원래대로라면, Swift는 안정성을 굉장히 중시하는 언어로 한 메서드당 지정된 타입 하나만을 사용할 수밖에 없을 것이다.
Int 타입 두 개의 값을 swap해주는 swapTwoInts 함수와 String 타입 두개의 값을 swap해주는 swapTwoStrings 함수를 별도로 각각 만들어주는 상황!

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

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

하지만, Generic을 사용한 함수는 특정 타입에 종속되지 않게 사용할 수 있다.

Generic은 <> 기호 안에 타입대신 사용할 이름 "T"을 선언해 타입처럼 사용할 수 있게 된다.
이 T는 타입 파라미터 (Type Parameter)라고 부르며, Type의 첫 글자를 따와 T를 사용한다.
두 개 이상의 타입 파라미터를 사용하는 경우 편의에 따라 "U", "V" 등의 이름을 사용하는 게 일반적이며, 딕셔너리나 배열과 같은 특수한 경우에는 <Key, Value>나 Array<Elements>와 같은 이름을 붙이기도 한다. (UpperCamelCase로 Naming을 하는 것이 일반적)

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

위 Generic 함수를 사용하는 경우를 아래에서 살펴보게 되면,
함수에 입력되는 값의 타입에 따라 Type Parameter의 값이 알아서 지정되는 것을 확인할 수 있다. (Int도 들어가고 String도 들어가는 하나의 함수!)

var x = 3
var y = 107
swapTwoValues(&x, &y)       // T: Int
print("x: \(x), y: \(y)")   // x: 107, y: 3

var str1 = "hello"
var str2 = "world"
swapTwoValues(&str1, &str2)             // T: String
print("str1: \(str1), str2: \(str2)")   // str1: world, str2: hello

 

3️⃣ 제네릭 타입 (Generic Types) 개념 정복하기

제네릭(Generic)은 위에처럼 함수에만 적용가능한 것이 아니라 클래스나 구조체, 열거형 등에도 사용할 수 있다.

이처럼 제네릭을 클래스(class), 구조체(struct), 열거형(enum)에 사용하는 경우를 제네릭 타입 (Generic Type)이라 부른다.

아래는 제네릭을 활용해 struct로 Stack을 구현한 코드이다.
<Element>라는 Type Parameter를 정의해 Stack에 들어올 수 있는 값의 타입의 제약을 두지 않은 것을 확인할 수 있다.

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

그럼 실제 Generic Type의 인스턴스를 생성할 때는 <> 사이에 사용할 타입을 지정해서 만들면 된다.

var intStack = Stack<Int>()
intStack.push(1)
intStack.push(2)
print(intStack.pop()) // 2

var stringStack = Stack<String>()
stringStack.push("Hello")
stringStack.push("World")
print(stringStack.pop()) // World

 

4️⃣ 제네릭 타입에 조건을 걸 수 있다고? 타입 제약 (Type Constraints)

지금까지 배운 내용을 봤을 때, 제네릭 (Generic)을 사용한다면 타입 파라미터에는 어떠한 타입의 값이든 들어올 수 있게 된다.

하지만, 제네릭을 사용하더라도 타입 파라미터 (Type Parameter)가 특정 조건을 충족하는 경우에만 들어올 수 있도록 만들 수 있는데,
이를 제네릭 타입 제약 (Generic Type Constraints)이라 부른다.
*여기서 말하는 특정 조건이란 제네릭 타입이 특정 프로토콜을 준수하는 경우, 혹은 특정 클래스를 상속받아야 하는 경우 등을 말한다.

타입 제약의 기본적인 사용법은 아래 코드와 같다.
첫 번째 타입 파라미터 T는 SomeClass를 상속받은 타입만 들어올 수 있고, 두 번째 타입 파라미터 U는 SomeProtocol이라는 프로토콜을 준수하는 타입만 지정될 수 있게 된다.

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // 어쩌구 저쩌구 함수 내용...
}

실제 이 부분이 어떻게 코드에서 적용되는지 확인해 보자.

findIndex에서 받게 되는 타입 파라미터 T는 Equatable 프로토콜을 준수하는 타입만 받을 수 있게 제약을 걸어뒀다.
value == valueToFind라는 연산, 즉 비교 연산자를 사용하기 위해서이다. (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 Generic Function을 사용할 때는 Equatable을 준수하는 타입의 Int나 String 등을 유연하게 사용할 수 있게 된다.

if let intIndex = findIndex(of: 3, in: [1, 2, 3, 4, 5]) {
    print("Index 3 is \(intIndex)") // Index 3 is 2
}

if let stringIndex = findIndex(of: "banana", in: ["apple", "banana", "cherry"]) {
    print("Index banana is \(stringIndex)") // Index banana is 1
}

 

5️⃣ 제네릭 프로토콜 (associatedtype) 개념 정복하기

1탄까지는 제네릭을 함수나, 타입 (클래스, 구조체, 열거형)으로 사용하는 방법을 알아봤다면, 이번 글에서는 프로토콜(Protocol)에서 제네릭을 사용하는 방법에 대해 설명하고자 한다.

프로토콜에서 제네릭을 사용할 때는 이전과 같이 <> 기호를 사용하지 않고, associatedtype라는 키워드를 사용한다.

구체적인 사용법은 프로토콜 내부에서 associatedtype와 범용 타입으로 사용할 이름을 함께 선언해주기만 하면 된다.
아래 예시의 경우 프로토콜 내에서 사용할 범용 타입으로 ItemType라는 이름을 지정해 뒀으며, 이를 append 메서드에 입력받는 파라미터 타입이나 subscript가 반환할 값의 타입에서 사용하며, 말 그대로 해당 프로토콜에서 연관되는 타입(associatedtype)을 지정해 둔 모습이다.

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

실제 위에서 정의한 Container 프로토콜을 채택한 IntContainer 구조체를 살펴보겠다.

프로토콜을 채택했으니 해당 구현부에서 사용할 범용 타입(associatedtype)이 무엇인지 명시해줘야 할 텐데,
아래 보면 typealias 키워드를 사용해서 Container에 명시했던 ItemType으로 사용할 타입이 무엇인지 표시해준 것을 확인할 수 있다.
*아래의 경우와 같이, ItemType가 사용되는 append 메서드나 subscript 부분에서 사용할 타입인 Int가 명시되어 있는 상황에서는 typealias 선언부를 생략해도 문제가 없다고 한다.

아무튼 제네릭 프로토콜을 활용해 Int 타입의 Array에 대해 append를 하거나 subscript로 Int 타입의 값을 찾는 구조체를 만들 수 있었다.

struct IntContainer: Container {
    typealias ItemType = Int
    
    var items = [Int]()

    mutating func append(_ item: Int) {
        items.append(item)
    }
    
    var count: Int {
        return items.count
    }
    
    subscript(i: Int) -> Int {
        return items[i]
    }
}

뿐만 아니라, 아래와 같이 제네릭 프로토콜을 채택해서 제네릭 타입(Generic Type)으로 선언할 수도 있다.
*역시 Element라는 타입의 이름을 각 메서드 부분에서 명시해줬기 때문에 typealias로 별칭을 명시하지 않아도 문제가 없는 모습이다.

struct Stack<Element>: Container {
    var items = [Element]()
    
    mutating func append(_ item: Element) {
        items.append(item)
    }
    
    var count: Int {
        return items.count
    }
    
    subscript(i: Int) -> Element {
        return items[i]
    }
}

또한, 제네릭 타입에 조건을 걸었던 타입 제약(Type Constraints)을 이번에 배운 제네릭 프로토콜인 associatedtype에서도 걸 수 있다.

아래 코드는 Container 프로토콜에서 범용으로 사용하는 ItemType를 Equatable 준수하는 경우만 받을 수 있도록 제약을 둔 모습이다.

protocol Container {
    associatedtype ItemType: Equatable    // Equatable 제약 추가
    
    mutating func append(_ item: ItemType)
    var count: Int { get }
    subscript(i: Int) -> ItemType { get }
}

 

6️⃣ 제네릭 Where절 (Generic Where Clauses)을 사용해서 제네릭을 더욱 우아하게 사용하기

지금부터는 제네릭을 더욱 우아하게 사용하는 추가 방법에 대해 살펴보겠다.
바로 associatedtype에 대한 추가 요구사항을 정의하는 제네릭 Where절 (Generic Where Clauses)이다.

바로 아래 코드를 살펴보면, 제네릭 프로토콜(associatedtype)에 대해 특정 프로토콜을 준수해야 한다거나, 특정 타입 파라미터와 associatedtype가 동일해야 한다는 조건 등을 표시할 때 사용하고 있는 모습이다. (전자는 프로토콜에서도 사용할 수 있었고, 후자는 where절을 이용해 사용가능한 기능)

아래에서 사용한 where절은 아래 두 가지의 제약을 추가하는데 사용되었다고 보면 된다.

  • C1.ItemType와 C2.ItemType는 서로 같은 타입을 사용해야 한다.
  • C1.ItemType는 Equatable 프로토콜을 준수하는 타입이어야 한다.
func allItemsMatch<C1: Container, C2: Container>(_ container1: C1, _ container2: C2) -> Bool
where C1.ItemType == C2.ItemType, C1.ItemType: Equatable {
    
    if container1.count != container2.count { return false }
    
    for i in 0..<container1.count {
        if (container1[i] != container2[i]) { return false }
    }
    
    return true
}

where절은 위와 같이 제네릭 함수에서만 사용가능한 것이 아니라,
제네릭 타입이나 심지어는 아래와 같은 특정 상황에서의 Extension으로도 활용 가능할 수 있다는 것을 알아두면 도움이 될 것 같다.

아래 상황은 제네릭 프로토콜에서 사용되는 associatedtype가 특정 타입인 경우에만 작동 가능하도록 하고 싶은 메서드가 있는 경우에 대해 Where절을 사용해 확장(Extension)시켜 구현한 것을 보여준다.

  • ItemType가 Int일 때만 전체 값의 평균을 구하는 average 메서드가 작동 가능하도록 구현
  • ItemType가 Equatable 프로토콜을 따르는 타입일 때만 마지막 값이 같은지 비교할 수 있는 endsWith 메서드 구현
extension Container where ItemType == Int {
    func average() -> Double {
        var sum = 0.0
        for i in 0..<count {
            sum += Double(self[i])
        }
        return sum / Double(count)
    }
}

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


길고 길었던 Swift Generic 완전 정복하기는 여기까지!
잘못된 개념이나 더 추가해줬으면 하는 내용이 있거나, 질문 있으면 얼마든지 댓글로 환영하겠습니다 ^__^

 

Documentation

 

docs.swift.org