Swift.org - Swift 5.7 Released! 글에서 Swift 5.7에서 추가된 모든 proposal들을 영역별로 정리해놓은 것을 보았다. Pointer, Swift Package Manager까지는 아직 관심이 없고, Concurrency, String Processing( = Regex)는 대충 알겠는데, 타입 시스템 관련해서 대충 넘어가기에는 생각보다 모르는 것들이 많았다. 여기저기 둘러보아도 뭔가 딱 내가 원하는 형태와 양만큼만 정리한 자료를 찾을 수 없어서, 결국 내가 직접 작성하기로 했다.
SE-0309는 다른 proposal보다 좀 복잡해서 간단하게 요약하기 어렵다. 이해하려면 OOP의 covariant 개념을 알아야 한다.
protocol P {
func foo() -> Self
func bar(_: Self)
}
구체적인 타입을 existential 타입으로 안전하게 치환할 수 있는 경우를 covariant하다고 한다. 위 예시에서, foo
함수가 반환하는 Self
는 항상 호출자의 타입과 동일함이 보장된다. 그러나 bar
함수가 인자로 받는 타입은 호출자의 타입과 구체 타입이 서로 다를 수 있다. P
의 existential 타입 변수 p
와 q
를 생각해보면, p.bar(q)
와 같이 호출 가능하지만 p
와 q
의 구체 타입은 서로 다를 수 있으므로 타입 안정성 문제가 발생한다. 즉,
foo
함수의 Self
-> covariantbar
함수의 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
타입클로저를 함수의 인자로 전달할 경우 함수의 정의로부터 클로저의 타입을 추론한다. 그러나 해당 함수가 제네릭 함수일 경우 클로저 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)
}
컴파일러는 위 예시에서 다음과 같이 타입 추론한다.
이제 반환 타입으로 Opaque 타입을 포함하여 구조화된 타입을 사용할 수 있다. 예컨대, 다음과 같은 정의가 가능하다:
func f0() -> (some P)? { ... }
func f1() -> (some P, some Q) { ... }
func f2() -> () -> some P { ... }
func f3() -> S<some P> { ... }
이제 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 ??? { ... } ❌
if-let
구문 간소화이제 if let a {...}
형식의 구문은 if let a = a {...}
구문과 동일시된다.
다음 예시처럼 이름이 긴 옵셔널 변수가 있을 경우 유용하다:
let someLengthyVariableName: Foo? = ...
let anotherImportantVariable: Bar? = ...
if let someLengthyVariableName, let anotherImportantVariable {
...
}
큰 주제인 만큼, primary associated type의 필요성을 먼저 이해해보자.
func readLine(_ file: String) -> SyntaxTokenSequence<LineSequence>
반환타입 SyntaxTokenSequence<LineSequence>
를 간단히 some Sequence
로 표현하고 싶지만, 호출자는 some Sequence
만 읽고서는 반환값의 Sequence.Element
타입을 어떻게 처리할지 알 방도가 없다. 즉, SyntaxTokenSequence
구체 타입을 노출시키지 않고 associated type 정보만을 노출할 방법이 없다.
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
이제 제네릭 매개변수에 기본값을 제공하는 것이 허용된다. 단, 기본값을 제공받은 매개변수가 여러 개일 경우 각각에 쓰인 타입 매개변수는 서로 독립적이어야 한다.
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로 추론된다.
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
이제 제네릭 타입인 매개변수에 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-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-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
이제 Opaque 타입을 반환하는 함수를 실행할 때 if #available
구문이 항상 호출됨이 보장된다면, 함수의 나머지 부분에 명시된 반환 타입과 다른 타입을 반환하는 것이 허용된다.
즉,
함수의 반환 타입은 런타임 이전에 정해져야 하므로, availibility 조건을 다른 조건과 섞어 - if
, guard
, switch
등 - 사용하게 되면 해당 제안이 작동하지 않게 된다.
func test() -> some Shape {
if #available(macOS 100, *) { ✅
return Rectangle()
}
return self
}
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에서 타입과 관련하여 생긴 문법적 변화가 어떤 것들이 있는지 명확하게 식별하고자 작성되었다. 각 타입에 대한 명확한 개념이 이미 잡혀있는 분들은 이 글이 오히려 도움이 많이 될 수도 있겠지만, 나는 이 주제를 다음 글을 작성하기 위한 징검다리라고 생각하며 정리했다.
그리고 이전 글에 비해서 힘을 많이 뺐다. 시간도 덜 먹었고, 스트레스도 덜했고, 공부가 필요할 것이라고 이전부터 생각해오던 주제였다. 하지만 여전히 욕심이 많다. 가벼우면서도 원하는 내용의 정리글을 작성할 수 있도록 좀 더 고민해봐야겠다.