26
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C++Advent Calendar 2022

Day 22

C++にモナドはいらない

Last updated at Posted at 2022-12-22

C++ Advent Calendar 2022 の 3 日目の記事 を読んだのですが、C++の規格を作っている人の中にモナドが好きな人がいるようで、仕様書の中にモナド的などという単語を必要もないのに入れようしているようです。それでモナドに興味を持った人もいるかと思って記事を書いてみることにしました。巷にモナドの説明自体はいっぱいあるんですが、Haskellを使って説明するものばかりで読みにくいですし、なんだか要領を得ないものが多いので、C++で説明する需要はあるだろうと思っています。ただし、C++でモナドを作っても意味がないよという否定的な意見を書きますので、そういうのがお好みでない方は許してください。

シンタックスシュガーがないのに、モナドを作るんですか?

とりあえず説明のために簡単なIOモナドっぽいのものを書いてみます。まず、文字列を表示する動作を次のように書けるようにします。これは関数オブジェクト戻す関数を定義するだけなので簡単です。

const Io m1 = PutStrLn("ハロー");
m1(); // 注: 以下の例ではこの部分を省略します

入力と出力の動作を合成する演算子>>=を定義します。入力した文字列をそのまま表示する動作を次のように書けるようにします。

const Io m2 = GetLine >>= PutStrLn;

>>=を使うと、表示を二回する動作はたとえば次のように書けますが、

const Io m3 = PutStrLn("ハロー") >>= [](std::string s) {return PutStrLn("ワールド");};

これでは不便なので、別に出力動作の合成用に演算子>>を定義します。>>を使うと同じ動作は次のように書けます。

const Io m4 = PutStrLn("ハロー") >> PutStrLn("ワールド");

そうするとメッセージを表示をしてから文字列を入力してそれを出力する動作は、

const Io m5 = PutStrLn("何か入力してください") >> GetLine >>= PutStrLn;

文字列の入力を二回して一回目を無視して二回目を表示する動作は、

const Io m6 = GetLine >> GetLine >>= PutStrLn;

のように書けるわけです。ここまでをC++で実装すると次のようになります。

#include <iostream>
#include <string>
#include <functional>

class Io
{
public:
	explicit Io(const std::function<std::string()>& f1)
		: f(f1) {}

	std::string operator () () const
	{
		return f();
	}

	friend Io operator >>= (const Io&, const std::function<Io(std::string)>&);

private:
	std::function<std::string()> f;
};

Io operator >>= (const Io& io, const std::function<Io(std::string)>& next)
{
	auto f = io.f; // copy
	auto n = next; // copy
	return Io([f, n]() {return n(f())();});
}

Io operator >> (const Io& io1, const Io& io2)
{
	auto io = io2; // copy
	return io1 >>= [io](std::string s) {return io;};
}

const Io GetLine([]() {
	std::string s;
	std::getline(std::cin, s);
	return s;
});

Io PutStrLn(std::string s)
{
	return Io([s]() {
		std::cout << s << std::endl;
		return "";
	});
}

int main()
{
	const Io m1 = PutStrLn("ハロー");
	m1();

	const Io m2 = GetLine >>= PutStrLn;
	m2();

	const Io m3 = PutStrLn("ハロー") >>= [](std::string s) {return PutStrLn("ワールド");};
	m3();

	const Io m4 = PutStrLn("ハロー") >> PutStrLn("ワールド");
	m4();
	
	const Io m5 = PutStrLn("何か入力してください") >> GetLine >>= PutStrLn;
	m5();

	const Io m6 = GetLine >> GetLine >>= PutStrLn;
	m6();
}

また、>>=は式の中に一つしか置けないわけではありません。直前の入力を受け取って入力した文字列と連結して出力する動作を戻す関数GetMoreが定義されているとき、文字列を二つ入力して、それを連結して出力する動作は次のように書けます。(>==は右結合なのでカッコが必要なことに注意)

const Io m7 = (GetLine >>= GetMore) >>= PutStrLn;

このとき、>>=で接続された任意の長さの式の中の、任意の部分を別の変数に取り出すことができます。左端から取り出すときはカッコで、中央や右端から取り出すときはラムダ式を使います。次のAとBが同じ動作を定義していることを確認してください。

const Io A = (GetLine >>= GetMore) >>= PutStrLn;

const auto GetLine2 = GetLine >>= GetMore;
const Io B = GetLine2 >>= PutStrLn;
const Io A = (GetLine >>= GetMore) >>= PutStrLn;

const auto GetPut = [](std::string s) {return GetMore(s) >>= PutStrLn;};
const Io B = GetLine >>= GetPut;
const Io A = ((GetLine >>= GetMore) >>= GetMore) >>= PutStrLn;

const auto GetMore2 = [](std::string s) {return GetMore(s) >>= GetMore;};
const Io B = (GetLine >>= GetMore2) >>= PutStrLn;

さらに、入出力を伴わない動作を表すために、呼び出すと文字列を戻すだけの動作を戻す関数Returnを定義します。

const Io m8 = Return("ハロー");
if (m8() == "ハロー") std::cout << "OK" << std::endl;

このReturnを使うと、入出力の任意の部分を中止することができるようになります。たとえば入力動作を受け取る関数にReturnの戻り値を渡して入力の代わりに文字列を使用したり、出力動作を戻す関数を受け取る関数にReturnを渡して出力を中止したりできるようになります。

Io Echo1(const Io& Input)
{
	return Input >>= PutStrLn;
}

Io Echo2(const std::function<Io(std::string)>& Output)
{
	return GetLine >>= Output;
}

Io Echo3(const std::function<Io(std::string)>& More)
{
	return (GetLine >>= More) >>= PutStrLn;
}

int main()
{
	const Io m9 = Echo1(GetLine);
	m9();  // 入力してそれを表示する

	const Io m10 = Echo1(Return("エコー")); 
	m10(); // 入力せずに"エコー"を表示する

	const Io m11 = Echo2(PutStrLn);
	m11(); // 入力してそれを表示する

	const Io m12 = Echo2(Return);
	m12(); // 入力するが表示はしない

	const Io m13 = Echo3(GetMore);
	m13(); // 二回入力してそれらを連結したものを表示する

	const Io m14 = Echo3(Return);
	m14(); // 一回入力してそれを表示する
}

なお、なぜReturnという名前かというと再帰定義によって繰り返す動作を定義しているときに、その終了部分で使われることが多いからです。入力があるまで繰り返すのであれば次のように書けます。

Io m15;
m15 = GetLine >>= [&m15](std::string s) {if (s.empty()) return m15; return Return(s);};

ここまでをC++で実装すると次のようになります。

#include <iostream>
#include <string>
#include <functional>

class Io
{
public:
	Io()
		: f([]() {return "";}) {}

	explicit Io(std::string s)
		: f([s]() {return s;}) {}

	explicit Io(const std::function<std::string()>& f1)
		: f(f1) {}

	std::string operator () () const
	{
		return f();
	}

	Io& operator = (const Io& io)
	{
		if (&io != this) f = io.f;	
		return *this;
	}

	friend Io operator >>= (const Io&, const std::function<Io(std::string)>&);

private:
	std::function<std::string()> f;
};

Io operator >>= (const Io& io, const std::function<Io(std::string)>& next)
{
	auto f = io.f; // copy
	auto n = next; // copy
	return Io([f, n]() {return n(f())();});
}

Io operator >> (const Io& io1, const Io& io2)
{
	auto io = io2; // copy
	return io1 >>= [io](std::string s) {return io;};
}

Io Return(std::string s)
{
	return Io(s);
}

const Io GetLine([]() {
	std::string s;
	std::getline(std::cin, s);
	return s;
});

Io GetMore(std::string s1)
{
	return Io([s1]() {
		std::string s2;
		std::getline(std::cin, s2);
		return s1 + s2;
	});
}

Io PutStrLn(std::string s)
{
	return Io([s]() {
		std::cout << s << std::endl;
		return "";
	});
}

Io Echo1(const Io& Input)
{
	return Input >>= PutStrLn;
}

Io Echo2(const std::function<Io(std::string)>& Output)
{
	return GetLine >>= Output;
}

Io Echo3(const std::function<Io(std::string)>& More)
{
	return (GetLine >>= More) >>= PutStrLn;
}

int main()
{
	const Io m7 = (GetLine >>= GetMore) >>= PutStrLn;
	m7();

	{
		const Io A = (GetLine >>= GetMore) >>= PutStrLn;

		const auto GetLine2 = GetLine >>= GetMore;
		const Io B = GetLine2 >>= PutStrLn;

		A();
		B();
	}

	{
		const Io A = (GetLine >>= GetMore) >>= PutStrLn;

		const auto GetPut = [](std::string s) {return GetMore(s) >>= PutStrLn;};
		const Io B = GetLine >>= GetPut;

		A();
		B();
	}

	{
		const Io A = ((GetLine >>= GetMore) >>= GetMore) >>= PutStrLn;

		const auto GetMore2 = [](std::string s) {return GetMore(s) >>= GetMore;};
		const Io B = (GetLine >>= GetMore2) >>= PutStrLn;

		A();
		B();
	}

	const Io m8 = Return("ハロー");
	if (m8() == "ハロー") std::cout << "OK" << std::endl;

	const Io m9 = Echo1(GetLine);
	m9();  // 入力してそれを表示する

	const Io m10 = Echo1(Return("エコー")); 
	m10(); // 入力せずに"エコー"を表示する

	const Io m11 = Echo2(PutStrLn);
	m11(); // 入力してそれを表示する

	const Io m12 = Echo2(Return);
	m12(); // 入力するが表示はしない

	const Io m13 = Echo3(GetMore);
	m13(); // 二回入力してそれらを連結したものを表示する

	const Io m14 = Echo3(Return);
	m14(); // 一回入力してそれを表示する

	Io m15;
	m15 = GetLine >>= [&m15](std::string s) {if (s.empty()) return m15; return Return(s);};
	m15();
}

さて、ここでこの動作を表すIoクラスのインスタンスをモナドと呼んでいいのかという話になるのですが、Haskellには一応モナドと呼べる基準として使われることの多いモナド則というものがあります。===を左右が同じ動作を表現していることを示す記号とするとモナド則は次のように定義されます。

  1. return x >>= m === m x
  2. m >>= return === m
  3. m >>= f >>= g === m >>= (\x -> f x >>= g)

C++で書き直すと次のようになります。

  1. Return(x) >>= m === m(x)
  2. m >>= Return === m
  3. (m >>= f) >>= g === m >>= [](X x){return f(x) >>= g;}

これは文字列しか入出力ができない、つまりmの型がIoで、かつxの型であるXが常にstd::stringであるという制限を与えれば、既に説明した仕様と同じです。よってIOモナドっぽいものができました。Haskellでは他にも関数をいくつか定義する必要があるのですが、Haskellと完全に同じでなければいけない理由もないので、これでも広い意味では十分にモナドと呼べるでしょう。

ここまでの説明を見るとそんなに見た目も悪くないし何かの役には立ちそうに思えますが、実はもう少し複雑な動作になるとすぐにラムダとキャプチャだらけになって手に負えなくなります。たとえば、単語を二つ入力して長いほうを表示する動作を合成するならば次のようにするしかありません。

const Io m16 =
GetLine >>= [](std::string s1) {
	return GetLine >>= [s1](std::string s2) {
		if (s1.length() >= s2.length()) {
			return PutStrLn(s1);
		} else {
			return PutStrLn(s2);
		}
	};
};

ではプログラミングにおけるモナドの本家であるHaskellではこういう場合にどうしているのかというと、do記法というシンタックスシュガーが用意されていて、それを使って簡単にこの問題を回避しています。

上のC++のコードと同じものをHaskellで実装する次のようになりますが、

main = getLine >>= \s1 -> getLine >>= \s2 -> if length s1 >= length s2 then putStrLn s1 else putStrLn s2

通常はdo記法を使って次のようにするので読みづらくはないわけです。

main = do
	s1 <- getLine
	s2 <- getLine
	if length s1 >= length s2
		then putStrLn s1
		else putStrLn s2

結論: do記法のないモナドはゴミ。C++にはdo記法に相当するものがないのでモナドを作っても意味がない。

モナドを使って本当にやりたかったことは?

ここまでの説明を聞いて、何かおかしいと思いませんか。感のよい方はすぐに気づいたでしょうけれども、結局次のC++のコードとやっていることは同じなので、よく考えるとあまり便利になっていないのです。

#include <iostream>
#include <string>

std::string getLine()
{
	std::string s;
	std::getline(std::cin, s);
	return s;
}

void putStrLn(const std::string& s)
{
	std::cout << s << std::endl;
}

int main()
{
	const auto s1 = getLine();
	const auto s2 = getLine();
	if (s1.length() >= s2.length()) {
		putStrLn(s1);
	} else {
		putStrLn(s2);
	}
}

ではHaskellの方と何が違うのかというと、C++の場合は次のように入出力と関係のないコードを勝手に追加できますが、

int main()
{
	const auto s1 = getLine();
	const auto s2 = getLine();
	doSomething(); // 入出力と関係のない関数を呼んでもOK
	if (s1.length() >= s2.length()) {
		putStrLn(s1);
	} else {
		putStrLn(s2);
	}
}

Haskellのdo記法の方ではできません。一部の場所、条件式の部分とかreturnやputStrLn等のIOモナドを返す関数の引数の部分とかそういうところを除けはIOモナドしか書けないのです。特定の処理しか書けないという制約のある範囲を手続き型のプログラミング言語と同じような読みやすい見た目で提供する、これがモナドのやりたいことです。ただ、C++にはdo記法に相当するものがありませんからこれはできません。それにC++のプログラマーからすると入出力しか書けない範囲を作れたところで嬉しくもないですし、正直いらない、むしろ迷惑な機能でしょう。

実用的な実装をしようとすると意外にめんどくさいというのもあります。大きな理由としてHaskellの関数はC++でいうテンプレート関数のようなものだからです。しかもHaskellの場合、関数の引数の側と戻り値の側の双方で型の指定が自由ですから同じことをしようとするとテンプレート引数が2つ必要です。上記のC++例ではテンプレートを使わずに簡略化していましたが、IOモナド以外も作りたいstd::string以外にも対応したいとなると、IoReturnの代わりにIo<std::string>Return<Io<std::string>>と書かなければいけないことになりますから実装が大変です。そのくせ本当にやりたいことはできないのだからC++でモナドを実装するのはお勧めできません。

結論: C++でモナドを実装しても本当にやりたかったことはできないし、本当にやりたかったことはC++には必要がない。

まとめ

Haskellのプログラマーの中には、Haskellにはモナドというすばらしいアイディアがあってモナドを学ぶと他の言語でプログラミングするときにも役に立つとか、新しい物の見方を得ることができるとか、その手の無責任なことをいう人もいますが決してそんなことはないのでHaskellのモナドを学ぶ必要はありません。ご安心ください。自信をもってC++にモナドはいらないと言いましょう。

26
7
6

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
26
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?