콘텐츠로 건너뛰기
Home » 타입 지우기 – Type Erasure (Swift)

타입 지우기 – Type Erasure (Swift)

프로토콜은 특정한 타입에 기대할 수 있는 인터페이스를 정의한 것입니다. 어떤 코드에서 사용되는 객체의 타입을 강제한다는 것은, 해당 타입의 인터페이스를 사용하기 위함임을 의미하는 것과 같습니다. x.foo 라는 프로퍼티를 코드에서 참조한다면, 변수x는 foo 라는 프로퍼티를 가지고 있는 타입이어야 하기 때문입니다. 만약 이 foo가 어떤 P라는 프로토콜에서 선언되었다면, x의 타입은 P라는 프로토콜을 채택한 임의의 타입이어도 무방합니다. 이 말은 단순히 인터페이스의 측면에서 봤을 때, 프로토콜은 그 자체로 타입처럼 간주되는 것이 가능하다는 말입니다.

실제로도 그렇습니다. 다음의 간단한 예를 보겠습니다.

protocol Person {
  var name: String { get }
}

struct Student: Person {
  let name: String
  let age: Int
  init(name: String, age: Int) {
    self.name = name
    self.age = age
  }
}

struct Friend: Person {
 let name: String
 let hobby: String
 init(name: String, hobby: String) {
    self.name = name
    self.hobby = hobby
  }
}

let a = Student(name: "Tom", age: 18)
let b = Friend(name: "Bob", hobby: "music")

let arr: [Person] = [a, b]

위 예제에서는 Person 이라는 프로토콜을 선언하고, 이를 채택하는 StudentFriend 라는 구조체 타입을 정의했습니다. a와 b의 타입은 각각 Student와 Friend로 다른 타입이지만, arr이라는 배열은 Array<Person> 이라는 타입을 지정해서 a와 b를 하나의 배열에 포함시킬 수 있게 되었습니다. 두 객체는 분명 다른 타입이지만, 프로토콜 자체를 타입처럼 간주해서 사용하고 있습니다.

a와 b의 타입이 서로 다르기 때문에 arr 의 타입은 사실 Array<Any>를 사용해야 했을지도 모릅니다. 하지만 [Any] 타입이 배열은, 그 내부의 각 원소의 타입이 Any 이기 때문에 원소의 타입에 대한 가정을 아무것도 할 수 없습니다. 하지만 [Person] 타입의 배열은, 그 내부의 원소의 타입이 모두 동일하다는 보장은 할 수 없지만, 모든 원소가 Person 이 선언하는 인터페이스를 갖추고 있음은 보장할 수 있습니다. 프로토콜을 일종의 부분적인 타입으로 보는 셈입니다.

이제 약간 다른 경우를 보겠습니다. 연관타입을 갖는 프로토콜입니다.

protocol CarType {
  var powerSource: String { get }
}

protocol CarMaker {
  associatedtype Car where Car: CarType
  func produce() -> Car
}

// 각 프로토콜을 따르는 타입을 정의
struct ElecCar: CarType {
  let powerSource: String = "Electricity"
}

struct GasCar: CarType {
  let powerSource: String = "Gasoline"
}

struct Tesla: CarMaker {
  typealias Car = ElecCar  
  func produce() -> Car {
    retirm ElecCar()
  }
}

struct BMW: CarMaker {
  func produce() -> GasCar {  // produce의 리턴타입으로부터 Car의 타입을 추론함
    return GasCar()
  }
}

위와 같은 상황에서 두 개의 CarMaker 프로토콜을 채택한 다른 타입의 객체를 만들고 Array<CarMaker> 타입의 배열에 넣을 수 있는지 확인해보겠습니다.

let f1 = BMW()
let f2 = Tesla()
let factories: [CarMaker] = [f1, f2]

// -> protocol 'CarMaker' can only be used as a generic constraint because it has Self or associated type requirements

에러가 나면서 컴파일에 실패합니다. f1, f2는 모두 produce() 라는 메소드를 가지고 있기 때문에, factories의 원소들에 대해서 이 메소드를 사용할 수 있겠지만, 이 경우에는 허용되지 않습니다. 그 이유로는 Self 나 다른 연관 타입을 가지고 있는 프로토콜은 제네릭 타입의 제약 조건으로만 사용할 수 있다고 합니다.

왜 안되는가?

연관타입이 있는 프로토콜은 왜 타입처럼 사용될 수 없을까? 위의 예제를 조금 더 확장해보도록 하겠습니다.

// 프로토콜 정의

protocol CarType {
  var powerSource: String { get }
  var price: Int { get }
}

protocol CarMaker {
  associatedtype Car where Car: CarType
  func produce() -> Car 
  func price(of car: Car) -> Int
}

// 프로토콜을 따르는 타입 구현

struct EV: CarType {
  let powerSource: String = "electricity"
  let model: String
  let price: Int
  
  init(model: String, price: Int) {
    self.model = model
    self.price = price
  }
}

struce Tesla: CarMaker {
  typealias Car = EV
  func produce() -> EV {
    return EV(model:"S1", price:5000)
  }
  return price(of car: EV) -> Int {
    return car.price
  }
}

/*  이런 식으로 CarMaker를 채택하는 타입을 몇 가지 정의하고, 
    f1, f2, f3라는 객체를 만들었다고 가정하자. (모두 Tesla 여도 상관없다.)
    그리고 Array<CarMaker> 타입을 사용해도 에러가 나지 않는다고 가정한다.
*/

// 각 공장에서 생산하는 차량의 가격의 합계를 알고 싶다.
let arr:[CarMake] = [f1, f2, f3]  
var total_price: Int = 0
for maker in arr {
    total_price += maker.price(of: <# ???? #>)
}
print(total_price)

만약 Array<CarMaker> 라는 타입이 허용된다 가정하더라도, 연관타입이 등장하는 시점이 문제가 됩니다. maker.price(of:) 를 컴파일 하는 시점에 컴파일러는 maker의 구체적인 타입을 결정할 수 없습니다. 비록 maker.price(of:) 메소드가 있다는 사실은 보장됩니다만, 여기서 인자로 전달되는 car의 타입을 컴파일 시점에 알 수 없기 때문에 컴파일에 실패하게 됩니다.

따라서 연관타입을 갖는 프로토콜을 타입으로 취급하려는 시도는 제네릭타입 그 자체를 하나의 구체적인 타입으로 취급하는 것과 비슷하다고 할 수 있습니다. 예를 들어 배열은 Array<T> 라는 제네릭 타입으로 정의되어 있습니다. 어떤 배열을 다루는 함수를 작성한다면, 제네릭 함수의 형식을 작성하여, 실제 원소의 타입에 상관없는 함수를 작성하는 것은 가능합니다. (물론 T의 타입이 확정되지 않기 때문에 이러한 함수에 T를 직접 사용하는 코드는 작성할 수 없을 것입니다.) 그렇지만 var myArray: Array<T> = [] 와 같이 제네릭 타입을 특정 객체의 타입으로 직접 사용하지는 않습니다.

위 예에서 price(of:) 를 차라리 T타입에 대한 제네릭 함수로 정의하면 되는 거 아니냐'고 반문할 수도 있겠습니다. 물론 그렇게하면 여기서 제시하는 문제는 넘어갈 수 있습니다, 그렇다고 하더라도 연관타입이 있는 프로토콜을 구체적인 타입처럼 사용하는 것은 Array<T>를 객체의 타입으로 지정하는 것과 똑같은 문제이기 때문에, 허용되지 않아야 하는 것은 변함이 없습니다.

우회하기

연관 타입을 갖는 프로토콜을 그렇지 않은 프로토콜처럼 타입으로 사용하기 위해서는 연관 타입까지 구체적으로 명시할 수 있어야 합니다. 하지만 제네릭 타입과 달리, 프로토콜은 제네릭 타입처럼 SomeProtocol<Element> 와 같은 표기를 허용하지 않고 있습니다. 따라서 이를 우회하기 위해서는 프로토콜을 따르는 구체적인 타입을 감추고, 프로토콜 자체를 대표하는 제네릭 타입을 정의하여야 합니다. 이렇게 특정한 프로토콜을 따르는 여러 타입에 대해서 그 타입을 감추고 하나의 타입처럼 사용할 수 있게 하는 기법을 “타입 지우기(Type Erasure)”라고 합니다.

타입을 지우는 방법은 제법 번거롭지만, 그리 어렵지는 않습니다. 프로토콜을 따르는 구체적인 타입들을 감싸는 타입을 하나 만들면 됩니다. 만약 해당 프로토콜이 연관 타입을 선언하고 있다면, 제네릭 타입이 됩니다. 먼저 위의 CarType 을 대표하는 AnyCar라는 타입을 지운 타입을 만들어 보겠습니다. (물론 CarType 자체는 연관 타입을 사용하지 않기 때문에, 그 자체로 타입으로 사용은 가능합니다.)

struct AnyCar: CarType {
  let _powerSource: String
  let _price: Int 
  init<Concrete: CarType>(_ concrete: Concrete) {
    _powerSource = concrete.powerSource
    _price = concrete.price
  }
  var powerSource: String { return _powerSource }
  var price: Int { return _price }
}

타입 지우기는 래퍼 타입을 정의하면서 구체적인 타입의 객체를 받아 감싸고 있습니다. 프로토콜의 요구조건을 충족하기 위해서 필요한 인터페이스는 구현하지만, 실제로는 구체적인 타입 객체의 구현을 이용하는 것으로 되어 있습니다.

그렇다면 CarMaker에 대한 타입을 지운 AnyCarMaker는 어떻게 구현할까요? 우선 연관 타입이 있으니, 제네릭한 타입으로 구현된다고 보면 됩니다.

struct AnyCarMaker<T:CarType>: CarMaker {
  typealias Car = T
  let _produce: () -> T
  init<U:CarMaker>(_ concrete: U) where U.Car == T {
    _produce = concrete.produce
  }
  func produce() -> T { return _produce() }
}

이렇게하면 실제 공장의 타입은 상관없이 전기차를 생산하는 공장은 AnyCarMaker<EV>, 가솔린 차량을 생산하는 공장은 AnyCarMaker<GV> 와 같이 생산되는 차량에 대한 제네릭으로 취급할 수 있습니다.

이때 연관타입인 Car 역시 CarType 이라는 프로토콜을 준수한다는 제약조건이 있고, 이를 바탕으로 AnyCar라는 타입을 지운 새로운 타입도 정의를 했었습니다. 그러면 이 두 가지를 조합할 수 있다면, 제네릭이 아닌 단일한 구체적인 타입으로도 정의할 수 있을 것 같습니다. 다음과 같이 AnyCarMaker를 새로 정의합니다.

struct AnyCarMaker: CarMaker {
  typealias Car = AnyCar
  private let _produce: () -> AnyCar
  init<U: CarMaker>(_ concrete: U) {
    // produce()의 결과를 AnyCar로 감싸서 리턴하면 끗.
    _produce = { return AnyCar(concrete.produce()) }
  }
  func produce() -> AnyCar {
    return _produce()
  }
}

연관타입에 대한 타입을 지워버리고 나면 AnyCarMaker 타입은 생산되는 자동차의 구체적인 타입이나, 자동차 생산메이커의 구체적인 타입에 상관없이 CarMaker 프로토콜을 준수하는 어떠한 타입의 객체도 대신할 수 있게 됩니다. 이제 아래와 같이 타입을 지우는 객체를 만드는 eraseType() 메소드를 CarMaker 프로토콜에 확장해줍니다. 그러면 CarMaker 프로토콜을 채택한 여러 자동차 메이커 타입들에 대해서 같은 타입인 것처럼 다룰 수 있게 됩니다.

extension CarMaker {
  function eraseType() -> AnyCarMaker {
    return AnyCarMaker(self)
  }
}

let makers = [ Tesla().eraseType(),
               GM().eraseType(),
               BMW().eraseType(),
               Hyundai().eraseType()
]
// makers: Array<AnyCarMaker>

let x = makers.map{ $0.produce().price }.reduce(0, +)

실제로 연관 타입을 갖는 프로토콜은 Swift의 표준 라이브러리에서도 광범위하게 사용되고 있습니다. 예를 들어 제네릭 타입인 ArrayCollection 이나 Sequence 와 같은 프로토콜을 채택하고 있는데, 이러한 프로토콜은 모두 연관타입을 갖는 프로토콜입니다. Collection 프로토콜의 경우 Element 라는 연관 타입을 가지고 있고, Element는 다시 Equatable 프로토콜을 준수해야 하는 제약이 있습니다. 따라서 AnyCollection 이라는 타입을 지운 Collection 프로토콜을 따르는 타입을 정의해서, 배열이나 사전, 집합등의 세부 타입에 상관없이 Collection 프로토콜을 따르는 임의의 객체들을 하나의 배열로 취급하는 것이 가능하게 됩니다.

그 외

Swift 5.1에서는 Opaque Type이라는 게 소개됐습니다. some 이라는 키워드를 사용하여, 연관 타입이나 Self를 언급하여 모호한 프로토콜을 객체의 타입으로 직접 적용할 수 있는 키워드인데, 이와 관련된 내용은 좀 더 알아보고 정리하도록 하겠습니다.