Golang数据类型
简单数据类型
查看某个变量的数据类型以及所占字节大小:
var k int64 = 10
fmt.Printf("k的类型: %T,k占用的字节数: %d", k, unsafe.Sizeof(k))整型(Integer Types)
整型用于表示整数数值,分为有符号整型和无符号整型两大类,具体包括:
有符号整型
int:平台相关的整数类型,通常为32位或64位。在特定平台下,其大小足够表示常用数值范围。
int8:8位(1字节)有符号整数,取值范围 -128 到 127。
int16:16位(2字节)有符号整数,取值范围 -32,768 到 32,767(-2^15~2^15-1)。
int32:32位(4字节)有符号整数,取值范围 -2,147,483,648 到 2,147,483,647(-2^31~2^31-1)。
int64:64位(8字节)有符号整数,取值范围 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807(-2^63~2^63-1)。
无符号整
uint:平台相关的无符号整数类型,大小与int相同,但仅表示非负整数。
uint8 或 byte:8位(1字节)无符号整数,取值范围 0 到 255。byte 是 uint8 的别名,常用于表示字节数据。
uint16:16位(2字节)无符号整数,取值范围 0 到 65,535(0~2^16-1)。
uint32:32位(4字节)无符号整数,取值范围 0 到 4,294,967,295(0~2^32-1)。
uint64:64位(8字节)无符号整数,取值范围 0 到 18,446,744,073,709,551,615(0~2^64-1)。
其他整型类型
rune: 有符号与int32一样,取值范围 -2,147,483,648 到 2,147,483,647(-2^31~2^31-1),等价int32表示一个Unicode码。
byte: 无符号与uint8等价是uint8 的别名,取值范围 0 到 255,当要存储字符时选用byte。
使用规则
Golang 各整数类型分:有符号和无符号,int uint 的大小和系统有关。
Golang 的整型默认声明为int 型。
查看某个变量的数据类型以及所占字节大小:
govar k int64 = 10 fmt.Printf("k的类型: %T,k占用的字节数: %d", k, unsafe.Sizeof(k))Golang 程序中整型变量在使用时,遵守保小不保大的原则,即:在保证程序正确运行下,尽量使用占用空间小的数据类型。如年龄使用:
govar age byte = 20bit: 计算机中的最小存储单位。byte:计算机中基本存储单元。(1byte = 8 bit)
设计初衷
为什么在有了这么多整型版本后,还要提出int和uint呢?这其实是基于以下两个方面的考虑。
避免选择。程序员通常不太关心数据长度具体大小,如果认为变量所需要容纳的数字无论是32位还是64位都已足够,便可以直接使用int或者uint,避免选择困难。
提升效率。在计算机世界中,存在着字长(word size)的概念。字长是指CPU一次性存取数据的最大长度。CPU按照字长或者字长的整数倍来处理数据,效率最高。目前,主流操作系统的字长为32或者64(对应了CPU的位数)。因此,int/uint在不同系统中自动识别为32位或者64位,以自动向字长看齐,保证CPU的处理效率。
浮点型(Floating-Point Types)
说明
浮点型用于表示带有小数部分的数值,包括:
float32:32位(4字节)单精度浮点数,遵循 IEEE-754 标准,精度大约为 6-7 位有效数字。
float64:64位(8字节)双精度浮点数,也是 IEEE-754 标准,精度约为 15 位有效数字。这是 Go 中默认且最常用的浮点数类型。
说明
浮点数都是有符号的。
float64 的精度比float32 的要准确,如果我们要保存一个精度高的数,则应该选用float64,如果使用不当将会导致精度缺失。
使用规则
Golang 浮点类型有固定的范围和字段长度,不受具体OS(操作系统)的影响。
Golang 的浮点型默认声明为float64类型。
浮点型常量有两种表示形式:
- 十进制数形式:如:5.12 .512 (必须有小数点)
- 科学计数法形式:如:5.1234e2 = 5.12 * 10 的2 次方5.12E-2 = 5.12/10 的2 次方
通常情况下,应该使用float64 ,因为它比float32 更精确。[开发中,推荐使用float64]
浮点型变量声明
其中的32和64分别代表了浮点数的二进制位数。以下代码声明了两个浮点型变量a和b,并为二者赋值。
var a float32
var b float64
a = 1.0
b = 1.21浮点型精度问题
浮点数并不能精确表达所有小数,原因与其存储算法有关。概括地说,十进制整数转化为二进制,之所以能精确表达,是因为所有整数的基石1能够用二进制精确表达。本质上讲,所有整数都是由1累加获得的,因此,整数也是可以精确表达的。但是,浮点型的小数部分利用2^-1, 2^-2, 2^-3……,2^-n(也就是0.5、0.25、0.125等一系列小数)来累加获得。很明显,大部分小数不能通过这一系列小数累加获得。
如下图展示了浮点数的存储格式。从图中可以看出,其存储格式类似于科学记数法。指数部分利用8或11个bit位来存储,指数部分所代表的数字用来计算小数点的位置。指数和尾数部分共同计算浮点数的实际值。
在浮点数的存储规则中,整数部分和小数部分之间的分界线由指数的大小决定,即二进制1(即2^0)和0.5(即2^-1)的分界线。对于一个数字而言,这个分界线是浮动、不固定的。这就是浮点数名称的来源。

话外
在很多编程语言中都提供float关键字来声明32位浮点数。但Go语言中并没有提供float关键字,也并未模仿int和uint定义一个与平台位数相关的浮点类型来随着平台的变更而自动转换为32位或64位。在浮点数与整数的设计风格上,为何会有如此之大的差别呢?笔者认为原因不外乎以下两点:
- int的存在是因为除了数据溢出外,程序员并不特别关心内存存储的大小,并且float并不能完全精确地表示一个数字,因此,程序员往往需要关心精度问题。浮点型的位数直接决定了精度。
- int和uint是为了自动匹配系统字长,以提升计算效率。但由于浮点型复杂的存储格式(指数+尾数),影响计算效率的主要因素在于浮点型特殊的算法,自动与系统字长对齐所带来的效率提升并非主要因素。
综合以上两点,提供一个类似int/uint的数据类型(例如float),会显得有些鸡肋,而且对于熟悉其他编程语言的开发者来说,还会潜意识地将float视作32位(例如Java中的float代表32位浮点数),从而引起使用上的误解。
浮点型的类型推导
虽然Go语言中并未提供与平台位数相关的float关键字,但是,利用类型推导能获得相同的效果,如下代码所示:
package main
import (
"fmt"
"reflect"
)
func main() {
var a float32
b := 3.1
fmt.Println("显示声明,a的类型是:", reflect.TypeOf(a))
// 类型推导的结果与操作系统平台位数相关
fmt.Println("类型推导,b的类型是:", reflect.TypeOf(b))
}输出结果如下:
$ go run .\demo_4.go
显示声明,a的类型是: float32
类型推导,b的类型是: float64通过输出结果可以看出,显式声明的变量a,其类型为float32;而利用类型推导的变量b,其类型与当前操作系统相关,在笔者的64位系统中,其类型被自动推导为float64。
浮点型比较
因为浮点型存在精度缺失,所以在做大小比较时,不能使用“==”“>”“<”等比较运算符,而应该使用big包中的函数。如下代码演示了浮点数的比较。
package main
import (
"fmt"
"math/big"
)
func main() {
a := 3.14
b := 3.15
// 利用big.NewFloat()创建Float指针,两个Float指针进行比较
result := big.NewFloat(a).Cmp(big.NewFloat(b))
if result < 0 {
fmt.Println("a<b")
}
if result == 0 {
fmt.Println("a=b")
}
if result > 0 {
fmt.Println("a>b")
}
}代码解析:
- 函数big.NewFloat(a)用于创建一个Float指针,并将浮点型数据作为参数传入。
- 对利用变量a和b创建的两个Float指针进行比较,获得比较结果——result。变量result是一个int类型的整数。
- 将变量result与0进行比较,若result小于0,则代表前者小于后者;若result等于0,则代表二者相等;若result大于0,则代表前者大于后者。
执行结果:
$ go run .\demo_4.go
a<b补充:复数型(Complex Number Types)
复数类型用于表示实部和虚部均为浮点数的复数,包括:
complex64:由两个 float32 组成的复数,共 8 字节。
complex128:由两个 float64 组成的复数,共 16 字节。
布尔型(Boolean Type)
布尔型只有两种可能的值,表示逻辑上的真或假:
- bool:取值为 true 或 false。
在Go语言中,利用关键字bool来声明一个布尔类型的变量。布尔类型的取值有true和false两种。Go语言是强类型检查的,也不存在从其他类型隐式转换的可能(在类似JavaScript、Shell等语言中,在进行逻辑判断时会将非零值视作布尔类型的true)。
声明和使用
赋值使用
package main
import "fmt"
func main() {
var success = true
if success {
fmt.Println("success")
}
var result bool
result = false
fmt.Println(result)
}运算
除了直接赋值之外,还可以通过比较运算和逻辑运算来获得一个布尔类型的值,如以下代码所示:
package main
import "fmt"
func main() {
var eq = 1 == 0
var r1 = true
var r2 = true
var result = r1 && r2
fmt.Println(eq, result)
}结果如下:
$ go run .\demo_4.go
false true字符型
字符型指的是单个字符。这里的字符不仅仅是ASCII码,也包含了中文及其他语言文字字符。在Go程序运行时,字符型会按照UTF-8编码转换为数字,因此,字符型和整型数字是一一对应的。
使用
字符型通常使用单引号进行赋值:
var char = '电'在定义一个字符型变量后,可以利用fmt.Println()函数打印其内容,如下代码演示了如何定义并打印一个中文字符。
package main
import "fmt"
func main() {
var char = '电'
fmt.Println(char)
}执行该代码段会发现,其输出内容并非我们预期的字符“中”,而是一个数字,结果如下所示:
$ go run .\demo_4.go
30005而30005正是字符“电”转换为UTF-8编码后的十进制数字。要想打印其原始内容,应该使用打印格式化函数,或者强制转换为字符串类型。代码可以修改为如下形式:
package main
import "fmt"
func main() {
var char = '电'
fmt.Printf("%c\n", char)
fmt.Println(string(char))
}结果如下:
$ go run .\demo_4.go
电
电转义字符
除了普通字符外,转义字符也是需要我们特别注意的一类字符。这类字符主要包括特殊字符和控制符。例如,Go语言中的双引号是一个特殊字符。当编译器遇到双引号时,认为它是一个字符串定义的开始。要表示双引号的原义字符,需要进行转义,即在双引号前增加反斜线(")。而控制字符往往不能直接出现在字符串中。例如,直接在字符串中输入换行符,会导致编译器将字符串视作多行代码,而非原本要表达的字符串中含有一个换行符。
Go语言中的转义字符包括回车符、换行符、单/双引号、制表符等,如下图所示。

如下代码演示了如何在控制台上打印符号“'”和“\”的原义字符:
package main
import "fmt"
func main() {
char01 := '\''
fmt.Printf("%c", char01)
fmt.Println()
char02 := '\\'
fmt.Printf("%c", char02)
}在该代码段中,“'”指的是原义字符“'”;“\”指的是原义字符“\”。s
字符串型(String Type)
string:由双引号 "..." 包围的任意字符序列
字符串类型也是最常用的数据类型。要理解字符串,需要从字符串的表现形式和实际存储两个方面进行分析。日常编程中,声明和定义字符串类型其实都是最直观的表现形式,而存储又有磁盘和内存两种形式。
字符串声明
在Go语言中,声明一个字符串可以使用string关键字来定义其数据类型,如下代码所示:
package main
import (
"fmt"
"reflect"
)
func main() {
var s string
s = "Go语言"
fmt.Printf("s的内容:%v\n", s)
fmt.Printf("s的类型:%s", reflect.TypeOf(s))
}结果如下:
$ go run .\demo_4.go
s的内容:Go语言
s的类型:string字符串存储
磁盘存储
我们所写的程序代码总是要存储到磁盘的。对于磁盘来说,无论是代码本身,还是代码中包含的字符,都是以二进制形式进行存储的。我们知道,8个二进制的bit位形成一个字节,而字节本质上还是二进制数据。
当字符串存储到磁盘时,需要以一定的编码格式转化为二进制形式。相应地,编译器读取时,读入的是二进制数据,此时要以同样的编码格式将二进制数据解释为字符串。对于Go语言代码来说,存储到磁盘时,是使用UTF-8编码进行转换。同样地,Go语言代码中的字符串也会使用UTF-8编码转换为二进制,并写入磁盘。
内存存储
字符串在内存存储时,实际是一个字节数组:[]byte,即将字符串按照UTF-8编码转化,生成字节数组。我们可以调用len()函数来查看字符串的长度,并打印字节数组中的内容进行验证,如代码如下所示。
package main
import (
"fmt"
)
func main() {
var s string
s = "Go语言"
length := len(s)
fmt.Println(length)
for i := 0; i < length; i++ {
fmt.Printf("%X ", s[i])
}
}代码解析:
- 在该代码段中,首先定义了一个字符串类型的变量s,并为它赋值“字符串”。注意变量s的内容全部为中文字符。
- len(s)函数用于获得变量s实际占用的存储空间,即底层字节数组的长度。
- 在循环语句中,s[i]获取的是底层字节数组的每个元素。
- fmt.Printf("%X ", s[i]),用于对每个字节进行格式化并打印。%X表示字节的十六进制形式(如果直接打印字节数据,会显示为十进制形式)。
执行结果如下:
$ go run .\demo_4.go
8
47 6F E8 AF AD E8 A8 80从输出结果可以看出,变量s的长度为9,其内容也符合UTF-8编码的特征——汉字往往用3个字节存储,而打印出的9个十六进制数也与图3-2中的完全一致。因此,字符串在内存中的存储结构也是UTF-8转换后的结果。
综上所述,Go语言字符串的存储(无论是磁盘还是内存中)都可以看作UTF-8编码后的字节数组。其实这也非常合理,UTF-8本来就是为了节省存储空间而提出的编码格式,凡是涉及存储,UTF-8都是首选方案。
rune类型处理文本
虽然字符串的存储结构为字节数组,但是这并不意味着符合我们的日常需求。由于人类语言的自然处理思维,我们更加习惯面向字符来处理字符串。例如,对于“字符串”这3个汉字,我们更习惯将它看作3个字符。在Go语言中,要处理自然语言的字符,需要利用另外一个数据类型——rune。
我们知道,Unicode是国际通用的字符集,而UTF-8是Unicode衍生的一个变种编码格式。实际上,rune类型正是用来对应Unicode字符的。同时,rune在表现形式上是一个数字(int32),可以满足Unicode字符集的容量需求。而在其他编程语言中,字符往往利用独立的关键字char进行定义。
使用函数utf8.RuneCountInString()可以获得一个字符串实际字符个数;利用for-range关键字,可以循环获取字符串中的字符。如下代码演示了二者的用法:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
var s string
s = "高并发"
fmt.Println("字符串长度为:", utf8.RuneCountInString(s))
for _, c := range s {
fmt.Printf("%T, %X\n", c, c)
}
}代码解析:
- 在该代码段中,使用utf8.RuneCountInString()来获取字符串中的字符个数。
- 利用Printf按照固定格式打印每个字符,%T代表数据类型,%X代表十六进制格式。
执行结果如下:
$ go run .\demo_4.go
字符串长度为: 3
int32, 9AD8
int32, 5E76
int32, 53D1其中9AD8、5E76、53D1均为两个字节,分别代表“高”、“并”、“发”这三个字符的Unicode编码。
rune类型与字符集的关系
通过前面的代码可以看出,Go语言中每个字符(rune)实际对应了Unicode字符集中的一个编码(码点)。那么,为什么rune对应的是Unicode字符集,而不是与存储结构一致的UTF-8呢?原因有以下两点:
- UTF-8是Unicode的一个变种,除UTF-8之外,还有UTF-16等格式,利用Unicode可以隔离这些具体的编码格式变种。即使将来Go语言存储格式有所变化,对于字符(rune)的处理也不会受到影响。
- UTF-8是变长结构,这不利于CPU的计算,而Unicode长度固定,对于CPU的存取和运算都有着天然优势。
数据类型转换
Go 语言支持显式类型转换,但需要注意以下几点:
转换的目标类型必须是明确的,例如 int(x) 将 x 显式转换为 int 类型。
类型转换可能会导致精度损失,特别是在将大范围类型转换为小范围类型时,如将 float64 转换为 float32 或将大整型转换为小整型。
不允许进行可能导致溢出或违反语义的转换,例如将一个超出 int8 范围的整数转换为 int8。
数组类型
在日常开发中,数组并非最常用的数据类型,但却是很多数据类型的底层存储结构。数组是由一系列拥有相同数据类型的元素组成的数据结构。
数组类型有三个显著特点:
- 数组中的所有元素存储于连续内存;
- 数组可以直接利用位置索引来访问其中的元素;
- 数组大小不可变更;
数组声明
在Go语言中,数组类型利用中括号+元素类型的形式进行声明。由于数组大小固定,因此在声明时必须指定大小。例如,[6]int表示含有6个整型元素的数组;[5]string表示含有5个字符串元素的数组。如下代码演示了数组的声明方式:
package main
import "fmt"
func main() {
a := [5]int{1, 2, 3, 4, 5}
fmt.Println(a)
}代码解析:
- a:=[5]int{1, 2, 3, 4, 5}声明了一个变量a,变量a的数据类型为一个含有5个元素的整型数组。大括号内的数据定义了数组中的元素值1~5。
- fmt.Println(a)用于打印数组内容。
结果输出如下:
$ go run .\demo_5.go
[1 2 3 4 5]数组访问
我们可以通过数组的索引来访问数组元素。与大多数编程语言一样,Go语言中数组的索引从0开始。虽然数组的大小不能变更,但可以通过索引引用元素,或者改变元素的值。如下代码演示了数组索引的使用:
package main
import "fmt"
func main() {
a := [5]int{1, 2, 3, 4, 5}
fmt.Println("索引2的位置元素为:", a[2])
a[2] = 0
fmt.Println("修改后索引2的位置元素为:", a[2])
fmt.Println(a)
}代码执行结果如下:
$ go run .\demo_5.go
索引2的位置元素为: 3
修改后索引2的位置元素为: 0
[1 2 0 4 5]通过打印结果可以看出,a[2]中的2为数组的索引位置,通过a[2]可以访问和修改对应元素。
其他说明
数组大小不可变更
Go语言和其他编程语言(Java、C++等)一样,数组的大小不可变更。这主要是基于数组本身的存储设计理念。例如,含有5个整型元素的数组,其存储结构如图所示。

我们通过索引能够快速定位到数组中的第n个元素(n为索引),并非由于遍历数组很快,而是因为数组元素的尺寸固定,并且内存连续。在图3-3中,每个元素占用8字节,数组的内存结构中存储了首地址。当获取第n个元素时,利用首地址 + n x 8即可直接定位第n个元素的内存地址。
数组的这种存储方式决定了无论数组多大,都必须是连续内存。如果数组可以任意改变大小,那么扩张时,很可能原数组之后的连续内存没有足够的空闲空间,这就导致必须寻找一块新的、更大的连续内存。除了影响效率外,新的内存会导致数组的首地址也发生改变,展现到程序员面前的效果就是数组的引用发生了变化,严格意义上讲,新数组已经不是原数组了。
另外,如果数组中的中间元素被删除,那么由于数组必须保持连续性,被删元素之后的元素必须向首地址方向移动,这同样会造成较大的效率问题。
因此,数组的设计者选择使用切片类型(slice)来适应这种变化,而舍弃支持数组的大小可变的设计方案。
数组作为函数参数
Go语言中的数组是一个值类型,这意味着当数组变量作为参数传入函数时,将会导致数组元素的全量复制,而不像指针那样仅复制内存地址并共享底层存储。
如下代码演示的是将数组传入函数change(),在change()函数内部修改数组元素的值,并在调用函数的前、中、后3个环节打印数组内容。
package main
import "fmt"
func main() {
a := [5]int{1, 2, 3, 4, 5}
fmt.Println("函数调用前:", a)
change(a)
fmt.Println("函数调用后:", a)
}
func change(a [5]int) {
a[2] = 0
fmt.Println("函数执行中:", a)
}在该代码段中,将数组a传入函数change(),并在change中修改数组内容。执行该代码段后,控制台打印内容如下:
$ go run .\demo_5.go
函数调用前: [1 2 3 4 5]
函数执行中: [1 2 0 4 5]
函数调用后: [1 2 3 4 5]通过控制台上的输出可以看出,虽然在change()的函数体中通过a[2]=0修改了传入参数的元素,但是对于原始变量,该数组的值并未发生任何改变。这证明了将变量a传入函数change()时,实际是复制了一个新的数组。
补充
在上面转义字符中讲述了对于特殊字符,需要利用转义来获得其原义字符,但这种写法往往会带来很多麻烦。例如,在如下代码中,文件路径分隔符“\”必须利用“\”表示,英文双引号“"”必须利用“"”表示。
package main
import (
"fmt"
)
func main() {
var s string
s = "D:\\workspace\\lautrans\\docs\\golang\\variable"
fmt.Println(s)
}执行结果如下:
$ go run .\demo_4.go
D:\workspace\lautrans\docs\golang\variable在该代码段中,必须利用转义字符表示路径分隔符“\”和双引号“"”,这使得文件路径的编写非常烦琐。
我们可以利用另外一种语法——``(反引号)来定义原义字符。代码可以修改为如下形式。
package main
import (
"fmt"
)
func main() {
var s string
s = `D:\workspace\lautrans\docs\golang\variable`
fmt.Println(s)
}反引号中定义的字符会忽略特殊字符的特殊含义,其中的所有字符均为原义字符。利用反引号定义的字符串,不必再使用“\”对特殊字符转义。
小结
Go语言的6种常用数据类型:整型、浮点型、布尔类型、字符型、字符串类型,以及数组类型。各种数据类型都有不少细节值得推敲,例如,为什么浮点数往往产生精度损失,int和uint代表的实际类型等。
其中需要着重理解的是字符串类型。字符串类型存储的形式为字节数组,而rune代表的是字符概念。在日常编码过程中,我们也要明确当前的操作到底是面向字节还是面向字符,从而选择合适的函数。
复杂数据类型
值类型和指针类型
存储结构
在Go语言中,可以用“*”来声明一个变量是一个指针类型的数据(常量的值不可变,因此不能声明为指针类型)。指针存储的是内存地址,而内存地址指向真实存储的数据内容。例如,以下代码用于声明一个名为p的变量,其类型为一个整数指针。
var p *int可以利用&操作符来获取某个变量或常量的内存地址,从而产生一个指针。如下代码对比了值类型和指针类型的使用。
package main
import "fmt"
func main() {
var a = 65536
fmt.Println("值类型a的内容: ", a)
var b = 65536
var p = &b
fmt.Println("指针类型p的内容: ", p)
}在该代码段中,定义了值变量a和b,并将二者都赋值为65536。按照类型推导,两个变量都会被视作一个整型。另外,定义了一个变量p,并利用&b为它赋值。&b用于获得变量b的内存地址。按照类型推导,变量p会被视作指针类型。
运行该代码段,控制台打印内容如下:
$ go run .\demo_6.go
值类型a的内容: 65536
指针类型p的内容: 0xc00000a0f0从输出内容可以看出变量p的内容实际是一个内存地址。下图更加直观地展示了值变量和指针变量的区别。

值类型和指针类型从本质上说都是存储的二进制数据,只是运行时的处理方式有所不同。如果一个变量是值类型,那么其存储的内容被视作普通数据;而指针类型的变量,其存储的内容被解释为另外一块内存的首地址,例如图中的0xc000016060。
两种类型区分原因
当向一个函数传递参数(形参)时,会对传入的值(实参)进行复制,如下伪代码段所示:
func tryChange(account Account) {
account.Modify()
}代码解析:
- 该代码段定义了一个名为tryChange的函数,通过调用该函数来试图修改变量的内容。
- 参数account是一个值类型的数据。在调用函数tryChange()时,会将传入的参数account复制一份。复制出来的数据与原数据无关。
- 值赋值机制使得在tryChange()函数中对account参数所做的修改不会影响传入前的变量。
当然,我们也可以向其中传入一个指针类型,从而让函数中的修改能够反映到传入前的原始变量。此时,函数的定义需要修改为:
func realChange(account *Account) {
account.Modify()
}*Account代表参数account是一个指针类型。该参数同样复制了原始变量,不过,因为指针是一个内存地址,所以被复制出来的参数的值也是相同的内存地址。这样,在函数realChange()中,针对account的任何修改都会反映到原始变量中。
与之相对的是Java的设计,在Java中屏蔽了指针这一概念。对于对象类型的参数,传入的都是指针。这导致所有的修改都会直接反映到原始变量中。从这一点上讲,Go语言给了程序员更大的自由度——可选择复制完整数据或者指针。
从内存的角度考虑指针类型的必要性
复杂数据类型往往含有多个字段,而这些字段在内存分布上有一个隐性要求,即所有成员的内存是连续的。这意味着如果字段特别多,或者字段占用的空间特别大,则复杂数据类型的变量往往需要一块很大的连续空间。有了指针类型就可以实现更灵活的分配策略。如果字段被声明为指针类型,就意味着连续内存只需要保存内存地址即可,从而解决超大连续内存的需求问题。
注意
对于指针类型的变量,存储的内容是内存地址。而不同平台的机器,内存地址长度也不同:32位或64位。如果我们调用Print()函数直接打印指针,会发现其输出并非严格的32位或64位,这是因为省略了最高位为0的那些位。
关于引用类型
我们知道,C++对于参数的传递有传值、传引用和传指针之分。在Go语言中,参数的类型可以是值类型,也可以是指针类型,而这二者均可统一为传值这一概念(因为指针所代表的内存地址也是一个整数)。另外,Go语言中不存在传引用一说。
很多资料会将Go语言中的数据类型分为值类型和引用类型,但事实上,Go语言从未提出引用类型这一说法。之所以将指针、切片、map、channel以及接口类型归纳为引用类型,是因为这些类型的变量作为函数参数时,函数内部对参数的修改往往会反映到原始变量中。究其原因,是因为其存储结构往往含有指针字段,对指针字段进行修改,当然会影响到原始变量。
一旦我们理解了这些数据类型的真实结构,就会发现跟引用类型毫无关系。
slice切片
在上面提到了**数组**,数组的大小是不可变更的,很明显,这无法满足大多数的使用场景。为了解决这一问题,Go语言中提供了更加灵活的数据结构——slice(切片),切片可以看作大小可变的数组。
切片如何实现可变大小
切片在底层封装了一个数组指针。打开Go语言安装目录中的slice.go文件,可以看到slice的定义:
type slice struct {
array unsafe.Pointer
len int
cap int
}切片是一个复杂数据结构,包含3个成员(这些成员通常被称作字段,将在后面内容中详细讲述):array,为一个指向数组的指针;len,表示当前切片中的实际元素的数目;cap,是切片的容量,本质是底层数组array的长度。图4-2展示了一个切片的内部结构。

为了避免频繁重建底层数组,在扩展切片时,并非按需进行分配,而是适当多分配内存。因此,大部分时候,底层数组并非全满,而cap的值也往往大于len。
切片声明以及定义
简单声明
Go语言中声明和定义切片并不需要专门的关键字,其语法与声明数组类似,只是无须指定长度,例如:
var s[] int在该语句中,声明了一个切片变量s;[]int是类型说明。与数组的声明方式相比,该声明方式没有指定长度。因此,切片往往被视作长度可变的数组。
make函数创建切片
如果要在创建切片时为其指定初始大小和容量,则可以调用make()函数,如代码所示:
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
fmt.Printf("切片的长度:%d \n", len(s))
fmt.Printf("切片的容量:%d \n", cap(s))
}代码解析:
- make()函数用于创建一个对象,其中,[]int指定该对象为一个切片,其元素类型为int;5为切片初始长度;10为切片的容量(即底层数组的长度)。
- 利用len()和cap()函数可以获得切片长度和容量。
运行该代码段,控制台的输出如下:
$ go run .\demo_6.go
切片的长度:5
切片的容量:10切片中的元素
在初始状态下,切片中的元素都是其类型的零值。对于整型来说,其值都为0。利用索引位置访问切片中的元素并为各元素赋值,如代码所示:
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
fmt.Printf("切片的长度:%d \n", len(s))
fmt.Printf("切片的容量:%d \n", cap(s))
for i := 0; i < len(s); i++ {
s[i] = i
}
for _, e := range s {
fmt.Println(e)
}
}在该代码段中,利用s[i]=i为切片元素赋值,并利用for-range循环打印切片中的各个元素。运行该代码段,控制台的输出如下:
$ go run .\demo_6.go
切片的长度:5
切片的容量:10
0
1
2
3
4切片长度扩展
使用切片的一大好处就是长度可以扩展。我们自然想到,是否可以直接利用索引值突破切片的长度限制,来实现自动扩展,如下代码演示了这一尝试。
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
fmt.Printf("切片的长度:%d \n", len(s))
fmt.Printf("切片的容量:%d \n", cap(s))
for i := 0; i < 10; i++ {
s[i] = i
}
for _, e := range s {
fmt.Println(e)
}
}在该代码段中,首先利用make([]int, 5, 10)创建一个切片。该切片的初始大小为5,容量为10。然后,利用循环语句对其中的各个元素赋值,循环次数为10,循环次数不超过容量。运行该代码段,控制台的输出如下:
$ go run .\demo_6.go
切片的长度:5
切片的容量:10
panic: runtime error: index out of range [5] with length 5
goroutine 1 [running]:
main.main()
D:/goSpace/demo/main/demo_6.go:10 +0x18c
exit status 2通过输出可以看出,直接利用索引来尝试突破切片的长度限制并期望它自动增长是错误的做法,运行时会报出索引越界的错误。我们也可以设想一下,如果这种方式可行,那么Go语言必须监听切片的变动,这不仅实现起来比较复杂,而且很容易出错。例如,用户误填一个超大的索引值,将会导致底层数组的暴涨。
正确的切片长度扩展用法是调用append()函数,例如:
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
s = append(s, 5, 6, 7, 8, 9)
fmt.Printf("切片的长度:%d \n", len(s))
fmt.Printf("切片的容量:%d \n", cap(s))
}输出如下:
$ go run .\demo_6.go
切片的长度:10
切片的容量:10切片容量扩展
切片容量的扩展是通过底层数组的重建来完成的。append()函数执行时,会首先将传入的切片参数复制一份,生成一个新的切片,这意味着slice.array字段(指针类型)也被复制,从而与原切片共享底层数组。
当新切片容量不足时,将新建数组来完成,并将切片结构中的array指针指向新数组,然后修改len和cap。例如,向长度为5、容量为10的切片中追加6个元素,此时,我们可以通过指针和底层数组内存地址的变化来观察切片的扩容和数组的重建。下面代码演示了这种变化。
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
fmt.Printf("切片新建状态,底层数组地址:%p \n", s)
s = append(s, 5, 6, 7, 8, 9)
fmt.Printf("切片追加元素,未超过当前容量,底层数组地址:%p \n", s)
s = append(s, 10, 11, 12)
fmt.Printf("切片追加元素,超过当前容量,底层数组地址:%p \n", s)
}运行该代码段,控制台的输出如下:
$ go run .\demo_6.go
切片新建状态,底层数组地址:0xc0000165f0
切片追加长度,未超过当前容量,底层数组地址:0xc0000165f0
切片追加长度,超过当前容量,底层数组地址:0xc0000260a0从执行结果可以看出,当切片容量达到临界值时,再向其中追加一个元素便会引起底层数组的重建,在本例中,底层数组的内存地址由0xc0000165f0变为了0xc0000260a0。
**注意:**为什么使用fmt.Printf("%p\n", s)可以打印出切片s所指向的数组地址。这是因为在切片的结构中,第一个成员就是数组指针,利用%p格式获得的就是数组的内存地址。
切片参数赋值
调用append()函数可以实现切片的扩展,其典型用法如下所示。
s = append(s, 5, 6, 7, 8, 9)从该示例代码中可以看到,必须对变量s重新赋值才能实现切片扩展。这意味着append()函数不会对原始切片变量s进行操作。因为切片对象s传入函数时,对s的值进行了完整复制,生成了新的值对象。下图演示了传入参数的复制的情况。

因为切片的复制,所以在append()函数内部无法修改原始变量s,只能通过将变量s重新赋值为append()函数的返回值,才能实现真正意义上的扩展操作。反之,如果我们忽略append()函数的返回值,或者将返回值赋给其他变量,则不会对原始切片s的长度(len)和容量(cap)产生任何影响。如下代码演示了这一效果:
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
fmt.Printf("切片新建状态,长度:%d, 容量:%d\n", len(s), cap(s))
_ = append(s, 5, 6, 7, 8, 9)
fmt.Printf("切片追加元素,忽略返回值,长度:%d, 容量:%d\n", len(s), cap(s))
s1 := append(s, 10, 11, 12)
fmt.Printf("切片追加元素,赋值给新变量,原始变量s,长度:%d, 容量:%d\n", len(s), cap(s))
fmt.Printf("切片追加元素,赋值给新变量,新变量s1,长度:%d, 容量:%d\n", len(s1), cap(s1))
}在该代码段中,使用匿名变量_和s1来接收append()函数的返回值。运行该代码段,控制台的输出如下:
$ go run .\demo_6.go
切片新建状态,长度:5, 容量:10
切片追加元素,忽略返回值,长度:5, 容量:10
切片追加元素,赋值给新变量,原始变量s,长度:5, 容量:10
切片追加元素,赋值给新变量,新变量s1,长度:8, 容量:10从输出结果可以看出,无论是使用匿名变量还是新变量来接收append()函数的返回值,原始切片s都不会发生任何变化。
数组创建切片
在前面的内容中,一直在讨论如何新建一个切片,以及如何为切片追加元素,同时讨论了切片与底层数组的关系。其实,切片还可以直接利用数组进行创建。例如:
array := [5]int {10,20,30,40,50}
s := array[:]解析:
- array[m:n]的语法格式用于创建一个切片。冒号(:)前后的m和n均为整数,表示利用数组array中索引位置m~n-1的元素来创建切片。
- 在array[:]中,冒号前后的值都被省略,代表利用整个数组来创建一个切片。因此,新切片结构中的数组指针直接指向该数组的首地址,切片长度和容量均为数组大小。
下面代码是一个利用数组创建切片的典型实例:
package main
import "fmt"
func main() {
array := [5]int{10, 20, 30, 40, 50}
s := array[:]
fmt.Printf("切片s的长度:%d,容量:%d\n", len(s), cap(s))
s1 := array[1:3]
fmt.Printf("切片s的长度:%d,容量:%d\n", len(s1), cap(s1))
}在该代码段中,变量array是一个int类型的数组,我们分别利用array[:]和array[1:3]来创建新的切片s和s1,最后对比二者的长度和容量。该代码段运行后的输出如下:
$ go run .\demo_6.go
切片s的长度:5,容量:5
切片s的长度:2,容量:4array[1:3]获得的是数组array的索引1到2(结束位置的前一位)的元素。无论是切片还是数组,通过索引范围[:]获取其元素,都符合前闭后开原则。输出结果中的长度为2,也印证了这一点。而容量则是从array中索引位置1到数组末尾的元素个数,即容量为4。下图演示了此时的数组array、切片s、切片s1的内存结构。

当然,我们也可以通过打印数组array、切片s和切片s1的内存地址来印证这一结论。相应的代码如下:
package main
import "fmt"
func main() {
array := [5]int{10, 20, 30, 40, 50}
fmt.Printf("数组array的地址是%p\n", &array)
s := array[:]
fmt.Printf("切片s底层数组地址是%p\n", s)
s1 := array[1:3]
fmt.Printf("切片s底层数组地址是%p\n", s1)
}在该代码段中,利用我们熟悉的fmt.Printf()函数和“%p”格式来分别打印数组array,以及两个切片s1和s2的底层数组地址。运行该代码段,其输出结果如下:
$ go run .\demo_6.go
数组array的地址是0xc000014510
切片s底层数组地址是0xc000014510
切片s底层数组地址是0xc000014518对比s1和s的底层数组地址,会发现s1的底层数组地址增长了8字节。这是因为当前的运行环境为64位系统,一个int元素的长度为8字节。
s1的数组指针(slice.array字段)指向的是原始数组的第二个元素(索引为1)。s和s1仍然共用同一个底层数组。
利用切片创建切片
如同利用数组创建切片,我们同样可以利用切片来创建切片。新建切片与原切片共用一个底层数组。如下代码演示了这种用法。
package main
import "fmt"
func main() {
s := make([]int, 5, 10)
fmt.Printf("切片s底层数组地址是%p\n", s)
s1 := s[1:]
fmt.Printf("切片s1底层数组地址是%p\n", s1)
}在该代码段中,s[1:]用于截取切片s的一部分,开始位置为1,直至切片的最后一个元素。运行该代码段,同样可以看到两个切片底层数组的起始地址相差8字节。
$ go run .\demo_6.go
切片s底层数组地址是0xc0000165f0
切片s1底层数组地址是0xc0000165f8当然,也可以将一个切片的所有元素追加到另一个切片中。例如:
s:=[]int{1, 2, 3, 4, 5}
s1:=[]int{5, 6, 7, 8}
s =append(s, s1...)s和s1均为切片,利用append(s, s1...)将s1的所有元素追加到s切片的末尾,生成一个新的切片。在本例中,s1...可视作解压缩切片,从而将其各元素作为append()函数的不定参数。
切片元素修改
切片追加元素、元素的修改实际都是在修改底层数组。这对于共享底层数组的多个切片来说,会相互产生影响,如代码所示:
package main
import "fmt"
func main() {
s := [5]int{1, 2, 3, 4, 5}
s1 := s[1:]
s1[0] = 0
fmt.Println("s = ", s)
fmt.Println("s1 = ", s1)
}在该代码段中,首先利用切片s创建了切片s1,随后利用s1[0]=0修改其中元素的值,最后打印s和s1的内容会发现,两个切片中的元素都发生了改变:
$ go run .\demo_6.go
s = [1 0 3 4 5]
s1 = [0 3 4 5]切片循环处理
可以利用for-range循环来获得和操作切片中的每个元素。虽然实际操作的是底层数组,但会受到切片长度len的约束,如代码所示。
package main
import "fmt"
func main() {
array := [5]int{1, 2, 3, 4, 5}
s := array[1:3]
for i, e := range s {
fmt.Printf("第%d个元素是%d\n", i, e)
}
}在该代码段中,首先利用s:=array[1:3]截取数组array中索引1和2的元素,创建了一个切片s,然后利用for-range循环来打印切片中的每个元素。尽管底层数组含有5个元素,但切片元素仅有2个。运行该代码段,输出结果如下:
$ go run .\demo_6.go
第0个元素是2
第1个元素是3切片索引越界
当我们截取切片时,尤其需要注意切片索引越界问题。对于空切片,我们自然而然会做出如下判断:
var s []int
if len(s) == 0 {
//特殊处理
} else {
//一般性处理
}针对只有一个元素的切片,GoLand的处理稍微有些让人意外。例如:
s := []int {1}
index := 1
fmt.Println(s[index])在代码段中,s是只有一个元素的切片。如果利用s[1]去尝试获取索引为1的元素,则将抛出元素越界异常。这非常容易理解,因为该切片索引位置最大为0。但是,尝试使用如下代码进行切片截取,却有不同的表现:
s := []int{1}
fmt.Println(S[1:])在该代码段中,s[1:]用于截取切片s中索引位置1至最后一个元素的部分。根据s的实际内容,索引位置0是最大索引,但是s[1:]并不会抛出索引越界异常,而是返回一个空切片。
这是因为Go语言对于切片截取的索引值有着特殊的规则。一般来说,当我们截取一个切片时,往往会有两个位置:低位low和高位high。Go语言要求low和high的值符合0≤low≤high≤len(s)即可。在本例中,高位high没有指定,低位low的值为1,因此,低位只需要小于或等于切片长度1即为合法。
小结
通过前面的讲述我们知道,无论是利用make()函数、数组,还是切片来创建一个新的切片,都是在创建一个数据结构。该结构包含了底层数组的指针(array)、长度(len)、容量(cap)3个字段。针对切片的所有操作实际都是针对该数据结构来进行的。
由于数组长度的不变性,当涉及底层数组大小无法满足要求时,会创建新的数组。当切片作为传入参数,甚至是赋值操作的右操作数时,都会导致切片结构的完全复制,例如:
s1 := s在赋值操作中,切片s1其实是一份全新的数据结构,内容与切片s完全一致。这与基本数据类型(例如整数)的赋值操作完全相同,事实上,赋值操作与参数传递一样,都是一个值拷贝的过程。
变量零值
在Go语言中,如果一个变量只进行了声明而没有对它赋值,其实也会依据数据类型赋予变量零值。以下是各种数据类型对应的零值。
(1)整型的零值为0。
(2)浮点型的零值为0.0。
(3)布尔型的零值为false。
(4)字符串类型的零值为空字符串("")。
(5)数组类型的零值是按元素的数据类型将其所有元素置为零值。
(6)接口类型(interface)、切片(slice)、通道(channel)、字典(map)、指针(pointer)、函数(function),它们的零值均为空(nil)。
(7)结构体(struct)的零值是对其字段/元素填充零值。
这与大多数编程语言的规则一致,比较特殊的是结构体和数组。通过二者的填充规则我们可以看出,Go语言总是尽量避免真正意义上的空值。如此一来,也间接地减少了类似Java/C++中的空指针异常带来的困扰。
