본문 바로가기

Swift

Generic

728x90

제너릭 == 범용 타입 

제너릭 코드는 유연하게 작성할 수 있고 재사용 가능한 함수와 타입이 어떤 타입과 작업할 수 있도록 요구사항을 정의합니다. 제너릭을 사용하면 중복을 피하면서 의도를 명확하게 표현하고 추상적인 방법으로 코드를 작성할 수 있습니다.

 

Swift에서 가장 강력한 기능 중 하나로 Swift 표준 라이브러리 대다수는 제너릭 코드로 만들어졌습니다. 

Swift의 배열과 딕셔러니 타입은 제너릭 타입이며 Int 값을 갖고 있는 배열, String 타입을 갖고 있는 배열 .. 또는 다른 타입으로 배열을 만들 수 있습니다. 그리고 이러한 타입의 제한은 없습니다. 

 

Generic

제너릭이란, 타입에 의존하지 않는 범용 코드를 작성할 때 사용합니다.

제너릭을 사용하면 중복을 피하고 코드를 유연하게 작성할 수 있습니다. 

 

Generic Function

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

위의 함수와 같은 경우, 파라미터가 모두 Int형이라면 문제 없이 실행되지만, Double/String이라면 사용할 수 없습니다.

Swift는 타입에 민감한 언어이기 때문이죠.

 

그래서 만약 Double, String에 대한 함수를 작성하고 싶다면 다음과 같이 코드를 구현해야 합니다.

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

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

같은 로직에 대해서 타입이 다른 경우에, 위와 같이 하나 하나 구현하는 것은 좋은 방식이 아닙니다.

이럴 때 사용하는 것이 바로 제너릭 입니다.

 

타입에 제한을 두지 않는 코드를 사용하고 싶을 때 다음과 같이 함수를 만들 수 있습니다. 

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

위와 같이, < > 를 사용해서 그 안에 타입처럼 사용할 이름(T)를 선언하면 그 뒤로 해당 이름(T)을 타입처럼 사용할 수 있습니다.

 

여기서 T를 Type Parameter라고 부르는데, T라는 새로운 형식이 생성되는 것이 아니라 실제 함수가 호출될 때 해당 매개변수의 타입으로 대체되는 placeholder입니다. 

 

🤔 왜 함수의 이름 뒤에 < > 로 T를 감싸는 것일까요?

위에서 말한 것과 같이 T는 새로운 형식이 아니라, Placeholder이기 때문에, Swift에게 T는 새로운 형식이 아니라 실제 이 타입이 존재하는지 확인하지 않을 것을 말하는 것입니다. (자리 타입이라는 것이죠.)

 

그러므로 swapTwoValues라는 함수를 제너릭으로 선언하면, 다음과 같이 실제 함수를 호출할 때 Type Parameter인 T의 타입이 결정되는 것입니다.

var someInt = 1
var aotherInt = 2
swapTwoValues(&someInt,  &anotherInt)          
 
 
var someString = "Hi"
var aotherString = "Bye"
swapTwoValues(&someString, &anotherString)

이렇게 실제 함수를 호출할 때 Type Parameter인 T의 타입이 결정되는 것입니다. 

 

이 때 주의할 점은 하나의 함수에 대해서 다른 타입을 전달할 수 없다는 것입니다.

swapTwoValues(&someInt, &anotherString)

위와 같이 서로 다른 타입을 파라미터로 전달하면 첫번째 someInt로 타입 파라미터 T가 Int로 결정되었기 때문에 두번째 파라미터인 anotherString의 타입이 Int가 아니라 에러가 나게 됩니다.

 

그러므로 

  • 똑같은 내용의 함수를 오버로딩 할 필요 없이 제너릭을 사용하면 되고
  • 그렇기 때문에 코드 중복을 피하고 유연하게 코드를 짤 수 있습니다.

 

참고로, 타입 파라미터는 굳이 T가 아닌, 원하는 이름 마음대로 해도 상관 없고 하나 말고 여러 개를 (,)로 구분하여 사용할 수 있습니다.

func swapTwoValues<One, Two> { ... }

그러나, 보통 타입 파라미터 이름을 선언할 때는 가독성을 위해서 T또는 V와 같은 단일 문자 또는 Upper Camel Case를 사용합니다.

 

Generic Type

앞서 제너릭을 사용한 함수를 제너릭 함수라고 했습니다.

이 제너릭은 함수에만 가능한 것이 아니라 구조체/클래스/열거형 타입에도 선언할 수 있는데, 이것을 제너릭 타입이라고 합니다.

 

만약 Stack을 제너릭으로 만들고 싶다면 다음과 같이 만들 수 있습니다.

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

구조체 뿐만 아니라, 클래스/열거형에도 가능합니다. 

 

그러면, 제너릭 타입의 인스턴스를 생성할 때는 어떻게 해야 할까요?

let stack1: Stack<Int> = .init()

let stack2 = Stack<Int>.init()

선언과 마찬가지로 < >를 통해서 어떤 타입으로 인스턴스를 만들 것인지 명시해야합니다.

 

위와 같은 형식의 선언은 배열 생성할 때와 같습니다.

let array1: Array<Int> = .init()

let array2 = Array<Int>.init()

그 이유는 Swift에서 Array가 제너릭 타입이기 때문입니다.

 

Type Constraints

제너릭 함수와 타입을 사용할 때 특정 클래스의 하위 클래스 또는 특정 프로포콜을 준수하는 타입만 받을 수 있도록 제약을 설정할 수 있습니다.

 

프로토콜 제약

만약 파라미터로 두 개의 값을 받아서 두 값이 같으면 true, 다르면 false를 반환하는 함수를 제너릭으로 선언해보겠습니다.

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

이렇게 선언할 수 있을 것 같지만, 실제로는 에러가 납니다. 

 

왜냐하면, == 연산자는 a와 b의 타입이 equatable이란 프로토콜을 준수할 때만 사용할 수 있기 때문입니다.

그런데, T라고 선언한 타입 파라미터는 a, b가 equatable 프로토콜을 준수하는 타입일 수도, 아닐 수도 있는데 ==를 쓰면 안되기 때문에 에러가 납니다.

 

따라서, 이럴 경우에는 해당 프로토콜을 채택하면 됩니다.

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

타입 파라미터에 T: Equatable 로 제약을 줄 수 있습니다.

이렇게 하면 isSameValues 함수에 들어올 수 있는 파라미터는 Equatable이란 프로토콜을 준수하는 파라미터만 받을 수 있습니다.

 

클래스 제약

클래스 제약은 프로토콜 제약과 동일합니다.

해당 자리에 프로토콜이 아닌 클래스 이름이 오는 것입니다. 

 

class Bird { }
class Human { }
class Teacher: Human { }
 
func printName<T: Human>(_ a: T) { }

위와 같이 T: Human 이런 식으로 클래스 이름을 써서 제약을 줄 수 있습니다.

 

let bird = Bird.init()
let human = Human.init()
let teacher = Teacher.init()
 
printName(bird)                  // Global function 'printName' requires that 'Bird' inherit from 'Human'
printName(human)
printName(teacher)

Human 클래스 인스턴스인 human과 Human 클래스를 상속 받은 서브 클래스의 인스턴스인 teacher은 printName이란 제너릭 함수를 실행할 수 있지만,

Human 클래스의 서브 클래스가 아닌 Bird의 인스턴스인 bird는 실행할 수 없습니다.

 

제너릭 확장하기 

제너릭 타입인 Array를 확장해보겠습니다.

extension Array {
    mutating func pop() -> Element {
        return self.removeLast()
    }
}

만약 제너릭 타입을 확장하면서 타입 파라미터로 사용할 경우, 실제 Array 구현부에서 타입 파라미터가 Element이기 때문에 Element로 사용해야 합니다. 확장에서 새로운 제너릭을 선언하거나 다른 타입 파라미터를 사용하면 안됩니다.

 

 

또한, where을 통해서 확장/제약을 줄 수 있습니다.

extension Array where Element: FixedWidthInteger {
    mutating func pop() -> Element { return self.removeLast() }
}

위와 같이 타입 파라미터 Element가 FixedWithInteger라는 프로토콜을 준수해야 한다는 제약을 주게 되면,

let nums = [1, 2, 3]
let strs = ["a", "b", "c"]
 
nums.pop()              // O
strs.pop()              // X

FixedWidthInteger 프로토콜을 준수하는 Array<Int>형인 nums는 extension에서 구현된 pop이란 메서드를 사용할 수 있지만,

FixedWidthInteger 프로토콜을 준수하지 않는 Array<String>형인 strs는 extension에서 구현된 pop이란 메서드를 사용할 수 없습니다.

 

제너릭 함수와 오버로딩 

제너릭은 보통 타입과 관계 없이 동일하게 실행되지만, 특정 타입일 경우 제너릭 말고 다른 함수로 구현하고 싶다면 이 때는 제너릭 함수를 오버로딩하면 됩니다. 

 

func swapValues<T>(_ a: inout T, _ b: inout T) {
    print("generic func")
    let tempA = a
    a = b
    b = tempA
}
 
func swapValues(_ a: inout Int, _ b: inout Int) {
    print("specialized func")
    let tempA = a
    a = b
    b = tempA
}

 

이렇게 할 경우, 타입이 지정된 함수가 제너릭 함수보다 우선 순위가 높습니다.

var a = 1
var b = 2
swapValues(&a, &b)          // "specialized func"
 
 
var c = "Hi"
var d = "Sodeul!"
swapValues(&c, &d)          // "generic func"

Int 타입으로 swapValue를 실행할 경우, 타입이 지정된 함수가 실행되고 String 타입으로 swapValue를 실행할 경우, 제너릭 함수가 실행됩니다. 

'Swift' 카테고리의 다른 글

Any/AnyObject  (0) 2022.04.29
mutating  (0) 2022.04.29
atomic VS nonatomic  (0) 2022.04.26
Initialization - 구조체/클래스 초기화  (0) 2022.04.21
Initialization - 그 외  (0) 2022.04.20