对于php中unset函数的认识

unset($var)函数是我们经常会用到的函数。它的基本意思就是将传入的实参$var变量删除。我一直以为它就是直接把变量从内存中删掉了,从来没有多想,也没有详细阅读过php文档。 直到最近遇到一个问题,我才正确认识了它。

首先我先说明一下我用unset要解决的问题。drupal后台的菜单结构我们都应该见过,如下图:

屏幕快照 2016-10-23 下午3.47.40.png

菜单都是有层次的,层次的值放在depth中,并且可以自由的启用和禁用。禁用时菜单里的hidden值为true。我希望写一个函数来去除hidden为true的菜单,即去除其中被禁用的的菜单。为了更好的说明这个问题我来构造一个简单的菜单结构与上图对应。

屏幕快照 2016-11-01 下午1.32.27.png

去除函数是:

屏幕快照 2016-11-01 下午6.07.16.png

 

按照我开始对unset的理解,执行完unset_disabled_menu_data($all_menu_data) 后,$all_menu_data中应该同时去掉Job Opportunities、Solution、Corporate Website、Mobile Website即数组中序号为5、7、8、9的菜单。结果却只去掉了序号为7、8、9的菜单,Job Opportunities菜单仍然在$all_menu_data数组中。我思考了好久,经过再三确认,我觉得我的代码没什么问题。猜测原因可能出在unset函数这里,但它为什么删不掉Job Opportunities,我还是不清楚。唯一可以确定的是unset_disabled_menu_data是一个递归函数,形参&$menu_data是一个引用赋值,当要删除Job Opportunities时已经进入了第二层的递归。学过C/汇编语言/编译原理的同学大概都知道这中间会经过变量的引用赋值及入栈过程。递归又是一个多次连续入栈,最后一次性依次出栈的过程。但即使知道了这个过程我还是想不通为什么第二层的菜单Job Opportunities无法从$all_menu_data数组中删掉。

因此我决定去看看php文档中对unset的介绍,其中主要介绍的是unset()如何在函数中删掉全局变量,即unset时将变量$var放入$GBLOAL数组中——unset($GLOBALS[‘bar’]),即使变量是通过引入传入函数中的也是如此才能删除。这依然无法解答我上面的问题。

最后我在网上看了一些博客,做了些测试,才最终明白上述问题出现的原因。

要明白上面的问题出现的原因首先我们要知道一些基本的概念,PHP是如何实现变量的?PHP中的引用是如何实现的?什么是引用计数?及什么是写时复制?这些问题其实并不是相互独立的,我主要参考了下面两篇博客:

深入PHP变量存储结构

PHP变量在内存中的存储方式

我们都知道PHP是用C实现的,C是一个编译型,强变量类型的语言,而PHP是一个解释型,弱变量类型的语言。为了实现弱变量类型即变量类型可以相互转换,PHP中用C定义了一个_zval_struct的结构体,一般叫做变量容器即内存中实际存在的值。大家可以看一下深入PHP变量存储结构,我就不再详细介绍了。

typedef struct _zval_struct {

    zvalue_value value;

    zend_uint refcount;

    zend_uchar type;

    zend_uchar is_ref;

  } zval;

其中,

type存储的是变量类型,PHP自动转换时会主动修改type的值。

is_ref表明是否是引用。 (1/0,1表示当前为引用,0表示非引用)

refcount是引用计数。  (指向这个内存变量的个数,变量刚定义时refcount为1)

而且PHP也维护了很多的表格来存储变量名称。如一个GLOBAL表用来放全局变量,每一个函数都有一个表用来存储这个函数里的变量即局部变量。函数的表格和局部变量往往随着程序执行时的入栈出栈不停的创建和释放。

有两个函数  debug_zval_dump($var);   xdebug_debug_zval(‘var’); 可以刺探到is_ref和refcont的值。debug_zval_dump是PHP内置的函数,可直接使用。使用xdebug_debug_zval需要安装Xdebug。我推荐大家用xdebug_debug_zval,因为debug_zval_dump函数需要传入变量,其刺探到的is_ref和refcount的值会受到传参时写时复制规则的影响。

下面我们通过一些例子来了解一下引用和引用计数。

屏幕快照 2016-11-01 下午6.28.54.png

比较一下eg2、eg3,引用计数refcount都变为了2,一个是引用,一个非引用。eg2很好理解,引用赋值,直接把$b指向$a变量的内存。eg3中的表现就有点奇怪了,按照一般对赋值操作的理解,$b=$a,应该重新复制了一份$a的值在内存中。按道理refcount应该是1,然而它却是2。这就要说到PHP的写时复制的原理了。PHP在执行赋值操作时并不一定会直接复制一份新的变量,而是可能像引用赋值一样直接指向原来的内存,但是不改变is_ref的值,is_ref任然为0。那什么时候它才会重新复制一份新的变量到内存中呢?PHP会自动判断的。

我们来看一些例子,了解一下写时复制。

屏幕快照 2016-11-01 下午6.32.59.png

eg4中对$a再次赋值时,$a和$b会执行一次分离的操作。$a指向重新分配的内存,refcount为初始值1。由于$a不在指向原来的内存,所以$b的refcount由2变成了1,即refcount执行了减1的操作。eg5和eg6做对比我们发现给$c赋值时,根据右值指向的内存是否为引用,PHP对变量$c的处理并不相同。这就是我之前为什么说PHP在执行赋值操作时并不一定会直接复制一份新的变量。而是在需要复制时才会复制,这就是写时复制。由此我们可以看出,写PHP时不用为了节省内存而刻意的使用&引用赋值。一切交给PHP来处理吧!

我们再看一个例子

屏幕快照 2016-11-01 下午7.04.36.png

其中$a变量在函数中的引用计数refcount=3,由1加了2,而不是加1。参考PHP变量在内存中的存储方式博客,我才知道$a指向的内存除了GLOBAL表中的变量a,函数表foo中的变量a,还有函数栈中的a。

当明白了上述基本概念,我们再回过头来看unset为什么清除不了Job Opportunities这个菜单,一切都迎刃而解。unset在清除变量时不一定会释放实际的内存。它首先会检测释放的变量是属于哪个表格中的,先将变量名从表格中删除,然后将内存中的引用计数减1,再看内存中的引用计数是否为0,只有引用计数为0时unset才会真正的释放内存。