PHP面试题系列(一):看似简单的变量赋值,藏着多少“坑”?
作为PHP开发者,我们每天都会写下$a = 1; $b = $a;这样的代码。但如果你觉得这简单到不屑一顾,很可能在面试时被一道精心设计的题目打个措手不及。本系列文章从真实面试题出发,带你扎进PHP底层,既通俗又专业,把每一个知识点吃透。今天,我们聚焦变量赋值、引用以及背后“写时复制”的机制。
热身面试题:输出是什么?
先看几道入门题,大家可以在心里默默作答。
题目1
```php
$a = "hello";
$b = $a;
$b = "world";
echo $a;
```
题目2
```php
$c = "hello";
$d = &$c;
$d = "world";
echo $c;
```
题目3
```php
$arr1 = [1, 2, 3];
$arr2 = $arr1;
$arr2[] = 4;
print_r($arr1);
```
题目4
```php
function addItem($arr) {
$arr[] = 5;
}
$myArr = [1, 2];
addItem($myArr);
print_r($myArr);
```
答案并不难:题目1输出hello,题目2输出world,题目3输出[1, 2, 3],题目4输出[1, 2]。这些题目背后的核心区别,就是值传递(拷贝)与引用传递。
不过,如果你认为到此为止,那下面这道题可能会让你怀疑人生。
题目5(对象传递)
```php
$obj1 = new stdClass();
$obj1->name = 'foo';
$obj2 = $obj1;
$obj2->name = 'bar';
echo $obj1->name; // 输出?
```
很多人知道对象是“引用传递”,于是猜测会输出bar。事实也确实如此。但紧接着再看:
题目6
```php
$obj1 = new stdClass();
$obj1->name = 'foo';
$obj2 = $obj1;
$obj2 = null;
var_dump($obj1); // 输出?
```
如果对象是“引用传递”,那$obj2 = null后,$obj1也应该变成null。但实际上,$obj1依然完好,输出object(stdClass)#1 ...。这是为什么?要回答清楚,就得深入PHP变量的底层实现。
深入底层:zval、引用计数和写时复制
PHP是弱类型语言,变量不需要声明类型,可以随时切换。这背后依赖于一个叫 zval 的C语言结构体。每个PHP变量都对应一个zval,里面主要存放:变量值、类型和两个重要的管理字段:refcount(引用计数)和is_ref(引用标志)。
当写下$a = "hello";时,PHP创建一个zval,值存“hello”,类型为字符串,refcount=1,is_ref=0。接着$b = $a;,并没有立刻复制一份“hello”给$b,而是让$b也指向同一个zval,并把refcount增加为2。这就是写时复制(Copy On Write)的精髓:只有在真正需要修改时才复制。当执行$b = "world";时,PHP发现refcount=2,说明有多个变量共享这个zval,于是先复制一份zval给$b,refcount重新变为1(原来的refcount降为1),再将新zval的值改为“world”。这样$a的内容丝毫不受影响。这就是为什么题目1输出hello。
而引用&的行为完全不同。执行$d = &$c;时,PHP不会复制zval,而是将$c所在zval的is_ref设为1,同时refcount记为2,$d和$c严格共享同一个zval。此时若修改$d,因为is_ref=1,PHP直接修改这个共享zval的值,$c自然也跟着变了。这就是题目2的答案。
这个机制也解释了数组操作:$arr2 = $arr1只是增加refcount,$arr2[] = 4触发写时复制,因此$arr1不变。而$arr4 = &$arr3会使它们成为同一个zval的引用,修改会相互影响。题目4的函数参数默认按值传递,内部修改也是触发复制,外部数组不受影响;如果函数签名改为function addItem(&$arr),则传递引用,外部数组被修改。
对象传递:不是引用,而是“对象标识符”
PHP5之后,对象变量存储的其实是一个对象标识符(可以理解为指向实际对象的指针),这个标识符指向堆中真正的对象数据。当执行$obj2 = $obj1;时,复制的是这个标识符,而不是对象本身。因此$obj2->name = 'bar';修改的是同一个对象,$obj1->name自然变成'bar'。这常常被误解为“对象是按引用传递”,但严格来说,这是“对象标识符赋值”。
两者的关键区别在题目6中暴露出来:$obj2 = $obj1;后,$obj2和$obj1是两个不同的变量,虽然它们持有相同的对象标识符。接着$obj2 = null;,只是把$obj2的值清空,并没有影响到$obj1内部的标识符,$obj1仍然指向原对象。而如果用真正的引用$obj2 = &$obj1;,然后$obj2 = null;,相当于把这两个名字共用的zval置为null,$obj1也会变成null。这种微妙的差别常常被用来考察对PHP变量内核的理解深度。
经典陷阱:foreach循环中的引用雷区
理解引用后,必须警惕实际开发中的一个著名bug场景。
```php
$data = [1, 2, 3];
foreach ($data as &$val) {}
foreach ($data as $val) {}
print_r($data); // 输出什么?
```
如果你认为输出[1, 2, 3],那就掉坑里了。实际输出是[1, 2, 2]。剖析过程:
· 第一个foreach使用&$val,循环结束后$val还是最后一个元素$data[2]的引用。
· 第二个foreach按值迭代,每次将当前元素的值赋给$val。由于$val是$data[2]的引用,这意味着每次赋值都在修改$data[2]。
· 第一次迭代:$val = $data[0](即1),于是$data[2]变成1,数组变为[1,2,1]。
· 第二次迭代:$val = $data[1](即2),$data[2]变成2,数组为[1,2,2]。
· 第三次迭代:$val = $data[2](现在已经是2),保持[1,2,2]。
这个陷阱在真实项目中很难排查,因为代码看起来完全正常,只是多了个无意留下的引用&。解决方法有两个:一是在第一个foreach结束后立即unset($val),销毁引用;二是尽量避免在不需要的地方使用引用迭代。
进阶思考:unset 与引用的恩怨
再来看一道常见的面试题:
```php
$a = 10;
$b = &$a;
unset($b);
echo $a;
```
很多人会以为unset($b)会删除共同指向的数据,导致$a未定义或输出空。但答案是输出10。unset()并不是销毁值,而是销毁变量名与zval之间的映射关系。当$b被unset,引用关系断开,refcount减1,zval依然存在且被$a使用,值完好无损。这再次验证了“引用只是变量的别名”这一原则。
如何利用这些知识写出更好的代码?
1. 大数组传参时注意性能:由于写时复制,按值传递大数组并不会立刻加倍内存,只有在函数内发生修改时才会复制。但若你明知函数内部会修改,且希望返回新数组,按值传递仍很安全。如需刻意避免复制,可使用引用传递,但要小心副作用。
2. 函数返回值:返回对象或数组时,PHP默认按值返回,但受写时复制保护,不会立即产生深拷贝。但在需要新副本的场景,及时修改就会触发分离。
3. 引用滥用是万恶之源:除少数场景(如递归操作、需要修改外部变量)外,尽量用返回值传递数据,少用引用参数,尤其要警惕foreach中遗留下来的引用。
4. 深入理解对象行为:在单例模式、依赖注入容器中,对象标识符的概念至关重要,能帮你厘清在什么地方修改会影响全局,在什么地方只是局部替换。
本章小结
看似基础的变量赋值,背后是PHP优雅的写时复制机制和引用体系。面试官偏爱这类问题,因为它能快速判断候选人是否真正理解PHP的运行原理,而不仅仅停留在语法表面。掌握了zval、引用计数、对象标识符和foreach引用陷阱,你就把变量操作从一个“黑箱”变成了可预测、可解释的行为。
今日思考题:下面代码输出什么?为什么?
```php
$a = [1, 2];
$b = [&$a[0], &$a[1]];
$c = $b;
$c[0] = 10;
echo $a[0];
```
欢迎在脑海中推演,答案会在下一篇文章揭晓。下一次,我们聊一聊PHP面向对象中最容易被误解的魔术方法和后期静态绑定,敬请期待。