20 posts 77 tags 7 domains

PHP try/finally — kiedy finally naprawdę się wykonuje (i kiedy potrafi zaskoczyć)

Jak działa blok finally w PHP, do czego go używać i jakie pułapki kryją return oraz wyjątki w finally.

Problem

Masz kod, który zajmuje jakiś zasób — uchwyt pliku, połączenie z bazą, blokadę (lock), transakcję — i musisz go zwolnić niezależnie od tego, czy operacja się powiodła, czy rzuciła wyjątek. Bez finally sprzątanie ląduje w dwóch miejscach: w ścieżce sukcesu i w każdym catch. Łatwo o tym zapomnieć w jednym z nich, a wtedy zostają wiszące połączenia albo niezdjęte blokady.

php
$fh = fopen('/tmp/raport.csv', 'w');

try {
    fwrite($fh, generujRaport());
} catch (RuntimeException $e) {
    logBłąd($e);
    fclose($fh);   // duplikat #1
    throw $e;
}

fclose($fh);       // duplikat #2 — łatwo zapomnieć

finally rozwiązuje to jednym blokiem, który wykona się zawsze.

finally w pigułce

php
$fh = fopen('/tmp/raport.csv', 'w');

try {
    fwrite($fh, generujRaport());
} catch (RuntimeException $e) {
    logBłąd($e);
    throw $e;
} finally {
    fclose($fh);   // wykona się ZAWSZE
}

Blok finally wykona się:

  • gdy try zakończy się normalnie,
  • gdy zostanie złapany wyjątek w catch,
  • gdy wyjątek nie zostanie złapany i poleci dalej w górę,
  • gdy w try lub catch jest return (o tym za chwilę).

Klasyczne zastosowania

Transakcja w bazie

php
$pdo->beginTransaction();

try {
    $pdo->prepare('UPDATE konta SET saldo = saldo - ? WHERE id = ?')
        ->execute([$kwota, $zNadawcy]);
    $pdo->prepare('UPDATE konta SET saldo = saldo + ? WHERE id = ?')
        ->execute([$kwota, $doOdbiorcy]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

Tu akurat finally nie jest konieczne — commit i rollBack są w rozłącznych ścieżkach. Ale gdy masz dodatkowy zasób do zwolnienia (np. blokadę), finally jest idealne:

php
$lock->acquire();

try {
    przetwórzKolejkę();
} finally {
    $lock->release();   // blokada zdjęta nawet przy wyjątku
}

Pułapka #1 — return w finally nadpisuje wszystko

To najczęstsze źródło zaskoczeń. return w finally wygrywa z każdym return z try i catch:

php
function test(): string
{
    try {
        return 'try';
    } finally {
        return 'finally';   // to zostanie zwrócone
    }
}

echo test();   // finally

Co gorsza — return w finally połyka wyjątek, który właśnie leciał:

php
function test(): string
{
    try {
        throw new RuntimeException('coś się zepsuło');
    } finally {
        return 'wszystko ok';   // wyjątek znika bez śladu
    }
}

echo test();   // wszystko ok — a błąd przepadł

Pułapka #2 — kolejność a wartość zwracana

Gdy try ma return, PHP najpierw wylicza wartość do zwrócenia, potem wykonuje finally, a dopiero potem faktycznie zwraca. Modyfikacja zmiennej w finally nie zmieni już zwróconej wartości skalarnej:

php
function licz(): int
{
    $x = 1;
    try {
        return $x;        // wartość 1 jest "zamrożona" tutaj
    } finally {
        $x = 99;          // za późno — nie wpływa na zwrot
    }
}

echo licz();   // 1

Inaczej jest z obiektami — zwracasz referencję, więc zmiana stanu obiektu w finally jest widoczna na zewnątrz:

php
function build(): ArrayObject
{
    $obj = new ArrayObject(['stan' => 'start']);
    try {
        return $obj;
    } finally {
        $obj['stan'] = 'po finally';   // ta zmiana przejdzie
    }
}

print_r(build());
// ArrayObject Object ( [storage:...] => Array ( [stan] => po finally ) )

Pułapka #3 — exit/die i błędy fatalne

finally to nie magiczny gwarant. Nie wykona się, gdy w try zrobisz exit() / die() albo dojdzie do prawdziwego błędu fatalnego (np. wyczerpania pamięci):

php
try {
    echo "start\n";
    exit('koniec');
} finally {
    echo "to się NIE wykona\n";
}
bash
$php test.php
start
koniec

Co się dzieje pod spodem

Mentalny model jest prosty: w momencie wyjścia z try (czy to przez return, czy przez wyjątek) PHP zapamiętuje "co miało się stać" — jaką wartość zwrócić albo jaki wyjątek propagować — następnie uruchamia finally, a na końcu wznawia zapamiętaną akcję. Jeśli jednak finally samo wykona return lub throw, to zastępuje zapamiętaną akcję — stąd biorą się pułapki #1.

Praktyczna zasada na koniec: w finally trzymaj wyłącznie kod sprzątający (fclose, release, rollBack, zamknięcie połączenia). Żadnych return, żadnych throw, żadnej logiki biznesowej. Wtedy finally robi dokładnie to, czego od niego oczekujesz — i nic więcej.