이 글에서는 Swift 5.4부터 정식 도입된 @resultBuilder
의 기능을 이해하고 사용자 정의 DSL을 제작하는 방법을 알아본다.
닌텐도DSL
공식 문서의 설명을 읽어보자.
@resultBuilder
는 리스트나 트리와 같은 연결 자료 구조를 자연스럽고 선언적으로 생성할 수 있도록 DSL을 정의하는 타입이다. DSL 구문 내에서if
문 또는for
문 같은 보편적인 swift 문법을 처리 가능하도록 구현할 수 있다. 프로토콜을 채택했을 때와 유사하게, resultBuilder는 메소드들의 명세를 제공받는다.buildBlock(_:)
메소드는 필수사항, 나머지 메소드들은 선택사항이다.
간단히 말해 다음처럼 클로저 내에 단순 나열된 표현식들을,
// 선언적 표현
var resultBuilder적용된변수 {
"리터럴"
[표현식]
생성자()
}
대략 다음과 같은 코드 형태로 변환할 수 있게 해준다.
// 번역된 표현
var resultBuilder적용된변수 {
return .init(인스턴스, 인스턴스, 인스턴스)
}
따라서 우리는 복잡한 객체를 선언적 표현으로 쉽게 생성할 수 있다.
사용 예시로 SwiftUI의 ViewBuilder
를 들 수 있다.
(2022년 기준 새로운 정규표현식 표현방법인 regexBuilder
도 있다.)
@resultBuilder struct ViewBuilder
SwiftUI는 View
를 생성할 때 내부적으로 ViewBuilder
를 사용한다. 아래 두 예제를 비교해보면 선언적 표현에 있어 resultBuilder가 얼마나 중요한 역할을 하는지 체감할 수 있다.
// ViewBuilder 적용
struct contentView1: View {
// DSL 덕분에 View를 선언형으로 깔끔하게 작성할 수 있다.
var body: some View {
Spacer()
VStack {
Text("1")
HStack {
Text("2-1")
Text("2-2")
}
Text("3")
}
Spacer()
}
}
// ViewBuilder 미적용
struct contentView2: View {
var body: some View {
return TupleView((
Spacer(),
VStack<TupleView<(Text, HStack<TupleView<(Text, Text)>>, Text)>> {
return TupleView((
Text("1"),
HStack<TupleView<(Text, Text)>> {
return TupleView((
Text("2-1"),
Text("2-2")
))},
Text("3")
))},
Spacer()
))
}
}
대충 보아도 ViewBuilder
를 적용하지 않은 경우가 온갖 종류의 괄호들과 쉼표, 타입 명시 등으로 인해 가독성 및 사용성이 나쁘다는 것을 알 수 있다.
아, 그냥 선언형을 쓰지 말자고? 그건 이미 UIKit에서 10년 동안 시도해봤다.
사용 방법을 설명하기 위해 장문의 텍스트를 선언적으로 생성하는 DSL을 새로 작성해보도록 하겠다.
구조체를 하나 만들고, @resultBuilder
특성을 붙인다. 제공받은 명세에 따라 빌드 메소드를 작성해야 한다. 각 빌드 메소드의 기능은 뒤에서 설명한다.
(@resultBuilder
특성은 클래스, 구조체, 열거형 등에 모두 쓸 수 있지만 타입 메소드만 사용해 구현하므로 큰 의미는 없다.)
@resultBuilder
struct TextBuilder {
static func buildBlock(_ components: String...) -> String {
return components.joined(separator: " ")
}
}
변수(또는 프로퍼티, 매개변수, 서브스크립트, 함수 등)에 사용할 resultBuilder DSL 이름을 특성 형태로 붙인다.
@TextBuilder var...
이제 클로저에 해당 DSL 문법을 준수하는 선언적 구문을 작성할 수 있다.
@TextBuilder var longText: String {
"I"
"Love"
"Swift"
}
print(longText) // I Love Swift
함수를 잘 사용하면 다음과 같이 중첩된 표현도 가능하다.
func sentence(_ endMark: String = ".", @TextBuilder text: () -> String) -> String {
return text() + endMark
}
func dialogue(@TextBuilder text: () -> String) -> String {
return "\"" + text() + "\""
}
@TextBuilder var longText: String {
dialogue {
sentence(",") {
"I"
"Love"
"Swift"
}
sentence {
"You"
"Love"
"Python"
}
}
}
print(longText) // "I Love Swift, You Love Python."
github.com: awesome result builders에서 swift 유저들이 resultBuilder
를 사용해 만든 편리한 DSL들을 볼 수 있다.
다음은 resultbuilder
에 제공되는 명세이다.
// 필수 구현
static func buildBlock(_ components: Component...) -> Component
// 이하 선택 구현
static func buildOptional(_ component: Component?) -> Component
static func buildEither(first: Component) -> Component
static func buildEither(second: Component) -> Component
static func buildArray(_ components: [Component]) -> Component
static func buildExpression(_ expression: Expression) -> Component
static func buildFinalResult(_ component: Component) -> FinalResult
static func buildLimitedAvailability(_ component: Component) -> Component
위의 타입 메소드 명세에서 사용하는 타입은 3종류뿐이다.
Component
: 대부분의 빌드 메소드에서 데이터 변환 과정 중간에 반환하는 부분값의 타입Expression
: 해당 result Builder가 입력받는 표현식의 타입FinalResult
: 해당 result Builder가 생성하는 결과값의 타입세 타입은 모두 placeholder이므로 위 명세를 구현할 때는 placeholder들의 실제 타입을 각각 명시해주어야 한다(typealias
구문을 활용할 수도 있다). Expression
또는 FinalResult
타입의 실제 타입을 명시하지 않을 경우(또는 사용하지 않을 경우) 두 타입은 Component
와 동일한 타입으로 취급된다.
1개 이상의
Component
들을 단일Component
로 합친다.
앞서 등장했던 예제를 다시 살펴보자.
@resultBuilder
struct TextBuilder {
// 표현식의 개수에 상관없이 처리 가능하도록 가변 매개변수를 사용했다.
static func buildBlock(_ components: String...) -> String {
return components.joined(separator: " ")
}
}
@TextBuilder var longText: String {
"I" // 3개의 String 표현식이 나열되어 있다.
"Love"
"Swift"
}
print(longText) // I Love Swift
resultBuilder
는 클로저 내부에 나열된 모든 표현식을 buildBlock
함수에서 읽어들인다. 그리고 해당 함수의 구현부를 통해 데이터를 단일화한다. 위 예제에서는 읽어들인 각 문자열을 공백으로 구분하여 하나의 문자열로 합치는 것으로 처리했다.
한편, 가변 매개변수를 사용하지 않는 방법도 있다.
static func buildBlock() -> Component { ... }
static func buildBlock(_ c1: Component) -> Component { ... }
static func buildBlock(_ c1: Component, _ c2: Component) -> Component { ... }
...
매개변수의 개수로 오버로딩할 수 있다. 이 경우, 오버로딩된 메소드에서 지원하는 매개변수의 수에 맞추어 표현식을 나열해야 한다. 위 코드처럼 오버로딩 메소드가 3개뿐이라면 클로저로부터 0개, 1개, 2개의 표현식만 수용할 수 있다. 3개 이상의 표현식이 나열되면 컴파일 에러가 발생한다.
nil
이 될 수 있는Component
로부터 새Component
를 만든다. 이 함수를 구현하면else
문을 포함하지 않는 단일if
문을 지원하게 된다.
extension TextBuilder {
static func buildOptional(_ component: String?) -> String {
return component ?? ""
}
}
var isStudying: Bool = true
@TextBuilder var longText: String {
"I"
"Love"
"Swift"
if isStudying {
"Result"
"Builder"
}
}
print(longText) // I Love Swift Result Builder
조건에 따라 값이 다른
Component
를 만든다. 두 함수를 모두 구현하면switch
문과if-else
문을 지원하게 된다.
extension TextBuilder {
// if(case) 조건 만족
static func buildEither(first: String) -> String { first }
// else(default) 조건 만족
static func buildEither(second: String) -> String { second }
}
var isStudying: Bool = false
@TextBuilder var longText: String {
"I"
if isStudying {
"Love"
} else {
"Hate"
}
"Swift"
}
print(longText) // I Hate Swift
Component
배열로부터Component
를 만든다. 이 함수를 구현하면for
문을 지원하게 된다.
extension TextBuilder {
static func buildArray(_ components: [String]) -> String {
return "\n" + components.joined(separator: "\n")
}
}
@TextBuilder var longText: String {
dialogue {
sentence("!") { "spit";"it";"out" }
}
for _ in 1...5 {
sentence("!") { "I";"Love";"Swift" }
}
"\n" + dialogue {
sentence { "nice" }
}
} // 정말 쓸데없는 표현에 가슴이 웅장해진다
print(longText)
/*
"spit it out!"
I Love Swift!
I Love Swift!
I Love Swift!
I Love Swift!
I Love Swift!
"nice."
*/
입력된 표현식(
Expression
)으로부터Component
를 만든다. 이 함수를 구현하면 입력된 데이터를 전처리(preprocessing)해서 사용하게 된다(외부 입력 타입Expression
을 내부 타입Component
로 변환). 사용처에서 타입 추론을 할 때도 추가적인 정보를 제공한다.
할당 구문의 경우 표현식처럼 변환되지만()
타입으로 평가된다. 따라서 할당 구문을 허용하기 위해서는 매개변수의 타입이()
인 오버로딩 메소드BuildExpression(_: ()) -> Component
를 구현해야 한다.
buildExpression
의 타입 추론은 some
키워드와 관련이 있다. 키워드 some
과 그 개념 Opaque
타입에 대해 궁금하다면 공식 문서 번역본을 참고하기 바란다: Swift: 불투명한 타입
extension TextBuilder {
static func buildExpression(_ expression: Int) -> String {
return String(expression)
}
}
@TextBuilder var longText: some Any {
1577; 1577
}
print(longText) // 1577 1577
print(type(of:longText)) // String
위 예제에서 클로저에 Int
값만 존재함에도 불구하고 타입 추론이 정상적으로 작동함을 확인할 수 있다.
추가: ()
타입으로 평가되는 할당 구문이라는게 무엇을 지칭하는 것인지는 잘 모르겠다. 클로저 내에서 var a = 1577
과 같은 할당 구문이 ()
타입으로 평가되지 않음을 확인했기 때문이다. 다만 반환값이 없는(= 반환값이 Void
인) 함수 호출 구문은 ()
타입으로 평가됨을 확인했다. 또한 buildExpression
의 오버로딩 메소드를 다양한 타입으로 구현하면 다양한 타입 표현식을 지원할 수 있다.
@TextBuilder var longText: some Any {
1577
3.1415
print("this is () type")
UIView()
}
// 온갖 타입을 넣어도 동작하도록 만들 수 있다.
// 예컨대, static func buildExpression(_ expression: UIView) -> String 오버로딩 메소드를 구현하는 것이다.
Component
로부터FinalResult
를 만든다. 이 함수를 구현하면 최종 데이터를 후처리(postprocessing)해서 반환하게 된다.resultbuilder
의 중간 결과값(Component
)과 최종 결과값(FinalResult
)의 타입을 서로 다르게 할 수도 있다.
extension TextBuilder {
static func buildFinalResult(_ component: String) -> String {
return component + "\n"
}
}
@TextBuilder var longText: String {
"im";"lazy"
}
print(longText)
/*
im lazy
*/
전처리문이 아닌 일반 구문에서 가용성 검사가 수행될 때, 타입 정보를 노출하거나 지워버리는
Component
를 만들어 전달한다. 이 함수를 구현하면 조건문에 따라 변하는 타입의 정보를 감출 수 있다.
DSL을 라이브러리 형태로 배포/관리할 생각이 없다면 딱히 직접 구현할 일은 없는 함수이다. 다음은 buildLimitedAvailability
함수 구현을 필요로 하는 예제이다. 축약해서 살펴보도록 하자.
import SwiftUI
@available(macOS 10.15, iOS 13.0, *)
struct ContentView: View {
var body: some View { // body는 View 프로토콜로부터 @ViewBuilder를 상속받는다.
ScrollView {
if #available(macOS 11.0, iOS 14.0, *) {
LazyVStack {
ForEach(1...1000, id: \.self) { value in
Text("Row \(value)")
}
}
} else {
VStack {
ForEach(1...1000, id: \.self) { value in
Text("Row \(value)")
}
}
}
}
}
}
문제의 부분을 축약하면 다음과 같다.
ScrollView {
// ScrollView는 if-else문을 담당하는 buildEither 함수로부터 View 구조체를 받게 된다.
if #available(macOS 11.0, iOS 14.0, *) {
LazyVStack { ... }
} else { // macOS 10.15, iOS 13.0 이상에서는 다음 코드가 실행된다.
VStack { ... }
}
}
이 코드의 문제는 buildEither
함수에서 반환하는 Component
타입의 제네릭 부분에 LazyVStack
타입 정보가 포함되어있다는 것이다.
Component<LazyVStack, VStack>
buildLimitedAvailability
함수는 buildEither
이 실행되기 전에 Component
타입으로부터 문제가 되는 해당 타입을 감추어버린다.
// SwiftUI 라이브러리 내부
static func buildLimitedAvailability<Content: View>(_ content: Content) -> AnyView { .init(content) }
// AnyView 타입으로 래핑해버렸다.
Component<AnyView, VStack>
따라서 위 예제는 LazyVStack
을 지원하지 않는 구버전에서도 컴파일 에러가 발생하지 않는다.
함수에 대한 설명이 잘 이해되지 않거나, 좀 더 자세히 알고 싶다면 다음을 참조하기 바란다: (영어)github.com/apple: 0289-result-builders - example - Availability
resultbuilder
특성을 변수(프로퍼티, 서브스크립트) 또는 함수에 적용하는 것은 ABI와 독립적이며 소스코드 호환성에 영향을 주지 않는다. 하지만resultbuilder
특성을 함수의 매개변수에 적용할 경우 특성이 해당 함수 인터페이스의 일부가 되므로 소스코드 호환성에 영향을 줄 수 있다. (마찬가지로 ABI와는 관련이 없다)
ABI 안정성에 대한 설명은 다음 글들을 참조하면 좋을 것 같다: zeddios.tistory.com : ABI stability || jusung.github.io : ABI Stability란?
SwiftUI의 ViewBuilder를 공부하면서 'result builder까지 손댈 일은 없을거야'라고 생각했었는데, 그 생각 때문이었는지 예제 코드를 만들고 실제로 돌려보면서 엄청난 희열감을 느꼈다. 방치되있던 쓰레기 더미에서 새 생명을 발견한 느낌이랄까... (아마 내가 영어를 어려워하기 때문일 것이다)
공식 자료들에서는 result builder 함수들의 실제 호출 흐름까지도 설명하고 있지만 이 글에서는 해당 주제가 사용하는데 크게 중요하지 않다고 판단해 생략했다. 더 자세한 예제나 작동 방식 등을 알고 싶다면 다음 참고자료들에서 찾아보면 된다.