Haskell中的类型和高级类型

265 阅读9分钟

Haskell开发者对其他编程语言更频繁的评论之一是,他们缺乏一种叫做高类型的类型。

这导致了一个交流问题。由于这些编程语言没有表达种类或HKT的方法,非Haskell开发者很难理解他们所错过的东西。

最后,Haskell开发者的话被认为是疯狂的呓语,而每个人都在继续前进。

但是一旦你开始使用Haskell,它就会让你非常直观地理解种类和高类型的类型。

在这篇文章中,我将向你介绍种类的概念。然后,我们将用我们新发现的知识来理解什么是高阶类型,以及什么使它们变得有用。

什么是类型的类型?

用一句话来说,种类对于类型来说就像类型对于值一样。

我们可以想象一个由数值组成的宇宙,其中充满了像"hello",True,[1, 2, 3] 。我们可以想象一个由类型组成的宇宙,管理这些值,其类型包括:String,Bool, 和[Int]

但是在Haskell中,我们有第三个管理类型的宇宙,它由种类填充:

*
* -> *
* -> * -> *

现在,关于它们最重要的事情是它们显示了一个类型的arity(如果我们把这个类型看作是一个函数的话)。

你可以把* 读成Type 。事实上,一个常用的GHC扩展名为NoStarIsType ,它禁用了* ,而使用了Type 。但由于GHC仍然默认使用* ,我将在文章中使用它。

要查看一个类型的种类签名,你可以使用GHCi中的:k 命令。

让我们来看看一些例子。

所有的具体类型,如String,Bool, 和[Int] ,都有一个类型*

> :k String
String :: *
> :k Bool
Bool :: *
> :k [Int]
[Int] :: *

要想拥有更复杂的种类,你需要一个多态类型--一个在其定义中带有类型变量的类型。在其他语言中,类型变量也被称为泛型。

例如,看看Maybe (Haskell的可选类型)在Haskell中是如何定义的:

data Maybe a = Nothing | Just a

Maybe 取一个类型变量-- --并返回一个类型-- --该类型可能包含 的一个项目。a Maybe a a

因此,它的类型是* -> *

> :k Maybe
Maybe :: * -> *

Maybe 本身是一个类型级函数,一个类型构造器。因此,不存在类型为 的实际值--只有类型为 , , 等的值。我们可以说, 是Maybe Maybe Int Maybe String Maybe 无人居住的

一旦你向Maybe 提供一个类型,它将返回一个包含值的具体类型:

> :k Maybe Bool
Maybe Bool :: *
> :k Maybe String
Maybe String :: *

为了拥有一种* -> * -> * ,你需要有两个类型变量。

这方面的一个经典例子是Either (Haskell的结果类型):

data Either a b = Left a | Right b

它接收两个类型--ab --并返回一个具体类型。

它可以用零、一或两个参数来应用。它的类型签名也相应地变化:

> :k Either
Either :: * -> * -> *
> :k Either String
Either String :: * -> *
> :k Either String Int
Either String Int :: *

大多数编程语言都支持这类类型--最常见的例子是列表和数组类型。

但是,如果具体类型就像值,而多态类型就像函数,我们能不能在类型上有类似于高阶函数的东西?

换句话说,我们能不能有一种像(* -> *) -> * -> *

事实证明,可以。在Haskell中,这很容易。

什么是Haskell中的高阶类型?

将Haskell与大多数编程语言区分开来的原因之一是高类型类型的存在。

高等类型是指在左边某处有括号的类型签名,如:(* -> *) -> * -> *

这意味着它们是以Maybe 这样的类型作为参数的类型。换句话说,它们是对多态类型的抽象。

一个常见的例子是任何集合的类型:

data Collection f a = Collection (f a) deriving (Show)
> :k Collection
Collection :: (* -> *) -> * -> *

这个类型接受一个包装器f (比如[] )和一个具体的类型a (比如Int ),并返回一个f a (比如Collection [] Int )的集合:

a :: Collection [] Int
a = Collection [1,2,3]

b :: Collection [] String
b = Collection ["call", "me", "ishmael"]

c :: Collection Maybe String
c = Collection (Just "whale")

像这样的类型在大多数编程语言(如Java、TypeScript或Rust)中是无法创建的,除非求助于黑魔法。

在C#中尝试HKT(失败)的例子:

interface ISingleton<out T>
{
T<A> One<A>(A x);
}

class ListSingleton : ISingleton<List>
{
public List<A> One<A>(A x) => new List<A>(new A[]{x});
}

下面的代码返回了三个编译错误,其中第一个--The type parameter 'T' cannot be used with type arguments --是编译器明确禁止HKT的。

你也可以在我们关于功能性TypeScript的文章中看到一个不可实现的TypeScript例子Collection


当然,我们不能真的为这个数据类型写很多函数,因为它太通用了。为了让它更有用,我们可以添加一些约束条件,比如外部类型(f)是一个漏矢。

但什么是漏斗呢?

HKTs和 functor

在Haskell中,HKTs是像FunctorMonad 这样的类型的领域。在这篇文章中,我们将只讨论Functor ,因为它比较简单。但这里写的大多数东西也适用于Monad

让我们来看看GHCi能告诉我们关于Functor

> :info Functor
type Functor :: (* -> *) -> Constraint
class Functor f where
  fmap :: (a -> b) -> f a -> f b
  (<$) :: a -> f b -> f a

如果你对Haskell不熟悉,这可能看起来有点混乱。让我们试着解开它。

Haskell中的Functor是一个typeclass,它与Rust的traits很相似,或者如果你仔细看的话,是Java的接口。类型类定义了一组可以在不同类型之间共享的通用方法。

Functor 的类型签名是(* -> *) -> Constraint ,这意味着它接受一个类似于Maybe 的类型构造函数,并返回一个Constraint 的类型。


约束类型

虽然* 是你在没有扩展的基本Haskell中最常见的种类,但它不是唯一的种类。

虽然数据类型是像** -> * 这样的类型,但类型类是像* -> Constraint(* -> *) -> Constraint 这样的类型。

Constraint 是类约束的种类--它涵盖了定义类型时可以出现在箭头左边的任何东西。

例如,如果我们想在Haskell中定义一个多态加函数,我们需要添加一个Num 的约束:

--      {1}
plus :: Num a => a -> a -> a
plus a b = a + b
-- {1}: Num constraint on the type of a in the signature.

它来自于Num 类型,其种类是* -> Constraint

一般来说,当你开始使用扩展时,你会更多地遇到带名字的类型,如 [DataKinds](https://www.parsonsmatt.org/2017/04/26/basic_type_level_programming_in_haskell.html).


Functor 的主要方法是fmap ,它是一个map ,适用于多种类型的包装器。下面是它的一些使用例子。

fmap with :[]

> fmap (+3) [1, 2, 3]
[4,5,6]

fmap with :Maybe

> fmap (+3) (Just 1)
Just 4

fmap with 。Either

> fmap (+3) (Right 5)
Right 8
> fmap (+3) (Left "fatal Err0r")
Left "fatal Err0r"

如果你想了解更多关于类型类的信息,你可以阅读我们关于functors的文章。

一般来说,由于类型为* -> * 的类型没有任何值,所以在大多数编程语言中,你无法真正使用它们。

但是在Haskell中,我们可以用这样的类型为它创建一个Functor 的实例。例如,我们自己的数据类型Optional

data Optional a = Some a | None deriving (Show)

instance Functor Optional where
  fmap f (Some a) = Some (f a)
  fmap f None     = None

一旦我们有了这个实例,我们就可以为这个数据类型使用Functor 方法:

> fmap (+3) (Some 4)
Some 7

Functor实例的限制

请注意,你只能为一个类型的* -> * ,定义一个Functor 的实例。所以它必须是Optional ,而不是Optional IntOptional String

类型为* -> * -> * ,如Either(,) (元组类型)的类型不能有Functor 实例,除非被应用到正确的类型上。例如,你可以为Either a ,但不能为EitherEither a b ,有一个函数实例:

instance Functor (Either a) where
    fmap _ (Left x) = Left x
    fmap f (Right y) = Right (f y)

因为部分应用是从左边开始的,所以fmap 只能映射Right 的元素Either


现在我们对Functor 已经有些熟悉了,我们可以把它作为Collection 数据类型的约束条件。我们将在集合上定义一个cfmap 函数,其包装者是漏斗。

它接收一个函数和一个具有漏斗包装器的Collection ,并返回一个具有相同漏斗包装器的Collection ,但将该函数应用于其内部的值:

cfmap :: Functor f => (a -> b) -> Collection f a -> Collection f b
cfmap fun (Collection a) = Collection (fmap fun a)

下面是这个方法的工作原理:

> cfmap (<> "!") (Collection ["call", "me", "ishmael"])
Collection ["call!","me!","ishmael!"]

> cfmap (+3) (Collection [1, 2, 3])
Collection [4,5,6]

当然,这个定义与fmap 本身非常相似。所以我们可以直接把Collection 改为Functor

instance Functor f => Functor (Collection f) where
  fmap fun (Collection a) = Collection (fmap fun a)

这使得我们可以对有Functor 包装的集合使用fmap

> fmap (<> "!") (Collection ["call", "me", "ishmael"])
Collection ["call!","me!","ishmael!"]

相当酷。

至此,我们在Haskell中的高类型类型的旅程结束了。🏞️

总结一下,这里有一个表格,列出了具体类型、简单多态类型和高类型的区别。

具体类型多态类型高等类型
种类签名示例** -> *(* -> *) -> *
数据类型示例IntMaybe aCollection f a

(来自基地的实际例子包括来自Data.Monoid替代性应用性包装器。)

高类型类型的好处

那么为什么一种语言需要支持高类型的类型呢?

如果你问一个Haskeller,当然是为了能够轻松地定义类似于FunctorMonad 类型的东西。

这些类型库对于那些知道如何使用它们的人来说是非常有益的。对一堆类似的行为进行抽象可以导致非常优雅的代码。

而且,另外,如果HKTs是自然可用的,那么有人创建一个对99%的语言用户来说无法阅读的FP库的可能性就更小。🙃

同时,大量的编程语言选择跳过HKTs。例如,Rust在某种程度上受到了像Haskell这样的FP语言的启发,但它目前还缺乏高类型的类型。然而,人们还是喜欢它。

所以,这至少是一个复杂的权衡。值得庆幸的是,这主要是由语言设计师而不是我们这些凡人解决的问题。

总而言之,如果你用一种能够编写HKT的语言(如Haskell)来写代码,你当然应该利用这种能力。它们并不复杂,但却有相当大的帮助。

但如果你想在一种不自然的语言中使用它们(例如,通过FP库,如fp-ts),请事先确保每个人都能接受。