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 a
failable
は型変数の名称で、クラス用に使います。oops
は値で、計算不可を表します。- クラス変数
failable
に型変数a
を適用します。ゆえにfailable
はジェネリック型のパラメータを取らなくてはなりません。 pick
は関数で、2つのパラメータを取り上げます。- 最初のパラメータでうまくいけば、受け入れます。
- うまくいかない場合は戻って2番目のパラメータを試します。
- 従って、戻り値によって2つの可能性からうまくいく値が選択でき、そうでない場合は全くうまくいきません。
- つまり、うまくいく道筋は立てられますが、それは
a
をパラメータとして扱う場合のみです。
Maybe
型用のインスタンスは簡単に作ることができます。
-- [1] [2]
data Maybe a
-- [3]
= Just a
-- [5]
| Nothing
Maybe
はここで宣言された型の名称です。- これは単独のジェネリック型変数で、取り込んで
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 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa