LoginSignup
69

More than 5 years have passed since last update.

Swift 1.2で導入されたflatMapのユースケース7個を実際のプロジェクトから抜き出してみた

Last updated at Posted at 2015-04-09

Swift 1.2 が正式にリリースされ、 flatMap が追加されました。僕は Extension を使って flatMap独自に実装し1、利用していたため、これまでも実ブロジェクトで flatMap を使ってきました。

本投稿は、 僕が携わった実際のアプリのコードから flatMap を使っている箇所を抜き出し、その使い方を紹介する ものです。

なお、コードを抜き出すに当たって問題がないように若干修正してあるので完全に元のままではありません。また、実プロジェクトからの抜粋なのでユースケースとして網羅的でないことをご了承下さい。

flatMapArray だけでなく Optional のメソッドでもあるので、以下セクションを分けて記載します。

データ構造

初めに簡単にこのアプリで扱うデータ構造について説明します。今回取り上げるアプリでは、 Chapter, Section, Page, Content, Resource が入れ子になるデータ構造を扱っています。

Chaptersections: [Section] を持っていて、 Sectionpages: [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 の階層を潰してフラットにしました。

また、 UITableViewsection を使って SectionContent の 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]StringPage の 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 があるとします。この各要素を toIntInt に変換したいとします。しかし、 "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 を、失敗したら空の Arrayreturn して結合させることで、変換に失敗した要素を取り除くことができるのです。

これをワンライナーで書くと次のようになります。

["2", "3", "four", "5", "six", "7"].flatMap { $0.toInt().map { [$0] } ?? [] }

最初に挙げた JSON のパースのコードはこれと同じことをやっています。 major, minor, id のどれか一つでもパースすることができなければ Beacon.construct に失敗して nilreturn されます( <*>アプリカティブスタイルです)。

Optional

case 6: String? で String がキーの Dictionary を引く

pageIdOrNil.flatMap { pageIdToPage[$0] }

pageIdOrNilString ではなく String? なので、そのままでは pageIdToPage[pageIdOrNil] のように使えません。そこで、 flatMap を使って Optional の中身を取り出し Dictionary のキーとして利用します。

map でも同じことができるように思えるかもしれませんが、 Dictionary をキーで引いた戻り値は値の Optional になるので、このケースだと map を使うと戻り値の型が Page? ではなく Page?? になってしまいます。 Optional についても、 flatMap は入れ子になった Optional変換と同時にフラットにする 存在です。 flatMapArrayOptional も中身を変換してフラットにしてくれます。 ArrayOptional の類似性については、 "ArrayとOptionalのmapは同じです" をご覧下さい。

Optional な値を取りあつかうには Optional Chaining foo?.bar が便利ですが、上記のようなパターン foo.flatMap { bar($0) } ではうまくいきません。 Optional Chaining で書けることはすべて flatMap で書くことができるので、 Optional ChainingflatMap一部のケースを便利に書くための方法 と考えておくと良いです。

詳しくは "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 2case 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


  1. bind とか他の名前もあり得ましたが、たまたまシグネチャが完全一致しました。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
69