2017年2月21日
型クラスはインターフェースとどう違うのか

(2017-01-07)by 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- これは “スーパークラス” の制約です。型を
Ordのインスタンスにするには、それはEqのインスタンスでなければなりません。 - これはクラスの名前です。
- これは、私たちがクラスをパラメータ化した型です。
- これは関数の定義であり、
aをOrdのインスタンスにするために定義するものです。関数の定義は複数存在するかもしれません。
上記のコードスニペットは以下のように解釈できます。
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- キーワード
instanceを使ってインスタンスを作りはじめます。 - これは、私たちがインスタンスを作っているクラスの名前です。
ToyOrdは、Eqインスタンスのために作っている型です。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- 2つの
Smolの値は等しい。 Smolの値は、常にLargeよりも小さい。- 2つの
Largeの値は、そのIntの値によって比較されます。 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");
}
} compare と equalTo を定義しました。注意すべきは、これらのメソッドが適切に実装されるように 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 afailableは型変数の名称で、クラス用に使います。oopsは値で、計算不可を表します。- クラス変数
failableに型変数aを適用します。ゆえにfailableはジェネリック型のパラメータを取らなくてはなりません。 pickは関数で、2つのパラメータを取り上げます。- 最初のパラメータでうまくいけば、受け入れます。
- うまくいかない場合は戻って2番目のパラメータを試します。
- 従って、戻り値によって2つの可能性からうまくいく値が選択でき、そうでない場合は全くうまくいきません。
- つまり、うまくいく道筋は立てられますが、それは
aをパラメータとして扱う場合のみです。
Maybe 型用のインスタンスは簡単に作ることができます。
-- [1] [2]
data Maybe a
-- [3]
= Just a
-- [5]
| NothingMaybeはここで宣言された型の名称です。- これは単独のジェネリック型変数で、取り込んで
aという名とします。 - この型変数には2つの構造体があります。1つは
Justでジェネリック型a
のパラメータを1つ取り込みます。 - もう一つの構造体である
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 か同様のもののように、 IO で safeDivision 関数を使えるようになります。
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つです。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- X: @yosuke_furukawa
- Github: yosuke-furukawa





