Haskell中的类型类依赖和面向对象编程中的子类型有什么区别?

8

我们经常使用类型类依赖来模拟子类型关系。

例如:

当我们想要在面向对象编程中表达Animal、Reptile和Aves之间的子类型关系时:

abstract class Animal {
    abstract Animal move();
    abstract Animal hunt();
    abstract Animal sleep();
}

abstract class Reptile extends Animal {
    abstract Reptile crawl();
}

abstract class Aves extends Animal {
    abstract Aves fly();
}

我们可以将上述每个抽象类转换为Haskell中的类型类:
class Animal a where
    move :: a -> a
    hunt :: a -> a
    sleep :: a -> a

class Animal a => Reptile a where
    crawl :: a -> a

class Animal a => Aves a where
    fly :: a -> a

即使我们需要一个异构列表,我们也可以使用ExistentialQuantification

所以我想知道,为什么我们仍然说Haskell没有子类型,是否还有一些子类型可以做到但是类型类不能?它们之间的关系和区别是什么?


我认为问题不在于子类型能做什么类型类不能做,而是相反的情况。Haskell的类型类可以表达出我不知道如何用面向对象编程实现的关系,尽管这显然取决于语言。例如,在面向对象编程中如何定义“Functor”? - Mark Seemann
1个回答

18
一参数类型类是一种类型的集合,可以将其视为一组类型。如果SubSuper的子类(子类型类),则实现Sub的类型集是实现Super的类型集的子集(或相等)。所有Monad都是Applicative,所有Applicative都是Functor
在Haskell中,你可以使用存在性量化、类型类约束类型来完成子类化所能完成的所有操作。这是因为它们本质上是相同的:在典型的面向对象编程语言中,每个具有虚拟方法的对象都包含一个vtable指针,它与带有类型类约束的存在性量化值中存储的“字典”指针相同。Vtables就是存在性量化!当有人给你一个超类引用时,你不知道它是超类的实例还是子类的实例,你只知道它具有某个接口(从类或OOP“接口”中获取)。
实际上,你可以使用Haskell的广义存在性量化做的更多。我喜欢的一个例子是将返回某种类型a值的操作打包到一个变量中,在操作完成后,将结果写入变量中;源返回与变量相同类型的值,但这对外部是隐藏的。
data Request = forall a. Request (IO a) (MVar a)

因为Request隐藏了类型a,所以您可以将多个不同类型的请求存储在同一个容器中。由于a完全不透明,调用者唯一能做的事情就是运行这个操作(同步或异步),并将结果写入MVar。很难使用错误!
不同之处在于,在面向对象编程语言中,您通常可以:
1. 隐式上转型 - 在期望超类引用的地方使用子类引用,而在Haskell中必须显式地完成(例如通过打包到存在量中)。 2. 尝试下转型,这在Haskell中是不允许的,除非您添加额外的Typeable约束来存储运行时类型信息。
然而,类型类可以模拟比OOP接口和子类更多的东西,有几个原因。首先,由于它们是对类型的约束,而不是对对象的约束,因此您可以将常量与类型关联,例如Monoid类型类中的mempty
class Semigroup m where
  (<>) :: m -> m -> m

class (Semigroup m) => Monoid m where
  mempty :: m

在面向对象编程语言中,通常没有“静态接口”的概念来表达这个概念。C++中未来的“概念”功能是最接近的等价物。
另一件事是,子类型和接口基于单个类型,而您可以使用多个参数拥有一个类型类,它表示类型元组的集合。您可以将其视为一种关系。例如,一组可以强制转换为另一个的类型对:
class Coercible a b where
  coerce :: a -> b

通过 函数依赖,您可以告诉编译器关于这个关系的各种属性:

class Ref ref m | ref -> m where
  new :: a -> m (ref a)
  get :: ref a -> m a
  put :: ref a -> a -> m ()

instance Ref IORef IO where
  new = newIORef
  get = readIORef
  put = writeIORef

编译器知道关系是单值的,或者说是一个函数:每个“输入”(ref)的值都映射到“输出”(m)的一个值。换句话说,如果确定了Ref约束的ref参数为IORef,那么m参数必须是IO——你不能拥有这种函数依赖关系并且还有一个将IORef映射到不同单子的独立实例,比如instance Ref IORef DifferentIO。类型之间的这种函数关系也可以用关联类型或更现代的类型族来表示(在我看来通常更清晰)。

当然,直接使用“存在类型类反模式”将OOP子类层次结构转换为Haskell并不符合惯用法,这通常是过度设计。通常有一种更简单的转换方式,比如ADT / GADT / 记录 / 函数——大致相当于OOP建议中的“优先使用组合而非继承”。

大多数情况下,在OOP中编写类时,在Haskell中通常不应该使用typeclass,而应该使用module。导出类型及其上运行的一些函数的模块本质上与类的公共接口相同,当涉及到封装和代码组织时也是如此。对于动态行为,通常最好的解决方案不是基于类型的调度;而是使用高阶函数。毕竟这是函数式编程。 :)


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接