类型的类型:Haskell 的 Kind 体系

Hacker News Top 新闻

摘要

一篇博客文章,通过作者在 Haskell 编写的 Redis 服务器克隆项目中添加类型化配置的经验,讲解 Haskell 的 Kind 体系,涵盖值、类型、Kind、高阶 Kind 的类型/数据以及自定义 Kind。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/17 22:50

# 类型的类型:Haskell 的 Kind 系统 来源:https://thunk.blog/posts/types-of-types-haskell-kind-system 目录·7 个章节 - [值](https://thunk.blog/posts/types-of-types-haskell-kind-system#values) - [类型](https://thunk.blog/posts/types-of-types-haskell-kind-system#types) - [Kind](https://thunk.blog/posts/types-of-types-haskell-kind-system#kinds) - [高阶 Kind 类型](https://thunk.blog/posts/types-of-types-haskell-kind-system#higher-kinded-types) - [高阶 Kind 数据](https://thunk.blog/posts/types-of-types-haskell-kind-system#higher-kinded-types) - [自定义 Kind](https://thunk.blog/posts/types-of-types-haskell-kind-system#custom-kinds) - [结语](https://thunk.blog/posts/types-of-types-haskell-kind-system#conclusion) 在为我的 Haskell Redis 服务器克隆项目添加[类型化配置](https://github.com/OlaoluwaM/redis-haskell/pull/2)时,我学到了一些新技巧,它们全都源于 Haskell 的 Kind 系统。在本文中,我将分享我学到的内容以及由此构建出来的东西。 我们将从最基础的层面开始:值。 ## 值 值是我们程序中操作的基本构件,比如 `1`、`2`、`True`、`"hello"` 等等……在函数式编程中,它们也被称为"*项(terms)*"。它们是我们消费、创建、计算和输出的东西。值的管理和操作是编程和软件工程实践的重要组成部分。 我们每天都在与各种值打交道。就像奇幻世界中的生物一样,共享某些特征的值——以相同方式创建、使用和操作的值——属于同一个"物种"。形式上,这些"物种"被称为*类型(Types)*。 ## 类型 类型是一组相关值的集合。换一种说法,类型的概念为我们提供了一种集体引用/指代一组相关值的方式。在许多编程语言中,我们使用像 `Int`、`string`、`Bool`、`Text` 等词语来表示一个类型。 在我看来,将值归类为类型的主要好处是抽象。类型告知我们——程序员和编译器——某个值的特征,以及哪些操作可以或不可以对它执行。想想游戏中的武器类别吧。在许多 RPG 中,武器类别往往决定了你可以执行的攻击类型或可以使用的技能类型;类型对值做同样的事情。 再进一步,你可能会问自己,我们如何对类型进行分类?我们需要这样做吗?为什么?要回答这些问题,我们必须进入*Kind*的领域。 ## Kind Kind 之于类型,就像类型之于值一样。我对 Kind 系统的理解是,它是所有类型层面事物的分类手段。你可能会认为"所有类型层面的事物"仅仅指日常的类型,但在 Haskell(以及少数其他语言)中,还存在其他类型层面的构造,比如类型层面的数字(Kind 为 `Nat`)、类型层面的字符串(Kind 为 `Symbol`),或者来自 Typeclass 的约束(Kind 为 `Constraint`)。 我们不会深入探讨这些内容,但 `Nat` 和 `Symbol` 确实经常出现在类型层面编程的场景中。另一方面,Kind 为 `Constraint` 的实体你可能更熟悉,因为它们很大程度上就是(完全应用的)Typeclass。例如,`Show` Typeclass 属于 Kind `Type -> Constraint`,而 `Show Int` 属于 Kind `Constraint`。 普通类型属于 Kind `Type`。我知道这有点令人困惑。类型构造器,即像 `Maybe`、`Either`、`Last`、`Proxy`、`Dual` 等等的东西,其 Kind 由它们的[元数](https://en.wikipedia.org/wiki/Arity)和参数的 Kind 共同决定,如下表所示: | 值 | 类型层面的构造 | Kind | |---|---|---| | `1` | `Int` | `Type` | | `True` | `Bool` | `Type` | | `not` | `Bool -> Bool` | `Type` | | `[1, 2, 3]` | `[Int]` | `Type` | | `Just "hello"` | `Maybe String` | `Type` | | `Right 5` | `Either String Int` | `Type` | | `Maybe` | `Type -> Type` | `Type` | | `[]`(列表类型构造器) | `Type -> Type` | `Type` | | `Either` | `Type -> Type -> Type` | `Type` | | `Either String` | `Type -> Type` | `Type` | | `Three`(来自 `data Three a b c = ...`) | `Type -> Type -> Type -> Type` | `Type` | | `5`(类型层面的数字) | `Nat`(GHCi 打印为 `Natural`) | `Type` | | `"hello"`(类型层面的字符串) | `Symbol` | `Type` | | `Show Int` | `Constraint` | `Type` | | `Show` | `Type -> Constraint` | `Type` | | `Functor` | `(Type -> Type) -> Constraint` | `Type` | | `Compose`(简化) | `(Type -> Type) -> (Type -> Type) -> Type -> Type` | `Type` | 在旧文档和一些 GHC 错误消息中,你会看到 `*` 而不是我写的 `Type`。它们是同一个东西。据我所知,`*` 是 `Type` 的同义词,反之亦然。`*` 是较旧的名称,GHC 计划将其废弃(`-Wall` 在你使用它时已经会发出警告)。 从技术上讲,普通类型也是类型构造器,即没有参数的类型构造器。你可能会发现它们被称为"*零元类型构造器*"。 在我看来,如果没有 Kind,就无法区分 `Maybe`、`Maybe Int` 和 `Show` 这些类型层面的构造。根据你在 Haskell 学习旅程中所处的阶段,拥有这些其他类型层面实体的好处可能还不太明显,但我可以向你保证,它们都有助于使 Haskell 类型系统更具表达力,让我们能够在类型系统中编码更多的东西,从而使编写更安全的程序变得更加容易。 ## 高阶 Kind 类型 前面我们提到,类型允许我们对值进行抽象。这意味着我们可以通过类型来集体引用一组值。类似地,对于给定的 Kind,我们命名了一组类型层面的构造。例如,Kind `Type -> Type` 集体指代所有接受一个普通类型作为参数的类型构造器:这涵盖了像 `Maybe` 这样的东西,也包括部分应用如 `Either String` 或 `Three Int String`。同样,Kind `Type -> Type -> Type` 集体指代所有接受两个普通类型作为参数的类型构造器。回到值/项的层面,这类似于函数签名。`Int -> Bool` 这样的函数签名集体指代所有接受 `Int` 作为参数并返回 `Bool` 的函数。 我们知道像 `Int -> Bool` 这样的签名集体指代一组函数,但像 `(Int -> Int) -> Bool` 这样的签名呢?那意味着什么?该签名代表所有先接受一个签名为 `Int -> Int` 的函数作为参数,然后返回布尔值的函数。这种签名类型非常常见,具有这种签名的函数被称为高阶函数。 好,现在,如果我们尝试将这个概念外推到 Kind 层面会发生什么?如果我们把 `Type -> Type` 中的 `Type` 参数替换为 `(Type -> Type)` 呢?首先,我们会得到一个像 `(Type -> Type) -> Type` 这样的签名。我们知道 `Type` 是所有普通类型的 Kind,`Type -> Type` 是所有接受一个普通类型作为参数的类型构造器的 Kind。合在一起意味着,签名 `(Type -> Type) -> Type` 代表所有接受一个类型构造器作为参数的类型层面构造的 Kind。你明白我要说什么了吗?就像具有 `(Int -> Int) -> Bool` 这样签名的函数被称为高阶函数一样,Kind 为 `(Type -> Type) -> Type` 或 `(Type -> Type) -> (Type -> Type) -> Type` 这样的类型构造器被统称为高阶 Kind 类型,简称 HKT。 这是一个小例子: ```haskell data Wrap (f :: Type -> Type) = Wrap (f Int) ``` `Wrap` 接受一个类型构造器 `f` 并将一个 `Int` 放入其中,因此它的 Kind 是 `(Type -> Type) -> Type`。`Wrap Maybe` 是一个普通类型,`Wrap []` 也是。`Wrap (Just 5)` 和 `Wrap [1, 2, 3]` 分别是它们的值。前面表格中的 `Compose` 是另一个例子,只是参数更多。 从另一个角度来看,如果像 `Maybe` 或 `Either` 这样的参数化类型是其他语言所说的泛型,那么 HKT 就是可以接受其他泛型作为参数的泛型。 HKT 的概念与 Typeclass 结合后,使我们能够在类型层面的构造上实现某些功能。这很有用,因为它让我们能够在更多场景中复用操作。为了说明,让我们看 `Functor :: (Type -> Type) -> Constraint`。`Functor` 本身不是 HKT,因为它的 Kind 以 `Constraint` 结尾,而不是 `Type`。然而,它的参数*是*一个类型构造器,这使它成为高阶 Kind 的。 现在,如果一个类型构造器有一个 `Functor` 实例,我们就可以使用 `fmap` 函数对其内容进行映射。更重要的是,由于 `Functor` 的高阶 Kind 特性,我们只需要为一个 Kind 适当的类型构造器(具体来说是 `Type -> Type`)定义一个 `Functor` 实例,就能对它的任何特化使用 `fmap`。我的意思是,因为我们将 `Functor` 实例定义在 `Maybe` 上,而不是某个 `Maybe a` 上,所以我们可以对 `Maybe a` 的所有具体版本(`Maybe Int`、`Maybe String`、`Maybe Bool` 等等)使用 `fmap`。事实上,不可能以其他方式工作:`Functor` 的 Kind 签名只接受 Kind 为 `Type -> Type` 的类型构造器,所以像 Kind 为 `Type` 的 `Maybe Int` 甚至不是候选者。实例属于 `Maybe` 本身。这个原则适用于所有其他高阶 Kind 的 Typeclass 和 Kind 适当的类型构造器。 用非技术性的话来说,拿一个吃饭用的碗来说。只要碗本身是微波炉安全的,你就可以用它来加热放进其中的任何食物。在这个例子中,碗就是我们的"Kind 适当的类型构造器";决定某物是否"微波炉安全"的标准就是我们的高阶 Kind Typeclass(比如 `Functor`);我们在碗上贴的"微波炉安全"标签就是它的 Typeclass 实例;而我们能够加热的那碗食物代表类型构造器的一个特化。这里也一样:没有人会认证一碗汤是微波炉安全的。标签属于碗,与碗中的内容无关。 ## 高阶 Kind 数据 高阶 Kind 数据,简称 HKD,是一种模式:数据类型不是接受类型层面的东西作为参数,而是接受一个或多个类型构造器作为参数,并用它们来包装其部分或全部字段。人们可能认为这很"高级",但一旦你理解了[高阶 Kind 类型](https://thunk.blog/posts/types-of-types-haskell-kind-system/#higher-kinded-types),它实际上非常直接。这里有一个例子: ```haskell -- `f` 参数是一个类型构造器,使 `Profile` 成为高阶 Kind 的 data Profile (f :: Type -> Type) = Profile { age :: f Int , name :: f String , profession :: f String , address :: f String } -- `f = Identity`:每个字段都必须存在 grace :: Profile Identity grace = Profile (Identity 37) (Identity "Grace") (Identity "Computer scientist") (Identity "New York") -- `f = Maybe`:任何字段都可以缺失,就像一份只填了一半的注册表 draft :: Profile Maybe draft = Profile Nothing (Just "Grace") Nothing Nothing -- `f = Const String`:每个字段都持有一个 `String`,无论其类型如何,就像表单标签 labels :: Profile (Const String) labels = Profile (Const "Age") (Const "Name") (Const "Profession") (Const "Address") ``` 让我们通过我的 [redis-haskell](https://github.com/OlaoluwaM/redis-haskell) 项目中的这个 [PR](https://github.com/OlaoluwaM/redis-haskell/pull/2) 来深入了解,我在其中添加了对来自命令行选项和 `redis.conf` 文件中声明的选项的类型化 Redis 配置的支持。 首先,要实现这个功能,我需要一个包含我想支持的字段的 Redis 配置数据类型表示。 我首先遇到的问题是重复。我最初的数据类型想法是一个普通的记录类型,但它需要支持两个略有不同的场景: - 默认配置,其中所有字段都可用,并初始化为其官方默认值。 - 命令行配置和 `redis.conf` 配置,其中字段需要是可空的。 我最初的方向是定义两个记录类型:一个字段类型是裸的,另一个用 [`Last`](https://www.stackage.org/haddock/lts-24.63/base-4.20.2.0/Data-Monoid.html#t:Last) 包装。 ```haskell data RedisConfig = RedisConfig { rdbFileDirPath :: SomeBase Dir , rdbFilenamePath :: Path Rel File , useRDBCompression :: Bool , genRdbChecksum :: Bool , port :: Int } deriving stock (Generic) data PartialRedisConfig = PartialRedisConfig { rdbFileDirPath :: Last (SomeBase Dir) , rdbFilenamePath :: Last (Path Rel File) , useRDBCompression :: Last Bool , genRdbChecksum :: Last Bool , port :: Last Int } deriving stock (Generic) ``` `Maybe` 的 `<>` 会委托给其包含类型的 `Semigroup` 语义,除非其中一个操作数是 `Nothing`。此外,为了让它工作,我们所有包装的字段类型都需要有 `Semigroup` 实例。`Bool` 和 `Int` 没有。即使对于有实例的类型,`<>` 也会组合两个值,而不是选择其中一个。`Last` 保留最新的存在的值,允许后续来源覆盖先前的值。 我不喜欢这种方法,因为我希望对支持的配置字段名称和类型只有一个单一的事实来源。有了两个数据类型,很容易修改一个变体而忘记更新另一个。类型系统本可以捕获任何不一致之处,取决于实现方式,但这还不够好。 一种选择是只有一个 Redis 配置类型,每个字段都用 `Last` 包装。由于 `Last` 保留最右边的 `Just` 值,`defaults <> confFileOpts <> cliOpts` 将给予命令行选项最高优先级,其次是 `redis.conf` 选项,然后是默认值。正是我们想要的语义。然而,问题在于最终结果。它仍然是一个所有字段都用 `Last` 包装的记录,即使与默认记录(`defaults`)组合后可以确保所有字段都有值。因此,每个读取最终配置的地方都需要解包一个不可能是 `Nothing` 的 `Last` 值。 你可能也在想:为什么不为最终结果再定义一个裸字段的记录——哦……是的。 所以,我又需要配置类型的两个变体:一个用于合并的部分配置,另一个用于默认值和供服务器其他部分使用的完整配置。我不想要的是两个定义。 为了实现这一点,我最终得到了类似这样的定义: ```haskell data RedisConfigF (f :: Type -> Type) = RedisConfigF { rdbFileDirPath :: f (SomeBase Dir) , rdbFilenamePath :: f (Path Rel File) , useRDBCompression :: f Bool , genRdbChecksum :: f Bool , port :: f Int } deriving stock (Generic) ``` `RedisConfigF` 是一个高阶 Kind 数据类型。它是高阶 Kind 的,因为它的 `f` 参数的 Kind 是 `Type -> Type`。这使得 `RedisConfigF` 本身的 Kind 是 `(Type -> Type) -> Type`,与上面的 `Wrap` 定义(https://thunk.blog/posts/types-of-types-haskell-kind-system/#bccebc)相同。这让我用一个数据类型定义就覆盖了上述两种场景。对于默认值和最终合并的配置,`f = Identity`;对于字段值可能不存在的情况,`f = Last`。 合并时,我们利用 `Last` 的 `Semigroup` 实例来组合 `redis.conf` 选项和 CLI 选项的配置记录。然后我们用默认值填充空缺。这里是[合并代码](https://github.com/OlaoluwaM/redis-haskell/blob/f3839f961ee43c4d12f4f51272615c74abcb6b9f/codecrafters-redis/src/Redis/Server/Config.hs#L74-L81)。 之后,我们得到了一个类型为 `RedisConfigF Identity` 的记录,所有字段都已填充。然而,当 `f = Identity` 时,每个字段都被 `Identity` 包装,访问时仍然需要解包。实际代码[自动化了这个解包过程](https://gith

相似文章

Typeclass 与模块

Lobsters Hottest

一篇博客文章澄清了模块系统(OCaml)与 typeclass(Haskell、Rust)之间的异同,认为 typeclass 主要用于 ad-hoc 多态,而模块服务于模块化抽象;两者都能以一定代价部分地模拟对方。

Data types à la carte (2008)

Lobsters Hottest

本文提出了一种从独立组件组合数据类型和函数的技术,并将该方法扩展到结合自由单子,从而实现了对Haskell的IO单子的模块化结构。

记录类型推断入门指南

Lobsters Hottest

本文解释了静态类型语言中匿名记录类型推断的基础知识,使用了类型理论符号并以Haskell作为实现语言。

一致性与孤儿实例规则

Lobsters Hottest

本文解释了类型类系统中一致性的概念,并讨论了编程语言中的孤儿实例规则,以Haskell和Rust为例。