Go 语言类型系统
数据类型
数据类型指编译器、数据库和执行环境操作和处理数据的方式,Go 语言具有不多但够用的内置类型。
内置类型
内置类型按照结构分为两类:基本类型和复合类型。基本数据类型是最简单的数据表示形式,而复合数据类型则是由一到多个基本类型(或复合类型)组成,用以构建更复杂的数据结构。
基本数据类型(原始类型)直接由编译器或解释器支持,包括下面几类:
- 整数型:支持无符号(unsigned)和带符号(signed)两种整数。8 位整型长度为 1 字节。
- 浮点型:支持单精度和双精度(默认)小数,分别精确到小数点后 7 位和 15 位。类型长度分别为 4 字节和 8 字节。由于
math
包中函数要求高精度,所以尽可能使用float64
类型。 - 复数型:支持 64 位和 128 位复数值。类型长度分别为 8 字节和 16 字节。
- 布尔型:
true
或false
。类型长度为 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
类型。 - 布尔值:
true
和false
默认被推导为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)
}