说说不知道的golang中参数传递(代码片段)

腾讯云加社区 腾讯云加社区     2022-11-28     644

关键词:

本文由云+社区发表

导言

几乎每一个C++开发人员,都被面试过有关于函数参数是值传递还是引用传递的问题,其实不止于C++,任何一个语言中,我们都需要关心函数在参数传递时的行为。在golang中存在着map、channel和slice这三种内建数据类型,它们极大的方便着我们的日常coding。然而,当这三种数据结构作为参数传递的时的行为是如何呢?本文将从这三个内建结构展开,来介绍golang中参数传递的一些细节问题。

背景

首先,我们直接的来看一个简短的示例,下面几段代码的输出是什么呢?

//demo1
package main

import "fmt"

func test_string(s string)
    fmt.Printf("inner: %v, %v\n",s, &s)
    s = "b"
    fmt.Printf("inner: %v, %v\n",s, &s)


func main() 
    s := "a"
    fmt.Printf("outer: %v, %v\n",s, &s)
    test_string(s)
    fmt.Printf("outer: %v, %v\n",s, &s)

上文的代码段,尝试在函数test_string()内部修改一个字符串的数值,通过运行结果,我们可以清楚的看到函数test_string()中入参的指针地址发生了变化,且函数外部变量未被内部的修改所影响。因此,很直接的一个结论呼之欲出:golang中函数的参数传递采用的是:值传递

//output
outer: a, 0x40e128
inner: a, 0x40e140
inner: b, 0x40e140
outer: a, 0x40e128

那么是不是到这儿就回答完,本文就结束了呢?当然不是,请再请看看下面的例子:当我们使用的参数不再是string,而改为map类型传入时,输出结果又是什么呢?

//demo2
package main

import "fmt"

func test_map(m map[string]string)
    fmt.Printf("inner: %v, %p\n",m, m)
    m["a"]="11"
    fmt.Printf("inner: %v, %p\n",m, m)


func main() 

    m := map[string]string
        "a":"1",
        "b":"2",
        "c":"3",
    
    
    fmt.Printf("outer: %v, %p\n",m, m)
    test_map(m)
    fmt.Printf("outer: %v, %p\n",m, m)

根据我们前文得出的结论,按照值传递的特性,我们毫无疑问的猜想:函数外两次输出的结果应该是相同的,同时地址应该不同。然而,事实却正是相反:

//output
outer: map[a:1 b:2 c:3], 0x442260
inner: map[a:1 b:2 c:3], 0x442260
inner: map[a:11 b:2 c:3], 0x442260
outer: map[b:2 c:3 a:11], 0x442260

没错,在函数test_map()中对map的修改再函数外部生效了,而且函数内外打印的map变量地址竟然一样。做技术开发的人都知道,在源代码世界中,如果地址一样,那就必然是同一个东西,也就是说:这俨然成为了一个引用传递的特性了。

两个示例代码的结果竟然截然相反,如果上述的内容让你产生了疑惑,并且你希望彻底的了解这过程中发生了什么。那么请阅读完下面的内容,跟随作者一起从源码透过现象看本质。本文接下来的内容,将对golang中的map、channel和slice三种内建数据结构在作为函数参数传递时的行为进行分析,从而完整的解析golang中函数传递的行为。

迷惑人心的Map

Golang中的map,实际上就是一个hashtable,在这儿我们不需要了解其详细的实现结构。回顾一下上文的例子我们首先通过make()函数(运算符:=是make()的语法糖,相同的作用)初始化了一个map变量,然后将变量传递到test_map()中操作。

众所周知,在任何语言中,传递指针类型的参数才可以实现在函数内部直接修改内容,如果传递的是值本身的,会有一次拷贝发生(此时函数内外,该变量的地址会发生变化,通过第一个示例可以看出),因此,在函数内部的修改对原外部变量是无效的。但是,demo2示例中的变量却完全没有拷贝发生的迹象,那么,我们是否可以大胆的猜测,通过make()函数创建出来的map变量会不会实际上是一个指针类型呢?这时候,我们便需要来看一下源代码了:

// makemap implements Go map creation for make(map[k]v, hint).
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If h.buckets != nil, bucket pointed to can be used as the first bucket.
func makemap(t *maptype, hint int, h *hmap) *hmap 
    if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) 
        hint = 0
    
    ...

上面是golang中的make()函数在map中通过makemap()函数来实现的代码段,可以看到,与我们猜测一致的是:makemap()返回的是一个hmap类型的指针hmap。也就是说:test_map(map)实际上等同于test_map(hmap)。因此,在golang中,当map作为形参时,虽然是值传递,但是由于make()返回的是一个指针类型,所以我们可以在函数哪修改map的数值并影响到函数外。

我们也可以通过一个不是很恰当的反例来证明这点:

//demo3
package main

import "fmt"

func test_map2(m map[string]string)
    fmt.Printf("inner: %v, %p\n",m, m)
    m = make(map[string]string, 0)
    m["a"]="11"
    fmt.Printf("inner: %v, %p\n",m, m)


func main() 
    var m map[string]string//未初始化
    
    fmt.Printf("outer: %v, %p\n",m, m)
    test_map2(m)
    fmt.Printf("outer: %v, %p\n",m, m)

由于在函数test_map2()外仅仅对map变量m进行了声明而未初始化,在函数test_map2()中才对map进行了初始化和赋值操纵,这时候,我们看到对于map的更改便无法反馈到函数外了。

//output
outer: map[], 0x0
inner: map[], 0x0
inner: map[a:11], 0x442260
outer: map[], 0x0

跟风的Channel

在介绍完map类型作为参数传递时的行为后,我们再来看看golang的特殊类型:channel的行为。还是通过一段代码来来入手:

//demo4
package main

import "fmt"


func test_chan2(ch chan string)
    fmt.Printf("inner: %v, %v\n",ch, len(ch))
    ch<-"b"
    fmt.Printf("inner: %v, %v\n",ch, len(ch))


func main() 
    ch := make(chan string, 10)
    ch<- "a"
    
    fmt.Printf("outer: %v, %v\n",ch, len(ch))
    test_chan2(ch)
    fmt.Printf("outer: %v, %v\n",ch, len(ch))

结果如下,我们看到,在函数内往channel中塞入数值,在函数外可以看到channel的size发生了变化:

//output
outer: 0x436100, 1
inner: 0x436100, 1
inner: 0x436100, 2
outer: 0x436100, 2

在golang中,对于channel有着与map类似的结果,其make()函数实现源代码如下:

func makechan(t *chantype, size int) *hchan 
    elem := t.elem
  ...

也就是make() chan的返回值为一个hchan类型的指针,因此当我们的业务代码在函数内对channel操作的同时,也会影响到函数外的数值。

与众不同的Slice

对于golang中slice的行为,可以总结一句话:与众不同。首先,我们来看下golang中对于slice的make实现代码

func makeslice(et *_type, len, cap int) slice 
  ...

我们发现,与map和channel不同的是,sclie的make函数返回的是一个内建结构体类型slice的对象,而并非一个指针类型,其中内建slice的数据结构如下:

type slice struct 
    array unsafe.Pointer
    len   int
    cap   int

也就是说,如果采用slice在golang中传递参数,在函数内对slice的操作是不应该影响到函数外的。那么,对于下面的这段示例代码,运行的结果又是什么呢?

//demo5
package main

import "fmt"

func main() 
    
    sl := []string
        "a",
        "b",
        "c",
    
    
    fmt.Printf("%v, %p\n",sl, sl)
    test_slice(sl)
    fmt.Printf("%v, %p\n",sl, sl)



func test_slice(sl []string)
    fmt.Printf("%v, %p\n",sl, sl)
    sl[0] = "aa"
    //sl = append(sl, "d")
    fmt.Printf("%v, %p\n",sl, sl)

通过运行结果,我们看到,在函数内部对slice中的第一个元素的数值修改成功的返回到了test_slice()函数外层!与此同时,通过打印地址,我们发现也显示了是同一个地址。到了这儿,似乎又一个奇怪的现象出现了:makeslice()返回的是值类型,但是当该数值作为参数传递时,在函数内外的地址却未发生变化,俨然一副指针类型。

//output
[a b c], 0x442260
[a b c], 0x442260
[aa b c], 0x442260
[aa b c], 0x442260

这时候,我们还是回归源码,回顾一下上面列出的golang内部slice结构体的特点。没错,细心地读者可能已经发现,内部slice中的第一个元素用来存放数据的结构是个指针类型,一个指向了真正的存放数据的指针!因此,虽然指针拷贝了,但是指针所指向的地址却未更改,而我们在函数内部修改了指针所指向的地方的内容,从而实现了对元素修改的目的了。

让我们再进阶一下上面的示例,将注释的那行代码打开:

sl = append(sl, "d")

再重新运行上面的代码,得到的结果又有了新的变化:

//output
[a b c], 0x442280
[a b c], 0x442280
[aa b c d], 0x442280
[aa b c], 0x442280

函数内我们修改了slice中一个已有元素,同时向slice中append了另一个元素,结果在函数外部:

  • 修改的元素生效了;
  • append的元素却消失了。

其实这就是由于slice的结构引起的了。我们都知道slice类型在make()的时候有个len和cap的可选参数,在上面的内部slice结构中第二和第三个成员变量就是代表着这俩个参数的含义。我们已知原因,数据部分由于是指针类型,这就决定了在函数内部对slice数据的修改是可以生效的,因为值传递进去的是指向数据的指针。而同一时刻,表示长度的len和容量的cap均为int类型,那么在传递到函数内部的就仅仅只是一个副本,因此在函数内部通过append修改了len的数值,但却影响不到函数外部slice的len变量,从而,append的影响便无法在函数外部看到了。

解释到这儿,基本说清了golang中map、channel和slice在函数传递时的行为和原因了,但是,喜欢提问的读者可能一直觉得有哪儿是怪怪的,这个时候我们来完整的整理一下已经的关于slice的信息和行为:

  1. makeslice()出来的一定是个结构体对象,而不是指针;
  2. 函数内外打印的slice地址一致;
  3. 函数体内对slice中元素的修改在函数外部生效了;
  4. 函数体内对slice进行append操作在外部没有生效;

没错了,对于问题1、3和4我们应该都已经解释清楚了,但是,关于第2点为什么函数内外对于这三个内建类型变量的地址打印却是一致的?我们已经更加确定了golang中的参数传递的确是值类型,那么,造成这一现象的唯一可能就是出在打印函数fmt.Printf()中有些小操作了。因为我们是通过%p来打印地址信息的,为此,我们需要关注的是fmt包中fmtPointer():

func (p *pp) fmtPointer(value reflect.Value, verb rune) 
    var u uintptr
    switch value.Kind() 
    case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer:
        u = value.Pointer()
    default:
        p.badVerb(verb)
        return
    
    ...

我们发现在fmtPointer()中,对于map、channel和slice,都被当成了指针来处理,通过Pointer()函数获取对应的值的指针。我们知道channel和map是因为make函数返回的就已经是指针了,无可厚非,但是对于slice这个非指针,在value.Pointer()是如何处理的呢?

// If v‘s Kind is Slice, the returned pointer is to the first
// element of the slice. If the slice is nil the returned value
// is 0.  If the slice is empty but non-nil the return value is non-zero.
func (v Value) Pointer() uintptr 
    // TODO: deprecate
    k := v.kind()
    switch k 
    case Chan, Map, Ptr, UnsafePointer:
        return uintptr(v.pointer())
    case Func:
        ...
    case Slice:
        return (*SliceHeader)(v.ptr).Data
    
    ...

果不其然,在Pointer()函数中,对于Slice类型的数据,返回的一直是指向第一个元素的地址,所以我们通过fmt.Printf()中%p来打印Slice的地址,其实打印的结果是内部存储数组元素的首地址,这也就解释了问题2中为什么地址会一致的原因了。

总结

通过上述的一系列总结,我们可以很高兴的确定的是:在golang中的传参一定是值传递了!

然而golang隐藏了一些实现细节,在处理map,channel和slice等这些内置结构的数据时,其实处理的是一个指针类型的数据,也是因此,在函数内部可以修改(部分修改)数据的内容。

但是,这些修改得以实现的原因,是因为数据本身是个指针类型,而不是因为golang采用了引用传递,注意二者的区别哦~

此文已由作者授权腾讯云+社区在各渠道发布

获取更多新鲜技术干货,可以关注我们腾讯云技术社区-云加社区官方号及知乎机构号

golang中array与slice(代码片段)

在golang中有数组和Slice两种数据结构,Slice是基于数组的实现,是长度动态不固定的数据结构,本质上是一个对数组字序列的引用,提供了对数组的轻量级访问。那么在go的函数中以数组或Slice为形参的时候就存在一些差别。?首先... 查看详情

golang中设置函数默认参数的优雅实现(代码片段)

在Golang中,我们经常碰到要设置一个函数的默认值,或者说我定义了参数值,但是又不想传递值,这个在python或php一类的语言中很好实现,但Golang中好像这种方法又不行。今天在看Grpc源码时,发现了一个方法可以很优雅的实现... 查看详情

golang"..."用法(代码片段)

Golang的一种语法糖,大抵有三种用法:当函数函数需要传入多个不定参数时,...可以接受多个不确定数量的参数将切片中元素打散进行传递定义数组时,表示参数不定,suchas:[...]intpackagemainimport"fmt"//当函数函数需要传入... 查看详情

golang之不安全编程(代码片段)

...的也就是指针了,只有指针才可能导致不安全问题。我们知道golang中是有指针的,但是golang的指针并不能像C语言中的指针一样,可以进行运算,所以golang中的指针既提供了指针的便利性,又保证了安全。但是在golang中,可以通... 查看详情

golang-指针与引用(代码片段)

?Golang有指针,那么一切数据都是值传递吗?都需要用户进行指针传递吗,其实不然,对于Go语言,虽然有指针,但是其也有引用传递.是不是很绕,因为引用传递就是指针传递哇.我们继续解释.概念在Go语言中,对于布尔变量或数值类型或字... 查看详情

golang中数组与切片的区别(代码片段)

初始化:数组需要指定大小,不指定也会根据初始化的自动推算出大小,不可改变数组:a:=[...]int1,2,3a:=[3]int1,2,3切片:a:=[]int1,2,3a:=make([]int,5)a:=make([]int,5,10)函数传递:数组需要明确指定大小,切片不需要。数组是值传递,切片是... 查看详情

5函数(代码片段)

1golang函数特点  1.不支持重载,一个包中不能有2个同名的函数  2.函数是一等公民,函数也是一种类型,一个函数可以赋值给变量  3.匿名函数  4.多返回值2函数参数传递方式  1.值传递  2.引用传递  需要注意的是,... 查看详情

说说java中你不知道switch关键字的奥秘(代码片段)

Switch语法switch作为Java内置关键字,却在项目中真正使用的比较少。关于switch,还是有那么一些奥秘的。要什么switch,我有if-else确实,项目中使用switch比较少的一个主要原因就在于它的作用能被if-else代替,况且switch对类型的限制... 查看详情

有没有办法在接口 [Golang] 中传递多个参数? [关闭]

】有没有办法在接口[Golang]中传递多个参数?[关闭]【英文标题】:Isthereanywaytopassmultipleargumentsininterface[Golang]?[closed]【发布时间】:2021-11-0604:39:17【问题描述】:有没有办法传递多个参数?在这里,我分享我正在尝试调试的代码... 查看详情

解决方法参数传递中的疑惑(代码片段)

首先我们需要知道java中参数传递的,如果是基本类型的值,那么在方法中传的就是复制后的基本类型的值,对形参的修改不会改变实参。publicclassBlogWritepublicstaticvoidmain(String[]args)intn=555;changeValue(n);System.out.println(n);... 查看详情

jwt身份验证错误参数3传递给lcobuccijwtsignerhmac::doverify()(代码片段)

...的教程。它运作良好,但现在显示错误并不总是,但我不知道为什么。就是这个:传递给LcobucciJWTSignerHmac::doVerify()的参数3必须是LcobucciJWTSignerKey的实例,null给定,在C:xampphtdocsinmobiliariavendorlcobuccijwtsrc中调用SignerBaseSigner.php在... 查看详情

深入理解java中方法的参数传递机制(代码片段)

形参和实参我们知道,在Java中定义方法时,是可以定义参数的,比如:publicstaticvoidmain(String[]args)这里的args就是一个字符串数组类型的参数。在程序设计语言中,参数有形式参数和实际参数之分,先来看下它们的定义:形式参数... 查看详情

使用参数在python脚本之间传递函数(代码片段)

...中:当我将参数添加到函数中时,会发生这种情况:我不知道我应该怎么称呼这个功能。任何想法谢谢。答案您只需要导入该函数,然后就可以调用该函数。fromDatabaseimportDBRegister然后你可以正常调用该函数。DBRegister(username,First 查看详情

js--函数参数(你可能不知道的参数传递)

前言:函数分为有参有返回值,有参无返回值,无参无返回值,无参有返回值;那么对于无参数的函数你想使用函数的调用怎么办呢?如果你想封装一个代码,实现多种功能,但是形参大于实参或者实参大于形参又该如何?本文... 查看详情

[golang]语法基础之接口(代码片段)

...ing、map等是不一样的。一般来说,具体的类型,我们可以知道它是什么,并且可以知道它可以用来做什么。但是对于接口来说,接口是抽象的,只有一组接口方法,我们完全不需要关心接口当中的这些方法是如何实现的,我们只... 查看详情

golang中如何使用多参数属性传参

...决这个问题。但是一般我们直接写已知代码即所有的值都知道一个一个塞进去就好了,但是绝大部分我们是得到用户的大量输入想通过循环传入,但是这样发现无法使用这个多值参数的功能。其实底层实现将多个参数视为传入的... 查看详情

golang✔️走进go语言✔️第八课函数(代码片段)

【Golang】✔️走进Go语言✔️第八课概述函数函数定义函数传递参数函数传递地址函数闭包概述Golang是一个跨平台的新生编程语言.今天小白就带大家一起携手走进Golang的世界.(第8课)函数函数(Function)是基本的代码的代码块,用于执... 查看详情

golang✔️走进go语言✔️第八课函数(代码片段)

【Golang】✔️走进Go语言✔️第八课概述函数函数定义函数传递参数函数传递地址函数闭包概述Golang是一个跨平台的新生编程语言.今天小白就带大家一起携手走进Golang的世界.(第8课)函数函数(Function)是基本的代码的代码块,用于执... 查看详情