[Swift] existential type 개념과 Swift 5.7 이후의 변경사항

2 minute read

1. Existential type 개념

protocol 선언 후 채택하여 함수를 사용한다고 생각해 보자.

protocol Testable {
    func test()
}

struct A: Testable {
    func test() {
        print("A Test")
    }
}

struct B: Testable {
    func test() {
        // 테스트
    }
}

만일 타입과 상관없이 동일하게 동작하는 함수를 사용하고 싶다면 두 가지 방법이 있다. 첫째, Generic 을 활용한다. 둘째, existential type 을 활용한다.

// generic 활용
func test<T: Testable>(with test: T) {
    test.test()
}

// existential type 을 활용
func test(with test: any Testable) { 
    test.test()
}

var test: any Testable = A()

existential type 은 이와 같이 protocol이 타입으로써 사용될 때, 그 타입을 의미한다.

↔ 반대되는 개념: concrete type

2. Swift 5.7 이후의 변화

swift-evolution/proposals/0335-existential-any.md at main · swiftlang/swift-evolution

기존에는 타입을 선언할 때 protocol 을 채택하든, concrete type 이라고 쓰든 코드만 보고 알 수가 없었다.

Swift 5.7 부터는 이를 구분하기 위해 existential type 에는 any 키워드를 붙여서 구분이 가능하게끔 컴파일러가 강제한다.

any 키워드를 사용 시의 장점: type-safety

What’s the ‘any’ keyword? Understanding Type Erasure in Swift

5.7 이후에는 any 키워드를 써서 existential type 을 만들어 사용 시 컴파일러가 type-safety 를 보장한다.

다음과 같은 protocol 이 있다고 하자.

protocol Animal {
    associatedtype Food
    func eat(_ food: Food)
}

protocol FoodProvider {
    func getFood<T: Animal>(for animal: T) -> T.Food
}

만약 여러 동물이 있어 다 모아 두고 밥을 주는 기능이 필요하다고 한다면, 기존에는 다음과 같이 함수를 만들었을 것이다.

func feedAll(_ animals: [Animal], provider: FoodProvider) {
    animals.forEach {
        let food = provider.getFood(for: $0)
        $0.eat(food)
    }
} 

Swift 5.7 부터는 에러를 방출한다.

이 상황을 해결하기 위해서 Type 을 지운 다음과 같은 AnyAnimal 타입을 만들어서 사용할 수도 있다.

final class AnyAnimal {
    var getFood: (FoodProvider) -> Any
    var eatFood: (Any) -> Void

    init<T: Animal>(_ animal: T) {
        self.getFood = { provider in
            provider.getFood(for: animal)
        }
        self.eatFood = { food in
            animal.eat(food as! T.Food)
        }
    }
}

func feedAll(_ animals: [AnyAnimal], provider: FoodProvider) {
    animals.forEach {
        let food = $0.getFood(provider)
        $0.eatFood(food)
    }
}

이렇게 하면 해결이 가능하지만, 여기에는 문제가 있다. FoodProvider 에서 제공하는 food 타입이 Animal 에서 제공하는 Food 타입으로 캐스팅이 되지 않는다면? 크래시가 발생한다.

크래시가 발생하는 상황을 만들어보자.

struct DogFood {
    let name: String
}

struct CatFood {
    let name: String
}

struct Dog: Animal {
    typealias Food = DogFood
    
    func eat(_ food: DogFood) {
        print("Dog is eating \\(food.name)")
    }
}

struct Cat: Animal {
    typealias Food = CatFood
    
    func eat(_ food: CatFood) {
        print("Cat is eating \\(food.name)")
    }
}

// -- 잘못된 예시
class SimpleFoodProvider: FoodProvider {
    func getFood<T: Animal>(for animal: T) -> T.Food {
        // 잘못된 타입의 음식을 제공
        return CatFood(name: "Cat Food") as! T.Food
    }
}

let dog = Dog()
let dogWrapper = AnyAnimal(dog)
let animals: [AnyAnimal] = [dogWrapper]
let provider = SimpleFoodProvider()

feedAll(animals, provider: provider)  // 여기서 크래시 발생

이런 경우 컴파일러는 알 수가 없다.

5.7 이후로 any 를 사용하여 다음과 같이 사용하면, 컴파일러가 for 문을 돌 때마다 각자의 concrete type 에 맞는 값을 얻을 수 있게 되어 컴파일 단에서 타입안정성을 얻을 수 있다.

func feedAll(_ animals: [any Animal], provider: FoodProvider) {
    animals.forEach {
        feed($0, provider: provider) // Now works!
    }
}

func feed<T: Animal>(_ animal: T, provider: FoodProvider) {
    let food = provider.getFood(for: animal)
    animal.eat(food)
}

References

Categories: ,

Updated:

Comments