Go 语言类型系统

数据类型

数据类型指编译器、数据库和执行环境操作和处理数据的方式,Go 语言具有不多但够用的内置类型。

内置类型

内置类型按照结构分为两类:基本类型和复合类型。基本数据类型是最简单的数据表示形式,而复合数据类型则是由一到多个基本类型(或复合类型)组成,用以构建更复杂的数据结构。

基本数据类型(原始类型)直接由编译器或解释器支持,包括下面几类:

  • 整数型:支持无符号(unsigned)和带符号(signed)两种整数。8 位整型长度为 1 字节。
  • 浮点型:支持单精度和双精度(默认)小数,分别精确到小数点后 7 位和 15 位。类型长度分别为 4 字节和 8 字节。由于 math 包中函数要求高精度,所以尽可能使用 float64 类型。
  • 复数型:支持 64 位和 128 位复数值。类型长度分别为 8 字节和 16 字节。
  • 布尔型truefalse。类型长度为 1 字节。
  • 字符串:由单字节连接而成,默认使用 UTF-8 编码标识 Unicode 文本。
  • 字符型:字符是 int32 的别名,用于表示一个 Unicode 码点。
  • 字节型:字节是 uint8 的别名,用于表示 ASCII 字符或二进制数据。

复合数据类型(结构化类型)包括:

  • 数组:固定长度的同类型元素序列。
  • 切片:动态长度的数组引用。
  • 映射:键值对集合。
  • 结构体:多种类型数据集合。
  • 接口:方法集。
  • 通道:消息传递管道。
  • 指针:变量地址。
  • 函数:函数签名。

传递方式

在 Go 语言中,数据类型根据在内存中如何存储和传递,又分为两种:

  • 值类型(Value Types):包括基本数据类型、数组结构体。值类型直接在栈上存储数据。当值类型变量在赋值操作或作为参数传递给函数时,实际传递原始数据的副本(值传递)。因此,修改副本不会影响原始数据。
  • 引用类型(Reference Types):包括切片映射通道指针函数接口。引用类型存储数据的引用(即内存地址),而不是在堆上分配的数据。当引用类型变量赋值时,会复制内存地址副本(引用传递),多个变量实际指向相同的数据。因此,修改其中一个变量会影响所有引用到这些数据的变量。

需要注意,尽管结构体是值类型,但结构体包含引用类型字段时,复制结构体会保持引用字段特性,即引用字段依然使用引用传递。

类型检测

类型检测(Type Checking)是指验证变量和表达式的数据类型是否符合预期,以避免运行时出现类型错误。Go 语言中大部分类型检查在编译时完成。

静态类型

在静态类型语言中,变量一经声明便拥有了确定类型,此后无法更改。而在动态类型语言中,类型转换在执行时进行,错误或异常直到运行时才会被发现。

Go 语言属于静态类型编程语言,所有变量类型都必须在编译时确定,任何类型转换需要显式声明。

通过对比静态和动态语言,展示两者主要区别。首先看一个 JavaScript 函数:

var addition = function (a, b) {
    return a + b;
};

函数 addition 本意接受两个数字参数,并返回两数之和。例如:addition(1, 2) 返回 3。但如果传递给函数一个数字和一个字符串,如 addition(1, "2"),JavaScript 会将数字转换为字符串,然后进行字符串拼接,返回 "12",而不是将 "2" 转为数字 2 再与数字 1 求和。

然后看 Go 语言实现方式:

package main

import "fmt"

func addition(x int, y int) int {
	return x + y
}

func main() {
	fmt.Println(addition(1, 2)) // 输出: 3
	//fmt.Println(addition(1, "2")) // 编译错误:无法将 '"2"' (类型 string) 用作类型 int
}

这段代码同样定义一个 addition 函数,函数接受两个整数参数并返回一个整数。然而第二个参数传入字符串时,编译器会报错:cannot use "2" (type untyped string) as type int in argument to addition。这种类型检查发生在编译时,而不是运行时,只有当错误被修正后才能继续编译。

类型推断

Go 语言支持类型推断,在声明变量,特别是短变量声明时,可以不显式指定类型,由编译器通过赋值来推断类型。一旦变量类型被推断出来,对变量的所有操作都必须符合其类型:

package main

import "fmt"

func main() {
	// 无类型常量时可以随意计算
	fmt.Println(1.0+float64(1.1), 1.0+int(4))
    
	// 显式声明类型为整数
	var a int = 1.0

	// 自动类型推断为浮点数
	b := 1.0

	// 使用 %T 动词打印类型,输出:int float64
	fmt.Printf("%T %T", a, b)
    
	// 确定类型后,不能把 a 同浮点数计算,也不能再把 b 同整数计算
	fmt.Println(a+float64(1.1), b+int(4))
}

基础类型通过字面量默认推导出来的类型有:

  • 整数:整数字面量默认被推导为 int 类型。
  • 浮点数:包含小数字面量默认被推导为 float64 类型。
  • 复数:复数字面量默认被推导为 complex128 类型。
  • 布尔值truefalse 默认被推导为 bool 类型。
  • 字符串:被双引号或反引号包围的文本默认被推导为 string 类型。

其他数据类型如 uint, float32, byte 等都不能直接通过字面量推导。

类型断言

对于一个接口类型变量,可以在运行时使用类型断言获取具体类型:

package main

import "fmt"

func main() {
	var i any = "hello"

	// 单独断言要使用 ok 形式,否则可能会抛出 panic
	s, ok := i.(string)
	if ok {
		fmt.Println(s) // 断言成功,输出: hello
	} else {
		fmt.Println("Not a string") // 断言失败,不会报错
	}

	// 使用类型切换判断类型,可以在多个类型间进行选择
	switch v := i.(type) {
	case int:
		fmt.Println("整数:", v)
	case string:
		fmt.Println("字符串:", v)
	default:
		fmt.Println("未知类型")
	}
}

反射检查

反射允许程序在运行时检查对象的类型和结构:

package main

import (
	"fmt"
	"reflect"
)

func main() {
	fmt.Println(reflect.TypeOf("hello"))  // 输出:string
	fmt.Println(reflect.TypeOf(1.0))      // 输出:float64
	fmt.Println(reflect.TypeOf(int(1.0))) // 输出:int
}

虽然反射功能强大,但其运行时成本较高,通常用于更复杂的场景。

类型转换

Go 语言是静态类型语言,因此所有类型转换(Type Conversion)必须显式进行。由于复合类型之间不提供直接转换方法,因此类型转换一般指基本数据类型之间转换:

targetType(variable)

targetType 是希望转成的数据类型,而 variable 是不同类型的变量:

package main

import "fmt"

func main() {
	intNumber := 123

	// 调用 float32 函数进行类型转换
	floatNumber := float32(intNumber)

	// 输出:整型值 123 转换为 float32 类型的结果为 123.000000
	fmt.Printf("整型值 %d 转换为 float32 类型的结果为 %f\n", intNumber, floatNumber)
}

涉及数值类型时,转换过程中可能会产生值溢出或精度损失,需要小心进行:

package main

import (
	"fmt"
)

func main() {
	var a int32 = 65537
	var b float32 = 10.2

	// 将 int32 类型转为 int16 类型,导致数据溢出,因为 65537 大于 int16 的最大值 32767
	fmt.Println("int32 to int16, 溢出情况:", int16(a)) // 发生数据溢出,输出:1

	// 将 float32 类型转换为 int16 类型,导致精度损失,因为浮点数 10.2 无法完全转换为整数
	fmt.Println("float32 to int16, 精度损失情况:", int16(b)) // 发生精度损失,输出:10
}

字符串与字节或字符切片可以互相转换,但不能与数值型直接转换,需要使用 strconv 包中函数来进行。此外,指针类型变量间转换需要使用 unsafe 包,不推荐平时使用。

类型别名

类型别名(Type Alias)指为现有类型创建另一个名称,两者可以互换使用:

type TypeAlias = ExistingType

TypeAlias 在编译时被视为与 ExistingType 完全相同,任何接受 ExistingType 类型的函数或方法都接受 TypeAlias 类型。

类型别名常用于:

  • 代码重构:需要重构一个广泛使用的类型时,可以在旧包中声明一个类型别名指向新包类型,帮助旧包逐渐迁移到新类型。
  • API 兼容性:在开发库或框架时,要想改变一个公开类型的名称,可以使用类型别名来帮助保持向后兼容性
  • 简化名称:对于非常复杂的函数签名或结构体类型,可以使用类型别名简化名称。

类型别名看起来类似自定义类型,但它们在语义上有重要区别:

package main

import (
	"fmt"
	"reflect"
)

// 定义类型别名组
type (
	Boolean = bool // Boolean 是 bool 的类型别名
	BoolExt bool   // BoolExt 是基于 bool 的新类型
)

func main() {
	var boolAlias Boolean = true
	fmt.Printf("boolAlias 值:%v,类型:%v\n", boolAlias, reflect.TypeOf(boolAlias)) // 输出实际类型:bool

	var customBool BoolExt = false
	fmt.Printf("customBool 值:%v,类型:%v\n", customBool, reflect.TypeOf(customBool)) // 输出自定义类型:main.BoolExt
}

使用类型别名会使代码难以维护,应当在重构完成后逐步淘汰,以避免长期依赖于别名。

自定义类型

自定义类型(Custom Types)通过 type 关键字创建,允许基于已有的数据类型定义一个新类型:

type TypeName UnderlyingType
  • TypeName:新类型名字。
  • UnderlyingType:已存在的类型,可以是任何有效数据类型。

自定义类型不仅用来增强代码可读性和类型安全性,还可以在基本类型或复合类型之上添加额外方法。

定义和初始化

自定义类型可以基于基本类型,也可以基于复合类型定义:

package main

import "fmt"

func main() {
	// 基于基本类型
	type UserID int
	type UserName string

	// 基于复合类型
	type UserMap map[int]UserName
	type CallbackFunc func(UserID) bool

	// 声明变量
	var userID UserID
	var userName UserName = "Alice"
	var userMap UserMap = make(map[int]UserName)
	var callback CallbackFunc = func(n UserID) bool {
		return n < 100
	}

	// 修改和访问,支持基本类型操作
	userID += 2
	userName += "Bob"
	userMap[2] = userName
	userAdmin := callback(userID)

	// 输出:2 Bob map[2:Bob] true
	fmt.Println(userID, userName, userMap, userAdmin)
}

自定义类型不会引入额外性能开销。

方法定义

自定义类型的主要用途是为其绑定方法,有助于在数据操作上保持封装性和逻辑清晰:

package main

import "fmt"

type UserID int

func (id UserID) fix() UserID {
	return id + 1000
}

func main() {
	var userId UserID = 1
	fixedId := userId.fix()
	fmt.Println(fixedId) // 输出: 1001
}

自定义类型也可以实现接口。

显式转换

自定义类型虽然基于某个类型,但在使用时不能直接与原始类型互转。即使底层类型相同,也必须进行显式类型转换:

package main

import "fmt"

type UserID int

func main() {
	var userId UserID = 1
	var offset = 1000
	
	// 必须显式转换成同一类型,才能计算
	fixedId := userId + UserID(offset)
	fmt.Println(fixedId) // 输出: 1001
}

这就是自定义类型安全性的体现,避免了不同类型数据混用可能导致的逻辑错误。

深度复制

将引用类型传递给函数,可能会因为函数内操作导致原始数据被修改。要保证函数无副作用时,必须创建引用类型副本。

切片

先使用 make 函数创建新切片,再用 copy 函数来复制切片:

package main

import "fmt"

func main() {
	source := []int{1, 2, 3}

	target := make([]int, len(source))
	copy(target, source)

	fmt.Println(target, source)
}

映射

同样先使用 make 函数创建新映射,遍历并逐个复制键值对:

package main

func main() {
	source := map[string]int{"a": 1, "b": 2}

	target := make(map[string]int, len(source))
	for k, v := range source {
		target[k] = v
	}
}

结构体

如果结构体中元素都是基本数据类型,可通过直接赋值来复制。如果结构体中包含引用类型,则需要单独对字段深拷贝:

package main

import "fmt"

func main() {
	type Person struct {
		Name string
		Age  int
		Tags []string
	}
	p1 := Person{Name: "Alice", Age: 30, Tags: []string{"friendly", "happy"}}

	// 浅拷贝,p2.Tags 切片仍然指向 p1 同一个底层数组
	p2 := p1
	p1.Tags[0] = "smiling"
	p1.Age = 20
	fmt.Println(p1, p2)

	// 深拷贝,需要手动复制引用类型字段
	p2.Tags = make([]string, len(p1.Tags))
	copy(p2.Tags, p1.Tags)
	p1.Tags[0] = "friendly"
	fmt.Println(p1, p2)
}