Opaque Type과 Protocol의 잘못들

kio·2023년 6월 23일
0

Swift

목록 보기
9/11

처음엔 some의 역할과 컴파일러가 어떻게 이를 받아드리고 언제 type을 정할까? 에서 시작된 의문이 어느덧 any, generic, existential container를 공부하게 만들었고 Swift 5.7에서는 뭐가 어떻게 다른건지까지 공부하게 되었다.


some (Opaque Type)

protocol TestProtocol {
    associatedtype nameType
    var name: nameType { get }
}

struct TestStruct: TestProtocol {
    var name: String = "Test"
}

struct Test {
    func makeTestStruct() -> TestProtocol {
        TestStruct()
    }
}

우선 이 코드는 될까요?
안된다...
대답하기 쉬웠나요?

그럼 이 코드는 될까요?

protocol TestProtocol {
    associatedtype nameType
    var name: nameType { get }
}

struct TestStruct: TestProtocol {
    var name: String = "Test"
}

struct Test {
    func makeTestStruct<T: TestProtocol>(_ input: T) -> T {
        input
    }
}

정답은 됩니다..
아래는 비교적 대답하기 쉽지 않나요?
이 둘의 차이점은 뭘까요?

Protocol은 타입이 아닌가?

뭐 당연한 말이죠 protocol은 타입이 아닙니다.

아닙니다.

물론 타입이라고 표현하기 그렇지만 protocol은 특정자료구조로 구현되어있습니다.
프로토콜이 타입이라고 하긴 뭐하지만 프로토콜에도 타입이 있습니다
바로 existential container입니다.

자 실제로 프로토콜의 위와 같이 구현되어있습니다.
위 그림의 블럭은 1Word의 크기를 가지고 있고 5워드로 구성되어있습니다.

VWT (Value Witness Table)

즉 값 참조 테이블정도로 해석되고, container에 들어오게 되면 이를 구현하고 있는 것의 정확한 타입을 알 수 없기 때문에 복사를 할때 VWT에 있는 copy()를 통해 복사하게 됩니다.

PWT (Protocol Witness Table)

프로토콜의 참조 테이블 정도로 해석되는 PWT는 구체적이 타입이 어떻게 구현하고 있는지를 담습니다.
리턴 타입이 프로토콜이여도 그 안에 함수를 사용할 수 있는건 저곳에 구체적인 타입의 구현이 있기 때문입니다.

5워드로 고정되기 때문에 Protocol의 배열로 만들수 있는 겁니다.
만약 5워드를 넘으면 힙에 저장되게되고, 함수는 동적으로 디스패치됩니다.

Generic과 Protocol 비슷하지만 다른 두 녀석

이 둘의 무엇인가를 추상화한다는 점에서 유사합니다.
하지만 크게 다른 것이 있습니다.

Generic은 Type을 추상화합니다!
Protocol은 값을 추상화합니다!

좋은 예시

func max<T: Comparable>(_ a: T, _ b: T) -> T { ... }

struct Queue { var items: [Nodable] }

이해가 되시나요?

max함수는 파라미터를 추상화했습니다. 어떤 타입이던 Comparable만 만족한다면 타입은 상관없기 때문에 타입을 감춘것입니다.

반면 Queue의 item은 어떤 값이 들어오던 그것을 Nodable로 받아 드리게 됩니다. 그 값이 어떤 구현체가 아니라 추상화된 Nodable인 것입니다.

나쁜예시

func max(_ a: Comparable, _ b: Comparable) -> Comparable { ... }

struct Queue<Node: Nodable> { var items: [Node] }

이는 반대가 됩니다. 위에 코드랑 봤을 때 딱 봐도 덜 유연합니다.
max함수는 기본적으로 에러지만 이유는 후술하기로하고 된다고 쳐도 문제가 생깁니다.

max(1, "Test") 

이 문제는 어떻게 하죠? ....

Queue는 반대로 Nodable한 모든것을 담는 것이 아니라 특정 타입만 받는 items를 가지게 됩니다. 이는 별로 유연하지 않죠.. (설계에 따라 하나의 타입으로 고정해야될 경우는 고려하지 않았습니다.)

그렇기 때문에 protocol은 값을 generic은 타입을 추상화합니다.

그렇다면 some은 무엇일까요?

이는 프로토콜의 한계로 인해 등장하였습니다.

한계 1. associated type이 있는 프로토콜은 타입으로써 사용할 수 없다.

이는 과거 PWT에서 protocol에 있는 associated type을 불러올 수 없었기 때문입니다.
하지만 이는 고쳐졌으며 이제는 PWT에서 associated type의 메타데이터를 받을 수 있지만 레거시 때문에 안되는 한계가 있습니다.

한계 2. protocol에서 Self는 타입 안전성을 안될 수 있습니다.

자 코드를 한번 봐보겠습니다.

protocol Test {
	func a() -> Self
	func b(_ x: Self)
}

a는 protocol 안에 이를 구현한 곳이 존재합니다. 하지만 어떤 타입을 리턴해야 되는지는 알 수가 없습니다.
하지만 그 결과값이 프로토콜로 치환하기만 하면 내부의 구현과는 상관이 없습니다.
이렇게 특정 타입을 프로토콜로 안전하게 치환가능한 것을 covariant하다고 합니다.
b는 다릅니다.
특정 함수의 파라미터가 어떤 타입인지 모릅니다. 그렇기 때문에 함수를 실행조차 할 수가 없습니다.
또 b는 호출하는 인스턴스와 파라미터의 타입이 같아야합니다.
하지만 확신할순 없죠 이런것을 타입 안정하지않다라고 합니다.

protocol Test {
    func a() -> Self
}

struct TestStruct: Test {
    
    func a() -> TestStruct {
        self
    }
}

func make() -> Test {
    TestStruct()
}

let temp = make()
let temp2 = temp.a()

여기서 temp2의 타입은 무엇일까요?

TestStruct가 아닌 Test입니다.
이는 타입내부에서 리턴된 TestStruct를 안전하게 Protocol로 치환했기에 가능합니다.

우리는 b같은 함수를 사용할 수 없습니다.


위와 같은 한계를 극복하기 위해 Swift는 Opaque type을 지원합니다.
블로그
를 보시면 opaque type인 some은 구체적인 타입을 가지고 있게 됩니다.

protocol SomeThing { }
struct SomeStruct: SomeThing { }

func makeSomething() -> SomeThing {
	SomeStruct()							   
}

makeSomeThing은 프로토콜 타입이 리턴되고 이는 위의 existential container를 내보내게 되고 이 안에는 구체적인 타입의 정보는 없습니다~(위에서 말씀드렸습니다.)

하지만 some을 붙이게 되면 그 구체적인 타입을 저장하게 됩니다.

그렇기 때문에 위에서 생기는 문제들을 해결할 수 있습니다.

protocol Test {
    func a() -> Self
    func b(_ x: Self)
}

struct TestStruct: Test {
    
    func b(_ x: TestStruct) {
        print(x)
    }
    
    
    func a() -> TestStruct {
        self
    }
}

func make() -> some Test {
    TestStruct()
}

아주 정상적으로 컴파일됩니다.

즉 some은 protocol안에 함수에서 프로토콜을 채택한 구현체의 타입을 알아야될 때 (함수 b같은거, 즉 covariant하지 않은게 있을 때)
반드시 필요로 하게 됩니다.
하지만 단점이 있습니다.

func makeComaparableThing() -> some Comparable {
    [1, "1"].randomElement()!
}

이는 컴파일되지 않습니다.

왜냐!!!!!!
some은 구체적인 타입을 담아야합니다. 그럼 여기서 some Comparable은 어떤 구체적인 타입을 담고 있을까요?
Int?, String? 두개는 담을 수 없으니 이는 컴파일을 하지 못하게 됩니다.

하지만 이는 좋은 역할을 합니다. 타입을 하나로 고정시키고 이를 호출하는 곳에선 감추는 효과가 있습니다.
즉 some은 리턴타입을 제한하고 외부에 감추는 역할을 할 때도 사용된곤 합니다.

가장 대표적으로 사용되는 곳은 SwiftUI입니다.

struct ContentView: View {
	var body: some View { ... }
}

우리는 body의 구체적인 타입이 없다면 여기다가 추가적으로 modifier를 붙일 때 어디선간 Self의 타입을 chec해야한다면 우리는 some View를 return하지 않는다면 할 수 없습니다.

extension View {
    
    func crazy() -> View {
        return Text()
    }
    
    func make() -> some View {
        if Text.self is Self {
            print("isText")
        }
        else {
            print("isn't Text")
        }
        
        return Text("")
    }
}

let temp1 = Text("").foregroundColor(.red).make()
let temp = Rectangle().make()

crazy 함수는 애초에 View가 associated type이 있어서 문제가 되지만 만약 그것이 없더라도 make는 실행할 수 없는 미친 코드가 되고 애초에 되지도 않는다. View에는 associated type이 있기때문이죠


any란

여전한 단점은 some protocol이 리턴이면 제한하는 장점은 있지만 여전히 유연하지는 못한 단점이 있다.

func makeSomthing() -> some View {

    [Text(""), Rectangle()].randomElement()!
}

이런것은 컴파일이 안됩니다.
특정 프로토콜을 뱉을 수 없는게 너무 큰 단점이 됩니다.

그래서 구체적인 타입을 가지고 있는 프로토콜 타입은 하나로 규정되어 있기 때문에 위 코드는 불가합니다.
그래서 나온것인 any입니다.
구체적인 타입을 가진 protocol을 box형태로 감싸고 있는 것이 바로 any입니다.


이렇게 감싸고 있다.
그렇기 때문에 어떤 값이 나와도 똑같이 적용할 수 있다.

func makeSomthing() -> any View {
    [Text(""), Rectangle()].randomElement()!
}

let temp = makeSomthing().foregroundColor(.black)

이런것도 가능하다!!!


결론

Swift는 POP(Protocol Oriented Programming)이고 그렇기 때문에 모든 것을 protocol로 구성하기 위해선 많은 것들 잘 적용되어야한다.
그렇기 때문에 위와 같은 다양한 키워드들이 프로토콜을 보좌하고 있는 것이다.

이를 잘 써서 나도 POP를 완벽하게 구사하려는 꾸준한 노력이 있어야할 것 같다.

0개의 댓글