7
Help us understand the problem. What are the problem?

posted at

updated at

Organization

resultBuilderを使ってswift製の内部DSLを作ろう

resultBuilderとは?

resultBuilderとはSwift5.4で新たに追加されたアトリビュートの一つです。

この機能を一言で説明すると「内部DSLの生成補助ツール」だと思います。
(上記は個人的な理解です。正確な定義はproposalや公式のドキュメントを参照お願いします。)

内部DSLとは?

DSLとはドメイン固有言語の略称であり、特定のドメイン領域に特化した言語のことを意味します。
DSLにも2種類あり、プログライミング言語の書き方を工夫して見かけ上の構文を自然言語に近づけたものを内部DSL、汎用プログラミング言語とは全く別の構文を持ったものを外部DSLと呼ぶそうです。

内部DSLの例

内部DSLの例としてRubyのRspecが挙げられます。
RspecはRubyという言語を拡張したフレームワークですが、テストの記述という機能に特化して自然言語のようにテストケースを記載できます。

外部DSLの例

一方外部DSLの例としてはSQLが挙げられます。
SQLはデータ操作に特化した言語ですが、RubyやJavaのような汎用プログラミング言語とは異なる独自の構文で記述する必要があります。


DSLについては以下の記事で詳しく解説して下さってます。是非見てみてください。


上記を踏まえると、swiftにおける内部DSLとは「swiftという言語を拡張し、特定のドメイン領域の実装を書きやすく読みやすくした言語」だと言えそうです。

例えばSwiftUIのViewのbody実装部分はまさにresultBuilderを利用した内部DSLと言えます。
Viewをポンポン置くだけでViewを構築できるのは不思議だなと思ったことはありませんか?
あれの裏側ではresultBuilderが機能しています。

こんな風にresultBuilderを使うことで今までより簡単に内部DSLが作成できます。
それはつまり、これからのiOS開発では各プロジェクトのドメインに対してどんどん内部DSLが作成され、
複雑だったドメインの実装がどんどん読みやすく書きやすくなっていくのではないかと思ってます。
なので、僕はこのresultBuilderは近いうちにiOS開発において当たり前に使われる存在になってくると思ってます。
(WWDCのaync/awaitやactorの登場に影に隠れてあんまり目立ってない気がしますが、、、)

なので今回は以下2点を分かる範囲で書いてみます。

  1. resultBuilderを使うと何ができてどういう理由で内部DSLが作成しやすくなるのか
  2. resultBuilderを使用した内部DSL作成例

この記事を読んでresultBuilderを使って内部DSLを作ってみようという気になる方がいたら幸いです。

1.resultBuilderを使うと何ができて何が嬉しいのか

シンタックスなしで配列を宣言できる。

まずはこちらの配列をご覧ください。

var numbers: [Int] {[
    1,
    2,
    3
]}

// [1,2,3]

ただのIntの配列です。
resultBuilderを使うと上記の配列をこんな感じに置き換えられます。

@resultBuilder
public struct NumbersBuilder {
    public static func buildBlock(_ components: Int...) -> [Int] {
        components
    }
}

@NumbersBuilder
var numbersMadeViaBuilder: [Int] {
    1
    2
    3
}
// [1,2,3]

SwiftUIのように要素を置くだけでIntの配列を生成できました。

配列を宣言していた時と比べ、resultBuilderを利用した場合は以下の変化がありますね。
1. 各要素の末尾にカンマを打つ必要がない。
2. 配列の最初と最後にArrayのシンタックス([])をつけなくてよい。

小さな変化ですが、カンマの追加や[]の区切りを修正するのも要素が増えてくると面倒になってきます。
resultBuilderで記載する場合はそれらの作業は不要になります。

要素を列挙する際に条件分岐やループを使える

まだあります!

resultBuilderを使うと要素を列挙する際に条件分岐やループが使用できます

配列を条件によって分岐させたい場合があるとします。
shouldAppendFourがtrueの時に4を追加する場合を考えてみます。

// Pattern1
var numbers: [Int] {
    if shouldAppendFour {
        return [1, 2, 3, 4]
    } else {
        return [1, 2, 3]
    }
}

// Pattern2
var numbers: [Int] {
    var numnbers = [1, 2, 3]

    if shouldAppendFour {
        numnbers.append(4)
    }

    return numnbers
}

こんな感じでしょうか。上記の書き方の課題を整理してみましょう。

Pattern1: 追加したい要素以外も宣言する必要がある。

Pattern1の書き方では条件によっては4が欲しいだけなのに、それ以外の要素も毎回宣言する必要があります。
+の演算子で結合する場合も同様です。
これくらいの規模なら気になりませんが、要素の数が増えてきたり条件が複雑になると行数も増えて読み辛くなります。

Pattern2: appendのために配列をvarで宣言しないといけない。

Pattern1の書き方では4を追加するために配列を再代入可能なvarで宣言する必要があります。
上記の例では計算型プロパティの中での宣言なので意図せず代入される恐れはありませんが、再代入可能というだけで複雑度は少し上がります。

resultBuilderの場合

resultBuilderでは要素を並べている途中で条件分岐を記述できます。

ifを使用できるように先ほど作ったNumbersBuilderを修正します。

@resultBuilder
public struct NumbersBuilder {

    public static func buildBlock(_ components: Int...) -> [Int] {
        components
    }

    public static func buildOptional(_ component: [Int]?) -> Int {
        component?.first ?? 0
    }
}

buildOptional(_)の関数を用意してやることでelseなしのif文を許容できるようになります。

上記を踏まえて4を追加してみましょう。

@NumbersBuilder
var numbersMadeViaBuilder: [Int] {
    1
    2
    3
    if shouldAppendFour {
        4
    }
}

これで条件ごとに追加したい要素以外も宣言したり、varで宣言してappendする必要がなくなりました!
見た目もかなりスッキリします。

resultBuilderには他にも様々な拡張用の関数が用意されています。

@resultBuilder
public struct NumbersBuilder {

    public static func buildBlock(_ components: Int...) -> [Int] {
        components
    }

    // elseなしのifが使えるようになる
    public static func buildOptional(_ component: [Int]?) -> Int {
        component?.first ?? 0
    }

    // if-elseが使えるようになる
    public static func buildEither(first component: [Int]) -> Int {
        component.first ?? 0
    }

    // if-elseが使えるようになる
    public static func buildEither(second component: [Int]) -> Int {
        component.first ?? 0
    }

    // for-inが使えるようになる
    public static func buildArray(_ components: [[Int]]) -> Int {
        components.flatMap { $0 } .reduce(0) { $0 + $1 } // for-inの中身全部足した合計を返す
    }

    // #if (ビルドターゲット指定での条件分岐)が使えるようになる
    public static func buildLimitedAvailability(_ component: [Int]) -> [Int] {
        component
    }
}

var sources: [Int] { [1, 2, 3, 4] }

@NumbersBuilder
var numbersMadeViaBuilder: [Int] {
    1
    2
    3

    shouldAppendFour ? 4 : 5

    for source in sources {
        source * 5
    }

    #if DEBUG
    6
    #endif
}

print(numbersMadeViaBuilder) // [1, 2, 3, 4, 50]

要素を変換したり、最終的なアウトプットを加工して返すこともできる。

さらにresultBuilderを使うと要素を変換したり、最終的なアウトプットを加工して返すこともできます。

要素を変換する

buildExpression(_ expression:)を使うと受け取った要素を他の型に変換できます。

@resultBuilder
public struct NumbersBuilder {

    public static func buildBlock(_ components: Int...) -> [Int] {
        components
    }

    // Doubleが来たらIntに変換する
    public static func buildExpression(_ expression: Double) -> Int {
        Int(expression)
    }
}

@NumbersBuilder
var numbersMadeViaBuilder: [Int] {
    1.5
    2.3
    7.9
}

print(numbersMadeViaBuilder) // [1, 2, 7]

この書き方ができることによって、型の異なる要素を同列に並べる表現が可能になります。

resultBuilderを使用しない場合、swift自身の型チェックにより異なる型を列挙するのは困難でした。
当たり前ですが、Intの配列で宣言するとIntしか扱うことはできません。
IntとDoubleが混在させる場合は、Genericsやprotocolによって抽象化した型を扱うか、別々に用意して同じ型に変換して扱う必要がありました。
上記のような型の整合性に対応するためにコードを書くのも冗長ですし、本来は要素を列挙して同列に扱いたいものが別々に宣言されると見通しが悪くなります。

var numbers: [Int] {[
    1,
    2.0 // コンパイルエラー
]}

resutlBuilderを使うことで型の変換をreusltBuilder側に委譲しつつ、特定の要素を同列に列挙していくことが可能です。

最終的なアウトプットを加工して返す

buildFinalResult(_ component:)を使うと最終的なアウトプットの型を指定することができます。

@resultBuilder
public struct NumbersBuilder {

    public static func buildBlock(_ components: Int...) -> [Int] {
        components
    }

    // Doubleが来たらIntに変換する
    public static func buildExpression(_ expression: Double) -> Int {
        Int(expression)
    }

    // 全部の合計をIntにして返す
    public static func buildFinalResult(_ component: [Int]) -> Int {
        component.reduce(0) { $0 + $1 }
    }
}


@NumbersBuilder
var sum: Int {
    1.5
    2.3
    7.9
}

print(sum) // 10

1のまとめ

ここまでの内容をまとめます。

  • reusltBuilderを使うとできること

    • シンタックスなしで配列を記述できる。
    • 配列の中で条件分岐やループなどの機能を使えるように拡張できる。
    • 受け取った要素を変換して列挙できる。
    • 列挙した要素を最終的に加工して返すことができる。
  • 嬉しいポイント

    • 要素ごとにカンマやカッコなどの細かいシンタックスの調整をしなくて良い。
    • 条件分岐やループが使えるので、重複した要素を用意したり、varで宣言してappendしなくても良い。
    • 異なる型を同列に列挙できる。

2.resultBuilderを使用した内部DSL作成例

Swift5.4で登場したresultBuilderですが、既に各方面で使われています。

とりあえずプロジェクトで使ってみたい場合

配列の生成をresultBuilderでやってみるのがおすすめです。
Builderとなるstruct側をGenericにしてあげると、一つの型ならどんな型の配列も対応できる配列生成用のBuilderができます。

@resultBuilder
public struct ArrayBuilder<T> { 
    public static func buildBlock(_ components: T...) -> [T] {
        components
    }
}

// Tを変えれば何でもいける。

@ArrayBuilder<String>
var strings: [String] {
    "あああ"
    "いいい"
    "ううう"
}

print(strings) // ["あああ", "いいい", "ううう"]

@ArrayBuilder<URL>
var urls: [URL] {
    URL(string: "https://www.google.com")!
    URL(string: "https://qiita.com")!
}

print(urls) // [https://www.google.com, https://qiita.com]

配列の生成はどんなプロジェクトでもどこかでやっていると思いますので、少し書き換えてみて効果を実感できます。

resultBuilderを使用した内部DSL作成例

以下のレポジトリがとても良いです。

色々と紹介されてますが、UIKit周りはConstraintの組み立てやStackViewへのsubviewの追加とかが使いやすそうです。

image.constraints { view in
    view.centerXAnchor == container.centerXAnchor
    view.topAnchor == container.topAnchor + 20
    view.widthAnchor == container.widthAnchor -- 20
    view.heightAnchor == view.widthAnchor * 0.6
}
icon.constraints { view in
    view.centerAnchor == iconContainer.centerAnchor
    view.sizeAnchor == CGSize(width: 20, height: 20)
}

あと個人的に面白いと思ったのがCombineのPubliserをresultBuilderで構築できるようにした例です。

CombineでPublisherからPubliserに変換する時は途中flatMapを噛ませます。
その時にFailureのハンドリングやeraseToAnyPubliser()の呼び出しでネストもコード量も増えるのが気になってました。

上記のレポジトリではFinalResultでeraseToAnyPubliser()を返したり、ExpressionでsetFailureType(to: )を使ったりと、Publisherを連結する時の冗長性をresultBuilderで排除してます。
Combineを使うことが増えてきたので使ってみようと思います。

2.のまとめ

  • resultBuilderをとりあえず試してみたい場合はGenericな配列の生成用Builderを試してみるのがおすすめ。
  • awesome-reuslt-buildersに色んなドメインでの内部DSLがまとめられてる。
  • PubliserBuilder良さそう。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
7
Help us understand the problem. What are the problem?