WWDC/Swift

[Swift] WWDC22 - Embrace Swift generics

유정주 2022. 9. 16. 01:21
반응형

서론

WWDC22는 모든 세션에서 한글 자막을 지원합니다.

한글 자막이 어떤지 궁금해서 요즘 공부 중인 Generic에 관련된 세션을 보고 정리해보았습니다.

한글 자막에 대한 후기도 마지막에 가볍게 말해볼게요.

 

Abstraction

 

추상화(Abstraction)에 대한 개념으로 영상이 시작됩니다.

 

아래 코드를 봅시다.

코드에서 * .pi / 180.0 이 중복됩니다.

중복되는 코드를 함수로 묶어 추상화가 가능하고 보일러 플레이트를 줄일 수 있습니다.

 

이번 포스팅(영상)에서는 농장 환경을 구성하는 코드를 예시로 들고 있습니다.

 

Model with concrete types

첫 번째로 Cow 구조체가 있습니다.

Cow 구조체는 eat 메서드를 가지고 있고 먹이로 Hay 구조체를 전달합니다. (Hay : 건초)

struct Cow {
    func eat(_ food:Hay) {...}
}

 

두 번째로 Hay 구조체가 있습니다. 

Hay 구조체의 grow 메서드는 건초가 성장하면 Alfalfa가 되는 것을 표현합니다.

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

세 번째로 Alfalfa가 구조체가 있습니다.

Alfalfa 구조체의 harvest 메서드는 수확되면 Hay가 되는 것을 표현합니다.

struct Alfalfa {
    func havest() -> Hay { ... }
}

마지막으로 Farm 구조체가 있습니다.

Farm 구조체는 feed 메서드를 통해 소에게 먹이를 주는 것을 표현했습니다.

struct Farm {
    func feed(_ animal: Cow) {
        let alfalfa = Hay.grow()
        let hay = alfalfa.harvest()
        animal.eat(hay)
    }
}

건초가 자라서 alfalfa가 되고, alfalfa를 수확해서 hay로 만듭니다.

소(animal)에게 수확한 hay를 먹이로 줍니다.

 

하지만 농장에는 소가 아닌 다른 동물이 있을 수 있습니다.

농부가 말과 닭도 키운다면 어떻게 될까요?

 

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 메서드는 모두 다른 먹이를 파라미터로 받습니다.

 

반복적인 코드를 줄이기 위해서는 다형성(polymorphism)을 이용해야 합니다.

다형성이란 코드 한 개를 이용해 여러 가지 동작을 가질 수 있는 것을 의미합니다.

Cow, Horse, Chicken에서 중복되는 eat를 하나의 코드로만 사용할 수 있다면 중복되는 코드를 줄일 수 있을 것입니다.

 

다형성은 세 가지가 존재합니다.

  1. 오버로드를 통한 ad-hoc pholymorphism
  2. Subtypes를 이용한 subtype polymorphism
  3. 제네릭을 이용한 parametric polymorphism

오버로드를 이용하면 같은 함수 호출을 파라미터 타입에 따라 동작을 다르게 할 수 있습니다.

하지만 일반적인 해결책이 아니므로 Ad Hoc 다형성이라고 합니다.

(Ad Hoc : 특정한 문제나 일을 위해 만들어진 관습적인 해결책 일반화할 수 없는 해결책 어떤 다른 목적에 적응시킬 수 없는 해결책)

 

Subtype 다형성은 클래스의 상속같은 다형성을 의미합니다.

supertype에 공통 부분을 작성하고 subtype에서 메서드 오버라이드를 하면 런타임에 다른 동작을 할 수 있습니다.

하지만 위 코드는 한 가지 문제가 있습니다.

Cow, Horse, Chicken의 food 타입이 모두 다르기 때문에 Animal 클래스의 eat 메서드 파라미터를 특정할 수 없습니다.

 

이럴 때 대충 모든 타입을 받을 수 있는 Any를 박아버리면...?

진짜 아무 타입이나 받을 수 있어서 전달 받은 타입이 우리가 원하는 타입인지 확인해야 할 것입니다.

 

이렇게요 ㅎㅎ;; 보기만 해도 어지럽죠?

보일러 플레이트를 줄이기 위해 보일러 플레이트가 생성되는... 뭔가 잘못되고 있다는 것을 바로 알 수 있습니다.

심지어 에러가 발생했을 때 컴파일 타임에 알 수 없고 런타임에 알 수 있습니다.

즉, 프로그램이 크래시가 나야 "아 에러가 났구나 ㅠ"라고 알 수 있어요.

 

다음으로 시도할 수 있는건 Type Parameter입니다.

슈퍼 클래스에 공통으로 사용할 타입을 정의합니다.

그리고 소, 말, 닭 클래스를 정의할 때 

Food에 해당하는 타입을 전달하면 됩니다.

Cow는 Hay 타입이 Food 타입으로 치환되어 동작합니다.

 

eat 메서드만 보면 훌륭한 코드같습니다.

하지만 동물은 먹기만 하는 존재가 아니죠.

클래스에는 사는 곳도 들어갈 수 있고 생산하는 물품(?)도 들어갈 수 있습니다.

이런 추가적인 정보가 들어갈 때마다 Type Parameter를 추가해줘야 합니다.

 

Build an interface

위 문제들은 프로토콜을 사용해 해결할 수 있습니다.

프로토콜을 이용해 수행하는 동작에 대한 아이디어를 세부 구현과 분리하는 것입니다.

 

위의 더러운 Animal을 프로토콜로 바꿔봅시다.

Animal에 associatedtype인 Feed를 선언했습니다.

associatedtype은 프로토콜 내부에 요구사항을 정의할 때 사용할 공통의 타입을 정의한 것입니다.

associatedtype은 특정 동물이 Animal을 채택할 때 항상 똑같은 먹이를 먹는 것을 보장합니다.

eat은 Feed를 파라미터로 받는 메서드입니다.

프로토콜에는 이 메서드의 구현 부분이 없으며 프로토콜을 채택하는 곳에서 구현합니다.

 

이제 각 동물 타입이 Animal 프로토콜을 채택하게 합니다.

프로토콜은 구조체, 열거형, actor에서도 채택할 수 있습니다.

즉, 이제 참조 타입이 아닌 값 타입으로 다형성을 유지할 수 있는 것이죠.

심지어 이제 컴파일러가 타입과 필수 구현 메서드를 체크해주고 문제가 있으면 컴파일 에러를 발생시켜 줍니다.

 

Write generic code

이제 제네릭 코드를 작성할 수 있습니다.

위에서 만든 Animal 프로토콜을 이용해 농장에서 feed(먹이를 주는) 메서드를 구현하려고 합니다.

이 feed는 모든 Animal에서 공통으로 사용할 수 있어야 해요.

 

이를 해결하는

첫 번째 방법은 제네릭 + where절입니다.

feed에 전달하는 A 타입과 animal 파라미터의 타입은 반드시 일치합니다.

또한 where절을 통해 A는 Animal 프로토콜을 채택하고 있다는 것을 보장하죠.

where절은 정교한 타입 지정이 가능하다는 장점이 있지만 실제 하는 일보다 더 복잡해보입니다.

위 코드만 보더라도 "A는 Animal을 채택함"을 표현하는데 과한 타이핑을 해야 합니다.

 

이때 사용할 수 있는 것이 "some" 입니다.

Type 프로퍼티를 명시적으로 쓰는 대신 추상적인 타입을 프로토콜 적합성의 관점에서 표현합니다.

some Animal은 animal의 타입으로 Animal 프로토콜을 준수하는 타입만 와야 한다는 것을 보장합니다.

some Animal은 where A: Animal과 완전히 동일한 동작을 하지만 불필요한 구문을 확 줄였습니다.

딱 봐도 훨씬 간단하죠?

 

some

추상적 타입을 더 자세히 살펴보겠습니다.

특정 concrete 타입의 placeholder를 나타내는 추상적 타입을 Opaque(불투명) 타입이라고 하고,

대체되는 특정 concrete 타입을 underlying(기본) 타입이라고 합니다.

 

Opaque 타입이 있는 값의 경우 값의 범위에 대해 Underlying 타입이 고정됩니다.

이렇게 하면 값을 사용하는 제네릭 코드는 항상 동일한 Underlying 타입을 얻을 수 있습니다.

some 키워드와 <> 안의 타입 파라미터를 사용하는 타입은 모두 Opaque 타입을 선언합니다.

 

Opaque 타입은 input과 output에 모두 사용할 수 있으므로 파라미터나 return 위치에 쓸 수 있습니다.

Opaque 타입은 프로그램의 추상 타입을 보고 프로그램의 어떤 부분이 구체적 타입을 결정하는지 결정합니다.

만약 아래처럼 Opaque 타입이 input 옆에 있다면,

호출 부분에서 underlying 타입을 결정하고 getValue의 구현에서 추상 타입을 사용합니다.

일반적으로 opaque 타입 또는 result 타입에 대한 값을 제공하는 프로그램 부분은 underlying 타입을 결정하고, 사용하는 프로그램 부분은 abstract 타입을 봅니다.

 

로컬 변수에서 underlying 타입은 할당 오른쪽에 있는 값에서 추론됩니다.

위 코드에서는 대입 기호(=) 오른쪽에 있는 Horse()에 의해 Animal이 추론됩니다.

그렇기 때문에 Opaque 타입은 항상 초기값을 가져야 합니다.

만약 초기값이 없으면 컴파일러가 컴파일 에러를 내뿜습니다.

또한, Underlying 타입이 일정하다는 것을 보장하므로 animal에 Horse가 아닌 다른 타입을 넣어도 컴파일 에러가 발생합니다.

 

some Animal을 input으로 받는 메서드인 feed는 아래처럼 생겼습니다.

func feed(_ animal: some Animal)

파라미터에 사용되는 some은 Swift 5.7의 새로운 기능이죠.

파라미터 범위에 대해서만 Animal 타입이 유지되면 되기 때문에

feed(Horse())
feed(Cow())
feed(Chicken())

이 코드가 가능합니다.

 

반환 위치에 사용되는 some 키워드는 구현부의 반환 값에서 타입이 추론됩니다.

위 연산 프로퍼티는 FarmView를 반환하므로 some View의 View는 FarmView 타입으로 추론됩니다.

 

이때 주의할 점은, return 타입은 모든 return문에서 동일해야 합니다.

즉 이렇게 어디에서는 FarmView 타입을, 어디에서는 EmptyView 타입을 반환할 수 없습니다.

이렇게 반환 타입이 다르면 타입 불일치 컴파일 에러가 발생합니다.

 

하지만 Opaque SwiftUI View의 경우, ViewBuilder를 이용하면 이 구문도 가능합니다.

ViewBuilder를 이용하면 각 분기에 대해 동일한 underlying return 타입을 갖도록 control-flow문을 변환할 수 있습니다.

return 문을 삭제하고 @ViewBuilder를 넣으면 컴파일 에러 없이 사용할 수 있습니다.

 

다시 농장으로 돌아갑시다

feed의 some Animal은 다른 곳에서 참조하지 않기 때문에 Opaque 타입을 사용할 수 있습니다.

 

만약 함수에서 Opaque 타입을 여러 번 참조해야 하는 상황이라면 타입 인자가 더 유용합니다.

예를 들어, Animal 프로토콜에 서식지를 나타내는 associatedtype Habiat 을 추가하는 경우

Farm 구조체에 Animal의 서식지를 만드는 것을 원할 수 있습니다.

이 경우 return 타입은 특정 타입에 따라 달라지므로 파라미터와 return 타입에 타입 파라미터 A를 사용해야 합니다.

만약 A가 소라면 Habitat는 외양간, A가 닭이라면 양계장을 반환해야 하잖아요?

이때는 Opaque 타입인 some을 사용할 수 없고 명확하게 "A"라고 못 박아야 한다는 의미입니다.

 

제네릭 타입에서도 Opaque 타입을 여러 번 참조합니다.

코드는 종종 타입 파라미터를 선언한 뒤, 이 타입 파라미터를 저장 프로퍼티에 사용하거나 memberwise initializer에 사용합니다.

다른 context에서 제네릭 타입을 참조하려면 < > 안에 타입 파라미터를 명시적으로 지정해야 합니다.

선언 부분의 < >는 제네릭 타입을 명확히 하는데 도움이 되므로 Opaque 타입은 제네릭 타입에 대해 항상 타입 이름을 지정해야 합니다.

여기에서는 "Hay"라고 명확하게 전달했네요.

 

feed 구현하기

이제 feed를 구현해볼까요?

animal 매개변수를 이용해 grow 작물 타입에 접근할 수 있습니다.

다음으로 harvest 메서드를 호출해 자라난 작물을 수확합니다.

마지막으로 animal에게 밥을 줍니다.

 

컴파일러는 feed로 전달되는 underlying animal의 타입이 고정된다는 것을 알기 때문에

만약 animal.eat( )에 다른 타입이 전달되면 컴파일 에러를 발생시킵니다.

초반에 Any 타입으로 구현했을 때와 달리, 개발자가 실수로 다른 먹이를 주는 것을 방지해줍니다.

 

feedAll 구현하기

모든 동물에게 밥을 주는 feedAll 메서드를 구현해 봅시다.

animals에는 Animal 프로토콜을 준수하는 다양한 타입이 올 수 있어야 합니다.

 

여기에 [some Animal]은 사용할 수 없습니다.

왜냐하면 underlying 타입은 모두 동일해야 하기 때문입니다.

소가 들어오면 소만 넣을 수 있고, 닭이 들어오면 닭만 넣을 수 있습니다.

우리는 소도 넣고 닭도 넣을 수 있는 animals 배열을 원하기 때문에 some Animal은 우리가 원하는 답이 아닙니다.

 

이럴 때는 any 키워드를 사용하면 됩니다.

any 키워드는 이 타입이 임의의 Animal 타입을 저장할 수 있으며 런타임에 underlying Animal 타입이 달라질 수 있음을 의미합니다.

 

animals의 원소에는 다양한 Animal이 들어갈 수 있습니다.

닭처럼 상자 안에 들어갈 수 있는 작은 Animal도 있고, 소처럼 상자 안에 들어갈 수 없어 포인터로 저장되는 Animal도 있습니다.

이러한 동적 저장을 허용하기 위해 existential 타입이라는 특별한 표현을 사용합니다.

existential 타입은 any Animal 타입이 모든 concrete Animal 타입을 저장할 수 있음을 나타냅니다.

그리고 다른 concrete 타입에 대해 동일한 표현을 사용하는 방법을 type erasure라고 합니다.

concrete 타입은 컴파일 타임에 erase 되고 런타임에만 알 수 있습니다.

 

extential type any Animal 타입인 두 인스턴스의 정적 타입은 동일하지만 동적 타입은 다릅니다.

type erasure는 다른 Animal 사이의 type-level distinction을 제거하여 다른 동적 타입의 값을 동일한 정적 타입으로 교환하여 사용할 수 있도록 합니다.

이 특성을 이용해 우리가 원하는 animals 배열을 만들 수 있습니다.

(associated 타입이 있는 프로토콜에 any를 사용하는 것은 Swift 5.7부터 지원합니다.)

feedAll 메서드를 구현하기 위해 각 animal에 대한 eat 메서드를 호출합니다.

이때 반복해서 underlying animal에 대한 Feed 타입을 가져와야 합니다.

그러나 Animal에 대해 eat 메서드를 호출하려고 하자 컴파일 오류가 발생했습니다.

type-level distinction을 제거했기 때문에 특정 Animal 타입에 의존하는 associated 타입도 제거되었습니다.

그래서 이 동물의 먹이가 어떤 것인지 추론할 수 없어요.

 

우리가 type relationship을 의존하려면 특정 타입의 Animal이 고정된 context로 가야합니다.

Animal에서 직접 eat를 호출하는 대신, some Animal을 이용해 feed 메서드를 호출하면 됩니다.

 

any Animal이 상자에 담긴 Animal이라면 some Animal은 언박싱한 Animal 입니다.

any Animal과 some Animal은 다른 타입이지만, 컴파일러는 underlying value를 언박싱하고 some Animal 파라미터로 직접 전달함으로써 any Animal의 인스턴스를 some Animal로 변화할 수 있습니다.

이때, arguments를 언박싱하는 것이 Swift 5.7의 신기능입니다.

 

이제 우리는 feedAll을 작성할 수 있습니다.

feed()의 some Animal 파라미터 범위는 값이 underlying 타입으로 고정되므로,

underlying 타입에 대한 operations를 포함하여 associated 타입에도 접근 가능합니다.

 

some과 any 비교

some은 underlying 타입이 고정됩니다.

이를 통해 제네릭 코드의 underlying 타입에 대한 type relationship을 의존할 수 있습니다.

작업 중인 프로토콜의 API 및 associated 타입에 대한 전체 액세스 권한을 갖게 됩니다.

 

any는 arbitrary(임의의) concrete 타입을 저장할 때 사용하면 유용합니다.

any는 type erasure을 제공하여 서로 다른 종류로 이루어진 Collection을 나타낼 수 있고 옵셔널을 이용해 underlying 타입이 "없음"을 표현합니다.

또한 세부적인 요구사항을 추상화할 수 있습니다.

 

일반적으로 some을 사용하고 임의의 값을 저장해야 한다면 any로 변경하면 됩니다.

이 접근 방식을 사용하면 storage flexibility가 필요할 때 type erasure와 semantic limitations만 지불하면 됩니다.

이 작업 흐름은 값의 변화가 필요하다는 것을 알 때까지 let 상수를 사용하는 것과 유사합니다.

 

끝!

 

한글 자막 후기

한글 자막은 나름 만족스러웠지만 오히려 이해를 방해하는 부분이 있었습니다.

some, any같은 중요한 키워드도 모두 한글화해서 보여주거든요.

예를 들어 any Animal 은 "모든 동물"이고 some Animal은 "일부 동물"로 번역해서 보여줍니다.

 

뭐... 어쩔 수 없다고 생각은 해요.

그래서 제가 생각했을 때 최고의 WWDC 학습 방법은 자막은 영어로, 프롬프트는 한글로 보는 게 최고라고 생각합니다.

(사실 진짜 최고는 영어 공부임 ㅋㅋ;)

애플이 한글을 취급해주니 나머지 WWDC도 열심히 봐야겠습니다.

(WWDC15에도 자막 달아주면 좋겠다....)

 

감사합니다.

 

참고

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

https://fomaios.tistory.com/entry/WWDC-2022-제너릭과-프로토콜을-사용해-코드-추상화하기-feat-someany-Embrace-Swift-generics

https://gyuios.tistory.com/189


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

반응형