[TIL] 제네릭과 연관 타입

valse·2022년 7월 22일
0

Swift

목록 보기
4/7
post-thumbnail

확장, 상속, 프로토콜... 이만하면 됐지?

아쉽게도(?) 개발 효율성을 더 높일 수 있는 방법은 존재한다.
왜냐하면 함수를 정의할 때, 서로 다른 타입을 리턴하는 함수, 메소드는 해당 타입에 맞게 각각 메소드를 정의해야 했기 때문.
물론 파라미터를 받아오는 타입도 각 타입에 따라 따로따로 함수와 메소드를 정의해야 하는데, 여간 불편한게 아니었다.

var a = 10
var b = -10

var i = 0.5
var j = -0.5

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

func swap(_ i: inout Double, _ j: inout Double) {
	let tmp = i
    i = j
    j = tmp
}

swap(&a, &b)
print(a, b) // -10, 10

swap(&i, &j)
print(i, j) // -0.5, 0.5

그래서 제네릭이 뭔데

제네릭 문법은 함수 정의의 편의성을 굉장히 높여주는 방법론이다.
어떤 타입이 오든지 대응할 수 있는 게릴라 특화 문법이라 할 수 있겠다.

인풋과 아웃풋 타입마다 서로 다른 함수를 정의할 필요 없이 인풋 타입에 무관한 함수를 정의할 수 있으며 당연히 클래스, 구조체, 열거형에도 적용할 수 있다.
함수, 커스텀 타입 등에 제네릭 타입 파라미터를 선언해 주기만하면 된다.

타입에 따른 유연한 함수 정의가 가능하고, 유지보수의 편의성, 재사용성이 증가한다.
타입 파라미터는 함수 내부에서 파라미터 형식이나 리턴형으로 사용된다(함수 바디에서도 사용 가능).
그래서 만약 위에서 쓴 예시를 제네릭 문법을 적용해서 다시 쓴다면, 이렇게 할 수 있다.

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

제네릭 타입을 일컬을 때, 보통 T를 사용하지만 이 T라는 문자는 커스텀할 수 있다.
대문자 영문자(Upper Camel Case)면 상관 없다.

제네릭 타입 파라미터를 구별하기 위해 2개 이상 선언할 수도 있다.
함수를 선언하고 함수의 이름 뒤에 <타입파라미터>를 지정한다.
당연히 배열에도 적용이 가능하다!

스위프트의 배열 생성자 메소드 자체도 Array<Element> 로 정의되어 있는 걸 알 수 있는데, 덕분에 배열 생성자 함수를 통해 만들 때 타입에 자유로운 배열을 만들 수 있다!


제네릭의 진짜 쓸모

  1. 구조체와 클래스
// 제네릭 타입 파라미터 T를 갖는 구조체
struct MemberGeneric<T> {
    // 모든 타입을 담을 수 있다.
    var members: [T] = []
    var name: T = "김덕배"
}

// 제네릭 타입 파라미터 T를 갖는 클래스
class Grid<T> {
    var x: T
    var y: T

    init(x: T, y: T) {
        self.x = x
        self.y = y
    }
}

확장의 경우

구조체나 클래스가 확장되더라도 제네릭 타입은 그대로 남아있고, 확장되는 코드 블럭에서도 자유롭게 사용이 가능하다.
오히려 본체에서 선언했던 제네릭 타입을 확장 부분에서 다시 선언하면 컴파일 에러를 반환한다.
아, where 절도 사용이 가능하다!

extension MemberGeneric {
	func getName() -> T {
    	return self.name
    }
}

// where 절로 제네릭 타입 제약도 가능
extension Grid where T == Int {
	func printer() -> T {
    	print("제네릭 타입이 정수형일 때만 확장합니다!")
	    return T
    }
}

상속의 경우

클래스는 특히 상속이 가능하기 때문에 상속 족보 속에서 제네릭 타입을 활용할 수 있다.
상속 족보 내의 특정 클래스를 상속할 때에 제네릭 타입을 쓰는 경우가 아래 예시와 같다

class Person {
    // code
}

class Student: Person {
    // code
}

let person = Person()
let student = Student()

// 특정 클래스와 상속관계에 있는 클래스만 인풋 타입으로 사용할 수 있다.
// 타입 파라미터의 제약사항으로 전달되는 클래스 타입을 상속하면 사용할 수 있다.
func personClassOnly<T: Person>(array: [T]) {
	// code
}
  1. 열거형
    열거형은 case의 원시값에는 Hashable한 타입만 써주면 되어서 자유로운 편이다.
    그런 열거형도 제네릭 타입을 사용할 수 있는데, 바로 연관값으로 활용할 때이다.
enum Pet<T> {
    case dog, cat
    case etc(T)
}

프로토콜과 제네릭

제네릭은 특히 프로토콜에서 빛을 발하는데, 프로토콜에 제네릭이 적용되면 다형성을 구현할 때 큰 도움이 된다.
특정 프로토콜이 서로 다른 타입에 더욱 자유롭게 채택할 수 있도록 하여 통일성을 높이고 재활용성도 함께 높아진다.
그러나 프로토콜 자체가 제네릭 타입 파라미터를 가질 수는 없다.
프로토콜에서의 제네릭 타입 파라미터는 쓰임 자체가 어색하다.
프로토콜은 메소드나 속성을 구체화 할 의무가 없고 최소 요구사항만 갖기 때문이다.

프로토콜은 자기 자신을 채택하는 타입이 제네릭하게 메소드나 속성을 구현할 수 있도록 '연관 타입(associatedtype)'을 활용한다.

associatedtype은 프로토콜을 사용하는 개발자들이 더욱 시맨틱하게 코드를 짜는 것을 돕는다.
연관 타입을 요구사항으로 갖는 프로토콜을 채택하면, 해당 연관 타입을 각 타입에서 구체화할 수 있고 그전까지는 명시되지 않는다.
프로토콜의 연관 타입은 이렇게 쓴다.

protocol A {
	associatedtype Element
}

struct NumStruct: A {
    // 프로토콜의 연관타입을 채택하면, 어떤 타입으로 쓸 건지 타입 앨리어스로 언급해야만 한다.
    // 프로토콜의 요구사항을 구체화하면서 타입 앨리어스는 생략이 가능하다.
    typealias Element = Int 
    
    func doSth(to: Int) {
        print(to)
    }

    func alert() -> Int? {
        return 1
    }
}

연관 타입??

솔직히 제네릭과 연관 타입의 차이는 얼핏 알았는데, 연관 타입 자체의 특징은 이해하지 못했다.
그래서 연관 타입을 더 살펴보기 위해 에러 학습법(?)을 해보았다.

// 연관 타입 프로토콜
protocol Hi {
    associatedtype Element
    func doSth() -> Element
    func greeting() -> Element
}

우선은 이렇게 프로토콜을 만든다.
애초부터 프로토콜은 제네릭 타입 파라미터를 갖지 못하기 때문에 protocol Hello<T>는 정의할 수 없다.
이제 이 프로토콜을 구조체에 적용해서 억지로 에러 메시지를 한 번 띄워볼 예정이다.

⚠️ 예시에서는 고의적으로 typealias를 쓰지 않았음을 미리 밝힙니다!!

protocol Hi {
    associatedtype Element
    func doSth() -> Element
    func greeting() -> Element
}

struct He: Hi {
    func doSth() -> String {
        return "He"
    } // ✅

    // func greeting() -> String {
    //     return "Hi"
    // }

    func greeting() -> Int {
        return 0
    } // 🚫
}

원래대로라면 프로토콜의 메소드를 구체화하면서 자동으로 Element가 타입추론 되어야 한다.
그러나 나는 고의적으로 greeting() 메소드의 리턴 타입을 doSth()의 리턴 타입과 다르게 설정했고,
덕분에 멋진 에러 메시지를 받을 수 있었다.
프로토콜 부분에서 발생한 컴파일 에러 메시지를 보자.

ambiguous inference of associated type 'Element': 'Int' vs. 'String'

요컨대 나더러 Element 타입을 왜 IntString 2개로 나눠서 쓰고 있냐고 따지는 거다.
오, 그렇다면 연관 타입은 하나의 구조체, 클래스, 열거형 내에서 동일한 타입으로 구현되어야 한다는 특징을 갖는구나!

따라서 Hi 프로토콜을 채택하는 여러 구조체들은 서로 다른 타입을 가질 수 있으나, 각 구조체의 내부에서는 Hi 프로토콜의 Element 타입이 일관적으로 사용되어야 한다.


profile
🦶🏻🦉(발새 아님)

0개의 댓글