본문 바로가기
iOS/Swift

Swift의 디자인 프로토콜 인터페이스

by 바등쪼 2023. 4. 27.

WWDC22 Embrace Swift generics(Swift 제네릭 활용)과 이어집니다.

 

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

주요 내용

1. 프로토콜의 assiciated 타입이 Any 타입과 상호작용 하는 방법을 type erasure으로 이해

2. Opaque type으로 구현부 숨기기

3. 프로토콜의 동일 타입 요구 사항이 어떻게 서로 다른 구체 타입 집한 간의 관계를 모델링하는지

 

1. Understand type erasure

  • 2개의 Animal 타입, 2개의 Food 타입이 있다.

  • Animal 프로토콜은 Food를 채택한 CommodityType을 연관타입으로 가지며 이것을  produce하도록 명시한다.

  • 다이어그램으로 나타내면 위와 같다.

  • 구현체의 예시는 위와 같다. Cow 구조체는 Animal 프로토콜을 채택했고 produce 함수의 리턴 타입이 Milk이므로 Cow의 연관타입(CommodityType)은 Milk가 된다.

농장으로 예시의 규모를 키워보자

농장에서는 여러 종류의 동물이 있을 수 있다.

  • Farm 의 animals은 any Animal 타입의 배열이다. any Animal로 표현하면 Animal을 만족하는 어떠한 종류의 동물들도 동적으로 들어갈 수 있다. 이러한 상태를 박스로 표현할 수 있는데 이렇게 다양한 구체 타입에 대해 동일한 표현을 사용하는 전략을 Type Erasure(타입 소거)라고 한다.

  • produceCommoditeies() 함수를 살펴보자
  • any animals가 들어있는 어레이에서 map을 수행하고 있다.
  • 실존 타입에 대해 연관 타입을 반환하는 메소드를 호출하면 컴파일러는 타입 소거를 사용해 호출의 결과 타입을 정한다.
  • any Animal로부터 any Food 어레이를 만든 것이다.
  • 이 예시에서 any Food 타입은 upper bound of the associated CommodityType이라고 불린다.
    • any Animal에 대해 produce() 메소드가 호출되었기 때문에 리턴 값은 타입 소거되어 any Food가 된 것이다.

Type erasure semantics

위의 예시처럼 연관 타입이 함수의 리턴값에 위치한다면 이 위치는 producing position이다.

any Animal에 대해 이 메소드를 실행하면 컴파일 시점에는 구체적인 결과 타입은 알 수 없지만 상한 타입의 하위 타입이라는 것은 알 수 있다. (Food타입의 하위타입)

 

반대로 연관타입이 리턴 타입의 위치가 아니라 함수의 파라미터 목록에 위치했을 때를 알아보자! 🚀

 

이 예시에서는 FeedType이라는 연관 타입이 eat 함수의 파라미터 타입으로 들어가있다.

앞선 예제와 반대인 상황이기 때문에 메소드를 호출하려면 이 FeedType의 값을 전달해야 한다.

하지만 변환이 반대 방향이기 때문에 타입 소거를 수행할 수 없으며 구체 타입을 알 수 없기 때문에 연관 타입의 상한 실존 타입은 구체 타입으로 안전하게 변환되지 않는다.

 

any Animal 박스에는 Cow가 들어있고 Cow는 Hay(건초)를 먹는다고 했을 때 위와 같은 상황이 발생한다.

Cow는 Hay를 먹기 때문에 올바른 상황이지만 위와 같이 박스에 감싸진(임의의) any AnimalFeed가 주어졌을 때 이것이 Hay 구체 타입이라는 것을 정적으로 보장할 수 없다. 

따라서, 타입 소거는 소비 위치(파라미터)에서 연관 타입을 사용하는 것을 허용하지 않는다.

그 대신 opaque 타입인 some 타입을 취하는 함수에 입력함으로써 any 타입을 언박싱해야 한다.

 

연관 타입의 타입 소거 동작은 기존(Swift 5.6)의 기능과 유사하다.

  • Cloneable 프로토콜에는 clone을 하는 함수가 있고 이 함수는 Self 타입을 리턴한다.
  • 이 함수를 이용하여 만든 객체는 Self가 상한까지 타입 소거되어 any Cloneable 타입의 값이 된다.

Type erasure 요약

 


 

2. Hide implementation details

구체적인 결과 타입을 추상화하여 구현 세부 정보에서 코드의 필수 인터페이스를 분리함으로써 정적 타입 할당을 더욱 모듈화하고 유지보수에 용이하게 만드는 방법을 알아보자

동물에게 먹이를 줄 수 있도록 Animal 프로토콜을 일반화하자!

Animal 타입은 isHungry 프로퍼티를 가진다.

  • 배고픈 동물들에게 먹이를 주기 위해 위와 같이 코드를 작성할 수도 있다.
  • hungryAnimals 어레이는 animals 어레이에서 filter를 통해 배고픈 동물들만 골라낸다.
  • 이 hungryAnimals 어레이를 반복문을 통해 돌며 먹이를 주는 방식이다.
  • 동물의 수가 많지 않다면 이 코드도 유용하겠지만 동물의 수가 많아지면 filter에서 불필요한 연산이 증가하게 된다.

  • 따라서 우리는 lazy.filter를 이용해 불필요한 연산을 줄일 수 있다.
  • lazy filter는 filter를 통해 반환된 배열과 동일한 요소를 가지지만 임시 할당을 피할 수 있다.
  • 하지만 이 코드에도 문제점이 있다.
  • hungryAnimals 배열의 타입이 LazyFilterSequence<[any Animal]> 로 선언되어야 한다는 점이다.
  • 이것은 불필요한 구현 세부 사항을 노출한다. (lazy.filter를 사용했다는 사실을 노출하고 있다.)
  • hungryAnimals를 사용하는 곳에서는 hungryAnimals이 filter를 사용했는지 혹은 lazy.filter를 사용했는지에는 관심이 없다.
  • 그저 any Animals 타입의 요소가 들어있는 Collection만 제대로 받을 수 있으면 충분한 것이다!

  • 불투명 타입 some을 사용해 hungryAnimals의 타입을 some Collection으로 선언하면 앞선 문제를 해결할 수 있다.
  • 복잡한 구체 타입을 추상 인터페이스인 Collection으로 대체시켜 불필요한 구현 세부 사항을 숨긴 것이다!
  • 이로 인해 hungryAnimals를 호출하는 클라이언트는 이것이 Collection 프로토콜을 채택하고 있는 어떠한 구체 타입이라는 것만을 알고 정확한 구체 타입에 대해서는 모르게 된다.
  • 하지만 이 코드에도 문제점이 있다 😢
  • 클라이언트로부터 정적 타입 정보를 너무 많이 숨긴 것이다.
  • feedAnimals() 메소드에서 hungryAnimals 배열을 돌며 먹이를 주는 작업을 해야하는데 이 hungryAnimals의 타입이 some Collection이기 때문에 이 콜렉션의 요소의 타입이 감춰져 있어서 먹이를 주는 함수를 호출할 수 없는 것이다.

  • 너무 많은 정보를 숨기는 문제를 해결하고 인터페이스 정보를 노출시키는 정도로 균형 있게 조절한 예시가 위와 같다!! 👍
  • Swift 5.7의 제한된 불분명한 결과 타입(Constrained opaque result type)은 프로토콜 이름 뒤에 있는 꺾쇠 괄호 안에 타입 인수를 넣는 형태로 선언할 수 있다.
  • 이렇게 하면 hungryAnimals의 사용자는 이것딩 any Animal을 담은 배열임은 알 수 있지만 어떻게 구해졌는지는 알 수 없게 된다!! 굿!!

사용처에 따라 hungryAnimals 배열이 lazy하지 않게 계산되길 원할 수도 있다.

  • 그렇다면 hungryAnimals의 타입을 any Collection<any Animal>로 설정하여 분기처리 할 수도 있다. (반환 타입의 종류가 2개이기 때문에 some은 사용 불가능하다.)

 


 

3. Identify type relationships

불투명 타입 제네릭 코드 작성은 추상 타입 관계에 기반해야 한다.

연관 프로토콜을 사용하여 여러 추상 타입 간에 필요한 타입 관계를 식별하고 보장하는 방법에 대해 알아보자!

 

이번에도 Animal을 사용한 예시이다!

기존코드에서 다음과 같은 로직을 추가한다고 생각해보자

  • 동물에게 먹이를 주기 전에 적절한 타입의 작물을 재배하고 수확하여 사료를 생산해야 한다.

  1. Cow는 Hay를 먹는다.
  2. Hay를 구하기 위해서는 우선 Hay가 grow하여 Alfalfa가 되어야 한다.
  3. 이렇게 얻은 Alfalfa를 harvest(수확)하여 Hay를 구한다.
  4. 이 Hay를 cow에게 먹인다!

Chicken도 필요한 구체 타입들은 다르지만 같은 구조를 가진다.

 

먹이를 주는 feedAnimals 함수는 위와 같이 구현했다.

any animal 타입인 animal을 소비 위치(파라미터)로 받아서 eat 함수를 실행시켜야 하기 때문에 any animal을 언박싱해야 한다.

따라서, feedAnimal이라는 함수를 만들고 파라미터 타입을 some Animal로 지정하여 실존 타입의 언박싱을 진행한다!

 

자, 이제 AnimalFeed와 Crop의 관계에 대해 자세히 알아보자~!!

이 두 타입을 논리대로 프로토콜로 나타내면 위와 같다.

  • AnimalFeed는 연관 타입으로 CropType(Crop)을 가진다.
  • grow 함수를 통해 이 CropType을 얻을 수 있다.
  • Crop은 연관 타입으로 FeedType(AnimalFeed)를 가진다.
  • harvest 함수를 통해 이 FeedType을 얻을 수 있다.

잠깐 읽어봐도 무언가 심상치 않다...!

서로가 서로를 가지고 있는(필요로 하는) 상태이다.

 

다이어그램으로 나타내면 다음과 같다.

 

서로가 서로를 연관 타입으로 가지고 있기 때문에 순환이 끊나지 않고 계속 이어지는 상황이 발생한 것이다!! 🤨

  • 이 상태에서 feedAnimal 함수를 구현하면 위와 같다.
  • 앞서 발생한 연관 타입의 순환으로 인해 이 코드는 정상적으로 실행되지 않는다.
  • animal.eat(feed)가 실행될 때 이 feed의 타입은 (some animal).FeedType 이어야 한다.
  • 하지만 실제로 들어가게 될 타입은 (some Animal).FeedType.CropType.FeedType이다. ➡️ 에러 발생
프로토콜 정의가 너무 일반적이라서 구체 타입 간에 요구되는 관계를 정확하게 모델링하지 못했다. 

 

where 절의 사용으로 문제 해결

앞선 문제는 where를 사용하여 해결 할 수 있다!

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

동일 타입 요구 사항(same type requirement)는 중첩 가능성이 있는 2개의 연관 타입이 사실상 동일한 구체 타입이어야 한다는 정적 보장을 나타낸다.

동일 타입 요구 사항을 추가하면 AnimalFeed 프로토콜을 따르는 구체 타입에 제한 조건이 부과된다.

즉, Self.CropType.FeedType이 Self과 같은 타입임을 선언하는 것이다.

중첩 연관 타입의 무한 참조의 고리를 끊을 수 있게 된 것이다!

 

여기까지만 하면 Crop 프로토콜에서는 아직 연관 타입이 많이 존재하게 된다.

Crop의 FeedType.CropType이 최초에 시작했던 Crop과 같은 타입이라는 것을 명시해주자! ➡️ Crop 프로토콜의 연관 타입인 FeedType에도 where절 사용!

 

개선 결과

  • 앞서 살펴본 개선 사항들을 적용한 코드이다. feedAnimal 함수를 확인해 보자
  • some Animal 타입으로 시작하여 AnimalFeed 프로토콜을 따르는 FeedType을 얻는다. 그리고 이것의 CropType을 grow()를 통해 얻을 수 있다. 이때 이번에는 중첩 연관 타입을 얻는 대신 동물에게 필요한 정확한 CropType을 얻게 된다.
  • 마찬가지로 이것을 Harvest하면 정확한 FeedType을 구할 수 있고 동물에게 먹이면 된다!

 

다이어그램 정리

Cow, Hay, Alfalfa와

Chicken, Scratch, Millet이 구성하는 2가지 세트를 요약한 다이어그램이다.

3개의 프로토콜의 3가지 구체 타입의 각 세트 간의 관계를 정확히 모델링하고 있다! ⭐️

 

데이터 모델을 정확히 이해하면 동일 타입 요구 사항을 사용해 중첩된 서로 다른 연관 타입 간의 동일성을 정의할 수 있다.

이를 통해 프로토콜 요구 사항에 대한 여러 개의 호출을 연결하는 제네릭 코드를 작성할 수 있다.

 

 

 

'iOS > Swift' 카테고리의 다른 글

Creating Swift Packages  (1) 2023.06.07
Getting to Know Swift Package Manager  (0) 2023.05.28
ARC in Swift: Basics and beyond  (0) 2023.04.13
Understanding Swift Performance  (2) 2023.03.13
Explore structured concurrency in Swift  (0) 2023.02.11

댓글