0%

值传递与引用传递

基础概念

通过一段C++示例代码来理清几个概念。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;
int main () {
int num = 20; // 实际变量的声明
int *p; // 指针变量的声明
p = &num; // 在指针变量中存储 num 的地址,即,指针的值为 num 的地址

// 输出实际变量的值
cout << "Value of `num` variable: ";
cout << num << endl;

// 输出在指针变量中存储的地址
cout << "Address stored in `p` variable: ";
cout << p << endl;

// 访问指针中地址的值
cout << "Value of `*p` variable: ";
cout << *p << endl;

return 0;
}

运行结果:

1
2
3
Value of `num` variable: 20
Address stored in `p` variable: 0x7ffe4705e3ac
Value of `*p` variable: 20

alt

值 / 地址

值 - 保存在内存(Heap 或 Stack)中的数据,eg. 20
地址 - 指的是内存地址,通过内存地址可以访问到内存中对应的数据,eg. 0xff10xff2

变量名与符号表

变量名是一种标识符,在编译或解释过程中结合符号表转换成对应数据的内存地址。
符号表(Symbol Table)是一种用于语言翻译器(eg. 编译器、解释器)中的数据结构。
在符号表中,程序源代码中的每个标识符都和它的声明或使用信息绑定在一起,比如其数据类型、作用域以及内存地址。
符号表一般通过哈希表(或线性表)来实现,变量名为哈希表的键,内存地址为哈希表的值。
比如,C语言中,所有变量通过编译器的符号表都被替换成了内存地址。
PHP中,所有变量在解析执行的过程中,通过符号表获取指向的 zval 的内存地址。

指针 / 指针变量 / 变量指针

指针 和 指针变量是两个不同概念。
通常,我们表述时会把 指针变量 简称为 指针;但严格来讲,指针 只是概念,指针变量 是具体实现
即,指针 也是一个变量,对于指针的定义,与普通变量一样;不同于普通变量,指针变量 是存放内存地址的变量。
变量指针 指的是 变量的指针,即,指向该变量的指针;此时指针的值为该变量的地址。
比如上例中,*p 就是指针变量,p 就是 num 变量的指针;& 为地址操作符,* 为解引用操作符。

引用 / 指针

引用是C++引入的重要机制,可以理解为变量的别名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int num = 20;
cout << "Value of `num` variable: ";
cout << num << endl;
cout << "Address of `num` variable: ";
cout << &num << endl;

int &r = num; // 也可以写成 int& r = num
// 引用变量即表示初始化时指向的对象
cout << "Value of `r` variable: ";
cout << r << endl;
cout << "Address of `r` variable: ";
cout << &r << endl;

r = 30;
cout << "Value of `r` variable: ";
cout << r << endl;
cout << "Address of `r` variable: ";
cout << &r << endl;
cout << "Value of `num` variable: ";
cout << num << endl;
/*
运行结果:
Value of `num` variable: 20
Address of `num` variable: 0x7ffed2dabb94
Value of `r` variable: 20
Address of `r` variable: 0x7ffed2dabb94
Value of `r` variable: 30
Address of `r` variable: 0x7ffed2dabb94
Value of `num` variable: 30
*/

其中,&r 是引用变量,引用本身也是一个变量,它存放的是被引用对象的地址
这里的 & 不是地址操作符,只是一个标识,用来表示 r 是变量 num 的引用,即 num 的别名;
num 和 r 都可以表示数值为20的内存块,对 r 赋值,就是修改 num 和 r 指向的内存块。
引用变量实际上是按照指针常量(即,指针本身是常量,不能改变)的方式实现的

1
2
int num = 10;
const int *p = &num;

语言层面上,主要有如下区别:

  • 引用必须在声明时初始化,而指针可以在声明之后再初始化。
  • 引用必须指向合法的内存块,即,不存在空引用。
  • 引用初始化后无法被指向另一个对象,而指针可以在任何时候指向另一个对象。

下面分别来探究下几个热门语言的实现。

PHP中的值传递与引用传递

要弄清PHP的值传递与引用传递,还得从变量存储、赋值、引用等方面的底层实现入手。

Zval 和 符号表

PHP的底层是C语言,变量是基于C的结构体来实现的:

1
2
3
4
5
6
7
8
9
10
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L334
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};
// https://github.com/php/php-src/blob/PHP-5.6.40/Zend/zend_types.h#L55
typedef struct _zval_struct zval;

Zval 结构体中,refcount__gc 是一个计数器,用以记录指向该 zval 的变量(也称符号,即 Symbol)个数;
初始化变量时,refcount 为 1,变量间赋值会令 zval 的 refcount 加 1;
unset 变量时除了删除符号表记录,变量指向的 zval 的 recount 也会减 1。
is_ref__gc 用于标识这个变量是否属于引用集合(reference set),以便区分普通变量和引用变量;
不同于C++,PHP的引用本质上是符号表别名

1
2
3
4
5
6
7
$a = ['aaa'];
$b = $a;
$c = &$a;
array_push($a, 'bbb');
xdebug_debug_zval('a');
xdebug_debug_zval('b');
xdebug_debug_zval('c');

运行结果(浏览器访问):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a:
(refcount=2, is_ref=1),
array (size=2)
0 => (refcount=2, is_ref=0),string 'aaa' (length=3)
1 => (refcount=1, is_ref=0),string 'bbb' (length=3)
b:
(refcount=1, is_ref=0),
array (size=1)
0 => (refcount=2, is_ref=0),string 'aaa' (length=3)
c:
(refcount=2, is_ref=1),
array (size=2)
0 => (refcount=2, is_ref=0),string 'aaa' (length=3)
1 => (refcount=1, is_ref=0),string 'bbb' (length=3)

注:xdebug_debug_zval 是 Xdebug 扩展提供的用于打印 zval 的函数。
alt

  • 在当前作用域的符号表中插入新的符号 a,由于该变量是一个普通变量,因此会生成一个 refcount=1is_ref=0 的 zval 容器。
  • 变量 a 赋值给变量 b,zval 的 refcount 加 1,即,refcount=2
  • 变量 a 引用赋值给变量 c,则 c 也指向 zval。
    a、c 指向的 zval 的 is_ref=1,此时 a、c 称为引用变量。
    同时,原本与 a 共享 zval 的 b 指向了新的 zval。为什么?

Copy On Write

为了减少内存占用,PHP变量赋值被设计为变量指向同一个 zval(共享),但既然是同一个 zval,为什么修改变量不会影响到其他变量呢?
PHP 的 Copy On Write (简称 COW,写时复制)机制

  • 在修改一个变量之前,会先检查这个变量对应 zval 的 refcount,如果大于 1,则复制出一个新的 zval (is_ref=0, refcount=1)
  • 原 zval 的 refcount 减 1,并修改符号表,使修改的变量指向新的 zval(分离)。
  • 对于引用赋值的情况,通过判断 zval 的 is_ref 是否为 1 来决定是否分离。
1
2
3
4
5
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L791
#define SEPARATE_ZVAL_IF_NOT_REF(ppzv) \
if (!PZVAL_IS_REF(*ppzv)) { \
SEPARATE_ZVAL(ppzv); \
}

引用 和 分离

当执行 $c = &$a 时:
PHP发现要操作的 zval 的 refcount 大于 1,则,将 $b 分离出去(复制出一个新的 zval (is_ref=0, refcount=1),原 zval 的 refcount 减 1);
同时,$c 指向原 zval,原 zval 的 refcount 加 1,即,refcount=2

变量作用域

PHP通过不同的符号表实现变量的作用域,除了全局符号表,每个函数、类、命名空间等都有自己独立的符号表。
当创建一个变量时,PHP会为这个变量分配一个 zval,填入相应的变量值,然后将这个变量的名字和指向这个 zval 的指针填入一个符号表中。
当获取这个变量时,PHP会通过查找这个符号表,获得对应的 zval。
PHP符号表分全局符号表和活动符号表,定义在 _zend_executor_globals 结构体(这个结构体用于在执行器中保存一些执行相关的上下文信息)里:

1
2
3
4
5
6
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_globals.h#L168
struct _zend_executor_globals {
...
HashTable *active_symbol_table;
HashTable symbol_table; /* main symbol table */
...

其中,全局符号表,保存了在顶层作用域(即不在任何函数 / 对象内)的变量。
每当调用一个函数(或对象的方法)时,就会为这个函数创建一个活动符号表,所有在这个函数内定义的变量,都会保存在这个活动符号表里。

值传递 / 引用传递

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
$a = 'aaa';
$b = 'bbb';
function test($x, &$y) {
echo 'x:'.$x.PHP_EOL;
$x = 'xxx';
echo 'x:'.$x.PHP_EOL;
echo 'y:'.$y.PHP_EOL;
$y = 'yyy';
}
test($a, $b);
echo 'a:'.$a.PHP_EOL;
echo 'b:'.$b;

运行结果:

1
2
3
4
5
x:aaa
x:xxx
y:bbb
a:aaa
b:yyy

alt

  • 参数 $x$y 是保存在函数 test 的活动符号表里,但是对应的 zval 指针是指向传递过来的 zval(即 $a$b)。
  • 函数传参时,会复制所有传递的参数放入函数堆栈中,所以指向 zval 的 refcount 会额外加 1。
    可通过 func_get_args() 获取传递的参数,也可以用 debug_backtrace() 查看。
  • 函数结束后,销毁函数的活动符号表并清理堆栈,b 对应的 zval 的 is_ref 恢复为 0。

特殊情况:对象的值传递

保存对象的结构是 _zend_object,zval 里实际保存的是指向该结构体的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L322
typedef union _zvalue_value {
long lval; /* long value */
double dval; /* double value */
struct {
char *val;
int len;
} str;
HashTable *ht; /* hash table value */
zend_object_value obj;
zend_ast *ast;
} zvalue_value;

// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_types.h#L57
typedef struct _zend_object_value {
zend_object_handle handle;
const zend_object_handlers *handlers;
} zend_object_value;

然后通过 zend_object_handle 获取对象,也就是一个int的索引去全局的object buckets里查找。

1
2
3
4
5
6
7
8
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_objects_API.c#L281
/*
* Retrieve an entry from the objects store given the object handle.
*/
ZEND_API void *zend_object_store_get_object_by_handle(zend_object_handle handle TSRMLS_DC)
{
return EG(objects_store).object_buckets[handle].bucket.obj.object;
}

全局的object buckets在对象池初始化内设定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_objects_API.c#L30
ZEND_API void zend_objects_store_init(zend_objects_store *objects, zend_uint init_size)
{
objects->object_buckets = (zend_object_store_bucket *) emalloc(init_size * sizeof(zend_object_store_bucket));
objects->top = 1; /* Skip 0 so that handles are true */
objects->size = init_size;
objects->free_list_head = -1;
memset(&objects->object_buckets[0], 0, sizeof(zend_object_store_bucket));
}
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_objects_API.h#L31
typedef struct _zend_object_store_bucket {
zend_bool destructor_called;
zend_bool valid;
zend_uchar apply_count;
union _store_bucket {
struct _store_object {
void *object;
zend_objects_store_dtor_t dtor;
zend_objects_free_object_storage_t free_storage;
zend_objects_store_clone_t clone;
const zend_object_handlers *handlers;
zend_uint refcount;
gc_root_buffer *buffered;
} obj;
struct {
int next;
} free_list;
} bucket;
} zend_object_store_bucket;

可以看到,zend_object_store_bucket.bucket.obj.object 为指向真正对象的指针。

1
2
3
4
5
6
7
// https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L312
typedef struct _zend_object {
zend_class_entry *ce;
HashTable *properties;
zval **properties_table;
HashTable *guards; /* protects from __get/__set ... recursion */
} zend_object;

至于为什么是 void *object 不是 zend_object* object,是为了兼容扩展(C写的PHP扩展)中的自定义对象。

是不是感觉找个对象很不容易,所以PHP7优化了性能,直接把 zend_object 指针放到了 zval 里。

1
2
3
4
5
6
// https://github.com/php/php-src/blob/master/Zend/zend_types.h#L292
typedef union _zend_value {
...
zend_object *obj;
...
} zend_value;

所以,修改对象的属性会影响所有指向该对象的变量。
举个简单例子:

1
2
3
4
5
6
7
8
9
$obj = new stdClass();
$obj->field = 'aaa';
function test($param) {
echo 'field:'.$param->field.PHP_EOL;
$param->field = 'bbb';
echo 'field:'.$param->field.PHP_EOL;
}
test($obj);
echo 'field:'.$obj->field;

运行结果:

1
2
3
field:aaa
field:bbb
field:bbb

Python中的值传递

Python中只有值传递,这个值是变量的值,即对象的内存地址。
变量名与对象是通过命名空间进行映射的(大部分命名空间是通过Python字典来实现的,字典的底层也是HashTable)。
可以说命名空间是Python运行时的符号表,不过CPython编译器在解析代码中的变量时有用到符号表
不同于PHP,Python的变量皆指向对象(底层应该也是指针),底层数据(堆内存)不改变,修改变量即指向新的对象,没有引用的概念。
可以回顾下之前的内容(Python中的对象与拷贝)。

Java中的值传递

Java中只有值传递,没有引用传递,即指针隐藏在内部实现里了。
后续补充…

Go中的值传递和引用传递

后续补充…

小结

「引用」是语言特性,不同语言的「引用」实现原理各不相同。
C++的引用本质上是指针常量。
PHP的引用本质上是符号表别名。
而Python直接不提引用,或者说变量与对象的指向就是引用。
不同语言的值传递与引用传递实现,无外乎 符号表、地址及指针 的应用。

几个问题

如何理解空指针解引用

C/C++中,如果一个指针的值是NULL,解引用这个指针时,会导致程序崩溃。
注:指针的值是内存地址,值为NULL的指针实际上指向的是0x0这个内存地址,此地址不允许访问,程序直接异常中断。
如何避免?在指针解引用之前先判断指针是否为NULL。

1
2
3
4
5
6
7
8
9
int *p;
p = NULL; // 等价于 int *p = NULL 或 int* p = NULL
if (p != NULL) {
printf("p is not NULL.\n");
cout << *p << endl;
} else {
printf("p is NULL.\n");
}
// 运行结果:p is NULL.

注: int *pint* p 都是定义指针变量,写法不同而已。

C++ 的变量 vs Python 的变量

C++ 中,编译器为变量分配一个内存空间,改变变量是直接改变内存块的数据,变量的地址不变;引用是固定的,修改的数据是直接写入引用所指的内存块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int num1 = 20;  // 实际变量的声明
cout << &num1 << endl;
num1 = 30;
cout << &num1 << endl;

int num2 = 20;
int &r = num2; // 引用变量的声明
cout << &r << endl;
r = 30;// 等价于 num2 = 30
cout << &r << endl;
/*
运行结果:
0x7ffd0cf08a78
0x7ffd0cf08a78
0x7ffd0cf08a7c
0x7ffd0cf08a7c
*/

Python 不改变内存块,变量类似于 C 语言的指针,指向堆内存;修改变量即指向新的内存块。

1
2
3
4
5
6
7
8
9
num = 20
print(id(num))
num = 30
print(id(num))
"""
运行结果:
2452180921232
2452180921552
"""

参考资料

C++中,引用和指针的区别是什么
PHP手册 - 垃圾回收机制 - 引用计数基本知识
如何获取一个变量的名字
深入理解PHP原理之变量作用域
深入理解PHP原理之变量分离/引用
PHP内核探索之变量(1)Zval
PHP内核探索之变量(2)-理解引用
深入理解PHP7内核之OBJECT