Ошибки и исключения: Error, Exception, Throwable

Вопрос, где половина кандидатов до сих пор отвечает по правилам PHP 5.

Throwable — интерфейс, который реализуют оба ствола иерархии: Exception (ошибки времени выполнения, которые приложение может обработать) и Error (ошибки движка и программиста).

Вопрос 1: «Чем Error отличается от Exception

Что проверяет интервьюер

Знаете ли вы, что с PHP 7 фатальные ошибки движка перестали «убивать» скрипт и стали объектами Error, которые можно поймать. Оба класса реализуют Throwable, но не наследуют друг друга: catch (Exception $e) не поймает TypeError.

Throwable (interface)
├── Error
│   ├── TypeError
│   ├── ValueError
│   ├── ArithmeticError → DivisionByZeroError
│   ├── ArgumentCountError
│   └── AssertionError
└── Exception
    ├── LogicException
    │   ├── InvalidArgumentException
    │   ├── DomainException
    │   └── LengthException
    └── RuntimeException
        ├── OutOfBoundsException
        ├── RangeException
        └── UnexpectedValueException
<?php
function divide(int $a, int $b): float {
    if ($b === 0) {
        throw new InvalidArgumentException('делитель не может быть нулём');
    }
    return $a / $b;
}

try {
    echo divide(10, 2), "\n";
    echo divide(10, 0), "\n";
} catch (InvalidArgumentException $e) {
    echo get_class($e), ": ", $e->getMessage(), "\n";
} finally {
    echo "finally выполняется всегда\n";
}

try {
    echo intdiv(1, 0);
} catch (DivisionByZeroError $e) {
    echo get_class($e), ": ", $e->getMessage(), "\n";
}

var_dump(new InvalidArgumentException('x') instanceof Throwable);
var_dump(new TypeError('x') instanceof Exception);

Вывод:

5
InvalidArgumentException: делитель не может быть нулём
finally выполняется всегда
DivisionByZeroError: Division by zero
bool(true)
bool(false)

Последняя строка — суть вопроса: TypeError не является Exception. Если нужно поймать вообще всё (например, в глобальном обработчике перед отдачей 500-й страницы), ловят Throwable.

Практическое правило выбора

  • LogicException и потомки — «программист ошибся»: неверный аргумент, нарушенный инвариант. Такое чинят кодом, а не обработкой.
  • RuntimeException и потомки — «внешний мир подвёл»: база недоступна, файл не читается, API вернул мусор. Это обрабатывают и логируют.
  • Error обычно не ловят точечно: он означает, что нарушен контракт языка.

Вопрос 2: «Зачем нужен finally и что будет с return внутри него?»

finally выполняется всегда — и при нормальном завершении блока, и при исключении, и даже при return внутри try. Это место для освобождения ресурсов: закрыть файл, откатить транзакцию, снять блокировку.

$pdo->beginTransaction();
try {
    $repository->save($order);
    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;          // пробрасываем дальше, не проглатываем
} finally {
    $lock->release();  // выполнится в любом случае
}

Важная тонкость, которую любят спрашивать: return внутри finally перезаписывает значение, возвращённое из try, и «съедает» летящее исключение. Поэтому return в finally не пишут.

Вопрос 3: «Как обрабатывать ошибки на уровне приложения?»

// одна точка входа для необработанных исключений
set_exception_handler(function (Throwable $e) use ($logger) {
    $logger->error($e->getMessage(), ['exception' => $e]);
    http_response_code(500);
    echo 'Внутренняя ошибка';
});

// превращаем предупреждения и notice в ErrorException
set_error_handler(function (int $no, string $str, string $file, int $line): bool {
    throw new ErrorException($str, 0, $no, $file, $line);
});

// ловим то, что не ловится: фатальные ошибки памяти и таймаута
register_shutdown_function(function () use ($logger) {
    $err = error_get_last();
    if ($err !== null && $err['type'] === E_ERROR) {
        $logger->critical('Fatal: ' . $err['message']);
    }
});

Что стоит добавить в ответ: на проде display_errors должен быть выключен, а log_errors — включён; текст исключения нельзя показывать пользователю (в нём бывают пути и параметры подключения); свои исключения полезно наследовать от общего AppException, чтобы отделять «свои» ошибки от чужих; а «проглатывание» через пустой catch { } — худшее, что можно сделать с ошибкой.

Типичные ошибки кандидатов

  • Уверены, что catch (Exception $e) ловит всё. Не ловит Error — нужен Throwable.
  • Ловят исключение и логируют только getMessage(), теряя стек-трейс и getPrevious().
  • Используют исключения как управление потоком — например, бросают их в цикле для «выхода».
  • Не знают, что деление на ноль в PHP 8 бросает DivisionByZeroError, а не выдаёт предупреждение.
  • Пишут @ перед вызовом, чтобы «спрятать» ошибку, вместо того чтобы обработать причину.
  • Забывают, что finally отработает даже при return в try.

Как ответить кратко

«В PHP 7+ есть общий интерфейс Throwable, от которого идут две независимые ветки: Exception — прикладные ошибки, Error — ошибки движка вроде TypeError или DivisionByZeroError. catch (Exception) не поймает Error, для «поймать всё» нужен Throwable. LogicException — про ошибку программиста, RuntimeException — про внешний мир. finally выполняется всегда и служит для освобождения ресурсов, а на уровне приложения ставлю set_exception_handler и логирую исключение целиком, а не одно сообщение».

Проверьте себя
1. Поймает ли блок catch (Exception $e) исключение TypeError?
AДа, TypeError наследуется от Exception
BНет, TypeError — потомок Error; нужен catch (Throwable) или catch (TypeError)
CДа, но только при включённом strict_types
DНет, TypeError вообще нельзя поймать
2. Какое исключение по смыслу нужно бросать, когда база данных недоступна?
AInvalidArgumentException
BTypeError
CПотомка RuntimeException
DLogicException
3. Что произойдёт с блоком finally, если внутри try выполнен return?
Afinally будет пропущен
BВозникнет фатальная ошибка
Cfinally выполнится только при исключении
Dfinally выполнится до фактического возврата значения