본문 바로가기
Swift

Type Erasure in Swift

by SeoB-P 2022. 5. 23.

계기

토이 프로젝트를 진행 중 Protocol의 Associated Type을 이용해서 타입을 추상화 시키려 했으나 밑과 같은 에러를 마주쳤다. 도대체 이런 Error가 왜 생기고 이를 해결하기 위한 방법을 알아보자.

Error: Can only be Used as a generic constraint ...

계기가 되었던 이 에러는 도대체 왜 나오는 것일까?

이유부터 말하자면 컴파일러 타임에 타입을 추론하지 못해서  생기는 문제이다.

먼저 이 에러가 나오는 상황을 예시를 들어서 알아본다.  

protocol SomeProtocol {
    var some: String { get }
}

struct A: SomeProtocol {
    var some: String = "A"
}

struct B: SomeProtocol {
    var some: String = "B"
}

var something: SomeProtocol = A()
something = B()

 

Something이라는 Protocol이 있다.

그리고, 그 프로토콜을 따르는 A,B 구조체가 있다.

이 프로토콜을 타입으로 해서 우리는 SomeProtocol을 따르는 A와 B를 변수에 저장할 수 있다.

 

하지만 Associtated Type을 사용한다면..?

protocol SomeProtocol {
    associatedtype SomeType
    var some: String { get }
}

struct A: SomeProtocol {
    typealias SomeType = String
    
    var some: String
}

struct B: SomeProtocol {
    typealias SomeType = Int
    
    var some: String
}

 

'그' 에러가 나왔다.

 

에러의 원인. Associated Type 

Associated Type이란 프로토콜에서 쓰는 제네릭 타입과 비슷한데, 가장 큰 차이점은 사용하기전 PlaceHolder로 사용되는 타입이 구체 타입으로 변환되는 시점이다.

 

Generic Type: 컴파일 타임에 PlaceHolder가 구체 타입으로 바뀜 

Associated Type:  PlaceHolder가 프로토콜을 채택한 시점에 구체타입으로 바뀜

 

Swift는 Type에 아주 민감한 언어이다.  그리고, 컴파일러는 항상 컴파일타임에 명확한 타입을 알아야한다.

즉, SomeProtocol이라는 Protocol을 채택을 하는 순간에 컴파일러는 타입을 알아야하는데 변수, 인자, 리턴타입으로 프로토콜을 사용한다면 컴파일 타임에 정확한 Associated 타입을 알 수 없는 것이다.

 

Protocol을 채택한 Struct, Class나 SubClass처럼 런타임에 구체 타입이 결정되는 경우도 있는데 컴파일러가 Associated타입까지는 알지 못하는것 같다? 🤔

 

쨋든, 위 에러를 피하기 위해 제네릭을 이용하여 아래와 같이 작성한다면 에러를 피할 수 있다.

 

 

하지만 여전히 [SomeProtocol]과 같이 값을 store할 수 없다. 

 

그렇다면 우리는 어떻게 이 문제를 해결 할 수 있을까??

Type Erasure방식을 사용한다면 해결이 가능하다.

Type Erasure?

Type Erasure? 타입을 지운다???

Type-erasure simply means "erasing" a specific type to a more abstract type in order to do something with the abstract type (like having an array of that abstract type). And this happens in Swift all the time, pretty much whenever you see the word "Any."

출처: https://triplebyte.com/blog/the-perils-of-type-erasure-in-swift-and-how-to-avoid-it-completely 

 

추상타입을 이용해서 뭔가 작업을 하고 싶을때, 구체 타입을 erasing하고 좀 더 추상적인 타입으로 만드는 것이다.

그리고 사실.. 우리는 이 작업을 많이 보았다.

가장 쉬운 예제는 Any이다.

 

var someString: String = "some"
var someInt: Int = 1
var someBool: Bool = true

var anyType: [Any] = [someString,someInt,someBool]

 

Any라는 좀더 추상적인 타입을 통해 우리는 여러가지 Type을 한곳에 모을 수 있다.

하지만, 모든 Type을 Any로 묶으면 그안의 property나 method에 접근 할 수 없다.

따라서 , 좀더 좋은 추상화 방법(Erasure)을 찾아야한다

Wrapper

Type을 Erasure하는 방법은 여러가지가 있다.

그 중, 가장 많이 쓰이는 것으로 보이는 Wrapper를 이용해서 문제를 해결 해보자.

Wrapper는 이름 그대로 타입을 한번더 감싸주는 것이다.

 

프로토콜에 함수 printSomething을 추가함.

protocol SomeProtocol {
    associatedtype SomeType
    var some: String { get }
    func printSomething()
}

struct A: SomeProtocol {
    typealias SomeType = String
    var some: String = "A"
    func printSomething() {
        print("SomeThing!")
    }
}

struct B: SomeProtocol {
    typealias SomeType = Int
    var some: String = "B"
    func printSomething() {
        print("Something2!")
    }
}

 

여기서 핵심은, Protocol의 Associated Type때문에 구체타입이 필요하니까 그것을 추론 할 수 있게 Generic으로 만들어준다.

 

struct AnySomeStruct<SomeType: SomeProtocol> {
    var someStruct: SomeType
    
    init(someStruct: SomeType) {
        self.someStruct = someStruct
    }
    func printSomething() {
        someStruct.printSomething()
    }
}

 

이제 AnySomeStruct를 생성하려하면 타입이 보이지 않게되는데(아마 이 것때문에 erasure타입이라고 하는것 같다.)

 

SomeProtocol을 따르는 어떠한 Type을 이니셜라이저의 인자로 받을 수 있게 되었다!

풀리지 않은 문제

SomeProtocol을 따르는 타입들을 한 배열같은 곳에 모으고 싶었다.

하지만, 여전히 someStruct와 someStruct2의 타입은 다르기 때문에 한 배열안에 넣으려 하면 에러가 나온다.

 

Closure 활용하기

Closure와 where type 제한절을 이용해서 같은 associated Type끼리 묶어보자.

 

기존의 AnySomeStruct를 변경한다.

 

바뀐 AnySomeStruct는 SomeProtocol을 직접적으로 채택하고 있으며 SomeType이라는 제네릭을 가지고 있다.

생성시의 제네릭 T는 SomeProtocol을 따르도록 하고, where 절을 이용해서 제네릭 타입의 제한을 둔다.

그리고, 인자로 들어오는 someStruct의 함수를 클로저에 넣는다.

struct AnySomeStruct<SomeType> {

    var some: String = "ANY"
    private var _printSomething: () -> Void

    init<T: SomeProtocol>(someStruct: T) where T.SomeType == SomeType  {
        self._printSomething = someStruct.printSomething
    }

    func printSomething() {
        _printSomething()
    }
}

 

struct A: SomeProtocol {
    typealias SomeType = String
    var some: String = "A"
    func printSomething() {
        print("SomeThing!")
    }
}

struct C: SomeProtocol {
    typealias SomeType = String
    var some: String = "C"
    func printSomething() {
        print("Something3!")
    }
}

 

만약 associated Type이 같은 SomeStruct라면 한 배열로 묶을 수 있게 되었다.

모든 SomeProtocl의 배열은?

이제 같은 Associated Type을 가진 SomeProtocol의 구조체들은 한곳에 모을 수 있게 되었다.

그렇다면 다른 모든 Associated Type을 가진 구조체들을 한 곳에 모을 수는 없는 걸까?

 

해결책(최종의 최종..)

Swift는 Value Type과 Protocol을 잘 활용해야 한다고 생각을 한다.

하지만, 위의 문제를 해결하기 위해 예시를 찾아본 결과 대부분이 abstract Class를 이용해서 해결하는 모습을 보여주었다.

 

abstract Class대신에 Protocol을 사용해서 문제를 해결할 수 없을까? 생각하다가 결국 Wrapper들이 원소로 들어가는 배열이라면

Wrapper들을 프로토콜로 추상화 시키면 되지 않을까? 라는 생각에 진행을 해보았다.

 

Wrapper에게 AnyStructAble이라는 SomeProtocol과 비슷하지만 associated Type은 없는프로토콜을 만들어서 넣어주었다 .

 

protocol AnyStructAble {
    var some: String { get }
    var _printSomething: () -> Void { get }
}

그 결과..

 

let a = AnySomeStruct(someStruct: A()) // SomeType == String
let b = AnySomeStruct(someStruct: B()) // SomeType == Int
let c = AnySomeStruct(someStruct: C()) // SomeType == String

let structs:[AnyStructAble] = [a,b,c]

structs.map {
    print($0.some)
}

 

let a = AnySomeStruct(someStruct: A()) // SomeType == String
let b = AnySomeStruct(someStruct: B()) // SomeType == Int
let c = AnySomeStruct(someStruct: C()) // SomeType == String

let structs:[AnyStructAble] = [a,b,c]

structs.map {
    print($0.some)
    $0._printSomething()
}

// result
A
SomeThing!
B
Something2!
C
Something3!

 

에러도 나지않고 Type별로 선언한 Print문이 잘 찍힘을 알 수 있다!

 

의문점. Wrapper의 이유?

프로토콜을 두개로 할거면 애초에 Wrapper도 필요없는거 아닌가?

 

SomeProtocol을 AnyStrcutAble을 채택하게 하고  Wrapper대신 값을 그대로 넣어도 되지 않나??

associatedtype을 가지는 SomeProtcocol의 뒤에서 추상화시켜준다.

(이를 Shadow type erasure 라고 부르기도 하더라.)

protocol SomeProtocol: AnyStructAble {
    associatedtype SomeType
    var some: String { get }
    func printSomething()
}

 

Wrapper없이 잘 출력되는 모습.

var someStructs:[AnyStructAble] = [A(),B(),C()]
someStructs.map {
    print($0.some)
    $0.printSomething()
}

// result
A
SomeThing!
B
Something2!
C
Something3!

 

 

 

결론 및  한계점 

  • 프로젝트 중에 생긴 궁금증을 해결하기 위해 진행을 했지만, 아직까지 어디에 써야할 지 감이 잘 오지는 않는다. (예제 코드를 살펴보다보니 SwiftUI가 조금 보이긴 했다)
  • Shadow Erasure방식을 사용했을때 Wrapper의 기능이 많이 희석되는 것 같은데 둘 다 써야할 이유를 아직은 이해가 잘 안된다.
  • 이 기능을 사용하기 위해 boiler plate code가 너~무 많다.(심지어 예시는 쉬운 편임에도 불구하고..)

 

여담

애플은 어떻게 구현했을까??

애플은 어떻게 구현했을까 급 궁금해져서 찾아봤다.

Apple의 Type-Erasure-Wrapper중 하나인 Anyhashable의 모습도 조금 다르긴 하지만, 우리가 짜놓은 코드와 비슷함을 볼 수 있다.

 

Type이 바뀌네 ??

배열의 앞에 있는 < > 안에 배열제일 첫번째 요소의 SomeType이 찍힌다는것.

만약, a와 b의 자리를 바꾼다면?

 

C.SomeType으로 바뀌었다.

사실 둘다 String이기 때문에 같은 말이기는 하지만..

 

Opaque Type

최근에 나온 Opaque Type은 이러한 문제점을 조금이나마 해결해 주려고 나온 것 같다.

Protocol Type과 달리 Type Identity를 보존하기 때문에 변수로 선언이 가능하다.

단, opaque Type은 하나의 특정한 Type만 참조하기 때문에 유연성이 부족하다.

자세한 내용은 여기 를 참고해 보자.

 

 

참고:

https://sanichdaniel.tistory.com/16 

https://www.swiftbysundell.com/articles/different-flavors-of-type-erasure-in-swift/

https://soooprmx.com/%ED%83%80%EC%9E%85-%EC%A7%80%EC%9A%B0%EA%B8%B0-type-erasure-swift/

https://www.donnywals.com/understanding-type-erasure-in-swift/

https://triplebyte.com/blog/the-perils-of-type-erasure-in-swift-and-how-to-avoid-it-completely

https://fabernovel.github.io/2020-06-03/approaches-to-type-erasure-in-swift

 

'Swift' 카테고리의 다른 글

DiffableDataSource 알아보기.  (7) 2022.07.25
정규 표현식 in Swift  (2) 2022.06.14
Data Testing in Swift  (0) 2022.05.12
Generic을 이용한 CompileTime에 Type 검사하기.  (0) 2022.05.12
Memory Leak Case When ReferenceType in Dictionary  (0) 2022.05.11

댓글