본문 바로가기
Dev/WWDC 정리

[WWDC22] Design protocol interfaces in Swift

by Mintta 2023. 4. 27.

Understand type erasure

associatedtype을 가진 프로토콜이 어떻게 실존 타입과 상호작용할까?🤔

- 닭은 달걀을 낳고 소는 우유를 만들어낸다. 이를 한번 더 추상화해보자. (associatedtype)

- 이를 다이어그램으로 나타낸다면 아래와 같다.

 

- protocol의 Self타입은 Animal protocol을 채택하는 actual concrete type.

- protocol Self타입은 Commodity라는 'Food'를 채택하는 associatedtype을 가지고 있다.

 

- any Animal을 통해 Animal 프로토콜을 채택하는 동물들을 담을 수 있다. some이게 되면 underlying type이 고정되기 때문에 any를 통해 선언해준다.

- any가 가능한 이유는 any의 type erasure기능 덕분. type erasure는 type-level distinction을 제거하기 때문하기 때문에 Animal 프로토콜을 채택한 서로 다른 유형의 객체들을 동일한 정적(static) 타입으로 사용가능.

protocol Animal {
    associatedtype CommodityType: Food

    func produce() -> CommodityType
}

struct Farm {
    var animals: [any Animal]

    func produceCommodities() -> [any Food] {
        return animals.map { animal in
            animal.produce()
        }
    }
}

- map클로저 안의 animal은 any Animal 타입, produce의 리턴값은 associatedtype.

- 실존 유형(existential type)에서 연관 유형(associatedtype)을 반환하는 메서드를 호출하면 컴파일러는 유형 지우기(type erasure)를 사용하여 호출의 return type을 결정합니다.

- type erasure는 이러한 associatedtype을 동등한 제약 조건이 있는 해당 실존 유형(existential type)으로 대체합니다. 아래 그림과 같이.

이로써 concrete Animal typeassociated CommodityType을 각각 any Animalany Food로 교체함으로써 둘의 relationship을 지웠다.

any Food는 associated Commodity Type의 upper bound type.

그냥 쉽게 말해서 any Animal로 타입이 지워졌기 때문에 produce로 반환되는 값 또한 any Food.

이렇게 되면 컴파일 타임에 어떤 구체적인 타입이 올지는 알 수 없지만 upper bound(any Food)의 하위 타입이라는 것은 명백하다 !

 

이 예제에서는 런타임에 Cow를 보유한 'any Animal'에 대해 produce()를 호출한다.

이 경우 Cow에 대한 produce() 메서드는 우유를 반환하는데, 우유는 Animal protocol의 associated CommodityType의 upper bound인 'any Food' 안에 저장될 수 있다.

 

Animal protocol을 준수하는 모든 concrete type에 대해 항상 안전합니다.

associatedtype이 parameter에 있다면?

메서드를 호출하기 위해서는 값을 전달해야한다.

conversion이 거꾸로 일어나기 때문에 type erasure가 일어나지 않는다.

Animal이 구체적으로 어떤 타입인지 명확하게 결정되지 않기 때문에([any Animal]이기 때문에) 인자로 전달되는 FeedType도 명확하게 결정할 수 없다.

 

Instance(Cow()) -> 메서드의 result type의 방향은 result type이 upper bound인 any Food 하위인것이 보장되기 때문에 type erasure가 가능하다.

메서드의 파라미터 -> Instance(Cow())는 parameter type이 upper bound에 해당되는 any AnimalFeed의 하위인 임시의 any AnimalFeed가 주어진다해도 concrete type을 모르기 때문에 그것이 Cow가 먹는 Hay일 것이라는 것을 보장할 수 없다.

 

Type erasure를 사용하면 associated types을 소비하는 위치에서 작업할 수 없습니다. 대신 opaque 'some' type을 취하는 함수에 전달하여 실존하는 'any' 타입의 박스를 해제해야 합니다.

(some을 쓰게 되면 underlying type이 고정되기 때문에 컴파일 때문에 타입을 알 수 있게 된다)

consuming: 파라미터로 받는다 consuming

producing: return으로 반환, 제공한다.

 

producing위치에 있는 associatedtype은 upper bound로 type erased된다.

 

Hide implementation details

protocol Animal {
    associatedtype CommodityType: Food
    var isHungry: Bool { get }
    func produce() -> CommodityType
}

struct Farm {
    var animals: [any Animal]
    var hungryAnimals: [any Animal] {
        animals.filter(\.isHungry)
    }

    func feedAnimals() {
        for animal in hungryAnimals {
            print(animal)
        }
    }

    func produceCommodities() -> [any Food] {
        return animals.map { animal in
            animal.produce()
        }
    }
}
    • Animal protocol에 isHungry 프로퍼티를 추가한다.
    • filter를 통해서 isHungry가 true인 동물의 배열을 만들고, 해당 배열을 iterate하면서 작업을 수행한다.
    • feedAnimals()가 hungryAnimals의 결과를 한 번만 반복한 다음 이 임시 배열을 즉시 버리는 것을 알 수 있다. 이는 농장에 굶주린 동물이 많은 경우 비효율적이다.
    • 이런 비효율적인 할당을 피할 수 있는 방법으로 LazyFilterSequence가 있다.

 

protocol Animal {
    associatedtype CommodityType: Food
    var isHungry: Bool { get }
    func produce() -> CommodityType
}

struct Farm {
    var animals: [any Animal]
    var hungryAnimals: LazyFilterSequence<[any Animal]> {
        animals.lazy.filter(\.isHungry)
    }

    func feedAnimals() {
        for animal in hungryAnimals {
            print(animal)
        }
    }

    func produceCommodities() -> [any Food] {
        return animals.map { animal in
            animal.produce()
        }
    }
}
  • LazyFilterSequence, animals.lazy.filter
  • Lazy collection은 일반 'filter' 호출로 반환되는 배열과 동일한 요소를 갖지만 임시 할당을 피한다. 그러나 이제 'hungryAnimals' 프로퍼티의 유형은 다소 복잡한 concrete type인 'LazyFilterSequence of any Animal의 배열'로 선언해야한다.
  • 이것은 불필요한 구현 세부 사항을 노출하게 된다.
  • 이 말은 client는 우리가 lazy filter를 쓰던 아무 상관없고 알고 싶어하지 않고 그저 iterate할 수 있으면 된다는 것이다.
  • opaque type을 통해 개선시켜보자.
var hungryAnimals: some Collection {
    animals.lazy.filter(\.isHungry)
}
  • some을 통해서 Collection의 abstract interface 뒤로 concrete type을 숨길 수 있다.
  • 하지만 static type information을 너무 많이 숨겨버린다.
  • 이 때 제약된 불투명 결과 유형(constrained opaque result type)을 사용하면 구현 세부 사항을 숨기는 것과 충분히 풍부한 인터페이스를 노출하는 것 사이에서 적절한 균형을 맞출 수 있다.
  • 아래의 오른쪽 그림을 보면 알 수 있다.

좌: some Collection, 우: some Collection<any Animal>

실제로 '어떤 동물의 배열의 LazyFilterSequence'라는 사실은 클라이언트에게 숨겨져 있지만, 클라이언트는 여전히 Element 연관 유형이 'any Animal'과 같은 Collection에 부합하는 어떤 concrete type이라는 것을 알고 있다.

이것이 가능한 이유는 Collection 프로토콜 안에 associatedtype의 Element가 있기 때문.

associatedtype Element in Collection protocol

 

만약 hungryAnimal을 지연 계산할지, 즉시 계산할지 선택 할 수 있도록 하고 싶은 경우에는 아래와 같이 코드를 작성가능하다.

하지만 some은 underlying type이 fixed되는데 두가지 return type이 반환되기 때문에 에러가 뜬다.

any

이 때 any를 사용하여 다른 return type이 반환될 수 있음을 알려줄 수 있다.

Identify type relationships

연관 프로토콜(related protocols)을 사용하여 여러 추상 타입 간에 필요한 타입 관계를 식별하고 보장하는 방법을 살펴보자.

 

동물들에게 먹이를 주기 전에 식물을 기르고(grow) 식물로부터 곡물을 재배(harvest)하는 과정이 추가되었다.

그리고 소와 닭 앞으로 어떤 동물이와도 먹일 수 있는(feedAnimal) 메서드를 만들어보자.

feedAnimal 메서드를 만들어보자 !

Bad case of protocol

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

protocol AnimalFeed {
    associatedtype CropType: Crop
    static func grow() -> CropType
}

Crop과 AnimalFeed가 각각 associatedtype으로 서로를 가지고 있다.

이를 다이어그램으로 나타내보면

무한 굴레

AnimalFeed가 Crop을 연관 타입으로 가지고, Crop은 AnimalFeed를 연관 타입으로 가지고 다시 AnimaFeed는 Crop을...

이렇게 무한 중첩이 생겨버린다. 당연히 Crop protocol도 마찬가지.

그럼 이 프로토콜이 concrete type간의 관계를 정확하게 모델링하는지 확인해보자.

 

protocol Animal {
    var isHungry: Bool { get }

    associatedtype FeedType: AnimalFeed
    func eat(_: FeedType)
}

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

protocol AnimalFeed {
    associatedtype CropType: Crop
    static func grow() -> CropType
}

extension Farm {
    private func feedAnimal(_ animal: some Animal) {
        let crop = type(of: animal).FeedType.grow()
        let feed = crop.harvest()
        animal.eat(feed) // 에러: Cannot convert value of type '(some Animal).FeedType.CropType.FeedType' (associated type of protocol 'Crop') to expected argument type '(some Animal).FeedType' (associated type of protocol 'Animal')
    }
}

에러메시지를 살펴보면

(some Animal).FeedType을 받기를 기대했는데 실제 넘어온 타입은

(some Animal).FeedType.CropType.FeedType 이라고 한다.

왜 이런일이 생길까

 

some Animal을 받는다 -> Animal에는 AnimalFeed타입의 FeedType이 있다. -> grow()호출가능 -> grow() 메서드는 AnimalFeed의 중첩 associated Crop타입의 CropType을 반환한다. -> Crop타입의 값이기 때문에 harvest()호출가능 -> harvest()는 Crop 프로토콜의 associatedtype FeedType을 반환한다. -> 이 때 base type이 (some Animal).FeedType.CropType이기 때문에 harvest를 통해 얻는 것은 (some Animal).FeedType.CropType.FeedType이 된다.

 

Wrong type! Animal 프로토콜에 선언한 eat 메서드를 보면 eat메서드는 (some Animal).FeedType을 인자로 받기 때문이다.

 

또한 이러한 모델링은 type safe하지 않다.

Crop에서 harvest되는 곡물이 Hay이든 Scratch이든 어느쪽이든 AnimalFeed타입이기 때문에 요구사항을 충족해버린다. 이렇게 되면 소에게 scratch를 먹이고, 닭에게 Hay를 먹여도 아무도 모를 것이다...

어떻게 해결할 수 있을까?

 

where 키워드 사용

associatedtype간의 관계는 where문에 동일 타입 요구 사항을 작성하여 표현할 수 있다.

 

protocol Crop {
    associatedtype FeedType: AnimalFeed where FeedType.CropType == Self
    func harvest() -> FeedType
}

protocol AnimalFeed {
    associatedtype CropType: Crop where CropType.FeedType == Self
    static func grow() -> CropType
}

 

AnimalFeed부터 살펴보면 associatedtype인 CropType에 조건이 붙었다.

associated Crop type의 FeedType의 타입이 자기 자신(AnimalFeed)의 타입과 같을 때 !

또한, Crop의 AnimalFeed의 CropType이 자기 자신(Crop)의 타입과 같을 때 !

  • 이제 무한 중첩이 사라지고 관계가 명확해졌다.

전체 모델링 다이어그램

 

댓글