flatMapについて
どうもこいつがいろいろな表現をされて、いろいろなところで活躍しているようなので、少し抽象的な理解をしてみようと思う。
flatMapやmapを「配列」に対する操作とみたときは別にそこまでややこくないです。
ですが、OptionalがもつflatMapやmapとかを考えると分かりづらいみたいな話ですね。
つまり、配列以外についてmapするflattenする、という世界観を受け取るところから問題が発生すると考えています。
ということで、mapやflattenが本質的に何をしているのか、について拡張すべきだと思い至りました。
mapを、
Array<T>
がもつ関数だとみてみる。
mapがうけとる関数を
f: T -> U
とすると、mapはこう書ける
map<U>(f: T -> U) -> Array<U>
ちょっと暗黙的だけど、mapは具体的にはArrayの要素に対して逐次的に適応される。
仮にTがArray<T>だったとすると、
map<U>(f: Array<T> -> U) -> Array<U>
flattenは、Arrayを再帰的にArrayに射影していく関数そのものなので、
flatten: Array<Array<T>> -> Array<T>
とシンプルに書いておきましょう
flatMapは、mapしたあと、flattenするものだ、とシンプルに理解します。
flatMap<U>((f: Array<T> -> U) -> Array<U>) -> Array<U>
ここでflattenが生きるためには、UがArray<U>になっているとよりよい
flatMap<U>((f: Array<T> -> Array<U>) -> Array<Array<U>>) -> Array<U>
こうかくとスッキリですね。
これをもう少し抽象的に捉え直します。
いまmapをもつ適当な型があったとします。
Foo<T>
さて、このときmapは一体何をするものなのか。
FooがArrayのようなものという保証はない。
つまりT型の要素を保持していてそれを順繰りに、、、というわけではない。
本質的にmapは何をしていたのかを考え直さなきゃいけない。
map<U>(f: T -> U) -> Foo<U>
だととらえるのはどうでしょうか。
flattenも同様に。
flatten: Foo<Foo<T>> -> Foo<T>
こうすると、mapしてflattenするという動作は、
flatMap<U>((f: Foo<T> -> Foo<U>) -> Foo<Foo<U>>) -> Foo<U>
となる
Foo<Foo<T>>
が
Foo<U>
になってかえってくるわけですね。
つまり、flattenは「入れ子になった型をもともとの型として抽出する」という行為になる
Hogeが入れ子になってたとして
Foo<Hoge<T>>とすると
flatten: Foo<Hoge<T>> -> Foo<T>
となるとすると、
mapは
map<U>(f: Hoge<T> -> Hoge<U>) -> Foo<Hoge<U>>
になる
flatMapは
flatMap<U>((f: Hoge<T> -> Hoge<U>) -> Foo<Hoge<U>>) -> Foo<U>
ここまで試行錯誤してわかったことがある、
Foo<Hoge<T>>に対して
mapはジェネリクスに対する変換操作後に親の型に戻す操作
map<U>(f: Hoge<T> -> Hoge<U>) -> Foo<Hoge<U>>
flattenは親の型に対してジェネリクスを戻す操作
flatten: Foo<Hoge<T>> -> Foo<T>
ではここまできて、関心事はどこにあるか。
最終的にFoo型の世界でいたい。
Foo型に対してT型の関心ごとを処理させたい。
だが、FooのなかのTは、必ずしも何も装飾されない状態でいるわけではない。
まとまった何らかの型Hogeで包まれている可能性が高い。
Fooの中にある任意の型=Hogeについてそとからはよく知らない。
しかし、Fooに対して関心ごとになるようなTを与えているのは事実。
常にFoo<T>として考えたいが、実際はFoo<Hoge<T>>のようになってしまうのが現実。
このTに対してUに変換する操作を、Hogeは関係なく、Fooのなかにあるものとして行いたい => map
さらに、行ったあとFooに対して余計な型を排除して関心事のみのものにもどしたい => flatMap
という感じだな!
この流れでPromiseとかすっと理解できるといいんだが。
少し時間が経って。
ちょっと違うかもなと思い出したので追記する。
Foo<Hoge<T>>
という想定は恣意的すぎる。
もっとモチベーションに即したい。
Tに対して操作を行う関数fを考える。
f: T -> U
ところが、現実にはUのかたちで返すことが困難なケースがある。
Uがまるごと違うときは例外処理とかがある
本当はUだけを返したいんだけど、いろいろな事情でUと付帯情報(ログとか、その後の処理に必要なカーソル情報とか)を一緒に返したい時がある。
つまり、U以外の型に「くるまれて」返ってくる。
f: T -> Foo<U>
確かにUは存在しているはずだが、それをくるんだ型が返ってくるケースが現実には多い。
受け取った側は、ここからUを安全に取り出せなくてはならない。
つまりFooにはとあるメソッドmethodXがあって、
methodX: <U>() => U
がほしい。
しかしこのmethodXを叩くのは大概「最後」だ。
Foo<U>の状態で受けたあと、これに対して更にUをOに変えるような操作をするかもしれない。
だが、最初のときと違って、UはFooにくるまれてしまっている。
なので、Fooに対して純粋にUに対する操作を与えて、それが問題なくFooで囲まれた状態で返ってくるmethodYが欲しくなる。
methodY<U>(g: U => O) => Foo<O>
このmethodXがflattenでmethodYがmapだ。
関数fによってFooでくるまれてしまったUに対して、
さらに関数g: U => Oを適応しようとしたら、
mapしたあとflattenすれば、見事Fooでくるまれている状態でgを適応してOだけを取得できる。
ほとんどの操作は、型を別の型に変換する行為とみなせるとする。
実際、具体的な計算をいかに繰り返そうと、Intの繰り返ししかないなら、それはInt => Intにまとめられる。
しかし、多くの変換において本意となる型変換で終わるわけではなく、様々な文脈を追ったオブジェクトな表現になったりする。その処理全体において常に保持していなければならない文脈を常に保持しておきながら、もともとの型変数Tを操作し続けようと考えたとき、最初にその文脈Fooでくるんでやる必要がでる。
Foo<T>
いま、Fooに包まれたTに対して行われるあらゆる操作においてもFooと同じように文脈が必要になる可能性が高い。
つまり、本当は
f: T -> U
というシンプルな変換を行いたいのだけど、現実にはくるんだほうがよいので、
f: Hoge<T> -> Hoge<U>
のように返すものがあるとする。つまりHogeはfの文脈を背負っている。
するとFoo<T>に対して、fを作用させようと思ったら、
method<U>(f: Hoge<T> -> Hoge<U>) -> Foo<U>
のような動作をして欲しくなるわけだ。
Fooを使おうと思っている側からすると、あくまでFooでくるまれて運びたいので、
fの文脈Hogeに関してはfの中だけで完結させていてほしい。
これをなしうるのがflatMap
本当は
map<U>(f: Hoge<T> -> Hoge<U>) -> Foo<Hoge<U>>
となってそのあと
flatten<U>: Foo<Hoge<U>> -> Foo<U>
でくるみを剥がす
すると晴れて、
fの文脈を剥がしてFooの文脈でfを作用させることに成功する。
code:typescript
class Foo<O> {
constructor(protected interest: O) {
}
// とりだす
flatten(): O {
return this.interest;
}
// 関心事にfを作用させる
map<T>(f: (interest: O) => T): Foo<Foo<T>> {
return new Foo<Foo<T>>(new Foo(f(this.flatten())));
}
// 関心事にfを作用させてFooでくるんで返す
flatMap<T>(f: (interest: O) => T): Foo<T> {
return this.map(f).flatten();
}
}
こうなる
例えば、この例でPromiseを理解してみたいですね。
Promiseを同期・非同期文脈から始めることをやめたほうがいいと思っている自分の立場からして、
js上でやりたいことを例にも寄ってこう考える。
すなわちI/O処理fを受けたあと、その結果に対してさらにI/O処理を経て変化させようとするときを想定する。
最初の値をxとして、これにたいしてFile1.txtに書かれた数値10を足す、という世界を考えよう。
ファイルを読み込むということは、pathというstringを指定して、numberを返すこととなので、
そんなfunction readFile_fを用意しよう。
code:typescript
const x :number = 0;
const path_1 :string = 'File1.txt';
function readFile_f(path: string) : number {
// 本当はここに非同期処理が入る
if (path == 'File1.txt') {
return 10;
} else {
return 0;
}
}
本来は、const y = readFile_f(path_1)して得られたnumberをそのままxに足せばいいわけだが、そうならない。
非同期処理が入ってしまうので、その文脈を背負わないといけない。
いまstring to numberを純粋に行うことができなかったので、stringに対してFooでくるんでやる。
code:typescript
const _path_ = new Foo<string>(path_1);
const _result_1_: Foo<number> = _path_.flatMap(readFile_f);
こうすることで、安全に「非同期」の文脈がFooによって解決され、純粋にreadFileを作用させることに成功した。
成功したと見える。
ということで、Fooに対して今回の文脈を実装しよう。
今回の文脈を正確に把握してみる。
「非同期処理が終わったら(正確には成功したら)、次の処理を行う」
である。
関心事と合わせて書き直すと、
「関心事に対して非同期処理な関数を加えたあと、次の処理を行う」
ということなので、このケースでは、
関心事 = path_1
関心事に対する非同期処理 = readFile_f
次の処理 = x + result
次の処理という新しいものがでてきたので、それも関数でnext_f(result:number) => numberと表現しておきましょう。
これをいつFooにあげればいいのか。
正確にはflatMapをしたタイミングであげれればいい。
なので、flatMapをwrapしたメソッドをはやしましょう。
readFile_fは本来非同期処理なので、これに対してコールバックを与えるような処理にしましょう。
ということで、そもそもreadFile_fをcallbackをもらえるかたちに変更します。
ここに多少の無理矢理感がありますが、非同期処理は流石にコールバックがないと、次の処理を確実に執行できません。
なので残念ながらこうします。
あれ?
コールバックがあるんなら、FooでflatMapする必要なくない?
Fooでくるんだ理由は、同期、非同期のことを考えたり、コールバックのことを考えたくなかったから。
と考えると、コールバックを渡す、なんて事考えたくない。
そんな事考えずに、string => numberの関数を適用したかっただけなのに!
そのためにFooを用意したのに!
大事なのは順番のほうだった。
ということで、どんどんfを追加していけばいい。
fを追加したら、追加されたFooを返せばいい。
というか文脈の渡し方の話を全くしてなかった。
fさえシンプルに渡せるのであればそれでよい。
コールバックがあーだこーだといわずに。
ということで素直に非同期処理を渡そう。
それを渡した上で、それが終わったことを受けられればいい
つまり、非同期処理が終了したことを知らせてくれれば、その後の処理をやるよ、ということであればよい。
終了したことを教える関数を用意しよう。
それをendとして、それがendされたら、次の処理を行うという形にしておけば良い。
次の処理をどんどん追加していって、
すべての処理は終了し次第endすればいい。
add_funcしていこう。
その時はすべてFooでくるもう。
非同期処理という関心事の分離の方法がイマイチ分からんな
型変換というよりは、ジャンプなんだよね。
returnはvoidでいいから、とにかくnextを呼び出せれば良いみたいな。
処理が途中で横に飛び出すってことを宣言的に表現するのってむずくね。
なんか関数っぽくないんだよね、バトンであって。
何を返すかが重要ではなくて、次々にfを「ただ呼び出していく」みたいな。
引数を加工して、それを食わせて呼び出す
その繰り返し
つまり、returnに意味がない
これが厄介というか引っかかるな。