はじめに
入門Haskellプログラミング を最近読みました。そこでモナドの考え方と使い方が良いな思い、TypeScript 使いの自分向けにこのエントリをまとめてみました。
このエントリについて
- 関数型プログラミングの話の中でモナドという言葉を聞いたことがあるものの、でもモナドのイメージがつかめない方向け。
- タイトルの「モナド三兄弟」は「Functor, Applicative, Monad」です。
- TypeScript の配列が Functor, Applicative, Monad であること、それぞれの定義が増えると新しく何ができるようになるかを示します。
- 前編でモナドの定義と使用例、後編でモナド則を扱います。
- TypeScript の型指定を多用します。JavaScript として見る場合は、型指定を読み飛ばせば良いはずです。
- Haskell の用語を使います。数学の用語とは一部異なることがあります。
参考記事
モナドに関する良質な技術記事はたくさんあります。とくにこちらをお勧めです。
-
箱で考えるFunctor、ApplicativeそしてMonad
- 図でイメージできる記事。最初におすすめ。
-
モナド則がちょっと分かった?
- 同じように図でイメージできる記事。
-
30分でわかるJavaScriptプログラマのためのモナド入門
- とても詳しい解説。
-
関数型つまみ食い: モナドが難しいと思われている理由
- モナドを値とおまけの組で考える記事。本記事のきっかけの1つ。
-
Typeclassopedia
- Haskell Wiki の解説。本記事の Haskell 型定義はこちらをもとにしています。
こんな所に初学者が記事を増やしてもどうなんだろうマサカリが飛んでくるだけかもしれないと思いながらも、Haskell に詳しくなくても読める軽い記事なら意味があるはず。はず。。
配列を例としてモナド三兄弟を順に見る
配列はモナドの条件を満たしています。本章では話を簡単にするため、配列だけ扱います。
配列以外にもモナドのものはありますし、自分で作ることもできます。
配列(箱) と値
const a: number[] = [1, 2, 3]
という数を入れた配列があるとします。
配列からはそれぞれの値を取り出せます。 a[0]
のように。数値を取り出せる入れ物ということで、配列を数を入れる「箱」と考えてみます。箱の中に入っている値の個数は決まっていません。この例では3つですが、空のこともあります。
さて、数は 1 + 1
のように計算ができます。これを箱についても同様に、箱の中の値について行いたいとします。
しかし、 [1, 2, 3] + 1
とすると「中の数に対して」の操作ではなく、「配列に対して」の操作になります。きっと期待しない結果になります。 1
箱に入ったままでは、元の値と同じように計算できません。だからといって箱から値を出して [a[0] + 1, a[1] + 1, a[2] + 1]
みたいにするのはなんだか面倒です。
箱に入ったまま操作する仕組みを箱側に用意する。これが Functor, Applicative, Monad というモナド三兄弟です。Functor が一番シンプルでできることが少なく、Monad が一番強力。
箱の種類 | できること |
---|---|
Functor | map |
Applicative | map, zip |
Monad | map, zip, flatMap |
順に見ていきます。
Functor: map できる
Functor インターフェース定義
class Functor f where
fmap :: (a -> b) -> f a -> f b
interface Functor {
fmap: <A, B>(fn: (a: A) => B, as: A[]) => B[];
}
-
fmap()
- 入力:
- A型の値を入力にして、B型を出力する関数
- A型の値の配列
- 出力: B型の値の配列 (要素数: 入力2と同じ2)
- 入力:
Functor。関手という訳語があります。訳してもあまり分かりやすくならないと思い、そのまま Functor と呼ぶことにします。
Functor fmap() ≒ map()
Functor では fmap
関数を使えます。
これ、TypeScript の配列だと Array.map() メソッド そのままです。
最初の例の、[1, 2, 3]
の中にそれぞれ +1
するのは次のように書けます。
[1, 2, 3].map((x) => x + 1); // [2, 3, 4]
入力値 x | 式 | 出力値 |
---|---|---|
1 | x + 1 | 2 |
2 | x + 1 | 3 |
3 | x + 1 | 4 |
型を補うなら次のようになります。
type Fn = (x: number) => number;
const plus1: Fn = (x) => x + 1;
const fmap = (fn: Fn, ary: number[]): number[] => (
ary.map(fn)
);
const result = fmap(plus1, [1, 2, 3]);
console.log(result); // [2, 3, 4]
この例は 型A、型B ともに number
でした。
Functor は型B を別の型にできます。Array.map()
ももちろんできます。たとえば奇数なら true
, 偶数なら false
を返すものがこちら。
[1, 2, 3].map((x) => (x % 2 === 1)); // [true, false, true]
このように、「適用する関数を1つと、任意個の値が入った箱を渡すと、箱の中の値に対してそれぞれ関数を適用しものを入れた新しい箱を返す」ものが Functor です。「map できる」と読み替えて良いと思います。
Functor まとめ
- Functor でできること
- 1つの関数を複数の値にまとめて適用する
- Functor の制限
- 値ごとに違う関数を適用することはできない
- 入力と出力の箱に入っている値の個数は同じ
Applicative: zip できる
Functor では渡せる関数は1つだけでした。それぞれに別の関数を適用したいときは力不足です。たとえば、 [1, 2, 3]
と [1, 3, 5]
を順に足し合わせた [2, 5, 8]
を作りたいと思っても、できません。
その場合は、Functor を強化した Applicative Functor を使います。
Applicative インターフェース定義
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
interface Applicative extends Functor {
pure: <A>(a: A) => A[];
ap: <A, B>(fns: ((a: A) => B)[], as: A[]) => B[];
}
-
pure()
- 入力:
- A型の値
- 出力: A型の値の配列 (要素数: 1)
- 入力:
-
ap()
- 入力:
- 「A型の値を入力にして、B型の値を出力する関数」の配列
- A型の値の配列
- 出力: B型の値の配列 (要素数: 入力1, 入力2と同じ)
- 入力:
Applicative pure()
pure()
は a
を [a]
1要素の配列にする関数です。値を空の箱に入れる、値の世界から値にぴったり対応する箱の世界に持ち上げる、というイメージです。
Applicative ap() ≒ zip()
ap()
は次のイメージです。
ap([(x) => x + 1, (x) => x + 3, (x) => x + 5], [1, 2, 3]); // [2, 5, 8]
入力値 x | 入力式 | 出力値 |
---|---|---|
1 | x + 1 | 2 |
2 | x + 3 | 5 |
3 | x + 5 | 8 |
関数が配列に入っていて一見分かりにくいです。1つの関数を配列に適用する Functor が力不足なら、配列の値それぞれに対応するように、関数も配列で渡すことになるでしょう。
TypeScript で書くならこんな感じ。2つの配列から順に値を取るために、2つの配列を1つに組み立てる zip()
関数を追加しました。JavaScript にはありませんが、 Ruby, Python など他言語で見たことがある方もいるかと思います。
type Fn = (x: number) => number;
const map2 = (ary: [Fn, number][]): number[] => (
ary.map((x) => x[0](x[1]))
);
const zip = (ary0: Fn[], ary1: number[]): [Fn, number][] => (
ary0.map((x, i) => [x, ary1[i]])
);
const fns: Fn[] = [(x) => x + 1, (x) => x + 3, (x) => x + 5];
const nums: number[] = [1, 2, 3];
const result = map2(zip(fns, nums)); // map2([[(x) => x + 1, 1], ...])
console.log(result); // [2, 5, 8]
これで別々の値を足し算できました。
まあしかし、関数を配列に入れるのは面倒です。 [1, 3, 5]
という数値の配列から関数の配列を作りましょう。
const fns: Fn[] = [1, 3, 5].map((x) => ((y: number) => x + y));
または、zip()
関数では [[1, 1], [2, 3], [3, 5]]
という数値の2次元配列を組み立てるところまで行い、その後の処理は Functor で使った map()
に引き継ぐ形でも良いです。こちらの方が簡単です。
const zip = <T0, T1>(ary0: T0[], ary1: T1[]): [T0, T1][] => (
ary0.map((x, i) => [x, ary1[i]])
);
const result = zip([1, 3, 5], [1, 2, 3]).map((x) => x[0] + x[1]);
console.log(result); // [2, 5, 8]
さらに、zip()
と map()
をつなげて呼ぶ代わりに、 zipWith()
1回で終わらせるという方法もアリです。2入力の関数1つと、2つの配列を渡します。関数と1つ目の配列から関数の配列を作っているのと同じです。
const zipWith = <T0, T1, T2>(fn: (_: T0, __: T1) => T2, ary0: T0[], ary1: T1[]): T2[] => (
ary0.map((x, i) => fn(x, ary1[i]))
);
const result = zipWith((x, y) => x + y, [1, 3, 5], [1, 2, 3]);
console.log(result); // [2, 5, 8]
これらは書き方こそ違うものの、「関数の配列を順に適用している」というところでは同じです。Applicative は「zip できる」と読み替えて良いと思います。
Applicative まとめ
- Functor でできること
- 1つの関数を複数の値にまとめて適用する
- Applicative でできること
- n個の関数が入った箱と、n個の値が入った箱に対して、1番目・2番目と順に関数を値に適用できる
- n個の値が入った箱2つに対して、1番目・2番目と順に値を取り出し、順に処理できる
- Functor と Applicative の制限
- 複数の入力と、出力の箱に入っている値の個数はすべて同じ
Monad: flatMap, then できる
Functor, Applicative では入力の個数と出力の個数が同じでした。そうすると「奇数だけ取り出す」のように、入力と出力で個数を変えたいものを扱えません。
そこで最後の道具、Monad を使います。
Monad インターフェース定義
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
interface Monad extends Applicative {
bind: <A, B>(as: A[], fn: (a: A) => B[]) => B[];
then: <A, B>(as: A[], bs: B[]) => B[];
}
-
bind()
- 入力:
- A型の値の配列
- A型の値を入力にして、B型の値の配列を出力する関数
- 出力: B型の値の配列 (要素数: 入力1と異なることがある。入力1が空の場合、出力も空。)
- 入力:
-
then()
- 入力:
- A型の値の配列
- B型の値の配列
- 出力: B型の値の配列 (入力1の数だけ、入力2の配列を繰り返したもの。入力1が空の場合、出力も空。)
- 入力:
Functor の fmap()
と Monad の bind()
は似ています。並べて書いてみます。
interface Monad {
fmap: <A, B>(fn: (a: A) => B, as: A[]) => B[];
bind: <A, B>(as: A[], fn: (a: A) => B[]) => B[];
}
どちらも配列と fn()
関数を入力します。違いは1つ、fn()
関数の出力が「値」か、「値の入った箱」か。そしてfn()
関数の出力が箱になると、bind()
の入力配列と出力配列の個数を変えられるようになります。
Monad bind() ≒ flatMap()
bind()
は次のイメージです。
[1, 2, 3].flatMap((x) => (x % 2 === 1 ? [x] : [])); // [1, 3]
入力値(x) | 奇数? | 出力値 |
---|---|---|
1 | true | [1] |
2 | false | [] |
3 | true | [3] |
Array.map()
でも [[1], [], [3]]
という「もう1つ階層をたどると2個の値」 というところまでは作れます。
[1, 2, 3].map((x) => (x % 2 === 1 ? [x] : [])); // [[1], [], [3]]
[[1], [], [3]]
から [1, 3]
のように階層を1つ減らす 3メソッドがあります。 Array.flat() メソッド 。
[[1], [], [3]].flat(); // [1, 3]
[1, 2, 3].map((x) => (x % 2 === 1 ? [x] : [])).flat(); // [1, 3]
Applicative pure
に対応する []
と、この Array.flat()
は対になっているイメージです。 []
で箱の世界に持ち上げて、 Array.flat()
で箱が入れ子になっているときは1つ取り出すという。
[[[1]]] 値の箱の箱の箱
↑ ↓
[] ↑ ↓ flat()
↑ ↓
[[1]] 値の箱の箱
↑ ↓
[] ↑ ↓ flat()
↑ ↓
[1] 値の箱
↑
[] ↑
↑
1 値
ただし、一番外側の箱だけが残っているときに、箱をすべて取り去って値を取り出すことはできません。箱の中に値が1つとは限りませんから。
Array.map()
と Array.flat()
の組み合わせは強力です。これを使えば奇数を取り出すフィルターのように入出力の数を変えられます。
あまりによく使う組み合わせだから、それなら1つのメソッドにすれば良いのでは? というわけであります。 Array.flat() メソッド 。この2つは同じ意味です。
[1, 2, 3].map((x) => (x % 2 === 1 ? [x] : [])).flat(); // [1, 3]
[1, 2, 3].flatMap((x) => (x % 2 === 1 ? [x] : [])); // [1, 3]
Array.map().flat()
だと [[1], [], [3]]
経由します。Array.flatMap()
はべつに経由しなくても [1], [], [3]
を順番につなげれば同じ結果になります。ほんの少し処理が速くなるかもしれません。
flatMap 使用例
先ほどの奇数を取り出すフィルターに型注釈を付けます。たしかに flatMap()
は「関数と配列を入力にして、異なる個数の配列を出力する」ことができています。
type Fn = (x: number) => number[];
const flatMap = (fn: Fn, ary: number[]): number[] => (
ary.flatMap(fn)
);
const oddFilter: Fn = (x) => (x % 2 === 1 ? [x] : []);
const result = flatMap(oddFilter, [1, 2, 3]);
console.log(result); // [1, 3]
flatMap()
で同じ数の配列を取り出すこともできます。map()
で行った plus1 を書き直してみます。
const plus1: Fn = (x) => [x + 1];
const result = flatMap(plus1, [1, 2, 3]);
console.log(result); // [2, 3, 4]
また、flatMap()
した結果をつぎの flatMap()
につなぐこともできます。 [1, 2, 3]
から奇数を取り出して、その結果に対して plus1 するのは次の通り。
const result = flatMap(plus1, flatMap(oddFilter, [1, 2, 3]));
console.log(result); // [2, 4]
2つの関数を1つにまとめることもできます。途中の箱 [1, 3]
を経由せず、 [1, 2, 3]
を順に処理して [2], [], [4]
をつなぐという流れにできますので、少し効率的です。
const oddFilterPlus1: Fn = (x) => flatMap(plus1, oddFilter(x));
const result = flatMap(oddFilterPlus1, [1, 2, 3]);
console.log(result); // [2, 4]
このように Monad の bind は Array.flatMap()
に対応する強力な道具です。 Array.map()
や Array.filter()
に対応するいろいろなことが、これだけで行えます。Monad は 「flatMap できる」 と読み替えて考えることもできます。
さて、Monad の強力さはこれだけではありません。もう1つ道具が用意されています。
Monad then()
1つの道具は then
。型だけでなく実装も決まっています。
class Applicative m => Monad m where
m >> n = m >>= \_ -> n
abstract class MonadAbs implements Monad {
abstract fmap: Functor['fmap'];
abstract pure: Applicative['pure'];
abstract ap: Applicative['ap'];
abstract bind: Monad['bind'];
then<A, B>(as: A[], bs: B[]) {
return this.bind(as, (_) => bs);
}
}
次のイメージです。入力の箱に値が何か入っていれば、その値を捨てて、別の箱の中身を返します。
[].flatMap((_) => [true]); // []
[1].flatMap((_) => [true]); // [true]
値を変換するだけだとあまり嬉しそうな感じがしません。でも、別の箱には関数を入れることもできます。例えばこうしてみると。
const fn = () => console.log('filled');
[].flatMap((_) => [fn]); // []
[1].flatMap((_) => [fn]); // [fn]
中身が入っているときだけ、「値が入っている」と表示する関数を箱に入れて返します。使えそうです。
Monad bind() を then() のように使う
bind()
の場合は then()
のように入力を捨てません。「この値が入っている」と表示する関数を箱に入れて返すこともできます。
const fn = (x) => (() => [console.log(`value: ${x}`)]);
[].flatMap(fn); // []
[1].flatMap(fn); // [console.log('value: 1')]
先ほどのように処理をつなげてみます。
const plus1 = (x) => [x + 1];
const fn = (x) => (() => [console.log(`value: ${x}`)]);
[].flatMap(plus1).flatMap(fn); // []
[1].flatMap(plus1).flatMap(fn); // [console.log('value: 2')]
このように、「結果が返ってくれば次の処理につなげる」という用途でもモナドを使えます。then できる、chain できる、というところです。
Monad まとめ
- Functor でできること
- 1つの関数を複数の値にまとめて適用する
- Applicative でできること
- n個の関数が入った箱と、n個の値が入った箱に対して、1番目・2番目と順に関数を値に適用できる
- n個の値が入った箱2つに対して、1番目・2番目と順に値を取り出し、順に処理できる
- Monad でできること
- 入力の箱に入っている値の個数と、出力の箱に入っている値の個数を変えられる
- 入力の箱の中身が入っているときだけ次の処理につなげる、という使い方ができる
いろいろできるようになりました。お疲れさまでした。
難しいと思っていたこと (あとがきに代えて)
モナドは難しいものだと思っていました。WEB でモナドに関する記事を調べると何か同じものなのに違う書き方をしているようでかえって混乱。本を読んでからもう一度見直すと、やっと分かったつもりになった、という感じです。
というわけで、あとがきに代えて、以前混乱していたところと、今はどう理解しているかというところを書いてみます。解釈や思い込みが入っています。間違えていてマサカリが飛んでくるかもしれません。
本章では配列以外も扱います。
Q1. 値が1つ入るものと複数入るもの、どちらも「箱」のイメージで良い?
最初私が混乱したところ。簡単なモナドの例として Maybe, リスト (配列) がよく挙げられます。
型クラス | 値の数 |
---|---|
Maybe | 0, 1 |
List | 0~ |
「箱」から値を取り出すと言います。ここには値が入っていないことも、1つ取り出せることも、型によっては複数取り出せることもあります。
箱は「値がいつも入っているもの」ではありません。「値を取り出せるなら、取り出した値に対して順番に関数を適用すれば良いよ。関数は箱の構造が何かということは知らなくても良いよ。」くらいの感じで考えれば良いです。
Q2. 配列は ary[0] のように直接値を取り出せるからモナドではないのでは?
モナドは「共通のインターフェースで箱から値を取り出して処理できる」ということを言っています。それ以外のインターフェースを持っていることは別に問題ありません。その意味では、配列はインデックスで直接アクセスする方法がありますが、共通のインターフェースを持っている以上モナドです。
まあでも、配列をモナドとして使わなければ、モナドでないとも言えます。どちらでとらえても良い、ではないかと。
Q3. then でつなげるというと Promise みたいなもの?
Promise も値を入れる箱として考えられます。モナドと似ています。Promise が成功すると Promise にくるまれた値を返し、これを then でどんどんつなげていけます。
- 似ていること
- Promise もモナドも処理の流れをチェーンで書ける。
- Promise は async / await で処理順に並べて書き直せる。モナドも do 記法でチェーンを順に書き直せる。
- 違うこと
- Promise は非同期でだけ使う。モナドは箱から値を取り出すものならなんでも使える、より汎用的なもの。
- Promise には失敗時の分岐 catch がある。汎用的なモナドには直接対応する機能はない。
- Promise は入れ子で使えない。モナドは入れ子で使える。
たとえば、Haskell Wiki IO入門編 に、メソッドチェーンを do 記法で書き換える例が書いています。do 記法は糖衣構文 (syntax sugar) で、どちらも同じ意味になります。
main = putStrLn "Hello, what is your name?"
>> getLine
>>= \name -> putStrLn ("Hello, " ++ name ++ "!")
main = do putStrLn "Hello, what is your name?"
name <- getLine
putStrLn ("Hello, " ++ name ++ "!")
TypeScript で Promise チェーンを async-await で手続きっぽく書き換えるのとよく似ています。
node.js readline を使って試してみました。
import readline from "readline";
const rl = readline.createInterface({
input: process.stdin,
});
const putStrLn = (text: string) =>
new Promise<void>((resolve) => {
console.log(text);
resolve();
});
const getLine = () =>
new Promise<string>((resolve) => {
rl.question("", (answer) => {
resolve(answer);
rl.close();
});
});
const main = () =>
putStrLn("Hello, what is your name?")
.then(() => getLine())
.then((name) => putStrLn(`Hello, ${name}!`));
const main = async () => {
await putStrLn("Hello, what is your name?");
const name = await getLine();
await putStrLn(`Hello, ${name}!`);
};
Q4. IO をモナドで扱うと何が良いの?
IO モナドから値を取り出すところはシステム側が行ってくれます。プログラムを書く人は「いつキーボードが押されて入力が確定するか」という現実世界のことは気にせず、値が取れればその先は状態のない綺麗な世界 (参照透過性が保証されている世界) でこう処理しますよ、という仕組みだけ考えれば良いです。
言い換えると、複雑なところだけをうまくコンパイラーに押し付けている形。IO bind がうまく世界の境界を分けています。
then の紹介で使ったコードを少し変更してみると、
const plus1 = (x) => [x + 1];
const fn = (x) => (() => [console.log(`value: ${x}`)]);
const x = [1].flatMap(plus1).flatMap(fn); // [console.log('value: 2')]
const y = [1].flatMap(plus1).flatMap(fn); // [console.log('value: 2')]
x の箱を開けて中の関数を実行すると、現実世界に「value: 2」という文字が現れます。でも箱を開けなければ、何回 [1].flatMap(plus1).flatMap(fn);
を実行しても現実世界には影響しません。同じ「開けて中の関数を実行すれば現実世界に影響する」箱が返ってきます。箱を開けるところはシステムにおまかせです。
Q5. 定義は型についてばかりで、挙動については何も言っていないのでは?
interface Functor {
fmap: <A, B>(fn: (a: A) => B, as: A[]) => B[];
}
入出力の型と fmap
というところから Array.map()
っぽいという話をしました。でもこれ、入出力の型がそうだというだけで、実装については何も言っていません。型を満たすだけなら違うものを入れても良いです。
type Fn = (x: number) => number;
const plus1: Fn = (x) => x + 1;
const fmapFake = (_fn: Fn, ary: number[]): number[] => (
ary.map(0)
);
const result = fmapFake(plus1, [1, 2, 3]);
console.log(result); // [0, 0, 0]
fmap
同様に fmapFake
も、配列を操作する Functor の入出力型条件を満たします。しかし Functor ではありません。型だけ見てもダメで、「法則」についても満たす必要があります。モナド則とか。
というわけで1か月後くらいの後編に続きます。たぶん箱を作る人は気にする内容でも、箱を使いたいだけの人にとっては「普通そう作るよね、配列もそうですし」となる、と思います。