제네릭은 함수, 타입 및 데이터 구조에 대한 유연하고 추상적인 코드를 작성할 수 있게 해주는 기능으로, 정의한 요구사항에 따라 모든 타입에서 동작할 수 있다. (이렇게 말하면 무슨 말인지 잘 모르겠다..🫨) 예시를 통해 알아보자!
두 정수의 값을 바꿔주는 함수를 작성한다고 해보자
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
let temporaryA = a
a = b
b = temporaryA
}
그런데 만약 이 함수를 문자열이나 다른 타입의에도 적용하고 싶다면 어떻게 해야 할까?? 이럴때 제네릭을 사용하면 간단히 해결할 수 있다.
func sort<T: Comparable>(array: [T]) -> [T] {
return array.sorted()
}
func swapTwoInts(_ a: inout Int, _ b: inout Int)
func swapTwoValues<T>(_ a: inout T, _ b: inout T)
함수의 제네릭 버전을 더 자세히 살펴보자. 함수의 제네릭 버전은 Int, String과 같은 실제 타입 이름 대신에 T라는 임의의 타입 이름을 사용한다. T가 무엇이어야 하는지는 정의하지 않았지만 들어오는 array는 T 타입이여야 한다. T의 실제 타입은 함수가 호출될 때마다 결정된다.
제네릭 함수의 이름 바로 뒤에 <T>가 위치한다. 이 <T>는 swift에게 해당 함수나 타입이 제네릭 타입 매개변수를 사용한다는 것을 알려주는 역할을 한다. 그러면 swift는 T라는 실제 타입을 찾지 않는다. 여러개의 타입 매개변수를 갖고 싶다면 홀화살괄호 기호 안쪽에 쉼표로 분리한 여러개의 타입 매개변수를 지정해줄 수 있다. <T, U>
T는 타입 파라미터라고 한다. 타입 파라미터는 임의의 타입을 지정하고 이름을 지정하며 꺾쇠 괄호 사이에 기록하고 함수의 이름 바로 뒤에 작성된다. T, U, V를 사용하는게 일반적이다.제네릭 타입은 배열과 딕셔너리와 유사한 방식으로 모든 타입에서 동작할 수 있는 사용자 정의 클래스, 구조체, 그리고 열거형을 말한다. 예를 들어 스택에서 제네릭 콜렉션 타입이 어떻게 작성되는지 보자.
struct IntStack {
var items: [Int] = []
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
}
이 구조체는 스택에 값을 저장하기 위해 items라는 배열을 사용하고 push, pop 메서드 2개를 제공한다. 그러나 이 스택은 Int 값만 사용할 수 있다. 이를 제네릭 타입으로 구현하면 다음과 같다.
struct Stack<Element> {
var items: [Element] = []
mutating func push (_ item: Element){
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
}
stack의 제네릭 버전은 Element이라는 타입 파라미터를 사용한다. Element는 모든 타입이 될 수 있다.
var stackDouble = Stack<Double>()
앞의 예시에서는 제네릭 타입과 함수는 모든 타입에서 동작이 가능했다. 그러나 가끔 제네릭 함수와 제네릭 타입으로 사용될 수 있는 타입에 타입 제약을 해야하는 경우가 있다. 타입 제약은 타입 파라미터가 특정 클래스를 상속하거나 특정 프로토콜을 준수해야함을 말한다.
예를 들어, 제네릭 함수를 작성하여 배열의 요소 중에서 최댓값을 찾는다고 가정해보자. 이때 이 함수는 요소들을 비교할 수 있어야 한다.
func findMax<T>(array: [T]) -> T? {
guard let max = array.max() else {
return nil
}
return max
}
위와 같이 작성하면 동작할 것 같아 보인다. 그러나 array.max() 가 제네릭 타입에 대해서 보장되는 동작이 아니다. max()메서드는 Comparable 프로토콜을 준수하는 타입에 대해서만 정의되어 있다. 따라서 위의 함수에 제약을 걸어주지 않으면 컴파일은 어떤 타입이든 사용될 수 있다고 인식해서 해당 함수를 컴파일할 수 없다는 오류를 발생시킨다!! (이게 아마 배열 같은 비교 가능한 타입들은 다 Comparable 프로토콜을 준수해서 max()나 sorted()같은 메서드를 제공해주는듯!!🤓) 따라서 이 함수는 다시 작성되어야 한다.
func findMax<T: Comparable>(array: [T]) -> T? {
guard let maxElement = array.max() else {
return nil
}
return maxElement
}
다른 예시로, Equatable 프로토콜을 준수하는 타입에 대해서만 두 값이 같은지 비교하여 true 또는 false를 반환하는 함수를 작성할 수 있다.
func areEqual<T: Equatable>(_ a: T, _ b: T) -> Bool {
return a == b
}
where 키워드를 사용하여 연관된 타입이 특정 프로토콜을 준수해야 하거나 특정 타입 파라미터와 연관된 타입이 동일해야 한다고 요구할 수 있다. 타입 또는 함수의 본문에 여는 중괄호 바로 전에 where 절을 작성한다.
func process<T>(value: T) where T: Numeric {
// Numeric 프로토콜을 채택하는 타입만을 제네릭 타입 T로 받음
print("Value is a numeric type.")
}