【Scala】flatMap は怖くない!
はじめに
こんばんは!
突然ですが、 flatMap 使ってますか?
flatMap は難しいメソッドではありません。たった一つの願いを叶えるために生まれた、とってもシンプルなメソッドです。
そして、使いこなすことができればとても強力です。
もし flatMap を List 型でしか使ったことがないのであれば、少しもったいないです。
ちょっと長くなりますが、 flatMap 生まれる理由になった「flatMap を使うと叶えることができる願い」とは何なのか、一緒に探っていきましょう!
さて、flatMap について探るためには、値たちが持っている 文脈 について知っておく必要があります。
ぱっと想像しにくいし、あまり関係なさそうに思うかもしれませんが、とりあえず進みましょう。
文脈を持った値
Option[Int]
は、「整数があるかもしれないし、ないかもしれない」という文脈を持っています。Future[String]
を同じように考えてみると、これも「将来的に取得できるであろう文字列」という文脈を持っています。
また、これらの値が持つ文脈は、値そのものを修飾するようになっていることが見て取れます。
- "整数があるかもしれないし、ないかもしれない"
- "将来的に取得できるであろう文字列"
このように、コード上では SomeClass[A]
の形で表現でき、それが「どのような A であるか」を説明をすることができる値を文脈付きの値と呼びます。
Scala API から例をいくつか上げてみましょう。
データ型 | 文脈 |
---|---|
List[A] | 存在しない、または1個以上の順序をもった重複しうる A |
Set[A] | 存在しない、または1個以上の重複しない A |
Future[A] | 未来に取得できるかもしれないし、できないかもしれない A |
また、型引数は必ずしも1つでなければならないわけではありません。たとえば、次のような値も文脈を持っています。
データ型 | 文脈 |
---|---|
Map[X, A] | 存在しない、または任意 X 型のキーに一対一で紐付いている1個以上の A |
ここで注意が必要なのは「型引数をとる全てのクラス(全ての総称型) ≠ 文脈付きの値」ということです。型引数をとり、文脈付きの値とはいえないクラスも存在します。
たとえば SeqFactory[A]
は、 Seq のサブクラス A を生成するファクトリであり、 文脈付きの値ではありません。もっと身近な例を上げると、わたし達が普段よく目にしている(であろう) Provider[A]
や Publisher[A]
というインターフェイスも、それぞれ「Aを提供するもの」「Aを発行するもの」という意味であり、Aという値を修飾しているわけではないので文脈付きの値とは呼びません。
文脈付きの値を返す関数
親友検索を考える
さて、文脈付きの値がどのようなものかわかってきたところで、実際に文脈付きの値を生成する関数を作ってみましょう。
…などと大げさに言ってみましたが、そんなもの、今までだってたくさん作ってますよね。
List や Option だって文脈付きの値(そしてその代表格!)なのですから、当然です。ある値をとって List を返す関数などは、何十回、何百回と書いたことがあると思います。
ここではある SNS のバックエンドを作成しているとしましょう。
このサービスではユーザー同士が友達登録できるものとします。
findBestFriend 関数は、ユーザーID(文字列)を引数にとって、そのユーザーにとっての「一番のなかよし」ユーザーのユーザーIDを返す関数です。
def findBestFriend(userId: String): Option[String] = { val friends: List[String] = UserRepository.findById(userId).friends friends.headOption }
※ UserRepository.findById が異常系を想定していないのは、説明をしやすくするためです。本来、findByIdのようなメソッドは、 Option[User] を返すべきです。
friends リストは、友達のユーザーID(文字列)のリストです。 型は List[String]
ですね。
また friends リストは、ユーザー自身が仲良しな順に自由にソートできるものと考えてください。
friends.headOption
は、仲良し順にソートされた友達リストの最初の要素を取得します。これはすなわち、ユーザーにとって最も仲がいいユーザーを取得していることと同義です。
ところで、なぜ戻り値は Option にくるまれているのでしょうか? それは、友達がひとりもいなければ一番の仲良しも存在しないからです。
つまり、この戻り値 Option[String]
は「いるかもしれないし、いないかもしれない一番仲良しなユーザーのID」という文脈付きの値です!
よって、この関数は あるふつうの値(文字列)をとって、文脈付きの値(いるかもしれない)を返す関数 と説明できます。
findBestFriend 関数を(ふつうに)使ってみる
せっかく作ったし、実際に findBestFriend 関数を使ってみましょうか。
val maybeBestFriend = findBestFriend(userId = "minami") val result = maybeBestFriend match { case None => "ともだちなんていらないよ…" case Some(bestFriend) => s"${bestFriend} は、私の一番のともだち!" } println(result) // -> kosaka は、私の一番のともだち!
非常にシンプルですね。 findBestFriend の戻り値は文脈をもっているため、文脈によって処理が分岐しています。
あ、そういえばこの match 式は fold で置き換え可能ですね。時間があればあとでリファクタリングしておくことにしましょう。
ところで、実行結果を見てみると minamiさん にとっては kosakaさん が一番のともだちのようですが、 kosakaさん にとっても minamiさん が一番のともだちといえるのでしょうか?
ユーザーの friends リストは、ユーザー自身が自由にソートできる、と説明しましたね。
それならば、 minamiさん が kosakaさん を友達リストの最上位に設定しているからといって、 kosakaさん が同じように minamiさん をリスト最上位に設定しているとは限りません。
つまり、この SNS における「一番のともだち」関係は、無情にも片思いの可能性があるということです。
println(findBestFriend(userId = "kosaka")) // -> Some(sonoda)
両思いかどうかを判定する関数
今回、この「一番のともだち」関係が両思いの場合には、画面上にこっそりハートマークを表示するという追加要件が発生しました。
ではそれを達成するために、一番なかよしの友達と両思いかどうか判定する関数を実装しましょう。
def hasMutualBestFriend(userId: String): Boolean = { val maybeBestFriend: Option[String] = findBestFriend(userId) maybeBestFriend match { case None => false case Some(bestFriend) => val maybeBestFriendOfBestFriend: Option[String] = findBestFriend(bestFriend) maybeBestFriendOfBestFriend match { case None => false case Some(bestFriendOfBestFriend) => userId == bestFriendOfBestFriend } } }
ネストふか! Option#fold などを使って改善する余地はありそうですが…
それでも、一応実装することはできました。
今回定義した hasMutualBestFriend 関数の内部では、 findBestFriend 関数が2回呼び出されています。
findBestFriend 関数は、ふつうの値(文字列)を引数にとって文脈付きの値(いるかもしれない)を返す関数でしたね。
わかりやすく手順を追ってみると、
- 文脈付きの値を返す関数 (findBestFriend) を呼び出す
- 文脈付きの値からふつうの値 (ユーザーID) を取り出す
- 文脈付きの値を返す関数 (findBestFriend) を呼び出す
- 文脈付きの値からふつうの値 (ユーザーID) を取り出す
- 自分の ID と 4 の結果を比較する
こんな感じです。
ふつうの値 String
を引数に取る findBestFriend を呼び出すために、文脈付きの Option[String]
をふつうの値 String
に戻す必要があり、ネストが深くなってしまった感じですね。
うーん。 findBestFriend に Option[String]
をそのまま突っ込めれば楽なんですが…。
ここで、ふと冷静になってみると、感じることがあります。
ふつうの値を取って文脈付きの値を返す findBestFriend のような関数はたくさんある。
今回のように、ふつうの値を取って文脈付きの値を返す関数を、連続して使っていくようなケースも、きっとたくさん出てくるだろう。
そのたびにこの量のコードを書くのはあまりにもつらい。それに読みにくい。なんか良い方法はないのだろうか…。
flatMap とは
文脈付きの値 を ふつうの値を引数に取って文脈付きの値を返す関数 に突っ込みたい!
( ≒ Option[String] を findBestFriend に突っ込みたい!)
flatMap は、まさにこの願いを叶えるために生まれたメソッドです。
いったん、List 型に特化した flatMap の知識はあたまの隅っこに追いやって下さい。
flatMap はどこにある?
flatMap は、 Option, Future, List, Set, Map と言った文脈付きの値の型に定義されたメソッドです。
文脈付きの値を扱っているならば、 flatMap メソッドを呼ぶことができます。
flatMap はどんなヤツ?
flatMap の引数には
- 文脈付きの値から文脈を取り払った ふつうの値 を引数に取って
- 文脈付きの値 を返す
ような関数を渡します。
実際に使ったほうがわかりやすいですね。じゃあ使ってみましょう。
val option: Option[Int] = Some(100) val result: Option[Int] = option.flatMap { value => // <- 文脈付きの値から文脈を取り払ったふつうの値 (Int) Some(value + 120) // <- 文脈付きの値 (Option[Int]) } println(result) // -> Some(220)
ところで、flatMap では、文脈さえ保たれていれば、対象が変わってもOKですよ。
val option: Option[Int] = Some(200) val result: Option[String] = option.flatMap { value => Some(s"The value is $value") // <- 文脈付きの値 (Option[String]) } println(result) // -> Some(The value is 200)
さて、上記のような例で、もし引数が None だったらどうなるでしょうか。
値の持つ 文脈 を意識すれば答えが見えてきます。
val option: Option[Int] = None val result: Option[Int] = option.flatMap { value => Some(value + 120) } println(result) // -> None
flatMap を呼び出した option には None が入っています。
言い換えると、この option は「値がない」という文脈を持っています。
そして、flatMap の特性は 必ず、文脈を保ったまま計算をする という点です。
flatMap の特性は、必ず文脈を保ったまま計算をするという点です。大切なことなので、2回言いました。
上例の option は「値がない」という文脈を持っています。flatMap の呼び出しでは文脈が保たれるため、どのような関数を引数に渡しても 値がない という文脈が保たれます。結果として、 Option#flatMap は呼び出し元が None である場合に 引数の関数を適用しない という挙動になります。
この特性は、どんな値を対象にしても変わりません。その値が文脈を持った値でさえあれば。
たとえば List 型を見てみましょう。
val list: List[Int] = List(1, 10, 100) val result: List[Int] = list.flatMap { value => // <- ふつうの値をとって List(value, value + 1, value + 2) // <- 文脈付きの値で返す } println(result) // -> List(1, 2, 3, 10, 11, 12, 100, 101, 102)
この例では「1個以上の順序をもった重複しうる〜」という文脈を保っていますね。
では空のリストだった場合はどうでしょうか?
val list: List[Int] = Nil val result: List[Int] = list.flatMap { value => List(value, value + 1, value + 2) } println(result) // -> Nil
空のリストは「存在しない」という文脈を持っています。そのため、「存在しない」という文脈が保たれます。結果として、 List#flatMap は呼び出し元が空のリストである場合には 引数の関数を適用しない という挙動になります。
Option のときと全く同じですね。もちろん、 List や Option 以外でも、flatMap の特性は同じです。
…ところで、これらの特徴は map メソッドにも言えます。 map も、 文脈付きの値の型に定義されているメソッドです。
map とは?
flatMap の時と同様に、List 型に特化した map の知識は一旦わすれちゃいましょう。
map メソッドに渡す引数は、
- 文脈付きの値から文脈を取り払った ふつうの値 を引数に取って
- ふつうの値 を返す
ような関数です。 Option で使ってみましょう。
val option: Option[Int] = Some(200) val result: Option[String] = option.map { value => // <- 文脈付きの値から文脈を取り払ったふつうの値 (Int) s"The value is $value" // <- ふつうの値(String) } println(result) // -> Some(The value is 200)
map は文脈を保ったまま、ふつうの値を取ってふつうの値を返す関数を適用します。
val list: List[Int] = Nil val result: List[Int] = list.map { value => // <- ふつうの値 value + 1 // <- ふつうの値 } println(result) // -> Nil
もちろん、 flatMap と同じように文脈が保たれます。上例では「存在しない」という文脈が保たれていますね。
今回作った findBestFriend(userId: String): Option[String]
のような関数を map に渡すことは基本的にありません。
map は 文脈付きの値 を ふつうの値を引数にとってふつうの値を返す関数 に適用するためのメソッドだからです。
文脈を持った値を返す関数に突っ込みたい時は flatMap を使いましょう :-)
flatMap を使って両思い判定をする
さてさて、 flatMap は、文脈付きの値を、文脈を保ったまま、ふつうの値を引数にとって文脈付きの値を返す関数に適用するためのメソッドであることがわかりました。
今回の findBestFriend(userId: String): Option[String]
メソッドは、まさに flatMap に渡すべき(ふつうの値を引数にとって文脈付きの値を返す)関数です。
じゃあ、実際に使ってみましょう。
val maybeUserId: Option[String] = Some("yazawa")
まずここには、OptionにくるまれたユーザーIDがあります。これは次のような文脈を持った値です。
「いるかもしれないし、いないかもしれないユーザーのID」
この文脈付きの値の flatMap メソッドを使って、 findBestFriend を適用しましょう
val result = maybeUserId.flatMap { userId => // <- 文脈を取り払ったふつうの値 findBestFriend(userId) // <- 文脈付きの値 Option[String] } println(result) // -> Some(nishikino)
文脈を保ったまま、Option[String]
を String
に手動で変換することなく、 findBestFriend 関数に適用することができました!
これを使えば、酷いことになっていた hasMutualBestFriend をうまくリファクタリングすることができそうです。
両思い判定をする hasMutualBestFriend の flatMap なしバージョンを再度確認しておきましょう。
def hasMutualBestFriend(userId: String): Boolean = { val maybeBestFriend: Option[String] = findBestFriend(userId) maybeBestFriend match { case None => false case Some(bestFriend) => val maybeBestFriendOfBestFriend: Option[String] = findBestFriend(bestFriend) maybeBestFriendOfBestFriend match { case None => false case Some(bestFriendOfBestFriend) => userId == bestFriendOfBestFriend } } }
ひどいですね。次は flatMap をとりあえず使ってみるバージョンです。
def hasMutualBestFriend(userId: String): Boolean = { val maybeBestFriend: Option[String] = findBestFriend(userId) val eachBestFriends: Option[Boolean] = maybeBestFriend.flatMap { bestFriend => val maybeBestFriendOfBestFriend: Option[String] = findBestFriend(bestFriend) maybeBestFriendOfBestFriend.map { bestFriendOfBestFriend => userId == bestFriendOfBestFriend } } eachBestFriends getOrElse false }
最初の例に比べて、かなりシンプルになりましたね。
もし、 maybeBestFriendOfBestFriend
のような長い変数名にうんざりしているなら、次のように書くことも可能でしょう。
def hasMutualBestFriend(userId: String): Boolean = findBestFriend(userId).flatMap { bestFriend => findBestFriend(bestFriend).map { bestFriendOfBestFriend => userId == bestFriendOfBestFriend } }.getOrElse(false)
また、メソッドチェインが大好きだ!というならば、次のように書くこともできます。
def hasMutualBestFriend(userId: String): Boolean = findBestFriend(userId) .flatMap(findBestFriend) .contains(userId)
ついでなので、少々の可読性と引き換えにコードの量をおもいっきり減らしてみました。
ここで留意すべきなのは、このようにメソッドチェイン形式で書くことができるのは、後続の処理が直前の処理の結果にのみ依存しているからだということです。もし、1つ前と2つ前のふたつの処理結果に依存するような処理を書くのであれば、メソッドチェインは使えません。(無理やり使おうと思えば、工夫して使えないこともないですが、本末転倒なのでやめておきましょう)
hasMutualBestFriend は、flatMap を使用していない最初のバージョンのとくらべて、だいぶわかりやすく、そして読みやすくなりましたね。flatMap を使用することで、あるていど満足のいく成果が得られました。
しかし、もっと美しくまとめる方法があります。
for式を使う
実のところ、Scala の for 式は flatMap と map でネストした処理を超シンプルに書くことができる、最高にクールな糖衣構文です。
def hasMutualBestFriend(userId: String): Boolean = { for ( bestFriend <- findBestFriend(userId); bestFriendOfBestFriend <- findBestFriend(bestFriend) ) yield userId == bestFriendOfBestFriend }.getOrElse(false)
このように書けば、今まで flatMap や map をネスト/チェインさせて書いていた処理をひとつの式として表すことができます。
for 式は、私たちの代わりに flatMap や map (や withFilter) といったメソッドを呼び出してくれます。
高い可読性を保ったまま、少ないコードで文脈を持った値に対する処理をまとめることができる for 式は、間違いなく Scala で最高の構文のひとつです!
まとめ
- 文脈付きの値は、「整数があるかもしれないし、ないかもしれない」のような説明をすることができる値です
- 文脈付きの値の代表例は Option, List, Future, Try, Map など... すべて flatMap や map を使うことできます!
- flatMap は
- ふつうの値を引数にとり、文脈付きの値を返す関数に対して
- 文脈付きの値を渡したいときに使うことができる
- "文脈付きの値"型のインスタンスが持っているメソッドです
- ≒ 文脈付きの値をふつうの値を引数に取って文脈付きの値を返す関数に突っ込みたいという願いを叶えるためのメソッドです
- map は
- ふつうの値を引数にとり、ふつうの値を返す関数に対して
- 文脈付きの値を渡したいときに使うことができる
- "文脈付きの値"型のインスタンスが持っているメソッドです
- これらのメソッドは、文脈を持った値の”文脈を保つ”という特徴があります
- 「存在しない」という文脈を持つ 空のリスト (Nil) や None に、どのような関数を適用しても、「存在しない」という文脈は変わりません
- 結果として Nil や None などが渡された場合には、"関数が適用されれない"という挙動になっています
- flatMap / map を駆使することで、ふつうの値を引数に取って文脈付きの値を返す (findBestFriend のような) 関数たちを連続して適用する処理を非常にシンプルに書くことができます。
- …それをラップしてくれているのが Scala の for 式 の正体です
- for 式を使えば、より直感的に、視覚的にわかりやすく文脈付きの値を扱うことができます
ちょっと長くなってしまいました。
文脈付きの値は、本当に強力です。一度体験すると二度と戻れなくなるほど使いやすいです。
flatMap や for 式を使って、文脈付きの値とうまく付き合っていきましょう。ではまた!