go语言中函数参数传值还是传引用的思考

TOC

背景

算起来这些年大大小小也用过一些不同编程语言,但平时开发还是以C++为主,得益于C++精确的语义控制,我可以在编写代码的时候精准地控制每一行代码的行为,以达到预期的目的。但是C++的这种强大的语义控制,就带来了极多的概念和极大的学习成本,几乎逼着使用者不得不去了解该语言中的所有细节行为,以防出现意料之外的情况。新时代的语言如golang等,较之C++就好比美图秀秀对比photoshop(绝非贬义),同样都提供了修图的功能,但是前者屏蔽了诸多细节,更傻瓜式且易于使用,一样能达到好的效果;而后者则提供了更多专业的编辑手段,能够满足更精细化更底层的需求,但是随之而来的就是巨大的学习成本。显然两者各有优劣,但是对当今快速发展的互联网来说,以golang为代表的新时代语言更加能够适应敏捷开发的模式,比较起来,C++这些前辈还是“太重”了。

于是乎,最近开始转向go编程,和以前写JAVA一样遇到了很多细节问题,以后有机会再多总结几篇,今天主要说一下go语言中,函数调用时参数传值和传引用的问题。先说结论,golang中所有函数参数传递都是传值,slice、map和chan看上去像引用只是因为他们内部有指针或本身就是指针而已。后面我们可以看到,使用make方法生产的slice其实是一个含有指针的结构体,而map和slice本身就是一个指针。

C++函数参数的传值和传引用

熟悉C++的程序员们应该都清楚,C++里传递函数参数的时候,传值还是传引用是函数声明的时候决定的。下面几种函数声明方法都很常见:

// 传值
void PassByValue(int a)
void PassByPtr(int* a)
// 传引用
void PassByRef(int& a)

这里我们把C++中形参传指针也归类为传值,因为这里形参copy的是一个指针的副本,本质上还是传值,只不过和调用方的原始指针指向了同一块内存而已,所以函数内针对该内存进行的修改才会反应到外面,看起来像是“传引用”,弄清楚这点很重要。

而如果内部的指针副本在函数体后面指向了其它内存,之后的变化就不会反馈到外面了,若希望同时改变内外指针所指向的内存地址,就需要传递指针的引用,如:

void PassByPtrRed(int*& a)

另一方面,在传递结构体或复合类型做入参时,为了避免拷贝的开销,传常引用的声明方式更是司空见惯:

void PassStructByRef(const std::string& str)

这种传递大结构体时用引用的习惯,自然让人思考go语言里该如何高效地在函数之间传递slice,map等复合类型。

综上所述,对于C++来说,传值还是传引用完全是由程序员自己控制的,这一点也体现了C++的精确语义控制。下面我们来看一看go语言中是怎么样的。

go函数参数一律传值

预声明类型如int,string等,以及普通的命名结构类型没什么好说的,无论是传递该类型的值还是指针作为函数参数,本质上都是传值,这点和C++一样。这里主要讨论slice,map和chan三种复合类型在作为函数参数时的情况。

网上有很多的说法,听到的最多的是slice,map和chan作为参数传递到函数中时是传的引用,其实这个说法不准确,我们不能单纯因为函数内部的修改可以反馈到外面就认为是传递的引用,更何况这种看法还会带来一些语言陷阱

要弄清楚这三者是如何传递的,其实只需要了解它们的数据结构到底是什么样的就可以了。它们都可以通过make内置函数创建,那么我们去追踪一下make函数的实现,看下其返回值,最终我们可以追踪到下面的源码:

// 注:较新版本的go中优化了makeslice函数,返回了一个unsafe.Pointer,但是外层make函数最终返回的还是slice结构体
func makeslice(et *_type, len, cap int) slice {
}

func makemap(t *maptype, hint int, h *hmap) *hmap {
}

func makechan(t *chantype, size int64) *hchan {
}

可以看到,make函数对于slice创建返回的是slice的结构体实例,对于map和chan的创建则返回的是对应的header指针,而slice结构体的定义如下:

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

slice结构体里有一个指向底层数组array的指针,所以slice在作为函数参数传递进去的时候,虽然和map以及chan一样可以修改其中的值,但是内部slice若使用append之类的方法修改了大小,则这部分长度信息的变化不会反馈到外层slice中,甚至会因为底层数组扩容导致内外slice指向了不同的底层数组,进而后续的所有修改也将不会再影响到外部,使用的时候一定要小心;而map和chan因为本质上就是指针,故所有函数内的变动都会反馈到外面,除非在函数内部改变了这些指针指向的内存,如以下这种写法是达不到预期目的的:

func main() {
	var result map[int]int
	makeNewMap(result)
}
func makeNewMap(result map[int]int) {
	result = make(map[int]int)
	result[0] = 0
}

这里main函数中的result调用makeNewMap之后还是nil,而应该用下面的写法替代:

func main() {
	var result map[int]int
	makeNewMap(&result)
	result2 := makeNewMapV2()
}
func makeNewMap(result *map[int]int) {
	*result = make(map[int]int)
	(*result)[0] = 0
}
func makeNewMapV2() (result map[int]int) {
	result = make(map[int]int)
	result[1] = 1
	return result
}

一般来说,我们在函数参数中传递slice、map和chan的时候,除非有上面这种在函数内部改变其所指向内存的需求,我们都不需要刻意传递三者的指针作为参数,因为它们本身传递的时候都不会多一次底层数据拷贝,即便是slice结构体拷贝开销也足够小了。

综上所述,对于go来说,函数参数的传递其实都是传值的方式,go里面真正涉及到引用概念的,大概只有闭包里了,有兴趣的同学可以去研究一下go闭包的实现。

其它——语言习惯上的差异

这里也说一些最近上手使用go一段时间后的一些体验,出于个人习惯,总是不自觉地和C++进行一些对比:

  • C++大结构体通过引用来传递,go用指针,但指针可能是nil的,引用则代表一定存在值,注意两者的区别。通常来说使用指针前都应该判空,这是个好习惯,但这样在go函数体里充满判空语句也会显得比较繁琐。
  • C++的函数参数通过常引用和引用来区分入参和出参,对读代码的人来说一目了然;go里函数参数不存在const修饰符,取而代之的提供了多返回值的特性,故完全可以把入参放到普通参数的位置,而把出参全部作为返回值,同样具有良好的可读性,比如append这些内置函数就是这么做的。
  • go语言里尽量用指针存放结构体,可以避免很多值拷贝,也能避免map类型的value不能取址的问题,这个以后有机会再讲。
  • 与C++里的NULL只用来代表空指针不同,go里的nil含义不仅仅代表空指针,它还可以代表slice这种类型的空结构体,这部分是go底层采用特殊处理的方式实现的。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区