最近学习golang,顺带复习一下传值和传引用的概念。
传值:函数传递的是参数的一个副本,将传入的变量在内存中复制一份进行操作,本质上是存储在不同内存地址的不同变量
传引用:传引用是指函数通过内存地址将函数取出进行操作,本质上是存储在相同内存地址的相同变量
C
C语言的传参没有传引用,只有传值。那C语言是如何做到通过调用函数修改函数体外的实参的呢?答案是传址调用。经常有人把C语言的传值调用和传址调用并列比较,在我看来,传址调用只不过是传值调用的子集。当传的值是地址的时候,传值调用就是传址调用了。
传值调用的时候,函数会给形参a单独分配一个内存,实参a会把值复制到形参a的内存中,对形参的操作是在形参对应的内存里操作,这是完全独立于实参的。如果参数是一个地址的话,这里举一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
3address of pointer is:0x7ffee9cdb490
address of pointer is:0x7ffee9cdb468
2
也就是说,在两处地址中均存着指向a的指针,地址不同,但存放的指针的值是相同的。通过这个指针,就能读取并修改实参的值。
Go
go的函数传参和C类似(毕竟是增强版C语言嘛),是绝对的传值,当指针作为函数参数时,调用过程和C也一样,也是临时申请一个空间存放指针的值,然后通过这个指针修改实参。但Go有三种自带的数据类型map,chan,和slice,他们可以认为是一种引用类型,虽然调用的时候不需要加&符号,但实际上也是一种传址。
map
1 | package main |
输出:1
2
3
4Outer: 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
5func 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
3func 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
24package 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
5type 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
13func (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
20package 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
3a: 0
a: 1
a: 0
由此可见,a和b被捕获到了闭包中,闭包中的逻辑之所以可以具有记忆性,就是因为通过引用修改了变量,变量随着闭包的生命周期存在
C++
C++在C传值/传地址的基础上,还有一个传引用的特性,看一个例子1
2
3
4
5
6
7
8
9
10
11
12
13
void modify(int& a)
{
a = 2;
}
int main()
{
int a = 1;
modify(a);
printf("%d", a); // 输出2
return 0;
}
引用传递的形参加一个&符号,这个形参相当于实参的一个别名,对形参的操作都相当于对实参的操作。
Java
先得解释一下Java的一些术语
- 基本数据类型、引用类型定义
- 基本数据类:Java 中有八种基本数据类型“byte、short、int、long、float、double、char、boolean”
- 引用类型:new 创建的实体类、对象、及数组
- 基本数据类型、引用类型在内存中的存储方式
- 基本数据类型:存放在栈内存中。用完就消失。
- 引用类型:在栈内存中存放引用堆内存的地址,在堆内存中存储类、对象、数组等。当没用引用指向堆内存中的类、对象、数组时,由 GC回收机制不定期自动清理。
Java这里也很类似Go,Java也是只有值传递。Java没有指针,和Go里传入参数是指针(引用类型)的情况相似,当Java传入引用类型时,会先给形参一个与实参相同的地址(此处与 C++ 的不同之处是,C++ 是别名,没有在内存中给形参开辟空间,而 Java 给形参开辟了一个栈内存空间,存放与实参相同的引用地址)。Java在进行方法调用修改了引用类型形参后,会影响到实参的值,这和Go的结果也是一样的。
但是Java有一点要注意的是,一些特殊的类,比如String,包装类,虽然是引用类型,但是它们每次赋值的时候会重新创建对象,也就是说,它们身为引用类型的特性已经被破坏了,修改形参是不能对实参构成影响的。
Python
python目前还不熟悉,待填坑~