🔥 Result Builder로 선언형 코드 작성하기

764자
8분

Swift에서 Result Builder를 사용하면 리스트나 트리와 같은 중첩된 데이터를 자연스럽고 선언적인 방식으로 생성할 수 있습니다. Result Builder를 사용하면 if문이나 for문과 같은 일반적인 Swift 문법으로 조건부 또는 반복적인 데이터 조각을 처리할 수 있어요.

아래 코드는 별표와 텍스트를 사용하여 한 줄에 그리는 몇 가지 타입을 정의합니다.

protocol Drawable {
    func draw() -> String
}
 
struct Line: Drawable {
    var elements: [Drawable]
 
    func draw() -> String {
        elements.map { $0.draw() }.joined(separator: "")
    }
}
 
struct Text: Drawable {
    var content: String
 
    init(_ content: String) {
        self.content = content
    }
 
    func draw() -> String {
        content
    }
}
 
struct Space: Drawable {
    func draw() -> String {
        " "
    }
}
 
struct Stars: Drawable {
    var length: Int
 
    func draw() -> String {
        String(repeating: "*", count: length)
    }
}
 
struct AllCaps: Drawable {
    var content: Drawable
 
    func draw() -> String {
        content.draw().uppercased()
    }
}
swift
  • Drawable 프로토콜은 선이나 도형과 같이 그릴 수 있는 것에 대한 요구사항을 정의해요. 해당 타입은 반드시 draw() 메서드를 구현해야 합니다.
  • Line 구조체는 한 줄 그리기를 나타내며, 대부분의 그림에서 최상위 컨테이너 역할을 맡아요. Line은 선의 각 구성 요소에서 draw()를 호출하고, 결과 문자열을 하나의 문자열로 연결합니다.
  • Text 구조체는 문자열을 감싸 그림의 일부로 만들어요.
  • AllCaps 구조체는 다른 그림을 감싸고 수정하여 그림의 모든 텍스트를 대문자로 변환해요.

우리는 이러한 타입의 이니셜라이저를 호출하여 그림을 만들 수 있어요:

let name: String? = "Ravi Patel"
let manualDrawing = Line(elements: [
     Stars(length: 3),
     Text("Hello"),
     Space(),
     AllCaps(content: Text((name ?? "World") + "!")),
     Stars(length: 2),
])
print(manualDrawing.draw())
// "***Hello RAVI PATEL!**" 출력
swift
  • 이 코드는 동작하지만 약간 어색해 보여요. AllCaps 다음에 깊게 중첩된 괄호는 읽기 어렵죠.
  • namenil일 때 "World"를 사용하는 대체 로직은 ?? 연산자를 사용하여 인라인으로 처리해야 해서 더 복잡한 것은 어려워요.
  • 그림의 일부를 작성하기 위해 switch문이나 for 루프를 포함해야 하는 경우에는 수행할 방법이 없어 보여요.

Result Builder를 사용하면 우리는 이 코드를 일반 Swift 코드처럼 보이도록 다시 작성할 수 있어요.

Result Builder를 정의하려면 타입 선언에 @resultBuilder 속성을 작성하세요. 예를 들어, 아래 코드는 선언적인 구문을 사용하여 그림을 설명할 수 있는 DrawingBuilder라는 Result Builder를 정의합니다:

@resultBuilder
struct DrawingBuilder {
    static func buildBlock(_ components: Drawable...) -> Drawable {
        Line(elements: components)
    }
 
    static func buildEither(first: Drawable) -> Drawable {
        first
    }
 
    static func buildEither(second: Drawable) -> Drawable {
        second
    }
}
swift
  • DrawingBuilder 구조체는 Result Builder 구문의 일부를 구현하는 세 가지 메서드를 정의해요.
  • buildBlock(_:) 메서드는 코드 블록에서 일련의 줄을 작성할 수 있도록 지원해요. 이 메서드는 해당 블록의 구성 요소를 Line으로 결합합니다.
  • buildEither(first:)buildEither(second:) 메서드는 if``else를 지원해요.

우리는 함수의 매개변수에 @DrawingBuilder 속성을 적용하여 해당 함수에 전달된 클로저를 Result Builder가 해당 클로저에서 생성한 값으로 변환할 수 있어요. 예를 들면:

func draw(@DrawingBuilder content: () -> Drawable) -> Drawable {
    content()
}
 
func caps(@DrawingBuilder content: () -> Drawable) -> Drawable {
    AllCaps(content: content())
}
 
func makeGreeting(for name: String? = nil) -> Drawable {
    let greeting = draw {
        Stars(length: 3)
        Text("Hello")
        Space()
        caps {
            if let name = name {
                Text(name + "!")
            } else {
                Text("World!")
            }
        }
        Stars(length: 2)
    }
    return greeting
}
 
let genericGreeting = makeGreeting()
print(genericGreeting.draw())
// "***Hello WORLD!**" 출력
 
let personalGreeting = makeGreeting(for: "Ravi Patel")
print(personalGreeting.draw())
// "***Hello RAVI PATEL!**" 출력
swift
  • makeGreeting(for:) 함수는 name 매개변수를 받아 개인화된 인사말을 그려요.
  • draw(_:)caps(_:) 함수는 모두 @DrawingBuilder 속성으로 표시된 단일 클로저를 인자로 받아요.
  • 우리는 이러한 함수를 호출할 때 DrawingBuilder에서 정의한 특수 구문을 사용해요.
  • Swift는 그림에 대한 선언적 설명을 DrawingBuilder의 메서드에 대한 일련의 호출로 변환하여 함수 인자로 전달되는 값을 작성합니다.

예를 들어, Swift는 해당 예제에서 caps(_:)에 대한 호출을 다음과 같은 코드로 변환해요:

let capsDrawing = caps {
    let partialDrawing: Drawable
    if let name = name {
        let text = Text(name + "!")
        partialDrawing = DrawingBuilder.buildEither(first: text)
    } else {
        let text = Text("World!")
        partialDrawing = DrawingBuilder.buildEither(second: text)
    }
    return partialDrawing
}
swift
  • Swift는 if``else 블록을 buildEither(first:)buildEither(second:) 메서드에 대한 호출로 변환해요.
  • 우리는 자신의 코드에서 이러한 메서드를 직접 호출하지는 않지만, 변환 결과를 보면 DrawingBuilder 구문을 사용할 때 Swift가 코드를 어떻게 변환하는지 더 쉽게 확인할 수 있어요.

특수한 그리기 구문에서 for 루프를 작성할 수 있도록 지원을 추가하려면 buildArray(_:) 메서드를 추가하세요.

extension DrawingBuilder {
    static func buildArray(_ components: [Drawable]) -> Drawable {
        Line(elements: components)
    }
}
 
let manyStars = draw {
    Text("Stars:")
    for length in 1...3 {
        Space()
        Stars(length: length)
    }
}
swift
  • 위의 코드에서 for 루프는 그림의 배열을 생성하고, buildArray(_:) 메서드는 해당 배열을 Line으로 변환합니다.

Swift가 Builder 구문을 Builder 타입의 메서드 호출로 변환하는 방법의 전체 목록은 resultBuilder를 참조하세요.

Result Builder를 활용하면 우리는 중첩된 데이터를 선언적이고 읽기 쉬운 방식으로 생성할 수 있어요. 우리는 조건부 로직과 반복을 포함하여 자연스러운 Swift 구문을 사용할 수 있죠. Result Builder는 도메인 특화 언어(DSL)를 만드는 강력한 도구이며, 코드의 의도를 명확하게 표현할 수 있게 해줍니다.