Блог

Чому викидання винятків – найкращий вибір?

Юрій Мельников

Короткий розбір з простими та зрозумілими прикладами.

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

Наведу приклад оновлення зображення місця в проєкті 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().

Ось так легко і невимушено ми зробили код більш читабельним і убезпечили себе від можливих помилок при перевірці значення, що повертається.

Звичайно, у винятків є свої мінуси. Наприклад, при настанні події винятку, створюється відповідний об'єкт, який наповнюється потрібними даними.

Так, у сучасних реаліях це займає незначний час і цим можна знехтувати, але в чутливих системах це потрібно враховувати.

Винятки використовувати потрібно, але з розумом!