5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

flatMapとはなんなのか 〜30代疲れ目エンジニアの平坦化考察〜

Last updated at Posted at 2024-11-01

JavaScript や TypeScript でmapはよく使うけど、flatMapって何者?そんな疑問、抱えてない?

mapは知ってるけど、flatMapって何が違うの?使うとどうなるの?」って思うよね。

今日はそんな、なんとなくわかってるようなわかってないような、
「いい加減そろそろちゃんと flatMap わかっときたいなー」って思ってる30代の疲れ目エンジニアのための話。

ちなみに「map って単なるループでしょ?」って思ってる人にとっても、意味のある話かもしれません。

mapflatMapの違いをゆる〜く考えて、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]

偶数の24はスキップされ、奇数の13は元の値とその 2 倍の値が追加されたね。

つまり、flatMapを使うと「要素を増やしたり減らしたり」自由自在なんだよね!


3. flatMap の本当の力を見てみよう

flatMapの真髄は、要素の増減じゃないんです。
以下のような処理を例にflatMapができることを見ていきましょう。

例題

  1. 数字の1からスタート
  2. 1を足す
  3. 2倍する
  4. 3倍する
  5. 4倍する
  6. 各計算ごとにログを出力する
  7. 文字列に変換して!を付ける

手続き型のコードとの比較

まずは普通の手続き型で書いてみる。

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,
})

ここで mapflatMap のシグネチャはそっくりだけど、
引数部分が
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を返す関数を作成しておく。

ちなみに addmul についてはカリー化(部分適用可能な形に)しておくと都合が良いね。

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 のようにロードローラーを使いこなしてください。

ちなみにあえて触れてはないですが、この考え方というのがいわゆるアレに通じます。
興味のある人はロードローラー演算子(便宜上)の >>= とかで調べたらいいかもしれません。
※ 知ってる人にとっては「ロードローラーってなんやねん」となりそうですが、そっとしといてください。。。

5
6
3

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
5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?