从多种语言看传值传引用

最近学习golang,顺带复习一下传值和传引用的概念。

传值:函数传递的是参数的一个副本,将传入的变量在内存中复制一份进行操作,本质上是存储在不同内存地址的不同变量

传引用:传引用是指函数通过内存地址将函数取出进行操作,本质上是存储在相同内存地址的相同变量

C

C语言的传参没有传引用,只有传值。那C语言是如何做到通过调用函数修改函数体外的实参的呢?答案是传址调用。经常有人把C语言的传值调用和传址调用并列比较,在我看来,传址调用只不过是传值调用的子集。当传的值是地址的时候,传值调用就是传址调用了。

传值调用的时候,函数会给形参a单独分配一个内存,实参a会把值复制到形参a的内存中,对形参的操作是在形参对应的内存里操作,这是完全独立于实参的。如果参数是一个地址的话,这里举一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
void modify(int* a)
{
printf("address of pointer is:%p\n", &a);
*a = 2;
}

int main()
{
int a = 1;
int* p = &a;
printf("address of pointer is:%p\n", &p);
modify(p);
printf("%d", a);
return 0;
}

指针是指向变量的地址,假如传入的参数是指针,函数则会为这个指针分配一个空间来存放指针的值(说得绕口一点,就是另分配一个地址来存放变量的地址,是变量地址的地址)。所以上述代码的运行结果是
1
2
3
address of pointer is:0x7ffee9cdb490
address of pointer is:0x7ffee9cdb468
2

也就是说,在两处地址中均存着指向a的指针,地址不同,但存放的指针的值是相同的。通过这个指针,就能读取并修改实参的值。

Go

go的函数传参和C类似(毕竟是增强版C语言嘛),是绝对的传值,当指针作为函数参数时,调用过程和C也一样,也是临时申请一个空间存放指针的值,然后通过这个指针修改实参。但Go有三种自带的数据类型mapchan,和slice,他们可以认为是一种引用类型,虽然调用的时候不需要加&符号,但实际上也是一种传址

map

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

import "fmt"

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

func main() {
m := map[string]int{
"a": 1,
"b": 2,
"c": 3,
}
fmt.Printf("Outer: %v %p\n", m, m)
testMap(m)
fmt.Printf("Outer: %v %p\n", m, m)
}

输出:

1
2
3
4
Outer: map[a:1 b:2 c:3] 0xc000064180
inner: map[a:1 b:2 c:3], 0xc000064180
inner: map[a:11 b:2 c:3], 0xc000064180
Outer: map[a:11 b:2 c:3] 0xc000064180

没错,指针是一样的,说明形参m和实参m的地址是一样的,而map并不是以地址形式传入的呀,那为什么这里发生了类似“传引用”的情况呢?看一下makemap的源码就知道:
1
2
3
4
5
func makemap(t *maptype, hint int, h *hmap) *hmap {
if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) {
hint = 0
}
...

可以看到,makemap返回的类型是一个指针类型*hmap,也就是说:testMap(map)实际上等同于testMap(*hmap)。因此,在golang中,当map作为形参时,虽然是值传递,但是由于make()返回的是一个指针类型,也就和传指针是一样的效果了。

chan

chan类型和map类型类似,看一下makechan源码:

1
2
3
func makechan(t *chantype, size int) *hchan {
elem := t.elem
...

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

slice

slice和map/chan略有区别,先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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"
fmt.Printf("%v, %p\n", sl, sl)
sl = append(sl, "d")
fmt.Printf("%v, %p\n", sl, sl)
}

运行结果:
1
2
3
4
5
[a b c], 0xc000090180
[a b c], 0xc000090180
[aa b c], 0xc000090180
[aa b c d], 0xc0000b0120
[aa b c], 0xc000090180

可以看出,修改的部分生效了,但append追加的部分却没有生效,这是因为,slice在Go的实现中,是一个结构体,对应源码如下:
1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

array是具体数据,len和cap分别是长度和承载量。数据array部分由于是指针类型,所以在函数内部对slice数据的修改是可以生效的。而同一时刻,表示长度的len和容量的cap均为int类型,那么在传递到函数内部的就仅仅只是一个副本,因此在函数内部通过append修改了len的数值,影响不到函数外部slice的len变量。

也就是说,slice还是以值的形式传入函数中的,函数对slice实参进行拷贝得到了一个slice形参,在对slice形参修改的过程中,因为array部分是指针,所以能修改到实参里的array,而len和cap是int类型,所以只能修改副本。那既然形参是实参的拷贝,为什么打印出来的地址值是一样的呢?这就是printf的实现细节了,继续看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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
}
...
}

我们可以看到,在打印的时候,对于slice类型的数据,打印的是Data的地址值,也就是前文array的地址值。所以,虽然slice形参和slice实参实际的地址并不一样,但是由于array地址一样,所以输出的地址就是一样的

总结

Go只能传值,指针的传值类似C语言,map和chan因为底层就是指针,所以看上去像传引用,实际上只不过传的是经过封装的指针,而slice,底层是结构体(类),在传参过程中,妥妥的传值、拷贝,但是由于成员变量array是地址,所以能影响到实参,又因为fmt打印地址的时候,输出的是array的地址,所以看似形参实参地址相同

是不是Go里没有引用呢?

在之前讲闭包的时候,提到过一个引用环境的概念,引用环境就是对自由变量的引用,例如:

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

import "fmt"

func swap_(fn func()) {
fn()
}

func main() {
a := 0
b := 1
fmt.Println("a: ", a)
swap := func() {
a, b = b, a
}
swap_(swap)
fmt.Println("a: ", a)
swap_(swap)
fmt.Println("a: ", a)
}

输出:
1
2
3
a:  0
a: 1
a: 0

由此可见,a和b被捕获到了闭包中,闭包中的逻辑之所以可以具有记忆性,就是因为通过引用修改了变量,变量随着闭包的生命周期存在

C++

C++在C传值/传地址的基础上,还有一个传引用的特性,看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
void modify(int& a)
{
a = 2;
}

int main()
{
int a = 1;
modify(a);
printf("%d", a); // 输出2
return 0;
}

引用传递的形参加一个&符号,这个形参相当于实参的一个别名,对形参的操作都相当于对实参的操作。

Java

先得解释一下Java的一些术语

  1. 基本数据类型、引用类型定义
    • 基本数据类:Java 中有八种基本数据类型“byte、short、int、long、float、double、char、boolean”
    • 引用类型:new 创建的实体类、对象、及数组
  2. 基本数据类型、引用类型在内存中的存储方式
    • 基本数据类型:存放在栈内存中。用完就消失。
    • 引用类型:在栈内存中存放引用堆内存的地址,在堆内存中存储类、对象、数组等。当没用引用指向堆内存中的类、对象、数组时,由 GC回收机制不定期自动清理。

Java这里也很类似Go,Java也是只有值传递。Java没有指针,和Go里传入参数是指针(引用类型)的情况相似,当Java传入引用类型时,会先给形参一个与实参相同的地址(此处与 C++ 的不同之处是,C++ 是别名,没有在内存中给形参开辟空间,而 Java 给形参开辟了一个栈内存空间,存放与实参相同的引用地址)。Java在进行方法调用修改了引用类型形参后,会影响到实参的值,这和Go的结果也是一样的。

但是Java有一点要注意的是,一些特殊的类,比如String,包装类,虽然是引用类型,但是它们每次赋值的时候会重新创建对象,也就是说,它们身为引用类型的特性已经被破坏了,修改形参是不能对实参构成影响的。

Python

python目前还不熟悉,待填坑~