POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

Matt Persons

本記事は、原著者の許諾のもとに翻訳・掲載しております。

(注:2017/02/27、いただいたフィードバックを元に翻訳を修正いたしました。)

Haskellの型クラスは、Haskellを学び始めたばかりの多くの人にとっては難しい概念です。たいていの言語はこれを表すことが全くできませんし、それに近い概念も持っていません。多くのオブジェクト指向型の言語にとっては、利用可能なものの中では Interface が最も近い言語要素でしょう。Rubyの modules は似たような役割を持っています。しかし、この概念は両方とも、名前の多重定義と一種のポリモーフィズムをアドレスするので、型クラスが提供するパワーの一部を欠いています。

この記事は、型クラスに興味を持っている人向けです。Haskellや関数型プログラミングの予備知識は必要ありません。JavaやC言語のような静的な型付き言語に慣れていれば、役に立つでしょう。

型クラスについての概要/要約

型クラスとは何かを知っているなら、この項目は飛ばしてください。

要約すると、Haskellの型クラスとは、以下のように定義されます。

--    [1]       [2] [3]
class (Eq a) => Ord  a  where
--  [4]
    compare :: a -> a -> Ordering

data Ordering = EQ | GT | LT
  1. これは “スーパークラス” の制約です。型を Ord のインスタンスにするには、それは Eq のインスタンスでなければなりません。
  2. これはクラスの名前です。
  3. これは、私たちがクラスをパラメータ化した型です。
  4. これは関数の定義であり、 aOrd のインスタンスにするために定義するものです。関数の定義は複数存在するかもしれません。

上記のコードスニペットは以下のように解釈できます。

Eq 型クラスのインスタンスを持つ、何らかの型 a でパラメータ化されている Ord クラスを宣言します。 a 型を Ord インスタンスにするには、compare関数を定義しなくてはいけません。この関数は型 a の2つの値を持ち、 Ordering 型を使って値を戻します。

まず、toyデータ型の ToyOrd を作ります。

data ToyOrd = Smol | Large Int

このデータ型には、2つの構成要素があります。フィールドを持たない Smol と、1つの Int フィールドを持つ Large です。これを Ord のインスタンスにするには、 Eq のインスタンスにしなくてはいけません。

--  [1]  [2]  [3]
instance Eq  ToyOrd where
--         [4]
    Smol    == Smol    = True
    Large x == Large y = x == y
    _       == _       = False
  1. キーワード instance を使ってインスタンスを作りはじめます。
  2. これは、私たちがインスタンスを作っているクラスの名前です。
  3. ToyOrd は、 Eq インスタンスのために作っている型です。
  4. Eq クラスは (==) :: a -> a -> Bool(/=) :: a -> a -> Bool 関数を定義します。 (/=) はデフォルトの実装を持っているので、 (==) だけを実装できます。

ToyOrd データ型のための Eq インスタンスを定義したので、 Ord を定義できます。

instance Ord ToyOrd where
    compare Smol Smol =           -- [1]
        EQ
    compare Smol (Large _) =      -- [2]
        LT
    compare (Large x) (Large y) = -- [3]
        compare x y
    compare (Large _) Smol =      -- [4]
        GT
  1. 2つの Smol の値は等しい。
  2. Smol の値は、常に Large よりも小さい。
  3. 2つの Large の値は、その Int の値によって比較されます。
  4. Large は常に Smol より大きい。

型クラスを得たら、インプットとしてその型クラスのインスタンスだと期待される関数を書くことができます。順序付け演算子を定義してみましょう。

(<=) :: Ord a => a -> a -> Bool
a1 <= a2 =
    case compare a1 a2 of
        LT -> True
        EQ -> True
        GT -> False

(>) :: Ord a => a -> a -> Bool
a1 > a2 = not (a1 <= a2)

この関数は a を提供する全ての型で機能するように記述します。そのような型は、 Ord 型クラスのインスタンスです。

インターフェースと似ているところ

Javaのインターフェースを使えば、オブジェクトがサポートするメソッドのセットを記述することができますし、Java 8では、これらのメソッド用のデフォルトでの実装も記述することができます。ですから、 Ord と本質的には全く同じインターフェースを書くことができるのです。

public interface Eq {
    default public bool equalTo(Eq a) {
        return ! this.notEqualTo(a);   
    }

    default public bool notEqualTo(Eq a) {
        return ! this.equalTo(a);
    }
}

これは、Javaの Eq インターフェースです。デフォルトの実装を利用しています。どちらか一つをオーバーライドしなければ、メソッドを呼び出そうとする際に無限にループを続けることになります。

public interface Ord extends Eq {
    public Ordering compare(Ord other);
}

public enum Ordering {
    LT, EQ, GT

これらのインターフェースに関して、ジェネリックなメソッドを書くこともできます。

class OrdUtil {
    bool lessThanOrEqual(Ord a1, Ord a2) {
        Ordering result = a1.compare(a2);
        return result == LT || result == EQ;
    }
}

表面上は、これらはそっくりに見えます。しかし、いくつか重要な違いがあるのです。

違い

異なる型

Haskellの compare 関数の型シグネチャは、その引数の型に非常に特異的なものです。

compare :: Ord a => a -> a -> Ordering

この型シグネチャが意味するのは、次のようなことです。

この関数の呼び出し元は、 Ord のインスタンスである a ならどれでも選ぶことができます。そして Ordering に戻ります。

忘れないでほしいのは、 compare に対するパラメータがいずれも同じ型を持つように型シグネチャが 要求する ということです! compare Smol 10 と記述してしまうと実行できません。Javaの場合は2つのオブジェクトが 何であろうとOrd インターフェースが実装されていれば引き渡されます。

Javaによる同等のコードはこんな風になります。

public class OrdUtil {
    static <A extends Ord> bool lessThanOrEqual(A a1, A a2) {

        Ordering result = a1.compare(a2);
        return result == LT || result == EQ;
    }
}

このメソッドのシグネチャはジェネリックの型変数 A を導き、 A によって Ord インターフェースが拡張か実装されなければならないと述べています。このメソッドでは、次に2つのパラメータを取り上げますが、どちらも同じジェネリック A 型です。

実装の分割

Javaのクラスは1か所で定義されます。クラスが実装するインターフェースはいずれも同じクラス上に定義されなければなりません。Javaは直和型を処理することが得手ではありませんので、上位の ToyOrd クラスから Large を処理するだけになります。

class Large implements Eq, Ord {
    public final int size;

    public Large(int size) {
        this.size = size; 
    }

    public bool equalTo(Eq other) {
        if (other instanceof Large) {
            Large other1 = (Large) other;
            return other1.size == this.size; 
        } 

        return false;
    }

    public Ordering compare(Ord other) {
        if (other instanceof Large) {
            Large other1 = (Large) other;
            if (other1.size < this.size) {
                return Ordering.LT; 
            }
            if (other2.size == this.size) {
                return Ordering.EQ; 
            }
            return Ordering.GT;
        } 

        throw new RuntimeException("what does this even mean");
    }
}

compareequalTo を定義しました。注意すべきは、これらのメソッドが適切に実装されるように instanceof を実行し、型の変換を行なわなければならないということです。では、任意の型のオブジェクト2つを取り上げ比較することにどんな意味があるでしょうか?

あるupstream packageから Large を取り込み、独自のインターフェースを定義したとします。

interface SomeOtherPackage {
    public bool doSomeThing(int lol);
}

SomeOtherPackage インターフェースに Large を実装させることは全くできません! そんなことはせずに、コントロール可能な新しいクラスで元々のクラスをラップしなければなりません。そうすることでインターフェースが実装されるのであり、もしそうしない場合には Large へデリゲートします。

public class MyLarge implements SomeOtherPackage {
    public final Large large;

    public MyLarge(Large large) {
        this.large = large; 
    }

    public bool doSomething(int lol) {
        System.out.println("wut"); 
    }
}

型クラスはデータ型の定義とクラスのインスタンスに 分けられます 。ゆえに別のパッケージから ToyOrd を取り込むとしたら、以下のようなことが容易にできます。

import JokesAreFun (ToyOrd(..))

class MyNewClass a where
    doSomething :: a -> Int -> IO ()

instance MyNewClass ToyOrd where
    doSomething Smol x = putStrLn "hahahaa yess"
    doSomething (Large x) y = putStrLn ("numbers! " ++ show (x + y))

return型ポリモーフィズムへ

これが、 幅広くて 驚嘆の誰でも使える型クラスです。 return型ポリモーフィズム と言います。ちょっといやらしい存在です。

実行してもうまくいかないHaskellの型クラスを定義してみましょう。

--            [1]
class CanFail failable where
--  [2]       [3]
    oops :: failable a
--  [4]       [5]            [6]           [7]
    pick :: failable a -> failable a -> failable a
--  [8]
    win  :: a -> failable a
  1. failable は型変数の名称で、クラス用に使います。
  2. oops は値で、計算不可を表します。
  3. クラス変数 failable に型変数 a を適用します。ゆえに failable はジェネリック型のパラメータを取らなくてはなりません。
  4. pick は関数で、2つのパラメータを取り上げます。
  5. 最初のパラメータでうまくいけば、受け入れます。
  6. うまくいかない場合は戻って2番目のパラメータを試します。
  7. 従って、戻り値によって2つの可能性からうまくいく値が選択でき、そうでない場合は全くうまくいきません。
  8. つまり、うまくいく道筋は立てられますが、それは a をパラメータとして扱う場合のみです。

Maybe 型用のインスタンスは簡単に作ることができます。

--   [1]   [2]
data Maybe  a
--    [3]
    = Just  a
--    [5]
    | Nothing
  1. Maybe はここで宣言された型の名称です。
  2. これは単独のジェネリック型変数で、取り込んで a という名とします。
  3. この型変数には2つの構造体があります。1つは Just でジェネリック型 a
    のパラメータを1つ取り込みます。
  4. もう一つの構造体である Nothing はいかなる型のパラメータも取り込みません。

では、 CanFail インスタンスを書いてみましょう。

instance CanFail Maybe where
    oops = Nothing
    pick (Just a) _ = Just a
    pick Nothing (Just a) = Just a
    pick Nothing Nothing = oops
    win a = Just a

CanFail の項で幾つかの関数を書くことができます。安全なゼロ除算関数が書けます。

safeDivision :: CanFail failable => Double -> Double -> failable Double
safeDivision x y =
    if y == 0 
       then oops
       else win (x / y)

この関数シグネチャはとても面白いことを行なっています。普通の文章に翻訳してみましょう。

safeDivision は関数で、 Double 型の2つの引数を受け、型が CanFail のインスタンスを持つかぎり、 failable がcallerに選択された可能性のあるジェネリック型変数である failable Double 型を持つ値を返します。

えっ、callerが型を選択? つまり下記のようなコードが書けます。

someMathFunction :: Double -> Double -> Double
someMathFunction x y =
    let result = safeDivision x y in
    case result of
        Just number -> 
            number * 3
        Nothing ->
            0

上の関数で safeDivision のcallerのように、 Maybe 型を選択することもできます。他のインスタンスは何があるでしょうか。

data MaybeError a = Error String | Result a 

instance CanFail MaybeError where
    oops = Error "oops"
    pick (Result a) _ = Result a
    pick _ (Result a) = Result a
    pick ohNooo = ohNooo
    win a = Result a

MaybeError も同じく 選択できます! 必要なら IO のインスタンスも作れます。

-- simplified. Runs an action and catches an exception.
try :: IO a -> IO (MaybeError a)

instance CanFail IO where
    oops = throwException "oops"
    pick first second = do
        eResult <- try first
        case eResult of
            Result a ->
                return a
            Error exception ->
                second

あたかも print か同様のもののように、 IOsafeDivision 関数を使えるようになります。

main :: IO ()
main = do
    putStrLn "Woah, look at us!" :: IO ()
    x <- safeDivision 3 2        :: IO Double
    putStrLn "the result was: "
    print x
    y <- safeDivision 3 0
    print "This never happens because we threw an exception!"

return型ポリモーフィズムは非常にスマートで、間違いなく型クラスの中で最高のものの1つです。同時に、他の言語でのインターフェースやモジュールとはかけ離れたところにあるものの1つです。