基础概念
通过一段C++示例代码来理清几个概念。
1 |
|
运行结果:
1 | Value of `num` variable: 20 |
值 / 地址
值 - 保存在内存(Heap 或 Stack)中的数据,eg. 20
。
地址 - 指的是内存地址,通过内存地址可以访问到内存中对应的数据,eg. 0xff1
和 0xff2
。
变量名与符号表
变量名是一种标识符,在编译或解释过程中结合符号表转换成对应数据的内存地址。
符号表(Symbol Table)是一种用于语言翻译器(eg. 编译器、解释器)中的数据结构。
在符号表中,程序源代码中的每个标识符都和它的声明或使用信息绑定在一起,比如其数据类型、作用域以及内存地址。
符号表一般通过哈希表(或线性表)来实现,变量名为哈希表的键,内存地址为哈希表的值。
比如,C语言中,所有变量通过编译器的符号表都被替换成了内存地址。
PHP中,所有变量在解析执行的过程中,通过符号表获取指向的 zval
的内存地址。
指针 / 指针变量 / 变量指针
指针 和 指针变量是两个不同概念。
通常,我们表述时会把 指针变量 简称为 指针;但严格来讲,指针 只是概念,指针变量 是具体实现;
即,指针 也是一个变量,对于指针的定义,与普通变量一样;不同于普通变量,指针变量 是存放内存地址的变量。
变量指针 指的是 变量的指针,即,指向该变量的指针;此时指针的值为该变量的地址。
比如上例中,*p
就是指针变量,p
就是 num
变量的指针;&
为地址操作符,*
为解引用操作符。
引用 / 指针
引用是C++引入的重要机制,可以理解为变量的别名。
1 | int num = 20; |
其中,&r
是引用变量,引用本身也是一个变量,它存放的是被引用对象的地址;
这里的 &
不是地址操作符,只是一个标识,用来表示 r
是变量 num
的引用,即 num 的别名;
num 和 r 都可以表示数值为20的内存块,对 r 赋值,就是修改 num 和 r 指向的内存块。
引用变量实际上是按照指针常量(即,指针本身是常量,不能改变)的方式实现的。
1 | int num = 10; |
语言层面上,主要有如下区别:
- 引用必须在声明时初始化,而指针可以在声明之后再初始化。
- 引用必须指向合法的内存块,即,不存在空引用。
- 引用初始化后无法被指向另一个对象,而指针可以在任何时候指向另一个对象。
下面分别来探究下几个热门语言的实现。
PHP中的值传递与引用传递
要弄清PHP的值传递与引用传递,还得从变量存储、赋值、引用等方面的底层实现入手。
Zval 和 符号表
PHP的底层是C语言,变量是基于C的结构体来实现的:
1 | // https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L334 |
Zval 结构体中,refcount__gc
是一个计数器,用以记录指向该 zval 的变量(也称符号,即 Symbol)个数;
初始化变量时,refcount 为 1,变量间赋值会令 zval 的 refcount 加 1;
unset 变量时除了删除符号表记录,变量指向的 zval 的 recount 也会减 1。is_ref__gc
用于标识这个变量是否属于引用集合(reference set),以便区分普通变量和引用变量;
不同于C++,PHP的引用本质上是符号表别名。
1 | $a = ['aaa']; |
运行结果(浏览器访问):
1 | a: |
注:xdebug_debug_zval
是 Xdebug 扩展提供的用于打印 zval 的函数。
- 在当前作用域的符号表中插入新的符号
a
,由于该变量是一个普通变量,因此会生成一个refcount=1
且is_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 | // https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L791 |
引用 和 分离
当执行 $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 | // https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_globals.h#L168 |
其中,全局符号表,保存了在顶层作用域(即不在任何函数 / 对象内)的变量。
每当调用一个函数(或对象的方法)时,就会为这个函数创建一个活动符号表,所有在这个函数内定义的变量,都会保存在这个活动符号表里。
值传递 / 引用传递
举个例子:
1 | $a = 'aaa'; |
运行结果:
1 | x:aaa |
- 参数
$x
和$y
是保存在函数 test 的活动符号表里,但是对应的 zval 指针是指向传递过来的 zval(即$a
和$b
)。 - 函数传参时,会复制所有传递的参数放入函数堆栈中,所以指向 zval 的 refcount 会额外加 1。
可通过func_get_args()
获取传递的参数,也可以用debug_backtrace()
查看。 - 函数结束后,销毁函数的活动符号表并清理堆栈,b 对应的 zval 的 is_ref 恢复为 0。
特殊情况:对象的值传递
保存对象的结构是 _zend_object
,zval 里实际保存的是指向该结构体的指针。
1 | // https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L322 |
然后通过 zend_object_handle 获取对象,也就是一个int的索引去全局的object buckets里查找。
1 | // https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_objects_API.c#L281 |
全局的object buckets在对象池初始化内设定:
1 | // https://github.com/php/php-src/blob/php-5.6.40/Zend/zend_objects_API.c#L30 |
可以看到,zend_object_store_bucket.bucket.obj.object 为指向真正对象的指针。
1 | // https://github.com/php/php-src/blob/php-5.6.40/Zend/zend.h#L312 |
至于为什么是 void *object
不是 zend_object* object
,是为了兼容扩展(C写的PHP扩展)中的自定义对象。
是不是感觉找个对象很不容易,所以PHP7优化了性能,直接把 zend_object 指针放到了 zval 里。
1 | // https://github.com/php/php-src/blob/master/Zend/zend_types.h#L292 |
所以,修改对象的属性会影响所有指向该对象的变量。
举个简单例子:
1 | $obj = new stdClass(); |
运行结果:
1 | field:aaa |
Python中的值传递
Python中只有值传递,这个值是变量的值,即对象的内存地址。
变量名与对象是通过命名空间进行映射的(大部分命名空间是通过Python字典来实现的,字典的底层也是HashTable)。
可以说命名空间是Python运行时的符号表,不过CPython编译器在解析代码中的变量时有用到符号表。
不同于PHP,Python的变量皆指向对象(底层应该也是指针),底层数据(堆内存)不改变,修改变量即指向新的对象,没有引用的概念。
可以回顾下之前的内容(Python中的对象与拷贝)。
Java中的值传递
Java中只有值传递,没有引用传递,即指针隐藏在内部实现里了。
后续补充…
Go中的值传递和引用传递
后续补充…
小结
「引用」是语言特性,不同语言的「引用」实现原理各不相同。
C++的引用本质上是指针常量。
PHP的引用本质上是符号表别名。
而Python直接不提引用,或者说变量与对象的指向就是引用。
不同语言的值传递与引用传递实现,无外乎 符号表、地址及指针 的应用。
几个问题
如何理解空指针解引用
C/C++中,如果一个指针的值是NULL,解引用这个指针时,会导致程序崩溃。
注:指针的值是内存地址,值为NULL的指针实际上指向的是0x0这个内存地址,此地址不允许访问,程序直接异常中断。
如何避免?在指针解引用之前先判断指针是否为NULL。
1 | int *p; |
注: int *p
与 int* p
都是定义指针变量,写法不同而已。
C++ 的变量 vs Python 的变量
C++ 中,编译器为变量分配一个内存空间,改变变量是直接改变内存块的数据,变量的地址不变;引用是固定的,修改的数据是直接写入引用所指的内存块。
1 | int num1 = 20; // 实际变量的声明 |
Python 不改变内存块,变量类似于 C 语言的指针,指向堆内存;修改变量即指向新的内存块。
1 | num = 20 |
参考资料
C++中,引用和指针的区别是什么
PHP手册 - 垃圾回收机制 - 引用计数基本知识
如何获取一个变量的名字
深入理解PHP原理之变量作用域
深入理解PHP原理之变量分离/引用
PHP内核探索之变量(1)Zval
PHP内核探索之变量(2)-理解引用
深入理解PHP7内核之OBJECT