欢迎,来自IP地址为:216.73.216.27 的朋友


指针 对于支持它的语言来说都是一个非常基础和令人头疼的概念,幸运的是,Go 语言指针的使用就显得非常直接和安全:

什么是指针

了解内存

内存对于计算机来说,就是一个非常巨大的字节(bytes)序列,序列中每个字节都具有唯一的地址。程序中使用的每个变量都会占用内存中单个或连续的字节来保存数据,占用的空间依据变量的类型来区分:

  • 一个 int32 类型的数据通常占用4字节
  • 一个 int64类型的数据通常占用8字节
  • 一个 bool 类型的数据通常占用1字节

结构体(struct)类型、数组和切片类型占用其字段大小的总和,加上用于对齐(以便快速访问)的潜在填充空间。每个变量都有一个唯一的内存地址,用于存储其数据。

例如:

var a int32 = 100
var b bool = true

在内存中两个变量的存储示意图如下:

其中变量 a 占用4个字节用于存储其值”100″,而变量 b 占用1个字节用于存储其值”true”,而变量的地址就是内存中保存其值的起始位置。示例中变量 a 的地址是 0x0202,而变量 b 的地址是 0x0207。

栈与堆

在 Go 语言中,变量既可以分配到内存栈,也可以分配到内存堆。栈是用于保存本地数据和函数调用信息的内存区域,它可以快速分配和快速回收,工作方式是先进先出。

堆是一个容量的内存池,用于动态分配内存空间。分配到堆中的变量可以有比定义它们的函数更长的生命周期,使它们可以更加适合程序的数据共享和修改。

Go 运行时(runtime)自动管理内存分配和垃圾收集,所以 Go 语言不像一些其他语言一样需要手动释放内存。

栈和堆本身来说都是内存空间,只是在实施细节上有所差异。作为一名 Go 程序员,通常不必关注变量分配到哪里,Go 编译器和运行时会帮助处理好。我们只需要知道它们的存在以及指针可以指向它们。

指针

指针是一种用于存储内存中其他变量保存地址的变量,从之前示意图中可以看到,内存地址就是一个整型数据(它恰好可以方便的展示内存位置)。在常用的 64 位系统中,内存地址通常是8字节长(64 bits),所以与之对应的指针也占用8字节。

在 Go 语言中,使用”*”操作符来定义指针变量,指针还通常包含一个类型,用于指示指向变量的类型,例如:

var p *int32 // a pointer to an int32

同样的,我们还可以使用”&”操作符来获取变量的存储地址,这样就可以给指针变量赋值:

var a int32 = 100
var p *int32 = &a // p now holds the address of a

内存示意图如下:

变量 a 存储值100,地址为0x0202,变量 p 存储变量 a 的地址0x0202,并且使用新的内存地址0x0207。

而指针携带类型信息的原因在于方便解引用,即按照内存地址访问保存值。这项操作同样可以使用”*”操作符来完成:

var a int32 = 100
var p *int32 = &a // p now holds the address of a

fmt.Println(*p)   // prints the value at the address p points to, which is the value of a: 100

“*”的双重用途常常引起混淆,因此让我们详细说明一下:

  1. 在类型定义时,* 用于表明变量是指向某种类型的指针
  2. 在表达式中,* 用于解引用指针,即访问指针指向的实际值

定义和使用指针

基本类型的指针

同之前一样,我们可以使用”*”操作符来定义一个指针变量,例如:

var p *int      // p is a pointer to an int, but currently nil
fmt.Println(p)  // <nil>

同 Go 语言的其他类型变量一样,如果不对其初始化,那么该变量则默认使用该类型的0值作为其值。对于指针变量,其0值是”nil”,即不指向任何内存地址。

我们还可以使用内置的 new 函数来分配空间并获得其指针:

p := new(int)  // p is a pointer to an int with a zero value (0 for int)
fmt.Println(*p) // prints 0, the zero value for int

使用”&”操作符获取地址

& 操作符可以返回一个已经存在的变量的内存地址:

x := 42
p := &x // p now holds the address of x

fmt.Println(*p) // 42
*p = 99         // change the value at the address p points to (which is x)
fmt.Println(x)  // 99
fmt.Println(p)  // prints the memory address of x, e.g., 0xc0000140b8
fmt.Println(&x) // prints the same address as p

结构体指针

指针可以指向任何类型,特别是对于结构体类型非常普遍。

type User struct {
    Name string
    Age  int
}

func main() {
    u := User{"Alice", 30}
    p := &u                 // pointer to User
    fmt.Println((*p).Age)   // 30
    fmt.Println(p.Name)     // Alice - shorthand for (*p).Name
}

对于结构体中的字段,可以使用 (*p).Age 来访问,也可以使用 p.Name 来访问,Go 语言会自动解引用结构体指针。

用户自定义类型指针

type Point struct {
    X, Y int
}
p := &Point{X: 1, Y: 2} // pointer to Point
fmt.Println(p.X, p.Y)   // 1 2 - shorthand for (*p).X and (*p).Y

对于用户自定义类型,语法同内置类型一样可以正确的工作。

使用指针的原因

乍一看,使用指针好像是绕了一个弯子,作无谓的脑力劳动,为什么不直接使用数值本身呢?以下是 Go 使用指针的一些主要原因。

减少拷贝操作

在 Go 语言中对变量进行赋值时,会进行拷贝操作:

type User struct {
    Name string
    Age  int
}

u1 := User{"Alice", 30}
u2 := u1  // copy
u2.Age = 40

fmt.Println(u1.Age) // 30
fmt.Println(u2.Age) // 40

当结构体比较小时(只包含几个字段),进行变量赋值传递时内存消耗比较廉价,但是如果结构体比较大,进行值传递时就显得非常低效,传递指针就可以避免完全拷贝:

func Birthday(u *User) {
    u.Age++
}

u := User{"Bob", 29}
Birthday(&u)
fmt.Println(u.Age) // 30

共享和变更状态

有时如果希望程序的多个部分使用同一个对象。使用值时,每次赋值都会生成一个副本:

type Counter struct {
    value int32
}

c1 := Counter{value: 0} // c1 is a Counter
c2 := c1  // c2 is a copy - another Counter

c2.value++
fmt.Println(c1.value) // 0
fmt.Println(c2.value) // 1

使用指针就可以确保所有变量引用同一实际值:

pc1 := &Counter{value: 0} // pc1 is a pointer to a Counter
pc2 := pc1   // copy of the pointer - both point to the same Counter

pc2.value++
fmt.Println(pc1.value) // 1
fmt.Println(pc2.value) // 1

下图示例展示了两种引用的内存布局方式:

变量 c1 和 c2 分别存储于不同的内存地址0x0202和0x0207,每个变量占用各自的4bytes内存空间。而第二个例子中,指针变量 pc1 和 pc2 分别存储在0x0202和0x020a,各自占用8bytes空间,而每个指针变量中保存的值是相同的地址0x0102,这个地址是堆中保存的一个4字节 Counter 实例。

方法接收器

Go 方法可以具有值接收器或指针接收器。在以下情况下需要使用指针接收器:

  • 方法需要修改结构体
  • 结构体很大,复制成本高昂
  • 需要保持一致性(如果某些接收器需要,通常将所有接收器都设置为指针)

与底层 API 接口

某些库和系统调用要求传递内存地址,而不是副本。指针可以实现这一点,同时在 Go 中仍然是类型安全的。

常见的误解

空指针

如果声明了一个指针变量而未对其初始化,那么在解引用该变量时会引起运行时恐慌:

var p *int
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference

这是 Go 语言通知我们试图使用不存在地址的方式,想要安全的使用指针,在解引用之前需要明确给出合法的目标:

x := 42
p := &x          // p points to x
fmt.Println(*p)  // 42

q := new(int)    // allocates memory for an int, initializes it to 0
fmt.Println(*q)  // 0

所以,& 符和 new 函数都可以确定指针指向了合法的内存地址。

引用类型

在 Go 中,所有操作都是按值传递的。但这个值取决于类型的内部表示。例如,切片在内存中的存储方式如下:

struct {
    ptr *ElementType // pointer to the underlying array
    len int          // length of the slice
    cap int          // capacity of the slice
}

当我们将切片传递给函数时,实际传递的是该结构体的副本。ptr 字段仍然指向相同的底层数组,因此在函数内部对切片元素的更改将影响原始切片。

由于这种行为,切片通常被称为引用类型:无需使用指针来共享或修改数据。

Go 语言中的其他引用类型包括映射(map)和通道(chan)。字符串也被视为引用类型,但它们是不可变的,因此无法更改其内容。

请注意,指针本身不是引用类型:它们只是保存内存地址的变量。

更复杂的是,如果将切片传递给函数,然后对其进行重新切片或附加操作,则实际修改的是切片结构体的副本。函数外部的原始切片将不会看到这些更改!

指针接收器

在 Go 中定义结构体方法时,可以选择值接收器或指针接收器。理解两者的区别是编写正确高效的 Go 代码的关键。

  • 值接收器:方法获取结构体的副本。方法内部的任何修改都不会影响原始结构体
  • 指针接收器:方法获取指针的副本,该副本仍然指向原始结构体。方法内部的任何修改都会影响原始结构体

例如:

type Counter struct {
    value int
}

如果使用值接收器,那么不会影响原结构体的内容:

func (c Counter) Inc() {
    c.value++ // INCORRECT: modifies the copy, not the original
}

c := Counter{value: 5}
c.Inc()
fmt.Println(c.value) // still 5

在使用指针接收器时,则会对原结构体产生影响:

func (c *Counter) Inc() {
    // note the shorthand syntax to (*c).value
    c.value++ // CORRECT: modifies the original via the pointer
}

c := Counter{value: 5}
c.Inc()
fmt.Println(c.value) // now 6

虽然该方法接收到的是指针的副本,副本和原始指针都指向内存中(堆中)的同一个结构,因此方法内部的更改会影响原始结构。

Go 的语言习惯

  • 如果不需要修改,小型结构体可以使用值接收器
  • 大型结构体或任何必须修改的结构体都应该使用指针接收器
  • 如果某些方法需要指针接收器,通常建议所有方法都使用指针接收器以保持一致性

指针接收器可以说是 Go 语言中最常见、最实用的指针用法。它们允许方法安全地修改状态,而无需进行不必要的复制。

虽然 Go 指针不如 C/C++ 等语言中的指针强大,但它安全且易于使用。在小型程序中试用它们,很快就会发现它们如何帮助我们编写更高效、更易读的 Go 代码。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注