Haskell 中的存在类型和其他语言中的泛型

Existential types in Haskell and generics in other languages

我试图通过文章 Haskell/Existentially quantified types 来掌握 Haskell 中存在类型的概念。乍一看,这个概念似乎很清晰,有点类似于面向对象语言中的泛型。主要例子里面有个东西叫“异构列表”,定义如下:

data ShowBox = forall s. Show s => SB s
 
heteroList :: [ShowBox]
heteroList = [SB (), SB 5, SB True]

instance Show ShowBox where
  show (SB s) = show s
 
f :: [ShowBox] -> IO ()
f xs = mapM_ print xs

main = f heteroList

我对“异构列表”有不同的概念,类似于 Shapeless in Scala。但在这里,它只是包裹在只添加类型约束的存在类型中的项目列表。它的元素的确切类型并没有体现在它的类型签名中,我们唯一知道的是它们都符合类型约束。

在面向对象的语言中,写这样的东西似乎很自然(Java中的例子)。这是一个普遍存在的用例,我不需要创建包装器类型来处理所有实现特定接口的对象列表。 animals 列表有一个通用类型 List<Vocal>,所以我可以假设它的元素都符合这个 Vocal 接口:

interface Vocal {

    void voice();
}

class Cat implements Vocal {

    public void voice() {
        System.out.println("meow");
    }
}

class Dog implements Vocal {

    public void voice() {
        System.out.println("bark");
    }
}

var animals = Arrays.asList(new Cat(), new Dog());
animals.forEach(Vocal::voice);

我注意到存在类型只能作为语言扩展使用,大多数“基础”Haskell 书籍或教程中都没有描述它们,所以我的建议是这是一门相当高级的语言功能。

我的问题是,为什么?在具有泛型的语言中看起来很基本的东西(构造和使用其类型实现某些接口并以多态方式访问它们的对象列表),在 Haskell 中需要语言扩展、自定义语法和创建额外的包装器类型?如果不使用存在类型就没有办法实现类似的东西,还是没有基本级别的用例?

或者我只是混淆了概念,存在类型和泛型意味着完全不同的东西。请帮助我理解它。

是的,存在类型和泛型意味着不同的东西。存在类型可以类似于面向对象语言中的接口来使用。您当然可以将一个放在列表中,但是使用接口不需要列表或任何其他泛型类型。有一个 Vocal 类型的变量来演示它的用法就足够了。

它在Haskell中没有被广泛使用,因为大多数时候并不是真正需要它。

nonHeteroList :: [IO ()]
nonHeteroList = [print (), print 5, print True]

在没有任何语言扩展的情况下做同样的事情。

存在类型(或面向对象语言中的接口)只不过是一段数据和捆绑的方法字典。如果你的字典里只有一种方法,那就用函数吧。如果您有多个,则可以使用元组或这些元组的记录。所以如果你有类似

interface Shape {
   void Draw();
   double Area();
}

你可以用Haskell表示,例如

type Shape = (IO (), Double)

然后说

circle center radius = (drawCircle center radius, pi * radius * radius)
rectangle topLeft bottomRight = (drawRectangle topLeft bottomRight, 
           abs $ (topLeft.x-bottomRight.x) * (topLeft.y-bottomRight.y))

shapes = [circle (P 2.0 3.5) 4.2, rectangle (P 3.3 7.2) (P -2.0 3.1)]

虽然你可以用类型类、实例和存在

表达完全相同的东西
class Shape a where
  draw :: a -> IO ()
  area :: a -> Double

data ShapeBox = forall s. Shape s => SB s
instance Shape ShapeBox where
  draw (SB a) = draw a
  area (SB a) = area a

data Circle = Circle Point Double
instance Shape Circle where
  draw (Circle c r) = drawCircle c r
  area (Circle _ r) = pi * r * r

data Rectangle = Rectangle Point Point
instance Shape Rectangle where
  draw (Rectangle tl br) = drawRectangle tl br
  area (Rectangle tl br) = abs $ (tl.x - br.x) * (tl.y - br.y)

shapes = [Circle (P 2.0 3.5) 4.2, Rectangle (P 3.3 7.2) (P -2.0 3.1)]

好了,N 倍长。

is there just no basic-level use cases for this?

差不多,是的。在 Java 中,您别无选择,只能打开 类,Haskell 具有通常用于此类用例的 ADT。在您的示例中,Haskell 可以用以下两种方式之一表示它:

data Cat = Cat

data Dog = Dog

class Animal a where
  voice :: a -> String

instance Animal Cat where
  voice Cat = "meow"

instance Animal Dog where
  voice Dog = "woof"

data Animal = Cat | Dog

voice Cat = "meow"
voice Dog = "woof"

如果您需要可扩展的东西,您会使用前者,但如果您需要能够区分动物的类型,您会使用后者。如果您想要前者,但想要一个列表,则不必使用存在类型,而是可以在列表中捕获您想要的内容,例如:

voicesOfAnimals :: [() -> String]
voicesOfAnimals = [\_ -> voice Cat, \_ -> voice Dog]

或者更简单

voicesOfAnimals :: [String]
voicesOfAnimals = [voice Cat, voice Dog]

无论如何,这就是您对异构列表所做的事情,您有一个约束,在这种情况下,每个元素上的 Animal a 允许您对每个元素调用 voice,但没有别的,因为约束不会为您提供有关该值的更多信息(好吧,如果您有约束 Typeable a ,您将能够做更多的事情,但我们不用担心这里的动态类型)。


至于为什么Haskell不支持没有扩展和包装器的异构列表,我会让其他人解释,但关键主题是:

在您的 Java 示例中,Arrays.asList(new Cat()) 的类型是什么?好吧,这取决于您将其声明为什么。如果你用 List<Cat> 声明变量,它会进行类型检查,你可以用 List<Animal> 声明它,你也可以用 List<Object> 声明它。如果您将其声明为 List<Cat>,您将无法将其重新分配给 List<Animal>,因为那样是不合理的。

在 Haskell 中,type类 不能用作列表中的类型(因此 [Cat] 在第一个示例中有效, [Animal] 有效在第二个示例中,但 [Animal] 在第一个示例中无效),这似乎是由于 Haskell 中不支持谓词多态性(并非 100% 确定)。 Haskell 列表的定义类似于 [a] = [] | a : [a]。 [x, y, z] 只是 x : (y : (z : [])) 的语法糖。因此请考虑 Haskell 中的示例。假设您在 repl 中键入 [Dog](这相当于 Dog : [] btw)。 Haskell 推断其具有 [Dog] 类型。但是如果你在前面给它 Cat,比如 [Cat, Dog] (Cat : Dog : []),它会匹配第二个构造函数 (:),并且会推断出 Cat : ... 的类型[Cat]Dog : [] 将无法匹配。

由于其他人已经解释了在许多情况下如何避免存在类型,我想我会指出您可能需要它们的原因。我能想到的最简单的例子叫做 Coyoneda:

data Coyoneda f a = forall x. Coyoneda (x -> a) (f x)

Coyoneda f a 包含一个充满某种类型 x 的容器(或其他仿函数)和一个可以映射到它上面以生成 f a 的函数。这是 Functor 实例:

instance Functor (Coyoneda f) where
  fmap f (Coyoneda g x) = Coyoneda (f . g) x

请注意,这没有 Functor f 约束!是什么让它有用?要解释这还需要两个函数:

liftCoyoneda :: f a -> Coyoneda f a
liftCoyoneda = Coyoneda id

lowerCoyoneda :: Functor f => Coyoneda f a -> f a
lowerCoyoneda (Coyoneda f x) = fmap f x

很酷的是 fmap 应用程序一起构建和执行:

lowerCoyoneda . fmap f . fmap g . fmap h . liftCoyoneda

正在运行

fmap (f . g . h)

而不是

fmap f . fmap g . fmap h

如果 fmap 在底层仿函数中开销很大,这会很有用。