なぜ Haskell が好きなのか
自分は Haskell が好きで休日は Haskell を書いています。そういうことを言うと関数型が好きなんですねと言われるのですが、Haskell のよさはそこじゃないと感じているので書き起こそうかと、筆を執りました。
というわけで、この記事は技術的文書というよりもお話です。Haskell を知らない人向けです。
この記事は Haskell Advent Calendar 2017 その3の6日めの記事です。6日が過ぎても担当のいない日だったため担当します。
関数型プログラミングだから Haskell が好きというわけではない
まず、「Haskell というと手続き型とは全然違う関数型なんでしょう?」という印象を持つかと思いますが、一部合っていて一部まちがっていると思っています。
まず、用語の意味を絞っておかないと議論が発散してしまうのでそうしておきます。
ここでは「手続き型」は、先に書いた処理が後に書いた処理に影響を与えるようなプログラムを指すものとします(先や後は、ファイルの先頭に近い方が先、反対が後とします)。C・C++・Ruby などなどよくあるプログラミング言語です。
ここでは「関数型」は、関数がファーストクラスであることが最低条件とします。つまり、Func<T, …, T>
のインスタンスがある C# は満たしていますし、JavaScript や PHP もそうです。あくまで最低条件で実際にはそれらを活用したライブラリーがないと強くはそう言わないと思います。
さて、これら用語でいうと Haskell は「関数型」ですし、ライブラリーのサポートもあるので強くそうです。では、「手続き型」かというと意外に思うかもしれませんが、手軽に手続きも書けます。Web アプリケーションプログラミングなどではバリバリ手続きを書きます。
なので最初の「Haskell というと手続き型とは全然違う関数型なんでしょう?」に対する答としては「Haskell は手続き型であるし、関数型でもあり、それらは全然違うわけではない。」となります。
データの設計がしやすい
Haskell では「代数的データ型」(algebraic data type)を採用しています。代数的データ型は以降 ADT と表記します。代数的データ型は、直和と直積を持つ系です。(Haxe・Rust・Scala・Kotlin・Swift なども有していると聞いています。)
まず、直積について見ていきます。例えば、よくある例で Person
という型があって name
と age
というフィールドがあるとすると、それは Haskell では次のように記述できます。
data Person = Person { name :: String , age :: Int }
値を作るときは次のようになります。
me = Person { name = "Kazuki Okamoto", age = 28 }
次は直和についてです。先の Parson
の例の続きで、実は乗客として人を扱うために Parson
を作ったとしましょう。さて、乗客からの要望により新たにペットも乗せられるようになりました。「乗客」型は、値として「人」もしくは「動物」ということになります。これを Haskell で記述すると次のように記述します。
data Passenger = Person { name :: String , age :: Int } | Animal { species :: String , name :: String }
値を作るときは次のようになります。
me = Person { name = "Kazuki Okamoto", age = 28 } pet = Animal { species = "dog", name = "Max" }
同等のことを C# や Java などでやろうとすると、インタフェース1つとクラスが2つ必要になります。別の相違点として、直和型の場合は乗客は人もしくは動物のみであることは定義から分かりますが、継承でシミュレートした場合はプログラム全体を検索しないと他の種の乗客があるかどうか分かりません。反対に、継承を使った場合は元のコードに手を加えずに拡張ができるということがいえます。
型の定義部でしか値の種類の追加ができないことは、パターンマッチにおいて網羅性チェックができるという利点があります。
case me of Person n a -> {- 何らかの処理 -} Animal s n -> {- 何らかの処理 -} -- 例えば 上記の Animal のパターンがなければ次のような警告が出ます -- warning: [-Wincomplete-patterns] -- Pattern match(es) are non-exhaustive -- In a case alternative: Patterns not matched: Animal
Haskell における手続き
手続きを使ったプログラミングの例として、西暦を入力すると平成何年かを出力するようにしましょう。
import System.IO (hFlush, stdout) main :: IO () main = do putStr "Christian Era: " hFlush stdout christianEra <- readLn :: IO Int let heiseiEra = christianEra - 1988 putStr "Heisei Era: " putStrLn (show heiseiEra)
実行すると次のようになります。
λ stack runghc .\heisei.hs Christian Era: 2017 Heisei Era: 29
C 系との違いとしては、関数呼び出し(関数適用)が括弧なしに関数と引数を並べる点、変数への代入(変数の束縛)が … <- …
と let … = …
の2種類を使い分ける点があり、それを除けば雰囲気で読めるのではないかと思います。
先の例では main
の定義の先頭に do
キーワードが使われていますが、Haskell では do
キーワードを使えば、その式を手続きで書けるようになります。
詳細は省きますが for_
関数を使えばループを書けますし、IORef
などを使えば再代入(再束縛)可能な変数も作れます。
副作用の明示
Haskell で手続きが難なく書けることは示しましたが、ここで Haskell でいいことがあります。それは副作用の種類が型に明示されるということです。
先の例の main
は IO ()
という型になっています。これは IO を副作用として持つことを意味します。他には、Reader r a
というものは、読み取り専用の大域変数(型は r
)があることを意味します。ST s a
は読み書きのできる大域変数があることを意味します。もちろん大域とはいいつつプログラム全体ではなく副作用は局所化されます。(なんか矛盾してるようですけど局所的に大域的なのです。)などなど、アプリ固有の副作用を示す型を作ることもできます。
IO
の手続きの中から IO
の手続きは呼べますし副作用のない関数も呼べますが、反対に副作用のない関数の中から IO
の手続きは呼べません。呼べてしまうと関数に副作用ができてしまうので当然ですね。ST
も ST
の手続きの中から ST
の手続きと副作用のない関数が呼べます。副作用のない関数の中からは ST
の手続きを「実行」することができます。副作用は ST
の中にのみ影響するので ST
全体を実行することには副作用がありません。ST
の中に局所化される副作用しか ST
の中には書けないので入出力などは ST
の中に書けません。
これは、プログラムのまちがいが起こりにくいのは当然、他人のプログラムを読むときも大変有用です。C# や Java などのオブジェクト指向では手続きのカプセル化には成功しましたが、副作用の有無についても隠蔽してしまいました。
せめて pure
修飾子などがあるとよいですね。一番近いのは IntelliJ IDEA で Java を書いたときに付けられる @Contract(pure = true)
ですかね。
まとめ
以上、なぜ自分が Haskell が好きなのかというと次が主要な理由です。
- 代数的データ型、特に直和型があることはモデルの設計が簡単になる
- Haskell で手続きは書きやすいし、実際よく書く
- 副作用が明示されることは、コードのまちがいが減るし、読みやすい