我一直在为我当前的爱好语言研究静态类型系统。为了尽量保持语言尽可能简单,我试图看看我是否可以在没有子类型的情况下生活。由于我的大部分编程经验都是面向对象的语言,因此我一直在更多地了解缺乏(或至少声称缺乏)子程序的语言,以了解它们是如何工作的。
对我来说最有趣的是 Go,因为作者说它没有子类型,但是当你查看接口时,它似乎确实有一些非常接近子类型的东西。是只是用另一个名称进行子类型化,还是真的发生了不同的事情?
这篇文章是我所能告诉的这个问题的最佳答案。简短的回答是,不,Go 没有子类型。但同时,是的,确实如此。
什么是子类型化?
如果您正在阅读我的博客,您可能已经知道什么是子类型,但让我们确保我们都从同一个地方开始。子类型定义了两种类型之间的关系。给定两种类型 A 和 B,B 可能是 A 的子类型,也可能不是。
由于子类型是一对类型之间的关系,因此它仅在涉及两种类型的语言中发挥作用。主要的地方就是作业。您有一个类型为 A 的变量,并且将类型 B 的表达式的结果分配给它。该任务允许吗?
编程语言人员通常将“赋值”概括为指为变量赋予某些值的任何位置。这包括赋值表达式,还包括初始化变量声明和函数调用,其中参数值绑定到函数顶部的参数。
还有一些其他地方会发挥子类型的作用,通常围绕类型推断,但赋值是主要的:您有一个上下文,需要某种类型 A 和某种类型 B 的值。类型 A 和 B 是什么,其中该代码有效吗?
这个问题是类型检查器工作的核心。静态系统的主要用户界面是编译错误,最常见的编译错误是“我期望这种类型的值,但你给了我一个值或其他类型”。
为什么要有子类型?
您有一个需要类型 A 的上下文,并且给它一个类型 B 的值。在没有子类型的语言中,只有当 A 和 B 是完全相同的类型时才可以。在 SML 中,如果您声明一个int
类型的变量,则唯一可以使用int
类型的值来初始化它。子类型的存在主要是为了放松这种限制——允许多种不同的类型流入某个上下文。为什么一种语言会允许这样做?
原因是多态性:子类型允许您编写一段代码并使用一系列不同(但相关)的类型重用相同的代码。在没有子类型的语言中,您经常会发现自己复制/粘贴相同的函数来处理多种不同的输入类型。 (泛型可以提供帮助,但这是另一种形式的多态性,我们将在本文中忽略它。)
比如说,在 Java 中,如果您定义一个采用Iterable
方法,那么您可以向它传递一个List
、一个Stack
等。您可以在实现Iterable
接口的所有类型中分摊该方法的有用性。子类型化是代码的力量倍增器。
(当然,就语言复杂性而言,这种好处并非没有巨大成本,这就是我希望避免它的原因。)
Go 有子类型吗?
如果你在(写得非常好!) Go 语言规范中搜索“subtype”,你会得到零结果。因此,从文本层面来看,答案是明确的“不”。
Java 确实有子类型。现在,如果您要创建一种名为“Blava”的新语言,它是 Java 语言规范的文字复制/粘贴,并将每次使用的“子类型”替换为“blubtype”,您会说 Blava 具有子类型吗?它的行为与具有子类型的语言没有什么区别,所以我倾向于说是。
Go 规范没有提到“子类型”,但它确实有“可分配性”的概念。当您有一个需要某种类型的上下文并且给它一个其他类型的值时,可分配性决定了允许哪一组其他类型。具体来说,规则是:
如果 T 实现 I,则非接口类型 T 可以分配给接口类型 I。
如果 A 的方法是 B 的子集,则接口类型 A 可分配给接口类型 B。
你知道,这听起来很像子类型。 “可分配给”只是 Rob Pike 所说的“子类型”的特殊方式吗?除了名称之外,Go 是否对所有内容都进行了子类型化?为了完全回答这个问题,我们需要查看程序中的所有类型。
复合类型和方差
如果 Go 类型系统中唯一的类型是像数字、结构体和接口这样的原语,那么我认为你有一个很好的论据,Go 确实有子类型,只是拼写不同。但是,一旦您开始研究切片类型和函数类型,情况就会发生变化。 (还有数组和通道类型,但切片和函数足以说明这一点。)
后一种类型的共同点是它们包含其他类型。切片类型具有切片元素的内部类型。函数类型具有参数类型列表和返回类型列表。
您准备好了解更多计算机科学术语了吗?我们一直在讨论类型对上的关系,例如“是子类型”和“可分配”。但现在我们有包含其他类型的类型。这就提出了一个问题:两个复合类型的内部类型上的关系是否说明了两个外部类型之间的关系。
例如,假设我们有两个切片类型[]E1
和[]E2
,它们分别是E1
和E2
元素的切片。如果E1
可分配给E2
是否意味着[]E1
可分配给[]E2
?可分配性是否从内部类型“传播”到外部类型?
计算机科学家将这种属性(元属性?)称为方差。他们将问题表述为“切片类型的可分配性如何随其元素类型而变化?”。对于这样的问题,有几种可能的答案。
切片类型的差异
特别是对于 Go 中的切片类型,有一些可分配性规则,但唯一适用于切片类型的是:
V 和 T 相同。
换句话说,要使两个切片类型可分配,它们必须是完全相同的类型。这反过来意味着它们必须具有完全相同的元素类型。即使两种元素类型是可分配的,这两种类型的切片也不能分配。
从 StackOverflow 上无数困惑的人提出问题来看,无论是 Go 还是其他语言,这种行为对于程序员来说都是不直观的。假设您有这个 Go 程序:
type Dog struct { name string } type Barker interface { Bark () } func ( d Dog ) Bark () { fmt . Println ( "Woof!" ) }
这里我们有一个Dog
具体类型,它可以分配给接口Barker
。所以这很好:
func speak ( barker Barker ) { barker . Bark () } func main () { speak ( Dog { "Sparky" }) }
鉴于此,您可能希望这也能起作用:
func speakAll ( barkers [] Barker ) { for _ , barker : = range barkers { barker . Bark () } } func main () { dogs : = [] Dog { Dog { "Sparky" }, Dog { "Fido" }} speakAll ( dogs ) }
但不是。类型系统给予和类型系统带走:
example.go:29:11: cannot use dogs (variable of type []Dog) as []Barker value in argument to speakAll
如果类型系统没有对你大喊大叫,这个程序在运行时会很好。那么到底是怎么回事呢?在这种情况下,程序恰好很好,因为speakAll()
仅从切片中读取。但如果我们写:
type Tree struct { species string } func ( t Tree ) Bark () { fmt . Println ( "Rough (but not ruff)!" ) } func appendTree ( barkers [] Barker ) [] Barker { return append ( barkers , Tree { "Elm" }) }
这个appendTree()
函数没有任何问题。它将一Tree
添加到给定的切片中。由于Tree
可分配给Barker
,所以没问题。但如果你调用它并传入一个[]Dog
,你最终会得到一组被一棵树卡住的狗!这会违反语言的健全性。
这就是为什么 Go 仅将两个切片类型视为可分配的(如果它们具有完全相同的元素类型)。用 PL 术语来说,切片类型相对于其元素类型而言是不变的。而且,对于像切片这样的可变数据结构,该规则是有意义的。
(一个理智的人可能想知道为什么 Java 和 C#没有这个规则,而是说数组类型是可分配的,如果它们的元素类型是可分配的。然后,因为正如你所看到的,这样做是不安全的,他们如果您尝试将错误类型的元素填充到数组中,则必须添加运行时检查。)
所以,好吧,切片(和数组)类型保持不变是有意义的。那么函数类型呢?
函数类型的变化
为了简单起见,首先我们将只考虑不带任何参数且具有单一返回类型的函数。给定这样的两个函数类型,它们什么时候可以赋值?同样,Go 语言规范中匹配函数类型的唯一规则是V and T are identical
。因此,只有当两个函数类型具有完全相同的返回类型时,它们才是可赋值的。即使返回类型本身是可分配的,如果它们是不同的类型,则函数也是不可分配的。
我们需要那么严格才能保持健全吗?事实上,不!这是一个例子:
func returnDog () Dog { return Dog { "Rex" } } func useCallback ( callback func () Barker ) { barker : = callback () barker . Bark () } func main () { useCallback ( returnDog ) }
因此,我们有一个函数returnDog
,它返回Dog
类型的值。我们传递对该函数的引用,该函数需要一个返回Barker
函数。 Dog
类型确实实现了Barker
。如果运行这个程序,那就绝对安全了。而且,事实上,您可以在useCallback()
中放入任何东西,以免传递returnDog
违反类型系统的健全性。
理论上它是安全的……但 Go 不允许这样做:
./prog.go:49:14: cannot use returnDog (value of type func() Dog) as func() Barker value in argument to useCallback
我所知道的所有其他具有子类型和函数类型的语言都允许这样做。如果A
的返回类型是B
的返回类型的子类型,则函数类型A
是另一个函数类型B
的子类型。因此,返回类型的子类型关系传播出去以确定函数类型的子类型关系。我们称此为协变,并称函数类型在其返回类型上是协变的。 “co-”前缀意味着内部类型之间的子类型关系与它暗示的外部类型的子类型关系处于“同一方向”。
这个方向很重要,因为子类型和可分配性等关系不是对称的。 Dog
类型可分配给Barker
,但Barker
不可分配给Dog
。潜在的价值可能是一Tree
!
是否存在内部类型的方差与外部类型的方差方向不同的情况?确实有,而且他们就在我们旁边。让我们看看参数类型,而不是返回类型。现在假设我们只关心接受单个参数且不返回任何内容的函数。这是一个例子:
func acceptBarker ( barker Barker ) { barker . Bark () } func useCallback ( callback func ( Dog )) { callback ( Dog { "Laika" }) } func main () { useCallback ( acceptBarker ) }
请注意,与返回类型示例相比,参数类型被翻转。这里, useCallback()
中的回调类型采用更精确的Dog
类型。我们传递给它的函数acceptBarker
有一个类型为Barker
的参数。
在这里你可能会感到有点迷失方向。代码感觉很奇怪并且有点倒退。等一下,头晕就会过去。茶苯海明可能有帮助。
虽然这绝对不如返回类型协变那么直观,但如果您仔细考虑一下,您会发现上面的程序是完全正确的。在其他具有子类型的语言中,如果A
的参数类型是B
的参数类型的子类型,则函数类型B
是函数类型A
的子类型。请注意该句子后半部分的A
和B
是如何颠倒的。参数类型的方差是相反的。用技术术语来说,我们说函数类型在其参数类型上是逆变的。前缀“contra-”的意思是“反对”。
(您可能想知道当您有一个带有参数的函数类型时会发生什么,而该参数的类型本身就是带有某种参数类型的函数类型。那是如何流出的?当有两层嵌套时,它会翻转到与最外层类型。我的想法是逆变是关系方向的 180° 翻转。如果嵌套逆变类型,则翻转两次并返回到原始方向。)
逆变参数类型是合理的,但 Go 再次不允许它们。仅当两个函数类型的参数类型完全相同时才可进行赋值。
Go 中的不变性
在我所知道的带有子类型的每种语言中,函数类型的返回类型是协变的,而参数类型是逆变的。但在 Go 中,函数类型是不变的。
Go 并不是一种以妨碍程序员做某事而闻名的语言,那么为什么函数类型的限制比健全性所需的限制更多呢?
它也不仅仅是函数类型。所有复合类型在 Go 中都是不变的:数组、切片、通道、映射、函数。因此,基本类型(不包含任何其他类型的类型)具有一些类似子类型的可分配性概念。但是,一旦将一种类型包装在另一种类型中,任何可分配性的概念都会消失。
Go 的设计者为什么这么做?如果您打算麻烦地拥有接口和可分配性,为什么不一直为函数和其他复合类型提供可分配性呢?
如果设计者关心的只是语义正确性和用 LaTeX 编写的漂亮优雅的规范,那么他们可能会支持方差,至少对于函数来说是这样。 (其他类型都应该是不变的,因为它们是可变的。当类型可以流入和流出时,任何其他变化都是不合理的。)
但 Go 从一开始就被设计为一种高性能系统语言。它与为校样和出版物设计的象牙塔语言完全相反。该语言的目标是让真实的用户发布真实的应用程序。而且,重要的是,能够快速交付应用程序并推断其代码的性能。
代表价值观
到目前为止,我们只关心类型在编译时如何流经类型检查器。但是,假设没有编译错误,编译器最终会输出一些在运行时执行的代码。当这种情况发生时,类型检查器检查的所有类型都已破茧而出,并以美丽的运行时值蝴蝶的形式在内存中飞来飞去。
选择如何在内存中表示不同类型的值对性能有很大影响。那么,有关可分配性和子类型的规则如何与这些表示选择相互作用呢?
在许多面向对象语言(Java、C#、Python 等)中,对象类型的值由指向堆分配结构的指针表示。该结构具有一些用于垃圾收集和运行时类型跟踪的标头信息,可能是某种指向用于虚拟方法分派的vtable 的指针,然后(最后!)用于存储实例字段的内存。
当然,语言实现之间存在差异,但对象通常都是:
-
创建速度很慢,因为它们是在堆上分配的。
-
相当大,为每个对象存储了一些额外的簿记信息。
-
间接,类型为对象的变量或字段仅保存指向该对象的指针,该指针始终位于堆上。访问对象上的状态总是需要指针间接寻址,由于局部性差和缓存未命中,这可能会很慢。
结构类型
对于像 Go 这样的系统语言来说,这些成本是不可接受的。当然,当你想要运行时多态性时,你必须以某种方式为此付出代价。但如果你只是将数据存储在内存中,Go 不想让你为不使用的东西付费。为此,Go 中结构类型的值仅存储结构自身字段所需的字节。
如果结构体的字段本身就是某种结构体类型,则内部结构体的字段将直接放入周围结构体的连续内存中。如果您有结构类型的局部变量,则字段将直接存储在堆栈上(除非您使用指向转义函数的结构的指针)。
这减少了结构的内存开销,并且(可能对性能更重要)减少了指针间接。在典型的 Java 程序中,堆最终会成为一个巨大的蜘蛛网,其中的微小对象都相互指向,而可怜的 CPU 则像一只筋疲力尽的蜘蛛,在该网上四处游荡,试图找到它想要吃掉的实际数据位。
在典型的 Go 程序中,更多的状态直接存储在堆栈上,并且堆“更块”,内存块更少、更大。 CPU 在堆上进行的跳跃次数更少,并且每次都会处理更大的数据块。这使得内存访问更加缓存友好,并且还减轻了垃圾收集器的负载,因为需要遍历的单独分配更少。
(Java 对基本类型做了类似的事情,C# 对结构类型也做了类似的事情。)
接口类型
所以结构很快,很棒。但 Go 确实以接口的形式提供了运行时多态性。如果一个值直接内联存储而没有额外的数据来跟踪其运行时类型或方法实现,那么接口方法分派如何工作?
答案是接口具有完全不同的运行时表示。接口类型的变量占用两个字:
-
指向用于接口方法的运行时分派的类型信息的指针(换句话说,基本上是一个vtable )。
-
指向实现接口的具体类型所使用的实际数据的指针。 (如果数据只是一个单词,我认为它是内联存储的。)
这种表示形式的可爱行业术语是“胖指针”:它不是带有单个指针的单个单词,而是一对指针,一个用于数据,一个用于某种元数据或簿记信息。
Go 真正很酷的事情之一是,您只在需要时使用这种表示形式——您只需为这种表示形式增加的内存和间接成本付费。在需要虚拟调度的地方,您可以使用接口类型并接受胖指针和间接寻址的开销。但是,在您只想存储单个具体类型的地方,您可以使用其基础类型,并且内存直接内联存储。
C# 支持类和结构的类似区别。但这主要是一个“声明时间”的选择。一旦你确定某个东西是一个类,该类类型的每个变量都会将其存储为对堆分配对象的引用。相反,如果您已将某些内容声明为结构体,则它将始终内联存储在堆栈或包含对象中(除非您特意将其装箱)。
在 Go 中,内联存储与间接存储之间的区别是在每个使用站点进行的。这给用户带来了一些额外的复杂性:他们总是必须思考“我应该在此处使用接口、指针还是结构类型吗?”,但这使他们能够更细粒度地控制如何使用内存和指针间接成本。
隐式转换
我们即将理解为什么 Go 允许您将一个结构体分配给一个接口,但不能将这些相同结构体的一部分分配给同一接口的一部分。
如果结构和接口具有完全不同的内存表示形式,那么可分配性到底如何工作?当你这样做时:
type Dog struct { name string } type Barker interface { Bark () } func main () { var barker Barker = Dog { "Rex" } }
当它尝试将结构的内存表示视为接口时,难道不应该破坏内存吗?答案当然是否定的。编译代码时,Go 知道每个变量和每个表达式的类型。在每次赋值、变量声明或参数绑定时,如果该值不可分配给目标,它会报告错误。
当值是可分配的时,它仍然知道这些类型是否相同。如果它们完全相同,则可以将赋值编译为单个寄存器移动或内存副本。当它们不同但仍可分配类型时,编译器会默默地插入代码以将值类型的内存表示形式转换为目标类型的表示形式。
当您将结构类型的值分配给接口类型时,编译器会插入代码来构建胖指针,将其方法表指针连接到正确的接口实现,将结构的数据移动到堆上,等等。
同样,如果将一种接口类型分配给另一种接口类型,编译器会插入代码来复制数据指针,但会在给定值的接口类型的类型信息的情况下查找目标接口的正确方法表。
这就是 Go 中所有复合类型都是不变的原因。当将单个值分配给相关但不同的类型时,编译器可以轻松插入固定成本代码,以将该值的运行时表示形式转换为目标类型的表示形式。但是要将某种结构类型的切片转换为接口类型的切片将需要O(n)
遍历整个切片来转换每个元素。
函数类型就更难了。为了支持协变返回类型和逆变参数类型,编译器需要在某处插入转换代码,但没有合适的位置放置它。将它放在函数本身中是行不通的,因为它可能会使用各种不同的参数类型来调用,并且我们不知道要从什么转换它。在传递参数之前将其放在调用站点同样行不通,因为我们不知道每个回调可能需要什么类型。
通过支持每对源类型和目标类型的函数的多个入口点,您可能可以做一些聪明的事情,但是使用多个参数,您很快就会面临代码大小指数级爆炸的风险。
这就是为什么支持子类型和方差的语言几乎总是对参与子类型层次结构的所有对象具有统一的内存表示形式。
Go 有子类型吗?
如果你做到了这一步,恭喜你。这最终比我预期的要深入得多。我在探索语言空间的这个角落学到了很多东西,我希望你也能学到一些东西。
回到最初的问题,我认为我们可以用两种等效的方式准确地描述 Go 的子类型故事:
-
是的,Go 有子类型,但它不支持方差,并且所有复合类型都是不变的。我认为,这就是那些只关注语言抽象语义的人会如何描述它。如果您正在撰写有关类型系统的论文并且需要对 Go 进行建模,您可能会采用这种观点。如果您不关心如何有效地实现 Go,因为您纯粹将其视为抽象,那么这是查看它并将其与其他语言进行比较的好方法。
以这种方式看待语言的主要问题是它掩盖了为什么每个复合类型都是不变的。
-
不,Go 没有子类型,但它确实在某些类型对之间具有隐式转换。这就是 Go 的设计者对这门语言的描述。如果您的任务是坐下来编写该语言的生产质量实现,那么这就是您希望查看该语言的方式。它描述了该语言在编译时和运行时实际执行的操作。
我从这个角度发现的挑战是,它让我更难将 Go 的设计选择与其他更明确的面向对象语言联系起来。您可以将整篇长文视为我试图找出第一种解释的过程。
我开始深入研究这个问题并不是因为我是一个活跃的 Go 用户并且想知道幕后发生了什么。我的工作和爱好是设计编程语言,所以我想了解其他语言是如何工作的,看看有什么好的想法可以收获。
因此,此时我脑海中始终萦绕着一个问题:“他们为什么要这样设计?这种选择在其他语言中是否有意义?”对于这个特定的设计选择,我认为它非常酷。你可以想象一种语言需要三件事:
-
非统一表示:内存中的值仅占用所需的空间,并尽可能避免指针间接,以最大限度地提高运行时效率。
-
多态性:重用代码来处理一系列不同类型的值的能力。
-
变体:多态性的“提升”形式:重用代码以处理包含一系列内部类型的复合类型的能力。
拥有这些功能固然很好,但同时获得这三个功能确实很难。大多数面向对象语言都会牺牲第一个语言来获得另外两个语言。这为您提供了灵活性和表现力,但运行时成本普遍分布在整个程序中。
一些更简单的静态类型语言(例如 C、Pascal 和 SML)放弃了多态性和方差,这可以为您提供更有效的表示,但代价是减少代码重用。
像 C++ 和 Rust 这样的语言或多或少地为您提供了这三种语言,但代价是编译器单态化并生成可以处理多种类型的每个函数的专用版本,这使得编译速度慢得多,并且可能会因所有额外代码而产生一些运行时成本坐在记忆里。
Go 的目标是找到一个最佳点,为您提供快速编译、高效的运行时执行以及尽可能多的灵活性。它牺牲了方差,但将多态性保留在个体值级别。与隐式转换相结合可以实现非统一表示。在这三者中,方差对于用户来说可能是最没有价值的,所以我认为这是一个非常明智的权衡。
原文: http://journal.stuffwithstuff.com/2023/10/19/does-go-have-subtyping/