Referencje w PHP: footgun, który wchodzi na produkcję szybciej niż myślisz
Referencje w PHP to jedna z nielicznych funkcji językowych, przed którymi manual PHP explicite ostrzega przed niepotrzebnym używaniem. Ostrzeżenie jest zasadne. Debugowałem trzy oddzielne incydenty produkcyjne spowodowane przez referencje, i w dwóch z nich oryginalny developer nie był świadomy, że w ogóle wprowadził referencję.
To nie jest artykuł o tym, dlaczego & to code smell. To artykuł o tym, żeby rozumieć dokładnie co robi, bo spotkasz go w legacy codebases, będziesz go okazjonalnie potrzebować i na pewno kiedyś będziesz debugować buga przez niego spowodowanego.
Czym referencja faktycznie jest
Domyślne zachowanie PHP to copy-on-write: gdy przypisujesz jedną zmienną do drugiej, początkowo współdzielą tę samą wartość w pamięci. Kopia następuje dopiero gdy jedna z nich jest modyfikowana. To już jest dość wydajne przy czytaniu danych.
Referencja omija copy-on-write całkowicie. Dwie zmienne będące referencjami do tej samej wartości współdzielą pamięć niezależnie od modyfikacji. Modyfikacja którejkolwiek modyfikuje wartość bazową, do której obie wskazują.
$a = 'original';
$b = $a; // copy-on-write: $b points to the same memory, but...
$b = 'modified'; // ...the copy happens here. $a is still 'original'.
var_dump($a); // string(8) "original"
$a = 'original';
$b = &$a; // reference: $b is an alias for the same memory location as $a
$b = 'modified'; // no copy — modifies the underlying value directly
var_dump($a); // string(8) "modified" ← $a changed, not $b
Różnica ma znaczenie, bo zachowanie referencji w PHP nie jest zawsze oczywiste przy czytaniu kodu. Referencje nie wyglądają inaczej od zwykłych zmiennych po przypisaniu, $b wygląda tak samo w obu przypadkach. Trzeba cofnąć się do miejsca, gdzie & zostało wprowadzone.
Incydent 1: foreach który skorumpował tablicę
To najczęstszy bug referencyjny, który widziałem w produkcyjnych codebases. Pojawia się w kodzie sprzed PHP 7, który nigdy nie był refaktorowany:
$prices = [100, 200, 300, 400, 500];
foreach ($prices as &$price) {
$price = $price * 0.9;
}
// After the loop: $prices = [90, 180, 270, 360, 450] ✓
// Some other code, three lines later, iterates the same array:
foreach ($prices as $price) {
echo $price . "\n";
}
Oczekiwany output: 90, 180, 270, 360, 450. Rzeczywisty output: 90, 180, 270, 360, 360. Ostatni element jest błędny. Po pierwszym foreach, $price jest nadal referencją do ostatniego elementu $prices, wartości pod indeksem 4. Drugi foreach przypisuje po kolei każdą wartość do $price. Gdy przypisuje czwartą wartość (360) do $price, zapisuje 360 do $prices[4]. Potem próbuje odczytać $prices[4] dla piątej iteracji i znajduje 360, nie 450.
// The fix
foreach ($prices as &$price) {
$price = $price * 0.9;
}
unset($price); // break the reference before the variable goes out of scope
unset($price) nie niszczy ostatniego elementu tablicy. Niszczy połączenie referencji między $price a $prices[4]. W każdej codebase, gdzie widziałem ten bug, brakowało unset($price). Dokumentacja PHP explicite o tym wspomina. Nadal brakuje tego w codebases dziś.
Incydent 2: funkcja, która po cichu mutowała dane wywołującego
Pipeline transformacji danych miał funkcję normalizującą dane produktów. Była wywoływana z dużymi tablicami, i ktoś dodał & żeby uniknąć kopiowania:
// Original: safe, no side effects
function normaliseProduct(array $product): array
{
$product['title'] = trim(strtolower($product['title']));
$product['price'] = round($product['price'] * 100) / 100;
return $product;
}
// "Optimised" version: unsafe
function normaliseProduct(array &$product): void
{
$product['title'] = trim(strtolower($product['title']));
$product['price'] = round($product['price'] * 100) / 100;
}
Wywołanie $normalised = normaliseProduct($product) w oryginalnej wersji zwracało zmodyfikowaną kopię. W "zoptymalizowanej" wersji funkcja zwracała void, $normalised był null, a $product był modyfikowany w miejscu. Dane w cache dla każdego produktu to było null. System raportowania nic nie pokazywał. Nikt nie zauważył przez dwa dni, bo główna ścieżka odczytu trafiała do bazy danych, nie do cache'a. "Optymalizacja" referencją zaoszczędziła dosłownie zero pamięci, tablice PHP i tak używają copy-on-write, a funkcja odczytuje tylko dwa klucze.
Kiedy referencje są faktycznie poprawne
Referencje są odpowiednie w dokładnie dwóch sytuacjach, które napotkałem. Pierwsza to duże struktury danych modyfikowane w miejscu w algorytmie rekurencyjnym: jeśli przeglądasz i modyfikujesz głęboko zagnieżdżoną tablicę, przekazywanie przez referencję unika kopiowania całej struktury na każdej głębokości rekursji. To prawdziwy problem wydajności tylko przy znaczącej skali, nie sięgałbym po to poniżej 10MB danych. Druga to parametry wyjściowe w funkcjach w stylu rozszerzeń C, takich jak preg_match() z tablicą dopasowań.
Błędne przekonanie o referencjach obiektów
Bardzo powszechne nieporozumienie: obiekty w PHP są już "przekazywane przez referencję." Nie są. Obiekty są przekazywane przez uchwyt, wskaźnik do obiektu, nie sam obiekt. Ponowne przypisanie uchwytu wewnątrz funkcji nie wpływa na uchwyt wywołującego. Modyfikacja obiektu przez uchwyt już tak.
class Counter { public int $count = 0; }
function increment(Counter $counter): void
{
$counter->count++; // modifies the object — caller sees this
$counter = new Counter; // reassigns the handle — caller does NOT see this
}
$c = new Counter;
increment($c);
var_dump($c->count); // int(1) — the increment happened, the reassignment did not
Na co zwracam uwagę w code review
Gdy widzę & w sygnaturze funkcji lub w foreach, zatrzymuję się i uważnie czytam otaczające dwadzieścia linii. Czy ta referencja jest nadal aktywna po pętli? Czy wywołujący oczekuje, że funkcja nie będzie miała efektów ubocznych na argumencie? Czy uzasadnienie wydajnościowe jest realne, czy to przedwczesna optymalizacja od kogoś, kto nie przeczytał dokumentacji o copy-on-write?
Referencja w kodzie PHP warstwy aplikacji to żółta flaga, nie dlatego, że zawsze jest błędna, ale dlatego, że kod polega na semantyce aliasowania, która jest nieoczywista dla następnego czytającego. A "nieoczywiste dla następnego czytającego" to miejsca, gdzie żyją bugi.