FunctionBuilder とは
Xcode 11で追加されたSwiftの新機能で、有り体に言えばSwift言語内にDSLを定義できる言語機能になります。
2019/12/07時点で、publicではない機能として定義されていますが、SwiftUIにおいて一部のViewの記述に利用されています。
まだpublicではないので、公式ドキュメントは存在しません。代わりに公式のフォーラムがあるのでそちらを参照すると良いでしょう。
https://forums.swift.org/t/function-builders/25167/10
なおこのFunctionBuilderはORTとは異なりSwift5.1のランタイムを必要としない機能のため、iOS13未満のOSでも動作します。
SwiftUIはORTが必要なのでiOS13以降であることが必須ですが、FunctionBuilderだけなら今から使えるわけです。(推奨するとは言っていない)
体験版ですね。遊んでみましょう。
FunctionBuilderの価値
FunctionBuilderを使うことで、シンプルな構文と複雑な型を両立させることが可能になる。
これがFunctionBuilderにおける最も大きな価値でしょう。
ただシンプルな構文を作るだけなら、Array<AnyFoo>
であっても、順番に変数を並べるだけでも構わなかったはず。なぜFunctionBuilderなのか。その結論にコードを書きながら至ることがこの記事の目的です。
結論から書くと、複雑な型のstatic関数が鍵になります。
TableViewにおける考察
「僕の考えた最強のTableView」
浪漫に溢れたコーディングです。誰だって大好きです。私も大好きです。
今回はFunctionBuilderを使って、TableViewDataSourceを生成する事を題材に考えていきます。
FunctionBuilderの集合の型を準備する
FunctionBuilderでは、1行毎の返り値を複数束ねてその集合を型として生成することで、DSLとして成り立つようになっています。
単体ならそのままで良いので、直和と直積、空集合についての定義を追加します。
struct Empty {
}
struct Pair<C0, C1> {
var c0: C0
var c1: C1
}
enum Either<C0, C1> {
case c0(C0)
case c1(C1)
}
簡単ですねv
FunctionBuilderを構成する
FunctionBuilderが定める関数を定義します。
いずれも返り値の群を引数に取り、集合を返す関数になります。
@_functionBuilder
struct UIBuilder {
static func buildBlock() -> Empty { .init() }
static func buildBlock<C>(_ c: C) -> C { c }
static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> Pair<C0, C1> { .init(c0: c0, c1: c1) }
static func buildIf<C>(_ c: C?) -> Either<C, Empty> { .init(from: c) /* extension書いてネ */ }
static func buildEither<T, F>(first: T) -> Either<T, F> { .c0(first) }
static func buildEither<T, F>(second: F) -> Either<T, F> { .c1(second) }
}
この宣言で UIBuilder
において2値までの返り値が使えるようになります。
集合の型を一般化する
Protocolを作って一般化しましょう。
protocol TableViewCellProtocol {
}
extension Empty: TableViewCellProtocol {
}
extension Pair: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
}
extension Either: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
}
DataSourceを作ってみる - FunctionBuilderを使ってみる
ここまでの作業で実際にFunctionBuilderとしてコンパイル可能になっています。
関数を定義して呼び出し、コンパイルしてみましょう。
extension UITableView {
func generateDataSource<Item, C: TableViewCellProtocol>(items: [Item], @UIBuilder _ tableViewCells: @escaping (UITableView, IndexPath, Item) -> C) -> UITableViewDataSource? {
dataSource
}
}
class MyTableViewCell0: UITableViewCell, TableViewCellProtocol {}
class MyTableViewCell1: UITableViewCell, TableViewCellProtocol {}
lazy var dataSource = self.tableView.generateDataSource(items: [1, 2, 3]) { tableView, indexPath, item in
if item % 2 == 0 {
MyTableViewCell0() // 今回は本質ではないのでDequeueは省略
} else {
MyTableViewCell1()
}
}
コンパイルは通ると思いますが、このままではまだ使い物になりません。
DataSourceを作ってみる - Cellのレンダリングをする
DataSourceクラスを作りましょう。Itemの配列を持ち、セットされたらリロードします。また、クロージャーからCellを生成できるようにします。
とは言っても、難しいことは何もせずに受け取ったクロージャを実行するだけです。
class TableViewDataSource<Item>: NSObject, UITableViewDataSource {
var items: [Item] {
didSet {
reloadData(oldValue, self.items)
}
}
var reloadData: ([Item], [Item]) -> ()
var cellForItem: (UITableView, IndexPath, Item) -> UITableViewCell
init(items: [Item], reloadData: @escaping ([Item], [Item]) -> (), cellForItem: @escaping (UITableView, IndexPath, Item) -> UITableViewCell) {
self.items = items
self.reloadData = reloadData
self.cellForItem = cellForItem
super.init()
}
func numberOfSections(in tableView: UITableView) -> Int {
1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
cellForItem(tableView, indexPath, items[indexPath.row])
}
}
DataSourceとして動作させるために、UITableViewCell
を取り出す必要があります。
空の TableViewCellProtocol
は実質 Any
なので宣言を変更して対応します。
protocol TableViewCellProtocol {
func asTableViewCell() -> UITableViewCell?
}
extension Empty: TableViewCellProtocol {
func asTableViewCell() -> UITableViewCell? { nil }
}
extension Pair: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
func asTableViewCell() -> UITableViewCell? { c0.asTableViewCell() ?? c1.asTableViewCell() }
}
extension Either: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
func asTableViewCell() -> UITableViewCell? {
switch self {
case .c0(let c0): return c0.asTableViewCell()
case .c1(let c1): return c1.asTableViewCell()
}
}
}
extension TableViewCellProtocol where Self: UITableViewCell {
func asTableViewCell() -> UITableViewCell? { self }
}
このDataSourceクラスを、先の generateDataSource
で生成、利用します。
extension UITableView {
func generateDataSource<Item, C: TableViewCellProtocol>(items: [Item], @UIBuilder _ tableViewCells: @escaping (UITableView, IndexPath, Item) -> C) -> TableViewDataSource<Item> {
let dataSource = TableViewDataSource(
items: items,
reloadData: { [weak self] _, _ in self?.reloadData() },
cellForItem: { tableView, indexPath, item in
return tableViewCells(tableView, indexPath, item).asTableViewCell()!
})
self.dataSource = dataSource
return dataSource
}
}
DataSourceが大体動くようになりました。
DataSourceを作ってみる - registerできるようにする
今回はdequeueを省略したので動いてますが、TableViewを使う場合は本来dequeueが必要であり、対となるregisterも必要です。
TableViewCellProtocol
に register
するための関数を生やします。
// ※先の章で宣言した項目について省略
protocol TableViewCellProtocol {
static func register(to tableView: UITableView)
}
extension Empty: TableViewCellProtocol {
static func register(to tableView: UITableView) {
}
}
extension Pair: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
static func register(to tableView: UITableView) {
C0.register(to: tableView)
C1.register(to: tableView)
}
}
extension Either: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
static func register(to tableView: UITableView) {
C0.register(to: tableView)
C1.register(to: tableView)
}
}
extension TableViewCellProtocol where Self: UITableViewCell {
static func register(to tableView: UITableView) {
print("Called!! \(Self.self)")
}
}
printの行が呼ばれれば、これを本来のtableView.register
の呼び出しに変更すれば動作することは自明ですね。
呼び出し側でこの関数を利用しましょう。
extension UITableView {
func generateDataSource<Item, C: TableViewCellProtocol>(items: [Item], @TableViewBuilder _ tableViewCells: @escaping (UITableView, IndexPath, Item) -> C) -> TableViewDataSource<Item> {
C.register(to: self) // ここに追加!!
let dataSource = TableViewDataSource(
items: items,
reloadData: { [weak self] _, _ in self?.reloadData() },
cellForItem: { tableView, indexPath, item in
return tableViewCells(tableView, indexPath, item).asTableViewCell()!
})
self.dataSource = dataSource
return dataSource
}
}
たった一行追加するだけで、TableViewCellのRegisterが完了するようになりました。
3個以上の返り値に対応する
必要な材料は揃っているので、 TableViewBuilder
に雪だるま式に buildBlock
を追記していくだけで動くようになります。
extension TableViewBuilder {
static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> Pair<Pair<C0, C1>, C2> {
.init(c0: .init(c0: c0, c1: c1), c1: c2)
}
static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> Pair<Pair<Pair<C0, C1>, C2>, C3> {
.init(c0: .init(c0: .init(c0: c0, c1: c1), c1: c2), c1: c3)
}
...
}
lazy var dataSource = self.tableView.generateDataSource(items: [1, 2, 3]) { tableView, indexPath, item in
if item == 0 {
MyTableViewCell0()
} else if item == 1 {
MyTableViewCell1()
} else if item == 2 {
MyTableViewCell2()
} else {
MyTableViewCell3()
}
}
MyTableViewCell0
~ MyTableViewCell3
の register
が呼び出せることが確認できます。
FunctionBuilderの価値(2回目)
最後に書いた4種類のCellを返す関数ですが、どこにも Pair
や Either
、ましてやそれがネストした凶悪な型はどこにも見当たりません。返り値もそれなりにシンプルな TableViewDataSource<Item>
です。
しかし、実行時に各Cellの register
をコールするスタックトレースの底にある型は、Either<Either<MyTableViewCell0, MyTableViewCell1>, Either<MyTableViewCell2, MyTableViewCell3>>
です。
この型から Either
の register
を再帰的に呼び出すことで、MyTableViewCell0
~ MyTableViewCell3
のregister
がコールされる。
ちょうどMLにおけるDefine and Runで計算グラフを作るように、事前の処理を定義できるのです。
ビルドされた関数を実行するだけでなく、その関数の型の評価に価値がある、そういったケースでFunctionBuilderは真価を発揮します。
ココがダメだよFunctionBuilder
では実際にこれが最強のTableViewになるかというと、そんな事はありません。
FunctionBuilderには以下の制限があります。
if let, if caseが使えない
OptionalBindingが使えません。したがってOptionalを非Optionalとして取り出す方法は
if value != nil {
MyComponent(value!)
}
になります。SmartCastが欲しくなる。
switchが使えない
残念ながらSwitchも使えません。したがってAssociatedValueを取り出す方法は、
extension MyEnum {
var myCase: MyCase? {
switch self {
case myCase(let value): return value
default: return nil
}
}
}
if value.myCase != nil {
MyComponent(value.myCase!)
}
になります。これはちょっと厳しい…
capture listが使えない
SwiftUIは全て値型だったのでcapture listを必要としない世界でした。
UIKitはそうではないので、参照カウントに気をつけなければならない。しかしどうやらcapture listは使えません。(バグ?)
したがって関数の外に手書きします。しかもOptionalBindingの呪いも刺さります。
weak var weakSelf = self
tableView.generateDataSource(items: [1,2,3]) { (_, _, _) in
if weakSelf != nil {
weakSelf!.createCell(...)
}
}
おまけ
すぐに動かせるサンプルをGithubに用意しておきました。
https://github.com/tarunon/UIViewBuilder
プロダクトで使うクオリティではない、というのとFunctionBuilderは未だpublicではないということにご注意下さい。
CollectionViewへの拡張や、差分更新などはやっていませんが、既存の他のコードと組み合わせて十分再現可能だと思います。
是非遊んでみて、そしてあわよくば自分のコードに取り込んでみてください。完成させてくれても嬉しいのよ。
それでは。