编程语言中有时会出现名义类型系统和结构类型系统之间的差异。
虽然我绝对不是第一个谈论这个问题的人,但对我来说值得注意的是,在我看来,这几乎是所有编程语言都会犯的错误。
但让我们从顶部开始
名字里有什么
如果您来自 Java、Kotlin、Haskell、OCaml 或 Rust(或大多数其他语言)等语言,那么名义类型可能就是您可能认为的“类型”。
在这样的语言中,需要在类型声明中定义类型,并为其指定一个名称,作为其唯一的标识源,而不考虑其实现。即使两个不同的类型碰巧共享相同的实现,它们也会被视为不平等,因为它们具有不同的名称。
例如,如果我要定义两个java类
class A { int x; } class B { int x; }
或两个 OCaml 记录
type a = { x : int } type b = { x : int }
接受A
函数不会接受B
,因为即使它们的实现完全相同, A
和B
也不是同一类型。
这是大多数语言中的默认设置是有原因的:它非常适合数据抽象和错误消息,并且通常比替代方案更不会产生意外。
但有时它可能有点限制。例如,假设您要定义一个函数来执行某些内部数据库查询,并且可以返回一个结果、多个结果或一个错误。在纯主格类型系统中,您需要显式定义此类型。
data QueryResult a = One a | Many (List a) | Error QueryError performQuery :: Query a -> QueryResult a
这不仅定义起来很烦人,使用起来也很烦人!当用户在您的文档或编辑器中看到performQuery
的类型时,他们看到的只是QueryResult a
。他们不会看到它的情况,直到将鼠标悬停在它上面(如果您有一个好的 LSP 1 )或手动查找其定义(如果您没有)。如果您曾经使用过广泛使用这些一次性标称类型的库,您就会明白我的意思。
此外,名义类型非常不灵活。如果您有QueryResult a
类型的内容,您必须假设它实际上可能是这三种情况中的任何一种。对于标称类型,不存在中间类型,因为实现与类型无关:“可能是One
或Error
但不是Many
东西”不是有效类型,因为它没有名称。
结构类型
相反,如果您来自 TypeScript,您将习惯光谱的另一端:结构类型!虽然结构类型可能有名称,但它更像是类型别名,实际上并不是其标识的一部分。
两个结构类型相等当且仅当它们的定义相等。
例如,在 TypeScript 中复制之前的示例
class A { x : number = 0 } class B { x : number = 0 } const x : A = new B()
将表明您实际上可以将B 类型的某些内容分配给现在需要 A 的上下文,即使这些是具有不同名称的单独类,只是碰巧共享相同的实现!
很多时候这正是您想要的!匿名行多态记录是一个流行的功能是有原因的,多态变体解决了上面名义类型部分的所有问题。
但它们也有其缺点。其一,结构类型使数据抽象变得不可能,因为它们总是暴露其整个定义:这就是重点!它们对于错误消息也很糟糕,因为编译器通常根本不知道您实际上并不希望它将您的Person
类型拼写为具有 27 个字段的记录。
一个稍微微妙的问题是,名义类型提供的接口在技术上通常不太准确。如果您的函数采用Player
,您可能只希望它对实际有效玩家的记录有效,但如果您将Player
表示为结构记录,它将接受任何具有id
、 name
和health
字段(其中Enemy
也可能碰巧包含其中!)
因此,绝大多数编程语言主要坚持名义类型,有时为结构记录或变体提供非常有限的单独支持。
然而,对于Polaris ,我采用了稍微不同的方法。
¿Por Qué No Los Dos?
如果结构类型非常适合指定和操作类型的定义,而名义类型非常适合将类型限制为其名称,那么我们为什么不只采用这些方面并将它们组合起来呢?
在 Polaris 中,所有记录和变体都是行多态、结构记录和多态变体,而名义类型是通过轻量级新类型包装器实现的。
这意味着所有名义变体和记录实际上只是结构变体或记录的名义包装。
例如,二叉树可以实现为
data Tree(a) = < Empty , Branch({ left : Tree(a), value : a, right : Tree(a) }) >
这里data
定义了一个新类型(与定义类型别名的type
相反),定义右侧的语法指定了带有Empty
和Branch
构造函数的多态变体的类型,其中Branch
构造函数本身接受一个参数匿名的结构记录类型。
现在,因为这是一个名义上的新类型,所以它的构造函数可以隐藏到外部以使类型抽象,错误消息只会提到Tree(a)
并且如果其他人要定义自己的 Tree 类型,他们不会意外地通过它适用于您的任何函数,即使它们使用完全相同的定义。 Tree
类型的值的行为本质上就像Tree
是 Haskell 或 OCaml 中的名义变体一样。
也就是说,直到新类型被解开。那时,它揭示了其潜在的结构变体,现在突然免费获得结构类型的所有好处。
这样做的一个优点是,如果您确实希望能够传递其他人写入您的函数的 Tree 类型,您不需要任何混乱的转换函数!因为它们具有相同的(公开的)实现,所以您需要做的就是换出新类型。
let convertTree : forall a. TheirTree(a) -> Tree(a) let convertTree(theirTree) = Tree(theirTree!)
(Polaris 使用后缀!
来表示“展开表达式”,这使得展开基本上不会造成干扰。)
但这还不是全部。由于结构类型不依赖于单一名称,因此可以比名义类型更灵活地操作。
为了证明这一点,让我们看看另一种语言:Gleam!✨
Gleam 团队最近发布了一项很酷的新功能,他们称之为“变体推断”。这意味着在匹配变体时,Gleam 将跟踪哪些变体已经用尽以及哪些变体已与当前案例匹配,以缩小用于记录更新或访问器的构造函数的类型。 2
这是一个很棒的功能,是 Gleam 实现者在其语言的用户体验方面付出大量努力的结果。
但如果我告诉你 Polaris 在 Gleam 之前就已经支持这个了呢?甚至更好的是:由于 Polaris 类型系统的工作方式,它的实现比 Gleam 的更强大、更简单、更一致!
Polaris 中的变体匹配会在每次匹配后细化审查者的类型,以便在下一个案例中不会出现耗尽的分支。
例如,如果我们像这样在之前的Tree
进行模式匹配
let f : Tree(String) -> () let f(tree) = match tree! { Empty -> () rest -> ... }
rest
类型仅为< Branch({ left : Tree(String), value : String, right : Tree(String) }) >
因为 Empty 情况已被处理。
这比 Gleam 的方法更强大,因为这些信息不仅仅是编译器跟踪以使覆盖率检查器更智能的东西;它还包括编译器跟踪的信息。它实际上是rest
类型的一部分,甚至可以跨函数边界持续存在!
它也更简单,因为没有其他需要注意这一点:调整类型后,结果仍然只是一个常规的(尽管稍小)多态变体类型。
现在,几乎所有这一切都可以在 TypeScript 中实现。然而,与 TypeScript 不同的是,Polaris 在没有放弃名义类型的任何好处的情况下实现了这一目标。
-
ocamllsp 做得很好↩
-
Haskell 的“Lower Your Guards”覆盖检查器支持类似的功能,尽管它在那里几乎没有那么有用,因为记录访问器无论如何都可以(不幸地)对具有多个构造函数的记录进行操作,因此这仅与对同一值进行模式匹配两次的程序相关↩
原文: https://welltypedwitch.bearblog.dev/nominal-for-storing-structural-for-manipulating/