PHPlay

Curso de PHP gratis · Capítulo 11 de 13 · con Iris Espejo

Referencias en PHP: & a fondo y el bug del foreach

Las referencias son uno de los temas más malentendidos de PHP. Aquí verás qué son realmente (dos nombres para una misma caja) y las trampas que causan.

Dos nombres, una caja

Recuerda: cada variable es un nombre en la tabla de símbolos que apunta a una caja (zval). Hasta ahora, cada nombre tenía su propia caja: $b = $a copiaba el valor.

Con & creas una referencia:

$b = &$a;

Esto NO copia nada: hace que el nombre $b apunte a la misma caja que $a. Ahora son dos nombres para un único valor: cambia uno y "cambia" el otro, porque en realidad solo hay una caja.

Pergamino del porque

Por dentro, el motor Zend envuelve el valor en una estructura especial (zend_reference) con un contador: cuántos nombres la comparten (su refcount). En la Lupa verás las cajas compartidas con el mismo color y una etiqueta &A, &B...

Avanza paso a paso y observa cómo $b se une a la caja de $a, mientras que $c tiene su propia copia.

<?php
$a = 1;
$c = $a;    // copia el valor
$b = &$a;   // comparte la caja
$b = 99;
echo "a=$a b=$b c=$c\n";
Salida
a=99 b=99 c=1

Una referencia no es un puntero

Si vienes de C, olvida los punteros. Una referencia en PHP no es una dirección de memoria: no puedes sumarle 1 ni imprimirla. Es simplemente otro nombre para la misma caja.

¿Qué hace unset($b)? Borra el nombre, no la caja. La caja sigue viva mientras le quede algún nombre (su refcount sea mayor que 0). Cuando nadie la usa, PHP la libera.

Y si después haces $b = 5, se crea un $b nuevo con su propia caja: el vínculo se ha roto.

Truco

Para "desenganchar" una variable de una referencia, unset() es la herramienta correcta. Asignarle otro valor NO la desengancha: ¡cambiaría el valor compartido!

<?php
$a = "luz";
$b = &$a;
unset($b);
echo "a sigue siendo: $a\n";
$b = "sombra";
echo "a=$a b=$b\n";
$c = &$a;
$c = "eco";
echo "a=$a\n";
Salida
a sigue siendo: luz
a=luz b=sombra
a=eco
Pregunta

Después de $a = 1; $b = &$a; unset($a);, ¿qué vale $b?

  1. 1
  2. null
  3. Da error
Ver respuesta

A. 1

unset($a) solo borra el nombre $a. La caja sigue viva porque $b apunta a ella, y vale 1.

Paso por referencia

Normalmente una función recibe copias de sus argumentos. Pero si el parámetro lleva &, recibe la caja original:

function subir(&$nivel) {
    $nivel++;
}

Dentro de la función, $nivel y la variable que le pasaste comparten caja: modificar uno es modificar la otra.

Así funcionan sort(), array_push(), shuffle()... Por eso modifican tu array y devuelven true: ¡reciben tu array por referencia!

Solo puedes pasar variables a un parámetro por referencia: subir(5) es un error, porque un 5 no tiene caja que compartir.

<?php
function subir(&$nivel) {
    $nivel++;
}
function subir_copia($nivel) {
    $nivel++;
}
$x = 1;
subir($x);
subir($x);
subir_copia($x);
echo $x, "\n";
Salida
3

¿Referencias para ir más rápido? No.

Un mito muy extendido: "pasa los arrays por referencia para no copiarlos y ganar velocidad". Es falso en PHP.

Pergamino del porque

Recuerda el copy-on-write: $b = $a o pasar un array a una función NO lo copia. Ambos apuntan al mismo array y PHP incrementa su refcount. La copia real solo ocurre si alguien escribe mientras está compartido.

De hecho, las referencias pueden hacer tu código más lento: un array que forma parte de una referencia a veces tiene que copiarse cuando no haría falta. Y sobre todo, lo hacen más difícil de entender.

Regla de oro: usa & solo cuando quieras modificar el original a propósito. Si solo quieres leer, pasa por valor.

<?php
function total(array $numeros) {
    // solo leemos: PHP no copia el array
    return array_sum($numeros);
}
function vaciar(array $numeros) {
    // escribimos: se copia (separa) SOLO en este momento
    $numeros = [];
    return count($numeros);
}
$grande = range(1, 1000);
echo total($grande), "\n";
echo vaciar($grande), " ", count($grande), "\n";
Salida
500500
0 1000

foreach por referencia

foreach ($array as &$v) hace que $v sea una referencia a cada elemento del array, uno tras otro. Así puedes modificar el array mientras lo recorres:

foreach ($precios as &$p) {
    $p = $p * 2;
}
unset($p);   // ¡IMPORTANTE!

¿Por qué ese unset($p)? Porque al terminar el bucle, $p sigue siendo una referencia al último elemento. Si más adelante usas $p para otra cosa... cambiarás el último elemento del array sin darte cuenta.

Míralo en la página siguiente: es uno de los bugs más famosos de PHP.

<?php
$precios = [10, 20, 30];
foreach ($precios as &$p) {
    $p = $p * 2;
}
print_r($precios);
var_dump($precios);
Salida
Array
(
    [0] => 20
    [1] => 40
    [2] => 60
)
array(3) {
  [0]=>
  int(20)
  [1]=>
  int(40)
  [2]=>
  &int(60)
}

El bug del foreach

Este código parece inofensivo: dobla los números y luego los recorre para mostrarlos. Pero el último elemento sale mal.

Qué pasa, paso a paso (compruébalo con la Lupa):

  1. Tras el primer bucle, $v es una referencia a $datos[2] (misma caja).
  2. El segundo bucle asigna cada elemento a $v... ¡pero $v ES $datos[2]!
  3. Vuelta 1: $v = $datos[0] → escribe 2 en $datos[2].
  4. Vuelta 2: $v = $datos[1] → escribe 4 en $datos[2].
  5. Vuelta 3: $v = $datos[2] → que ahora vale 4.

Resultado: el último elemento es una copia del penúltimo. La solución: unset($v) después del primer bucle (o usar otro nombre de variable).

<?php
$datos = [1, 2, 3];
foreach ($datos as &$v) {
    $v = $v * 2;
}
foreach ($datos as $v) {
    echo $v, " ";
}
echo "\n";
print_r($datos);
Salida
2 4 4 
Array
(
    [0] => 2
    [1] => 4
    [2] => 4
)
Pregunta

¿Qué línea arreglaría el bug del ejemplo?

  1. unset($v); justo después del primer bucle
  2. $v = null; después del primer bucle
  3. Poner & también en el segundo bucle
Ver respuesta

A. unset($v); justo después del primer bucle

unset($v) rompe el vínculo con $datos[2]. En cambio $v = null escribiría null EN el último elemento, ¡porque $v sigue siendo una referencia!

La rareza: referencias dentro de arrays

Ahora algo que casi nadie sabe. Si un elemento de un array forma parte de una referencia, esa casilla sigue siendo compartida aunque copies el array:

$a = [1, 2, 3];
$r = &$a[0];     // la casilla 0 pasa a ser una referencia
$b = $a;         // "copia"...
$b[0] = "X";     // ¡cambia también $a[0]!
Pergamino del porque

¿Por qué? Al copiar un array, PHP copia cada casilla. Una casilla normal se copia por valor, pero una casilla que es una referencia compartida (refcount > 1) se copia... como referencia. Las dos copias del array acaban compartiendo esa caja.

Esto demuestra que las referencias no son "variables especiales": son una propiedad de la caja. Cualquier sitio que apunte a esa caja (una variable, una casilla de array, una propiedad) la comparte.

var_dump marca esas casillas con &. Moraleja: las referencias tienen efectos a distancia. Úsalas con mucho cuidado y deshazlas (unset) cuando termines.

<?php
$a = [1, 2, 3];
$r = &$a[0];
$b = $a;
$b[0] = "X";
echo "a[0] = ", $a[0], "\n";
var_dump($a);
unset($r);
$c = [1, 2];
$c2 = $c;
$c2[0] = "Y";
echo "c[0] = ", $c[0], "\n";
Salida
a[0] = X
array(3) {
  [0]=>
  &string(1) "X"
  [1]=>
  int(2)
  [2]=>
  int(3)
}
c[0] = 1

Objetos: handles, no referencias

Un último espejo. Los objetos (que verás en el Gremio) se comportan de forma parecida a las referencias... pero no son lo mismo.

Una variable con un objeto NO guarda el objeto: guarda un identificador (handle), algo como "el objeto número 1". Al hacer $b = $a se copia el identificador: los dos apuntan al mismo objeto.

  • Si la función modifica el objeto recibido ($o->valor = 5), el cambio se ve fuera: es el mismo objeto.
  • Si la función reasigna su parámetro ($o = new ...), fuera no cambia nada: solo cambió su copia del identificador.
  • Con &, reasignar SÍ afecta al original: comparten la caja que contiene el identificador.

Mira en la Lupa el montón de objetos: las variables solo guardan #1.

<?php
$dron = new stdClass();
$dron->bateria = 100;
function gastar($d) {
    $d->bateria = 50;
    $d = new stdClass();
    $d->bateria = 0;
}
function cambiar(&$d) {
    $d = new stdClass();
    $d->bateria = 7;
}
gastar($dron);
echo $dron->bateria, "\n";
cambiar($dron);
echo $dron->bateria, "\n";
Salida
50
7

Ejercicios de Referencias en PHP resueltos

Intenta resolverlos antes de mirar la solución. En el juego puedes ejecutarlos y comprobarlos automáticamente.

Predicción 1. El reflejo rebelde

Iris duplica precios con un script espejo. Luego los recorre otra vez. ¿Qué queda?

Elige la salida exacta.

<?php
$precios = [10, 20, 30];
foreach ($precios as &$p) {
    $p = $p * 2;
}
foreach ($precios as $p) {
}
echo implode(", ", $precios);
  1. 20, 40, 60
  2. 20, 40, 40
  3. 10, 20, 30
  4. 20, 40, 20
Ver solución
20, 40, 40

El segundo bucle escribió en $precios[2] a través de $p. Míralo paso a paso en la Lupa.

Predicción 2. Nombres y cajas

Tres espejos, tres nombres... ¿cuántas cajas?

Escribe exactamente la salida.

<?php
$a = "sol";
$b = &$a;
$c = $a;
$b = "luna";
unset($a);
echo $b, " ", $c, " ", isset($a) ? "a existe" : "a no existe";
    Ver solución
    luna sol a no existe

    $c copió "sol" antes del cambio; unset($a) solo borró un nombre. La caja "luna" sigue viva gracias a $b.

    Encargo 3. El intercambio del espejo

    Iris necesita un script que intercambie el contenido de dos variables cualesquiera.

    Declara intercambiar(&$a, &$b) que intercambie los valores de las dos variables que le pasen. No devuelve nada.

    Pistas
    • El código de dentro ya es correcto. ¿Qué les falta a los parámetros para modificar las variables de fuera?
    • function intercambiar(&$a, &$b)
    Ver solución
    <?php
    function intercambiar(&$a, &$b) {
        [$a, $b] = [$b, $a];
    }
    

    Encargo 4. Subida de precios

    La inflación sube 5 monedas cada precio de $precios (un array asociativo que ya existe).

    Suma 5 a cada precio de $precios modificando el array con un foreach por referencia. Y como buen mago, rompe la referencia al terminar para que la variable del bucle ya no exista.

    Pistas
    • foreach ($precios as &$p) { $p += 5; }
    • Después: unset($p);
    Ver solución
    <?php
    foreach ($precios as &$p) {
        $p += 5;
    }
    unset($p);
    

    Caza de bugs 5. El espejo roto

    Este programa pone en mayúscula la inicial de cada nombre y luego los muestra. Pero Eva ha desaparecido y Luis sale dos veces.

    Arregla el programa para que imprima Ana Luis Eva (con el espacio final).

    Código de partida:

    <?php
    $nombres = ["ana", "luis", "eva"];
    foreach ($nombres as &$n) {
        $n = ucfirst($n);
    }
    foreach ($nombres as $n) {
        echo $n, " ";
    }
    
    Pistas
    • Tras el primer bucle, $n sigue siendo una referencia a $nombres[2].
    • Añade unset($n); entre los dos bucles.
    Ver solución
    <?php
    $nombres = ["ana", "luis", "eva"];
    foreach ($nombres as &$n) {
        $n = ucfirst($n);
    }
    unset($n);
    foreach ($nombres as $n) {
        echo $n, " ";
    }
    

    Misión del dron 6. El contador del espejo

    Iris quiere saber cuántas hierbas cosecha BIT-8, llevando la cuenta en una sola variable que varias funciones van actualizando.

    Declara cosechar_columna(&$total) que recorra la columna actual (3 casillas hacia el NORTE) cosechando lo maduro y sumando 1 a $total por cada cosecha. Úsala en las 3 columnas del campo y al final imprime Total: 9.

    Esta misión mueve al dron BIT-8 por un campo de cultivo: juégala en PHPlay para ver tu código animado.

    Pistas
    • Sin &, la función suma en su propia copia de $total.
    • Llámala tres veces (o en un bucle), moviéndote al ESTE entre columnas.
    Ver solución
    <?php
    function cosechar_columna(&$total) {
        for ($i = 0; $i < 3; $i++) {
            if (puede_cosechar()) {
                cosechar();
                $total++;
            }
            mover(NORTE);
        }
    }
    
    $total = 0;
    for ($col = 0; $col < 3; $col++) {
        cosechar_columna($total);
        mover(ESTE);
    }
    echo "Total: $total";
    

    Acertijo 7. El examen de Iris

    Iris cruza los brazos: "Última prueba. Sin espejos".

    Responde a las cinco preguntas.

    1. ¿Qué hace $b = &$a;?

      1. Copia el valor de $a en $b
      2. Hace que $b y $a compartan la misma caja
      3. Guarda la dirección de memoria de $a en $b
      Ver respuesta

      B. Hace que $b y $a compartan la misma caja

    2. Pasar un array grande a una función sin &...

      1. Copia todo el array siempre (lento)
      2. No lo copia salvo que la función lo modifique (copy-on-write)
      3. Da error
      Ver respuesta

      B. No lo copia salvo que la función lo modifique (copy-on-write)

    3. ¿Por qué sort($a) modifica $a?

      1. Porque recibe el array por referencia
      2. Porque devuelve el array ordenado
      3. Porque los arrays son objetos
      Ver respuesta

      A. Porque recibe el array por referencia

    4. Tras un foreach ($a as &$v), lo correcto es...

      1. $v = null;
      2. unset($v);
      3. Nada
      Ver respuesta

      B. unset($v);

    5. Una función recibe un objeto (sin &) y hace $obj->x = 5;. Fuera...

      1. No cambia nada
      2. Se ve el cambio: es el mismo objeto
      3. Da error
      Ver respuesta

      B. Se ve el cambio: es el mismo objeto

    Chuleta: lo esencial

    • $b = &$a;: dos nombres, una caja (zend_reference con refcount). Cambiar uno cambia el "otro".
    • No son punteros: no hay direcciones. unset($b) borra el nombre; la caja vive mientras tenga nombres.
    • Asignar a una referencia cambia el valor compartido; unset() es lo que la desengancha.
    • function f(&$x): la función recibe la caja original. Así funcionan sort, array_push... Solo se pueden pasar variables.
    • Copy-on-write: pasar arrays por valor es barato. No uses & "por rendimiento".
    • foreach ($a as &$v) { } unset($v); — sin el unset, $v sigue enganchada al último elemento.
    • Una casilla de array que es referencia compartida sigue compartida al copiar el array.
    • Objetos: la variable guarda un handle (#id). $b = $a comparte el objeto; reasignar un parámetro no afecta fuera (salvo con &).