TIL(230416)

Youth·2023년 4월 16일

WWDC 영상 정리(Embrace Swift generic)

Generic

  • 제네릭은 Swift에서 추상 코드를 작성하는 기본 도구로 코드가 진화할수록 복잡성을 관리하는 데 매우 중요하다
  • 코드의 양이 늘어남에 따라 증가하는 복잡성을 제어하기 위해 필수적

Abstraction

  • 추상화는 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것
  • 추상화는 아이디어를 구체적인 세부사항과 구분한다.
let startRadians = startAngle * .pi / 180.0
let endRadians = endAngle * .pi / 180.0
  • 예를들어 위와같은 코드를 쓸때 동일하게반복되는부분을 함수로 빼고싶은 느낌이든다. 즉, 공통적인 기능이나 value 가 여러 번 나타날 때, 아래와 같이 메소드나 변수로 추상화 시키는 것이다.
func radians(from degrees: Double) -> Double {
  degrees * .pi / 180.0
}

let startRadians = radians(from: startAngle)
let endRadians = radians(from: endAngle)
  • 위와 같이 radians 메소드로 추상화시킴으로써, 구체적인 수치를 반복할 필요없이 원하는 functionality 를 제공받을 수 있다.
  • 추상화를 사용하는 코드는 세부 정보를 반복하지 않고 현재 일어나고 있는 일에 대한 아이디어를 표현할 수 있다.

Abstraction in swift

<T> where T: Idea
  • Swift 에서 Concrete Type 을 추상화할 수 있다.
  • 여기서 Contcrete Type이란 구체화된 타입을 말하고 아래와 같은 경우 number의 타입이 Int로 구체화가 되었기때문에 이런경우 concrete type이라고 할 수 있다.
let number: Int = 3
  • 같은 아이디어를 가지지만 다른 구현 방법을 가진 여러 가지 타입이 존재할 때, 위와 같이 generic 을 활용해 모든 Concrete Type 에서 동작하는 abstract code 를 작성할 수 있다.

이제부터 아래 예제에서 swift 5.7 Generic 을 활용하는 방법을 알아보자!

  1. 먼저 Concrete Type 으로 구현된 Model 들을 살펴볼 것이다
  2. Concrete Type 들의 공통적인 부분을 찾아보자
  3. Interface 를 만들어보자
  4. Generic code 를 작성해보자

##WWDC영상에서 제공하는 코드 : 농장 시뮬레이션을 위한 코드
스크린샷 2023-04-16 오후 12 51 48

Model with concrete types

struct Cow {
    func eat(_ food: Hay) {}
}
struct Hay {
    static func grow() -> Alfalfa {
        return Alfalfa()
    }
}
struct Alfalfa {
    func harvest() -> Hay {
        Hay()
    }
}
struct Farm {
    func feed(_ animal: Cow) {
        let alfalfa = Hay.grow()
        let hay = alfalfa.harvest()
        animal.eat(hay)
    }
}
  • 농장(Farm)이 있다고 생각해보자. 농장엔 소(Cow)가 있고, 소에게 먹일 곡물(Hay), 그리고 곡물을 수확할 식물(Alfalfa)이 있다.

  • feed - 식물(Alfalfa)에게서 곡물(Hay)을 수확하고, 수확한 곡물을 동물인 소(Cow)에게 먹임으로써 우리는 농장을 운영할 수 있다.

  • 그러나 소말고도 아래와 같이 말, 닭과 같은 동물을 농장에서 추가적으로 기르려고 하면 어떻게 할까?

struct Cow {
    func eat(_ food: Hay) {}
}
struct Horse {
    func eat(_ food: Carrot) {}
}
struct Chicken {
    func eat(_ food: Grain) {}
}
  • 그러기 위해선 위와 같이 Horse, Chicken 을 나타내는 구조체를 추가로 선언하고, 각 동물에게 먹일 음식들도 추가적으로 선언해야 한다.
  • 또한 농장(Farm) 에서 모든 동물에게 먹이를 먹일 수 있는데, 이것을 코드로 나타내면 엄청난 양의 Boiler Plate 코드가 탄생한다.
  • 이때 여러분이 반복적인 구현으로 오버로드를 작성하고 있다면 일반화하라는 신호일 수 있습니다라고 이야기한다
struct Farm {
    func feed(_ animal: Cow) {
        let alfalfa = Hay.grow()
        let hay = alfalfa.harvest()
        animal.eat(hay)
    }

    func feed(_ animal: Horse) {
        let root = Carrot.grow()
        let carrot = root.harvest()
        animal.eat(carrot)
    }

    func feed(_ animal: Chicken) {
        let wheat = Grain.grow()
        let grain = wheat.harvest()
        animal.eat(grain)
    }
}

Identify common capabilities

  • 말과 닭, 소와 같은 동물 타입의 구조체들을 선언했을 때, 동물들은 모두 어떤 음식을 먹을 수 있는 eat 메소드를 가지고 있는 것을 확인할 수 있었다.

  • 각 동물들은 서로 다른 음식을 먹고, 또한 음식을 먹는 방법 또한 다를 것이다.

  • 우리는 여기서 eat 메소드를 abstract code 로 작성할 수 있고, eat 메소드가 정의된 concrete type 에 따라 다르게 동작하도록 정의할 수 있다.

  • abstract code 가 서로 다른 concrete type 에서 다르게 동작하는 것을 polymorphism (다형성) 이라고 한다.

Polymorphism - 다형성

  • 추상 코드가 먹기 메서드를 호출하도록 허용하고 추상 코드가 작동하는 구체적인 유형에 따라 다르게 동작하도록 구축하려고 한다. 다양한 구체적인 유형에 대해 추상 코드가 다르게 동작하는 능력을 ‘다형성’이라고 하고 다형성을 통해 코드 한 개는 사용되는 방식에 따라 여러 가지 동작을 가질 수 있다
  • 중요한 부분은 다형성을 통해 코드 한 개는 사용되는 방식에 따라 여러 가지 동작을 가질 수 있습니다라는것이다

다형성의 다양한 형태
1. function overloading(함수 오버로딩)

  • polymorphism 이라고 불린다
  • argument type 에 따라 같은 메소드 호출이 다른 의미를 가질 때를 말한다.
  • 일반적인 해결책은 아니다
  • Subtype(하위 유형 다형성)
    • Super Type 의 코드가 특정 subtype 에서 다른 동작을 할 때를 말한다.
  • Parametric(매개변수 다형성 - 제네릭 사용)
    • Generic 에 의해 형성되는 polymorphism
    • Generic code 는 타입 파라미터를 사용한다.
    • 타입 파라미터를 통해 여러 가지 타입에서 사용되는 하나의 코드 덩어리를 작성할 수 있다.
    • concrete type 들은 제네릭 타입의 argument 로 사용될 수 있다.

Subtype polymorphism (서브타입 다형성) 을 이용해 위와 같은 문제를 해결해보자

  • Subtype 관계를 표현하는 방법으로 class hierarchy (클래스 계층구조) 가 있다.
  • Animal class 를 선언하고, 모든 동물이라면 기본적으로 가져야할 eat 메소드를 선언해보자
class Animal {
    func eat(_ food: ???) {fatalError("Subclass must implement `eat`")}
}
  • 위의 코드는 Animal이라는 클래스가 eat이라는 함수를 가지고있고 이를 상속받는 함수는 이 함수를 override할수있게된다 하지만 상속받는 클래스의 특징에 따라 food가 다르기 때문에 이부분을 해결해야한다는 문제를 가지고있다, 여기서는 구체적인 타입이 될수가 없구나 하는정도만 이해하고 우선은 이대로 넘어간다

  • 그 다음 구조체로 선언된 각 동물들을 클래스 타입으로 바꾸고, Animal 슈퍼클래스를 상속시키자

  • Animal 슈퍼클래스를 상속한 동물 클래스들에서 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) {}
}
  • 이제 모든 동물 타입을 표현할 수 있는 abstract-base class 인 Animal 을 가지고 있다.
  • Animal type 의 eat 메소드를 호출하는 것은, subtype polymorphism 을 사용해 subclass implementation 을 호출할 것이다.

위와 같은 subtype polymorphism 의 단점

  • 각 동물은 서로 다른 타입의 음식을 먹는다. 이것은 클래스 계층 구조로 표현하기 정말 어렵다

  • 인스턴스 간 상태를 공유하고 싶지 않아도 강제로 Reference 타입인 class 타입을 사용해야 한다.

  • super class 의 method 를 재정의하지 않으면, 런타임 이전까지 에러를 알지 못한다. 왜냐면 class의 함수는 direct dispatch방식이 아니라 table dispatch방식이기때문에 런타임이 되서야 함수를 알 수 있게된다. 컴파일시점에서는 알수없기때문에 문제가 발생할 수 있다

  • 각 동물이 서로 다른 타입의 음식을 먹는 것을 어떻게 표현할 수 있을까?

첫 번째 방법: Any 사용하기

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)") }
    }
}
  • Any 로 모든 Food 타입을 받도록 강제할 수 있다.
  • 그러나 런타임에 정확한 타입이 넘어왔는지 확인하도록 하위타입 implementation 에 너무 의존적이다
  • 매개변수로 Food 가 아닌 타입을 넘길 수 있고, 이것은 또 다른 버그로 이어질 수 있다.

두 번째 방법: Generic 사용하기

class Animal<Food> {
    func eat(_ food: Food) {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) {
        ...
    }
}
struct Hay {
    static func grow() -> Alfalfa {
        return Alfalfa()
    }
}
  • 타입 파라미터를 사용해 각 서브타입에서 음식으로 지정할 타입에 대해 플레이스홀더를 제공해줄 수 있다.
  • 이와 같은 방법으로 Animal 의 서브 클래스를 선언할 때 항상 Food 타입 파라미터에 들어갈 타입을 제공해주어야 한다.
  • 그러나 음식을 먹는 것이 animal 의 core purpose 가 아니고, animal 과 관련된 많은 코드가 Food 타입과 관계없이 돌아갈 것이다.
  • 또한 Animal 에 여러가지 기능이 추가된다고 생각해보자. 가령 Animal 에서 나오는 상품이나 (Commodity) 서식지(Habitat)등의 정보가 추가적으로 필요한 경우 아래와 같이 Animal 에 더 많은 타입 플레이스 홀더가 들어간다. 정말 끔찍하다. 그러면 Animal 의 서브타입을 정의할 때 마다 더 많은 타입 파라미터를 지정해주어야 한다.
class Animal<Food, Habitat, Commodity>

Build Interface

  • Animal 은 두 개의 공통적인 특성을 지니고 있다
    • 각 동물에게는 특정 먹이 유형이 있고 그 음식 중 일부를 소비하는 작업도 있다
  • 위 두 가지 공통적인 특성을 나타내기 위해 swift 에서 protocol 을 사용할 수 있다.

Protocol

스크린샷 2023-04-16 오후 1 09 05
  • protocol 은 conforming type 의 기능을 설명하는 추상화 도구이다.
  • protocol 을 사용하여 기능을 정의할 수 있고, 또한 기능과 실제 구현을 분리할 수 있다.
  • Subclass polymorphism 과 다르게, class 에 국한되어 있지 않고 enum, actor, struct 등의 다양한 타입에 사용될 수 있다.

Protocol 을 사용하여 Animal 을 표현해보자

protocol Animal {
  associatedtype Feed: AnimalFeed
  func eat(_ food: Feed)
}
  • associatedtype 은 type parameter 와 마찬가지로 concrete type 을 위한 플레이스홀더 역할을 한다.

  • associatedtype 은 protocol 을 conform 하는 타입에 의존적이다. (무슨 말인지 모르겠다면 아래에서 추가적인 예시를 보도록 하자)

  • eat 메소드는 associatedtype 으로 정의된 Feed 타입을 받는 메소드

  • protoocl 은 위와 같이 청사진만 제공해줄 뿐 실제 구현을 하진 않는다.

Protocol 을 conform 하는 Animal 타입들을 구현해보자.

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) { ... }
}
  • 채택하는 곳에서 associatedtype이 선언된 제네릭 프로토콜을 쓸 때면 typealias로 해당 타입을 구체화 해줘야한다(하지만 여기서는 안했음 타입앨리어스없이도 충분이추론가능한 경우엔 생략가능하기때문)
  • protocol 을 confrom 하면, 컴파일러가 해당 concrete Type 이 protocol 의 요구사항을 만족하는지 검사한다.
  • associatedType 이 사용된 eat 메소드의 구현부를 보고, 컴파일러가 conform type 에 사용된 associatedType 을 추론한다. (위의 경우엔 각 Animal conform type 이 eat 함수에서 사용한 Hay, Carrot, Grain 타입)

Write a generic code

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

struct Farm {
  func feed(_ animal: ???) {...}
}
  • Animal protocol 로 추상화를 완료했기 때문에, 이제 농장에선 어떤 Animal 이던 하나의 함수로 먹이를 줄 수 있다.
  • Parametric polymorphism 을 이용해 feed 함수를 재정의해보자.
struct Farm {
  func feed<A>(_ animal: A) where A: Animal {...}
  // or
  func feed<A: Animal>(_ animal:A) {...}
}
  • 클래스, 구조체, enum 은 물론 함수에서도 타입 파라미터를 정의할 수 있다.
  • 또한 타입 파라미터의 제약조건을 줄 수 있는데, 위에선 A 라는 타입을 받는다고 명시해주었고, 또한 A는 Animal protocol 을 conform 해야 한다는 제약조건을 지정해주었다.
  • where clause 절, 또는 <A: Animal> 과 같은 형식으로 제약 조건을 지정해 줄 수 있다.
  • 이제 함수 feed 는 Animal protocol 을 conform 하는 모든 concrete Type 에 대해 사용할 수 있다.

타입 파라미터 더욱 간소화하기

func feed(_ animal: some Animal)
  • some Animal 문법으로 타입 파라미터나 where clause 절에 비해 코드가 간소화된 걸 확인할 수 있다.
  • swift 5.7 에서 some 키워드와 any 키워드에 대해 변화가 생겼다. 지금부터 some 키워드에 대해서 알아보자

What is some?

func feed(_ animal: some Animal)
  • some 키워드는 특정한 conform type 을 반환하거나 받을 때 사용할 수 있다.
  • SwiftUI 에서 View 구조체를 정의할 때 우리는 항상 some View 를 반환하는 body 프로퍼티를 정의한다.
  • some 키워드는 항상 conformance requirement 앞에 적힌다.
  • placeholder 타입을 나타내는 abstract type (some 과 같은) 은 opaque type (불명확 타입) 이라고 불린다.
  • 그리고 이런 불명확 타입에 의해 대체되는 concrete type 은 underlying type 이라고 불린다.
  • 하나의 opaque type 에 대응하는 underlying type 은 해당 opaque type 이 사용되는 범위 내에서 항상 동일하다.
func feed<A: Animal>(_ animal: A)
func feed(_ animal: some Animal)
  • 위 두 가지 함수는 모두 opaque type 을 선언한다.
func getValue(Parameter) -> Result
  • opaque type 은 input 과 output 모두에 사용될 수 있다.

  • func getValue(Parameter) 같은 함수에서 타입 파라미터는 모두 input side 에 작성되는데, 따라서 함수를 호출하는 쪽에서 underlying type 을 결정한다.

  • 보통 값을 넣어주는 쪽이 underlying type 을 결정하고, 값을 사용하는 쪽이 opaque type 과 같은 abstract type 을 바라본다.

Inferring the underlying type for some

let animal: some Animal = Horse()
  • 위와 같은 지역변수에서, underlying type 은 대등호 우측에 있는 concrete type 에 의해 추론된다.
  • 따라서 opaque type 을 가지는 지역 변수는 항상 초기값을 가지고 있어야 하며, 만약 초기값을 제공하지 않는다면 컴파일러는 에러를 보고한다.
  • 아까도 말했듯이 opaque type 의 underlying type 은 해당 값의 사용 범위 내에서 항상 고정되어야 하며, 만약 바꾸려는 시도가 있으면 컴파일러는 에러를 보고한다.
var animal: some Animal = Horse()
animal = Cow() // Compiler Error 발생! underlying type 이 이미 Horse 로 결정된 상태에서 Cow 로 바꾸려고 시도하기 때문이다.

타입 파라미터는 언제 사용하는게 좋을까?

  • 하나의 opaque type 을 여러 번 명시해야 할 때, 타입 파라미터를 사용하는게 좋다. 다음과 같은 예시를 보자
struct Silo<Material> {
  private var storage: [Material]
  
  init(storing materials: [Material]) {
    self.storage = materials
  }
}

var hayStorage: Silo<Hay>
  • 위 코드에서 Material 이라는 opaque type 은 Silo 구조체 내부에서 여러 번 명시된 것을 확인할 수 있다. 이럴 땐 제네릭으로 타입 파라미터를 지정해 주는 것이 훨씬 편할 것이다.

이제 다시 generic code 를 작성하러 돌아가자

feed 함수 다시 작성하기

protocol Animal {
  associatedtype Feed: AnimalFeed // <- grow static function 을 가지고 있는 추상화 프로토콜 이라고 생각하자.
  func eat(_ food: Feed)
}

struct Farm {
  func feed(_ animal: some Animal) {
    let crop = type(of: animal).Feed.grow() // Animal 의 associatedtype에 접근하기 위해 type(of:) 를 사용할 수 있다.
    let produce = crop.harvest() // 작물로부터 곡물을 획득한다.
    animal.eat(produce) // opaque animal type 에 곡물을 먹인다.
  }
}
  • opaque type 인 animal 의 underlying type 은 고정되어 있기 때문에, type(of:) 를 사용해 Animal 이 가지고 있는 associatedtype 에 접근할 수 있다.
  • associatedtype 인 Feed 타입도 underlying type 에 의해 추론되므로, 자유롭게 사용할 수 있다.
  • 이것은 모두 underlying type(Animal 의 concrete type) 이 하나의 타입으로 고정되어 있기 때문에 가능하다. 컴파일러는 animal type, plant type, product type 간의 모든 관계를 알고 있다.
  • 이러한 관계들은 우리가 동물에게 잘못된 먹이를 주는 것을 근본적으로 방지한다.
func feed(_ animal: some Animal) {
  let crop = type(of: animal).Feed.grow()
  let produce = crop.harvest()
  animal.eat(Hay.grow().harvest()) // 만약 올바르지 않은 Feed 타입을 먹이려고 한다면, 컴파일러에 의해 에러가 발생한다.
}

마지막으로 모든 동물에게 먹이를 주는 feedAll 함수를 구현해보자!

struct Farm {
  func feed(_ animal: some Animal) {
    let crop = type(of: animal).Feed.grow()
    let produce = crop.harvest()
    animal.eat(produce)
  }
  
  func feedAll(_ animals: [some Animal]) {
    
  }
}
  • opaque type 의 underlying type 은 항상 특정한 하나의 타입으로 고정되야 한다고 계속 언급했었다.
  • 위에서 animal 배열의 underlying type 도 항상 하나로 고정되어야 한다고 했다. 따라서 배열의 모든 원소는 똑같은 타입을 가지고 있어야 한다.
  • 서로 다른 동물을 모두 담을 수 있는 배열을 정의해야한다
    스크린샷 2023-04-16 오후 1 26 11

any 키워드

func feedAll(_ animals: [any Animal]) {
  
}
  • [some Animal] 을 [any Animal] 으로 바꿔보자!
  • any 키워드는 여러 가지 종류의 Animal 타입을 배열에 담을 수 있고, underlying type 또한 런타임에 실시간으로 변경될 수 있다는 것을 알려주는 키워드이다.

  • any 키워드 역시 conformance requirement 의 앞에 작성한다.

  • any 키워드를 통해 여러가지 concrete animal type 을 저장할 수 있는데, 이것을 통해 우리는 값 타입에서 subtype polymorphism 을 구현할 수 있다.

스크린샷 2023-04-16 오후 1 26 47
  • any 키워드를 사용해 여러 concrete type 을 하나의 표현식으로 작성하는 것을 type erasure라고 표현한다. type erasing 을 통해 컴파일 타임에선 concrete type 에 대해 알지 못하고, 런타임에 concrete type 에 대해 알게 된다.

다시 feedAll 메소드로 돌아가자!

  • 우선 다양한 concrete type 을 가지는 any Animal 배열을 선언했으니, 해당 배열을 iterate 하자.
  • 그 다음 iterate 하는 각 동물에게 먹이를 주기 위해 아래와 같이 Animal 프로토콜의 eat 메소드를 직접 호출해보자!
func feedAll(_ animals: [any Animal]) {
  for animal in animals {
    animal.eat(food: Animal.Feed) // 이 부분에서 컴파일 에러가 발생한다!
  }
}
  • 위 코드처럼 Animal 프로토콜의 eat 메소드를 직접 호출하면 컴파일 타임에 에러가 발생하는 것을 알 수 있다. 이유가 무엇일까?
  • 우리는 animals 에 type erasure 를 사용하면서 컴파일러 타입이 underlying type 을 하나로 고정할 수 없다. 즉 Animal 이 가지고 있는 associatedtype 인 Feed 등의 타입 relationship 도 전부 해제된 상태인데, 따라서 컴파일 타임에 Animal Feed 타입이 어떤 concrete type 을 가지고 있는지 전혀 알 수가 없다.
  • 따라서 우리는 각 animal 을 다시 underlying type 이 하나로 고정되는 context 로 옮겨야 한다. 이를 위해서 코드를 아래와 같이 수정해보자
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) // any Animal 을 some Animal 으로 넘겨서 underlying type 을 unboxing 한다.
  }
}
  • any Animalsome Animal 은 서로 다른 타입이다.
스크린샷 2023-04-16 오후 1 30 34
  • 그러나 위 코드와 같이 any Animal 의 underlying type 을 some Animal 을 통해 unboxing 할 수 있다. 즉 underlying type 이 하나로 고정되지 않은 any Animal 타입을 some Animal 타입으로 넘겨줌으로써 underlying type 을 꺼낸 것이다.
profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글