Swift/Swift 기본기

10. 제네릭 (Generic)

여성일 2023. 7. 31. 15:52
728x90

제네릭은 Swift에서 가장 강력한 기능 중 하나이다.

실제로 Swift 표준 라이브러리 또한 수많은 제네릭으로 구성되어 있는데, 우리가 흔하게 사용하는 배열과 딕셔너리도 제네릭 타입이다.

 

제네릭이 무엇일까?

제네릭이란 모든 타입에서 동장할 수 있는 더 유연하고 재사용 가능한 함수와 타입을 작성할 수 있도록 하게 해주는 기능이다.

즉, 제네릭을 사용하면 중복을 피하고 명확하고 추상적인 방법으로 그 의도를 표현할 수 있는 코드를 작성할 수 있고 코드를 유연하게 작성할 수 있다.

 

제네릭 문법

제네릭을 사용하기 위해서는 <>를 사용하면 된다.

 

제네릭 함수

스위프트 공식문서에 있는 문제를 예를들면

func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    print("변경 전 : \(a) \(b)")
    let tempA = a
    a = b
    b = tempA
    print("변경 후 : \(a) \(b)")
}

위와 같이 두 정수 값을 바꾸는 swapTwoInts 함수가 있다. 

var intA: Int = 10
var intB: Int = 20

func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    print("변경 전 : \(a) \(b)")
    let tempA = a
    a = b
    b = tempA
    print("변경 후 : \(a) \(b)")
}

swapTwoInts(&intA, &intB)

이 함수의 경우 파라미터 모두 정수형일 경우엔 잘 작동하지만 파라미터 타입이 Double이나 String형이면 사용할 수 없다.

따라서 Double, String 타입에 대해서 swapTwoInt 함수를 사용하고 싶다면

func swapTwoInts(_ a: inout Double, _ b: inout Double) {
    print("변경 전 : \(a) \(b)")
    let tempA = a
    a = b
    b = tempA
    print("변경 후 : \(a) \(b)")
}

func swapTwoInts(_ a: inout String, _ b: inout String) {
    print("변경 전 : \(a) \(b)")
    let tempA = a
    a = b
    b = tempA
    print("변경 후 : \(a) \(b)")
}

이런식으로 귀찮게 일일이 함수를 오버로딩해서 만들어야한다.

이럴 때 사용하는게 제네릭이다.

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

위에서 설명한대로 <>를 사용하면 된다. 여기서 T를 Type Parameter라고 하는데,

T라는 새로운 형식이 생성되는 것이 아니라, 실제 함수가 호출될 때 해당 매개변수의 타입으로 대체되는 Placeholder이다. 

 

Placeholder인 T는 타입이 어떤 타입이어야 하는지 명시하지 않는다. 다만 두 인자의 타입이 같다는 것을 알려준다.

 

따라서 이렇게 swapTwoValues라는 함수를 제네릭으로 선언 해주면, 

함수를 귀찮게 오버로딩 하지 않아도 한 개의 함수로 다른 타입을 받아올 수 있다.

✅ 여기서 주의해야할 것이 있는데, 서로 다른 타입을 파라미터로 전달 받으면 오류가 발생한다.

 

제네릭 타입

제네릭은 함수뿐만 아니라 구조체, 클래스, 열거형 타입에도 선언할 수 있다. 이것을 제네릭 타입이라고 한다.

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

Stack을 제네릭으로 만들고 싶다면 위와 같이 <>를 이용해서 제네릭으로 만들 수 있다.

var intStack: Stack<Int> = Stack<Int>()

제네릭 타입을 가지고 있는 Stack의 인스턴스를 생성할 땐 선언과 마찬가지로 <>를 통해 어떤 타입으로 사용할 것인지 명시해주면 된다.

 

타입 제한

제네릭을 이용할 때 특정 클래스를 상속하거나 특정 프로토콜을 따르거나 합성하도록 명시할 수 있다.

위에서 구현한 swapTwoValues 함수는 제네릭으로 구현되어 있기 때문에 모든 타입에서 동작한다. 

하지만, 때로는 제네릭 함수가 처리해야 할 기능이 특정 타입에만 한정되어야만 처리할 수 있다던가,

제네릭 타입을 특정 프로토콜을 따르는 타입만 사용할 수 있도록 제약을 두어야할 경우가 있을 것이다.

 

이때 사용하는 것이 타입 제한이다.

func isSameValues<T>(_ a: T, _ b: T) -> Bool {
	return a == b
}

파라미터로 두 개의 값을 받아서 두 값이 같으면 true, 다르면 false를 반환하는 함수를 제네릭으로 선언하려고 한다.

위와 같이 선언하면 에러가 발생한다. 왜냐하면 == 연산자는 a와 b의 타입이 Equatable이라는 프로토콜을 준수할 때만 사용할 수 있다.

func isSameValues<T: Equatable>(_ a: T, _ b: T) -> Bool {
	return a == b
}

따라서 이렇게 타입 파라미터에 T: Equatable 처럼 제약을 줄 수 있다. 

 

class Food { }
class Animal { }
class Dog: Animal { }

func printName<T: Animal>(_ a: T) { }

let food = Food.init()
let animal = Animal.init()
let dog = Dog.init()

printName(food) // err
printName(animal)
printName(dog)

위와 같이 T: Animal으로 제약을 주면 Animal 클래스 인스턴스인 animal과, Animal 클래스를 상속 받은 Dog는 printName이라는 제네릭 함수를 실행할 수 있지만, Animal 클래스의 서브 클래스가 아닌 food는 실행할 수 없다.

 

타입 제약에 자주 사용하는 프로토콜

Hashable, Equatable, Comparable, Indexable, IteratorProtocol, Error, Collection, CustomStringConvertible 등

 

제네릭 확장

제네릭 타입을 확장하기 위해선 extension을 사용하면 된다. 

extension Stack {
    var topElement: T? {
        return self.items.last
    }
}

위에서 구현한 Stack 구조체를 확장한 것이다. Stack은 제네릭 타입의 구조체이지만 extension의 정의에서는 따로 타입 파라미터 <T>를 명시해주지 않았다. 그 대신 기존의 제네릭 타입에 정의되어있는 T라는 타입을 사용할 수 있다.

 

where

where을 통해 확장 또는 제약을 줄 수 있다.

@frozen struct Dictionary<Key, Value> where Key : Hashable

이렇게, where을 통해 타입 파라미터 Key가 Hashable이라는 프로토콜을 준수해야 한다. 라는 제약을 줄 수 있다.

 

제네릭 함수와 오버로딩

제네릭은 타입에 관계없이 동일하게 실행 되지만, 만약 특정 타입일 경우 제네릭이 아닌 다른 함수로 구현하고 싶다면

제네릭 함수를 오버로딩 하면 된다.

func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    print("변경 전 : \(a) \(b)")
    let tempA = a
    a = b
    b = tempA
    print("변경 후 : \(a) \(b)")
}
 
func swapTwoValues(_ a: inout Int, _ b: inout Int) {
    print("오버로딩 함수")
    print("변경 전 : \(a) \(b)")
    let tempA = a
    a = b
    b = tempA
    print("변경 후 : \(a) \(b)")
}

이렇게 오버로딩 할 경우 타입이 지정된 함수가 제네릭 함수보다 우선순위가 높기 때문에 Int 타입으로 swapTwoValues 함수를 실행할 경우 타입이 지정 된 함수가 실행되고, 다른 타입으로 swapTwoValues 함수를 실행할 경우 제네릭 함수가 실행 된다.

 

제네릭 사용 예시

1. Optional

enum Optional<Wrapped> {
   case none
   case some(Wrapped)
}

 

2. 배열, 딕셔너리

@frozen struct Array<Element>
@frozen struct Dictionary<Key, Value> where Key : Hashable