JavaScript や TypeScript でmap
はよく使うけど、flatMap
って何者?そんな疑問、抱えてない?
「map
は知ってるけど、flatMap
って何が違うの?使うとどうなるの?」って思うよね。
今日はそんな、なんとなくわかってるようなわかってないような、
「いい加減そろそろちゃんと flatMap
わかっときたいなー」って思ってる30代の疲れ目エンジニアのための話。
ちなみに「map
って単なるループでしょ?」って思ってる人にとっても、意味のある話かもしれません。
map
とflatMap
の違いをゆる〜く考えて、flatMap
の真の力を探ってみましょう。
1. まずはおなじみの map
をおさらい
map
は配列の各要素に関数を適用して、新しい配列を作ってくれるメソッドだよ。めっちゃ便利だよね。
const numbers = [1, 2, 3]
const doubled = numbers.map((x) => x * 2)
console.log(doubled) // 出力: [2, 4, 6]
各要素を 2 倍にして、新しい配列[2, 4, 6]
が返ってくる。
まぁシンプルでわかりやすいよね!
2. flatMap
って何が違うの?
flatMap
は、一言で言うと「map
してから平坦化(フラットに)する」メソッド。
うん、それだけじゃピンとこないよね。
実際に見てみよう。
例 1: 要素を増やす
const numbers = [1, 2, 3]
const result = numbers.flatMap((x) => [x, x * 2])
console.log(result) // 出力: [1, 2, 2, 4, 3, 6]
各要素を[x, x * 2]
という配列に変換。flatMap
はその結果をフラットにしてくれるので、最終的な配列は[1, 2, 2, 4, 3, 6]
になるよ。
例 2: 要素を条件でフィルタリング
例えば、奇数のときだけ 2 倍の値も追加し、偶数は除外してみる。
const numbers = [1, 2, 3, 4]
const result = numbers.flatMap((x) => (x % 2 !== 0 ? [x, x * 2] : []))
console.log(result) // 出力: [1, 2, 3, 6]
偶数の2
と4
はスキップされ、奇数の1
と3
は元の値とその 2 倍の値が追加されたね。
つまり、flatMap
を使うと「要素を増やしたり減らしたり」自由自在なんだよね!
3. flatMap の本当の力を見てみよう
flatMapの真髄は、要素の増減じゃないんです。
以下のような処理を例にflatMapができることを見ていきましょう。
例題
- 数字の1からスタート
- 1を足す
- 2倍する
- 3倍する
- 4倍する
- 各計算ごとにログを出力する
- 文字列に変換して!を付ける
手続き型のコードとの比較
まずは普通の手続き型で書いてみる。
let x = 1
x = x + 1
console.log(`after add 1: ${x}`)
x = x * 2
console.log(`after mul 2: ${x}`)
x = x * 3
console.log(`after mul 3: ${x}`)
x = x * 4
console.log(`after mul 4: ${x}`)
x = `${x}!`
console.log(`Final Result: ${x}`)
// 出力:
// after add 1: 2
// after mul 2: 4
// after mul 3: 12
// after mul 4: 48
// Final Result: 48!
変数x
を順番に更新していくスタイル。
これは文の連続だね。
4. flatMap
で同じことをやってみる
flatMap
を使って、一連の処理を繋げてみよう。
const processFlow = [1]
.flatMap((x) => [x + 1])
.flatMap((x) => (console.log(`after add 1: ${x}`), [x]))
.flatMap((x) => [x * 2])
.flatMap((x) => (console.log(`after mul 2: ${x}`), [x]))
.flatMap((x) => [x * 3])
.flatMap((x) => (console.log(`after mul 3: ${x}`), [x]))
.flatMap((x) => [x * 4])
.flatMap((x) => (console.log(`after mul 4: ${x}`), [x]))
.flatMap((x) => [`${x}!`])
console.log(`Final Result: ${processFlow[0]}`)
// 出力:
// after add 1: 2
// after mul 2: 4
// after mul 3: 12
// after mul 4: 48
// Final Result: 48!
各ステップで計算結果をログ出力しながら、flatMapで次の処理に繋げることができるんだ。
最終的な値の結果は手続きと同様に "48!"
になったね。
これで一連の処理が 一つの評価可能な式 としてまとまっているんだよね。
5. シンプルな箱(Box)を作ってみよう
配列の flatMap が何をやっているのかを紐解くために、配列と同じような構造を自分で実装して考えてみよう。
配列は複数の値を包む箱のようなもの。
同じように、一つだけの値を包む箱を自作してみよう。
Box を定義する
まずは、値を保持してそれを返すだけのシンプルな Box を定義。
type Box<T> = {
getValue: () => T
}
const box = <T>(value: T): Box<T> => ({
getValue: () => value,
})
これで、box(1)
とすると、値1
を持った Box ができるよ。
6. 箱の中の値を操作したい!
でも、このままだと値を取り出せるだけ。
値を箱からわざわざ取り出さずに、箱の中の値を直接操作したくなるよね?
そこで、「箱に入れた値を操作する」方法を考えよう。
「箱の中の値に対して関数を適用し、その結果を新しい箱に入れて返す関数」map
を定義する
map
を使って、箱の中の値を操作して新しい箱を返せるようにする。
type Box<T> = {
map: <U>(fn: (value: T) => U) => Box<U>
getValue: () => T
}
const box = <T>(value: T): Box<T> => ({
map: (fn) => box(fn(value)),
getValue: () => value,
})
これで、箱の中の値に関数を適用して、新しい箱を作れる。
const result = box(1)
.map((x) => x + 1)
.getValue()
console.log(result) // 出力: 2
7. map
でも同じことはできないの?
さっきの連続した処理をmap
で書いてみよう。
const result = box(1)
.map((x) => x + 1)
.map((x) => (console.log(`after add 1: ${x}`), x))
.map((x) => x * 2)
.map((x) => (console.log(`after mul 2: ${x}`), x))
.map((x) => x * 3)
.map((x) => (console.log(`after mul 3: ${x}`), x))
.map((x) => x * 4)
.map((x) => (console.log(`after mul 4: ${x}`), x))
.map((x) => `${x}!`)
.getValue()
console.log(`Final Result: ${result}`)
// 出力:
// after add 1: 2
// after mul 2: 4
// after mul 3: 12
// after mul 4: 48
// Final Result: 48!
map
でも同じように処理を連結できる。
でも、引数の関数が新たな箱(Box)を返す場合、map
ではうまく連結できないんだ。
8. flatMap
の必要性
例えば、箱(Box)を返す関数があるとする。
const add = (y: number) => (x: number) => box(x + y)
このadd
関数は、値を受け取って新しい Box を返す。
map
で試してみると…
このadd
関数を2回適用してみる
const result = box(1)
.map(add(1))
.map(add(1))
.getValue()
console.log(result) // 出力: Box { map: [Function], getValue: [Function] }
あれ?結果が Box の中に Box が入ってしまった。
map
を使うと、ネストした箱になってうまく処理が連結できなくなってしまう。
無理やり値を取り出そうとすると以下のようになる。
const result = box(1)
.map(add(1))
.getValue()
.map(add(1))
.getValue()
.getValue()
console.log(result) // 出力: 3
このようにmap
だとネストした箱の処理の連結させるのはめんどくさい。
「箱の中の値に対して箱を返す関数を適用し、その結果の箱を返す関数」flatMap
を定義する
この問題を解決するために、flatMap
を定義する。
type Box<T> = {
map: <U>(fn: (value: T) => U) => Box<U>
flatMap: <U>(fn: (value: T) => Box<U>) => Box<U>
getValue: () => T
}
const box = <T>(value: T): Box<T> => ({
map: (fn) => box(fn(value)),
flatMap: (fn) => fn(value),
getValue: () => value,
})
ここで map
と flatMap
のシグネチャはそっくりだけど、
引数部分が
fn: (value: T) => U
と
fn: (value: T) => Box<U>
という違いがあるね。
flatMap
が受けとる関数は型としてBox型を返す必要があるという感じだね。
map
が受け取る関数の型にはその制約がなくて、関数を呼んだあとにBoxに包まれる。
そして flatMap
を使うと、ネストを防ぎながら箱を平坦化できる。
const result = box(1)
.flatMap(add(1))
.flatMap(add(1))
.getValue()
console.log(result) // 出力: 3
9. flatMap
の力を使って流れを作る
flatMap
を使って、さっきの処理をもっとシンプルに書いてみよう。
まず、ログを出力するlog
関数を作る。
const log = <T>(label:string) => (x:T) => (console.log(`${label}: ${x}`), box(x))
次に、処理を組み立てる。
box(1)
.flatMap((x) => box(x + 1))
.flatMap(log("after add 1"))
.flatMap((x) => box(x * 2))
.flatMap(log("after mul 2"))
.flatMap((x) => box(x * 3))
.flatMap(log("after mul 3"))
.flatMap((x) => box(x * 4))
.flatMap(log("after mul 4"))
.flatMap((x) => box(`${x}!`))
.flatMap(log("Final Result"))
// 出力:
// after add 1: 2
// after mul 2: 4
// after mul 3: 12
// after mul 4: 48
// Final Result: 48!
そう、flatMap
はまるで「ロードローラーだッ!」
ネストした箱をロードローラーのように平坦化しながら、次々と処理の道を突き進んでいく。
10. ロードローラー演算子
ここまでの話を聞いて、感のいい人は気づいたかもしれない。
flatMap
というのは、「箱の中の値に、箱を返す関数を適用する」
という 演算
っぽいよね。
演算
には、演算子(オペレーター)
が欲しくなるよね。
なので、演算子っぽく書けるようにしてみよう。
といっても、TypeScriptやJavaScriptにユーザーランドで演算子を作れる言語仕様はないので、Box型にそれっぽいプロパティを生やして対応してみるね。
type Box<T> = {
map: <U>(fn: (value: T) => U) => Box<U>
flatMap: <U>(fn: (value: T) => Box<U>) => Box<U>
">>=": <U>(fn: (value: T) => Box<U>) => Box<U>
getValue: () => T
}
const box = <T>(value: T): Box<T> => ({
map: (fn) => box(fn(value)),
flatMap: (fn) => fn(value),
[">>="]: (fn) => fn(value),
getValue: () => value,
})
flatMapのシグネチャと全く同じ、>>=
という演算子を Box に追加したよ。
この演算子を便宜的に ロードローラー演算子(便宜上)
とでも呼びますか。
JavaScriptやTypeScriptでは記号のようなプロパティは [""]
をつかうことでアクセス可能となる。
ロードローラー演算子(便宜上)を使って書くと
box(1)
[">>="]((x) => box(x + 1))
[">>="](log("after add 1"))
[">>="]((x) => box(x * 2))
[">>="](log("after mul 2"))
[">>="]((x) => box(x * 3))
[">>="](log("after mul 3"))
[">>="]((x) => box(x * 4))
[">>="](log("after mul 4"))
[">>="]((x) => box(`${x}!`))
[">>="](log("Final Result"))
// 出力:
// after add 1: 2
// after mul 2: 4
// after mul 3: 12
// after mul 4: 48
// Final Result: 48!
このような悪魔的記法で一連の流れを処理することが可能となる。
11. 関数を合成してもっとシンプルに
箱(Box)を返す関数の便利なところは、一つ一つの処理を関数として定義し、
それらの関数を合成して新しい関数が作成可能となることなんだ。
事前にBoxを返す関数を作成しておく。
ちなみに add
や mul
についてはカリー化(部分適用可能な形に)しておくと都合が良いね。
const add = (y: number) => (x: number) => box(x + y)
const mul = (y: number) => (x: number) => box(x * y)
const excl = (x: number) => box(`${x}!`)
combinedMul
を定義する
まずはシンプルに、>>=
で連結して combinedMul
を作る。
const combinedMul = (x: number) =>
box(x)
[">>="](mul(2))
[">>="](log("after mul 2"))
[">>="](mul(3))
[">>="](log("after mul 3"))
[">>="](mul(4))
[">>="](log("after mul 4"))
全体の処理を組み立てる
box(1)
[">>="](add(1))
[">>="](log("after add 1"))
[">>="](combinedMul)
[">>="](excl)
[">>="](log("Final Result"))
// 出力:
// after add 1: 2
// after mul 2: 4
// after mul 3: 12
// after mul 4: 48
// Final Result: 48!
関数を合成することで、処理の再利用性なんかもあがってくるよね。
12. combinedMul
を一般化する
さっきの combinedMul
だとまだまだ冗長で使い道よくわからないし、いちいち一個ずつ結合していてめんどくさいなと感じたよね。
なので、combinedMul
を一般化して、任意の乗算を連結できるようにしよう。
const combinedMul = (values: number[]) => (x: number) =>
values.reduce(
(acc, cur) => acc[">>="](mul(cur))[">>="](log(`after mul ${cur}`)),
box(x)
)
combinedMul
の引数でかけ合わせたい数値の配列を受け取って、
その全ての数値をかけてはログ出力する関数を合成した関数を返す関数だね!
全体の処理を組み立てる(一般化バージョン)
box(1)
[">>="](add(1))
[">>="](log("after add 1"))
[">>="](combinedMul([2, 3, 4]))
[">>="](excl)
[">>="](log("Final Result"))
// 出力:
// after add 1: 2
// after mul 2: 4
// after mul 3: 12
// after mul 4: 48
// Final Result: 48!
これで好き放題掛け算しまくれるよね!
まとめ
flatMap
を使うと、連続した処理を柔軟に書けるようになるかもしれないということがわかったね。
大事なのは、配列や Box が持つ「値に対して関数を適用する」という共通の性質を理解すること。
flatMap
はまるでロードローラー。
あなたも DIO
のようにロードローラーを使いこなしてください。
ちなみにあえて触れてはないですが、この考え方というのがいわゆるアレに通じます。
興味のある人はロードローラー演算子(便宜上)の >>=
とかで調べたらいいかもしれません。
※ 知ってる人にとっては「ロードローラーってなんやねん」となりそうですが、そっとしといてください。。。