🙆

型クラスとは何か?使用場面3選(Haskell)

2022/04/28に公開

この記事では、型クラスの概要を学びます。
データ型から変数・関数を作る..というよりか、データ型同士の関係に焦点を当てます。 抽象的な話も多いですが、具体例を通してしっかり使い方をイメージしていきましょう。

型クラスとは?

型クラスとはデータ型をグループ化し共通の関数を実装する仕組みです。 データ型同士に関係性を持たせるための機能となっています。

Int、Char、Stringなど、これまでデータ型をバラバラなモノとして扱ってきました。しかしある機能(振る舞い、処理内容)を元に、これらのデータ型を一定の役割でグループ化できます。

幼稚園のクラス分け

型クラスを例えるなら、幼稚園のクラス分けです。例えばここに1~3歳未満の子供がたくさんいたとします。この中から「年齢」という基準を元にグルーピングします。以下の組に分けたとしましょう。

ここで作ったりんご組・オレンジ組..というのが型クラスです。また型クラスは、所属するそれぞれのメンバーに対して機能を実装させることができます。

1歳:りんご組(→お歌)
2歳:ぶどう組(→お絵描き)
3歳:もも組(→駆けっこ)

とそれぞれの子たちに習い事をさせます(=機能を実装させます)。

あくまで型クラスはグループであって、実体として物理的な形はない点に注意して下さい。

計算のためのクラス分け

同じことをHaskellのデータ型でも行います。既存で用意されたデータ型としてInt、Char、String、Float、Double..などがありました。

これらのデータ型を「計算ができるモノ」としてグループ化します。既存でいくつかデータ型はありますが、その中で「計算できるモノ」は限られますよね。

「計算ができる」という目的の元、Numという型クラス(グループ)を作りました。さらにそれぞれのデータ型に対して機能を実装させてみましょう。計算では必ず使うことになる+、ー、*の関数です。

これによって既存のデータ型をグループ化し、それぞれに機能を持たせることができました。個別で1つ1つ実装しては面倒ですが、グループとして一気にまとめることができます。こちらがまずは最もスタンダードな型クラスの使用場面です。

クラスと何が違うの?

型クラスとよく勘違いしがちなのが、オブジェクト指向のクラスです。決定的な違いとしては、抽象的か?具体的か?という点です。

オブジェクト指向のクラスとは新しいデータ型を作るための仕組みです。 同じことがHaskellでは代数的データ型によって実現されます。

それに対して型クラスというのは、既に存在しているデータ型へ機能を実装する仕組みです。 他の言語であまり似ているモノはありませんが、強いていうならJavaのインターフェースに例えられます。

ただしインターフェースはクラスに対してしか、その振る舞いを決めることができません。型クラスの場合、既存のIntやChar〜代数的データ型まで、色々なデータ型をグループ化できます。

型クラスの具体例(Num型クラス)

実際に型クラスの例を見ていきます。先ほど例に挙げたNum型クラスです。:iコマンドで型クラスの情報を調べることができます。

Prelude> :i Num
type Num :: * -> Constraint
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
-- (↑Num型クラスに所属するデータ型へ実装する関数(=これらの機能を持つ))
  {-# MINIMAL (+), (*), abs, signum, fromInteger, (negate | (-)) #-}
        -- 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’
-- (↑Num型クラスに所属するデータ型(=インスタンス))

まずは下の部分から見ていきます。instance Num Word、instance Num Integerなどがありますが、こちらがNum型クラスに所属するデータ型です。インスタンスとは型クラスに所属するデータ型のことです。(オブジェクト指向のインスタンスとごっちゃにならないよう気をつけて下さい。)計算をするNum型クラスには、Word、Integer、Int、Float、Doubleの合計5つのデータ型が属しています。

次に上の部分です、+、-、*などの演算子があります。演算子は関数として提供されていることを思い出して下さい。ここではNum型クラスに所属するデータ型に対して、実装させる関数を決めています。つまりIntやDoubelなどのデータ型は、+や-などで計算できる..ということを表しています。

多態性を実現

型クラスを使えば、オブジェクト指向でいう多態性も実現できます。データ型同士をグループ化し、関係性を持たせることに大きな強みがあります。また既存であるデータ型の中から、ある機能を抽象化できることもポイントです。

わかりやすいのが動物ごとに違った鳴き声を出させたい..という場面です。そんな場面は絶対にこないですがオブジェクト指向であるあるの例ですよね。

Animalクラスを継承して、Dog、Catでそれぞれ違った鳴き声を出させたいとします。この場合、オブジェクト指向ではAnimalクラスを1つ作り、それを継承してDog、Catを作ります。C++で書いたコードはこちらです。

#include <iostream>
using namespace std;

class Animal{
    public:
        virtual void speak(){
            cout << "鳴き声を出します\n";
        };
};

class Inu : public Animal{
    public:
        void speak(){
            cout << "わんわん\n";
        };
};

class Neko : public Animal{
    public:
        void speak(){
            cout << "にゃんにゃん\n";
        };
};

int main (){
    Inu inu;
    Neko neko;
    inu.speak();
    neko.speak();
    return 0;
}

実行結果はこちらです。

わんわん
にゃんにゃん

逆にHaskellの場合、抽象度を上げると考えます。Dog、Catという2つのデータ型があって、それぞれに共通する要素をAnimal型クラスとしてグループ化します。それによってAnimal型クラスが持つ機能をDog、Catへ実装させることができます。

-- 型クラスとしてAnimalクラスを宣言
class Animal a where
    speak :: a -> String 
    speak _ = "..."

-- データ型としてDog、Catを用意
data Dog = Dog deriving Show
data Cat = Cat deriving Show

-- 型クラスAnimalにDogを所属、関数を実装
instance Animal Dog where
    speak _ = "わんわん"

-- 型クラスAnimalにCatを所属、関数を実装
instance Animal Cat where
    speak _ = "にゃんにゃん"

main = do
    putStrLn $ speak Dog
    putStrLn $ speak Cat

実行結果は同じくこちらです。

わんわん
にゃんにゃん

代数的データ型については、別の記事で解説することにします。ここでは型クラスを使ってオブジェクト指向の継承・多態性のようなこともできるんだな..ということを頭の片隅に入れておいて下さい。

型変数:a

型クラスとセットで覚えておきたいのが型変数aです、型変数とは、任意のデータ型を受け取れる仕組みです。

他の言語にあるテンプレートやジェネリクスの仕組みに似ています。処理内容は全く同じだけどデータ型だけを変えたい..そんな時に型変数は使われます。例えば、受け取った数値を2倍にするnibai関数を考えてみます。IntでもDoubleでもどんなデータ型でも受け取れるようにしたいとしましょう。

nibai a = a * 2
main = print (nibai 3.14)

aにはIntでもDoubleでもどんなデータ型でも受け取れます。ただしString、Boolなど明らかに間違えている場合はエラーとなります(* 2の部分と合わないとプログラムが判定するため)。面倒な構文を覚えなくてもaと書くだけでテンプレートを作れるのは便利ですよね。

型制約を実現

また型クラスは型変数:aと組み合わせることで、型制約を実現できます。型制約とは、これから扱うデータ型に制限を加えることです。

例えば、2つの値を足すことができる+演算子はどうでしょうか。:t'コマンドで+の情報を詳しく見てみます。+`も結局は関数の一部であることを改めて確認できます。

Prelude> :t (+)
(+) :: Num a => a -> a -> a
-- 型変数aに対し、Num型クラスで制約を加える
-- +で計算できるには、Num型クラスに所属するデータ型でないといけない

ここで注目して頂きたいのが、=>と言う記号です。この記号より前にあるモノが型クラス制約と呼ばれます。型変数aはどんなデータ型でも受け取れますが、それに対してNum型クラスで制限を加えています。つまり「+演算子で計算できるには、Num型クラスに所属するインスタンス(データ型)でなければならない..」ということを示しています。

まとめ

型クラスの仕組みと、その具体的な使用場面を3つ見てきました。

1、共通するデータ型に機能を実装(型クラス)
2、型制約をつけるため
3、データ型をグループ化するため(継承、関係性)

関係性に焦点を当てる..という少し抽象的な仕組みですが、ぜひ具体的な使用場面とセットで抑えて頂ければと思います。

Discussion