꿈돌이랜드

[WWDC] Embrace Swift generics 본문

Programming/WWDC

[WWDC] Embrace Swift generics

loinsir 2023. 9. 11. 18:06
반응형

WWDC22 - Embrance Swift generics

Embrace Swift generics - WWDC22 - Videos - Apple Developer
Generics are a fundamental tool for writing abstract code in Swift. Learn how you can identify opportunities for abstraction as your code...
https://developer.apple.com/videos/play/wwdc2022/110352/

추상화

  • 아이디어를 특정 세부 사항과 분리.
  • 추상화 형태 중 하나는 코드를 함수나 지역 변수로 분해하는 것.

Model with concrete types

농장을 시뮬레이션하기 위한 코드를 작성해 나가는 것으로 시작

  • Cow에는 Hay 타입을 받는 eat 메서드가 존재

struct Cow {
  func eat(_ food: Hay) { ... }
}
  • Hay는 또다른 구조체
  • Hay를 생산하는 Alfalfa구조체를 리턴하는 정적 메서드 grow가 존재
struct Hay {
  static func grow() -> Alfalfa { ... }
}
  • Alfalfa 구조체는 건초를 수확하는 메서드가 존재
struct Alfalfa {
  func harvest() -> Hay { ... }
}
  • 마지막으로 Cow에게 먹이를 주는 메서드가 있는 Farm 구조체
struct Farm {
  func feed(_ animal: some Animal) {
    let crop = type(of: animal).Feed.grow()
    let produce = crop.harvest()
    animal.eat(produce)
  }

  func feedAll(_ animals: [any Animal]) {
    for animal in animals {
      feed(animal)
    }
  }
}

  • 소에게 사료를 주기 위해서는 Alfalfa를 재배하여 건초 Hay를 생산하고 수확한 다음, Farm 구조체에서 소에게 먹이는 순서로 구현할 수 있다.
  • 이 상황에서 더 많은 종류의 동물들을 추가하고 싶을 때?
    struct Farm {
      func feed(_ animal: Cow) {...}
      func feed(_ animal: Cow) {...}
      func feed(_ animal: Cow) {...}
    }
    
    farm.feed(Cow())
    farm.feed(Horse())
    farm.feed(Chicken())
  • 위 처럼 각 매개변수 타입을 개별적으로 허용할 수 있도록 오버로딩을 구현할 수 있지만, 각 오버로드 메서드는 구현이 매우 유사
  • 이와 같이 반복적인 구현으로 오버로드가 되는 경우, 일반화해야한다는 신호가 될 수 있다.

Identify common capabilities

  • 동물 타입간의 공통 능력을 식별
    struct Cow {
      func eat(_ food: Hay) {
      }
    }
    
    struct Horse {
      func eat(_ food: Carrot) {
      }
    }
    
    struct Chicken {
      func eat(_ food: Grain) {
      }
    }
  • 각 동물 타입은 먹는 타입이 다르므로, eat 메서드를 구현할 때마다 그 동작이 다르다
  • 우리가 원하는 건, 추상 코드가 eat 메서드를 호출하고, 해당 추상 코드가 동작 중인 concrete type에 따라 다르게 동작하도록 하는 것.

Polymorphism

  • 다양한 concrete type에 대해 다르게 동작하는 추상 코드의 능력을 일컫는 말
  • 다형성을 이용해 코드 사용 방법에 따라 하나의 코드로 여러 동작을 하게 할 수 있다.

다형성은 여러 형태로 존재할 수 있다.

  • 오버로딩
    • 일반적인 해결책은 아니기에 “임시 다형성”으로 불린다.
  • 하위 타입 다형성
    • 상위 타입에서 동작하는 코드는 코드가 런타임에 사용하는 특정 하위 타입에 따라 다른 동작을 가질 수 있다.
  • 매개변수 다형성
    • 제네릭을 사용한 방법
    • 제네릭 코드는 타입 매개변수를 통해 다양한 타입에서 작동하는 하나의 코드를 작성할 수 있도록 하며 구체적인 타입 자체는 인자로 사용되게 된다.

하위 타입 다형성

  • 각 동물 구조체들을 클래스 타입으로 변형하고 Animal 슈퍼 클래스를 선언, 각 동물 클래스에서 상속
  • eat메서드를 각 동물 클래스에서 재정의
    class Animal {
      func eat(_ food: ???) { fatalError("Subclass must implement 'eat'") }
    }
    
    class Cow: Animal {
      override func eat(_ food: Hay) {
      }
    }
    
    class Horse: Animal {
      override func eat(_ food: Carrot) {
      }
    }
    
    class Chicken: Animal {
      override func eat(_ food: Grain) {
      }
    }
  • 이 코드에서는 몇 가지 문제가 존재
    1. 클래스를 사용되어 reference semantics를 강요하게 된다.
    1. 하위 클래스에서 슈퍼 클래스의 메서드를 오버라이드 하는 것을 잊으면 런타임까지 알 방법이 없다.
    1. 더 큰 문제는 각 하위 타입이 서로 다른 유형의 음식을 먹게 되면서, 종속성을 클래스 계층 구조로 표현하기가 어렵다는 것
      • 상위 타입의 eat 메서드의 인자를 Any와 같은 less specific한 타입을 허용하게 하여 해결할 수 있다.
        class Animal {
          func eat(_ food: Any) { fatalError("Subclass must implement 'eat'") }
        }
        
        class Cow: Animal {
          override func eat(_ food: Any) {
            guard let food = food as? Hay else { fatalError("Cow cannot eat \(food)") }
          }
        }
        
        class Horse: Animal {
          override func eat(_ food: Any) {
            guard let food = food as? Carrot else { fatalError("Horse cannot eat \(food)") }
          }
        }
        
        class Chicken: Animal {
          override func eat(_ food: Any) {
            guard let food = food as? Grain else { fatalError("Chicken cannot eat \(food)") }
          }
        }
      • 하지만 런타임에 올바른 유형이 전달되는지 확인하려면 하위 클래스에 의존하게 되어 또다른 문제를 야기하게 된다
      • 대신 Animal 슈퍼 클래스에 타입 매개변수를 추가해서 타입이 안전한 방식으로 동물의 사료 타입을 표시하도록 변경
        class Animal<Food> {
          func eat(_ food: Any) { fatalError("Subclass must implement 'eat'") }
        }
        
        class Cow: Animal<Hay> {
          override func eat(_ food: Hay) {
            
          }
        }
        
        class Horse: Animal<Carrot> {
          override func eat(_ food: Carrot) {
        
          }
        }
        
        class Chicken: Animal<Grain> {
          override func eat(_ food: Grain) {
            
          }
        }
      • 해결을 되었지만… 음식을 먹는 것이 동물의 핵심은 아니고, 해당 타입과 함께 작동하는 다른 코드들은 음식 타입에 전혀 상관이 없기에 약간은 부자연스러워 보인다.
      • 근본적으로 클래스가 데이터 타입이고, 슈퍼클래스를 복잡하게 만들어서 concrete type에 대한 추상적인 아이디어를 표현하려고 한다.

    Common capabilities of an animal

    • 특정한 음식 타입
    • 음식을 일부 소비하는 작업

    Build an interface

    프로토콜을 사용하여 타입이 수행하는 작업에 대한 아이디어를 구현 세부 정보와 분리할 수 있다.

    protocol Animal {
      associatedType Feed: AnimalFeed
      func eat(_ food: Feed)
    }
    
    struct Cow: Animal {
      func eat(_ food: Hay) {
      }
    }
    
    struct Horse: Animal {
      func eat(_ food: Carrot) {
      }
    }
    
    struct Chicken: Animal {
      func eat(_ food: Grain) {
      }
    }

    Write generic code

    protocol Animal {
      associatedtype Feed: AnimalFeed
      func eat(_ food: Feed)
    }
    
    struct Farm {
      func feed<A>(_ animal: A) where A: Animal { ... }
    }
    • 위 상황에서 타입 매개변수 A에 대해서, 꺾쇠 괄호 안에 작성하거나 where 절로 뒤에 작성하여 프로토콜 적합성을 정할 수 있다.
    func feed<A>(_ animal: A) where A: Animal { ... }

    Inferring the underlying type for some

    • 이 generic 메서드 패턴은 일반적이지만, 타입 매개 변수를 명시적으로 쓰는 대신에 이 추상 타입을 프로토콜 적합성의 관점에서 some Animal이라고 적어서 표현할 수 있다.
    • 이렇게 하면 매개 변수 선언에 바로 해당 매개 변수에 대한 의미를 포함할 수 있다.
    func feed(_ animal: some Animal)

    이러한 some 키워드는 함수의 매개 변수 및 리턴 타입에 사용할 수 있다.

    리턴 타입에 지정하는 경우, 해당 리턴 값을 사용하는 쪽에서 해당 타입의 구체적인 타입이 무엇인지 알 필요가 없다.

    이렇게 특정한 구체적인 타입의 placeholder를 나타내는 추상 타입을 불투명 타입(opaque type)이라고 한다.

    let animal: some Animal = Horse()
    • 바탕 타입(underlying type): opaque type의 실제 타입
    • 바탕 타입은 값에서 추론되므로, opaque type의 로컬 변수는 항상 초기 값을 가져야 한다. 그렇지 않으면 컴파일러에서 오류가 발생한다.
    • 지정 이후에는 바탕 타입이 고정되므로, 바탕 타입을 변경하려고 해도 오류가 발생한다.
      var animal: some Animal = Horse()
      animal = Chicken()
    • opaque 리턴 타입의 경우 바탕 타입은 구현의 반환 값에서 추론된다.
      func makeView(for farm: Farm) -> some View {
        FarmView(farm)
      }
      • 중요한 점은 해당 메서드는 전역적으로 어느 곳에서든 호출 할 수 있으므로, 호출하는 곳의 모든 곳에서 리턴 바탕 타입이 동일해야 한다. 그렇지 않으면 컴파일러에서 오류가 발생한다.
        func makeView(for farm: Farm) -> some View { // compiler error
          if condition {
            return FarmView(farm)
          } else {
            return EmptyView()
          }
        }
      • 다만, SwiftUI에서는 @ViewBuilder 를 통해서 위와 같은 상황을 유연하게 처리 할 수 있다.
        @ViewBuilder
        func makeView(for farm: Farm) -> some View {
          if condition {
            FarmView(farm)
          } else {
            EmptyView()
          }
        }

  • 이번엔 여러 동물들에게 먹이를 주는 feedAll메서드를 추가하려고 한다
    protocol Animal {
      associatedtype Feed: AnimalFeed
      func eat(_ food: Feed)
    }
    
    struct Farm {
      func feed(_ animal: some Animal) {
        let crop = type(of: animal).Feed.grow()
        let produce = crop.harvest
        animal.eat(produce)
      }
    
      func feedAll(_ animals: [???]) { }
    }
  • 이 상황에서 인자 배열의 각 요소가 Animal 프로토콜을 따라야 한다는 것은 알지만, 서로 다른 Animal 타입을 인자로 넣고 싶을 때는 some을 사용할 수 없다.
  • 왜냐하면 underlying type, 즉 바탕 타입이 고정되기 때문이다. 따라서 배열의 모든 요소가 동일한 유형을 가져야만 한다.
  • 이 상황에서 any 키워드를 사용해서 모든 Animal 타입을 대표하는 타입을 표현할 수 있다.
  • any 키워드는 이 타입이 임의의 Animal 타입을 저장할 수 있으며 바탕 타입이 런타임에 달라질 수 있음을 나타낸다
  • some 키워드와 마찬가지로 적합성 요구 사항 뒤에 나온다.
  • 모든 Animal 타입은 모든 구체적인 Animal 타입을 동적으로 저장할 수 있는 단일 정적 타입으로 값 타입을 가진 하위 타입 다형성을 사용할 수 있다.
  • 어떻게 가능할까?
  • 이런 유연한 저장을 가능케 하기 위해서 모든 동물 타입은 메모리에 특별한 표현을 가진다.
  • 이런 표현은 상자와 같이 생각 될 수 있다.
  • 모든 구체적인 동물 타입을 동적으로 저장할 수 있는 정적 유형의 모든 동물은 existential type(실존 타입) 이라고 한다.
  • 다른 구체적인 타입에 대해 동일한 표현을 사용하는 전략을 type eraser(타입 소거) 라고 한다
  • 구체적인 타입은 컴파일시 ‘소거’되고, 런타임에만 알 수 있다.
  • 이러한 type eraser를 통해 서로 다른 값 사이의 타입 수준의 구분을 제거하여 서로 다른 동적 타입을 가진 값을 동일한 정적 타입으로 바꾸어 사용할 수 있다.
  • 그렇기 때문에 feedAll 메서드에서 원하는 유형의 서로다른 타입을 가지는 배열을 작성할 수 있다.
  • 이러한 기능은 Swift 5.7의 새로운 기능이다.
    protocol Animal {
      associatedtype Feed: AnimalFeed
      func eat(_ food: Feed)
    }
    
    struct Farm {
      func feed(_ animal: some Animal) {
        let crop = type(of: animal).Feed.grow()
        let produce = crop.harvest
        animal.eat(produce)
      }
    
      func feedAll(_ animals: [any Animal]) { 
        for animal in animals {
          animal.eat(food: Animal.Feed) // compile error
        }
      }
    }
  • 그러나 위와 같이 각 animal에서 eat메서드를 실행하려 하면 컴파일에러가 발생하는데, type eraser를 통해 특정 동물 타입 간의 타입 수준의 구분을 제거했기 때문에 특정 동물 타입에 따라 달라지는 모든 associatedtype도 제거 했기 때문이다.
  • 다시 특정 타입의 associatedtype에 의존하려면 특정 타입의 동물을 static context로 돌아가도록 해야하는데, 이를 언박싱 인수로 구현할 수 있다.
  • 이러한 언박싱 인수는 Swift5.7의 새로운 기능이다. 언박싱은 컴파일러가 상자를 열고 내부에 저장된 값을 꺼내는 행위라고 생각해볼 수 있다.
  • 일부 동물 매개 변수의 범위의 경우 값은 고정된 바탕 타입을 가지므로, associatedtype을 포함해서 바탕 타입에 대한 모든 작업에 접근할 수 있다.
    protocol Animal {
      associatedtype Feed: AnimalFeed
      func eat(_ food: Feed)
    }
    
    struct Farm {
      func feed(_ animal: some Animal) {
        let crop = type(of: animal).Feed.grow()
        let produce = crop.harvest
        animal.eat(produce)
      }
    
      func feedAll(_ animals: [any Animal]) { 
        for animal in animals {
          feed(animal)
        }
      }
    }

    Capabilities of some and any

    정리!

    • some
      • 사용하면 바탕 타입이 고정된다.
      • 제네릭 코드에 대해 바탕 타입 간의 관계를 보장한다.
      • 따라서 현재 작업중인 타입의 associatedtype까지 전체적인 접근이 가능하게 된다.
    • any
      • 임의의 구체 타입을 저장할 경우에 사용
      • type eraser를 사용하게 되어 타입 관계를 제거하게 된다.
      • 이를 통해 서로 다른 종류의 컬렉션 타입을 표현할 수 있다.
      • 추상화를 구현의 세부 정보로 만든다.

    일반적으로, 기본으로 some을 사용하고, 임의의 값을 저장해야 할 경우 some을 any로 변경한다. 마치, 일반 변수가 변경이 필요하다는 것을 알기 전까지 기본적으로 let으로 선언하는 것과 마찬가지로 보면 된다.


Uploaded by N2T

반응형