Исключения PHP: полный разбор механизмов и сценарии использования
Обработка исключений в PHP: основные концепции
Исключения (exceptions) позволяют управлять ошибками в PHP, не прерывая выполнение скрипта. Основной механизм базируется на конструкции try, catch, finally и операторе throw. Рассмотрим наиболее эффективное решение, а также альтернативные подходы.
Базовая обработка исключений
Основной способ обработать исключение - использовать блок try, внутри которого размещается потенциально опасный код, и один или несколько блоков catch, перехватывающих исключения определённого типа. Блок finally выполняется всегда, независимо от того, было ли выброшено исключение.
<?php
try {
$file = fopen('test.txt', 'r');
if (!$file) {
throw new Exception('Не удалось открыть файл');
}
// работа с файлом
fclose($file);
} catch (Exception $e) {
echo 'Ошибка: ' . $e->getMessage();
} finally {
echo 'Выполнение завершено';
}
?>
В этом примере при неудачном открытии файла выбрасывается исключение, которое перехватывается блоком catch. Блок finally выводит сообщение в любом случае.
Типичная проблема: забывают указывать тип исключения в блоке catch, что приводит к перехвату всех исключений, включая системные. Рекомендуется перехватывать только определённые классы.
Варианты обработки исключений
Как обработать исключения разных типов по-разному?
Используйте несколько блоков catch для разных классов исключений. Это позволяет выполнять различную логику в зависимости от типа ошибки.
try {
if (rand(0,1)) {
throw new InvalidArgumentException('Неверный аргумент');
} else {
throw new RuntimeException('Ошибка выполнения');
}
} catch (InvalidArgumentException $e) {
echo 'Проблема с аргументом: ' . $e->getMessage();
} catch (RuntimeException $e) {
echo 'Ошибка времени выполнения: ' . $e->getMessage();
}
Ошибка: Если исключение не соответствует ни одному из перечисленных типов, оно останется необработанным. Добавьте общий блок catch (Exception $e) в конце.
Зачем нужен блок finally?
Блок finally гарантирует выполнение кода даже при возникновении исключения или при выходе из try через return. Это удобно для освобождения ресурсов (закрытие соединений, файлов).
function readConfig() {
$db = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
try {
$result = $db->query('SELECT value FROM config');
return $result->fetchColumn();
} finally {
$db = null; // соединение будет закрыто в любом случае
}
}
Сложность: код в finally выполняется даже если в try произошёл выход через return. Но если в finally сам выбрасывает исключение, оно заменяет предыдущее.
Как создать собственное исключение?
Расширьте класс Exception или любой другой встроенный класс. Это позволяет различать ошибки предметной области.
class DatabaseException extends Exception {}
class UserNotFoundException extends Exception {}
try {
throw new UserNotFoundException('Пользователь не найден');
} catch (UserNotFoundException $e) {
echo 'Ошибка: ' . $e->getMessage();
} catch (DatabaseException $e) {
// другая обработка
}
Проблема: если не перехватить конкретное пользовательское исключение, оно может быть перехвачено родительским Exception, что скроет специфику ошибки. Всегда добавляйте перехват для пользовательских исключений перед общим.
Как глобально обрабатывать не перехваченные исключения?
Используйте функцию set_exception_handler. Она вызывает указанный обработчик для всех исключений, которые не были перехвачены в try-catch.
set_exception_handler(function($exception) {
error_log('Необработанное исключение: ' . $exception->getMessage());
http_response_code(500);
echo 'Внутренняя ошибка сервера';
});
try {
throw new Exception('Что-то пошло не так');
} catch (Exception $e) {
// этот блок не выполнится, исключение будет обработано глобально
}
Ошибка: если в глобальном обработчике тоже выбросить исключение (например, через throw), выполнение скрипта завершится фатальной ошибкой. Лучше логировать и выводить сообщение, не выбрасывая заново.
Как обрабатывать исключения в циклах?
Внутри цикла блок try-catch позволяет обработать ошибку для конкретной итерации и продолжить выполнение.
$items = ['a', 'b', 'c'];
foreach ($items as $item) {
try {
if ($item == 'b') {
throw new Exception('Элемент b недопустим');
}
echo 'Обработка ' . $item . "\n";
} catch (Exception $e) {
echo 'Пропуск: ' . $e->getMessage() . "\n";
}
}
Сложность: если исключение не перехватывать внутри цикла, оно прервёт весь цикл. Решайте задачу в зависимости от бизнес-логики: либо перехватывать и продолжать, либо прерывать выполнение.
Как использовать вложенные try-catch?
Вложенные блоки нужны, когда внутренняя операция должна иметь собственную обработку, а внешний блок - общую логику.
try {
// внешний блок
try {
// внутренний блок
throw new Exception('Внутренняя ошибка');
} catch (Exception $e) {
echo 'Внутренняя обработка: ' . $e->getMessage();
throw $e; // проброс дальше
}
} catch (Exception $e) {
echo 'Внешняя обработка: ' . $e->getMessage();
}
Проблема: при пробросе исключения из внутреннего catch во внешний, внешний блок может перехватить его повторно. Важно следить за логикой, чтобы не возникло бесконечное вложение.
Как обрабатывать исключения в конструкторах и деструкторах?
В конструкторе исключение может предотвратить создание объекта. В деструкторе выбрасывать исключения не рекомендуется - они могут вызвать фатальную ошибку.
class MyClass {
public function __construct($value) {
if ($value < 0) {
throw new InvalidArgumentException('Значение не может быть отрицательным');
}
echo 'Объект создан';
}
public function __destruct() {
// исключения в деструкторе опасны
try {
// какой-то код
} catch (Exception $e) {
// лучше не выбрасывать заново
}
}
}
try {
$obj = new MyClass(-1);
} catch (InvalidArgumentException $e) {
echo 'Ошибка создания: ' . $e->getMessage();
}
Важно: при выбрасывании исключения из деструктора во время обработки другого исключения, PHP может выдать фатальную ошибку. Лучше не использовать throw в деструкторах.
Как комбинировать исключения и ошибки PHP?
Начиная с PHP 7, ошибки (Error) и исключения наследуют общий интерфейс Throwable. Блок catch (Throwable $e) перехватывает и исключения, и ошибки.
try {
// вызов несуществующего метода - ошибка Error
$obj = new stdClass();
$obj->nonExistentMethod();
} catch (Throwable $e) {
echo 'Перехвачено: ' . get_class($e) . ' - ' . $e->getMessage();
}
Риск: перехват Throwable скрывает критические ошибки (например, ошибки памяти). В production лучше обрабатывать Error отдельно или логировать их.
Расширенные примеры обработки исключений
Пример 1: Цепочка исключений (previous)
При повторном выбрасывании исключения можно передать предыдущее, чтобы сохранить контекст.
function processData($data) {
try {
// операция, которая может выбросить InvalidArgumentException
if ($data === null) {
throw new InvalidArgumentException('Данные отсутствуют');
}
return $data * 2;
} catch (InvalidArgumentException $e) {
throw new RuntimeException('Не удалось обработать данные', 0, $e);
}
}
try {
$result = processData(null);
} catch (RuntimeException $e) {
echo 'Сообщение: ' . $e->getMessage();
echo '\nПредыдущее исключение: ' . $e->getPrevious()->getMessage();
}
Сообщение: Не удалось обработать данные Предыдущее исключение: Данные отсутствуют
Пример 2: Исключения в анонимных функциях и замыканиях
Анонимные функции могут выбрасывать исключения, которые перехватываются во внешнем коде.
$handler = function($value) {
if ($value < 0) {
throw new OutOfBoundsException('Значение вне диапазона');
}
return sqrt($value);
};
try {
echo $handler(-4);
} catch (OutOfBoundsException $e) {
echo 'Ошибка: ' . $e->getMessage();
}
Ошибка: Значение вне диапазона
Пример 3: Исключения в рекурсивных функциях
Рекурсия может быть прервана исключением, которое поднимается по стеку.
function factorial($n) {
if ($n < 0) {
throw new RangeException('Факториал отрицательного числа не определён');
}
if ($n <= 1) {
return 1;
}
return $n * factorial($n - 1);
}
try {
echo factorial(-5);
} catch (RangeException $e) {
echo 'Ошибка: ' . $e->getMessage();
}
Ошибка: Факториал отрицательного числа не определён
Пример 4: Кастомные исключения с дополнительными свойствами
Добавление полей для детализации ошибки.
class ValidationException extends Exception {
private $field;
public function __construct($message, $field, $code = 0, Throwable $previous = null) {
parent::__construct($message, $code, $previous);
$this->field = $field;
}
public function getField() {
return $this->field;
}
}
try {
$data = ['name' => ''];
if (empty($data['name'])) {
throw new ValidationException('Имя обязательно', 'name');
}
} catch (ValidationException $e) {
echo 'Поле ' . $e->getField() . ': ' . $e->getMessage();
}
Поле name: Имя обязательно
Пример 5: Обработка исключений из трейтов
Трейты могут содержать код, выбрасывающий исключения. Обрабатывать их нужно в классе, использующем трейт.
trait FileLogger {
public function log($message) {
if (!is_writable('log.txt')) {
throw new RuntimeException('Лог-файл не доступен для записи');
}
file_put_contents('log.txt', $message . PHP_EOL, FILE_APPEND);
}
}
class Application {
use FileLogger;
public function run() {
try {
$this->log('Старт приложения');
} catch (RuntimeException $e) {
echo 'Проблема с логированием: ' . $e->getMessage();
}
}
}
$app = new Application();
$app->run();
(если файл недоступен) Проблема с логированием: Лог-файл не доступен для записи
Пример 6: Исключения в контексте транзакций баз данных
При ошибке в БД откатываем транзакцию и выбрасываем исключение.
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$pdo->beginTransaction();
try {
$pdo->exec('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
$pdo->exec('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
echo 'Транзакция отменена: ' . $e->getMessage();
}
(при ошибке) Транзакция отменена: ...
Пример 7: Использование expect в PHPUnit для проверки исключений
Юнит-тесты часто требуют проверки, что код выбрасывает определённое исключение.
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase {
public function testDivisionByZero() {
$this->expectException(DivisionByZeroError::class);
$calculator = new Calculator();
$calculator->divide(10, 0);
}
}
Тест пройден, если выбрасывается DivisionByZeroError.