Swift Attributes: @resultBuilder

Doldamul·2022년 8월 16일
0
post-thumbnail

이 글에서는 Swift 5.4부터 정식 도입된 @resultBuilder의 기능을 이해하고 사용자 정의 DSL을 제작하는 방법을 알아본다.

  • DSL이란?
    분야 특화 언어(Domain Specific Language). 특정 분야에서 가독성 및 사용성을 향상시킨 High-level 언어이다. 해당 언어는 내부 구현부의 코드 생성기에 의해 일반적인 GPL 언어로 변환된다. DSL의 반대 개념인 GPL은 다양한 분야에 폭넓게 사용될 수 있는 범용 언어(GPL, General Perpose Language)이며 일반적으로 대부분의 '프로그래밍 언어'가 이에 해당한다. 알려진 GPL에는 swift, python, C 등이 있고, DSL에는 HTML, SQL 등이 있다.

닌텐도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을 새로 작성해보도록 하겠다.

1. @resultBuilder 구현

구조체를 하나 만들고, @resultBuilder 특성을 붙인다. 제공받은 명세에 따라 빌드 메소드를 작성해야 한다. 각 빌드 메소드의 기능은 뒤에서 설명한다.
(@resultBuilder 특성은 클래스, 구조체, 열거형 등에 모두 쓸 수 있지만 타입 메소드만 사용해 구현하므로 큰 의미는 없다.)

@resultBuilder
struct TextBuilder {
    static func buildBlock(_ components: String...) -> String {
        return components.joined(separator: " ")
    }
}

2. resultBuilder 타입 명시

변수(또는 프로퍼티, 매개변수, 서브스크립트, 함수 등)에 사용할 resultBuilder DSL 이름을 특성 형태로 붙인다.

@TextBuilder var...

3. 선언적 구문 작성

이제 클로저에 해당 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와 동일한 타입으로 취급된다.

buildBlock(_:)

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개 이상의 표현식이 나열되면 컴파일 에러가 발생한다.

buildOptional(_:)

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

buildEither(first:), buildEither(second:)

조건에 따라 값이 다른 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

buildArray(_:)

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."
*/

buildExpression(_:)

입력된 표현식(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 오버로딩 메소드를 구현하는 것이다.

buildFinalResult(_:)

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

*/

buildLimitedAvailability(_:)

전처리문이 아닌 일반 구문에서 가용성 검사가 수행될 때, 타입 정보를 노출하거나 지워버리는 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

ABI와 소스코드 호환성

resultbuilder 특성을 변수(프로퍼티, 서브스크립트) 또는 함수에 적용하는 것은 ABI와 독립적이며 소스코드 호환성에 영향을 주지 않는다. 하지만 resultbuilder 특성을 함수의 매개변수에 적용할 경우 특성이 해당 함수 인터페이스의 일부가 되므로 소스코드 호환성에 영향을 줄 수 있다. (마찬가지로 ABI와는 관련이 없다)

ABI 안정성에 대한 설명은 다음 글들을 참조하면 좋을 것 같다: zeddios.tistory.com : ABI stability || jusung.github.io : ABI Stability란?

마치며

SwiftUI의 ViewBuilder를 공부하면서 'result builder까지 손댈 일은 없을거야'라고 생각했었는데, 그 생각 때문이었는지 예제 코드를 만들고 실제로 돌려보면서 엄청난 희열감을 느꼈다. 방치되있던 쓰레기 더미에서 새 생명을 발견한 느낌이랄까... (아마 내가 영어를 어려워하기 때문일 것이다)

  • 이 글 작성을 마치고 얼마 지나지 않아 swift 공식 문서의 한글 번역본 1, 2를 발견했다 ㅎㅎ 너무 행복해서 이 글을 올릴까 말까 고민했는데 그냥 올리기로 했다. 누군가에겐 도움이 될 수도 있겠지...

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

참고자료

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

0개의 댓글