[TIL]05.26

rbw·2022년 5월 26일
1

TIL

목록 보기
23/97

제네릭과 프로토콜

코드의 재사용을 위해 제네릭 활용하기

제네릭을 사용하면, 여러 타입에 공통으로 활용할 수 있는 함수와 자료형을 정의가 가능합니다. 먼저 제네릭이 없는 경우에 발생하는 문제점을 알아보겠습니다.

func maxInt(_ a: Int, _ b: Int, _ c: Int) -> Int {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
        return b
    } else {
        return c
    }
}

print("The answer to life: \(maxInt(3, 42, 7))")

위 함수를 만들고 다른 문자열이나 부동소수점 타입의 함수가 필요하다면, 똑같은 내용에 함수를 새로 작성을 해야합니다. 이럴 때 제네릭을 이용하면 구체적인 타입을 명시하지 않고 어떤 타입의 세 원소 중 가장 큰 원소를 고르는 max(_:_:_:)를 정의 할 수 있습니다.

// ❌ 컴파일되지 않습니다. 에러 메시지: Referencing operator function '>' on 'Comparable' requires that 'Element' conform to 'Comparable' 
func max<Element>(_ a: Element, _ b: Element, _ c: Element) -> Element {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
       return b
    } else {
       return c
    }
}

print("The answer to life: \(max(3, 42, 7))")
print(max("Alphabet", "한글", "ひらがな"))
print("Maximum temperature today is \(max(27.3, 31.2, 24.8))")

하지만 위 코드의 > 연산을 사용하려면 ElementComparable 해야 한다고 컴파일이 되지 않습니다. Element로 받는 모든 타입이 비교가 가능한 것은 아니기 때문에 발생하는 오류입니다.

프로토콜로 제네릭에 제약 걸기

이럴 때 비교가 가능한 타입들로만 제네릭으로 전달할 수 있게 제약을 걸 수 있습니다.

func max<Element: Comparable>(_ a: Element, _ b: Element, _ c: Element) -> Element {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
        return b
    } else {
        return c
    }
}

이미 Int, Double, StringComparable 프로토콜을 만족하기 때문에 정의만 해주면 컴파일이 성공적으로 되는 것을 알 수 있습니다.

만약 특정 기능을 수행하는 메서드가 필요한 경우라면 직접 프로토콜을 정의해서 제약 조건으로 사용할 수도 있습니다. 예를 들어서 신문 기사, 영상 보도 등 여러 미디어의 내용을 한 줄로 요약해서 보여주는 서비스를 만든다고 할 때, 각각의 미디어를 타입으로 정의하고 한 줄로 요약하는 기능을 프로토콜로 정의해서 제약조건으로 활용이 가능합니다.

protocol Summarizable {
    func summarize() -> String
}

struct NewsArticle: Summarizable {
    var title: String
    func summarize() -> String {
        title
    }
}

struct BlogPost: Summarizable {
    var title: String
    var author: String
    func summarize() -> String {
        "\(author) posted \(title)"
    }
}

func showSummary<Media: Summarizable>(of media: Media, to user: User) {
    print("Showed summary \(media.summarize()) to user \(user.name)")
}

showSummary(of: NewsArticle(title: "테스트를 최적화하는 방법"), to: User(name: "Nib")) // Showed summary 테스트를 최적화하는 방법 to user Nib

요약해서 문자열을 만들기Summarizable 이라는 프로토콜로 정의 했습니다. 그 다음, 함수에서 파라미터 자리에 올 수 있는 타입을 해당 프로토콜로 제한을 걸고있는 모습을 볼 수 있습니다.

프로토콜은 타입으로도 사용이 가능하다.

위의 예시를 아래 처럼 작성이 가능합니다.

// 제네릭스를 사용한 경우
func showSummary<Media: Summarizable>(of media: Media, to user: User) {
    print("Showed summary \(media.summarize()) to user \(user.name)")
}
// 프로토콜을 타입으로 사용한 경우
func showSummary(of media: Summarizable, to user: User) {
    print("Showed summary \(media.summarize()) to user \(user.name)")
}

제네릭을 사용할 때보다 프로토콜을 사용한 경우 코드가 더 명료해집니다.

이 둘의 차이는 무엇일지 다음 코드를 보며 알아보겠습니다. Summarizable 한 미디어를 저장하는 큐를 만든다고 생각할 때,

struct MediaQueueExistential { // existential은 프로토콜 타입의 또 다른 이름입니다
    var queue: [Summarizable]
}

struct MediaQueueGeneric<Media: Summarizable> {
    var queue: [Media]
}

let newsArticle = NewsArticle(title: "테스트를 최적화하는 방법")
let blogPost = BlogPost(title: "Swift 제네릭스 탐험기", author: "Nib")

// ✅ 잘 컴파일됩니다
let queueExistential = MediaQueueExistential(queue: [newsArticle, blogPost])


// ❌ 에러 메시지: Type of expression is ambiguous without more context
let queueGeneric = MediaQueueGeneric(queue: [newsArticle, blogPost])

제네릭으로 타입을 받는 구조체에서는 에러가 발생합니다.

에러 발생 이유로는 밑에서 나옵니당

이를 해결하기 위해 Summarizable 프로토콜을 제네릭의 타입 파라미터로 채택해보겠습니다.

// ❌ 에러 메시지: Value of protocol type 'Summarizable' cannot conform to 'Summarizable'
let queueGeneric: MediaQueueGeneric<Summarizable> = MediaQueueGeneric(queue: [newsArticle, blogPost])

이 에러의 이유도 밑에서 !

하지만 프로토콜을 만족하지 않는다는 에러가 발생합니다. 에러도 자주 발생하고, 문법도 장황한 제네릭은 사용하지 않는것이 옳을까요 ?

언제 프로토콜을 사용하고 언제 제네릭을 사용해야 할까요

제네릭과 프로토콜은 추상화의 단위가 다릅니다. 먼저 제네릭은 타입 단위에서 추상화를 진행합니다. 맨 처음 max(_:_:_:) 함수에서 비교할 세 인자는 모두 같은 구체적인 타입이였습니다.(Int, Double, String) max(1,"sdfsd",2.234) 처럼은 사용이 불가합니다. 이처럼 Comparable 을 이용해 어떤 타입의 인자를 받을 수 있는지 제한 하는 것이지, 다양한 타입을 받을 순 없습니다. 이런 의미에서 제네릭을 타입 단위의 추상화라고 부릅니다.

반면 프로토콜을 사용해서 만든 함수나 자료구조는 해당 프로토콜을 만족하는 값이라면 무엇이든 프로토콜 타입으로 넣을 수 있습니다. 이처럼 해당 프로토콜을 만족하는 값이라면 어떤 값이든 들어갈 수 있기 때문에, 값 단위의 추상화 라고 부릅니다.

제네릭과 프로토콜의 구현에 관해

먼저 제네릭은 타입에 대한 메타데이터를 넘겨주는 방식이나 특수화 방식을 사용해 구현됩니다.

타입에 대한 메타데이터를 넘겨주는 방법은 각 제네릭 원소가 지원하는 함수의 목록을 함수 호출시 함께 넘겨주는 방식입니다.

예를 들어 max(_:_:_:) 함수의 Element 타입은 Comparable 프로토콜을 만족하므로 ==, > 연산 등을 지원해야 하니 호출 시에 Int의 비교 연산자들을 담고 있는 표를 함께 넘겨 줍니다. 이렇게 함으로써, 해당 함수는 어떤 타입이 들어와도 타입에 맞는 연산을 호출 할 수 있습니다.

이 방법을 이용하면 연산의 구현이 런타임에 결정이 되기 때문에 함수 호출 인라이닝을 비롯한 컴파일러가 수행하는 다양한 최적화 기법의 혜택을 받지 못합니다.

인라인 : 함수의 모든 코드를 호출된 자리에 바로 삽입하는 행위 호출 과정을 거의 생략한다. c++ 내용

특수화란 우리가 제네릭을 이용해 만든 함수를 해당 타입에 맞게 컴파일러가 새로 만들어서 사용하는 방식 입니다. 예를 들어, Int 타입의 max()를 호출했다면 컴파일러가 제네릭을 사용한 함수의 본문을 복사해서 maxInt() 함수를 작성해서 그 함수를 대신 호출하는 의미입니다.

이 방식은 컴파일 시점에 어떤 타입의 연산을 사용하는지 알 수 있기 때문에, 최적화할 여지가 많아지지만, 하나의 함수를 여러 번 복사하기 때문에 바이너리 사이즈(파일크기)가 커집니다.

어떤 함수가 특수화 방식을 사용할지는 Swift 컴파일러의 최적화 레벨의 영향을 받습니다

프로토콜 타입은 existential 컨테이너라는 자료구조로 구현됩니다. 이는 프로토콜의 또 다른 이름입니다. 이 컨테이너 타입은 다섯 워드로 구성되어 있고, 첫 세 워드는 값 버퍼로 사용되고, 네 번째 워드는 값 참고표(Value Witness Table, VWT), 다섯 번째 워드는 프로토콜 참고표(Protocl Witness Table, PWT)를 가리킵니다.

워드 : 특정 프로세서가 자연스럽게 처리할 수 있는 데이터의 크기, 64비트 프로세서라면 64비트가 워드의 크기이다.

세 워드 이하의 타입, 첫 예시의 Point 같은 타입이 프로토콜 타입으로 사용시, 이 값 버퍼의 첫 번째, 두번째 워드가 x,y 좌표로 사용됩니다. 위 예시의 Line 처럼 네 워드 이상의 크기를 가지는 타입은 그 값이 힙에 할당 되고, 값 버퍼의 첫 워드가 그 할당을 가리킵니다.

Int = 4byte = 32bit

값이 한번 이 컨테이너에 담기면 구체적인 타입을 모르게 되므로, 항상 값을 취급할 때 VWT를 참고합니다. 예를 들어 Drawable 프로토콜 타입의 값을 복사할 떄는 VWT의 copy()를 사용하고, 이 프로토콜의 구체적인 값이 Int라면 VWT 필드는 IntVWT를 가리키고 있고, IntVWT의 copy() 함수는 값 버퍼의 첫 워드를 옮기도록 구현이 되어 있을 것입니다.

반면 클래스 인스턴스의 VWT는 copy() 함수가 레퍼런스 카운트를 하나 늘린 후 첫 워드에 저장된 레퍼런스를 복사하돌고 구현되어 있을 것입니다. PWT는 구체적인 타입이 해당 프로토콜을 어떻게 구현하는지를 담고 있습니다. draw() 처럼 프로토콜이 정의한 메서드를 호출하면 이 PWT는 현재 저장하는 구체적인 타입에 맞는 구현을 찾게됩니다.

이렇게 컨테이너를 활용하면 같은 프로토콜을 만족한다면 다른 사이즈를 가지는 타입을 균일하게 5워드 크기로 다룰수 있게 됩니다. 사이즈가 일정하므로 배열에도 넣을수 있고, 구조체의 프로퍼티로도 사용할 수 있습니다. 하지만 그 대가로 사이즈 3 워드 이상인 자료형은 힙에 할당이 되고, 메서드가 동적으로 디스패치 되는 오버헤드가 발생합니다.

힙에는 할당만으로도 시간이 많이 소요됩니다. 메서드 디스패치 : 어떤 메서드를 호출할지 결정하여 실제로 실행시키는 과정. 동적이라면, 컴파일러가 어떤 메서드를 호출할지 모르는 경우, 호출할 메서드를 런타임 시점에 결정합니다. 스태틱(정적) 메소드 디스패치는 컴파일 타임에 결정하므로, 성능상의 이점이 있습니다만, 다이나믹 메서드 디스패치는 성능상에서 손해를 봅니다.

프로토콜 타입의 한계

이제 다시 Summarizable 프로토콜 타입이 Summarizable 프로톸을 만족하지 못하는 문제를 보겠습니다.

앞에서 살펴봤듯이, 제네릭은 타입 단위 추상화입니다. 컴파일 과정에서 특수화 과정과 상관없이, 타입 파라미터는 프로토콜이 요구하는 메서드를 제시해야 합니다. 그런데 Summarizable 프로토콜 타입은 summarize() 메서드를 구현하고 있지 않습니다. 오직 구체적인 타입으로 정의한(NewsArticle, BlogPost) 것들만 메서드를 구현하고 있기 때문에, 위의 문제가 발생합니다.

또 다른 프로토콜의 문제는 애초에 타입으로 사용할 수 없는 프로토콜도 존재한다는 것입니다. 예로 Equatable은 타입으로 사용이 불가합니다. (제네릭의 제약 조건으로만 사용이 가능하다는 에러가 발생함)

하지만 이는, 곧 많이 완화될 예정이라고 하네요.

제네릭과 프로토콜 타입의 빈자리를 opaque result 타입이 채웁니다.

// ❌ 컴파일되지 않습니다. 에러 메시지: Protocol can only be used as a generic constraint because it has 'Self' or associated type requirements
func evenValues<C: Collection>(in collection: C) -> Collection
where C.Element == Int {
    collection.lazy.filter { $0 % 2 == 0 }
}

어떤 정수 Collection 을 받아서 짝수만 남긴 Collection을 리턴하는 함수를 작성하려고 할 때, 리턴 타입이 프로토콜을 만족한다는 정보만 제시하고 실제로 어떤 타입인지는 숨기고 싶을 수 있습니다.

하지만 Collection은 연관 타입이 있기 때문에 프로토콜 타입으로 사용이 불가합니다.

애러 메시지는 해당 프로토콜이 오직 제네릭의 제약 조건으로만 사용이 된다고 설명합니다. 요구 대로 제네릭의 제약조건으로 추가해봅시다

// ❌ 컴파일되지 않습니다.
func evenValues<C: Collection, Output: Collection>(in collection: C) -> Output
where C.Element == Int, Output.Element == Int {
    collection.lazy.filter { $0 % 2 == 0 }
}

위 함수의 경우는 Output 컬렉션이 어떤 구체적인 타입이 될지를 evenValues(in:)의 호출자가 결정을 하게 됩니다. 우리가 원하는 것은 함수의 작성자가 구체적인 타입을 결정하고, 호출자는 프로토콜에 명시된 메서드만 이용하여 리턴된 짝수의 컬렉션을 조작하는 것입니다.

위 함수는 호출자가 구체적인 리턴 타입을 결정할 수 있다는 약속을 하고 있지만, 본문은 이걸 어기고, LazyFilterSequence라는 구체적인 타입을 리턴하기 때문에 컴파일 에러가 발생합니다.

하지만 구체적인 타입으로 리턴을 명시한다면, 이는 함수의 구현 디테일이 너무 노출하기 떄문에 이후 구현을 변경할 여지를 제한합니다.

너무 구체적이기 때문에 변경하기에 제한이 많이 생길듯 하다. 원하는 부분은 프로토콜만 만족하는 리턴타입을 명시하는것 임을 명심하자.

작성자가 타입을 결정할 때 타입 단위의 추상화의 개념을 채우는 부분이 바로 opaque result 입니다.

func evenValues<C: Collection>(in collection: C) -> some Collection
where C.Element == Int {
    return collection.lazy.filter { $0 % 2 == 0 }
}

이는 기존의 제네릭의 호출자가 구체적인 타입을 결정하는 문제와, 구현 디테일의 노출하는 문제를 해결해 줍니다. 함수의 작성자가 구체적인 타입을 결정하고, 호출자는 리턴 값이 어떤 프로토콜을 만족하는지만 알 수 있기 때문입니다. 추가 장점으로는 컴파일러가 리턴 값의 구체적인 타입을 알고 있기 때문에 정적 디스패치를 통한 최적화가 가능합니다.


참고

https://engineering.linecorp.com/ko/blog/about-swift-type-system/#foot1

profile
hi there 👋

0개의 댓글