问题引入
不知道大家跟我有没有同样的疑惑,Go 语言中的两种声明类型的方式有什么不同?我们为什么不用第二种方式来声明结构体呢?
type A struct {n ints string
}type B = struct {n ints string
}
这篇文章,我们尝试来解答一下这个问题。
背景
首先,第一种类型声明方式叫作 type definition 类型定义;第二种类型声明方式叫作 alias declaration 类型别名 —— Go 1.9 引入。这两种方式是 Go 唯二支持的类型声明的方式。
既然类型别名晚于类型定义(第二种晚于第一种)发布,也就是说 Go 的开发团队是为了解决一些问题才提出的类型别名。
那么在解答问题之前,我们先探究一下类型别名提出的目的是什么?要解决的问题是什么?为什么类型定义方式无法解决这个问题?
答案就在 Go 关于重构代码的官方博客中。如果你的英文阅读能力还不错,建议读一下原文,保证你受益匪浅;否则请听我娓娓道来。
在多年的开发过程中官方和第三方的开发者经过大量的实践发现了一些软件包(package)设计的问题。比如,早期常量 EOF 是放在 os 包中,但是 os 包很大每次引入代价难以忽略;其次,早期 Error 类型和构造函数(constructor)NewError 都是放在 os 包里的,但是 Error 是一个错误处理时常用到的类型,其他一些标准库都会用到,这样就会导致这些标准库不能用 os 包,也就是产生了循环引用。
为此,Go 开发团队采取的重构方式是把 EOF 放到了 io 包中,新定义一个 errors 包,专门用来放错误处理相关的逻辑。这些改动本质上是把某个 API(函数、类型或者函数)移动到一个新的包中。
要想做重构,理想情况下直接删掉原 API,并在新的包中定义新 API —— Atomic code repair。但是,Go 1.8 时 Github 的 Go 语言仓库达 16 万个(现在是 41 万个)。如果如上面所说这般简单粗暴的修改,势必导致部分代码无法运行。而且这样的一次修改涉及两个内容的改动,无疑增加了审核代码的成本。一个可行的方案是,保留原 API 并定义新 API,让两者共存并且可以等价互换。然后随着时间推移逐渐废弃掉原 API。这种方案 Go 官方称为『Gradual code repair』。这种方案的优点显而易见,首先之前版本代码没有受到影响,而且改动要远小于上面的方案,同时两种 API 等价互换让开发者可以在后续开发软件的过程中逐渐舍弃原 API 。
现在问题就变成怎样让 API 等价可交换了。Go 语言中 API 一般有常量、函数、变量和类型四种情况。下面结合官方博客的例子逐一讲解每种情况。
常量
// new
package io
const SeekStart int = 0// old
package os
const SEEK_Set int = 0
常量应该最容易想到,只需确保常量值和类型相同即可。
函数
package errors
func New(msg string) error { ... }package os
func NewError(msg string) os.Error {return errors.New(msg)
}
要让两个函数等价只需保证函数的签名和实现逻辑相同即可。最简单的方法就是让旧函数直接调用新函数。因为 Go 不支持函数类型间比较,也就无从谈起函数的是否相等。
变量
package io
var EOF = ...package os
var EOF = io.EOF
变量跟常量差不多,直接把新值赋值给旧值即可。这里唯一的问题是两者地址不同,但是需要比较两个只读变量地址的情况几乎不会遇到,可以忽略不计。为什么常量没有这个问题?因为 Go 不允许对常量取地址。
类型
这是本文的重点,也是类型别名提出的主要目的。请认真读下去。
我们要做的改动是把一个类型从一个包中移到另一个包中,并且要让两个类型等价可交换。
比如下面 k8s 的例子:
package util
type IntOrString intstr.IntOrString// Not good enough for:// IngressBackend describes ...
type IngressBackend struct {ServiceName string `json:"serviceName"`ServicePort intstr.IntOrString `json:"servicePort"`
}
我们通过类型定义了新类型 IntOrString ,但是结构体 IngressBackend 中 ServicePort 字段类型仍是旧类型 instr.IntOrString。我们无法把 IntOrString 类型直接赋值给 ServicePort 字段。也就是说这里无法作为保持原 API 的情况下做出新 API。
这是因为 Go 默认会把两个具名类型(named type) 视为不同的类型(这里你可以简单地把具名类型看作是预定义的类型,如 int32、float64等,和通过类型定义声明的类型)。换句话说,两者本质上是不同的类型。在做类型断言(type assertion)时,会出现不等价的情况。
既然语法层面没有解决办法,Go 开发团队还曾试想过在编译器内做手脚,让它在编译时转换。显然修改编译器的方法无法推广到第三方,第三方的开发者仍旧面临同样的问题。
既然无法让两个具名类型等价可交换,Go 团队就引入了 Pascal 和 Rust 的类型别名概念,语法结构如下:
type OldAPI = NewPackage.API
既然叫类型别名,也就是说新 API 和旧 API 互为别名,本质是一种类型。
这也是类型别名跟类型定义的本质区别。类型定义定义一个新的类型。在其中定义的方法独立于源类型。类型别名定义一个类型的别名,与原类型是一个类型,两者等价可以相互赋值。其中定义的方法等价于定义在原类型的方法。
分析
- 类型别名提出的目的是让代码重构类型时更简单可行,让新旧类型等价可以无缝交换(实际为一种类型)。除重构外,如果遇到类型特别冗长的情况,我们可以用一下类型别名缩短类型名称的长度,提高代码可读性,方便外部程序调用。
- 解决的问题是类型定义无法声明等价于原类型的新类型。
- 因为类型定义本质上是定义一个新的类型,尽管新类型和旧类型可以互相转换,但是仍旧是两个不同的类型。
回到最初的问题,在声明新类型的时候我们为什么不用第二种方式?
首先,第二种方式可以帮助我们省去写未命名结构体的麻烦,直接用声明的别名即可。
其次,类型别名本质没有声明新的类型,因此只是未命名结构体的一个别名。又因为 Go 不允许给不在当前包中类型定义方法。这里的的未命名结构体不属于当前包,因此我们无法给声明的类型定义方法,而对声明的类型定义方法往往是我们定义新类型的目的之一。
总结
当我们需要重构代码(将某个 API 移到一个新的包中),或是为一个冗长的 API 写一个短一些的名字时,我们可以考虑用类型别名(type T = N);如果我们需要定义一个新类型,并且需要定义一些方法时,就用类型定义(type T N)。