Swift

[Swift] Opaque Type

Zedd0202 2022. 12. 23. 17:18
반응형

 

안녕하세요 :) Zedd입니다.

Swift 5.7에 추가된 Opaque Parameter Declarations을 보기 전에..

Opaque Type이 어떤건지!!! 

 

Opaque Type은 Swift 5.1에 추가되었습니다. 

Opaque Type을 직역하면 불분명한 타입 정도가 되겠네요.

불투명 타입이라고 부르는 사람도 있는데, 저는 불분명한 타입! 요게 더 와닿는것 같아서 ㅎ

 

# Generic 

(갑자기) 우리에게 익숙한 Generic을 보겠습니다.

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

위 Stack 구조체는 Generic Type인 Element를 사용하고있습니다.

우리가 이 코드를 작성할 때 이렇게 생각할 수 있겠죠.

'아 Element로 어떤 타입이 올지 모르겠으니 특정 타입에 국한되게 만드는게 아니라 좀 추상적으로/일반적으로 만들어야겠다! Int든 String이든 내가 만든 Custom Type이든 모든 타입에서 동작할 수 있게!'

핵심은, 이 Stack 구조체를 작성할때 우리는 Element가 어떤 타입인지 알 수 없습니다.

 

언제 Element의 타입을 알수있냐? 

var intStack = Stack<Int>()

사용하는 호출부에서 타입을 지정해줘야 비로소 Element의 타입이 결정되는것이죠. 

Int로 지정했으므로, intStack.items의 타입은 [Int]로 나오는 것을 확인할 수 있습니다.

예제는 struct로 들었지만 Generic으로 작성된 함수(메소드)도 다 똑같습니다.

간단히 정리하면,

구현부 - 추상화하여 작성 

호출부 - 구체적인 타입 지정 (타입을 알 수 있음) 

 

# Opaque Type

Opaque Type은 some 키워드 + 프로토콜로 사용할 수 있습니다. 

struct ContentView: View {

    var body: ✅some View✅ {}
}

우리가 SwiftUI에서 주구장창 본 some View. 이것이 Opaque Type입니다.

Opaque Type은

- 함수(메소드)의 리턴타입

- stored property 타입

- computed property 타입 

- subscripts

- (Swift 5.7 부터) 함수 파라미터 타입

으로 사용될 수 있습니다. 

 

함수(메소드)의 리턴타입으로 사용된 Opaque Type의 간단한 예를 봅시다. 

func makeArray() -> some Collection {
    return [1, 2, 3]
}

이런 Opaque Type은 Generic과 반대로 작동하므로 위에서 Generic을 먼저 설명했던것인데요.

어떤 부분이 Generic과 반대인지 살펴봅시다.

위 코드에서는 [1, 2, 3]을 바로 리턴했는데요.

Generic때 처럼 내부가 추상적으로 작성되지 않고 KTX타고 가면서 봐도 구체타입인 [Int]을 리턴하는 것 같습니다. 

makeArray함수 내부에서는 [Int]라는 구체타입을 알고있는 것이죠. 

 

makeArray를 호출하는 호출부의 입장을 생각해보겠습니다. 

Generic과 다르게 호출부에서 알 수 있는것은 아 Collection 프로토콜을 준수하는 어떤 구체 타입이구나..만 알 수 있습니다.

Generic과 다르게 호출부가 일반적/추상적으로 작성되어야 합니다. 

var intStack = Stack<Int>()

이런식으로 호출부가 완전히 구체적인 타입을 알고 있던 Generic과는 완전히 반대되는 경우인데요.

 

[Generic]

구현부 - 추상화하여 작성

호출부 - 구체적인 타입 지정 (타입을 알 수 있음) 

 

[Opaque Type]

구현부 - 구체적인 타입 지정 (타입을 알 수 있음)

호출부 - 추상화하여 작성

 

이것이 Opaque Type이 역 제네릭 타입(reverse generic type)으로 불리는 이유입니다. 

그리고 이러한 특성때문에 "Opaque Type을 사용하여 리턴 타입의 정보를 숨길 수 있다"고 말하는 것입니다. 

말 그대로 불분명한 타입인거죠. 

 

# Protocol Type

Opaque Type은 some 키워드 + 프로토콜이었는데요. 

이 some 키워드만 빠지면 우리가 자주 사용하는 Protocol Type입니다. 

protocol TestProtocol {}
func somethings() -> some TestProtocol {} // Opaque Type
func somethings() -> TestProtocol {} // Protocol Type

호출부에서 somethings 함수를 호출했을 때,

호출부는 TestProtocol 타입을 준수하는 어떤 타입이구나!로 아는건 똑같아보이는데..

Protocol Type과 Opaque Type은 어떤점이 다를까요? 

 

Differences Between Protocol Types and Opaque Types

✔️ [Protocol Type을 리턴할 때] ✔️

Protocol Type을 리턴하는 함수 내에서는 조건에 따라 해당 Protocol을 준수하는 어떤 타입이든 리턴할 수 있습니다

struct 오버워치: TestProtocol {}
struct 롤: TestProtocol {}

func somethings() -> TestProtocol {
    let 재밌다 = true
    if 재밌다 { return 롤() }
    
    return 오버워치()
}

핵심 : 특정 조건에 따라 리턴하는 구체타입을 달리할 수 있다는 것

 

✔️ [Opaque Type을 리턴할 때] ✔️

위 코드에서 some 키워드만 추가해주겠습니다.

struct 오버워치: TestProtocol {}
struct 롤: TestProtocol {}

func somethings() -> ✅some✅ TestProtocol {
    let 재밌다 = true
    if 재밌다 { return 롤() }
    
    return 오버워치()
}

그러면 아래와 같은 컴파일 에러가 발생하는데요. 

Function declares an opaque return type, but the return statements in its body do not have matching underlying types

💡 Opaque Type으로 사용하려면 해당 프로토콜을 준수하는 단 하나의 구체타입을 리턴해야만 합니다 💡

func somethings() -> some TestProtocol {
    return 롤()
}

이런식으로요.

 

아래와 같이 정리할 수 있습니다. 

Protocol Type - 프로토콜을 준수하는 모든 타입을 참조할 수 있음 (a protocol type can refer to any type that conforms to the protocol)

Opauqe Type - 하나의 특정 구체 타입을 참조하지만 함수 호출자는 어떤 타입인지 볼 수 없음 (An opaque type refers to one specific type, although the caller of the function isn’t able to see which type) 

 

이 둘을 딱 봤을 때 코드를 더 유연하게 작성할 수 있는 타입은 Protocol Type인데요.

struct 오버워치: TestProtocol {}
struct 롤: TestProtocol {}

func somethings() -> TestProtocol {
    let 재밌다 = true
    if 재밌다 { return 롤() }
    
    return 오버워치()
}

Protocol을 준수하는 어떤 타입이든 특정 조건에 따라 리턴할 수 있게 때문에 유연성이 높은 코드를 작성할 수 있게 됩니다.

다만 이러한 유연성의 대가도 있는데요. 

반환된 값에 대해 일부 작업을 수행할 수 없다는 것입니다. 예를 들어봅시다.

이렇게 두고, 

struct 오버워치: TestProtocol {}
struct 롤: TestProtocol {}

func somethings() -> TestProtocol {
    let 재밌다 = true
    if 재밌다 { return 롤() }
    
    return 오버워치()
}

-------

let a = somethings()
let b = somethings()

print(a == b)

a와 b가 같은지 비교해보려고 합니다. 

 

⛔️ 문제 1. == 연산자가 TestProtocol에 포함되어있지 않기 때문에 ==를 추가하려고 시도 

protocol TestProtocol: Hashable {}

extension TestProtocol {
    
    static func == (...) -> Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

(Equatable을 사용하지 않은건 hashValue를 사용하려고,..Hashable이 Equatable을 준수하고 있는건 아시죠!?) 

 

⛔️ 문제 2. == 연산자는 좌항과 우항의 타입을 알아야함

보통 이 TestProtocol을 채택하는 구체적인 타입과 일치하는 Self를 파라미터로 사용하게 됩니다. 

protocol TestProtocol: Hashable {}

extension TestProtocol {
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

 

⛔️ 문제 3. Protocol 'TestProtocol' can only be used as a generic constraint because it has Self or associated type requirements

func somethings() -> TestProtocol {
    let 재밌다 = true
    if 재밌다 { return 롤() }
    
    return 오버워치()
}
// Protocol 'TestProtocol' can only be used as a generic constraint because it has Self or associated type requirements

여기서 안본 사람이 없다는 '그 에러'..가 발생하게 됩니다. (물론 Xcode 14에서는 조금 다른 에러가 나오긴 하지만..)

지금은 Self제약 때문에 난 에러긴 하지만, 프로토콜이 Associated type을 가지고 있을때도 같은 에러가 발생합니다. 

위에서 언급했지만 ==에서 사용된 Self는 TestProtocol을 채택하는 구체적인 타입을 의미합니다.

근데 컴파일시에는 이 Self가 어떤타입인지 모르기 때문에 에러가 나게 됩니다.

 

에러메시지에 나와있는 것 처럼,

프로토콜 내에서 Self나 Associated type을 사용할 때 이 프로토콜을 단독으로 리턴타입이나 파라미터타입으로 쓸 수 없고 generic constraint으로만 사용될 수 있습니다. 

func somethings<T: TestProtocol>(source: T) -> Int {
    return source.hashValue
}

이런식으로요. 리턴타입으로 사용할 수 없으니 구체타입인 Int를 리턴하게 해주었습니다. 

let a = somethings(source: 롤())
let b = somethings(source: 오버워치())
print(a == b) // true or false

드디어 컴파일이 되지만.. 결과적으로 TestProtocol (Protocol Type)을 리턴하면서 ==를 사용할 수 있는 방법은 없습니다

 

문제는 하나 더 있습니다. 있었습니다..? Swift 5.7에서 아래 문제(문제 4)는 발생하지 않습니다. 

⛔️ 문제 4. Protocol 'TestProtocol' as a type cannot conform to the protocol itself

== 연산자 같은 구현은 전부 지우고, somethings 함수를 살짝 수정했습니다. 

protocol TestProtocol {}

struct 오버워치: TestProtocol {}
struct 롤: TestProtocol {}

func somethings<T: TestProtocol>(source: T) -> TestProtocol {
    return source
}

somethings함수는 TestProtocol을 준수하는 어떤 타입이든 파라미터로 받아서 그대로 리턴합니다.

let a = somethings(source: 롤())
let b = somethings(source: a) // Protocol 'TestProtocol' as a type cannot conform to the protocol itself

그럼 somethings(source: 롤())로 리턴한 a도 역시 TestProtocol을 준수하기 때문에 이 a를 그대로 파라미터로 넘길 수 있는데요. 

이러면 컴파일에러가 발생하게 됩니다. 

let a = somethings(source: 롤())

let b = somethings(source: 오버워치()) // ✅ OK
let b = somethings(source: a) // ⛔️ Error!

왜 오버워치로 넣은건 됐는데, a를 그대로 넣으면 에러가 나냐? 

func somethings<T: TestProtocol>(source: T) -> TestProtocol {
    return source
}

Generic은 컴파일시 프로토콜을 준수하는 구체적인 타입이 필요한데, a는 구체타입이 아니라 그냥 TestProtocol타입이기 때문에 컴파일이 되지 않습니다. (컴파일타임에 구체타입을 알 수 없어서)

그래서 generic이 아닌

func somethings(source: TestProtocol) -> TestProtocol {
    return source
}
let a = somethings(source: 롤())
let b = somethings(source: a)

이런식으로 사용해야하죠.

 

⚠️ 문제 4는 Swift 5.7을 사용하는 Xcode 14에서는 에러가 안납니다~

관련 Proposal은 Implicitly Opened Existentials을 참고해주세요. 

----

아무튼 이러한 문제가 발생하는 이유는..

Swift의 Protocol은 Type identity를 보존(preserve)하지 않기 때문인데요. 

저는 도대체 이 Type Identity가 뭘까...헷갈려서 많이 찾아봤는데, 제가 이해한바를 간단하게 예제로 설명하자면 

func somethings() -> TestProtocol {
    return 롤()
}

이런식으로 사용했을 때,

somethings를 호출하는 호출부에서는 이라는 구체타입에 대한 정보를 전혀 모르고 TestProtocol로만 받게되는데...

즉, 호출되었을 때 롤이라는 구체타입에 대한 정보를 잃어버리기 때문에? Type Identity를 preserve(보존)하지 않는다..라고 말하는것 같아요? 

그럼 Opaque Type은 Type Identity를 보존하냐? ➡️ 보존합니다. 

그래서 

protocol TestProtocol: Hashable {}

extension TestProtocol {
    
    static func == (lhs: Self, rhs: Self) -> Bool {
            return lhs.hashValue == rhs.hashValue
        }
}

struct 오버워치: TestProtocol {}
struct 롤: TestProtocol {}

func somethings() -> some TestProtocol {
    return 롤()
}

let a = somethings()
let b = somethings()
print(a == b) // true

이것이 가능합니다.

왜냐면 Opaque Type으로 사용하여 타입은 숨겼지만, 컴파일러는 구체타입을 알기 때문에 Type Identity를 preserve(보존)할 수 있습니다. 

 

이건 우리가 계속 봤던 예제라 넣은거고.. 

let a: some SignedInteger = 1
let b: some SignedInteger = 2

print(a == b) // false

간단하게 이런것이 그냥 가능하다!?

(예제를 함수로만 본 것 같아서.. 위에서 말했듯이 stored property도 Opaque Type으로 선언할 수 있습니다) 

 


 

이 Opaque Type은 Swift 5.1나왔던 그 순간부터 쓰려고 했던 주제인데..이제야 ^^;

틀린 부분이 있다면 댓글로 알려주세요!

반응형