iOS/iOS 지식

[WWDC2022] Design protocol interfaces in Swift

홍복치 2024. 6. 8. 17:07

WWDC2022에 소개된 프로토콜 인터페이스 설계 방법

개요

1. understand type erasure : 연관타입을 가진 프로토콜이 실존타입과 작용하는 방법(타입 소거)

2. hide implementation details:  불분명한 결과 타입을 인터페이스 구현하여 캡슐화를 개선하는 방법

3. identify type relationships : 프로토콜의 동일 타입 요구사항이 여러개의 서로 다른 구체타입 집합 관계 모델링 하는 방법

1. understand type erasure

protocol Animal { 
    associatedtype CommodityType: Food
    func produce() -> CommodityType
}

//CommodityType이 Egg인 프로토콜을 따름
struct Chicken: Animal {
    func produce() -> Egg { }
}

//CommodityType이 Milk인 프로토콜을 따름
struct Cow: Animal {
    func produce() -> Milk {}
}

protocol Food { }

struct Egg: Food { }

struct Milk: Food { }

동물은 여러종류가 존재하고 각각의 생산품이 다르다.

예를 들어 소는 우유를 만들어내고 닭은 알을 낳는다.

이렇듯 여러 생산품을 추상화하기 위해 '연관타입(associated Type)'을 사용할 수 있다.

protocol Animal { 
    associatedtype CommodityType: Food
    func produce() -> CommodityType
}

produce 메소드의 반환타입이 연관타입으로 대체 되면서 추상화가 이루어진다.

메소드를 호출하면 값이 생산 되기 때문에 CommodityType을 생산위치에 있다고 한다.

struct Farm {
    var animals: [any Animal] = [Cow()]
  
    func produceCommodities() -> [any Food]{
        return animals.map { animal in
            animal.produce()
        }
    }
}

동물이 많은 농장이 생겼다. 어떤 구체타입에 대해서도 동일한 표현을 사용할 수 있다.

소든, 닭이든, 뭐든 >  any Animal으로 표현될 수 있음

 

produceCommodities 메소드의 map 클로저 안의 animal은 무슨 타입일까 추론해보면

- any Animal

- 실존타입 Cow

 

동일하게 produce()의 반환타입

- any Food

- 실존타입 Milk

 

위와 같이 연관타입을 동등한 제약조건을 가진 실존타입으로 대응시킬 수 있다.

Animal의 타입과 연관 CommodityType의 관계를 any Animal, any Food로 대체하여 제거한 것을 볼 수 있다.

protocol Animal { 
    associatedtype FeedType : AnimalFeed
    func eat(_ : FeedType)
}

struct Farm {

    var animals: [any Animal] = [Cow()]
    
    func eatFeed() {
        animals.map { animal in
           // animal.eat(???)
        }
    }
}

Animal 프로토콜의 eat메서드는 매개변수위치에 연관타입(FeedType)을 가지는데 이 위치를 소비위치라고 한다.

결론적으로 타입소거는 소비위치에서 연관 타입을 사용하는 것을 허용하지 않는다.

만약에 eat()에서 Hay를 취한다고 가정한다면 FeedType의 상한은 any AnimalFeed이다.

임의의 any AnimalFeed가 주어져 버리면 Hay라는 구체 타입을 저장하는 걸 보장할 수 없다. 

(소는 건초를 먹어야 하는데 닭모이를 먹을수도 있는 상황이 벌어질 수 있음)

 

타입소거는 swift5.6의 기능과 비슷하다.

protocol Cloneable: AnyObject {
    func clone() -> Self
}

let object: any Cloneable = ....
let cloned = object.clone()

여기서 clone() -> Self를 보면 결과타입인 Self가 상한까지 타입소거 되는 걸 볼 수 있다.

self타입의 상한선은 항상 프로토콜 자체이다. 그러므로 여기서는'any Cloneable'타입의 새로운 값 반환된다.

 

요약

1. any를 사용하여 값타입이 프로토콜을 따르는 구체타입을 저장하는 실존 타입임을 선언 가능(cow, chicken > any Animal)

2. 연관 타입을 가진 프로토콜에서도 사용 가능

3. 생산위치에서 연관타입을 가진 프로토콜 메소드를 호출 ->  연관타입 제약조건을 가진 실존타입인 상한 타입까지 타입 소거 

 

왜 사용해야 할까?

구체 타입은 구현을 해야 볼 수 있다.

구체적인 결과 타입을을 추상화하여 구현 세부정보에서 필수 인터페이스를 분리할 수 있다.

(모듈화, 변화에 강하게 만들 수 있음)

2. hide implementation details

protocol Animal { 
    var isHungry: Bool { get }
}

extension Farm {
    var hungryAnimals: [any Animal] {
        animals.lazy.filter(\.isHungry) //any Animal 배열이 리턴
   }
   
    func feedAnimals(){
        for animal in hungryAnimals {
            ...
        }
    }
}

농장에는 여러 배고픈 동물이 살고 있다. 배고픈 동물들을 구하기 위해 hungryAnimals를 정의한다.

이 배고픈 동물은 동물에게 먹이를 주기 위해 필요한데(feedAnimals)  한번만 사용되고 버려진다.

배고픈 동물이 많아지면 이 방법이 효율적이지 않아서 lazy키워드를 사용해 임시 할당을 피한다.

 

그럼 lazy를 쓰면 다 해결이 되는건가? 답은 아니다.

lazy를 쓰면 hungryAnimals가 "lazyFilterSequence"라는 복잡한 타입으로 선언되어야 한다.

이 타입으로 선언이 되게 되면, 불필요한 구현의 세부사항이 노출된다.

"반복되는 컬렉션"을 얻는 것 정도만 표시할 수 있는 방법이 존재하는데 "제한된 불분명한 결과타입"을 사용하는 것이다.

extension Farm {
    var hungryAnimals: some Collection<any Animal> {
        animals.lazy.filter(\.isHungry)
    }
    
    var hungryAnimals: some Collection {
        animals.lazy.filter(\.isHungry)
    }
    
    var hungryAnimals: any Collection<any Animal> {
        if isLazy {
            animals.lazy.filter(\.isHungry)
        }else{
            animals.filter(\.isHungry)
        }
    }
}

some Collection<any Animal>

- any Animal과 같은 element 연관 타입을 가진 컬렉션을 따르는 구체타입

 

some Collection

- 컬렉션이지만 어떤 요소인지 알 수 없음. 요소타입이 'any Animal'이라는 지식이 없으면 전달만 하고 Animal의 어떤 메소드도 호출할 수 없음

 

any Collection<any Animal>

- any를 사용하여 호출 시 다른 타입을 반환할 수 있음을 알려줌

 

요약

불분명한 결과타입을 사용해서 복잡한 구체타입 컬렉션의 추상 인터페이스 뒤로 숨길 수 있음

인터페이스 정보를 노출시키는 정도를 균형있게 조절할 수 있음

3. identify type relationships

protocol Animal {
    associatedtype FeedType : AnimalFeed
    func eat(_: FeedType)
}

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

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

struct Cow: Animal {
    func eat(_: Hay) {}
}

struct Hay: AnimalFeed {
    static func grow() -> Alfalfa { }
}

struct Alfalfa: Crop {
    func harvest() -> Hay { }
}

동물에게 먹이를 주기 전에 올바른 타입의 사료로 가공해야 한다고 가정하면 

아래와 feedAnimal함수를 작성할 수 있다. 

crop은 (some Animal).FeedType.CropType 반환

feed는 (some Animal).FeedType.CropType.FeedType 반환

func eat(_: FeedType)으로 정의되어 있기 때문에 (some Animal).FeedType이어야 하는데 정확한 타입이 아니게 된다.

또한 건초를 재배하면 알팔파를 얻고, 알팔파를 수확해서 건초를 얻는 과정의 반복에서 만약에 알팔파 대신에 다른것이 반환된다고 하더라도 상관이 없어진다.

  extension Farm {
  	private func feedAnimal(_ animal: some Animal){
        let crop = type(of: animal).FeedType.grow()
        let feed = crop.harvest() 
        animal.eat(feed)
    }
}

문제를 해결하려면 "동일 타입 요구사항"을 작성해야 한다.

동일타입 요구사항 작성 = 중첩된 연관타입이 사실상 동일한 구체타입이어야 한다는 정적보장

무한중첩을 방지하고 관계를 단일 쌍으로 축소할 수 있다.

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

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

참고자료

https://developer.apple.com/videos/play/wwdc2022/110353/