Swift 1.2 が正式にリリースされ、 flatMap
が追加されました。僕は Extension を使って flatMap
を独自に実装し1、利用していたため、これまでも実ブロジェクトで flatMap
を使ってきました。
本投稿は、 僕が携わった実際のアプリのコードから flatMap
を使っている箇所を抜き出し、その使い方を紹介する ものです。
なお、コードを抜き出すに当たって問題がないように若干修正してあるので完全に元のままではありません。また、実プロジェクトからの抜粋なのでユースケースとして網羅的でないことをご了承下さい。
flatMap
は Array
だけでなく Optional
のメソッドでもあるので、以下セクションを分けて記載します。
データ構造
初めに簡単にこのアプリで扱うデータ構造について説明します。今回取り上げるアプリでは、 Chapter
, Section
, Page
, Content
, Resource
が入れ子になるデータ構造を扱っています。
Chapter
は sections: [Section]
を持っていて、 Section
は pages: [Page]
を持っていて…、というシンプルな構造です。
// これは Swift のコードではありません。データ構造を表しているだけです。
Chapter { sections: [Section] }
Section { pages: [Page] }
Page { contents: [Content] }
Content { resources: [Resource] }
Array
case 1: ある Chapter に属するすべての Resource を取り出し、それをサーバから一括ダウンロードするために NSURL に変換する
chapter.sections.flatMap { $0.pages }.flatMap { $0.contents }.flatMap { $0.resources }.map { toUrl($0) }
Chapter
の持つ [Section]
を [Resource]
に変換するために flatMap
を連続して使い、最後に map
で [Resource]
を [NSURL]
に変換しています。このように、 flatMap
は 入れ子になった構造をフラットにしてデータを取り出す ために使えます。
ここでキモになるのは、単に入れ子の Array
をフラットにするのではなく、フラットにするのと同時に任意の変換が可能が点です。上記では、 sections.flatMap { $0.pages }
のように、 Section
を [Page]
に 変換した上で結合 しています。これが flatMap = flatten + map という名前の理由です。
もしこれを flatMap
ではなく map
でやると Array
が入れ子になってしまい、結果が [NSURL]
ではなく [[[[NSURL]]]]
になってしまいます。
case 2, 3: UITableView で Section に属するすべての Content を表示する
※最初に掲載したコードはデータ件数が多いときにパフォーマンスが劣化するため、サンプルとして望ましくないと思い修正しました。その結果、 case 2 と case 3 が一つの case になってしまいました。
private let sectionContents: [[Content]] = sections.map { $0.pages.flatMap { $0.contents } }
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sectionContents[section].count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let content = sectionContents[indexPath.section][indexPath.row]
...
}
こちらも、 Page
, Content
という入れ子構造をフラットにするために flatMap
を使っています。
通常、 UITableView
では 1 階層ずつ潜っていくことが多いですが、 Page
に一つしか Content
がないことも多く、 Page
のために一つ階層を作ってしまうと冗長だったため Page
の階層を潰してフラットにしました。
また、 UITableView
の section を使って Section
と Content
の 2 階層分を一つの UITableView
で表示しているので、 [Content]
ではなく [[Content]]
になるように、間の Page
の階層だけをフラットにしています。
case 4: ID で Page を引くことができる Dictionary を作る
chapters.flatMap { $0.sections }.flatMap { $0.pages }.reduce([:]) { (var result, page) in
result[page.id] = page
return result
}
元のデータ構造では Page
が異なる Section
にバラバラに保持されていますが、そのままでは Page
の ID で Page
を引きたいときに不便です。 flatMap
で [Chapter]
を [Page]
に変換し、その後 reduce
で [String: Page]
( String
は Page
の ID )を生成しています。
case 5: JSON のパースに失敗した要素を含まない Array を作る
json["beacons"].array?.flatMap {
let major: NSNumber? = $0["major"].number
let minor: NSNumber? = $0["minor"].number
let id: String? = $0["id"].string
// どれか一つでも nil なら結果を nil にする。 nil になってしまった場合は空配列に置き換えて結果から除去する。
return (Beacon.construct <%> uuid <*> major <*> minor <*> id).map { [$0] } ?? []
}
上記のコードはやや複雑です。やりたいことは次のような処理と同じようなことです。
今、 ["2", "3", "four", "5", "six", "7"]
という Array
があるとします。この各要素を toInt
で Int
に変換したいとします。しかし、 "four"
や "six"
は当然変換に失敗します。変換に失敗した要素は結果から取り除き、 [2, 3, 5, 7]
という Array
を得るにはどうすれば良いでしょうか。
このようなケースでは単純な map
による変換は使えません。また、変換と条件判定が同時に行われるので filter
も不適切です。通常は次のように reduce
を使って処理します。
["2", "3", "four", "5", "six", "7"].reduce([Int]()) { (var result, element) in
if let number = element.toInt() {
result.append(number)
}
return result
}
しかし、 reduce
を使ったコードを書くのは面倒です。パフォーマンスが重要な箇所でなければ、 flatMap
を使って次のように書くこともできます。
["2", "3", "four", "5", "six", "7"].flatMap { (element: String) -> [Int] in
if let number = element.toInt() {
return [number]
} else {
return []
}
}
つまり、要素の変換に成功したら 1 要素の Array
を、失敗したら空の Array
を return
して結合させることで、変換に失敗した要素を取り除くことができるのです。
これをワンライナーで書くと次のようになります。
["2", "3", "four", "5", "six", "7"].flatMap { $0.toInt().map { [$0] } ?? [] }
最初に挙げた JSON のパースのコードはこれと同じことをやっています。 major
, minor
, id
のどれか一つでもパースすることができなければ Beacon.construct
に失敗して nil
が return
されます( <*>
はアプリカティブスタイルです)。
Optional
case 6: String? で String がキーの Dictionary を引く
pageIdOrNil.flatMap { pageIdToPage[$0] }
pageIdOrNil
は String
ではなく String?
なので、そのままでは pageIdToPage[pageIdOrNil]
のように使えません。そこで、 flatMap
を使って Optional
の中身を取り出し Dictionary
のキーとして利用します。
map
でも同じことができるように思えるかもしれませんが、 Dictionary
をキーで引いた戻り値は値の Optional
になるので、このケースだと map
を使うと戻り値の型が Page?
ではなく Page??
になってしまいます。 Optional
についても、 flatMap
は入れ子になった Optional
を 変換と同時にフラットにする 存在です。 flatMap
は Array
も Optional
も中身を変換してフラットにしてくれます。 Array
と Optional
の類似性については、 "ArrayとOptionalのmapは同じです" をご覧下さい。
Optional
な値を取りあつかうには Optional Chaining foo?.bar
が便利ですが、上記のようなパターン foo.flatMap { bar($0) }
ではうまくいきません。 Optional Chaining で書けることはすべて flatMap
で書くことができるので、 Optional Chaining は flatMap
の 一部のケースを便利に書くための方法 と考えておくと良いです。
詳しくは "SwiftのOptional型を極める" をご覧下さい。
なお、 flatMap
を使わずに上記の処理を書こうとすると次のようになり、とても面倒です。
let page: Page?
if let pageId = pageIdOrNil {
page = pageIdToPage[pageId]
} else {
page = nil
}
case 7: String が引数のイニシャライザに String? を渡す
json["uuid"].string.flatMap { NSUUID(UUIDString: $0) }
これも case 6 と同様のケースです。 String?
のままでは引数として使えないので flatMap
を使って中身を取り出してイニシャライザに渡しています。このイニシャライザの戻り値の型は NSUUID?
なので、ただの map
では戻り値の型が NSUUID??
になってしまうので flatMap
を使います。
おまけ: Int? と Int? を足す
本文の修正で case 2 と case 3 が一つになってしまい、ユースケースが 6 個になってしまったので、「実際のプロジェクトから抜き出して」いませんが、 flatMap
が使える他のケースを挙げておきます。
Swift を使っていると Optional
だらけになってしまいますが、 Int?
同士を足そうとするだけでも if let
で分岐してだと大変です。 flatMap
を入れ子にすれば次のように 1 行で計算することができます。
let a: Int? = 2
let b: Int? = 3
a.flatMap { a0 in b.flatMap { b0 in a0 + b0 } } // 5
let c: Int? = nil
a.flatMap { a0 in c.flatMap { c0 in a0 + c0 } } // nil
-
bind
とか他の名前もあり得ましたが、たまたまシグネチャが完全一致しました。 ↩