跳到主要内容

复杂数据类型

指针

指针是一个变量,它存储了另一个变量的内存地址。通过指针,能够直接访问和操作内存地址上的数据。

Go语言中的指针不能进行偏移和运算,因此只需要记住两个符号:& 获取内存地址,* 根据内存地址取实际值。

指针的声明和使用

1、指针的声明

使用*符号来声明指针类型。例 *int表示一个指向int类型的指针。

var ptr *int

2、获取变量的指针

使用&符号来获取一个变量的指针(即该变量的内存地址)。

x := 22
ptr = &x

3、通过指针访问值

使用*符号来访问指针指向的变量值。

fmt.Println(*ptr)  // 输出22
信息

符号后接数据类型是为指针类型。如`int *string` 等。

*符号后接变量名为获取指针内存所指向的实际存储内容。

package main

import "fmt"

func main() {
x := 10
ptr := &x // 获取 x 的指针
fmt.Println(ptr) // 输出指针地址,0xc00000a0c8
fmt.Println(*ptr) // 解引用,输出指针指向的值,即 10

*ptr = 20 // 修改指针指向的值
fmt.Println(x) // 输出 20,x 的值已经改变
}

指针与函数

在Go语言中,函数传递参数时通常是按值传递(即传递参数的副本),但通过传递指针,可以直接修改传递进来的变量。

package main

import "fmt"

func modifyValue(val *int) {
*val = 100 // 修改指针指向的值
}

func main() {
x := 10
fmt.Println("Before:", x) // 输出 10
modifyValue(&x) // 传递 x 的指针
fmt.Println("After:", x) // 输出 100
}

使用指针的注意事项:

  1. 指针的零值

    • 指针的零值是nil,表示它没有指向任何内存地址。在使用指针之前,确保它不是nil,否则会引发运行时错误。
  2. 避免悬空指针

    • 如果指针指向的内存地址被释放或不再有效,操作该指针可能导致程序崩溃。
  3. 指针的传递效率

    • 在某些情况,传递指针而不是整个数据结构(特别是大的结构体)可以提高程序的效率,因为指针本身通常比数据结构更小。

new()函数

在 Go 语言中,new() 是一个内置函数,用于分配内存。它的主要功能是为指定的类型分配零值内存,并返回一个指向该类型的新分配的内存地址的指针。

变量通过声明指针类型的方式是没有分配内存空间的。直接给*ptr的方式赋值会引发panic。

package main

import "fmt"

func main() {
var ptr *int
*ptr = 100
fmt.Println(ptr) // panic: runtime error: invalid memory address or nil pointer dereference
}

此时可以通过new()函数为其分配零值内存。

package main

import "fmt"

func main() {
// 使用 new() 分配一个 int 类型的内存
ptr := new(int)

// ptr 是一个指向 int 的指针
fmt.Println(ptr) // 输出指针地址,0xc00000a0c8
fmt.Println(*ptr) // 输出指针指向的值,即 int 的零值 0

// 修改指针指向的值
*ptr = 100
fmt.Println(*ptr) // 输出 100
}

new()具有以下特点:

  1. 分配零值内存
    • new()分配的内存会被初始化为该类型的零值。零值取决于类型,例 int的零值是0,string的零值是空字符串,指针的类型是nil
  2. 返回指针
    • new()返回一个指向所分配内存的指针。这个指针指向分配的内存卡,而这块内存的内容已经被初始化为零值。
  3. 不初始化内容
    • new()只分配内存,不会对内存进行其他初始化操作,除了将其内容设为零值。

new() 是一种分配内存并自动初始化为零值的简单方法,因此在需要快速创建指向某类型的零值指针时非常有用。

还有一个分配内存的函数是make(),而make()只适用于切片、map和channel,并且make()直接返回初始化后的数据结构,而不是指针。

切片

数组的大小是不可变的, 而切片是一个拥有相同类型元素的可变长度的序列。切片是基于数组类型做了一层封装, 非常灵活, 支持自动扩容。

切片如何实现大小可变

信息

当前 Go 版本 1.22.5

切片在底层封装了一个数组指针。slice 在Go语言底层 $GOROOT/src/runtime/slice.go 定义为

type slice struct {
array unsafe.Pointer
len int
cap int
}

切片是一个复杂数据结构, 包含3个成员:

  • array 为一个指向数组的指针
  • len 表示当前切片中的实际元素的数目
  • cap 是切片的容量, 本质是底层数组array的长度

切片扩展策略

为避免频繁重建底层数组, 在扩容切片时, 本非按需进行分配, 而是适当多分配内存。因此, 大部分时候, 底层数组并非全满, 而 cap 的值往往大于 len。

可以通过查看 $GOROOT/src/runtime/slice.go 源码, 其中扩容代码如下:

func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen
}

const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) >> 2

// We need to check `newcap >= newLen` and whether `newcap` overflowed.
// newLen is guaranteed to be larger than zero, hence
// when newcap overflows then `uint(newcap) > uint(newLen)`.
// This allows to check for both with the same comparison.
if uint(newcap) >= uint(newLen) {
break
}
}

// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
return newLen
}
return newcap
}
  • 对于容量小于256的小切片, 直接通过倍增快速扩容。
  • 对于容量大于256的大切片, 采用平滑过渡的扩容策略, 将容量增加1.25倍扩容, 以避免过渡分配内存。

切片的长度和容量

通过内置 len() 获取长度, cap() 获取容量

package main

import "fmt"

func main() {
a := make([]int, 5, 10)
fmt.Println(len(a), cap(a)) // 5, 10
}

切片的定义

声明切片, 切片声明和数组类型, 只是切片无需指定长度

var 切片变量名 []类型

// example
a := []int{}
fmt.Println(a) // []

利用数组创建切片

在Go语言中对于数组或切片截取的索引值有着特殊的规则。一般来说, 当截取一个切片时, 往往有两个位置: 低位low和高位high。Go语言要求low和high的值符合0≤low≤high≤len(a)即可。

切片的底层是一个数组, 所以可以基于数组得到切片。在截取值中low和high表示一个索引范围(左包含, 右不包含)。

package main

import "fmt"

func main() {
a := [5]int{1, 2, 3, 4, 5}
s := a[1:3] // a[low:high]
fmt.Printf("s:%v len(s):%d cap(s):%d\n", s, len(s), cap(s)) // s:[2 3] len(s):2 cap(s):4
}

// 为方便, 可以省略索引。 省略low则默认为0; 省略high则默认为数组的长度len(a)
a[2:] // 等同于 a[2:len(a)]
a[:3] // 等同于 a[0:3]
a[:] // 等同于 a[0:len(a)]

使用 make() 函数创建切片

如果要在创建切片时为其指定初始大小和容量, 可以调用 make() 函数

// make 格式
make([]类型, 切片元素数量, 切片容量)

func main() {
a := make([]int, 2, 5) // 存储空间分配5个, 实际使用2个
fmt.Println(a) // [0 0]
fmt.Println(len(a)) // 2
fmt.Println(cap(a)) // 5
}

为切片添加元素

通过调用 append() 函数可以为切片动态添加元素。

package main

func main() {
// 通过var声明的零切片, append()可以直接使用, 无需初始化
var s []int
s = append(s, 0) // [0]
// 追加多个元素
s = append(s, 1, 2, 3) // [0 1 2 3]

s2 := []int{4, 5, 6}
// 追加另一个切片中的元素, 被添加的元素后面添加...
s = append(s, s2...) // [0 1 2 3 4 5 6]
}

每个切片会指向一个底层数组, 这个数组的容量够用就添加新增元素。当切片容量不足时, 将新建数组来完成, 并将切片结构中的array指针指向新数组, 然后修改len和cap。

可以通过指针和底层数组内存地址的变化来观察切片的扩容和数组的重建。

package main

import "fmt"

func main() {
var s []int
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("%v len:%d cap:%d p:%p\n", s, len(s), cap(s), s)
// 在切片的结构中,第一个成员就是数组指针,利用%p格式获得的就是数组的内存地址
}
}

// 输出
[0] len:1 cap:1 p:0xc00000a0c8
[0 1] len:2 cap:2 p:0xc00000a110
[0 1 2] len:3 cap:4 p:0xc0000101c0
[0 1 2 3] len:4 cap:4 p:0xc0000101c0
[0 1 2 3 4] len:5 cap:8 p:0xc00000e2c0
[0 1 2 3 4 5] len:6 cap:8 p:0xc00000e2c0
[0 1 2 3 4 5 6] len:7 cap:8 p:0xc00000e2c0
[0 1 2 3 4 5 6 7] len:8 cap:8 p:0xc00000e2c0
[0 1 2 3 4 5 6 7 8] len:9 cap:16 p:0xc000074080
[0 1 2 3 4 5 6 7 8 9] len:10 cap:16 p:0xc000074080

切片的扩容策略上面说了, 小于256时, 每次扩容是前面的2倍。上面输出的容量也是按照1,2,4,8,16这样的规则自动进行扩容。

使用 copy() 函数复制切片

import "fmt"

func main() {
a := []int{1, 2, 3, 4, 5}
b := a
b[1] = 10
fmt.Println(a, b) // [1 10 3 4 5] [1 10 3 4 5]
}

由于切片是引用类型, 所以a和b指向同一个内存地址,当修改b时,a也会发生变化。

可以使用copy()函数将一个切片的数据复制到另一个内存空间中,

copy(destSlice, srcSlice []类型) // scrSlice:数据来源切片 destSlice:目标切片
package main

import "fmt"

func main() {
a := []int{1, 2, 3, 4, 5}
b := make([]int, 5, 10)
copy(b, a)
b[1] = 100
fmt.Println(a, b) // [1 2 3 4 5] [1 100 3 4 5]
}

切片中删除元素

切片没有专门删除元素的方法, 只有利用索引拼接来删除元素。

要删除索引为index的元素, 操作方法: a = append(a[:index], a[index+1:]...)

package main

import "fmt"

func main() {
a := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// 删除索引值为5的数值
a = append(a[:5], a[6:]...) // 此时append()第一个参数不是a本身, 不是追加
fmt.Println(a) // [0 1 2 3 4 6 7 8 9]
}

map

map是一种用于存储key-value(键-值对)的数据结构。与切片中存储元素的有序性不同, map中的key-value是无序的。map的主要优势在于可以根据key来快速查找对应的value。

声明

map声明格式:

var mapName map[key数据类型]value数据类型

var m map[string]int // string表示key的数据类型为字符串,int表示value的数据类型为整数

map类型的变量默认初始值为nil, 需要使用make()函数来分配内存。

make(map[KeyType]ValueType, [cap]) // cap表示map的容量,该参数虽然不是必须的

m := make(map[string]int, 5)

map示例

package main

import "fmt"

func main() {
m := make(map[string]int, 10)
m["小小"] = 18
m["大大"] = 20
fmt.Println(m) // map[大大:20 小小:18]
fmt.Println(m["小小"]) // 18
}

遍历map中的元素

使用for-range语句可以遍历map中的元素。

遍历map时的元素顺序与添加键值对的顺序无关, map是无序的

package main

import "fmt"

func main() {
learnInfo := make(map[string]int)
learnInfo["go"] = 30
learnInfo["python"] = 40
learnInfo["java"] = 50
learnInfo["node"] = 40

for k, v := range learnInfo {
fmt.Println(k, v)
}

// 如果只想遍历key
for k := range learnInfo {
fmt.Println(k)
}
}

判断key是否存在

Go中有个判断map中键是否存在的特殊写法, 利用多返回值的特点来同时返回value和"是否存在"的标识。

package main

import "fmt"

func main() {
learnInfo := make(map[string]int)
learnInfo["python"] = 40

v, ok := learnInfo["go"] // 存在ok为true, 不存在为false
fmt.Println(v, ok) // 0, false
if ok {
fmt.Println(v)
} else {
fmt.Println("没有此key")
}
}

删除键值对

使用delete()函数从map中删除一组键值对。该函数不会返回任何与执行结果相关的信息; 如果key不存在, 也不会抛出任何异常信息。

delete()格式:

delete(map, key) // map 表示要删除键值对的map, key 表示要删除的键值对的键 
package main

import "fmt"

func main() {
learnInfo := make(map[string]int)
learnInfo["python"] = 40
learnInfo["java"] = 50
learnInfo["node"] = 40

delete(learnInfo, "python")
fmt.Println(learnInfo) // map[java:50 node:40]
}

自定义结构体

自定义类型和类型别名

自定义类型是基于已有类型创建的新类型,尽管它们底层的数据表示相同,但它们在类型系统中被视为不同的类型。

使用 type 关键字定义自定义类型:

type NewType ExistingType

// 例
type Speed int

尽管speed的底层类型是int,但它们是不同的类型,不能直接混用:

package main

import "fmt"

func main() {
type speed int
var s speed
var i int = 10

// s = i // 这行代码会导致编译错误,不能直接赋值
s = speed(i) // 需要显式类型转换
fmt.Println(s)
}

类型别名是为现有类型定义一个新的名称,它们在类型系统中完全等同。使用类型别名可以让代码更加清晰,但不会创建新的类型。

使用 type 关键字结合 = 定义类型别名:

type AliasName = ExistingType

// 为 int 定义一个类型别名
type MyInt = int

类型别名和原类型在使用上完全等价,可以互相使用

var x MyInt = 10
var y int = x // 类型别名和原类型可以互相赋值
fmt.Println(x, y) // 10, 10

定义结构体

Go语言中基础数据类型可以表示一些事物的基本属性,但是当想要表达一个事物的多个属性时,再用单一的基本数据类型就无法满足需求了,Go语言提供了一种自定义数据类型,可以封装多个基本数据类型,这种数据类型就是结构体(struct)。可以通过 struct 来定义自己的类型了。

使用 type 关键字定义结构体。结构体由一个名称和一组字段组成,每个字段都有自己的名称和类型:

type StructName struct {   // StructName 在同一个包内不能重复
field1 Type1 // field1 结构体中的字段名必须唯一 Type1 字段的数据类型
field2 Type2
...
}

// 定义一个表示人的结构体
type Person struct {
Name string
Age int
City string
}
信息

结构体中字段大写开头表示可公开访问,其他包也可以访问,小写表示私有,仅在当前结构体的包中可访问。

实例化结构体

结构体只有实例化时才会真正分配内存。

结构体也是一种类型,通过 var 关键字声明结构体类型。

var 结构体实例 结构体类型

还可以通过new()函数进行内存分配

p := new(Person)

初始化结构体

实例化后的结构体,其成员变量都是对应其类型的零值。需要进行初始化为其赋值。

使用字段名键值对创建结构体实例,当某些字段没有初始值时,该字段可以不写。此时,没有指定初始值的字段的值就是该字段类型的零值。

p := Person{
Name: "Alice",
Age: 19,
City: "北京",
}

省略结构体字段名,顺序初始化,此方法需要初始化结构体的所有字段,初始化的填充顺序必须和字段在结构体中的声明顺序一致,该方式不能和键值方式混用。

p := Person{"Alice", 19, "北京"}

使用 new() 函数创建指针实例,可以直接通过点(.)语法来访问结构体成员

p := new(Person)
p.Name = "Alice" // p.Name 其实是 (*p).Name 这是Go语言实现的语法糖
p.Age = 19
p.City = "北京"

匿名字段

结构体允许其成员字段在没有声明时没有字段名而只有类型。

type Person struct {
string
int
}

func main() {
p := Person{"Alice", 18}
}
危险

匿名字段并不代表没有字段名,而是默认采用类型名称作为字段名,结构体要求字段名必须唯一,因此一个结构体中同种类型的匿名字段只能有一个。

嵌套结构体

结构体可以嵌套,即一个结构体可以包含另一个结构体作为其字段:

type Address struct {
City, State string
}

type Person struct {
Name string
Age int
Address Address
}

func main() {
p := Person{
Name: "Alice",
Age: 11,
Address: Address{
City: "Shanghai",
State: "Asia",
},
}
fmt.Printf("%#v\n", p) // main.Person{Name:"Alice", Age:11, Address:main.Address{City:"Shanghai", State:"Asia"}}
fmt.Println(p.Address.City) // Shanghai
}

结构体的继承

通过嵌套匿名字段,结构体也可以实现字段继承。

type Address struct {
City, State string
}

type Person struct {
Name string
Age int
Address // 通过嵌套匿名结构体实现继承
}

func main() {
p := Person{
Name: "Alice",
Age: 11,
Address: Address{
City: "Shanghai",
State: "Asia",
},
}
fmt.Println(p.City) // Shanghai
fmt.Println(p.Address.City) // Shanghai
}

方法和接收者

在 Go 语言中,方法是附加到特定类型上的函数。虽然 Go 语言没有类的概念,但通过方法,可以为结构体或其他自定义类型添加行为,使其类似于面向对象编程中的类。

方法的语法与普通函数类似,但它有一个额外的接收者参数。接收者是一个特殊的参数,它指定了方法是属于哪个类型的。

func (接收者变量 接收者类型) 方法名(参数列表) (返回参数) {
函数体
}
/*
接收者变量: 接收者中的参数变量名在命名时,官方建议使用接收者类型名称首字母的小写。例如 Person类型的接收者变量名应为 p。
接收者类型: 接收者类型和参数类似,可以是指针类型和值类型
方法名、参数列表、返回参数和函数定义一样
*/

为结构体定义方法

假设有一个表示矩形的结构体,并希望为它定义一个计算面积的方法:

type Rectangle struct {
Width, Height float64
}

// Area 定义一个方法,用于计算矩形的面积
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

func main() {
rect := Rectangle{Width: 10, Height: 5}
area := rect.Area() // 调用方法
fmt.Println(area) // 50
}

指针接收者和值接收者

当方法接收一个值类型是,它会对接收者进行值拷贝。方法内部对接收者的修改不会影响原始数据。

type Rectangle struct {
Width, Height float64
}

// SetWidth 修改 Width 值
func (r Rectangle) SetWidth(newWidth float64) {
r.Width = newWidth
}

func main() {
rect := Rectangle{Width: 10, Height: 5}
rect.SetWidth(50) // 调用方法
fmt.Println(rect) // {10 5} 原始实例值不变
}

当方法接收一个指针类型时,它直接操作接收者的原始内存地址。方法内部对接收者的修改会影响原始数据。

type Rectangle struct {
Width, Height float64
}

// SetWidth 修改 Width 值
func (r *Rectangle) SetWidth(newWidth float64) {
r.Width = newWidth
}

func main() {
rect := Rectangle{Width: 10, Height: 5}
rect.SetWidth(50) // 调用方法
fmt.Println(rect) // {50 5} Width值发生改变
}

指针接收者通常用于需要修改接收者的场景,或者为了避免大结构体的值拷贝带来的性能开销。

为非结构体类型定义方法

Go允许为任何自定义类型定义方法,不仅限于结构体。

type MyInt int

// IsPositive 判断m值是否大于0
func (m MyInt) IsPositive() bool {
return m > 0
}

func main() {
var num MyInt = 5
fmt.Println(num.IsPositive()) // true
}

方法值和方法表达式

可以将方法绑定到特定的接收者,并将其作为值传递。

rect := Rectangle{Width: 10, Height: 5}
areaFunc := rect.Area
fmt.Println(areaFunc()) // 50

可以获取方法的函数表达式,并在调用时传递接收者。

rect := Rectangle{Width: 10, Height: 5}
areaFunc := Reactangle.Area
fmt.Println(areaFunc(rect)) // 50
信息

非本地类型不能定义方法,也就是说不能给别的包的类型定义方法。

结构体标签

标签(Tag)是结构体的元信息,可以在运行的时候通过反射的机制读取出来。标签在结构体字段的后面定义,由一对反引号包裹起来:

type Person struct {
Name string `key1:"value1" key2:"value2"`
}

结构体标签由一个或多个键值对组成。键与值使用冒号分隔,值要用双引号包裹起来。同一个结构体字段可以设置多个标签,不同的键值对之间使用空格分隔。

危险

为结构体编写标签时,必须严格遵守键值对的规格。结构体标签的解析代码的容错能力很差,一旦格式写错,编译和运行时都不会提示任务错误,但通过反射无法正确取值。key和value之间不要添加空格。

type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}

func main() {
p := Person{"Bob", 20}
data, _ := json.Marshal(p)
fmt.Println(string(data)) // {"name":"Bob","age":20}
}

结构体中slice和map问题

在Go中,当结构体字段的类型是 slice 或 map 时,使用方法赋值可能会带来一些需要注意的问题。

slice和map的引用类型特性

slicemap 都是引用类型,这意味着当你将一个 slicemap 赋值给结构体字段时,实际上是将底层数组或哈希表的引用(指针)赋值给该字段,而不是进行深拷贝。这种行为会导致几个问题:

修改共享数据

如果多个结构体实例共享同一个slice或map,通过其中一个实例修改数据时,其他实例的slice或map也会被修改。

type MyStruct struct {
Data []int
}

func (ms *MyStruct) SetData(data []int) {
ms.Data = data
}

func main() {
s1 := MyStruct{}
s2 := MyStruct{}

// 共享同一个slice
sharedData := []int{1, 2, 3}
s1.SetData(sharedData)
s2.SetData(sharedData)

// 修改s1的Data
s1.Data[0] = 100

fmt.Println(s2.Data) // 输出: [100 2 3],s2的Data也被修改了

// 可以 unsafe.SliceData 查看 slice 底层数组的指针
fmt.Println(unsafe.SliceData(s1.Data)) // 0xc0000120d8
fmt.Println(unsafe.SliceData(s2.Data)) // 0xc0000120d8
// 底层数组都是指向同一个
}

解决方法有两种,一是使用append()追加元素,二是通过copy()

// append()
func (ms *MyStruct) SetData(data []int) {
ms.Data = append(ms.Data, data...)
}

// copy()
func (ms *MyStruct) SetData(data []int) {
ms.Data = make([]int, len(data))
copy(ms.Data, data)
}

零值问题

slice和map的零值都是nil,在使用这些零值时,容易引发运行时错误:

  • slice: 可以向零值slice追加元素,Go会自动分配底层数组
  • map: 必须先初始化零值map,否则在其上进行赋值操作会导致panic
type MyStruct struct {
Items []int
Lookup map[string]int
}

func (ms *MyStruct) AddItem(item int) {
ms.Items = append(ms.Items, item) // 可以对nil slice使用append
}

func (ms *MyStruct) AddLookup(key string, value int) {
if ms.Lookup == nil {
ms.Lookup = make(map[string]int) // 必须初始化map
}
ms.Lookup[key] = value
}

func main() {
s := MyStruct{}
s.AddItem(42)
s.AddLookup("key", 100)
}