6
4

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

Haskellを学ばないのは遠回りをしているような気がしてきた

Last updated at Posted at 2021-10-10

Haskell恐怖症

私はHaskell恐怖症です。最初から怖かったわけではありません。言い訳をさせてください。12年前に勉強しようとはしたんです。

  • 数学が得意な人に好まれるせいか聞きなれない用語が頻出する。
  • サンプルコードが数学の実務でほぼ書かない計算(階乗とか)をしている。
  • JavaScriptのPromiseを見てモナドっぽいなと思っていたらモナドではないと言われた。
  • これ実務で使おうと思ってもついてこれる人いるの?と口にしたら怖い顔をされる。

でも、やっぱりHaskellを知らないままでいる方が遠回りなのかもしれないと思いました。

  • いろんな言語に影響を与えている
  • 関数型言語由来の仕組みの話の少し細かいところを知ろうとするとすぐHaskellの話になる
  • 要するにサンプルコードのほとんどがHaskellで書かれている。

Haskellをバリバリ使うために理解したいわけじゃなくて、他の言語でHaskell由来な何かを活用する時にうまく使えるようになりたいというのが主な動機です…

GHCiの基礎

まずはHaskellをがっつり使うわけではないので、GHCiを使って確認していきます。
コマンドは他にもいろいろありますけど、以下が分かっていれば十分だと思います。

コマンド 意味
:? ヘルプを表示します。
:{\n ..lines ..\n:}\n 複数行のコマンドを書く時の書き方。
:cd ディレクトリの移動
:q, :quit GHCiの終了
:l, :load .hsファイルのロード
:r, :reload ファイルの再読み込み
:i, :info 型の定義の確認
:t, :type 値や関数の型注釈の確認

Haskellの基本

関数は以下のように中置にすることができます。

div 92 13 -- 7
92 `div` 13 --7

引数の部分適用。引数の一部だけを適用することができます。カリー化ってやつで

a = div 92
a 13 -- 7

遅延評価。cycleは無限に引数のリストを繰り返すので、これだけを実行すると終わりません。

cycle [1, 2]
-- [1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2

遅延評価のおかげで以下は問題なく動きます。

take 5 (cycle [1, 2]) -- [1,2,1,2,1]
take 5 $ cycle [1, 2] -- [1,2,1,2,1]

Pythonのようなリスト内包表記もあります。

[x * 2 | x <- [1..5]] -- [2,4,6,8,10]
[x * 2 | x <- [1..5], x `mod` 2 == 0] -- [4,8]
[x * y | x <- [1..5], y <- [2, 3], x `mod` 2 == 0] -- [4,6,8,12]

型を混在して持たせることができるペアやタプルももちろんあります。

fst ("b", "a") -- "b"
snd ("b", "a") -- "a"
zip [1, 2] ["a", "b"] -- [(1,"a"),(2,"b")]
zip [1, 2, 3] "abc" -- [(1,'a'),(2,'b')]
let (_, a, b, _) = (1, "a", 2, "b")
a -- "a"
b -- 2

パターンマッチを使うとif elseを使わずに引数の値ごとの分岐を表現することができます。
再帰を簡潔に書くのによく使われるものです。

test :: Int -> String
test 1 = "aaaa"
test 2 = "bbbb"
test x = "xxxx"

test 4 -- "xxxx"

数学っぽい例を書きたくないですが…5 * 4 * 3 * 2 * 1 = 120になります。

test :: Int -> Int
test 0 = 1
test n = n * test (n - 1)

test 5 -- 120 

ガード条件はパターンマッチと似ていますが、以下のように書きます。

foo x1 x2
  | x1 == 1 = "x1 = 1"
  | x2 == 2 = "x2 = 2"
  | otherwise = "other value"
foo 1 2 -- "x1 = 1"
foo 0 2 -- "x2 = 2"
foo 3 4 -- "other value"

whereを使って繰り返し出てくるものを省略する方法もあります。

foo x1 x2
  | x1 == c = "x1 = c"
  | x2 == c = "x2 = c"
  | otherwise = "other value"
  where c = 3
foo 3 2 -- "x1 = c"

Haskellの型と型クラス

用語の整理をまずしておきます。

  • 型クラス…関数の集まりを定義したもの
  • メソッド…型クラスに属する関数のこと
  • インスタンス…型クラスの振る舞いを実装した型

Eq型クラスのインスタンスになっている型は==を使うことができます。

:t (==) -- (==) :: Eq a => a -> a -> Bool

定義をみると分かるとおり、同じ型同士でないと==はできません。

1 == "1"
    ? No instance for (Num String) arising from the literal 1
    ? In the first argument of (==), namely 1
      In the expression: 1 == "1"
      In an equation for it: it = 1 == "1"

Ord型クラスの場合は<ですね。
こちらの定義も確認しておきましょう。こっちも同じ型同士でないと<はできません。

:t (<) -- (<) :: Ord a => a -> a -> Bool

再帰

どの言語で書く場合も同じだと思いますが、再帰の基本的な構造は引数の値が一定の条件になるまで繰り返し処理をして、処理した結果と未処理の引数を引数に指定してもう一度呼び出すというものだと思います。
こういう処理の時、パターンマッチはとても便利です。

sum2 :: (Num a) => [a] -> a
sum2 [] = error "empty"
sum2 [x] = x
sum2 (x:xs) = x + (sum2 xs)

sum2 [1..9] -- 45

畳み込みをやってくれるfoldlを使うこともできます。
これは2引数関数と初期値、畳み込み対象のリストをとって、2引数関数を繰り返し適用した結果となる単一値を返すものです。

:t foldl -- foldl :: (a -> b -> a) -> a -> [b] -> a
sum3 :: (Num a) => [a] -> a
sum3 xs = foldl (\ a x -> a + x) 0 xs

sum3 [1..9] -- 45

こっちの方が分かりやすいかもしれません。

sum3 :: (Num a) => [a] -> a
sum3 xs = foldl (+) 0 xs

sum3 [1..9] -- 45

data と 値コンストラクタ と レコード構文

data はデータ型を定義するためのキーワードです。data Dog = Dogとした場合、左側のDogは型で、右側の型は値コンストラクタというこの型の値を作るものになります。ここでは型と値コンストラクタを同じ名前にしていますが、違う名前になっていても良いものです。
deriving (Show)はShowという型クラスに準拠するための実装を作ってくれという意味です。

data Dog = Dog String Int deriving (Show)

dogName :: Dog -> String
dogName (Dog a _) = a

d = Dog "moro" 5
dogName d -- "moro"

レコード構文というのを使うと、値に名前を付けてその値を参照するための関数も作ってくれます。

data Cat = Cat { name :: String, age :: Int } deriving (Show)

c = Cat { name = "miro", age = 5 }
name c -- "miro"
age c -- 5

FunctorとApplicativeとMonadの定義

FunctorとApplicativeとMonadはいずれも文脈というかコンテキストを表現する仕組みだと思っています。
コンテキストというのはIOとか失敗(値がない)とか非同期処理とかのことです。

Functorのインスタンスはfmapという引数を一つとって引数と同じ型で返す関数と、処理対象の値を実装しています。
例を見てみましょう。(+1)(+1) :: Num a => a -> aのように、Num型の値を一つとってNum型の値を返す関数です。

class Functor f where
  fmap :: (a -> b) -> f a -> f b

fmap (+1) [1, 2] -- [2, 3]

fmap(<$>)と書くこともできて、その場合は以下のようになります。

(<$>) (+1) [1,2] -- [2,3]
(+1) <$> [1,2] -- [2,3]

ApplicativeのインスタンスはFunctorでなければならないので、先ほどのfmapを実装しています。
そしてこれに加えてpure(<*>)という二つの関数を実装していなければなりません。

class Functor f => Applicative f where
  pure :: a -> f a
  (<*>) :: f (a -> b) -> f a -> f b

(<*>)はコンテキストの中に入った関数を同じコンテキストの中の値に適用する関数です。
わけわからんですよね…例をみてください。
リストの中に(+1) :: Num a => a -> aという関数を閉じ込めて、これをリスト[1,2]に適用し、リスト[2,3]を返しています。
この例ではリストがコンテキストです。

(<*>) [(+1)] [1,2] -- [2,3]
[(+1)] <*> [1,2] -- [2,3]

pureは何かを受け取ってApplicativeな値、たとえばMaybeにします。
よく分からないですよね。私もよく分からないです…
私が使っている9.0.1の場合のMaybeのpureの実装はここです。
同じファイルの中にほかのいくつかの実装もありますので抜粋してみます。
これを見るとpureは何かを受け取ってApplicativeな値にするという意味が分かるのではないかと思います。

instance Applicative Maybe where
    pure = Just
instance Applicative Solo where
  pure = Solo
instance Applicative [] where
    pure x    = [x]

恐怖症を克服する時が来ました…モナドです。
モナドのインスタンスはApplicativeのインスタンスでなければなりません。
ApplicativeのインスタンスはFunctorでなければなりません。
なのでモナドのインスタンスは先ほどのfmappure<*>を実装しています。
そしてそれに加えて>>=>>returnを実装しています。
returnpureと同じで、歴史的な経緯で二個存在しているそうです。

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

まず(>>=)はコンテキストの中の値を引数とする関数をコンテキストの中に適用する関数です。
たとえばリストの[1,2]に符号を反転する関数というかラムダ(\x -> [-x])を適用する場合は以下のようになります。

(>>=) [1,2] (\x -> [-x]) -- [-1, -2]
[1,2] >>= (\x -> [-x]) -- [-1, -2]

>>は同じコンテキストの二つを値を引数として二つ目の引数の値を返すのですが、リストの場合はちょっと違う動きをします。

(>>) (Just 3) (Just 1) -- Just 1
(>>) (Just 1) (Just 3) -- Just 3
(Just 1) >> (Just 3) -- Just 3
(>>) [1,2] [3,4] -- [3,4,3,4]
(>>) [3,4] [1,2] -- [1,2,1,2]
[3,4] >> [1,2] -- [1,2,1,2]

FunctorとApplicativeとMonadの使い方とdo構文

これまでまがいなりにもFunctorとApplicativeとMonadをみてきたわけですが、何も説明されなくても感覚的に理解できるのはdo構文の方だと思います。
なので、do構文で書いた場合と使わずに書いた場合を並べてみて、FunctorとApplicativeとMonadの使い方を確認していきたいと思います。

まずはMaybeモナドの足し算をdo構文と使わずにやった場合と使ってやった場合です。

z = do 
  x <- Just 1
  y <- Just 2
  return (x + y)
z -- Just 3

z = Just 3 >>= (\x -> Just 2 >>= (\y -> return (x + y)) )
z -- Just 5

リストモナドで同じことをやってみます。

z = do 
  x <- [1]
  y <- [2]
  return (x + y)
z -- [3]

z = [1] >>= (\x -> [2] >>= (\y -> return (x + y)))
z -- [3]

IOモナドをやってみます。これも同じですね。

calc = do
  x <- getLine
  y <- getLine
  putStrLn $ x ++ " " ++ y

calc -- 二回、入力を求められて、入力した値を" "を挟んで結合したものになる

calc = getLine >>= (\x -> getLine >>= (\y -> putStrLn (x ++ " " ++ y)))
calc -- 同じ結果です

IOモナドですが、以下の場合は>>=ではうまくいかず、>>を使う必要があります。
putStr "hello"IO ()で渡せる値がないので、左辺を飛ばせる>>を使うのです。

calc = do
  putStr "hello"
  putStrLn "test"

calc -- "hellotest"

calc = putStr "hello" >> putStrLn "test"

アクションとdo構文をもう少し

参照透過性というのは処理の結果が引数だけに依存して一意に決まるもののことを言います。
Haskellでは参照透過性のない関数は定義できません。
乱数、時刻取得、ファイルの操作のように参照透過性がないものはすべて関数ではなくアクションという仕組みで扱うことになっています。
IOモナドで出てきたputStrgetLineもアクションです。

なんとなく分かるでしょという乱暴な表現でごまかしましたが、do構文を使っている場合、その中はletを使っている箇所を除いてすべてモナド値になります。
また、モナドの中から値を取り出す場合は<-を使います。

foo = do
    x <- Just 123
    y <- Just "ABC"
    Just (show x ++ y)
foo -- Just "123ABC"

do構文を私のように何も考えないで使うとやってしまうのですが、以下のようなことはできません。IOとMaybeを混ぜるんじゃない!と怒られます。

foo = do
    x <- Just 123
    y <- Just "ABC"
    putStr (show x ++ y)

以下のようにしていれば怒られはしません。

foo = do
    x <- Just 123
    y <- Just "ABC"
    Just (show x ++ y)
Just x = foo
putStr x -- "123ABC"

ほんの少しだけでも意味のあるコードを書こうとするとコンパイルエラーになってしまって、do構文の方が逆にどうしたらいいのか分からないなというのが私の感想です。

いろいろなモナド

私が他の言語にも影響を与えているなぁと思ったモナドです。
スレッド関連もみてみましたが、Haskellがどうやっているのかを知るのが近道であるように思えなかったのでやめました…Maybe、リスト、IOは取り上げたのでEitherについて触れます。

モナド 用途
Maybe NULLを扱う
リスト 文字通り
Either 例外を扱う
IO ファイルとか標準入出力とかを扱う

Either

Eitherは例外を扱うものだと思っています。
Eitherは2つの値をとりそれぞれ違う型と返せます。慣習的にLeftが失敗で、Rightが成功時の値になります。Rightは英語だと正しいという意味もあるので自然なんでしょう。

data Either a b = Left a | Right b

以下はゼロで割り算をしようとした場合に失敗するようにしたものです。

foo :: Float -> Either String Float
foo 0 = Left "zero"
foo a = Right $ 99 / a

foo 0 -- Left "zero"
foo 3 -- Right 33.0

引数もEitherにするこんな感じでしょうか。

foo :: Either String Float -> Either String Float
foo (Left a) = Left a
foo (Right 0) = Left "zero"
foo (Right a) = Right $ 99 / a

foo Left "hoge" -- Left "hoge"
foo 0 -- Left "zero"
foo 3 -- Right 33.0

小ネタ

Justから値を取り出す

一番簡単なのはパターンマッチです。

Just x = Just 123
x -- 123

import Data.MaybefromJustを使うこともできます。

import Data.Maybe
x = fromJust (Just 123)
x -- 123

恐怖症は克服できたか?

怖くはなくなりましたが、ほかの言語と同じレベルで使うのはハードル高いなぁというのが率直な感想です。
まだまだいろんなところの理解が曖昧ですし、実用的なプログラムに必要な開発フレームワークや依存関係の開発環境、テストフレームワーク、デバッグ方法、統合開発環境、定番ライブラリの調査など、知らないといけないことがたくさんあります。
当初かかげた、ほかの言語でHaskellの影響を受けた機能を使う時に困らないようにはしていきたいので、これからもこのページに足りないものはちょこちょこ追加していこうと思います。

参考

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?