使用 Golang 的开发者都知道,Go 语言里有指针的概念,它比 C++ 的指针要简单的多,同时你需要记住一个概念:Go 语言是 值传递。我们今天探讨的是在编码的时候到底该使用指针呢还是值类型?在作为参数和返回值的时候该如何去使用?两种传递方式有什么区别?

基础概念

这幅图中展示了常用的值类型和引用类型(引用类型和传引用是两个概念)。在左边是我们常用的一些值类型,函数调用时需要使用指针修改底层数据;而右边是 “引用类型”,我们可以理解为它们的底层都是指针类型,所以右边的类型在使用的时候会有些不同,下文中会举例说明。

举个栗子

type Foo struct {
    Name string
}

var bar = "hello biezhi"

// -------------方法返回值----------------
func returnValue() string {
    return bar
}

func returnPoint() *string {
    return &bar
}

// --------------方法入参-----------------
func modifyNameByPoint(foo *Foo) {
    foo.Name = "emmmm " + foo.Name
}

func nameToUpper(foo Foo) string {
    foo.Name = strings.ToUpper(foo.Name)
    return foo.Name
}

// --------------实例方法-----------------
func (foo Foo) setName(name string) {
    foo.Name = name
}

func (foo *Foo) setNameByPoint(name string) {
    foo.Name = name
}

这里我列出了 3 组方法,分别是指针类型和值类型的示例。这几个方法在编写代码的过程中都会经常遇到,我们从使用者的维度和内存的视角来分析一下这几个方法:

使用区别

大部分人都在讨论函数的入参是指针还是值类型呢?我们先来看看第一组方法,返回值的情况:

s1 := returnValue()
s2 := returnPoint()

fmt.Printf("s1: %v \n", s1)   // s1: hello biezhi 
fmt.Printf("s2: %v \n", *s2)  // s2: hello biezhi 

nilnil*

下面尝试传递参数,分别是指针类型参数和值类型参数:

foo := Foo{Name:"biezhi"}
fmt.Println("foo.name:", foo.Name)          // foo.name: biezhi

modifyNameByPoint(&foo)
fmt.Println("foo.name:", foo.Name)          // foo.name: emmmm biezhi

fmt.Println("foo.name:", nameToUpper(foo))  // foo.name: EMMMM BIEZHI
fmt.Println("foo.name:", foo.Name)          // foo.name: emmmm biezhi

modifyNameByPointfoo&nameToUpperfoonameToUpperfoo.Name

综上例子,我们可以看出 指针类型会修改指向的数据值类型的数据只有在返回的时候被使用,不会修改底层数据

Go 中是值传递,一个方法 / 函数总是获取这个传递的拷贝,只是有一个分配声明给这个参数分配这个数值。拷贝一个指针的值就做了这个指针的拷贝,而不是指针指向的数据(重点理解)。

内存变化

我们使用值类型和指针类型在内存的视角上会有什么不同之处吗?这将使得我们对这两个概念理解更加深入。

返回值的情况

var bar = "hello biezhi"
s1 := returnValue()
s2 := returnPoint()

fmt.Printf("bar: %v address: %p \n", bar, &bar) // 1
fmt.Printf("s1 : %v address: %p \n", s1, &s1)   // 2
fmt.Printf("s2 : %v address: %p \n", *s2, &s2)  // 3

// output
» bar: hello biezhi address: 0x115f480 
» s1 : hello biezhi address: 0xc00000e1e0 
» s2 : hello biezhi address: 0xc00000c030 

%ps1s2
s1s1s1s2*

既然都分配了地址,到底使用值类型还是指针类型作为返回值,我的推荐是这样的:

  • 当返回类型不涉及状态变更并且是较简单的数据结构,一律返回值类型
  • 当返回类型可能遇到状态变更或者你关心它的生命周期则使用指针类型
  • 当返回的结构比较大的时候使用指针类型

方法参数情况

nameToUpper
func nameToUpper(foo Foo) string {
    foo.Name = strings.ToUpper(foo.Name)
    fmt.Printf("nameToUpper foo: %v address: %p \n", foo, &foo) // 2
    return foo.Name
}
foo := Foo{Name:"biezhi"}
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 1

nameToUpper(foo)
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 3

// output
» foo: {biezhi} address: 0xc00000e1e0 
» nameToUpper foo: {BIEZHI} address: 0xc00000e200
» foo: {biezhi} address: 0xc00000e1e0

nameToUpperfoo

在方法内部接收这个 值类型变量 的时候,内存地址和外面不同,这意味着 Go 会将这个值类型参数作为一个拷贝传递过去,在方法内部的改变都不会影响到外面的变量。

modifyNameByPoint
func modifyNameByPoint(foo *Foo) {
    fmt.Printf("modifyNameByPoint foo: %v address: %p \n", foo, &foo) // 2
    foo.Name = "emmmm " + foo.Name
}
foo := &Foo{Name:"biezhi"}
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 1

modifyNameByPoint(foo)
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 3

// output
» foo: &{biezhi} address: 0xc00000c028 
» modifyNameByPoint foo: &{biezhi} address: 0xc00000c038 
» foo: &{emmmm biezhi} address: 0xc00000c028

foo0xc00000c0280xc00000c038

Receiver Type

如果你编写 Java 代码的话经常会看到这样的代码

public class Bar {
    String name;
    public void setName(String name){
        this.name = name;
    }
}

thisthisthis
func (foo *Foo) setNameByPoint(name string) {
    foo.Name = name
}

func (foo Foo) setName(name string) {
    foo.Name = name
}

Receiver Type
foo := Foo{Name:"biezhi"}
foo.setName("2333")
fmt.Println("foo.Name:", foo.Name)  // foo.Name: biezhi

foo.setNameByPoint("2333")
fmt.Println("foo.Name:", foo.Name)  // foo.Name: 2333

setNamefoo
func (foo Foo) setName(name string) {
    fmt.Printf("setName: %v address: %p \n", foo, &foo) // 2
    foo.Name = name
}

func (foo *Foo) setNameByPoint(name string) {
    fmt.Printf("setNameByPoint: %v address: %p \n", foo, &foo) // 4
    foo.Name = name
}
foo := Foo{Name:"biezhi"}
fmt.Printf("src foo: %v address: %p \n", foo, &foo)         // 1

foo.setName("set name")
fmt.Printf("by value foo: %v address: %p \n", foo, &foo)    // 3

foo.setNameByPoint("2333")
fmt.Printf("by point foo: %v address: %p \n", foo, &foo)    // 5

// output
» src foo: {biezhi} address: 0xc00000e1e0 
» setName: {biezhi} address: 0xc00000e200
» by value foo: {biezhi} address: 0xc00000e1e0 
» setNameByPoint: &{biezhi} address: 0xc00000c030 
» by point foo: {2333} address: 0xc00000e1e0

setNameByPoint

一般而言,工程化的项目中会出现非常多结构体定义方法的代码,这些方法的调用也会很频繁,使用结构体将其封装起来,和 Java 中类封装是一样的,大多数情况下建议都使用指针传递,避免值拷贝的情况。

其他类型

mapslice&*
func updateMap(mmp map[string]int)  {
    mmp["biezhi"] = 2333
}

func main() {
    mmp := make(map[string]int)
    mmp["biezhi"] = 1024
    fmt.Printf("src mmp: %v address: %p \n", mmp, &mmp) // 1

    updateMap(mmp)
    fmt.Printf("new mmp: %v address: %p \n", mmp, &mmp) // 2
}

// output
» src mmp: map[biezhi:1024] address: 0xc000094018 
» new mmp: map[biezhi:2333] address: 0xc000094018

slicemap
mapsliceupdateMap
func updateMap(mmp map[string]int) {
    fmt.Printf("param mmp: %v address: %p \n", mmp, &mmp)
    mmp["biezhi"] = 2333
}

再次运行代码

src mmp: map[biezhi:1024] address: 0xc000094018 
input mmp: map[biezhi:1024] address: 0xc00000c038 
new mmp: map[biezhi:2333] address: 0xc000094018

input mmp0xc00000c0380xc000094018

小结

前面我们通过一些代码示例来演示了在 Go 中值类型和指针类型的一些具体表现,最后我们要回答这么几个问题,希望你能够在使用 Go 编程的过程中更加清晰的掌握这些技巧。

Receiver Type 为什么推荐使用指针?

mapslicesync.Mutex

“结构较大” 到底多大才算大可能需要自己或团队衡量,如超过 5 个字段或者根据结构体内占用来计算。

方法参数该使用什么类型?

mapsliceboolintNewEngine() *Engine

参考资料

转载于