C#/Java頭の私がHaskellに戸惑ったところ(ただしモナドを除く)

いろいろあるが、以下の3つかな。

値コンストラクタは型ではない

たとえば、CircleとRectangleを含むShapeという独自のデータ型を定義するとする。Circleは半径を、Rectangleは幅と高さを持つとする。そういうデータ型はHaskellだとこんな感じで定義する。

data Shape = Circle Float | Rectangle Float Float

同じようなデータ型をたとえばC#で定義するとするなら

interface Shape {}

class Circle : Shape
{
    public float Radius;
}

class Rectangle : Shape
{
    public float Width;
    public float Height;
}

みたいな感じになる(Haskellの場合フィールド名を定義しなくてもいいが、それはまあ気にしない)。

C#/Javaの場合、CircleやRectangleはクラス。クラスがフィールド定義をもつ。Haskellの場合、CircleやRectangleは値コンストラクタと呼ぶ。値コンストラクタがフィールド定義を持つ。

C#/Javaの場合、クラスも型なんだけど、Haskellの場合、型はあくまでShapeだけ。値コンストラクタは型ではない。

Haskellの場合CircleやRectangleは型じゃないんで、関数を定義するときは引数や戻り値の型はあくまでShape。とはいえ、Circleコンストラクタで生成された値とRectangleコンストラクタで生成された値はきちんと区別する(じゃないとフィールドにアクセスもできない)。区別する方法はパターンマッチ。

area               :: Shape -> Float
area Circle r      =  pi * r * r

上のように定義した関数 area :: Shape -> FloatにRectangleで生成された値を渡してもコンパイルは通るが、実装が無いため実行時エラーになる。

型クラスのインスタンスは型

Haskellには型クラスというものがあって、型クラスのインスタンス(実例)は型。これはC#/Java頭だと用語がややこしい。

型が型クラスのインスタンスとなるとき、型クラスが宣言しているすべての関数の実装が定義されていなければならない。このあたりの雰囲気は、C#/Javaで言えば、interfaceで宣言されているメソッドの実装をclassが提供しなければならないところに似ている。

ただし、型クラスには関数のデフォルト実装を定義することができる。そういう観点ではC#/Javaのabstract classにも似ているかも。

さらに、ある型クラスは別の型クラスを継承することができる。C#/Javaで言えば、interfaceがinterfaceを継承するようなもの。

ともあれ、クラスとインスタンスの用語の使い方が違うことに注意しないと混乱の元。

Shape型がEq型クラスのインスタンスであることを表すHaskellのコード。Eq型クラスには==/=のデフォルト実装が用意されているが、循環定義なので、Eq型クラスのインスタンスには少なくとも片方の実装を定義しないと実行時エラーになる*1

instance Eq Shape where
  Circle r1       == Circle r2       = r1 == r2
  Rectangle w1 h1 == Rectangle w2 h2 = w1 == w2 && h1 == h2
  Circle _        == Rectangle _ _   = False

ところで、C#/Javaでは、interfaceもabstract classもclassも型なんだけど、Haskellでは、型クラスは型ではなく、型制約。

Prelude> :t (==)
(==) :: (Eq a) => a -> a -> Bool

(==)の型を見ると分かるが、型aが型クラスEqのインスタンスであるという制約がついている。なので、==の右辺と左辺に違う型を渡すと、もしそれらが両方ともEqのインスタンスだったとしてもコンパイルエラーになる。

遅延評価(非正格評価)

これが腑に落ちるには時間がかかった。なお、C#の遅延シーケンス(IEnumerable<T>)ともまったく違う。

False && (True && True)

という式があったとして、C#/Javaのような正格評価をする言語はカッコの内側から評価する。したがって、(True && True)がTrueになることを評価した上で、False && Trueを評価して、結果を得る。

ところがHaskellは、カッコの外側から評価を始める。つまり、カッコの中身が定まらない状態で、False && ?を評価する。そうすると、&&の左側がFalseなので、右側を評価するまでもなく結果がFalseであることがわかる。

foldr関数が無限リストに適用できるのは、この遅延評価があるからこそだ。foldrはこんな感じの再帰定義になっている。

foldr f z (x:xs) =  f x (foldr f z xs)

したがって、[x0, x1, x2, x3, ...]に適用すると、

f x0 (f x1 (f x2 (f x3 ( ... ) ) ) )

となるわけだが、遅延評価によりカッコの外側から評価されるので、無限リストであっても有限要素の評価で値が定まる場合はきちんと計算できるというわけ。右からの畳み込み関数だから右から評価すると思った?残念!左からでした!

*1:deriving節を使って自動実装させることもできる