Haskellの型入門

Posted by Takuya Noguchi on November 30, 2017 · 6 mins read

NaClの野口です。

最近Haskellを勉強し始めました。
勉強を始めてまだ日が浅いですが、面白い概念を多く学ぶことが出来たと感じています。

本記事ではHaskellの型について自分が理解したことを簡単にまとめます。
Haskellを動かす環境の構築についても記述しますので、読者の方も実際にコードを動かしてみてください。

環境

本記事内ではGHCiという対話形式でHaskellのコードを実行できるツールを使用します。
また実行環境のOSはUbuntuであることを前提として記載しています。

ではさっそく以下のコマンドを実行してGHCiをインストールしましょう。
Ubuntu以外のOSを使用している場合は、ドキュメントを参照してください。

$ sudo apt-get update
$ sudo apt-get install haskell-platform

上記コマンドを実行すると様々なツールがインストールされますが、本記事内ではGHCiのみ使用します。

実行方法

HaskellのコードをGHCiで実行する方法について記載します。
まず以下のサンプルコードをtest.hsという名前で作業用ディレクトリに保存してください。

add2 :: Int -> Int -> Int
add2 x y = x + y

後で詳しく解説しますが、上記コードでは引数2つを加算する関数(add2)の定義を行っています。

次にサンプルコードを保存したディレクトリ内で以下のコマンドを実行し、GHCiを起動します。

$ ghci
GHCi, version 7.10.3: http://www.haskell.org/ghc/  :? for help
Prelude>

GHCiの起動に成功した場合は、プロンプト(Prelude>)が表示されて入力待ちの状態になります。
ではサンプルコードをGHCiにロードしてみましょう。

Prelude> :load ./test.hs
[1 of 1] Compiling Main             ( test.hs, interpreted )
Ok, modules loaded: Main.
*Main>

:loadのように「:」が付いているものはGHCiのコマンドです。
上記のようなメッセージが表示され、プロンプト(*Main>)が表示されればロード成功です。

<no location info>: can't find file: ./test.hs のようなメッセージが表示された場合は、 GHCiを起動したディレクトリ内にサンプルコードが存在することをもう一度確認してください。

サンプルコードのロードに成功後、関数(add2)が実行できることを確認します。
関数の引数は カンマで区切る必要がない ことに注意してください。

*Main> add2 1 2
3
*Main> add2 3 2
5

関数に2つの引数を渡して実行すると、それらを加算した値が返ってくることが確認できました。

関数の型宣言

まずHaskellには様々な型があります。
例えば整数を表すInt型、文字を表すChar型、文字列を表すString型などです。
他にも知りたい方は基本的なデータ型 - ウォークスルー Haskellを参照してください。

では関数の型宣言についての説明に移ります。
関数の型宣言とは「関数の引数と戻り値がどのような型であるかを宣言する」ことです。
先程実行したサンプルコードで関数の型宣言を行っている箇所を以下に示します。

add2 :: Int -> Int -> Int

日本語で説明すると、「add2はInt型の値を2つ入力し、Int型の値を1つ出力する関数」となります。
最後の型が戻り値、それ以外は引数の型を順番に記述していると考えてください。

つまり引数が3つになった場合の関数(add3)の型宣言は以下のようになるということです。

add3 :: Int -> Int -> Int -> Int

またGHCiには関数の型を調べるための便利なコマンドがあります。

*Main> :t add2
add2 :: Int -> Int -> Int

型変数、型クラス

型変数、型クラスという概念について説明する前に、関数(add2)の挙動を詳しく見てみます。
引数に整数を渡したときは問題なく動いています。

*Main> add2 1 1
2
*Main> add2 2 1
3
*Main> add2 2 3
5

では引数に小数を渡してみましょう。

*Main> add2 2.0 3

<interactive>:17:6:
    No instance for (Fractional Int) arising from the literal ‘2.0’
    In the first argument of ‘add2’, namely ‘2.0’
    In the expression: add2 2.0 3
    In an equation for ‘it’: it = add2 2.0 3

引数に小数(2.0)を渡したことが原因でエラーが発生しました。
このままでは小数の足し算が出来ないので修正したいと思います。

まずInt型を小数を許容する型(Float型)に変更する方法から試してみましょう。

add2 :: Float -> Float -> Float
add2 x y = x + y

GHCiに変更をロードして確認してみます。

*Main> :l test.hs
[1 of 1] Compiling Main             ( test.hs, interpreted )
Ok, modules loaded: Main.
*Main> add2 2.0 3
5.0
*Main> add2 1 2.0
3.0

問題なく計算できていますね。
では2つの引数が整数の場合はどうなるのでしょうか。

*Main> :l test.hs
[1 of 1] Compiling Main             ( test.hs, interpreted )
Ok, modules loaded: Main.
*Main> add2 2 3
5.0
*Main> add2 1 2
3.0

結果が小数になってしまいましたね。
2つの引数が整数の場合は整数が戻り値になって欲しいです。

上記を解決するためには型変数という概念を使用します。
型変数について詳しい説明をする前に、型変数を使ったバージョンのコード示します。

add2 :: a -> a -> a
add2 x y = x + y

上記コードのaが 型変数 です。
サンプルでは型変数名にaを使っていますが、b, cや複数文字を使っても問題ありません。

この型変数は任意の型が入ることを示しています。
ただし同じ名前の型変数の型は、全て同じであることに注意してください。

では変更したコードをGHCiにロードしてみましょう。

*Main> :l test.hs
[1 of 1] Compiling Main             ( test.hs, interpreted )

test.hs:2:14:
    No instance for (Num a) arising from a use of ‘+’
    Possible fix:
      add (Num a) to the context of
        the type signature for add2 :: a -> a -> a
    In the expression: x + y
    In an equation for ‘add2’: add2 x y = x + y
Failed, modules loaded: none.

・・・エラーが発生してロードに失敗していますね。
エラーメッセージを読むと、ここまで意識していなかった「+」関数が関係しているようです。

エラーを解決するために、まず「+」関数の型を調べてみましょう。
ここでは(+)のように丸括弧で囲む必要があることに注意してください。

*Main> :t (+)
(+) :: Num a => a -> a -> a

Num a => は初めて見る書き方ですが、これは型変数aに対して型クラス制約を行っています。
上記の一文を理解するためには、「型クラス」と「型クラス制約」という2つの概念について知る必要があります。
ただし以降の説明で出てくる「クラス、メソッド、インスタンス」という用語は、オブジェクト指向言語の文脈で使用する時と意味が異なる ことに注意してください。

ではまず1つ目の型クラスについて説明します。
型クラスとは「複数の型に共通する操作(メソッド)をまとめたもの」で、型クラスに属する 型(値ではない) をその型クラスのインスタンスと呼びます。

「+」関数の型宣言ではNumが型クラスに該当します。
ここで型クラス自体は型ではないことに注意してください。
つまり型宣言の際に Num -> Num -> Num のように記述することは出来ません。

上記を理解したところで型クラスNumがどのように定義されているか、GHCiのコマンドを使って調べてみましょう。
--の右側はコメントであることに注意してください。

Prelude> :i Num
class Num a where
  (+) :: a -> a -> a
  (-) :: a -> a -> a
  (*) :: a -> a -> a
  negate :: a -> a
  abs :: a -> a
  signum :: a -> a
  fromInteger :: Integer -> a
        -- Defined in ‘GHC.Num’
instance Num Word -- Defined in ‘GHC.Num’
instance Num Integer -- Defined in ‘GHC.Num’
instance Num Int -- Defined in ‘GHC.Num’
instance Num Float -- Defined in ‘GHC.Float’
instance Num Double -- Defined in ‘GHC.Float’

「+」、「-」、「*」などがメソッド、instanceキーワードを使って定義している、Word、Integer、Intなどがインスタンスです。

次に2つ目の型クラス制約について説明します。
日本語だと「型クラス制約のある型変数の型は、その型クラスのインスタンスでなければならない」と説明できます。
型変数をそのまま使うと任意の型を許容してしまうため、型クラス制約を型変数にかける必要があるということです。

上記のことを理解すると、先程のエラーメッセージの内容も理解することができます。
以下に箇条書きで記載すると、

  1. add2の入力が「+」関数の入力、「+」関数の出力がadd2の出力である
  2. 「+」関数の型変数は型クラスNumによる制約を受けている
    • Word、Integer、Int、Float、Double を許容
  3. ただしサンプルコードの実装だとadd2の型変数の型が型クラスNumのインスタンス以外の場合がある
    • Char、String など
  4. add2が型クラスNumのインスタンス以外の型を受けとっても実行に失敗する
  5. 上記よりadd2の型変数にも型クラスNumによる制約をかけるべき

と理解できます。
型に関するバグを実行前に防ぐことができるのは嬉しいですね。

ではサンプルコードを型クラス制約を追加したバージョンに変更してみましょう。

add2 :: Num a => a -> a -> a
add2 x y = x + y

では変更したコードをGHCiにロードしてみましょう。

*Main> :l test.hs
[1 of 1] Compiling Main             ( test.hs, interpreted )
Ok, modules loaded: Main.
*Main> add2 1.0 2
3.0
*Main> add2 1 2
3
*Main> add2 3.0 3.5
6.5

整数同士の加算も期待通りの結果が返ってきていますね。

おわりに

書きはじめると型に関する簡単な説明だけで1つの記事になってしまいました。

Haskellにはカリー化、遅延評価、モナドなど興味深い概念が数多く存在しています。
どれも面白い概念なので、興味を持った方はぜひ調べてみてください。

参考サイト