[Swift] Swift 5.7 Opaque Type(some, any) 알아보기

Swift 5.7에서 개선된 Opaque Type을 잘 알아두면 코드를 정말 유연하게 작성할 수 있습니다. (너무 킹아… 👍)

일단 Some 키워드에 대해 알아볼까요?

Some 키워드란?

some 키워드의 경우 Swift 5.1 에서 부터 소개되었으며, 특정 프로토콜을 준수하는 것들을 담고 있는 불투명한 타입(Opaque Type)을 만들어내기 위해 사용됩니다. 말로만 들으니 어렵게 느껴지죠? 간단한 예와 함께 봅시다.

protocol Vehicle {

  var name: String { get }

  associatedtype FuelType
  func fillGasTank(with fuel: FuelType)
}

struct Car: Vehicle {

  let name = "car"

  func fillGasTank(with fuel: Gasoline) {
    print("Fill \(name) with \(fuel.name)")
  }
}

struct Bus: Vehicle {

  let name = "bus"

  func fillGasTank(with fuel: Diesel) {
    print("Fill \(name) with \(fuel.name)")
  }
}

struct Gasoline {
  let name = "gasoline"
}

struct Diesel {
  let name = "diesel"
}

일단 위의 코드를 확인해보면 Vehicle 이라는 프로토콜이 정의되어 있고 CarBus 객체가 해당 프로토콜을 준수하고 있네요! 추가 요건으로 이제 세차하는 함수도 정의해볼까요? 기존에 자주 사용하던 제네릭을 이용해서 정의해봅시다.

func wash<T: Vehicle>(_ vehicle: T) {
  print("세차완료 \(vehicle.name)")
}

wash(Car()) 와 같이 호출하면 세차완료가 되는 것을 확인할 수 있습니다 🙂 지금은 아주 간단하게 하나의 제네릭만 사용했지만 제네릭이 더 많이 추가된다면 함수 헤더 부분이 아주 길어집니다. some 키워드를 통해 단순하게 할 수 있습니다.

func wash(_ vehicle: some Vehicle) {
  print("세차완료 \(vehicle.name)")
}

기존의 제네릭이 사라지고 파라미터 타입에 some Vehicle로 변경했습니다! 여기까지는 제네릭으로도 가능하지만 some 키워드는 변수를 할당할때도 사용 가능합니다.

let someVehicles: [some Vehicle] = [Car(), Car(), Car(), Car()]
someVehicles.forEach { wash($0) }

위와 같이 특정 프로토콜을 준수하는 객체들의 배열을 저장할 수 있습니다. 하지만 아래와 같이 타입을 미리 정의해두고 나중에 할당하는 방식은 불가능합니다.

var someVehicles: [some Vehicle] = []
  
override func viewDidLoad() {
  super.viewDidLoad()
  someVehicles = [Car(), Car(), Car(), Car()]
  someVehicles.forEach { wash($0) }
}

위의 코드는 컴파일 에러가 발생하는데요, some 키워드는 기본적으로 변수 범위에 대해 고정되어야 하는 특성을 갖고 있어 불가능합니다. 이 특성으로 인해 다음과 같은 코드도 불가능합니다.

var someVehicles: [some Vehicle] = [Car(), Car(), Bus(), Car()] // 🔴 Compile Error

// or

var someVehicles: [some Vehicle] = [Car(), Car(), Car(), Car()]
  
override func viewDidLoad() {
  super.viewDidLoad()
  someVehicles = [Bus()] // 🔴 Compile Error
  someVehicles.forEach { wash($0) }
}

// or

var someVehicles: [some Vehicle] = [Car(), Car(), Car(), Car()]
  
override func viewDidLoad() {
  super.viewDidLoad()
  someVehicles = [Car()] // 🔴 Compile Error
  someVehicles.forEach { wash($0) }
}

하지만 아래와 같이 빈 배열로 초기화하는 건 가능합니다.

var someVehicles: [some Vehicle] = [Car(), Car(), Car(), Car()]
  
override func viewDidLoad() {
  super.viewDidLoad()
  someVehicles = [] // ✅ Compile OK
  someVehicles.forEach { wash($0) }
}

그렇다면 아래와 같은 분기를 통해 특정 프로토콜을 준수하지만 실질적으로 다른 객체를 반환하는 함수를 구현하고 싶다면 어떻게 해야할까요?

// 🔴 Compile Error
func createSomeVehicle(isPublicTransport: Bool) -> some Vehicle {
  if isPublicTransport {
    return Bus()
  } else {
    return Car()
  }
}

위와 같은 문제를 해결하기 위해서는 any 키워드를 사용하면 됩니다.

Any키워드란?

any 키워드의 경우 Swift 5.6에서 소개되었습니다. existential type 을 생성할 수 있도록 추가되었는데요, 한번 알아볼까요?

Compare the differences between the some and any keyword in Swift

some 키워드와 any 키워드에 대한 비교한 이미지인데요, some 키워드의 경우 상자가 감싸고 있지 않지만 any 키워드의 경우 상자가 감싸고 있는 모습입니다. 여기서 상자는 특정 프로토콜(여기서는 Vehicle 프로토콜이 되겠네요)이 준수하는한, 그 안에 어떠한 콘크리트 타입이라도 저장할 수 있도록 해주는 역할을 해주는데요, WWDC 2022 에서 자세히 설명해주고 있으니 시간되시는 분들은 꼭 한번 들어보시길 바랍니다 (20분 50초부터 보시면 될듯 😊)

이제 any 키워드를 통해 컴파일 오류를 고쳐봅시다. 아래와 같이 함수 리턴시 some이 아닌 any로 변경해주면 됩니다.

// ✅ Compile OK
func createSomeVehicle(isPublicTransport: Bool) -> any Vehicle {
  if isPublicTransport {
    return Bus()
  } else {
    return Car()
  }
}

이제 변수에 저장해볼까요? 앗, 하지만 아까 some 키워드는 변수 범위에 대해 고정되어야 하는 특성을 가지고 있으니 아래와 같이 사용할 수 없습니다.

// 🔴 Compile Error
let someVehicles: [some Vehicle] = [createSomeVehicle(isPublicTransport: true),
                                        createSomeVehicle(isPublicTransport: false)]
    
someVehicles.forEach { wash($0) }

하지만 이제 any 키워드를 사용하면 위의 컴파일 에러를 해결할 수 있습니다.

// ✅ Compile OK
let someVehicles: [any Vehicle] = [createSomeVehicle(isPublicTransport: true),
                                       createSomeVehicle(isPublicTransport: false)]
    
someVehicles.forEach { wash($0) }

이제 좀 더 유연하게 코드를 작성할 수 있게 되었습니다! 👏

이제 배운걸로 기존에 겪었던 문제들도 한번 해결해볼까요?

https://minsone.github.io/programming/swift-opaque-type-and-erase-type

민소네님이 예전에 겪으셨던 문제인데요, 이때 당시는 any 키워드를 사용할 수 없어 Type Eraser Box를 구현하셔서 문제를 해결하셨습니다. (저도 이 기술을 여기서 배웠습니다..)

아래는 컴파일 에러가 발생하는 코드입니다.

// 🔴 Compile Error
func get_comparable_value() -> some Comparable {
  if true { return 1 }
  else { return "a" }
}

1 (Int 타입)와 a(String 타입) 둘 다 반환할 수 있어서 문제가 되는 경우인데요, any 키워드를 적용해서 해결할 수 있습니다.

// ✅ Compile OK
func get_comparable_value(_ flag: Bool) -> any Comparable {
  if flag { return 1 }
  else { return "a" }
}

// 1, "a"
print(get_comparable_value(true), get_comparable_value(false))

any 키워드로 아주 간단하게 해결… 👍

some 키워드에서는 불가능했던, 나중에 값을 할당할 수 없었던 문제도 해결할 수 있습니다.

var someVehicles: [any Vehicle] = [createSomeVehicle(isPublicTransport: true),
                                   createSomeVehicle(isPublicTransport: false)]

// ✅ Compile OK
someVehicles += [createSomeVehicle(isPublicTransport: true)]

// 세차완료 bus
// 세차완료 car
// 세차완료 bus
someVehicles.forEach { wash($0) }

또는 아래와 같이 UIView 또는 UIControl 등을 다 갖고 있는 변수도 만들어낼 수 있습니다.

protocol Component {
  var id: String { get }
}

extension Component {
  var id: String { return UUID().uuidString }
}

extension Component where Self: UIView {
  func addSubview(_ component: some Component) {
    if let component = component as? UIView {
      addSubview(component)
    }
  }
}

extension UIView: Component {}

override func viewDidLoad() {
  super.viewDidLoad()
  let someComponents: [any Component] = [UILabel(),
                                         UISwitch(),
                                         UIButton(),
                                         UIView()]
  
  someComponents.forEach {
    self.view.addSubview($0)
  }
}

전부 다른 객체이지만 하나의 변수에서 관리할 수 있게 되었네요! 👍

기존에 Type Earser Box로 해결했다면 이제는 any 키워드와 some키워드로 해결해봅시다!

참고 자료