backend
artykuł··zaktualizowano ·5 min czytania

Referencje w PHP: co naprawdę robi & i gdzie psuje kod

#php #memory #debugging #references #performance #footguns

Dokumentacja PHP mówi, że referencje to nie wskaźniki, i odradza zwracanie wartości przez referencję dla wydajności. Oba zalecenia są prawdziwe i łatwo o nich zapomnieć. Poniżej opisuję, co & robi w silniku i gdzie psuje kod.

Wartości, copy-on-write i referencje

Domyślnie PHP przypisuje i przekazuje wartości. Tablice i stringi mają licznik referencji (refcount), więc $b = $a niczego nie kopiuje: obie zmienne wskazują tę samą wartość, a licznik rośnie. Kopia (separacja) powstaje dopiero wtedy, gdy coś zapisuje do jednej ze zmiennych, a licznik jest większy od jednego, i właśnie to nazywa się copy-on-write.

Referencja działa inaczej. $b = &$a opakowuje wartość w kontener referencji, który obie nazwy współdzielą. Zapis przez dowolną z nich zmienia wartość, którą widzą obie, i między $a a $b separacji nie ma. Jeśli jednak sama wartość jest współdzielona z inną zmienną, pierwszy zapis i tak zrobi kopię: po $x = [1]; $a = $x; $b = &$a; $b[] = 2; w $x dalej jest [1].

$a = 'original';
$b = $a;           // bez kopii, obie nazwy współdzielą jedną wartość
$b = 'modified';   // $b dostaje własną wartość
var_dump($a);      // string(8) "original"

$a = 'original';
$b = &$a;          // $a i $b to teraz jedna zmienna o dwóch nazwach
$b = 'modified';
var_dump($a);      // string(8) "modified"

Po przypisaniu nic w kodzie nie oznacza $b jako referencji. Żeby wiedzieć, jak zachowa się zapis do $b, trzeba znaleźć linię, w której użyto &.

Błąd 1: referencja zostawiona przez foreach

$prices = [100, 200, 300, 400, 500];

foreach ($prices as &$price) {
    $price = $price * 0.9;
}
// $prices to teraz [90, 180, 270, 360, 450]

foreach ($prices as $price) {
    echo $price, ' ';
}
// Wynik: 90 180 270 360 360

Po pierwszej pętli $price nadal jest referencją do $prices[4]. Druga pętla przypisuje każdy element do $price, czyli w praktyce zapisuje go pod $prices[4]. W czwartej iteracji ląduje tam 360, więc piąta odczytuje 360 zamiast 450. W PHP 8 dzieje się to samo. Zmiana semantyki foreach w PHP 7 tego nie usunęła.

Najmniejsza poprawka to zerwanie referencji zaraz po pętli:

foreach ($prices as &$price) {
    $price = $price * 0.9;
}
unset($price); // usuwa nazwę $price; $prices[4] zachowuje wartość

Lepiej w ogóle obejść się bez referencji:

foreach ($prices as $i => $price) {
    $prices[$i] = $price * 0.9;
}

// albo, jeśli nowa tablica nie przeszkadza
$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);

Błąd 2: parametr przez referencję zmienia kontrakt funkcji

Przykład: funkcja normalizująca dostaje duże tablice, więc ktoś przestawia ją na parametr przez referencję, „żeby nie kopiować”.

// Przed: czysta funkcja, tablica wołającego zostaje nietknięta
function normaliseProduct(array $product): array
{
    $product['title'] = trim(strtolower($product['title']));
    $product['price'] = round($product['price'], 2);
    return $product;
}

// Po: zmienia argument i nic nie zwraca
function normaliseProduct(array &$product): void
{
    $product['title'] = trim(strtolower($product['title']));
    $product['price'] = round($product['price'], 2);
}

Każde istniejące wywołanie w postaci $normalised = normaliseProduct($product) zapisuje teraz null, a $product zmienia się jako efekt uboczny. Samo PHP nie zgłasza użycia wyniku funkcji void. Wyłapie to PHPStan (od poziomu 0) albo Psalm (na errorLevel od 1 do 7, czyli na każdym poziomie poza najluźniejszym 8), jeśli działają w CI. A jeśli funkcja ma zmieniać argument, powinna to mówić jej nazwa (sortInPlace, applyDefaults).

Do tego optymalizacja daje mniej, niż się wydaje. Wersja przez wartość kopiuje tablicę raz, przy zapisie pierwszego klucza, i jest to kopia płytka: zagnieżdżone tablice zostają współdzielone, dopóki ktoś do nich nie zapisze. Wersja przez referencję unika tej kopii tylko wtedy, gdy tablica wołającego nie jest współdzielona. Jeśli jest współdzielona z inną zmienną (refcount większy od jednego), pierwszy zapis wewnątrz funkcji i tak ją rozdzieli.

Błąd 3: referencje przeżywają w tablicach

Referencja do elementu tablicy zostaje przypięta do tego elementu. Przy kopiowaniu tablicy referencja idzie razem z nią, więc element dalej jest współdzielony.

$a = [1, 2];
$ref = &$a[0];

$b = $a;        // wygląda na kopię
$b[0] = 99;

var_dump($a[0]); // int(99)

Dokumentacja opisuje to zachowanie. Z trzech opisanych tu błędów ten najtrudniej zauważyć, bo & może stać daleko od miejsca kopii. Może zostać na przykład po foreach przez referencję, kiedy nikt nie zwolnił zmiennej pętli.

Obiekty nie są przekazywane przez referencję

Obiekty przekazuje się przez wartość, a tą wartością jest uchwyt obiektu (handle). Przez uchwyt funkcja może zmienić stan obiektu. Przypisanie nowego obiektu do parametru zmienia tylko lokalną kopię uchwytu.

final class Counter
{
    public int $count = 0;
}

function increment(Counter $counter): void
{
    $counter->count++;       // widoczne dla wołającego
    $counter = new Counter(); // niewidoczne dla wołającego
}

$c = new Counter();
increment($c);
var_dump($c->count); // int(1)

& przy parametrze obiektowym (Counter &$counter) jest potrzebne tylko wtedy, gdy funkcja ma podmienić obiekt wołającego. Zwykle lepiej zwrócić nowy obiekt.

Kiedy referencja ma uzasadnienie

  • Parametry wyjściowe funkcji wbudowanych: preg_match($pattern, $subject, $matches), sort($array), array_push() i inne przyjmują argument przez referencję z założenia.
  • Modyfikacja w miejscu dużej zagnieżdżonej struktury, na przykład rekurencyjne przejście po drzewie tablic, które aktualizuje liście. Przekazywanie przez wartość rozdziela każdy modyfikowany poziom po drodze i wymaga, żeby wołający przypisał wynik z powrotem. Przed decyzją zmierz memory_get_peak_usage(), bo przy danych wielkości typowego żądania HTTP różnica zwykle wychodzi pomijalna.
  • Budowanie zagnieżdżonej struktury przez referencję do bieżącego węzła, częste przy zamianie płaskiej listy z parent_id na drzewo. Trzymaj to w jednej funkcji i zwolnij referencję przed return.

W kodzie aplikacyjnym, który operuje na obiektach, ten sam efekt da się zwykle uzyskać bez referencji: przekazać obiekt i zmienić jego stan albo zwrócić nową wartość.

koniec artykułu