This weekend I decided to try implementing a result builder using the new function body macro and here I share my results:
So, is it possible? Yes it is, but with a few limitations, specially regardless diagnostics.
enum StringBuilder: ResultBuilder {
public static func buildBlock(_ components: String...) -> String {
return components.joined(separator: ",")
}
public static func buildExpression(_ expression: Int) -> String {
return "\(expression)"
}
public static func buildOptional(_ component: String?) -> String {
component ?? ""
}
public static func buildEither(first component: String) -> String {
return component
}
public static func buildEither(second component: String) -> String {
return component
}
public static func buildArray(_ components: [String]) -> String {
return components.joined(separator: ",")
}
}
@ResultBuilder<StringBuilder>
func body() -> String {
"a"
"b"
"c"
if Bool.random() {
"true"
} else {
"false"
}
switch Int.random(in: 1...5) {
case 1:
"one"
case 2:
"two"
case 3:
"three"
default:
"default"
}
for i in 1...5 {
"\(i)"
}
}
The use is pretty much the same, but I had to make the macro generic over a type that implements a ResultBuilder
protocol. I wish it was possible to instead apply the @ResultBuilder
macro directly to the type that would generate a macro alias to a specialized macro.
macroalias @StringBuilder = ResultBuilderBody<StringBuilder>
The ResultBuilder
protocol was necessary to ensure it's possible to call a default buildExpression
. This is necessary because the main limitation of macros: The lack of information about external types.
The built-in @resultBuilder
attribute relies on being able to check if the builder has the appropriate methods during the body transformation. So it only calls buildExpression
if the builder supports it or it only visits a for-in
statement if the builder supports buildArray
, emitting a diagnostic otherwise.
Without knowing during the macro expansion which methods the builder implements it's necessary a default buildExpression
, thus the protocol, and all the if
s, switch
s and for-in
s have to be expanded and if the builder doesn't have the appropriate method the expanded macro will fail to compile, without a custom diagnostic or fix-it like @resultBuilder
can generate.
The supported methods could be specified using macro arguments:
// No `buildOptional`, `buildEither` and `buildArray`.
@ResultBuilder<IntBuilder>("buildBlock", "buildExpression")
func body() -> [Int] {
...
}
Not much practical, although it could be generated together with the macroalias
idea above:
macroalias @StringBuilder = ResultBuilderBody<StringBuilder>("buildBlock", "buildExpression")
This would still require all the methods to be inside the builder declaration so I think ultimately it would be necessary a way to access type information in a similar way that @resultBuilder
can.
There's also the fact the the function body is not type-checked so any warning or error not related to syntax will me emitted inside the expanded macro. I believe though that these diagnostic could be moved up, just didn't try.
So, that's it! Maybe this can useful information for the future of macros. You can check my implementation here.
It was quite fun to figure out how to expand body to a compilable code that relies solely on type inference. Expansion of switch
expression, for example, uses chained ternary operations:
switch Int.random(in: 1...5) {
case 1:
"one"
case 2:
"two"
case 3:
"three"
default:
"default"
}
let $component4 = {
var $case = 0
switch Int.random(in: 1 ... 5) {
case 1:
$case = 0
case 2:
$case = 1
case 3:
$case = 2
default:
$case = 3
}
let $case0_expr = {
let $component0 = StringBuilder.buildExpression("one")
return StringBuilder.buildBlock($component0)
}()
let $case1_expr = {
let $component0 = StringBuilder.buildExpression("two")
return StringBuilder.buildBlock($component0)
}()
let $case2_expr = {
let $component0 = StringBuilder.buildExpression("three")
return StringBuilder.buildBlock($component0)
}()
let $case3_expr = {
let $component0 = StringBuilder.buildExpression("default")
return StringBuilder.buildBlock($component0)
}()
let $b0 = $case0_expr
let $b1 = $case >= 1 ? StringBuilder.buildEither(first: $case1_expr) : StringBuilder.buildEither(second: $b0)
let $b2 = $case >= 2 ? StringBuilder.buildEither(first: $case2_expr) : StringBuilder.buildEither(second: $b1)
let $b3 = $case >= 3 ? StringBuilder.buildEither(first: $case3_expr) : StringBuilder.buildEither(second: $b2)
return $b3
}()