Go语言的类型系统

2022-10-21 ⏳22.9分钟(9.2千字) g

本文面试初学者,尝试用整体的观点统一介绍Go语言的类型系统。为方便读者理解,我将其分成简单类型、容器类型和自定义类型三大类。在此基础上又引申出指针、接口和泛型三个高级主题。本文并非要取代传统的学习材料和官方文档,其目标在于提供一份更加易用的入门材料。

Go 语言所有变量都需要指定类型。声明语法如下:

var a int

var 开头表示定义新变量,a是变量名,int是变量类型。Go 的变量类型需要放在变量的后面,这跟 C 家族语言不同。大家记住就行,我们后面会讲这种设计的好处。

如果想声明多个相同类型的变量,可以写成这样:

var a, b, c int

如果想声明多个不同类型的变量,可以写成一组:

var (
  a, b int
  c string
)

这里的string表示字符串类型,后面我们会详细介绍。

在声明的时候可以指定变量值,比如:

var a int = 1
var b string = "hello"

如果不指定,Go 语言默认会把变量都设置为零值。不同类型的变量对应的零值不一样,后面会细说。

Go 还支持声明常量,但常量必须在声明的时候指定取值,因为一旦声明就无法修改:

const a int = 1

常量也可以分组定义:

const (
  a int = 1
  b string = "foo"
)

Go语言不支持枚举类型。需要枚举的时候通常会定义多个连续的常量,比如:

const (
  CountryCN int = 0
  CountryUS int = 1
  CountryJP int = 2
  CountryKR int = 3
)

这种连续的常量可以简化为:

const (
  CountryCN = iota(0)
  CountryUS
  CountryJP
  CountryKR
)

iota(0)里面的0表示常量的初始值。如果从零开始可以直接简写为iota。

最后说一下类型推导。我们声明变量的时候使用var关键字,变量名后面需要指定类型。一个典型的赋值语句如下:

var a int = 1
var b int = a

第二句将a的值赋给b,显然b跟a是同一个类型,但在声明变量b的时候还是要指定类型为int,这有点多此一举了。

为了方便程序员,Go语言支持在赋值的时候自动确定变量的类型,也就是所谓的推导:

var a int = 1
b := a

大家注意变量b的声明方式,使用的是:=,一口气完成声明和赋值。编译器会自动根据变量a的类型设置变量b的类型。

其实var a int = 1也可以进一步简化成a := 1。但这一种类型推导比较独特,因为右边是字面量1,它到底是什么类型呢?

如果被赋值的变量有类型,那么字面量1会自动转化为对应的类型。比如var a int8 = 1中的1对应类型是int8。 如果被赋值的变量没有指定类型,那么字面量1会转换成整数的默认类型,也就是int。

以上是变量声明的基本语法。接下来我们详细介绍 Go 语言的各种变量类型。

我把类型分成了三类,分别是:

  1. 简单类型
  2. 容器类型
  3. 自定义类型

简单类型

简单类型又分为布尔、数字和字符串三大类。

布尔类型最简单,声明的时候写成var b bool,取值只有true和false两种。

布尔值的零值是false。

数字类又分成了整数、浮点数、复数和特殊整数类型。

有符号整数类型有:

int8    8-位整数 (-128 to 127)
int16  16-位整数 (-32768 to 32767)
int32  32-位整数 (-2147483648 to 2147483647)
int64  64-位整数 (-9223372036854775808 to 9223372036854775807)

对应的无符号版本为:

uint8   8-位无符号整数 (0 to 255)
uint16 16-位无符号整数 (0 to 65535)
uint32 32-位无符号整数 (0 to 4294967295)
uint64 64-位无符号整数 (0 to 18446744073709551615)

浮点数有两种精度:

float32     IEEE-754 32-位浮点数
float64     IEEE-754 64-位浮点数

复数有两种,但一般的服务端开发很少用到:

complex64   实部和虚部为32-位浮点数
complex128  实部和虚部为64-位浮点数

此外,Go 语言中还有两个特殊的整数类型:uint和int。它们的长度在不同的设备上会有变化。如果是32位设备,它们分别表示uint32和int32;如果是64位设备,它们分别表示uint64和int64。因为有这种不确定性,从可读性方面考虑,还是不建议使用。

最后一个特殊整数类型是uintptr,主要用来保存指针变量。这是比较进阶的内容,我会后面细说。

数字类型都支持各类算术和比较运算。整数类型还支持各类位运算。不同类型之间可以强制转换,但可能会损失精度。比如:

var a int32
var b int64

a = int32(b) // 有损
b = int64(a) // 无损

数字类型的零值是0。

最后一种简单类型是字符串,我们前面已经简单提过。声明语法如下:

var s string
s = "abc" // 赋值

字符串的零值是空字符串""。字符串支持比较运算和+运算,例如:

"a" == "b" // 结果为 false
"a" >= "b" // 结果为 true
"a" + "b"  // 结果为 "ab"

字符串还支持获取子串等专门操作,比如:

var a string = "abcde"
len(a) // 返回长度,结果为 5
a[0]   // 返回第一字符,结果为 'a'

a[len(a)-1] // 返回最后一个字符,结果为 'e'
a[1:3] // 返回由下标为[1,3)范围的字符组成的字符串 "bcd"

我们日常编程还需要对字符串做各种加工,比如查找字串、分拆、大小写转换、反转等等,这些功能都被写成标准函数,保存到strings这个标准包。另外一个常用的操作是字符串和数字之间相互转换,这部分函数被组织到strconv包。

以上就是 Go 语言的基础类型。但光有这些还不足以表达复杂的业务逻辑。为此,Go 原生提供了复合类型。

容器类型

内置的容器类型有三种,分别是数组(array)、切片(slice)和字典(map)。

数组(array)

数组的声明语法如下:

var a [10]int

[10]表示数组有10个元素,后面的int表示每个元素的类型为整数。编译器遇到这个声明后会分配一段连续内存,用于保存10个整数。

数组跟字符串一样,可以通过下标读取每一个元素的内容。但字符串无法修改,数组可以修改。

var a[10]int
a[0] = 1
a[1] = 2
fmt.Println(a[1]) // 输出 1

我们也可以在声明数组的时候直接初始化每个元素的内容:

var b = [3]int{1,2,3}

a[0] == 1
a[1] == 2

因为右边已经有完整的类型信息,左边的类型可以省略。

数组的元素可以是任意类型,甚至也可以是数组。于是我们就有了多维数组:

var a[10][10]int
a[1][2] = 10

上面的a是一个数组,有10个元素。a[1]表示数组的第二个元素,其类型还是一个数组(内层数组),而且有10个元素。所以a[1][2]就表示内层数组的第三个元素,它的类型是int。所以,a的每一个元素,都是一个长度为10的int数组,所以可以把a看成是一个10x10的二维数组。

我们可以根据需要声明任意维度的数组。但不论多少维,一经声明,编译器就会将对应的内存初始化为各元素类型对应的零值。在前面的例子中,a[10][10]默认就会保存100个零。

数组跟基础类型一样,在赋值和传参的时候会复制自身保存的内容。如果数组元素比较多,这种复制就会消耗大量的资源。为了解决这个问题,Go 语言还支持切片类型。

切片(slice)

切片类型简单来说是表示一个数组的某个下标范围。举个例子:

var a = [10]int{0,1,2,3,4,5,6,7,8,9}
var b = a[2:8]

这里a是一个数组,保存10个int元素。b是一个切片,注意方括号中不需要指定元素数量。左边的a[2:8]表示数组a下标在[2,8)范围的元素,左闭右开。内存结构如下:

a   b = a[2:8]
|   |   len = 6
v   /----------\
+-+-+-+-+-+-+-+-+-+-+
|0|1|2|3|4|5|6|7|8|9|
+-+-+-+-+-+-+-+-+-+-+
     \-------------/       
         cap = 8

切片b也就是a[2:8],对应2-7这6个元素。从这个角度看,切片跟数组有点像。我们可以像数组一样访问切片的数据:

b[0] // 2 == b[0] == a[2]
b[1] // 3 == b[1] == a[3]
len(b) // 返回切片的长度,为 6

但是,切片可以扩充自己的长度。比如我们可以执行:

b = b[:len(b)+1]

这样内存结构就会变成:

a   b = a[2:9]
|   |   len = 7
v   v------------\
+-+-+-+-+-+-+-+-+-+-+
|0|1|2|3|4|5|6|7|8|9|
+-+-+-+-+-+-+-+-+-+-+
     \-------------/       
         cap = 8

切片b最后一个元素就变成了8。如果再执行b = b[:len(b)+1],最后一个元素就变成了9。如果还执行b = b[:len(b)+1],程序就会报错,因为底层内存的边界到数字9为止,再往后就越界了。为了防止越界,切片变量会记录所指向数组所属内存的边界。

所以切片是一个特殊变量,保存了三个值:

  1. 底层数组中开始的位置(下标)
  2. 从开始位置算起,切片数据的长度(len)
  3. 从开始位置算起,到底层数组结尾所有元素的个数,也叫切片的容量(cap)

Go 语言提供两个内置函数len()和cap(),分别用于获取切片的长度和容量。

同一个数组可以用不同的切片,各切片共享底层内存,比如:

var a = [5]int{1,2,3,4,5}
var b = a[1:3] // 2,3
var c = a[2:4] // 3,4

如果执行c[0] = 10,那么a[1]也会变成10,它们共用底层内存。调用的函数的时候把数组改成切片,就可以避免内存复制。

切片另一个重要使用场景是零拷贝解析数据。假设有如下字符串:

                 // 0   4    9    14  18
var buf = [19]byte("key1=val1&key2=val2")

我们希望解析出key1,val1和key2,val2,则可以使用如下代码:

var k1 = buf[:4] // 等价于 buf[0:4]
var v1 = buf[5:9]
var k2 = buf[10:14]
var v2 = buf[15:] // 等价于 buf[15:19]

这段代码最核心的逻辑是k1,v1,k2,v2与buf共享底层内存,也就是所谓的零拷贝。

到现在我们都是对已有的数组定义切片。Go语言还支持在定义切片的同时也分配底层内存。这里需要使用内置函数make:

var a = make([]int, 5)

上面的代码跟下边的代码功能完全一样:

var tmp [5]int
var a = tmp[:]

也就是说切片a长度是5,容量也是5,保存了5个零。

make函数在创建切片的时候接受还可以通过第三个参数指定容量。比如:

// make([]T, length, capacity)
var a = make([]int, 3, 5)

这段代码的等效代码如下:

var tmp [5]int
var a = tmp[:3]

切片a只涵盖了底层数组的前三个元素。因为底层数组有5个元素,所以a的长度可以扩展,但最大扩展到5。

如果我们把length参数设为零,就会得到如下代码:

var a = make([]int, 0, 5)
// 等价于
var tmp [5]int
var a = tmp[0:0]

这个时候a是一个空切片,但长度最大可以扩展到5。

最后我们说一下切片的自动扩容。我们在前面说过,切片可以根据底层数组的容量扩展自己的长度。长度最大不能超过底层数组的容量。但现实中往往不能事先确定元素个数,所以需要一种办法来动态调整调整切片长度。

比如我们可以声明如下函数:

func Extend(s []int, i) []int {
  // 这里用到了基本的分支语句,我们在下一节会详细讨论。
  // 此处表示在 s 的长度达到容量的时候,需要执行扩容操作。
  if len(s) == cap(s) {
    // 创建一个长度跟 s 相同,但容量是 s 的两倍的切片
    var s2 = make([]int, len(s), len(s)*2+1)
    // 将 s 的元素复制到新的切片
    copy(s2, s)
    s = s2
  }

  // 扩展 s 的长度
  var n = len(s)
  s = s[:n+1]
  // 保存新插入的数据
  s[n] = i
  return s
}

var i []int
i = Extend(i, 1)
i = Extend(i, 2)
i = Extend(i, 3)

fmt.Println(i) // 输出 [1,2,3]

所以切片的扩容也不复杂,无非是分配一个新的切片,将老的数据复制过来,然后就可以扩展切片的长度了。因为上述代码太常用了,而且需要对不同的切片类型重复很多相似的代码,最后 Go 语言把这个过程封装成内置函数append()。上述代码可以简化为:

var i []int
i = append(i, 1)
i = append(i, 2)
i = append(i, 3)

fmt.Println(i) // 输出 [1,2,3]

内置的append()函数支持扩展任意类型的切片,有了它就不用重复实现Extend()函数了。

以上基本涵盖了切片类型的主要内容。下面我们来学习最后一个容器类型字典。

字典(map)

字典(map)也叫映射表或者哈希表,主要用来保存键值对形式的数据。Key 和 Value 都需要指定类型。举个例子:

var users = make(map[string]string, 8)

字典类型为map[K]V,其中K是键的类型,V是值的类型。初始化字典需要使用make(map[K]V, size)函数。上述代码会创建一个字典,Key 和 Value 的类型都是字符串,字典的初始容量是8。

创建之后就可以添加数据了:

users["a"] = "张三"
users["b"] = "李四"
users["c"] = "王五"

fmt.Println(users["b"]) // 输出"李四"

如果添加的数据超过初始容量,字典会自动扩容。如果想删除数据则需要内置函数delete()

delete(users, "a")

字典还可以跟切片和其他任意类型相互组合,从而形成非常复杂的类型定义。比如:

map[string][3][]int

从左向右依次是字典,Key 为 string 值为数组且长度为三。数组的元素为切片,切片元素的类型为int。看一遍就可以确定全部类型,这就是 Go 语言把类型放在变量后面的优势😄

以上就是字典的全部内容。这里留个问题:int和map[int]int在查询的时候哪个性能会更好?

自定义类型

不论是简单类型还是容器类型,它们都是 Go 语言自己定义的,有固定的内存布局和功能。但现实生活中,我常常需要自定义内存布局。举个例子。

假设程序要保存用户相关的信息,比如身份证号、姓名、年龄、比赛得分等信息,而且要方便查询。

如果只能使用数组,我们可以定义长度为5的数组:

var user [5]string
user[0] = "37XXXX" // 身份证
user[1] = "张三"   // 姓名
user[2] = "18"     // 年龄
user[3] = "90"     // 得分

数组的下标只是数字,查看年龄只能读取user[2],读者很难理解2是什么意思😂扩展起来也不方便。

使用字典可以缓解这个问题,因为字典的 Key 可以使用字符串:

var user = make(map[string]string, 4)
user["id"]    = "37XXXX" // 身份证
user["name"]  = "张三"   // 姓名
user["age"]   = "18"     // 年龄
user["score"] = "90"     // 得分

这样可以根据属性名来读取不同信息,比如年龄user["age"]。

无论是数组还是字典,它们虽然可以指定 Key 或 Value 的类型,但所有的 Key 和 Value 必须使用相同的类型。这就引出了别外一个问题。以上面的user字典为例,我们希望年龄和得分使用整数保存,因为这样可以针对整数做排序、查找最大值等操作。但是现在受限于字典,所有值只能用字符串保存。

为了解决这个问题,Go 语言支持定义结构体(struct)类型。我把它称之为自定义类型,是因为程序员可以灵活控制结构体的内存布局。

在学习结构体之前,我们先介绍一些简单的自定义类型。

所有自定义类型都需要使用type关键字。其中最简单的就是类型别名。

类型别名

别名的语法很简单,都是type alias = orignal,比如:

type UserID = int64

这样UserID就是int64的别名。它俩完全等价,可以相互替换。比如可以用UserID来声明变量:

var id UserID // 效果跟 var id int64 相同

这样读者一看就知道id是用户ID,比直接用var id int64要好一些。

Go 语言内置了多个类型别名,最常用的是byte和rune,它们的定义如下:

type byte = uint8
type rune = int32

byte用于表示一个字节,类似 C 语言的char类型。rune主要用来保存 Unicode 码位。

类型扩展

除了别名,Go 还支持所谓的类型扩展。这里的类型扩展是我自己的叫法,简单来说就是可以给已经定义好的类型添加功能(函数)。

以整数为例,我们希望给它添加一个函数计算两数之和。首先,我们需要先定义一个支持扩展的类型MyInt:

type MyInt int

声明语法跟别名很像,但没有加等号。一旦定义好扩展类型,我们就可以扩展其功能:

func (a MyInt) Add(b MyInt) MyInt { return a + b }

IsEven()是一个函数,而且在func跟函数名之间添加了(a Int),这部分叫receiver参数。a是参数名,可以随便取,只要跟函数体内的引用保持一致就行。后面的MyInt就是将要扩展的类型。

然后我们可以通过如下语法调用Add()函数:

var i MyInt = 2
i.Add(3) // 返回 5

其实这就是 Go 语言的提供的语法糖,它的实际执行过程如下:

// receiver 参数会被当成函数的第一个参数
func Add(a MyInt, b MyInt) MyInt { return a + b }
var i MyInt = 2
// i.Add(3) 等价于 Add(i, 3)
Add(i, 3) // 返回 5

现在我们终于可以介绍结构体类型了。

结构体类型(struct)

结构体(struct)可以看成类型集合,每个成员可以是前面介绍的各种类型,也可以是结构体类型。还是拿前面的用户信息举例:

type User struct {
  ID    string // 身份 
  Name  string // 姓名
  Age   int    // 年龄
  Score int    // 得分
}

类型定义以type开头,User是结构体的名字,struct表示结构体。每一个成员都需要定义自己的名字和类型。定义好结构体,我们就可以声明对应的变量:

var u User
u.ID = "37..."
u.Age = 18
u.Score = 90

这里使用u.ID这种形式来引用结构体的成员,使用方法跟普通变量也没什么不同。

结构体也支持在声明的时候指定各成员的取值:

var u = User{ID:"37...", Age:18, Score:90}

结构体跟其他类型一样,也支持类型扩展。比如我们可以给User结构体添加如下函数:

func (u User) DoubleScore() int { return u.Score * 2 }

调用结构体函数u.DoubleScore()的过程跟下面的代码完全等效:

var u = User{Score:90}
func DoubleScore(u User) int { return u.Score * 2 }
DoubleScore(u) // 返回 180

按照面向对象的编程习惯,一般也叫结构体的扩展函数为成员函数。但是Go语言本身不支持类和对象等机制。比较相近的功能则是结构体的嵌套(Embedding)。

先举一个简单的例子说明用法:

type Base struct {
  b int
}

type Container struct {
  Base // 嵌入语法
  c string
}

注意Container定义中的第二行,直接写成Base而非b Base。如果是后者,则是定义了一个成员变量,其类型为Base。但如果是前者,则表名是在Container结构中嵌入Base结构。其效果约等面向对象编程中的”继承”,即从使用上来看,结构体Container仿佛也定义了b int,这是它从Base继承过来的:

var c = Container{}
c.b = 1 // 可以给 b 成员赋值

如果结构体Base有定义扩展函数,那么Container类型的变量也可以直接调用该函数:

func (b Base) Foo() int { return b.b }

var c= Container{}
c.b = 1

c.Foo() // 返回 1

跟前面的扩展函数一样,这里的结构体嵌入也是Go语言的语法糖。前面说的Container其真正的定义如下:

type Container struct {
  Base Base // 等效语法
  c string
}

所以c.b和c.Foo()会被替换为c.Base.b和c.Base.Foo()。如果不相信,可以自己试一下:

c.Base.b = 2
fmt.Println(c.b) // 输出 2

所以说,Go语言根本没有继承,不过是把两个结构体强扭起来罢了。强扭的瓜不甜。如果我们在Container中也定义了b int,那么c.b就不能再表示了c.Base.b了,编译器会优先使用最外层的同名成员。

type Container struct {
  Base
  c string
  b int
}

c.b = 1
c.Foo() // 输出 0

这个时候再调用c.Foo()其本质是调用c.Base.Foo(),返回的是Base成员的b成员。因为没有指定,所以返回对应的零值。

同样的,我们还可以给Container定义同名的func Foo() int函数。这么一来c.Foo()只会执行Container关联的函数,而不会再执行c.Base.Foo()。

由上面的分析我们可以得出,在嵌套的结构体中,外层结构体可以访问内层结构体的成员和函数,但内层的无法访问外层的成员和函数。

我之前也写过专门的文章介绍 Embedding 特性,大家可以参考。

特殊结构体

在结束struct介绍之前,我简单提一下匿名结构体和空结构体。

所谓匿名结构体就是没有名字的结构体,一般是随用随定义,不对外曝露:

var s = []byte(`{"name":"张三","age":18}`)
var m struct {
  Name string
  Age int
}
json.Unmarshal(&m, s)
fmt.Println(m.Name, m.Age)

上面的代码通过匿名结构体声明变量m,然后又解析 json 数据,最后获取不同的 json 字段。我们也可以直接在声明的时候指定匿名结构体的各字段值:

var m = struct {
  Name string
  Age int
} {
  Name: "张三",
  Age: 18,
}

这里的语法有点怪了。右边的struct {...}部分表示结构体的成员定义,再后面的{...}部分表示初始化各成员变量。因为是匿名结构体,没有类型名字,所以只能将完整的结构定义写出来。如果有名字则可以把struct{...}替换为对应的类型名。

在众多匿名结构体中,还有另一朵奇葩——空结构体,也就是没有定义成员变量的结构体:

var m = struct{}{}

结构跟匿名结构体一样,但因为没有成员变量,所以两个大括号都是空的。

匿名空结构体最主要的作用就是当占位变量,它完全不占用内存,纯粹是一个符号。具体用途我们会还本文的泛型部分展示。

最后,我们介绍几个高级话题。这部分内容比较复杂,但有些又很常见。讲的太多初学者可能不容易消化,不讲又可能导致无法正常阅读别人的代码。所以我选几个话题简要介绍一下,让大家多少有一个印象。

高级话题

指针(pointer)

Go语言的指针跟C语言很像,但功能稍弱,所以在使用上出错的可能性也会小一些。

所谓指针,就是一个特殊的变量,它的值是内存的一个地址,比如:

     0x0000
     0x0001
a -> 0x0002  0x01
     0x0003  0x02
     0x0004  0x03
     0x0005  0x04

我们在地址0x0020开始的内存中依次保存0x01-0x04。如果想从内存读数据,就要解决两个问题:

  1. 从哪个地址开始
  2. 到哪个地址结束

比如我们只想读一个字节,那得到是0x01;如果读两个字节,得到的就是0x0102了。范围不同,结果也不一样。指针就是为了解决这个问题而设计的。

不同类型的变量所占用的内存长度在编译阶段就确定了。所以编译器可以根据变量的类型来确定内存的长度。作为指针变量也只需要保存内存的开始地址。但是指针变量在声明的时候还是需要指定类型。声明语法如下:

var p *int8

p是一个指针变量,保存一个内存地址。编译器在读写内存的时候只会操作一个字节。指针的零值是nil。

有了指针,我们就得给变量赋值。但这个变量保存的是一个内存地址,显然我们不能给它随便取一个值,因为这个值可能不是合法的内存地址。一般我们会提取某个变量的内存地址然后再赋给指针变量,语法如下:

var i int8 = 1
var p *int8 = &i

上面的&i就是提取变量i的地址。指针变量初始完成后,我们可以通过它来读写它所指向的内存,但语法跟普通的变量略有不同:

*p = 2
fmt.Println(*p) // 输出2
fmt.Println(i)  // 输出2

给定指针变量p,*p表示它所指向的内存里保存的值。在上面的例子中,p指向一个int8型的变量,也就是一个字节的内存。我们可以把*p看成一个普通变量,对它的所有操作都会影响现它指向的内存。

所以*p = 2会把这一个字节的内容由1改为2。这正式变量i底层用的内存,所以i的值自然会变成2。

有了指针,我们可以做一些比较奇巧的操作。比如之前的swap函数可以改写为:

func swap(a *int, b *int) {
  var t int = *a
  *a = *b
  *b = t
  return // 没有返回值可以省略 return
}

var a int = 1
var b int = 2
swap(&a, &b)
fmt.Println(a, b) // 输出 2 1

改写后的swap不需要返回值,而是接受两个int指针(也就是内存地址),然后通过指针直接操作对应的内存,交换了两个变量的值。如果没有指针,我们在调用swap函数的时候只会得到变量a和b的两份拷贝,在swap函数内部交换参数a和b的值根本不会影响到外面的a和b。

对于前面说的结构体方法,我们也可以改用指针:

type Foo struct {
  a int
}

func (f *Foo) Incr() { f.a++ }

var f = Foo{a:1}
f.Inc()
fmt.Println(f.a) // 输出 2

如果Incr()不用指针写些,那么f.a++修改的只是变量f的副本,根本不会修改原变量的a成员。

对于数组和结构体等类型的变量,声明成指针可以避免创建内存副本,提高程序执行效率。但其缺点也很明显,因为指针跟原变量共用底层内存,所以传入函数的变量可能会被函数内部的代码所修改,从而产生非预期结果。大家在使用指针的时候一定要小心。

指针另一个需要小心的就是空指针问题。大家觉得下面的代码可以正常运行吗:

var p *int8
*p = 1

答案是不能。因为p没有初始化,也就没有指向一段正常的内存。那么*p = 1尝试给对应的内存写入数字1,必然会报错。

指针相关的黑科技还有很多。作为初学者先掌握本小节的基础内容,后面慢慢来。

接口(interface)

接口也是Go语言比较有特色的设计。但在讨论接口之前,我们需要先介绍函数类型。

没错,在Go语言中,函数也是一种类型,跟前面介绍的所有类型有点像。只是它声明的时候还是使用func而不是前面的type关键字。大家也可以类比C语言的函数指针。

比如我们可以声明一个二元整数函数:

var f func(int,int) int

f是一个接受两个整数并返回一个整数结果的函数。声明函数类型时,参数可以不用写名字。典型的使用场景如下:

func sum(a, b int) int { return a + b }
func sub(a, b int) int { return a - b }

f = sum
f(1, 2) // 3
f = sub
f(1, 2) // -1

对于变量f而言,某个值要想保存到它的内存,那这个值一定是一个函数(指针),而且函数的入参和返回值必须满足一定的类型要求。不是随便一个什么函数就能赋值给f的。这是f与前面介绍的那些变量最大的不同。之前的变量只通过类型限制内存布局,而f是限制函数的参数和返回值。

但是,函数类型的变量只能限制一种函数类型。我前面在自定义类型一节讲到扩展类型和结构体都可以定义自己的扩展函数,而且还可以定义多个。对于给定的变量,我们想对它们的多个函数做出限制,这就要用到接口。

在Go语言中,一个接口包含两个方面的内容:

  1. 底层实际的变量
  2. 底层变量对应的类型需要包含的扩展函数

还是举个例子:

type Stringer interface {
    String() string
}

类型声明以type开头,后面是接口名字,再后面是interface,表示接口类型。大括号里是需要实现的函数。本例中所有变量需要附带一个扩展函数,名字必须是String,返回值必须是string类型。

我们可以声明一个Stringer类型的变量:

var s Stringer

如果我们再声明一个整数变量,然后尝试赋值:

var i int = 1
s = i // 报错

赋值会报错,因为i的类型是int,而int类型根本没有扩展函数。如果要能赋值,就必须定义扩展类型:

type MyInt int
func (i MyInt) String() string {
  // MyInt 跟 int 不是一个类型,需要转换
  return strconv.Itoa(int(i))
}

var i MyInt = 1
s = i // 成功

这次就成功了。编译器在给s赋值的时候发现Stringer接口需要一个String() string函数。更是编译器开始查找i的扩展函数,并找到了对应的函数指针。然后编译器把该函数指针连同变量i同时保存到变量s。

完成赋值以后,我们就可以通过接口变量调用对应的函数:

s.String() // 返回字符串 "1"

而实际执行的则是i的String()函数,返回数字1。从这个角度看,接口s是一个抽象类型,它只要求底层变量附带(实现)String() string函数。至于底层变量具体是什么类型就没那么重要了。这就是Go语言最重要的抽象机制。

接口类型的变量保存两组信息,一组是具体的底层变量,另一组是底层变量需要实现的函数列表。而使用接口变量的地址则无需考虑底层变量的具体细节。

从接口变量的赋值过程我们容易看出,如果我们声明一个空接口类型的变量,我们就可以用它来保存任意类型的数据。

type any interface {}

var a any
a = int(1)      // 成功
a = double(2.1) // 成功

为什么呢?因为编译器在检查需要实现的函数列表的时候发现是空的,会直接跳过,所以a可以保存各种类型的数据。又因为Go语言要求变量都必须有类型,那怎么实现类似链表、堆、集合等通用的数据结构呢?这就得使用空接口interface{}。

Go语言支持匿名类型,我们可以不指定类型名而直接使用,上述代码可以改写为:

var a interface{}
a = int(1)

基于空接口我们实现一个简单的集合:

type Set map[interface{}]struct{}

func (s Set) Add(e interface{}) { s[e] = struct{}{} }
func (s Set) Del(e interface{}) { delete(s,e) }
func (s Set) Has(e interface{}) bool {
  _, ok := s[e] // 通过 _ 丢弃返回的空结构体
  return ok
}

我们可以使用Set类型保存各种类型的数据。比如:

var s1 = make(Set)
s1.Add(1)
s2.Add(1.5)
s3.Add(true)

s3.Has(1) // true
s3.Has(2) // false

变量保存到接口是一个具体到抽象的过程。但有时候我们还需要它的逆过程,也就是从接口变量中提取具体的底层变量。最典型的例子就是基于空接口实现容器类数据结构,取出的元素的类型也必然是空接口,这时我们就需要次其重新转换成具体的类型。

接口变量支持类型转换,这种转换分强制转换和条件转换两类。

Go语言在转换的时候会检查底层类型跟目标类型是否匹配。如果不匹配,对于强制转换来说,程度会报错退出;对于条件转换来说,程序会获取一个布尔值表示无法转换:

var a interface = ...
a.(int) // 强制转换成 int
var i int
var ok bool
i, ok = a.(int) // 条件转换

同样的转换语法a.(int),如果左边有一个变量就表示强制转换,有两个变量就表示条件转换。对于条件转换,如果转换成功则i中保存int类型的变量,ok的值为true,否则,ok为false,i为空值。

一个接口不光可以变回原来的具体类型,更可以转换成其他的接口。只不过在转换的过程中运行时会检查底层的变量是否满足新接口的函数要求。注意,这次是运行时而非编译器检查了。因为空接口可以保存任意类型的变量,所以无法在编译时确定对应的变量能否转化为某特定接口类型。所以只能是在程序运行的过程中动态检查,这就可能产生BUG。

泛型

在上一节我们提到,如果要实现一些通用的数据结构,需要使用空接口来保存实际的变量,但代价是需要放弃了编译期间的类型检查,代码可能有BUG。为了解决这个问题,Go语言在1.19版本引入了泛型支持。本小节只对泛型做一个基本的介绍。

所谓泛型,就是多种类型的意思。也就是说我们可以写一段代码,同时支持多种类型,而且还能做编译期类型检查。干说比较抽象,举个例子:

func SumInt16(a, b int16) int16 { return a + b }
func SumInt32(a, b int32) int32 { return a + b }
func SumInt64(a, b int64) int64 { return a + b }

对于不同的类型,我们需要写不同版本的函数。但函数过程几乎没有区别。有了泛型,我们可以只写一遍:

func Sum[T int16|int32|int64](a, b T) T { return a + b }

Sum[int16](1,2) // SumInt16(1,2)
Sum[int32](1,2) // SumInt32(1,2)
Sum[int64](1,2) // SumInt64(1,2)

函数名跟参数列表中间多了一个方括号列表。这个列表里定义了所谓的类型参数。在本例中,类型参数为T,参数的类型为int16|int32|int64。这种用竖线连接的语法表示类型集合,也就是说T虽然支持多种类型,但只能从前面的三种类型里面选。T的取值不可能是这三种之外的类型。除了类型集合外,Go语言还支持通过接口限制类型参数的取值范围,这里就不展开介绍了。无论是类型集合还是类型接口,都是对类型参数的一种限制,这种限制统称类型约束。类型参数可以定义好多个。

有一点需要注意,如果我们希望某类型参数T可以是任意类型,也就不对它的范围做限制,我们仍然需要为其指定类型约束。只不过这时的约束是空接口interface{}。但是在泛型定义中写interface{}实在是太难看,于是Go官方给它定义了一个别名type any = interface{}。

一旦定义了类型参数,我们就可以反它们当成普通类型来用,用来声明变量、参数、函数返回值都可以。比如上例中入参a和b以及返回值的类型都是T。

声明了类型参数的函数叫泛型函数。因为类型参数是不确定的,所以在调用泛型函数的时候需要指定参数,就是Sum[int16](1,2)中[int16]要表达的含义——指定类型参数的取值。

为了最大程度方便程序员,Go和泛型支持自动推导类型参数。比如:

var a int16 = 1
var b int16 = 2
Sum(a, b) // 不需要指定类型参数

因为参数a和b的类型是int16,所以在执行Sum(a, b)的时候,编译器会自动猜出类型参数的取舍为int16,不需要再指定了。

类型参数的推导非常复杂。此外,Go语言还支持为结构体以及结构体函数定义类型参数。这些内容在初学的时候可以先略过,可以等学有余力了再去阅读我的系列文章。

到现在算是基本学完了类型系统的主要内容。显然这不是全部内容。其他部分大家可以边实践边学习,不要着急。有了类型也只能是解决了数据的表示问题,程序的另一个功能是处理这些数据。处理的过程需要动态控制流程,这就需要学习Go语言的流程控制。