GolangでシンプルなIOモナドを実装してみよう

2023/12/10に公開

はじめに

Haskellなどの純粋関数型言語を勉強している中で出てくる「モナド」という概念があります。
最初はこの概念についてよくわからず、そして色々調べる中で色々な表現で説明されていました。
例えば、

  • プログラムを構造化するための汎用的な抽象概念 [1]
  • 自己関手の圏におけるモノイド対象 [2]
  • bind関数とtoMonad関数を持つ型 [3]

のようにいくつかの表現がありました。
調べている中で一番しっくり来たのが以下の記事(参考3)だったので、これをベースにシンプルなIOモナドを実装していきます。

https://www.infoq.com/jp/articles/Understanding-Monads-guide-for-perplexed/

この記事では基本的な標準入力を取得する関数を使った式x = nextInput()から説明が始まり、これがシステムの状態に依存する変数であること、そして最終的にはモナドを活用してコード内にシステムの状態を明示的に表す部分を取り除くことに成功しています。
この流れに沿って実際に必要な関数をGolangで実装していきましょう。

なぜGolangか、というのは単純に自分が書きやすいからです。

実装方針

今回実装する必要がある関数は以下になります。

  • doInput
    • 標準入力から1行読み取って文字列として取得する関数で、アクションを返す
  • doPrint
    • 文字列を引数にとって標準出力に出す関数で、アクションを返す
  • bind
    • 2つのアクションを1つにつなげる関数で、アクションを返す

これらの関数はすべてアクションを返す関数であり、つまりすべて同じコンテキストであると言えます。
また、doInput()が返すのは文字列ではなくアクションであり、この返り値自体を文字列操作するような方法はできません。
例えば以下のようなコードを書いたとします。

func main() {
    input := doInput()
    fmt.Println("input: " + input)
}

あくまでこの関数が返すのは「標準入力から1行取得するというアクション」なので、文字列操作はできません。
そしてこれを解決するために文字列を変数に代入してしまうと、状態を持った変数がプログラム上に現れてしまいます。

これを解決するために、doPrintという「標準出力に文字列を出すアクション」をbindで連結させて、1つのアクションとして実行させることを考えます。
もう少し具体的に言うと、関数bindの目的は、「任意のアクションAと任意の文字列からアクションへの関数fを受け取り、それらを組み合わせて新しいアクションを作る」ことになります。
今回はIOアクションなので、doPrint実行後は、HaskellでいうIO ()のような状態であってほしいです。
これをどう表現するべきかが難しかったのですが、今回はdoPrintは空文字を含んだアクションを返すことにしています。

これにより一連のコンテキストの中で値を受け渡すことで外界に状態を公開することなく処理をすることができます。

では実際に具体的な実装について見ていきましょう。

実装

以下にそれぞれの要素をどのように実装したかについて解説していきます。

  • func() stringの型であるIOMonad型を定義する
type IOMonad func() string

IOMonad型を使って、この型の中の世界で副作用のある処理を実行したいです。
それを実現するために、この型はfunc() stringという文字列を返す関数として定義しています。
これにより標準入力から取得する実装をfunc() string内で実装したとき、その時外界から取得した情報が関数の外部に露出しません。
同様に標準出力する際もこの関数の中で実行することによって書き込みの副作用を隠蔽しています。

  • doInputで1行の文字列を読み込む関数を定義
func doInput() IOMonad {
	return func() string {
		sc := bufio.NewScanner(os.Stdin)
		sc.Scan()
		return sc.Text()
	}
}

この関数はIOMonad型を返します。
具体的には*bufio.Scannerを使って1行読み取り、その読み取った文字列を返す関数(IOMonad)を返します。

  • doPrintで引数の文字列を出力する関数を定義
func doPrint(s string) IOMonad {
	return func() string {
		fmt.Println(s)
		return ""
	}
}

この関数もIOMonad型を返します
引数に取った文字列をシンプルにfmt.Println()で標準出力に出しています。
そして実装方針でも話した通り、空文字を返す関数(IOMonad)を返します。

  • bindで2つのアクションを連結させる
func bind(m IOMonad, f func(string) IOMonad) IOMonad {
	input := m()
	result := f(input)
	return result
}

この関数の内部では、第1引数に取ったIOMonadを関数として実行します。
これによりdoInputのコンテキストの中にある文字列を変数に代入できるようになりました。
そして次にこの変数を第2引数の関数に渡して実行します。
そしてその結果もIOMonadを返すので、これをreturnして完了です。

この関数により2つのアクションが連結して、1つのコンテキストの中で入力から出力まで実行することができました。

Haskellでいうと以下の書き方と同様になると思います。

main :: IO ()
main = do
    input <- getLine
    putStrLn input
  • main関数で実行する
func main() {
	action := bind(doInput(), doPrint)
	action()
}

実際にアクションを実行してみましょう。
第1引数にはIOMonad型が欲しいのでdoInput()を渡します。
第2引数はfunc(string) IOMonad型が欲しいので、doPrintとし、実行したものではなく関数自体を引数に渡します。

これにより変数actionにはdoInputdoPrintが一連の処理として実行された結果のアクションが代入されます。
そしてこのアクション自体が何かできるわけではないですが、このactionが作成される過程で標準入力から入った値がコンテキスト内で標準出力まで渡るようになりました。

これによってmain()内部で標準入力で取得した値を格納するような変数が存在せず、状態に依存するものをコード上から排除することができました!

最後にコード全体も載せておきます。

package main

import (
	"bufio"
	"fmt"
	"os"
)

// IOMonadは、入出力操作をカプセル化するための関数型です。
type IOMonad func() string

// doInputは、標準入力から文字列を読み込むIOMonadを生成します。
func doInput() IOMonad {
	return func() string {
		sc := bufio.NewScanner(os.Stdin)
		sc.Scan()
		return sc.Text()
	}
}

// doPrintは、与えられた文字列を標準出力に出力するIOMonadを生成します。
func doPrint(s string) IOMonad {
	return func() string {
		fmt.Println(s)
		return ""
	}
}

// Bindは、IOMonadを実行し、その結果を別のIOMonadにバインド(適用)する関数です。
func bind(m IOMonad, f func(string) IOMonad) IOMonad {
	input := m()
	result := f(input)
	return result
}

func main() {
	// Bindで連結することで、時間依存のない標準入力と標準出力を実現できます。
	action := bind(doInput(), doPrint)
	action()
}

終わりに

モナドというものがどういうものか理解したかったので一から自分で実装してみよう、というのが動機で書き始めてみました。
これによって様々なモナド(ListMaybeなど)に対する理解や、これらを操作する関数たちについても理解が深まりました。
何か間違いなどあれば教えていただけるとありがたいです。

まだ関数型プログラミングについてはカバーしきれていない部分も多いので引き続き勉強していこうと思います。
最後まで見てくださってありがとうございました!

参考

[1]:

https://ja.wikipedia.org/wiki/モナド_(プログラミング)

[2]:

https://techblog.ap-com.co.jp/entry/2022/12/28/175316

[3]:

https://www.infoq.com/jp/articles/Understanding-Monads-guide-for-perplexed/

GitHubで編集を提案

Discussion