Swift

Swift - 제네릭

호종이 2022. 2. 20. 13:26

제네릭은 스위프트나 Java, Kotlin 같은 언어들의 강력한 기능으로, 어떤 타입에도 유연하게 대응할 수 있는 자료구조나 함수, 사용자 정의 클래스를 만드는데 아주 유용하게 사용되는 기능이다. 엄청나게 중요한 기능으로 꼭 익혀두어야 한다. 이미 우리가 사용한 많은 기능들이 이미 제네릭을 사용한 기능들이다. 예를 들어 Array 나 Set, Dictionary 의 경우 어떤 타입을 넣더라도 배열이나 집합, 사전으로 만들 수 있었다. 이것은 모두 제네릭이 적용되었기에 가능하다. 또 Optional 도 원래는 열거형으로 구현되있으며, 어떤 타입이든 Optional 로 표현할 수 있다. 제네릭이 적용된 Optional 의 정의를 보자

@frozen public enum Optional<Wrapped> : ExpressibleByNilLiteral {
    case none
    case some(Wrapped)

    /// Creates an instance that stores the given value.
    public init(_ some: Wrapped)

   
    @inlinable public var unsafelyUnwrapped: Wrapped { get }
	...
  }

Optional<Wrapped> 에서 <> 안에 들어간 이름이 타입 매개변수의 이름으로, 매개변수로 취급되기 때문에 당연히 실제 타입은 아니다. 단지 제네릭이 사용된 타입 내부에서 제네릭 매개변수의 이름을 실제 타입인 것처럼 사용할 수 있다. Optional 열거형 내부에서도 some 이라는 case 의 연관값으로 제네릭 타입인 Wrapped 타입이 사용된 것을 알 수 있다. 

 

프로그래머는 해당 제네릭 매개변수로 어떤 타입도 넘길 수 있으며, 따라서 Int, String, Double 부터 사용자 정의 타입까지 어떤 타입도 옵셔널로 선언할 수 있는 것이다. 

제네릭 함수 사용법


타입 이름<타입 매개변수,타입 매개변수2, ...>
함수 이름<타입 매개변수,타입 매개변수2,...>(함수 매개변수:타입 매개변수,함수 매개변수:Int...)

제네릭을 사용하고자 할 때는 제네릭이 필요한 타입 또는 메소드의 이름 뒤에 <> 기호와 함께, 기호 사이에 타입 매개변수의 이름을 써주어 제네릭을 사용할 것임을 명시한다. 제네릭의 사용법을 이해하기 위해, 두 개의 정수를 받아서 더한 값을 반환하는 함수를 작성해보자.

func add(_ a:Int,_ b:Int) -> Int {
    a+b
}

위 add 함수는 오로지 Int 타입에서만 사용할 수 있다. 만약 부호가 없는 UInt 타입의 정수를 해당 함수의 인자로 넘긴다면, 컴파일 에러가 발생한다.

Int 타입을 위한 함수기 때문에, UInt 도 정수 타입의 한 종류이지만 사용할 수 없다

만약 제네릭이 없는 상황에서 UInt 타입을 위한 add 함수를  구현하려면 다음과 같이 하나의 함수를 더 구현하는 수 밖에 없다. 

func add(_ a:UInt,_ b:UInt) -> UInt {
    a+b
}

그러나 코드의 재사용성을 고려해 제네릭을 사용하여 모든 정수 타입을 더하는 add 함수를 구현해보자. Swift 에서 정수 타입 프로토콜, BinaryInteger 프로토콜을 채택한 타입이면 add 함수를 사용할 수 있도록 함수를 수정해보자. 

func add<T:BinaryInteger>(_ a:T,_ b:T) -> T {
    a+b
}
let a : UInt = 10
let b : UInt = 20

print(add(a,b))

add 함수의 타입 매개변수의 이름을 T라 하고, 해당 T는 BinaryInteger 프로토콜을 채택한 타입이라는 조건을 주었다. 이제 UInt 같은 BinaryInteger 프로토콜을 채택한 모든 타입은 add 함수를 사용할 수 있다!

 

좀 더 쉬운 이해를 위해 두 매개변수의 값을 교환하는 swap 함수를 구현해보자! 일단 제네릭을 사용하지 않고, 정수만을 교환하는 함수를 구현해보자.

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

만약 Double 이나 String, 또는 사용자 정의 타입을 swap 하는 함수가 필요하다고 하면? 해당 타입을 swap 하는 모든 함수를 구현해야 할 것이다. 제네릭을 사용하여 모든 타입을 받아 swap 할 수 있는 함수로 변경해보자!

T 타입 매개변수를 선언!
func swap<T>(_ a:inout T,_ b:inout T){
    let temp = a
    a = b
    b = temp
}

var A = 5
var B = 3

swap(&A, &B) //A -> 3 , B -> 5

var stringA = "안녕"
var stringB = "월드"

swap(&stringA, &stringB) //stringA -> 월드, stringB -> 안녕

이전 swap 함수와 달리 <T> 라는 키워드가 붙었다. T는 타입 매개변수의 이름으로, 실제 타입은 아니지만 어떤 타입이든 올 수 있다고 알려준다. 따라서 해당 함수 내부에선 T 라는 타입을 실제 타입처럼 사용할 수 있다. T 라는 타입은 실제 함수가 호출될 때 결정되는데, 만약 매개변수로 Int 타입이 전달된다면 T는 Int 가 되고, String 타입이 전달된다면 T 는 String 타입이 된다. 

 

타입 매개변수는 다음과 같이 여러개가 올 수도 있으며, 이 때 각 타입 매개변수의 이름은 서로 달라야 한다. 

func typeCast<T,R>(_ value : T,type: R.Type) -> R? {
    guard let ret = value as? R else {
        return nil
    }
    return ret
}

print(typeCast(5,type: String.self)) //nil

T 타입을 매개변수로 받아서, R 타입을 반환하는 함수를 선언해주었다. 반환값인 R 타입을 정해주기 위해 함수의 매개변수로 R 타입을 넘기고, 만약 타입 캐스팅이 가능하다면 캐스팅 된 타입을, 아니면 nil 을 반환하도록 해주었다. Int 타입인 5 를 넘겨주었지만, Int 타입은 String 타입으로 캐스팅 할 수 없기 때문에 nil 이 반환된 것을 확인할 수 있다. 

 

보통 타입 매개변수의 이름은 의미있는 이름으로 짓는 경우가 많은데, Dictionary 의 경우 타입 매개변수의 이름이 Key 와 Value 로 제네릭 함수와 타입 매개변수의 관계를 조금 더 명확히 표현할 수 있다.

 

제네릭 함수 정리

1. 타입 매개변수는 실제 타입은 아니며, 어떤 타입이라고 알려주는 Place Holder 역할만 수행한다.
2. 타입 매개변수는 실제 함수가 호출될 때 결정된다.
3. 타입 매개변수의 이름은 의미있게 지어야 하며, 해당 타입이 함수나 타입 내부에서 어떤 역할을 수행하는지 표현해야한다.

제네릭 타입 사용법


제네릭을 사용한 사용자 정의 타입을 구현할 수도 있다. 제네릭 타입을 구현하면 구조체, 클래스, 열거형 등 어떤 타입과도 연관되어 작동할 수 있다. Array나 Set, Dictionary 타입이 모든 타입의 원소를 대상으로 동작할 수 있는 것과 유사하다. 제네릭 타입은 함수와 유사하게 타입 이름 옆에 <> 를 붙임으로써 구현할 수 있다. 

 

이번 파트에선 아주 중요한 자료구조중 하나인 Queue(큐) 를 직접 구현해보도록 하자. Queue 는 FIFO 형태의 자료구조로, 먼저 들어온 자료가 제일 먼저 나가는 자료구조다. 

https://velog.io/@gillog/큐Queue

먼저 제네릭을 사용하지 않고, 오로지 Int 타입만 관리할 수 있는 Queue 를 구현해보자.

struct IntQueue {
    var items = [Int]()
    
    mutating func push(_ data : Int) {
        items.append(data)
    }
    
    mutating func poll() -> Int {
        items.removeFirst()
    }
}


var queue = IntQueue()

queue.push(5)
queue.push(3)
queue.push(10)

print(queue.poll()) //5
print(queue.poll()) //3
print(queue.poll()) //10

queue 에 순서대로 5,3,10을 넣었다. 그 다음 데이터를 세 번 꺼냈는데, Queue 의 정의대로 먼저 들어간 순서대로 5,3,10 이 출력된 것을 확인할 수 있다. 그러나 이 Queue 는 오로지 Int 타입만 대응할 수 있으며, 이제 제네릭을 사용해 모든 타입을 대상으로 동작하는 Queue 를 구현해보자

struct Queue<T> {
    var items = [T]()
    
    mutating func push(_ data : T) {
        items.append(data)
    }
    
    mutating func poll() -> T {
        items.removeFirst()
    }
}

바뀐건 별로 없지만, T 라는 타입 매개변수가 생긴 것을 확인할 수 있다. 우리는 이 T 를 타입 내부에서 실제 타입인 것처럼 사용할 수 있으며, 함수의 매개변수, 프로퍼티, 함수의 반환타입 등 어떤 곳에도 사용할 수 있다. 이제 Queue 를 직접 사용해보자

var queue = Queue<String>()

queue.push("Hello")
queue.push("World")

print(queue.poll())
print(queue.poll())
//출력 결과
Hello
World

Queue 의 인스턴스를 할당할 때, 우리는 타입 매개변수의 타입을 직접 지정해줄 수 있다. 위 예제에서는 Queue 의 타입으로 String 을 지정해 주었으며, 이제 queue는 String 타입을 대상으로 동작하게 된다. 따라서 Hello, World 값을 순서대로 넣고 다시 poll 하면 먼저 넣은 String 타입이 차례대로 반환되는 것이다. 우리가 Dictionary 인스턴스를 할당할 때도 Dictionary<String,String>()  형식으로 할당하는 것과 유사하다. 

 

제네릭 타입 확장


익스텐션을 사용해 제네릭을 사용하는 타입에 기능을 추가하고 싶으면 익스텐션 정의에 타입 매개변수를 명시하지 않아야 한다. 대신 원래 제네릭 타입 매개변수를 익스텐션에서 사용할 수 있다.

struct Queue<T> {
    var items = [T]()
    
    mutating func push(_ data : T) {
        items.append(data)
    }
    
    mutating func poll() -> T {
        items.removeFirst()
    }
}

extension Queue {
    func isEmpty() -> Bool {
        items.isEmpty
    }
    //기존의 제네릭 타입에 정의되어 있는 T 타입을 사용!
    func peek() -> T {
        items[0]
    }
}

타입 제약


제네릭 타입이 특정 프로토콜을 따르는 타입만 사용하도록 제약을 두어야 할 때가 있다. 아니면 특정 클래스를 상속한 타입만 제네릭 타입으로 사용할 수 있도록 제약을 두어야 할 때도 있다. 예를 들어 json 데이터를 특정 타입으로 변환해주는 decode 함수를 작성해야할 때, 변환하려는 타입은 Decodable 프로토입을 따라야 한다. 이런 경우 제네릭 타입이 특정 프로토콜을 채택한 타입이거나, 특정 클래스를 상속한 타입만 올 수 있도록 다음과 같이 제약을 둘 수 있다. 

func decodeJsonData<T:Decodable>(type : T.Type, jsonData: JsonData) -> T {
    ...
}

제네릭 타입 제약은 타입 매개변수 옆에 콜론과 함께 클래스 타입, 혹은 프로토콜 이름을 명시해주면 된다. 위에서 구현한 Queue에 모든 원소를 String 으로 출력하는 함수를 추가한다고 생각해보자. Swift 에선 CustomStringConvertible 프로토콜을 채택해 타입의 String 값을 정의할 수 있다. 따라서 Queue 의 Element 인 T 타입이 CustomStringConvertible 프로토콜을 채택한 타입이라고 명시해주자. 

public protocol CustomStringConvertible {
    var description: String { get } //description 을 통해 타입의 특성을 String 으로 표현할 수 있다
}

그리고 원소의 모든 데이터를 출력하는 printAll() 함수를 추가해주자

 

struct Queue<T : CustomStringConvertible> {
    var items = [T]()
    
    mutating func push(_ data : T) {
        items.append(data)
    }
    
    mutating func poll() -> T {
        items.removeFirst()
    }
    
    func printAll() {
    //모든 요소의 description 을 출력한다!
        items.forEach { item in
            print(item.description)
        }
    }
}

이제 위 Queue 는 CustomStringConvertible 프로토콜을 채택한 타입만 Element 요소로 사용할 수 있다.

프로토콜의 연관타입


메소드, 클래스, 구조체나 열거형은 타입 매개변수를 사용하여 여러 타입에 대응하는 기능을 구현할 수 있었다. 그러나 프로토콜에선 타입 매개변수 대신 연관 타입(associated type) 을 사용한다. 연관 타입은 프로토콜에서 사용할 수 있는 타입 매개변수와 같은 것이다. 예를 들어 다음과 같은 프로토콜을 생각해보자 

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

Container 프로토콜은 존재하지 않는 타입인 ItemType 을 연관 타입으로 정의하여 타입 이름으로 활용한다. 이는 제네릭의 타입 매개변수와 유사한 기능으로, 프로토콜 정의 내부에서 사용할 타입이 그 어떤 것이어도 상관없지만 하나의 타입임은 분명하다라는 의미이다. Continer 프로토콜을 준수하는 타입이 꼭 구현해야 할 기능을 생각해보자

  • 컨테이너의 새로운 아이템을 append 함수를 이용해 추가한다
  • 아이템 개수를 확인하도록 count 프로퍼티가 있어야 한다
  • Int 타입의 인덱스 값으로 특정 인덱스에 해당하는 아이템을 가져올 수 있도록 서브스크립트를 구현해야 한다.

이 세 가지의 조건을 충족한다면 Container 프로토콜을 준수하는 타입이 될 수 있지만, 컨테이너가 어떤 아이템을 저장해야 될지에 대해선 언급하지 않았다. 그럼 이 프로토콜을 채택한 클래스를 구현해보자

class DefaultContainer : Container {
    var items: [Int] = [Int]()
    var count: Int {
        get {
            return items.count
        }
    }
    
    func append(item: Int) {
        items.append(item)
    }
    
    subscript(i: Int) -> Int {
        get {
            return items[i]
        }
    }
}

Container 프로토콜을 채택한 DefaultContainer 를 구현해주었다. 해당 클래스는 Int 타입을 프로토콜의 연관 타입으로 지정해주었다. 위 프로토콜의 모든 기능은 class 에서 지정해준 것처럼 Int 타입으로 가정하고 모든 기능을 일관성있게 구현하면 된다. 만약 ItemType 을 어떤 타입으로 사용할지 조금 더 명확히 설명해주고 싶다면 구현부에 typealias ItemType = Int 라고 별칭을 지정해 줄 수 있다.

class DefaultContainer : Container {
    typealias ItemType = Int
    ...
}

프로토콜의 연관 타입에 대응하여 실제 타입을 사용할 수도 있지만, 제네릭 타입에서는 연관 타입과 타입 매개변수를 대응할 수도 있다. 

//Container 프로토콜의 ItemType 연관 타입과 타입 매개변수인 Element를 일치시켰다!
class DefaultContainer<Element> : Container {
    typealias ItemType = Element
    
    var items: [Element] = [Element]()
    var count: Int {
        get {
            return items.count
        }
    }
    
    func append(item: Element) {
        items.append(item)
    }
    
    subscript(i: Int) -> Element {
        get {
            return items[i]
        }
    }
}
연관 타입 정리

1. 연관 타입은 프로토콜에서 사용할 수 있는 기능으로, 제네릭의 타입 매개변수와 똑같은 기능을 한다
2. 연관 타입은 typealias 로 정의해줄 수 있으며, 아니면 구현부에 실제 타입을 사용해줌으로써 대응할 수 있다
3. 제네릭 타입 매개변수와 프로토콜의 연관 타입을 대응할 수 있다.