Почему выбрасывать исключения - лучший выбор?

Краткий разбор с простыми и понятными примерами.

Разберём пару примеров, когда исключения применять объективно уместно.

Приведу пример обновления картинки места в проекте Tip2Go.

Давайте начнём писать класс PlaceService с методом updatePlaceImage.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): string
  {
    $place = $this->getPlaceById($place_id);
    ...
  }
}

С возможностью появления ошибки мы столкнулись уже при получении места по его ID, так как может быть передан несуществующий ID и вместо существующего места вернётся null.

В случае, если вернулся null, нам не нужно продолжать выполнение кода и нужно вернуться обратно без обновления картинки. Для этого нам нужно изменить возвращаемые методом updatePlaceImage типы и добавить возможность возврата null на случай ошибки.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): ?string
  {
    $place = $this->getPlaceById($place_id);

    if (!$place instanceof Place) {
      return null;
    }

    if (!file_exists($image_filename)) {
      return null;
    }

    $newImage = ImageHelper::resize($image_filename);

    if (!file_exists(self::IMAGES_DIR . '/' . $place->getId())) {
      $mkdir = mkdir(self::IMAGES_DIR . '/' . $place->getId());
    }

    if (!$mkdir) {
      return null;
    }
  }
}

Теперь мы добавили проверку на существование исходного файла и директории для хранения картинок и если её нет, то создаём её, использую ID места.

В данном случае мы можем столкнуться сразу с двумя ошибками: отсутствие исходно файла и невозможность создания новой директории. В случае ошибок, мы так же возвращаем null и прекращаем установку новой картинки.

Хорошо, наш метод уже в трёх местах может прервать своё выполнение из-за ошибок. Дальше можно добавить ещё проверки на ошибки, например, при перемещении временного файла в новую директорию и уже получится четыре точки, в которых может прерваться выполнение метода.

И как бы нам понять, что именно случилось? Вариант первый и самый наглядный, добавить в метод updatePlaceImage возможность возвращать int и в нём получать код ошибки.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): string|int
  {
    $place = $this->getPlaceById($place_id);

    if (!$place instanceof Place) {
      return -1;
    }

    if (!file_exists($image_filename)) {
      return -2;
    }

    $newImage = ImageHelper::resize($image_filename);

    if (!file_exists(self::IMAGES_DIR . '/' . $place->getId())) {
      $mkdir = mkdir(self::IMAGES_DIR . '/' . $place->getId());
    }

    if (!$mkdir) {
      return -3;
    }
  }
}

В этом случае, в вызывающем методе, нам нужно будет проверять все варианты возвращённых значений.

$placeService = new PlaceService();
$result = $placeService->updatePlaceImage(1, 'filename.jpg');

if (is_string($result)) {
  return 'Картинка обновлена';
} else {
  switch($result) {
    case -1:
      return 'Место с таким ID не найдено';
      break;
    case -2:
      return 'Исходная картинка не найдена';
      break;
    case -3:
      return 'Невозможно создать директорию с ID места';
      break;
  }
}

А теперь представьте, как будет разрастаться блок switch при увеличении количества проверок? А если сам метод должен возвращать int, то ещё что-то придумывать с возвращаемым типом ошибок? В общем, такой код быстро превратится в нечитаемые спагетти.

А теперь давайте попробуем сделать те же самые проверки, но с использованием исключений.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): string
  {
    $place = $this->getPlaceById($place_id);

    if (!$place instanceof Place) {
      throw new Exception('Место с таким ID не найдено');
    }

    if (!file_exists($image_filename)) {
      throw new Exception('Исходная картинка не найдена');
    }

    $newImage = ImageHelper::resize($image_filename);

    if (!file_exists(self::IMAGES_DIR . '/' . $place->getId())) {
      $mkdir = mkdir(self::IMAGES_DIR . '/' . $place->getId());
    }

    if (!$mkdir) {
      throw new Exception('Невозможно создать директорию с ID места');
    }
  }
}

В таком случае, при вызове метода, мы будем делать всего одну проверку на исключение.

$placeService = new PlaceService();

try {
  $placeService->updatePlaceImage(1, 'filename.jpg');
} catch (Exception $exception) {
  return $exception->getMessage();
}

return 'Картинка обновлена';

Так как мы при выбрасывании исключения уже указали текст ошибки, нам не нужно его повторно писать, он возвращается при вызове $exception->getMessage().

Вот так легко и непринуждённо мы сделали код более читаемым и обезопасили себя от возможных ошибок при проверке возвращаемого значения.

Конечно, у исключений есть свои минуты. Например, при наступлении события исключения, создаётся соответствующий объект, который наполняется нужными данными.

Да, в современных реалиях это занимает незначительное время и этим можно пренебречь, но в чувствительных системах это нужно учитывать.

Исключения использовать нужно, но с умом!