65
52

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 5 years have passed since last update.

自称C++中級者がHaskell初心者になってみる話

Posted at

ドーモ、Haskeller=サン、C++中級者です。

C++erを簡易的にレベル分けすることで初心者詐欺を減らそうという試み」の基準によって自己評価すると、私はおそらくC++中級者なのだと思います。いややっぱりC++規格書リーディング初心者かも。ううんむしろやっぱりC++初心者でいいんじゃないかな。いやそれどころかC++入門者かもしれない。いやC++初学者か? boost.asioは使えるけど。networkingはよ。

まあ、今回はHaskellの話です。
いい加減そろそろ関数型プログラミング言語について学ぶべきだと神は言っているような気がしたので、「そんな知識で大丈夫か?」と問われた時に「大丈夫だ、問題ない」と答えられるように一番いいのを頼むことにしました。なんたって「本物のプログラマはHaskellを使う」らしいですからHaskellとやらは一番いい言語なのでしょう。by C++初心者

この文章は、私がHaskellを理解するためにいろいろ調べながら考えたことを綴ります。なので、C++の知識に照らし合わせてみることもありますし、推測を述べたりもします。情報の正確度は期待できないでしょう。Haskellerのみなさんは、間違ったことを言っていたら訂正してくださると嬉しいです。

Haskell = なんだかむずかしそう

さて、Haskellといえば「モナドは単なる自己関手の圏におけるモノイド対象だよ。何か問題でも?」("A monad is a monoid in the category of endofunctors, what's the problem?")で有名なモナドが出てくる言語です。こんな言葉を見てしまうと、私のようなHaskell初学者(本当1)は「ハァ?」と思うわけですな。

とは言え、この言葉は「不完全にしておよそ正しくないプログラミング言語小史」における冗談の類であり、世のHaskellerが皆これを否定していない所を見ると確かにその通りではあろうけれど、「ハァ?」と思うのも無理からぬことなのではないか。「自己関手の圏におけるモノイド対象」がなんの事かさっぱり分からなくても、Haskellを使うことはできるはずでしょう。

Haskell
main = putStr "Hello, World!"

できた。
いやーHaskellは強敵でしたねー……とは行かないのです。
ハローワールドが動いたからってお前は何をそんなに慢心してるんだ。初心者か。初心者だったな。

純粋関数型言語とは

Haskellは純粋関数型言語らしい。なんだそりゃ。
純粋関数型言語は参照透過性が保たれるらしい。なんだそりゃ。
参照透過性というのは、同じ入力に対して常に同じ出力を伴うことを言うらしい。
この辺まで砕かれると何となく分かった気がしないでもない。
つまりHaskellでは、全ての関数が同じ入力に対して同じ出力を伴っているそうな。

例えば上のハローワールドにおけるmainは関数らしい。ということは、main関数は参照透過性が保たれているはずです。
mainと言えば、C/C++にも存在します。プログラムの開始地点にもなる関数であるが、当然、C/C++では参照透過性は保たれていないことが多い。

だが、C++でも、参照透過性が保たれている奴がいることを私は知っている。constexpr関数です。constexpr関数は副作用が生じません。すなわち同じ入力に対して同じ出力を伴い、内部状態を持たない。
Haskellの関数と、C++のconstexpr関数は参照透過性という点において共通している。共通しているからには、同じ考え方を当てはめることができるのではないでしょうか。

しかし。

Haskell
main = putStr "Hello, World!"

このmain関数、実行すると"Hello, World!"を標準出力に出力します。
そんな関数、C++ではconstexprにできません。
C++ではmainはエントリーポイントとして予約されているのでそもそもconstexprにできませんが、仮にconstexprにできたとしても、標準出力に"Hello, World!"なんて出力する関数がconstexprになることはあり得ません。
Haskellだって、標準出力に文字を出力する操作を行うことを「参照透過性が保たれている」なんて言う訳はないでしょう。

じゃあ、このmainは何者だ。
「参照透過性が保たれている」のだから、main関数を何度呼んでも結果は変わらないし、何も影響を及ぼさないはずです。
と、言うことは。
mainは、"Hello, World!"を出力している訳ではないのだ。
main関数は、あくまでputStr "Hello, World!"を返しているだけなのだ。
と考えれば、一応の辻褄はあっている気がします。

もう一度さっきのを見てみます。

Haskell
main = putStr "Hello, World!"

今、私はmainを実行してみましょう。
実行すると何やら、何かよくわからないけど何かの塊が手元に来ました。なんだこりゃ。中身も見えないし。
mainは実行し終わったけど、何も起こってません。端末は真っ白のままだし、カーズ様は宇宙空間を漂い続けているだけです。
ただし、手元にはこの何かよくわからないものがあります。何かよくわからないけど、ボタンが一個付いてます。
ボタンが付いてたら押すしかないよね! ポチッとな。

Hello, World!

うわっ!
あ、標準出力に文字列が出力された。

ふーむ。
「標準出力に文字列を出力する」という副作用のある操作を、Haskellは絶対にやってくれない。
けど、「標準出力に文字列を出力する部品」と「文字列」を組み合わせるという操作だけはやってくれる。
「標準出力に文字列を出力する部品」がputStrで。
「文字列」が"Hello, World"で。
さっきmainを実行した時に手に入れた何かは、putStr"Hello, World"を組み合わせた物だったんじゃないでしょうか。

……あれ?
これと似たようなものを、私は知っているよ?

コンパイラって、そういうものじゃない?

gccやclangやMSVCは、ソースコードを読んで、実行ファイルを作ります2

コンパイラは実行できるファイルを作りますが、別に実行してくれる訳じゃない。

c++
#include <iostream>

int main() {
    std::cout << "Hello, World!" << std::endl;
}

というソースコードをコンパイルしたところで、"Hello, World!"が出力される訳ではないのだと。
得られるのは、std::coutと<<演算子と"Hello, World"と<<演算子とstd::endlを組み合わせた実行可能なコードだけなのだと。

つまり、Haskellを実行することと、C++のコンパイルをすることは同じ事だったんだよ!
な、何だってー!

本 当 か ?
言ってて自信がないんだけど。
でも、考えれば考えるほど、そういうことじゃないのって気がするんです。

さて。
上の推測の真偽はともかく、ちょっと調べてみましたが、putStr "Hello, World!"は、Haskellではアクションと呼ぶものらしいです。
これに限らず、「副作用のある動作を表す値」のことをアクションと呼ぶらしい。
ということは、main関数はアクションを返す関数のことであり、さっきの「よく分からない何か」はまさにアクションそのものだった、ということでしょう。

putStrの正体も分かってきた気がします。
putStr "Hello, World!"は、「標準出力に"Hello, World!"と出力する」というアクションですが、putStrそのものはアクションではありません。
こいつは、関数です。
文字列を受け取って、「標準出力に文字列を出力するアクション」を返す関数のようです。

型の話

さて。
Haskellは純粋関数型言語であると同時に静的型付け言語なので、全ての値は型を持っています。
で、関数も実は値であるらしいのです。値なので、「関数の引数に関数を渡す」みたいなこともできる。まあ、C++なんかでもできることなので、それ自体は不思議な事ではありません。

先ほどの例。Haskellは型推論機構をもっているので、mainの型は書く必要はありませんでした。
ですが、あえて書くなら、mainの型はこうなります。

Haskell
main :: IO ()

型を定義する時は、「識別子 :: 型」と書くらしいです。
なので、この場合mainの型はIO ()である、ということになります。

ところで、先程からmainのことを関数関数と言っていましたが(実際そうだと思っていた)、実はmainは変数らしいです。
というか、変数の書き換えはできないので、実際のところは定数らしいです。
自分で書いてて、なかなか胡乱な文章だと思いますけれど。

良く見てみましょう。

Haskell
main = putStr "Hello, World!"

mainputStr "Hello, World!"の結果を代入しています。
そう言われると、確かにそんな気がします。
というか、そうとしか見えなくなってきました。
そうか!mainは変数だったのか! 関数じゃなかったんだ!

……と、言い切ってしまうのも何か違う気がします。

そもそも、関数って何だ。
関数は普通、「引数を受け取って、値を返す物」です。
putStrなんかはそうですね。Haskellの文法でputStrの型を表すと、こうなります。

Haskell
putStr :: String -> IO ()

関数の型を書く時は、「引数の型 -> 戻り値の型」と書きます。
引数が2個あるときは、「引数1の型 -> 引数2の型 -> 戻り値の型」となります。
もっと増えても同じです。

そうすると、関数と変数(定数)の違いって何でしょう。
型で表すと、「引数の型 ->」というのが付いているか付いていないか、ということになりますが、引数が一個増えるごとに「引数の型 ->」が一個ずつ増えていくということを考えると、逆に、mainなどは「引数が0個の関数」と言ってしまってもいいのではないでしょうか。
さらに言えば、「関数」とされるものも、「『引数の型 -> 戻り値の型』という型」の値が代入された定数と言ってしまってもいいのではないでしょうか。

多分、それほど間違ったことは言っていないはずです。Haskellの世界では、全ての関数は変数で、全ての変数は関数で、もっと言うと全ては定数なのだと思います。constexpr! constexprじゃないか! 本当か?
まあ、そもそもC++11以降のC++には関数型言語で発展してきた概念が取り込まれていたりするので、だいたいあってる……はず。

つまり、Haskellで

Haskell
a :: Int
a = 0

と書くと、aは「Int型の値0を束縛する定数」となりますが、「引数0個でInt型の値0を返す関数」と捉えてもいいはずです。

なんだか大分話題が逸れたなあ。何処から逸れたんだ。型の話を始めたあたりからか。
あー、IO ()型の話をしようと思ったんだったか。

IO ()型。
mainの型はIO ()でないといけないようです。
上述したように、mainの戻り値はアクションなのだから、IO ()型はアクションの型ということになると思います。
しかし、IO ()って何でしょう。特にこの()
Haskellの型、例えばIntとかStringなら分かります。それぞれ整数型と文字列型ですし、特に直感と反する動作をすることもありません。

では、IO ()は?
IOというからには、入出力を表すであろうことは予想できます。実際、putStrの戻り値は、「標準出力に文字列を出力するアクション」なのですから、まさにI/Oです。
ここで謎なのは、()です。IOは型の領域にあって関数ではないのですが、putStrに文字列を渡しているように、IO()を渡しているように見えます。
IO()を渡した結果、IO ()型ができた。
……うーむ。
これ、テンプレートじゃないの?

c++
template<typename T>
class IO {
    ...
};

IO<hoge> fuga;

C++で書くならこういう奴。
で、調べてみると、()はユニット型というものらしい。ここを見ると無意味な値と説明されてますが、それだとまるで必要ないもののようなので、「特に意味を定義されない値」と理解することにする。

まあ、C++で言えばvoidとかstd::nullptr_tに近いものでしょうか。どちらも全く無意味というものではありませんが。

そして、IOはというと、こちらは多相型というものらしい。

Haskell
data IO a

多相型というのは、要するにC++でのテンプレートと同じようなもので、a()を渡せばIO ()になるし、aBoolを渡せばIO Bool型になるらしい。
多相型に渡した型によりどのように振る舞いが変わるかはその多相型の定義によるわけですが、IOの場合は、アクションを実行した後の結果の型を表すようです。
つまり、IO ()型は入出力操作の結果が必要ないアクションの型です。
これが例えば「ファイルの存在を調べる」とかだと、存在するかしないかの結果が必要になるので、IO Bool型になるようです。

ふぃー。
疲れた。
でもこれでようやく、

Haskell
main = putStr "Hello, World!"

というプログラムの意味がある程度分かったような気がします。
……おい、それだけか? ここまで書いて、たった一行分しか分かってないというのか!

そうです。まだ入り口です。玄関から足一歩踏み込んだ程度です。
つーわけで、もうちょっと深く見ていきたいと思います。

もうちょっと深く

いつまでもハローワールドはどうかと思うので、扱うプログラムも変えましょう。

問題:ユーザーに名前を入力させて、Worldの代わりにユーザーにHelloを言うプログラムを書きなさい。

C++erぼく「そんなの簡単さ!」

C++
#include <iostream>
#include <string>

int main() {
    using namespace std;

    cout << "Type your name, please." << endl;

    string name;
    getline(cin, name);
    cout << "Hello, " << name << "!" << endl;

    return 0;
}

問題:では、それをHaskellで書きなさい。

ぼく「えっ」

必要な関数は標準ライブラリに用意されているので、何を使えばいいのかはわかります。putStrLnとか、getLineとか

Haskell?
main =
    putStrLn "Type your name, please."
    name = getLine
    putStr "Hello, "
    putStr name
    putStrLn "!"

実行しなくても分かる。これは違う。
そもそも、putStrとかputStrLnとかgetLineとか、そういうのはみんなIO ()型もしくはIO String型、つまりI/Oアクションを返す訳です。
アクションなのだから、実行しないといけない。実行しないと標準出力には出力されないし、標準入力から文字列を読み込むこともできない。
けれど、mainの型はIO ()です。
ということは、処理系が実行するアクションは常に一個です。
どうやって複数のアクションを実行しろというのか。

答え:複数のアクションを一つにまとめればいい。

そのための機能があるようです。
そして、それこそ、モナドの機能らしいです。

出た! モナド! (警戒しつつ)

IOMonadクラスのインスタンスだそうな。
Monadクラスってなんだ。

Haskell
class Monad m where
    (>>=) :: m a -> (a -> m b) -> m b
    return :: a -> m a

    (>>) :: m a -> m b -> m b
    m >> n = m >>= \_ ->  n

    fail :: String -> m a
    fail = error

なんだそりゃ。
しばし眺めます。

うーん、「IOMonadクラスのインスタンス」という言葉から察するに、Monadクラスのインスタンスはいろいろあるのでしょう。
class Monad m whereと言う文におけるmは、仮引数的なものだろうと予想できます。ということは、mにはインスタンスが入るのでは? 例えばIOを入れられるのでは?
入れてみる。

Haskell?
(>>=) :: IO a -> (a -> IO b) -> IO b

うん、ちょっと分かってきた気がする。
ここでabの場所にも型を入れることができるはずだ。試しにStringと()を入れてみる

Haskell?
(>>=) :: IO String -> (String -> IO ()) -> IO ()

こうしてみると、(>>=)は「第一引数にIO String型、第二引数にString -> IO ()型を受け取って、IO ()型を返す関数」になってしまった。

ちょうどいいことに3getLineの型はIO StringputStrLnの型はString -> IO ()なので、当てはめてみることにする。

Haskell?
(>>=) getLine putStrLn

ふむ。
ところで、(>>=)のように記号だけで定義された関数は演算子らしい。
で、演算子は()を取っ払うと、中置表記ができるそうな。

Haskell?
getLine >>= putStrLn

おお。なんかそれっぽい。
mainに入れてみる

Haskell
main = getLine >>= putStrLn

実行すると、標準入力に書いた文字列を一行読んで出力するエコープログラムになりました。

ということはだ。
この場合の>>=演算子は、「getLineの結果であるIO Stringアクションを実行し、その結果から文字列を取り出してputStrLnに渡し、その結果のIO ()アクションを実行する」というIO ()アクションを返す関数である、と言えるようである。
言葉にすると無駄にややこしいな。
要は標準入力から1行読んで読んだ文字列を標準出力に出力するというだけの話なんだけど。

うん?
「その結果から文字列を取り出してputStrLnに渡し」?
副作用のある操作をアクションという形で分離しておいたのに、その結果の文字列をputStrLnに渡すことができるの?
いや、そうか、それでいいんだ。
いくら外側で副作用のある操作が行われていても、putStrLnが行うのは同じ操作だ。putStrLnの参照透過性は保たれているはずだ。
と、いうことだよね……?

他も見てみましょう。

Haskell
    return :: a -> m a

mIOa()とすると、a -> m a() -> IO ()になります。
例えば、

Haskell
main = return ()

とすると、なんにもせずに終了するプログラムになります。

また、

Haskell
main = return "foo" >>= putStrLn

とすると、標準出力に"foo"を出力するプログラムになります。

つまり、return演算は、Haskellの値をモナドに包んで返す関数のようです。

Haskell
    (>>) :: m a -> m b -> m b

では同じように、IOString()を当てはめてみると、(>>) :: IO String -> IO () -> IO ()です。
つまり、こうだ。

Haskell
main = getLine >> putStrLn "foo"

getLineは標準入力から1行読み込みましたが、その値は使われることはありませんでした。標準出力にはただ"foo"と表示されます。

つまり、>>演算は、戻り値を使わずにアクションを繋げる役割をしている、と捉えれば良さそう。
というか、下に書いてあるこれが定義か。

Haskell
    m >> n = m >>= \_ ->  n

\_ -> nってなんだろう。
調べました。どうやらラムダ式のようです。引数を一個とってnを返す式ですね。
\で初めて、仮引数を並べて、 -> の後ろの値が戻り値になるようです。
仮引数に_を使うのは、特に引数の値を気にしない時だそうで、普通は名前をつけるみたいですね。
引数は使用されていないので、m >>= \_ -> n全体で、「mのアクションの結果を無視してnを返す」という処理を表していることになります。
つまり上で述べた「戻り値を使わずにアクションを繋げる役割」ですね。

Haskell
    fail :: String -> m a
    fail = error

名前からして、IO処理が失敗した時のためのものでしょう。文字列(おそらくはエラーメッセージ)を渡せるっぽい。やってみましょう。

Haskell
main = fail "しっぱい><"

実行してみると、

user error (しっぱい><)

と出力されました。なるほど。

改めて、Monadクラスの定義を見てみます。

Haskell
class Monad m where
    (>>=) :: m a -> (a -> m b) -> m b
    return :: a -> m a

    (>>) :: m a -> m b -> m b
    m >> n = m >>= \_ ->  n

    fail :: String -> m a
    fail = error

Monadクラスというからには、これがHaskellにおけるモナドの表現であることは疑いようがありません。
ということで、ある多相型mがモナドであるということは、

  • m a型であるアクションの結果からa型の値を取り出し、a -> m b型の関数の引数として伝播させ、その結果のm b型であるアクション実行する、というアクションを返す演算子>>=を持つ
  • m a型のアクションを実行し、続けて結果を無視してm b型のアクションを実行する演算子 >>を持つ
  • aの値を引数にとり、それを実行結果とするm a型のアクションを返すreturn演算を持つ
  • String型のエラーメッセージを引数にとり、実行時にエラーメッセージを出力するm a型のアクションを返すfail演算を持つ

ということなのだと思います。
ただまあ、failに関しては、数学的なモナドの定義には含まれていないんじゃないかななんて思ったり。

しかし、やっぱり言葉にするとやたら長いし意味不明ですね。

(>>=)「アクション実行するよ! 結果見るよ! 結果は次に渡してもひとつアクション実行するよ!」
(>>)「アクション実行するよ! 結果は無視してもひとつアクション実行するよ!」
return「引数もらうよ! それがアクションの結果だよ!」

ということ。多分。
で、重要なのが「これだけの機能があれば、どんなに複雑なシステムでも、アクションを組み合わせることで構築することができる」ということだと思います。2つのアクションの組み合わせはやっぱりアクションですから、いくらでもアクションを繋げることができるという訳です。

さて、今の私なら、さっきのアレをHaskellで書ける気がします。

さっきのアレ
#include <iostream>
#include <string>

int main() {
    using namespace std;

    cout << "Type your name, please." << endl;

    string name;
    getline(cin, name);
    cout << "Hello, " << name << "!" << endl;

    return 0;
}
Haskell
main =
    putStrLn "Type your name, please." >>
    getLine >>= \name ->
    putStr  "Hello, " >>
    putStr name >>
    putStrLn "!"

これでどーだ! 動いたぞコノヤロウ!

というかこれ、無茶苦茶読みにくいんですけど。
途中ラムダ式入ってるし。

do糖衣構文「チラッ」

do記法

……えー、基本的な所を理解するため今まで頑なに無視してきたのですが、やっぱり必要っぽいですね、do

「モナドを繋げていくやり方が基本ではあるけれど、読みづらいからdoを使う。doを使うと手続き型言語っぽく書ける」

って話は実は聞いたことがあったのです。

上のアレをdoを使って書き直すと、こうなります。

Haskell
main = do
    putStrLn "Type your name, please."
    name <- getLine
    putStr  "Hello, "
    putStr name
    putStrLn "!"

おー、なんかすごく普通っぽい!
ラムダ式も見かけ上消えてるし。
というか、do使っただけで、さっき「これは違う」って言った奴とものすごい似てるんですけど。
違いは、do使ってることと、nameに対する代入が、<-になってることだけ。
でもdoがあるとないとでは大違いですね。doはあくまで糖衣構文なので、実際は>>=>>で連結されていたり、ラムダ式使ってるのが隠されているだけです。

じゃあdoってどういうものなのか?
規格書眺めてみます。

do {e} = e
do {e;stmts} = e >> do {stmts}
do {p <- e; stmts} = let ok p = do {stmts}
                         ok _ = fail "..."
                     in e >>= ok
do {let decls; stmts} = let decls in do {stmts}

と、こんな感じに展開されるようです。
手動で展開してみます。

Haskell
main =
    putStrLn "Type your name, please." >>
    let ok name =
            putStr  "Hello, " >>
            putStr name >>
            putStrLn "!"
        ok _ = fail "..."
    in getLine >>= ok

なんか微妙に見慣れないことになりましたが、ラムダ式の代わりに局所変数としてok関数が定義されてそれを使っているという感じです。意味的には、エラー処理が増えてるけどそれ以外は変わらないと言ってしまっていいのかな。

あ、ちなみに、doの展開のところで{とか;とか}みたいなのが書いてあるけど、HaskellではCみたいに{, ;, }でブロックやステートメントの区切りをする方法と、Pythonみたいにインデントと改行を使う方法、どちらも使えるそうです。
なので、こんな風に書くこともできる。

Haskell
main = do { putStrLn "Type your name, please."; name <- getLine; putStr  "Hello, "; putStr name; putStrLn "!"; }

……改行は適切にやりましょう。ブックマークレットじゃないんだから。

まあ、それはともかく。
これで「純粋関数型言語のHaskellはどうやって副作用のある動作をしているのか」「do記法を使うとなぜ手続き型言語のように書けるのか」といった基本的な所を理解できました。
少なくとも、私は理解した気になっています。

では、今日のところはHaskellの話は終わりとしましょう。
またいずれ、書く気が起きたらもうちょっと発展的な内容の続きを書こうと思います。

  1. つまり今までのは嘘だった訳だな、この詐欺師め!

  2. 実際にはオブジェクトファイル作ってリンクしてなんやかんやとあるのですが、まあこの際ざっくり行きます。

  3. 意図的だろ!

65
52
3

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
65
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?