APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

モナドをわかったつもりになる

はじめに

こんにちは、コンテナソリューション事業部の髙井です。
時が過ぎるのは早いものでもう年末ですね。

さて、よく耳にするのに調べても難しそうでいまいち正体のつかめない言葉ってありませんか。

たとえば「モナド」もそのひとつだと思います。

ということで今日は「で、結局モナドってなにが嬉しいんだっけ?」に少しでも近づくことを目標に書いていきたいと思います。

まずは調べる

ネットで調べたら「モナドは単なる自己関手の圏におけるモノイド対象だよ。何か問題でも?」と書いてあって試合終了したので、とりあえず関連しそうな本を数冊読んできました。

基礎からわかる Elm

基礎からわかる Elm

Amazon

モナドの前にファンクタ

モナドが分からないのにいきなり新しいことばを出しますが、ファンクタ(関手)という概念がモナドに似ていて、こちらのほうが具体例と馴染みやすいので先に紹介します。

まず、題材として商品の税抜き価格priceを考えましょう。

税抜き価格があれば税込み価格を求める必要が出てくるというものです。税込み価格を求める関数fを作りましょう。現在、消費税は驚異の10%なのでcosnt f = price => price * 1.1です。

他にも必要となりそうな処理を想定していくと、プログラミングでよくあるケースとして、いくつもの税抜き価格を税込み価格に直したいなどが考えられます。

配列pricesを考えます。これも単体の値であるpriceと同じように操作できたら便利ですよね。数学でもプログラミングでも「特定の操作を一般化してより広い対象を取れるようにする」というのはよくある行為です。

いまやりたいことは[price1, price2][f(price1), f(price2)]という状態にすることです。手続き的に書くと以下のようなことをします。

const taxIncluded = [];
for (const price in prices) {
  taxIncluded.push(f(price));
}

しかし普通はこのような手続き型のようには書かずにprices.map(f)と書くでしょう。

これの何が嬉しいかというとpriceに対しf(price)とするのと同じように単体の値を対象に取る関数であるfを使ってprices.map(f)と書けていることです。

もう少し補足すると、仮にconst F = prices => prices.map(f)を定義すればF(prices)というようにまったく同じ形式で書けることからも、対象が値から配列に拡張されていることがわかります。

もっと嬉しいファンクタ

それだけではありません。税抜き価格に対して20円引きをする関数gを考えます。

const g = price => Math.max(0, price - 20)

です(価格なので負の値はないものとしています)。

さて、単体の値に対して20円引きの税込み価格を求めたい場合、f(g(price))です。これをリストで考えると、prices.map(price => f(g(price)))です。

先ほどと同じようにmap単体の値を対象に取る関数price => f(g(price))を、配列を対象にとる関数のレベルまで引き上げています。これを関数型言語の文脈ではよく「リフト」と呼びます。

ここで重要なのは、fgをそれぞれmapでリフトして連続して適用してもよいし、先にfgを連続して適用する関数を作ってあとからリフトしても同じ結果になる点です。

つまり、

prices.map(price => f(g(price)))

prices.map(g).map(f)

と書いても得られる結果は同じです。

配列を受け取って結果として配列を返す、だから次の配列を受け取る関数に渡すことができる。それはすなわちメソッドチェーンが形成できるということです。これは便利ですね。

ファンクタの条件

いま、ファンクタがズバリなにかはまだ分かりませんが、とりあえずファンクタの条件を満たせばメソッドチェーンの形式で便利に取り扱えそうなことが分かってきました。では、ファンクタとなるための条件とはなんでしょう。

これは、ファンクタ則と呼ばれていますが、具体的には恒等射があり、結合法則を満たせばよいです。

恒等射は簡単にいうと中身を変更しない関数が定義できるということです。配列の例で言えば、関数x => xがこれを満たします。prices.map(x => x)は元のpricesと同じ値のセットを持ちます。

また結合法則は、3つ以上の関数を合成する際に、その合成順序を問わないということです。つまり、

prices.map(f).map(price => h(g(price)))

prices.map(price => g(f(price))).map(h)

が等しいということです。

このように、ある法則さえ満たせば見た目が違っても同じような取り扱い方ができることがわかると形式的に処理できるため非常に便利です。

型Tを使って考える

いままでの内容を型のレベルで考えると、T => Tなる関数をmapでリフトしてT[] => T[]として扱えるようにしたのでした。

追加情報がなく当たり前のことを言っているように感じるかもしれませんが、これは次のモナドを考えるにあたって認識しておいてほしいポイントなので今はこらえてください。

モナド

お待ちかねのモナドです。ファンクタをさらに拡張していきましょう。

これまで値を受け取って値を返す関数(T => Tをリフトすることでファンクタとして扱ってきました。

しかし、値を受け取って、配列を返す関数(T => T[]で同じことをやろうとしてもうまくいかなくなります。

たとえば、ある値を受け取って、4:6に分けた配列を返す関数があったとします。x => [x * 0.4, x * 0.6]です。

試しにこれをmapでリフトして[10, 20, 30]に適用すると、[[4, 6], [8, 12], [12, 18]]になりますが、この2次元配列に対して別のT => T[]を連続して適用することはできません。

すでに対象の型はT[][]になっているからです。

flatMap

唐突ですが、javascriptにはflatMapという関数があります。これは、mapしてからflattenする操作を一度に行う関数です。

つまり関数T => T[]を与えた結果が再びT[]に戻ります。先ほどの例で言うと、[10, 20, 30]に適用すると、[[4, 6], [8, 12], [12, 18]]ではなく[4, 6, 8, 12,12, 18]が返ります。

このflatMapであれば、prices.flatMap(ff).flatMap(gg)のようにメソッドチェーンの形に持っていけます(ffggT => T[]なる関数)。

ざっくり言うとこれがモナドです。T[]に対してT => TをリフトするのがファンクタだったのがT => T[]の関数でも同じようなことができるのがモナドです。

もう少し一般化する

いままでリフトする先を配列に限定していましたが、より一般的にはT => Tを用いてU<T> => U<T>の処理を実現すればファンクタ、T => U<T>を用いてU<T> => U<T>の処理を実現すればモナドと言ってもよいです。

つまり、ある型Tを別の型Uでラップした型があったとき、中身のTがラップされたままの状態でうまいこと扱いたいというモチベーションがあることがわかります。

ここでようやくモナドの例としてよくとりあげられるOption型などについて話すことが出来ます。Option型はエラーを内包した型です。

Option型

除算結果を考えます。一般的にゼロ除算はエラーとなるので、number除算の結果はnumberまたはエラーです(javascriptだとゼロ除算でInifinityNaNが返りますが話が複雑になるため、エラーと同視します)。

そこで、以下のようなラップ型を考えます。

type Option<T> = Some<T> | None<T>

除算が成功していればSome<number>、ゼロ除算であればNone<number>を返すラップです。

SomeクラスとNoneクラスを実装してみます。

class Some<T> {
    value: T;

    constructor(x: T) {
        this.value = x;
    }

    lift(fn: (x: T) => Option<T>) {
        return fn(this.value);
    }
}

class None<T> {
    lift(fn: (x: T) => Option<T>) {
        return this;
    }
}

type Option<T> = Some<T> | None<T>;

各クラスのliftメソッドにnumber => Option<number>となる関数を投げれば、Option<number> => Option<number>のメソッドチェーンが実現できます。 ※除算で得られた結果Option<number>に対して、さらに除算を適用したい場面というのは容易に想像されます。

const f = (x: number, y: number): Option<number> => y === 0 ? new None : new Some(x / y);

const f1 = (x: number) => f(x, 1);
const f2 = (x: number) => f(x, 2);
const f4 = (x: number) => f(x, 4);
const f0 = (x: number) => f(x, 0);

上記のようにすれば、除算をnumber => Option<number>と捉えることができます。また、連続した除算処理のどこかでゼロ除算が発生すれば、それ以降は常にエラーとして伝播されます。

まさに、先ほどの配列の例と同じようにT => U<T>を用いてU<T> => U<T>のメソッドチェーンを実現する形になっています。

new Some(20).lift(f1).lift(f0).lift(f2);  //=> None {}
new Some(20).lift(f1).lift(f2).lift(f4);  //=> Some: { "value": 2.5 }

上記のようにOptionにおいてもflatMapに相当する手続きを規定すれば、同じモナドという枠組みで扱えるということです。

ファンクタもモナドもデザインパターンのように捉えることができる

ある形式的手続きをとるために満たすべき性質を整理することで、定型処理に落とし込むことができます。今回で言えばT => U<T>を使ってU<T> => U<T>なメソッドチェーンの形に落とし込めます。

このように類型と名付けを行うことこそがデザインパターンの意義のひとつですし、モナドもファンクタもデザインパターンに近しいものを私は感じました。

これは、アルゴリズムやデータ構造でも似たようなメリットを見出せます。
たとえばモノイドであればSegment Treeの構造に載せられるなど、満たすべき性質について整理することは本質的な理解、一般化に通じる行為です。

おわりに

モナド、調べるとHaskellだとか圏論だとか出てきてしまって理解を拒絶してくる概念ではありますが、この記事によって少しでもお近づきになれた感覚を与えることができたなら幸いです。

私自身もよく分かっていなかったのですが、冒頭に挙げた何冊かの本を行ったり来たりしながら読んでいるうちに「そういうことか~」となんとなく掴めた感覚を得ました。
よく箱にいれた絵でモナドが説明されたりしますが、まさにU<T>としてラップした形でうまく扱うということなのだな、ということだと思っています。

この記事で興味が湧いた方は、この年末の機会にぜひ書籍を手に取って関数型の勉強をしてみてはいかがでしょうか!


私達ACS事業部はAzure・AKSを活用した内製化のご支援をしております。ご相談等ありましたらぜひご連絡ください。

www.ap-com.co.jp

また、一緒に働いていただける仲間も募集中です!
切磋琢磨しながらスキルを向上できる、エンジニアには良い環境だと思います。ご興味を持っていただけたら嬉しく思います。

www.ap-com.co.jp

本記事の投稿者: 髙井 比文
AKSをメインにしたインフラとアプリの領域際をご支援することが多いです。Azureは11冠です。
Hifumi Takai - Credly