型クラスはインターフェースとどう違うのか


(注: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つです。