Swift 5.7 타입 시스템 개선사항 정리

Doldamul·2022년 10월 19일
0
post-thumbnail
post-custom-banner

Swift.org - Swift 5.7 Released! 글에서 Swift 5.7에서 추가된 모든 proposal들을 영역별로 정리해놓은 것을 보았다. Pointer, Swift Package Manager까지는 아직 관심이 없고, Concurrency, String Processing( = Regex)는 대충 알겠는데, 타입 시스템 관련해서 대충 넘어가기에는 생각보다 모르는 것들이 많았다. 여기저기 둘러보아도 뭔가 딱 내가 원하는 형태와 양만큼만 정리한 자료를 찾을 수 없어서, 결국 내가 직접 작성하기로 했다.

SE-0309: 모든 프로토콜을 existential 타입으로 사용 가능

SE-0309는 다른 proposal보다 좀 복잡해서 간단하게 요약하기 어렵다. 이해하려면 OOP의 covariant 개념을 알아야 한다.

protocol P {
    func foo() -> Self
    func bar(_: Self)
}

구체적인 타입을 existential 타입으로 안전하게 치환할 수 있는 경우를 covariant하다고 한다. 위 예시에서, foo 함수가 반환하는 Self는 항상 호출자의 타입과 동일함이 보장된다. 그러나 bar 함수가 인자로 받는 타입은 호출자의 타입과 구체 타입이 서로 다를 수 있다. P의 existential 타입 변수 pq를 생각해보면, p.bar(q)와 같이 호출 가능하지만 pq의 구체 타입은 서로 다를 수 있으므로 타입 안정성 문제가 발생한다. 즉,

  • foo 함수의 Self -> covariant
  • bar 함수의 Self -> non-covariant

이제 SE-0309를 알아보자.

이제 모든 프로토콜은 existential 타입으로 사용 가능하며 다음 예외에 해당하지 않을 경우 모든 종류의 멤버(변수, 함수, 생성자, 서브스크립트)에 접근 가능하다.
예외: 해당 타입의 멤버 변수가 non-covariant 위치에 있는 Self 또는 Self.associatedtype에 대한 참조를 포함하게 되는 경우.

즉 프로토콜에서 associatedtype 사용 시의 제한이 사라지고, Self 사용 시의 제한도 완화되었다.

protocol P {
    associatedtype Bar
    func foo(_: Bar) -> Self
}

func test(_ p: any P) {
	let x = p.foo(0) //  ✅ '음, x는 existential P 타입이군'
}

그러나 여전히, Equatable 실존 타입에서는 == 함수에 접근이 불가능하다. 정의에 non-convariant한 Self를 포함하기 때문이다.

protocol Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool // non-covariant
}

let lhs: Equatable = "Paul"
let rhs: Equatable = "Alex"

lhs == rhs // ❌ 컴파일 에러

...

if let ownerName = lhs as? String, let petName = rhs as? String {
  print(ownerName == petName) // ✅ 정상 컴파일, false 출력
}

프로토콜에서 Self를 covariant로 취급하는 경우는 다음이 있다:

  • 함수의 반환 타입
  • 튜플 타입의 항목 중 하나의 타입
  • 옵셔널 값의 Wrapped 타입
  • 배열의 Element 타입
  • 딕셔너리의 Value 타입

SE-0326: 다중 구문 클로저에서의 매개변수/반환 타입 추론

클로저를 함수의 인자로 전달할 경우 함수의 정의로부터 클로저의 타입을 추론한다. 그러나 해당 함수가 제네릭 함수일 경우 클로저 body로부터 타입 추론을 해야만 한다. 이것이 지금까지는 단일 구문 클로저에서만 가능했지만, 이제 다중 구문에도 클로저 body로부터의 타입 추론이 적용되었다.

// 다음과 같은 두 함수가 있다고 하자:
func map<T: BinaryInteger>(fn: (Int) -> T) -> T {
  return fn(Int.random(in: 1...100))
}

func doSomething<U: BinaryInteger>(_: U) -> Void { /* processing */ }

// 다중 구문 클로저를 전달하더라도 타입 정보를 생략할 수 있다.
let _ = map {
  logger.info("About to call 'doSomething(\$0)'")
  doSomething($0)
}

컴파일러는 위 예시에서 다음과 같이 타입 추론한다.

  • 클로저의 첫번째 매개변수 $0은 map 및 doSomething의 정의를 참고하여 Int 타입으로 추론된다.
  • 클로저의 반환 타입은 마지막 구문까지 return 키워드가 명시되어있지 않으므로 Void 타입으로 추론된다.

SE-0328: 구조화된 불투명 반환 타입

이제 반환 타입으로 Opaque 타입을 포함하여 구조화된 타입을 사용할 수 있다. 예컨대, 다음과 같은 정의가 가능하다:

func f0() -> (some P)? { ... }
func f1() -> (some P, some Q) { ... }
func f2() -> () -> some P { ... }
func f3() -> S<some P> { ... }

SE-0341: 불투명한 매개변수 타입

이제 some 키워드를 함수, 생성자, 서브스크립트 등에서의 매개변수에 사용할 수 있다. Opaque 타입을 매개변수로 사용할 경우 이름 없는 '타입 매개 변수'로 여겨지며 해당 함수는 제네릭 함수가 된다.

// 다음 두 정의는 동일하게 취급된다.
func f1<T: Collection>(a: T) { ... }
func f1(a: some Collection) { ... }

// where 구문은 여전히 명명된 타입 매개 변수(T)에만 사용할 수 있다.
func f1<T: Collection>(a: T) where T.Element == String { ... }func f1(a: some Collection) where ??? { ... }

SE-0345: 옵셔널 변수 언래핑 시의 if-let 구문 간소화

이제 if let a {...} 형식의 구문은 if let a = a {...} 구문과 동일시된다.
다음 예시처럼 이름이 긴 옵셔널 변수가 있을 경우 유용하다:

let someLengthyVariableName: Foo? = ...
let anotherImportantVariable: Bar? = ...

if let someLengthyVariableName, let anotherImportantVariable {
    ...
}

SE-0346: primary associated type을 통한 동등 관계 요구사항 축약 문법

큰 주제인 만큼, primary associated type의 필요성을 먼저 이해해보자.

  1. associated type이 지정된 구체 타입은 opaque result type으로 온전한 표현이 불가능
func readLine(_ file: String) -> SyntaxTokenSequence<LineSequence>

반환타입 SyntaxTokenSequence<LineSequence>를 간단히 some Sequence로 표현하고 싶지만, 호출자는 some Sequence만 읽고서는 반환값의 Sequence.Element 타입을 어떻게 처리할지 알 방도가 없다. 즉, SyntaxTokenSequence 구체 타입을 노출시키지 않고 associated type 정보만을 노출할 방법이 없다.

  1. 동등 관계 요구사항 축약 문법 필요성
func concatenate(_ lhs: Array<String>, _ rhs: Array<String>) -> Array<String>

위 함수 정의에 쓰인 Array 타입을 Collection으로 일반화하면 다음과 같이 될 것이다.

func concatenate<S : Sequence>(_ lhs: S, _ rhs: S) -> S where S.Element == String

이러한 동등 관계 요구사항은 유용하며 흔히 사용되지만, 작성하기 복잡하고 가독성을 해친다는 문제가 있다.


primary associated type으로 이러한 문제들을 해결할 수 있다.

이제 프로토콜은 1개 이상의 primary associated type을 가질 수 있다. 기존 associated type과 동일하게 작성한 뒤, 제네릭 타입에서의 타입 매개변수처럼 프로토콜 이름 옆에 꺾쇠 기호를 통해 명시할 수 있다.

protocol Sequence<Element> { // Element는 primary associated type
  associatedtype Element
  associatedtype Iterator : IteratorProtocol
    where Element == Iterator.Element
    // 동등 관계 요구 사항은 추상 관계를 강화한다.
  ...
}

protocol DictionaryProtocol<Key, Value> { // Key, Value는 primary associated type
  associatedtype Key : Hashable
  associatedtype Value
  ...
}

해당 프로토콜들을 opaque 타입, 프로토콜 준수성, 타입 제약 등의 위치에 명시할 때, 원할 경우 primary associated type를 표시하여 타입을 명시할 수 있다. 이를 통해 앞서 작성했던 정의보다 명확한 의도 표현이 가능해졌다.

func readLine(_ file: String) -> some Sequence<[Token]>
func concatenate<S : Sequence<String>>(_ lhs: S, _ rhs: S) -> S
func transformElements<S : Sequence<E>, E>(_ lines: S) -> some Sequence<E>

마지막 함수의 경우, primary associated type을 통해 opaque result type에 제네릭 매개변수를 사용했음에 주목할 필요가 있다. 기존에는 이러한 표현이 불가능했는데, opaque result type에서는 프로토콜만 명시가 가능했을 뿐더러 where절도 지원하지 않으므로 제네릭 매개변수를 배치할 수 있는 방법이 없었기 때문이다.

다음 영상에서는 동등 관계 요구사항의 유용성에 대해 자세히 설명한다(11분 소요): developer.apple.com: Design protocol interfaces in Swift

SE-0347: 기본값 표현으로 타입 추론

이제 제네릭 매개변수에 기본값을 제공하는 것이 허용된다. 단, 기본값을 제공받은 매개변수가 여러 개일 경우 각각에 쓰인 타입 매개변수는 서로 독립적이어야 한다.

func compute<C: Collection>(_ values: C = [0, 1, 2]) { ... }func compute<T, U>(_: T = 42, _: U) where U: Collection, U.Element == T { ... }func compute<T, U>(_: T = 42, _: U = []) where U: Collection, U.Element == Int { ... }

제네릭 타입에서도 사용 가능하다.

struct Box<F: Flags> {
  init(flags: F = DefaultFlags()) { ... }
}

Box() // F는 DefaultFlags로 추론된다.
Box(flags: CustomFlags()) // F는 CustomFlags로 추론된다.

SE-0348: result builder의 buildPartialBlock

이제 재귀적 표현을 사용하는 buildPartialBlock 함수로 buildBlock 오버로딩 함수의 개수 및 연산 횟수를 획기적으로 줄일 수 있다. 다음 두 함수만으로 n개의 Component를 처리해낼 수 있다:

@resultBuilder
enum Builder {
    static func buildPartialBlock(first: Component) -> Component
    static func buildPartialBlock(accumulated: Component, next: Component) -> Component
}

resultBuilder가 뭔지 모르는 분들은 예전에 작성했던 내 글을 참조하자: velog.io/@doldamul: Swift Attributes: @resultBuilder

SE-0352: Existential 타입의 암시적 언박싱

이제 제네릭 타입인 매개변수에 existential 타입을 인자값으로 전달하는 경우 암시적으로 언박싱되어 구체 타입으로 전달된다. 이는 기존 Swift 문법과도 유사한 부분이 있다 - existential 타입을 사용할 때, 프로토콜에 정의된 멤버를 호출하면 Self 타입을 암시적으로 언박싱하여 구체 타입의 멤버를 사용한다는 사실은 이미 잘 알고 있을 것이다. 따라서 이 제안은 부자연스러운 것이 아니다.

제안 SE-0341에 의해, 이제 매개변수에 사용된 제네릭 타입은 some 키워드로도 표현될 수 있다. 즉 any 타입과 some 타입을 다음과 같이 활용하여 동적 타입과 정적 타입의 사용 영역을 간명하게 분리할 수 있다.

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

class Cow {...}
class Sheep {...}
class Horse {...}

struct Farm {
//						  ↱ opaque type
    func feed(_ animal: some Animal) {
        let crop = type(of: animal).Feed.grow()
        animal.eat(animal.plantType)
    }
    //						  ↱ existential type
    func feedAll(_ animals: [any Animal]) {
        for animal in animals {
            feed(animal) // any Animal => unboxing... => some Animal
        }
    }
}

SE-0353: 제약된 Existential 타입

SE-0346에서 Opaque 타입의 primary associated type에 타입을 명시한 것처럼, existential 타입의 primary associated type에도 타입을 명시할 수 있다. 해당 표현은 동등 관계 요구사항으로 치환되어 처리된다.

protocol P<T, U, V> { }

var xs: [any P<B, N, J>] // 다음과 동일한 표현: [any P] where P.T == B, P.U == N, P.V == J

SE-0309에서 언급된 것처럼 existential 타입으로 non-covariant한 프로토콜을 사용할 경우, 기본적으로 인스턴스 내 멤버에 접근이 불가능하다. 그러나 primary associated type에 구체 타입을 명시할 경우 타입 안정성이 보장되므로 접근이 가능해진다.

var collection: any RangeReplaceableCollection = [1, 2, 3]
collection.append(4)
// error: member 'append' cannot be used on value of protocol type 'RangeReplaceableCollection'

var intCollection: any RangeReplaceableCollection<Int> = [1, 2, 3]
intCollection.append(4)
// okay: the Element type is concrete (Int) within the existential

SE-0358: Standard Library의 Primary Associated Type 지원

제안 SE-0346에 기반하여, 이제 Swift Standard Library의 몇몇 public 프로토콜들이 primary associated type을 가지도록 업데이트되었다. Swift의 버전업 때마다 더 많은 프로토콜들이 primary associated type을 가질 수도 있다. 물론 애초에 associated type이 없는 프로토콜과는 관계 없는 얘기다.

각 프로토콜이 가진 associated type, 그리고 primary associated type을 정리한 표는 다음 링크에 있다: Primary Associated Types in the Standard Library: protocol list

SE-0360: 각 availability에 따라 구체 타입이 달라지는 불투명한 반환 타입

이제 Opaque 타입을 반환하는 함수를 실행할 때 if #available 구문이 항상 호출됨이 보장된다면, 함수의 나머지 부분에 명시된 반환 타입과 다른 타입을 반환하는 것이 허용된다.

즉,

  • 각각의 availability에 따라 조건을 나누어 서로 다른 타입을 반환하도록 할 수 있고
  • 모든 availability 조건을 충족하지 못한 경우 가장 마지막에 명시된 반환 구문으로 안전하게 반환할 수 있다.

함수의 반환 타입은 런타임 이전에 정해져야 하므로, availibility 조건을 다른 조건과 섞어 - if, guard, switch 등 - 사용하게 되면 해당 제안이 작동하지 않게 된다.

func test() -> some Shape {
  if #available(macOS 100, *) {return Rectangle()
  }

  return self
}

SE-0361: 특정된 제네릭 타입의 extension 선언

primary associated type을 통해 타입 제약된 타입에 extension 선언이 가능해진 것처럼, 이제 제네릭 매개변수가 특정된 제네릭 타입을 대상으로도 extension을 선언할 수 있다. where 키워드를 사용할 수도 있고, 꺾쇠 기호를 사용할 수도 있다. [String] 또는 Int?와 같은 문법적 감초를 사용하여 extension을 선언할 수도 있다.

// protocol with primary associated type
extension Collection<String> { ... }

// concrete type with concrete generic type
extension Array where Element == String { ... } // ✅ (기존)
extension Array<String> { ... } // ✅
extension [String] { ... } // ✅

where절을 사용한 두번째 줄의 경우 기존에도 허용됐던 구문이지만, 나머지 구문들은 허용되지 않았던지라 개발자들이 매우 혼란스러워했었다고 한다.

마치며

나는 항상 제네릭을 어려워했다. 그 원인이 opaque, generic, existential 타입 등의 개념들이 서로 명확하게 정리되지 않았기 때문이란 결론을 내렸다. 그래서 이 다음 글은 Swift 5.7의 전반적인 타입 시스템에 대한 개념 정리글이 될 것으로 생각하고 있다. 이 글은 Swift 5.7에서 타입과 관련하여 생긴 문법적 변화가 어떤 것들이 있는지 명확하게 식별하고자 작성되었다. 각 타입에 대한 명확한 개념이 이미 잡혀있는 분들은 이 글이 오히려 도움이 많이 될 수도 있겠지만, 나는 이 주제를 다음 글을 작성하기 위한 징검다리라고 생각하며 정리했다.
그리고 이전 글에 비해서 힘을 많이 뺐다. 시간도 덜 먹었고, 스트레스도 덜했고, 공부가 필요할 것이라고 이전부터 생각해오던 주제였다. 하지만 여전히 욕심이 많다. 가벼우면서도 원하는 내용의 정리글을 작성할 수 있도록 좀 더 고민해봐야겠다.

참고자료

profile
덕질은 삶의 활력소다. 내가 애플을 좋아하는 이유. 재밌거덩
post-custom-banner

0개의 댓글