이 글에서는 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 함수들의 실제 호출 흐름까지도 설명하고 있지만 이 글에서는 해당 주제가 사용하는데 크게 중요하지 않다고 판단해 생략했다. 더 자세한 예제나 작동 방식 등을 알고 싶다면 다음 참고자료들에서 찾아보면 된다.