Swift Generic 이해하기

마이노·2024년 3월 7일
3

15주 글쓰기 🐣

목록 보기
3/9
post-thumbnail

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/generics/

애플에서 제공하는 공식문서를 따라가면서 제네릭에 대해 알아보도록 할께요.

제네릭

여러 유형에 작동하는 코드를 작성하고 해당 유형에 대한 요구사항을 지정함


코드를 읽다보면 다음과 같이 생긴 코드들을 어렵지 않게 만나볼 수 있습니다.
struct Heap<T: Comparable> {
	private var elements: [T] = []
    ...
    /// 힙을 구성하는 코드에서나
}

provider = MoyaProvider<GitHub>()
...
// 네트워크를 구성하는 코드에서나.. (꺽쇠)

제네릭을 사용하면 사용자가 정의한 요구 사항에 따라서 모든 유형에서 작동하는 유연하고 재사용 가능한 코드를 작성할 수 있습니다. 중복을 피하고 명확하고도 추상적인 방식으로 표현하는 코드를 만들 수 있어요.

제네릭은 다음과 같은 문제해결에 매우매우 효과가 있습니다.

1. 제네릭 함수

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

두개의 인자를 받아 서로의 위치를 스위칭해주는 간단한 함수인데요, 이 함수에는 가장 큰 문제점이 있습니다.

확장이 불가능합니다.
이말인 즉슨 입력을 받을 때 타입이 Int로 고정되어있기 때문에 Float, Double, String ...등등의 타입들을 받고 싶다면 함수를 다시 만들어야 합니다.

func swapTwoDouble(_ a: inout Double, _ b: inout Double) {
	let temp = a
    a = b
    b = temp
}

func swapTwoString(_ a: inout String, _ b: inout String) {
	let temp = a
    a = b
    b = temp
}

...

이는 명확하게 비효율적이겠죠? 이를 해결해주는 담당일진이 제네릭입니다.

func swapTwoValue<T>(_ a: inout T, _ b: input T) {
		let temp = a
    a = b
    b = temp
}

제네릭함수는 예상되는 타입을 함수이름 우측에 꺽쇠로 ' <> ' (병아리같네욤) 표현해줍니다.

💡 T는 어디서 나온 이름이에요?

보통 이름을 선언할 때 단일 문자나 Upper Camel Case로 많이 사용합니다.
예) T, V, U, Element

여기서 T는 Type의 약자입니다.

사용하게 될 실제 유형은 함수가 호출될 때마다 결정이 됩니다.
a와 b의 타입이 같기 때문에 서로 다른 타입을 넣는 것은 허용되지 않습니다.

let firstValue: Int = 10
let secondValue: Double = 5.41

swapTwoValue(&firstValue, &secondValue)

// Cannot convert value of type 'Double' to 
// expected argument type 'Int'

firstValue를 넣을 시점에 이미 T는 Int로 결정이 났기 때문에 Double이 들어올 수 없는 것입니다.


2. 제네릭 타입

앞에서 살펴본 제네릭은 함수였습니다. 클래스, 구조체, 열거형 타입에서도 선언할 수가 있어요. 이를 제네릭 타입이라고 합니다.

제네리 함수와 마찬가지로 작성할 수 있는데, Stack을 한번 만들어보도록 하겠습니다.

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

// 사용
var stackOfStrings = Stack<String>()
stackOfStrings.push("하나")
stackOfStrings.push("둘")
stackOfStrings.push("셋")
stackOfStrings.push("넷")
stackOfStrings.pop()

Stack에 저장할 유형을 <>안에 작성하여 새 인스턴스를 만들어 사용할 수 있습니다.

3. 제네릭 타입 확장

extension을 통해 확장을 할 수 있습니다. 다만 정의할 때 매개변수 목록을 제공하지 않기 때문에 본문의 매개변수를 가져다가 사용을 해야 합니다.

extension Stack {
	/// 본문에서 사용한 제네릭 타입 Element 사용
    var topItem: Element? {
        return items.isEmpty ? nil : items[items.count - 1]
    }
}

4.제네릭 제약조건 주기

확장의 일부로 where절을 활용 할 수도 있어요!

extension Stack where Element: Equatable {
    func isTop(_ item: Element) -> Bool {
    	...
    }
}

제네릭으로 어떤 타입이든 받던 Stack에서 Equatable Protocol을 채택한 타입만이 사용할 수 있습니다. 이렇게 제약을 주어 타입마다의 사용가능한 함수를 제한할 수 있게 됩니다.

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    ...
}

제약은 클래스 제약과 프로토콜 제약이 있습니다.

위 코드에서 클래스제약은 T 자리에 들어오는 어떠한 타입은 T의 하위 클래스여야 하는 조건입니다.
프로토콜 제약은 해당 프로토콜을 준수해야 하는 제약 조건이 되겠습니다.

class Move {}
class Animal: Move {}
class TV {}

let move = Move()
let animal = Animal()
let tv = TV()

func walk<T: Move>(input: T) -> String {
}

walk(move)		// ✅
walk(animal)	// ✅
walk(tv) 		// ❌

위 코드에서 Move라는 하나의 클래스를 만들었습니다.
Animal은 움직일 수 있으니 Move를 상속해줍니다.

인스턴스를 생성하고 walk함수의 인자로 넣어주면 move와 animal은 동작을 하게 됩니다. Move타입을 준수했기 때문입니다.

반면 tv는 타입을 준수하지 못했기 때문에 실행할 수 없습니다.

5. associatedtype (프로토콜의 제네릭)

protocol Hash {
    associatedtype Element
    var element: [Element] { get set }
    
    mutating func pop() -> Element?
}

프로토콜에서는 associatedtype를 통해 연관타입을 지정해줄 수 있습니다. 채택하는 타입에 의해서 실제 타입으로 대체되는 연관 타입을 정의하게 되며 추상화된 형태로 만들 수 있어요.

이제 Hash protocol을 한번 채택해보겠습니다.

struct Store: Hash {
    var element: [Int] = []
    
    mutating func pop() -> Int? {
        if element.isEmpty { return nil }
        return element.popLast()
    }
}

Hash에 의해 필수적으로 구현해야하는 프로퍼티와 메서드가 있습니다. 이들의 타입은 associatedtype을 통해 정해졌었는데요, swift는 타입추론을 통해 Store에서 사용된 Int라는 타입이 associatedtype의 Int임을 알 수 있습니다.

💡 다음과 같이 사용하지 않아도 자동으로 추론해요!

typealias Element = Int

associatedtype에도 제약을 걸 수 있나요? 넵 가능합니다.

protocol Heap {
    associatedtype Element: Equatable
}

Element가 Equatable 프로토콜을 준수하게 만들 수 있습니다.

또한 이런것도 가능한데요, 자기 자신을 준수해야 할 사항에 포함시킬 수 있습니다.

protocol Hash {
    associatedtype Element
    var element: [Element] { get set }
    
    mutating func pop() -> Element?
}

protocol HashImprove: Hash {
    associatedtype Improve: HashImprove where Improve.Element == Element
    mutating func pop() -> Improve
}

Hash 프로토콜을 준수하는 HashImprove 프로토콜을 만들었습니다.
HashImprove의 연관타입에는 Improve가 있습니다.

이 Improve는 HashImprove 프로토콜을 준수해요. 그리고 이 Improve의 Element 타입이 Element랑 같아야 합니다.

간단하게 그림으로 그려보았어요.
Improve에서 Element에 접근하는 방법과 바로 Element에 접근하는 방법을 정리한 그림입니다.

  1. Element는 Hash protocol을 채택했기 때문에 채택한 프로토콜쪽(HashImprove)에서 곧바로 접근이 가능하다!

  2. Improve는 HashImprove를 준수한다. HashImprove 또한 Hash를 준수한다. 따라서 Improve는 Hash의 associatedtype에 접근할 수 있다!


여기까지 제네릭을 알아보았는데요! 오픈소스를 찾아보거나 여러 사람들의 코드들을 볼 때면 항상 제네릭으로 되어있어 읽기에 어려움을 많이 느꼈었습니다. 이해하고 코드를 읽고 직접 만들어보며 어느정도 두려움을 사라진 것 같습니다. 😁

profile
아요쓰 정벅하기🐥

4개의 댓글

comment-user-thumbnail
2024년 3월 8일

Generic 덕에 Collection 같은 자료구조에도 우리가 직접 만든 enum, class, struct, protocol 등을 담아서 활용할 수가 있죠
애플이 미래를 예측해서 우리가 만들 클래스에서 사용할 수 있는 Array를 만들어 놓을 수는 없으니까요

자기 자신을 제약사항으로 거는 Generic을 설명하시면서 HashImprove라는 protocol을 제시하셨는데
이 프로토콜을 채택해서 구현하려면 어떻게 코드를 작성해야 할까요?

1개의 답글
comment-user-thumbnail
2024년 3월 9일

<>가 병아리를 닮았다는 말을 절대 잊을 수 없을 것 같습니다 🐤🐤🐤

1개의 답글