golang中你应该知道的指针知识

A monarchy conducted with infinite wisdom and infinite benevolence is the most perfect of all possible governments.
— Ezra Stiles

1.1 指针的基本概念

介绍什么是指针,指针在内存中的工作原理,以及为什么需要指针。

Go语言中,如果我们要存储一个整数,我们会使用整型(int),如果要存储一个字符串,我们会使用string类型,如何我们想存储一个内存地址呢,要用什么数据类型呢?

答案是指针。

当我们声明一个变量时,计算机会为这个变量分配一块内存,并将数据存储在这块内存中。指针则是一个存储了这个内存地址的变量。通过指针,我们可以间接地访问或修改该内存地址中的值。
例如:

1
2
var a int = 42
var p *int = &a // p 是一个指针,存储了变量 a 的内存地址

在上面的代码中,a 是一个整数变量,p 是一个指向 a 的指针。&a 表示 a 的内存地址,而 p 存储了这个地址。
image.png

从上面的示意图中可以看出一个指针类型的变量本身也有自己的内存地址。

我们都知道使用指针有以下好处,Go语言中的指针也不例外:

  1. 高效传递数据:在函数调用时,传递指针而不是传递整个变量,可以避免数据的复制,提高程序的性能。
  2. 共享数据:多个函数或数据结构可以共享同一个数据,通过指针操作同一个内存地址,实现数据的共享和一致性。
  3. 动态内存管理:通过指针可以动态分配、访问和释放内存,适应更多复杂的内存操作需求。

当然Go的指针也有其特殊之处,让我们继续往下看。

1.2 指针声明和使用:

学习如何在 Go 语言中声明和初始化指针,并了解如何使用指针。

在 Go 语言中,指针的声明和初始化非常简单。可以使用 * 符号来声明指针类型,使用 & 符号来获取变量的地址并初始化指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func main() {
var a int = 42
var p *int = &a // p 是一个指向 int 类型的指针,指向变量 a 的地址

fmt.Println("a 的值:", a) // 输出:a 的值: 42
fmt.Println("p 指向的值:", *p) // 输出:p 指向的值: 42

*p = 21 // 通过指针修改变量 a 的值
fmt.Println("修改后 a 的值:", a) // 输出:修改后 a 的值: 21
}

在这个示例中,我们首先声明了一个整数变量 a,然后声明了一个指向 a 的指针 p。通过 *p 可以访问和修改 a 的值。

在 Go 语言中,指针的零值是 nil,表示该指针没有指向任何有效地址。在使用指针之前,通常需要判断指针是否为 nil,以避免运行时错误。

1
2
3
4
5
6
7
8
var p *int
fmt.Println(p) // 输出:<nil>

if p != nil {
fmt.Println(*p)
} else {
fmt.Println("p is nil")
}

更多nil相关知识:Nil Notions You Should Know in Golang | by Wesley Wei | Jun, 2024 | Programmer’s Career

1.3 不支持指针运算等操作

Go 语言不支持直接的指针运算

在C语言中可以很自由地用指针(Pointer)来操作内存,C语言支持指针运算,直接操作内存虽然可以开发出高性能的程序,但也容易造成程序内存泄露与溢出。

Go语言的很多语法以及编程思想来源于C语言,但Go 语言更强调安全性和简洁性,为了避免编程错误和复杂性,Go 语言不支持直接的指针运算。例如,不能对指针进行加减操作来遍历内存地址。

1
2
3
4
5
6
var a int = 42
var p *int = &a

p++ // invalid operation: p++ (non-numeric type *int)
p-- // invalid operation: p-- (non-numeric type *int)
p = p + 1 // invalid operation: p + 1 (mismatched types *int and int)

总之,为了既可以享受指针带来的便利,又避免了指针的危险性,Go语言对指针的限制如下:

  1. 指针不能进行数学运算
  2. 不同类型的指针不能相互转换
  3. 不同类型的指针不能作比较
  4. 不同类型的指针不能相互赋值

但是有些场景我们使用非安全型指针更方便快捷,所以在 unsafe包中提供了unsafe.Pointer类型,具体细节可以参考 1.9 章节

1.4 函数中的指针参数:

学习如何在函数中使用指针参数,以便在函数内部修改外部变量的值。

在 Go 语言中,函数参数是按值传递的。这意味着当一个变量被传递给函数时,函数接收到的是该变量的副本,而不是原始变量本身。对副本的修改不会影响原始变量。

如果想要在函数内部修改外部变量的值,可以通过传递指针来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func safeUpdate(ptr *int) {
if ptr != nil {
*ptr = 100
} else {
fmt.Println("指针为 nil,无法更新值")
}
}

func main() {
var a int = 42
safeUpdate(&a) // 正常更新值
fmt.Println(a) // 100
safeUpdate(nil) // 输出:指针为 nil,无法更新值
}

指针参数的优点

  1. 减少内存复制:对于大数据结构(如数组、切片、结构体等),传递指针可以避免整个数据结构的复制,提高性能。
  2. 实现引用传递:通过指针参数,函数可以直接修改外部变量的值,而不仅仅是其副本。
  3. 一致性:多个函数可以通过指针共享和修改同一个变量,保持数据一致性。

1.5 指针和数组:

了解如何将指针与数组结合使用。

  1. 数组:在 Go 语言中,数组是一个固定长度的序列,其元素具有相同的类型。
  2. 指向数组元素的指针:指针可以指向数组中的某个元素,通过指针可以间接地访问和修改数组的元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

func main() {
var arr [5]int = [5]int{10, 20, 30, 40, 50}
var p *int = &arr[0] // 指针 p 指向数组 arr 的第一个元素

// 通过指针访问和修改数组元素
fmt.Println(*p) // 输出:10

*p = 100
fmt.Println(arr) // 输出:[100 20 30 40 50]

// 指针可以移动到数组的下一个元素
p = &arr[1]
fmt.Println(*p) // 输出:20

*p = 200
fmt.Println(arr) // 输出:[100 200 30 40 50]
}
  1. 数组长度固定:数组的长度在声明时就已确定,不能动态改变。使用指针操作数组时,需要确保指针不越界访问。
  2. 指针运算限制:Go 语言不支持直接的指针运算,因此无法通过 p++ 或 p-- 来遍历数组元素。需要显式地操作指针来访问数组的不同元素。
  3. 切片替代:在许多情况下,切片(slice)可以提供更灵活和方便的操作。切片是对数组的一个动态视图,可以更方便地进行遍历和修改。

1.6 指针和切片:

理解切片的底层实现与指针的关系,学习如何使用指针操作切片。

  1. 切片:切片是对数组的一个连续片段的引用。切片由三部分组成:指针(指向底层数组的起始位置)、长度(切片中元素的个数)、容量(从起始位置到数组末尾的元素个数)。
  2. 底层数组:切片依赖于一个底层数组。切片的所有操作其实都是对这个底层数组的操作。
1
2
3
4
5
type SliceHeader struct {
Data uintptr // 指向底层数组的指针
Len int // 切片的长度
Cap int // 切片的容量
}

通过指针可以操作切片的元素。可以获取切片元素的地址,通过这些地址来修改切片中的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import "fmt"

func main() {
// 声明和初始化一个数组
arr := [5]int{10, 20, 30, 40, 50}

// 创建一个切片,引用整个数组
slice := arr[:]

// 获取切片中第一个元素的指针
p := &slice[0]

// 通过指针访问和修改切片元素
fmt.Println(*p) // 输出:10
*p = 100
fmt.Println(slice) // 输出:[100 20 30 40 50]

// 访问和修改切片的其他元素
p = &slice[1]
fmt.Println(*p) // 输出:20
*p = 200
fmt.Println(slice) // 输出:[100 200 30 40 50]
}

切片可以动态增长,当切片的容量不够时,会自动分配一个更大的底层数组,并将原有的数据复制到新的数组中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
// 创建一个初始容量为2的切片
slice := make([]int, 2, 2)
slice[0] = 10
slice[1] = 20

// 添加元素,导致切片增长
slice = append(slice, 30)

// 输出切片的内容、长度和容量
fmt.Println("切片:", slice) // 输出:切片: [10 20 30]
fmt.Println("长度:", len(slice)) // 输出:长度: 3
fmt.Println("容量:", cap(slice)) // 输出:容量: 4
}

切片有以下注意事项:

  1. 切片共享底层数组:多个切片可以共享同一个底层数组,修改一个切片的元素会影响到其他共享该数组的切片。
  2. 避免越界访问:通过指针操作切片时,需要确保指针不越界访问数组的范围。
  3. 切片增长的性能影响:频繁的切片增长会导致多次内存分配和数据复制,应尽量避免。

1.7 指针与结构体:

学习如何使用指针操作结构体,以及结构体方法中的指针接收器。

  1. 结构体:结构体是一种复合数据类型,用于将多个不同类型的字段组合在一起。
  2. 指向结构体的指针:指针可以指向结构体,通过指针可以访问和修改结构体的字段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

// 定义一个结构体类型
type Person struct {
Name string
Age int
}

func main() {
// 创建一个结构体实例
person := Person{Name: "Alice", Age: 30}

// 创建一个指向结构体的指针
p := &person

// 通过指针访问和修改结构体的字段
fmt.Println(p.Name) // 输出:Alice
fmt.Println((*p).Name) // 输出:Alice
p.Age = 31
fmt.Println((*p).Age) // 输出:31
}

可以看到,p.Name 相当于 (*p).Name

指针接收器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

// 定义一个结构体类型
type Person struct {
Name string
Age int
}

// 定义一个方法,使用指针接收器
func (p *Person) CelebrateBirthday() {
p.Age++
}

func main() {
// 创建一个结构体实例
person := Person{Name: "Alice", Age: 30}

// 调用方法,修改结构体的字段
person.CelebrateBirthday()
fmt.Println(person.Age) // 输出:31
}
  1. 值接收器与指针接收器:如果方法不需要修改结构体的字段,可以使用值接收器;如果需要修改,则应使用指针接收器。
  2. 结构体字段的访问:通过指针可以方便地访问和修改结构体的字段,但要注意避免空指针访问。

1.8 内存分配:

深入理解 new 和 make 的使用场景与区别。

new 和 make 的区别

  1. **new**:new 是一个内置函数,用于分配内存。它返回一个指向类型零值的指针。new(T) 分配了类型 T 的内存空间,并且将其初始化为类型零值,返回一个指向该内存的指针 *T

  2. **make**:make 也是一个内置函数,但它只用于创建和初始化切片(slice)、映射(map)和通道(channel)。make 返回一个被初始化的(非零)值,而不是指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

// 定义一个结构体类型
type Person struct {
Name string
Age int
}

func main() {
// 使用 new 分配结构体
p := new(Person)
fmt.Println(p) // 输出:&{ 0}

// 修改结构体字段
p.Name = "Alice"
p.Age = 30
fmt.Println(p) // 输出:&{Alice 30}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

func main() {
// 使用 make 创建一个切片
slice := make([]int, 5) // 创建一个长度和容量均为 5 的切片
fmt.Println(slice) // 输出:[0 0 0 0 0]

// 使用 make 创建一个映射
m := make(map[string]int)
m["key1"] = 42
fmt.Println(m) // 输出:map[key1:42]

// 使用 make 创建一个通道
ch := make(chan int, 2)
ch <- 1
ch <- 2
fmt.Println(<-ch) // 输出:1
fmt.Println(<-ch) // 输出:2
}

new 用于分配内存并返回指针,而 make 用于创建和初始化切片、映射和通道。在实际开发中,根据需求选择合适的内存分配方式,可以提高代码的性能和可维护性。

1.9 unsafe包:

unsafe 包提供了一些绕过 Go 语言类型安全的操作,允许进行低级别的内存操作。尽管使用 unsafe 包可以实现一些高级功能,但也可能导致程序崩溃或不安全,因此应谨慎使用。

unsafe package - unsafe - Go Packages
unsafe包提供unsafe.Pointer类型本质一个*int,他可以指向任意类型:

1
2
3
4
5
6
7
type ArbitraryType int

type Pointer *ArbitraryType

func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr

unsafe包提供三个函数:

  1. func Sizeof(x ArbitraryType) uinptr : 返回x所占据的字节数,但不包含x所指向的内容的大小
  2. func Offset(x ArbitraryType) uinptr:返回结构体成员在内存中的位置距离结构体起始处的字节数,所传参数必须是结构体的成员
  3. func Alignof(x ArbitraryType) uintptr:返回对应参数的类型需要对齐的倍数

不知道你有没有注意到,这里的返回类型都是 uintptr。uintptr是一个整数类型,并没有指针语义,uintptr 所指向的对象会被gc回收,而unsafe.Pointer有指针语义,可以保护他所指向的对象在”有用”的时候不会被垃圾回收;

1
2
3
// uintptr is an integer type that is large enough to hold the bit pattern of
// any pointer.
type uintptr uintptr

unsafe.Pointer不能直接进行数学运算,但是它可以转换成uintptr,对uintptr类型进行数学运算,再转换成pointer类型。cs.opensource.google/go/go/+/refs/tags/go1.22.5:src/builtin/builtin.go;l=85

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"fmt"
"unsafe"
)

func main() {
// 创建一个整数数组
arr := [5]int{10, 20, 30, 40, 50}

// 获取数组第一个元素的指针
p := unsafe.Pointer(&arr[0])

// 打印原始数组和第一个元素的值
fmt.Println("Original array:", arr)
fmt.Println("First element:", *(*int)(p))

// 计算下一个元素的指针
sizeOfInt := unsafe.Sizeof(arr[0])
p = unsafe.Pointer(uintptr(p) + sizeOfInt)

// 打印第二个元素的值
fmt.Println("Second element:", *(*int)(p))

// 继续计算并打印第三个元素的值
p = unsafe.Pointer(uintptr(p) + sizeOfInt)
fmt.Println("Third element:", *(*int)(p))

// 为了演示垃圾回收问题,创建一个指针并设置为 nil
ptr := &arr[0]
fmt.Println("Pointer before:", *ptr)
ptr = nil
fmt.Println("Pointer after nil:", ptr)

// 即使 ptr 被设置为 nil,arr 仍然存在,因为它在 main 函数的范围内
fmt.Println("Array after nil pointer:", arr)
}

可以uinptr和unsafe.Pointer的区别

  • unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型的指针,他不可以参与指针运算
  • uintptr是用于指针运算的,GC不把uintptr当指针,也就说uintptr无法持有对象,uintptr类型的目 标会被回收
  • unsafe.Pointer 可以和 普通指针 进行相互转换;
  • unsafe.Pointer 可以和 uintptr 进行相互转换。

但是并不建议使用 unsafe 进行不同类型转换,除非你非常肯定,因为可能会遇到如下问题:

  • 内存截断(Memory Truncation):如果目标类型比源类型小,可能只会读取部分内存,从而导致数据截断。例如,将一个 int 类型(通常为 4 字节)的指针转换为 char 类型(通常为 1 字节)的指针,只会读取原来4字节整数中的一个字节。
  • 内存访问扩展(Memory Access Extension):如果目标类型比源类型大,可能会读取超出实际分配内存的范围,从而导致未定义行为,甚至崩溃。例如,将 char 类型的指针转换为 int 类型的指针,可能会读取未定义的数据,因为它会尝试读取4个字节。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"unsafe"
)

func main() {
var a int = 42
var p *int = &a

// 将 *int 转换为 unsafe.Pointer
var uptr unsafe.Pointer = unsafe.Pointer(p)

// 再将 unsafe.Pointer 转换为 *float32
var fptr *float32 = (*float32)(uptr)

fmt.Println(*fptr) // 不安全,输出结果是不确定的
}

更多该系列文章,参考medium链接:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

English post: https://programmerscareer.com/golang-pointers/
作者:Wesley Wei – Twitter Wesley Wei – Medium
注意:原文在 2024-07-04 01:02 时创作于 https://programmerscareer.com/golang-pointers/. 本文为作者原创,转载请注明出处。

Go 1.23 的 Timer 相关改动 拥抱AI时代的到来:工具及概念介绍

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×