Avatar

本节包括 Go 的数据类型、类型定义与别名、泛型等内容。

Part 5 Go 类型系统与泛型
2048 words

Go 的类型系统设计的非常精炼,既提供了底层内存控制的能力,又保证了高级语言的安全性。在 Go 1.18 引入**泛型(Generics)**之后,这个类型系统变得更加完整,既保留了静态类型的严谨,又具备了处理通用数据的灵活性。

1 数据类型h2

Go 的数据类型大致可分为三类:基础类型、复合类型、接口类型。

1.1 基础类型h3

基础类型是 Go 预定义的、不可再分的原子类型。包括布尔型、数值类型和字符串类型。

布尔型h4

  • bool:取值只有 truefalse

数值型h4

Go 中对数值型的划分非常细致,确保在不同架构下的性能。

  • 整型:
    • 有符号:int8 int16 int32 int64 以及 int(长度取决于系统架构,32位或64位);
    • 无符号:uint8 (即 byte), uint16 uint32 uint64 以及 uint
    • 特殊:uintptr(足以容纳指针的无符号整数);
  • 浮点型:float32 float64(Go 默认使用 float64);
  • 复数:complex64 complex128
  • 字符:rune(等价于 int32,用于表示一个 Unicode 码点)。

字符串型h4

  • string:在 Go 中,字符串是不可变的字节序列,默认以 UTF-8 编码存储。
基础类型的显式转换

Go 不允许类型之间进行隐式转换,所有的类型转换都必须显式进行。即使是 intint64,也必须写成 int64(myInt)

基础类型的零值

当基础类型变量被声明而未被初始化时,将被自动赋予零值:

  • 布尔型:false
  • 数值类型:0
  • 字符串类型:""

1.2 复合类型h3

复合类型是由基础类型或其他复合类型组合而成的复杂结构。

类型说明特点
指针 (Pointer)*T存储变量的内存地址。
数组 (Array)[n]T固定长度、连续内存的同类型序列。
切片 (Slice)[]T动态数组,是数组的视图,包含指针、长度和容量。
映射 (Map)map[K]V无序的键值对集合(哈希表)。
结构体 (Struct)struct{...}不同类型字段的聚合。
管道 (Channel)chan TGoroutine 之间通信的管道,实现并发同步的关键。
函数 (Function)func(...)Go 语言的一等公民,函数本身也是一种类型
复合类型的零值

当复合类型被声明而未被初始化时,将被自动赋予零值。

  • 指针、切片、映射、管道、函数:nil
  • 数组:按照数组存储的元素的类型,赋予每个元素该类型的零值。例如对于 [3]int,其零值为 [0, 0, 0];
  • 结构体:所有字段都被赋予其类型的零值。

1.3 接口类型h3

接口类型在 Part 4 Go 接口中已有较为详细的阐述。

2 类型定义与别名h2

在 Go 中,你可以基于已有类型定义新类型。为此有两种完全不同的实现方式。

2.1 类型定义h3

我们可以使用类型定义创建一个全新的类型:

type MyInt int

这个操作在 int 的基础上创建了一个全新的类型 MyInt。虽然它们的底层结构都是一样的(int),但是它们是完全不同的两个类型,不能相互赋值或计算,必须显式转换

如果我们想要在基础类型上加一点新功能,就可以使用自定义类型。因为自定义类型允许被绑定专属方法:

func (m MyInt) IsPositive() bool {
return m > 0
}
func main() {
var a MyInt = 10
a.IsPositive() // true
}

似乎和实现接口的方式有点类似?是的,在 Go 中,“为类型绑定方法”和“实现接口”其实是同一件事的两面。

函数的接收者与接收者参数

我们注意到,无论是为类型绑定方法,还是实现接口,都是在定义函数。不过在函数名前面加了个 (m MyInt)(m MyInt) 就是接收者(Receiver)

其中,

  • MyInt 表示这个方法属于 MyInt 类型;
  • m 叫做接收者参数,代表这个方法所属的实例本身,类似于其他语言中的 thisself

按照接收者是值类型还是指针类型,又分为值接收者指针接收者

func (m MyInt) doSomething(){}
func (m *MyInt) doSomething(){}

这与我们之前说的值传递和引用传递的区别类似。对于值接收者,方法内部拿到的是 m 的一份拷贝,修改 m 不会影响原始变量;而对于指针接收者,方法内部拿到的是内存地址,修改 m 会直接改变原始变量的值。

2.2 类型别名h3

更温和一点的方式是类型别名。就像给类型起了个外号,二者是完全等价的:

type MyInt = int

我们所熟知的 any 就是空接口 interface {} 的别名。

3 泛型h2

Go 在 1.18 版本引入了泛型(Generics),这是该语言自发布以来最大的语法特性更新。它允许你在编写代码时不预先指定具体的类型,而是使用类型参数在调用时才确定具体类型

泛型的核心意义在于:在保证类型安全(编译时检查)的同时,极大地减少了代码重复。

3.1 核心语法h3

泛型主要由三个部分组成:类型参数(Type Parameters)类型约束(Type Constraints)和类型实例化(Type Instantiation)

假设我们要写一个返回切片中最大值的函数。如果没有泛型,你需要为 intfloat64 各写一份;有了泛型,只需一份:

func GetMax[T int | float64](s []T) T {
var max T
for _, v := range s {
if v > max {
max = v
}
}
return max
}
func main() {
intSlice := []int{1, 3, 2, 5, 4}
floatSlice := []float64{1.1, 3.3, 2.2, 5.5, 4.4}
fmt.Println("Max int:", GetMax(intSlice))
fmt.Println("Max float64:", GetMax(floatSlice))
}

在这个函数中到处出现的 T 就是类型参数,它代表着一个尚未确定的类型。只有真正向这个函数传递参数后,才能知道这个 T 究竟是什么类型。

类型参数约定

为了避免函数参数造成误解,一般使用约定的以下类型参数。你当然可以不遵守约定,但是不建议不遵守约定。

  • T:表示类型;
  • K:通常在键值对中出现,表示键的类型;
  • V:通常在键值对中出现,表示值的类型;
  • E:通常在数组、切片等中出现,表示元素的类型。

我们还注意到这个函数相较于普通函数,多了一个 [T int | float64] 的结构。这个结构说明这个函数使用了泛型。对于 T,我们知道它是类型参数。而 int | float64 则是对 T约束,规定 T 只能是 intfloat64 类型的其中之一。

我们可以使用以下类型约束:

  • any:最宽松的约束,等价于 interface {},可以接受任何类型;
  • comparable:Go 中内置的约束,只接受支持 ==!= 操作的类型。如数值、字符串、指针、数组等,不包括切片和映射;
  • 联合约束:使用 | 运算符表示可以接受指定的类型,如 int | float64

事实上,Go 1.18 对接口的定义进行了扩展。从此接口不再是方法的集合,也可以是类型的集合:

type Number interface {
~int | ~int64 | ~float64
}

当然也能用于约束:

type Number interface {
~int | ~int64 | ~float64
}
func GetMax[T Number](s []T) T {
var max T
for _, v := range s {
if v > max {
max = v
}
}
return max
}

这说明类型约束本身也是一种接口。

~符号

~ 表示包含底层类型是这些类型的衍生类型。例如,我们刚才定义的 MyInt,其底层类型是 int。如果不带 ~,那么 MyInt 就不符合 int 约束;如果带了 ~,那么只要底层是 int 就可以。

3.2 泛型数据结构h3

除了函数,你还可以定义泛型的结构体、切片或 Map。这在实现通用的数据结构(如:链表、栈、队列、堆)时非常有用。

type Stack[T any] struct {
elements []T
}
func (s *Stack[T]) Push(v T) {
s.elements = append(s.elements, v)
}
func main() {
intStack := Stack[int]{}
intStack.Push(10)
strStack := Stack[string]{}
strStack.Push("Hello")
}