LoginSignup
7
7

More than 5 years have passed since last update.

Haskellにおける周辺概念をざっくり説明してみる (入門者向け)

Posted at

最近,Haskellを少し触っていたので,少しHaskellに関してアウトプットしようと思います.

はじめに

Haskellはすごく良い言語であり,他の言語にないような様々な概念を有しています.
しかし学習コストが高く,結構大変な言語だとも思います.
なので,本記事では,よりざっくりしたHaskellの概念と小さなコードを紹介していきなんとなく理解できるような文章を目指します.

想定読者

  • Haskellをざっくり知りたい人
  • CやJavaの手続き型言語を軽くやったことがある人
  • 再帰的にいろいろあれこれする考え方のある人

実行環境

Haskellの実行には,Stackというツールを利用します.
以下のコマンドでセットアップします.(Mac)

brew install stack
stack setup

すこし時間がかかるので,待ちます.

Hello World

何はともあれHello worldです.

main = putStrLn "Hello world!"
-- output: Hello world!

実行は以下のコマンドでします.

stack runghc -- helloworld.hs

Haskellでは,まずmain関数があり,そしてその処理内容を右辺に書きます.
ここではputStrLn関数を利用して,文字列を出力しています.(printlnのようなもの)

関数定義

関数はmain関数のように定義します.
関数fは,ただ文字列を返すだけの関数です.
Haskellreturn文は必要ではないので,より簡潔に関数を定義できます.

f = "Hello world!" -- 関数定義

main = putStrLn f
-- output: Hello world!

パターンマッチ

Haskellでは,よく処理を再帰的に書くことがあります.
なので再帰を簡潔に書けるように,パターンマッチというものがあります.
パターンマッチは,関数の定義を複数列挙し,マッチした関数の引数のパターンの右辺を実行するという動作をします.
以下に階乗計算をするコードを載せます.


factorial 0 = 1 -- 引数が0の時
factorial n = n * factorial (n - 1); -- 引数が0以外の時

main = print (factorial 10)
-- output: 3628800

factorial関数が2行分定義されていることがわかると思います.
これは引数が0nのときの2つの分岐を書いていることになります.
なので,普通の言語だとif (n == 0)と書くことが多いですが,
Haskellではどういう値が来た時にどういった値を返すかを直感的に書け,より簡潔に関数を定義できます.

同じ動きのJavaのコードを載せておくので比較してみると面白いかもです.

import java.io.*;
import java.util.*;
class Main {
 static int factorial (int n) {
   if (n == 0) {
     return 1;
   }
   return n * factorial (n - 1);
 }

 public static void main(String[] args) {
   System.out.println(factorial(10));
 }
}

Listのパターンマッチ

パターンマッチを利用して,Listの状態を判断することができます.
以下にただListの要素を足し続けるだけの関数を定義してみます.

f :: [Integer] -> Integer
f (x:xs) = x + f xs -- 先頭の要素がある時 (リストの長さが1以上)
f []     = 0 -- リストが空

main = print $ f [1..5]
-- output: 15

関数fでは,(x:xs)というパターンと[]というパターンを判断しています.
(x:xs)ですが,これは先頭の要素があるかどうかを判断します.
そして[]は,見た通り空のリストにマッチします.
もう一つ,関数定義の1行目のところにf :: [Integer] -> Integerという記述があります.
これは関数の型を定義しており,[Integer](IntegerのList)を受け取り,Integerを返す関数ということを示しています.
Haskellは,->の最後の型が戻り値でそれ以外が引数という形で定義します.(一部例外あり)

余談ですが,上のコードは,sum [1..5]でも一緒の動きをします.
普通はこちらを使うと思います.

foldlとfoldrという関数

Haskellには,foldlfoldrという関数があります.
これは両方とも畳み込む関数ですが,方向が違います.
ここでの畳み込みという表現ですが,初期値とリストを与えリストの要素をどんどん初期値に足し合わせていくイメージのものを指します.

foldlは,左から畳み込みます.
そしてfoldrは,右から畳み込みます.
用途的には結合則を満たさない演算(左から計算した結果と右から計算した結果が同じ結果にならないもの)で使い分けたり,
foldrはリストの生成順序と同一なので,リストを連結していく時に使ったりします.(多分)
以下に例を示します.

main = print $ foldr (:) [] [1, 2, 3] -- 引数は,結合する関数, 初期値, 与えるリスト
-- output: [1,2,3]

これをfoldlでやろうとすると以下のようなエラーが出ます.

 • Occurs check: cannot construct the infinite type: a ~ [a]
   Expected type: [a] -> [a] -> [a]
     Actual type: a -> [a] -> [a]

この理由として,リストに要素を追加する:は,a : [b]のように利用します.
しかしfoldlでは[b] : aのように利用しようとするのでエラーを吐きます.
これはfoldlが左から結合していくからです.
なので使い分けが大事になってきます.

代数的データ型 (Listを自分で実装してみる)

ここでHaskellの代数的データ型を用いて,Listの動きを実装してみましょう.
以下のコードは,Listというデータ型を定義しListの中身を合計する関数を定義したものです.

data List a = Nil | Cons a (List a) -- Listを定義

initList :: List Integer -- 初期リストを定義
initList = Cons 5 (Cons 4 (Cons 3 (Cons 2 (Cons 1 Nil))))

sumList :: List Integer -> Integer -- リストの中身の合計を計算する関数
sumList (Cons a b) = a + sumList b
sumList Nil        = 0

main = print $ sumList initList
-- output: 15

ここで,ListNilもしくはCons a (List a)という型の両方を表現しています.
そしてaは,型パラメータといい何型が入って来ても良いということを表現しています.
sumListでは,List型を受け取りパターンマッチをしています.
やっていることは,Cons a bにマッチしたらabを取り出し,右辺の処理を実行します.
そしてNilにマッチしたら0を返します.

このようにデータ型のパターンマッチも簡単に実装できます.

型クラス

Haskellには型クラスという概念があります.
感覚としては,インターフェースが近い気がします.
型クラスで定義されている関数をデータ型に実装することで,データ型がその型クラスのインスタンスになります.
そしてその型クラスの制約のもとで,そのデータ型を取り扱えます.
さっそく先ほどのList型をインスタンス化しましょう.
今回はfoldrを実行するためにFoldable classの関数を実装し,インスタンス化します.

なぜインスタンス化しないといけないか?なのですが,まずはFoldableの定義を見てみましょう.

class Foldable (t :: * -> *) where # 型パラメータ t
  Data.Foldable.fold :: Monoid m => t m -> m
  foldMap :: Monoid m => (a -> m) -> t a -> m
  foldr :: (a -> b -> b) -> b -> t a -> b # 3つ目の引数にt型を要求している
  ...

ここでは,class Foldableと書かれており,その次に(t :: * -> *)と書かれています.
このtというのは,型を見ると何かを受け取り,何かを返す関数ですね.
ここで関数と言うと若干わかりづらいのですが,データ型も関数として機能します.
data List aは定義としては,a -> List aになるので* -> *と同一になりますね.(おそらく)
そしてListtとして受け取り,foldrでは3つ目の引数にt型すなわちList型に包まれた引数を要求します.
このように型クラスは,ユーザが定義したデータ型を受け取り,それに対応したfoldrを実行できます.
こういった制約のことを型クラス制約と呼び,これにより既存の関数に自身の型を適応させていくことが比較的容易にできます.

以下が完成形のコードです.

data List a = Nil | Cons a (List a) deriving (Show) -- Show classのインスタンスを作成する

initList :: List Integer -- 初期リスト
initList = Cons 5 (Cons 4 (Cons 3 (Cons 2 (Cons 1 Nil))))

instance Foldable List where -- Foldable classのインスタンスを作成する
  foldr f z (Cons a b) = f a (foldr f z b)
  foldr f z Nil        = z

main = print $ foldr Cons Nil initList
-- output: Cons 5 (Cons 4 (Cons 3 (Cons 2 (Cons 1 Nil))))

ここでは,instanceキーワードを利用しFoldable classの関数を実装します.
そして先ほどと違い,derivingキーワードを利用しShow classを実装します.
derivingは実装が自明の場合に利用できます.
今回はただListをそのまま出力したいがために実装しています.

ちなみにFoldable classのインスタンスを作成しないと以下のようなエラーが出ます.
なのでエラーが出たらインスタンス化しないといけないんだなぁと思っていく感じが良いです.

• No instance for (Foldable List) arising from a use of ‘foldr’ # Foldable classのインスタンスではない!?

モナド

Haskellでは,モナドという概念が存在しています.
そしてモナドはHaskellの中でだいぶ重要な概念ではあるものの,
少しわかりづらいもので,Haskellの学習コストをある程度あげている気がします.
モナドを箱として説明している箱で考えるFunctor、ApplicativeそしてMonad
というすごく良い記事があるので,オススメです.
そしてモナドには様々な種類が存在しており,今回はそれを少し紹介します.

早速ですが,main関数は実はIOモナドを返す関数でした.
詳しくmainの定義を書くと以下のようになります.

main :: IO () -- 返り値がIO
main = print "hello"

そしてprint関数の定義は以下のようになります.

Prelude> :t print
print :: Show a => a -> IO () # print関数の定義

print関数がIOモナドを返していることがわかります.

ではmain関数がIOモナドを返さなければどうなるでしょうか.
実際に実行してみると以下のようになります.

main = "hello"

-- エラー内容
-- • Couldn't match expected type ‘IO t0’ with actual type ‘[Char]’
-- • In the expression: main
-- When checking the type of the IO action ‘main’

当然IOモナドを返してもらいたいのに,[Char]型が返ってきていると怒られています.

ここでモナドを返り値に持つ関数というのは,そのモナドに包まれた関数であると思ってもらえればいいと思います.
ここでのmain関数は,IOモナドに包まれた関数であるということです.

次にprint以外の関数で,標準入力から1行取得する関数getLineがあります.
これの定義は以下のようになっています.

Prelude> :t getLine
getLine :: IO String

こちらはprint関数と違い,IO String型を返します.
そしてこのIOに包まれたString型をどうやって取得するかというと,>>=関数を利用します.
ではgetLineで取得した値をprint関数に渡して出力してみましょう.
コードは以下のようになります.

main = getLine >>= print
-- input:
-- hello
-- output:
-- "hello"

>>=関数の定義は以下のようなもので,モナドの中身の値を取り出して次の関数に適応します.

(>>=) :: Monad m => m a -> (a -> m b) -> m b

>>=を無限に横に繋げていくことで処理を連続させることができますが,Haskellにはもっと良いやり方で同じことが書けます.
それが以下です.

main = do
  s <- getLine
  print s
-- input:
-- hello
-- output:
-- "hello"

doキーワードを利用します.
そしてs <- getLineは,getLineの結果から値を取り出し,それをsという変数に束縛します.
変数定義のような感覚で書けるので,より手続き型風に書けます.
モナドが利用できるケースでは,doキーワードをよく利用すると思います.

ここでIOモナドの動きが気になった方は,Haskell IOモナド 超入門という記事が非常にわかりやすいので,読んでみることをオススメします.

Stateモナド

Haskellでは,関数内で利用する状態を表現するStateモナドが存在します.
簡単な例を示してみます.

import           Control.Monad.State

count :: State Integer ()
count = do
  s <- get -- 値を取得する
  put (s + 1) -- 1を足して,Stateを更新する
  -- modify (+1)でもオッケー

count10 :: State Integer String
count10 = do
  count -- countを呼ぶ
  s <- get -- 値を取得する
  case s of -- 分岐
    10 -> return $ show s -- 10の時,数値を文字列にしてモナドに包んで返す
    _  -> count10 -- 再帰

main = print $ runState count10 0
-- output: ("10",10)

ここでcount10関数でのStateモナドの定義は,State Integer Stringとなっています.
これは状態としてInteger型を持っていて,結果としてString型を返すということを示しています.
次のcount関数を見ると,State Integer ()となっており,これは何も返さないことを意味します.

では動きを少しずつ見ていきましょう.
まずmain関数がrunStateという関数を呼びます.
この関数の定義は以下のようになっています.

runState :: State s a -> s -> (a, s)

第一引数には,Stateモナドの関数,第二引数に初期状態を入れることにより,最終結果をタプルとして得る関数です.

では次にcount10を見てみましょう.
count10では,count関数を呼んでますが,count関数ではget関数で状態を取り出し,それに1足した値をput関数で再代入を行っています.
Stateモナドの関数なので,関数内で状態をこねくり回すことができちゃいます.
まるでグローバル変数のような振る舞いをしてくれます.

そしてcountを呼び出したあとでは,count10関数はget関数で値を取り出しています.
最後にcaseを用いて分岐を行い,Stateモナドの中身が10になったら,show関数で数値を文字列化しreturn関数でモナドの形にし返します.
ここでやっといろんな言語で利用されているreturn関数が登場しましたが,ここでの意味合いはだいぶ違います.
Haskellでのreturn関数の定義は以下です.

return :: Monad m => a -> m a

aを受け取って,モナドに包んで返していますね.
このようにHaskellでは,値をモナドに包む時にreturnを利用します.
ここで値をモナドで包む理由としては,count10がモナドを返す関数だからです.

その後,返ってきた返り値を出力してプログラムは終了します.

Haskellにはグローバル変数がないらしいので(それらしいものはある),状態を扱う際はStateモナドなんかを利用する方向でプログラムを組んだらいいと思います.

まとめ

ここまで,パターンマッチ,データ型,型クラス,モナドとHaskellの概念を紹介してきました.
しかしながら本記事で,Haskellの全てが理解できる人は当然いないと思うので,だいたい感覚で理解してもらえればと思います.
そして少しでもHaskellに興味をもったら何かしら実装してみるのもいいかもしれません.

おまけ:Haskellを楽しく学べそうなサイト

7
7
0

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