Чому я ненавиджу виняткові ситуації та що я з цим роблю?

Особливі ситуації недостатньо особливі, аби порушувати правила.

– Python Дзен

Я ненавиджу код з вийнятковими ситуаціями, а особливо коли це код на Golang, у якому робота із особливими ситуаціями займає кількість строк, яку можна охарактерезувати як дебільну. І в цій публікації я поділюся власними думками щодо використання вийняткових ситуацій.

Коли ситуація виняткова?

Зазвичай, ситуація виняткова, коли вона не нормальна, коли вона суттево відрізняється від очікуваного, коли в статистиці вона може вважатися незначущою, коли щось йде не за планом.

Чорна вівця серед білих
Виняткова ситуація свідчить про погану абстракцію, яку треба виправляти, а не обслуговувати

Вийняткові ситуації у мовах програмування – це, зазвичай, особливий синтаксис/семантика для обробки помилок, ситуацій, коли чогось не вистачає, щось не працює або щось не належної якості. Чим, власне, це відрізняється від ще одної гілки виконання і навіщо так необхідний спеціальний синтаксис?

Спеціальний синтаксис не необхідний і це нам демонструє, наприклад, Golang, де помилки – це звичайні значення (об’єкти, що реалізують стандартний інтерфейс error). За це доводиться платити великою кількістю коду, що не реалізує безпосередньо т.з. “бізнес-логіки”. Інакще кажучи, з точки зору бізнесу ця “логіка” – шум.

Виняткові ситуації – за засіб функціональної одиниці повідомити своїх користувачів про те, що вона не знає як поводитися у певній ситуації і передати їм вирішення цієї проблеми.

Уявіть, що ви замовляєте прибирання у власній оселі для підготовки до зустрічі гостей на День народження дитини. До Вас приходять прибиральники, починають роботу, а потім говорять, що у них немає миючих засобів, у такий спосіб перекладаючи на вас вирішення своєї проблеми неспроможності надати замовлену послугу. Врешті решт, ви їх женете та прибираєтесь самостійно. Поздоровляю! Тепер Ви виконуєте функцію прибиральника, але робите це не ефективно, оскільки ви вже витратили годину, а ще нічого не прибрано.

Коротше кажучи, якщо якась функціональна одиниця “викидає” виняткову ситуацію, або повертає помилку (як у Golang, або у OCaml із алгебраїчним sum-типом (варіантом) Result), то це означає, що ця функціональна одиниця виконує свою роботу погано.

При цьому важливо зазначити, що підхід з sum-типами у OCaml та інших подібних мовах (переважно ML сімейство) значно краще й ефективніше, за використання виняткових ситуацій, чи того, як помилки повертаються останнім значення у Golang, оскільки обробка помилки/виняткової ситуації у безпосередньому користувачі гарантована самою системою типів. Таким чином, ми знаємо, що обробка присутня і знаємо де саме.

Проблеми з винятковими ситуаціями

 Кадр із Black sheep movie, вівці-зомбі пожирають людину
Кадр із Black sheep movie, вівці-зомбі пожирають людину

Підвищена складність

Чим принципово відрізняються обробка виняткових ситуацій та звичийне розгалуздження коду? Розгалузження коду дозволяє обробляти різноманітні ситуації тут і зараз у той час, як обробка вийняткових ситуацій відбувається невідомо де.

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

У функціональному програмуванні є поняття чистої функції. Це поняття важливе для аргументації стосовно коду, адже аргументація у коді з ефектами та/чи коді, в якому значення, що повертаються залежать не лише від значень, що передаються до функції значно складніше.

Функціональні одиниці, що використовують виняткові ситуації – це не чисті функції і тому розробники на т.з. функціональних мовах намагаються уникати їх використання, хоча більшість функціональних мов програмування (OCaml, наприклад) мають ту чи іншу реалізацію виняткових ситуацій.

Зниження найдійності

По-перше ми не можемо гарантувати, що виняткова ситуація дійсно оброблюється. По-друге, у більшості мов програмування можливі вийняткові ситуації відсутні у сигнатурах функцій. Таким чином функція (обо будь яка інша функціональна одиниця: процедура, методо, …) перестає бути чорною скринькою і ми маємо заглядати у її реалізацію, аби виявити які вийняткові ситуації можливі. Ба більш того, нам необхідно перевірити усі функції, що використовуються всередині тієї, що нас цікавить та усі функції, що використовуються в них всередині і так до самого низу. У такому випадку дуже складно гарантувати, що усі вийняткові ситуації дійно обробляються.

Зниження ефективності

Як правило, обробка виняткової ситуації значно важче/складніше, за звичайне виконання певної гілки коду. Це не завжі так, але у більшості реалізацій мов програмування це твердження вірне. Якщо можна скористатися звичайним оператором розгалуження, то навіщо нам необхідні менш ефективні обробки виняткових ситуацій?

Порушення принципу єдиної відповідальності (SRP)

Через використання виняткових ситуацій, функція-користувач має реалізовувати не лише власну “логіку”, а й обробку вийняткової ситуації, яка, зазвичай, належить зовсім іншим контекстам та предметним областям. Наприклад, можна зустріти ситуації, де у коді зі створення накладної, ми маємо також обробляти ситуацію, коли у базі данних не було знайдено певного постачальника.

Порушення принципу інверсії залежностей (DIP) та збільшення сплутанності (відсутності чітких кордонів відповідальності)

Особисто для мене принцип інверсії залежності тісно пов’язаний з низхідною (Top-Down) розробкою. У випадку, коли використовуються виняткові ситуації, Top-Down перетворюється на Top-Down-(Random)Top.

Коли ми намагаємося задовольнити вимоги принципу інверсії залежностей, в нас усі стрілочки звернень мають йти лише від “Top” до “Down”, при цьому “Down” має бути заміняємим на будь-який інший, без необхідності вносити зміни у “Top”. Для цього “Top” має “залежати” від абстракції – інтерфейсу, а цей інтерфейс має реалізовувати виключно те, що необхідно “Top”.

Наприклад, якщо я хочу зберегти якийсь запис, то я маю використовувати інтерфейс лише з однією сигнатурою – “Зберегти” та не очікувати повернення контролю через необхідність обробки виняткової ситуації, яка залежить від реалізації інтерфейсу (“Down”), але не притаманна самому інтерфейсу чи предметній області до якої належить “Top”.

Як уникнути використання виняткових ситуацій?

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

Повернення помилки у якості значення

У самому простому вигляді цей підход реалізовано у мові Golang, де помилка повертається останнім значенням із функції (у Golang функції можуть повертати декілька значень). При цьому помилка може бути будь-яким значенням, що реалізує інтерфейс error (має метод Error(), що повертає строку з текстом помилки). Розглянемо простий приклад:

package main

import (
	"errors"
	"fmt"
)

func main() {
	// case 1: everything is ok
	r, err := div(10, 5)
	if err != nil {
		fmt.Printf("\n\t(1): [error]: %s\n", err.Error())
		return
	}
	fmt.Printf("\n\t(1): 10 / 5 = %d\n", r)

	// case 2: error successfully ignored
	r, _ = div(10, 0)
	fmt.Printf("\n\t(2): 10 / 0 = %d\n", r)

	// case 3: error successfully handled, but no return
	r, err = div(10, 0)
	if err != nil {
		fmt.Printf("\n\t(3): [error]: %s\n", err.Error())
	}
	fmt.Printf("\n\t(3): 10 / 0 = %d\n", r)

	// case 4: error successfully handled
	r, err = div(10, 0)
	if err != nil {
		fmt.Printf("\n\t(4): [error]: %s\n", err.Error())
		return
	}
	fmt.Printf("\n\t(4): 10 / 0 = %d\n", r)
}
func div(x, y int) (res int, err error) {
	if y == 0 {
		return 0, errors.New("can't divide by 0")
	}
	return x / y, nil
}

Результат виконання (The Go Playground):

% go run main.go

	(1): 10 / 5 = 2

	(2): 10 / 0 = 0

	(3): [error]: can't divide by 0

	(3): 10 / 0 = 0

	(4): [error]: can't divide by 0

Переваги такого підходу:

  • Простіша реалізація самої мови Golang;
  • Обробка таких помилок за допомогою умовного оператора більш швидка та потребує менше ресурсів за пошук обробника виняткової ситуації у стеці викликів;

Недоліки цього підходу:

  • Помилка може бути проігнорована (2 випадок у зазначенному вище прикладі);
  • Помилка може бути оброблена, проте ми можемо забути зупинити подальше виконання (3 випадок у зазначеному вище прикладі). Навіть якщо відбулась помилка, функція div() має все одно повернути якесь число, яке може бути інтерпретоване як “все добре, використовуй ось цей результат”;
  • Велика кількість досить одноманітного коду обробки помилок;

Використання алгебраїчних sum-типів (варіантів)

Використання sum-типів є ще кращим рішенням, як можна позбутися виняткових ситуацій у коді. Розглянемо цей підхід на прикладі мови OCaml. Для цього розглянемо сеанс в utop (OCaml REPL):

utop # (/);;
- : int -> int -> int = <fun>
utop # 5 / 0;;
Exception: Division_by_zero.

За замовчуванням функція ділення “/” при діленні на нуль “викидає” виключну ситуацію як це відбувається і у більшості інших мов програмування. OCaml дозволяє працювати не лише у функціональній парадигмі, а у імперативній та об’єктно-орієнтованій.

utop # let div x y = if y == 0 then Error("division by zero") else Ok(x/y);; 
val div : int -> int -> (int, string) result = <fun>

Функція div також виконує звичайне ділення та використовує стандарнту функцію “/” для цього. Як бачимо, функція div повертає значення типу (int, string) result. Тип (int, string) result є одним із стандартних sum-типів в OCaml. Тип result має два можливі варіанти (являє суму варіантів): ‘a Ok та b Error, де ‘a та ‘b є змінними типів. У нашому випадку ми маємо конкретні типи-значення int Ok та string Error і тому сигнатура типу виглядає так: (int, string) result.

Нижче приводиться приклад власної реалізації типу result для кращого розуміння як все працює:

utop # type ('a, 'b) result = Ok of 'a | Error of 'b;;
type ('a, 'b) result = Ok of 'a | Error of 'b

utop # Ok(1);;
- : (int, 'a) result = Ok 1

utop # Error("shit happens");;
- : ('a, string) result = Error "shit happens"

Тепер повернемося до нашої реалізації функції div і подивимося як же вона працює:

utop # div 5 2;;
- : (int, string) result = Ok 2

utop # div 5 0;;
- : (int, string) result = Error "division by zero"

utop # let r = div 5 0 in
match r with
| Ok r -> r
| Error err -> 0;;
- : int = 0

utop # let r = div 5 0 in
match r with
| Ok r -> r;;
Lines 2-3, characters 0-11:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
Error _
Lines 2-3, characters 0-11:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
Error _
Exception: Match_failure ("//toplevel//", 2, 0)

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

Переваги такого підходу:

  • Помилка завжди обробляється і це гарантується самою системою типів;
  • Помилка обробляється беспосередньо на рівні користувача функції;
  • Можливі помилки абсолютно прозорі і зрозумілі із типу значення, що вертається;
  • Обробка таких помилок за допомогою умовного оператора більш швидка та потребує менше ресурсів за пошук обробника виняткової ситуації у стеці викликів;

Недоліки такого підходу:

  • У такому підході користувач таки мусить обробляти помилку, що виникла рівнем нижче. Однак, варто зазначити, що цей недолік може бути легко нівельовано за допомогою функції-обгортки, яка ховає код обробки у собі.

Використання “failback’ів”

Метод використання failback’ів полягає у том, що при виклику функції, ми передаємо у неї, частіше за все у якості останнього аргументу, функцію для обробки помилки. Таким чином ми надаємо функції усю необхідну інформацію для виконання її завдання, без необхідності перекладання відповідальності на користувача функції. При необхідності аргументів для обробки помилок може бути декілька, або усі обнобники помилок можуть бути поміщені в одному аргументі, наприклад, асоційованому массиві.

Якось так буде виглядати переписаний приклад на Golang:

package main

import (
	"errors"
	"fmt"
)

func main() {
	// case 1: everything is ok
	r := div(10, 5, handleError)
	fmt.Printf("\n\t(1) 10 / 5 = %d\n", r)
	r = div(10, 0, handleError)
	fmt.Printf("\n\t(2) 10 / 0 = %d\n", r)
	r = safeDiv(10, 0)
	fmt.Printf("\n\t(3) 10 / 0 = %d\n", r)
}
func div(x, y int, h func(error) int) int {
	if y == 0 {
		return h(errors.New("can't divide by 0"))
	}
	return x / y
}
func safeDiv(x, y int) int {
	return div(x, y, handleError)
}
func handleError(err error) int {
	fmt.Printf("\n\terror: %s\n", err.Error())
	return 0
}

Запустивши программу, отримуємо:

% go run main.go

	(1) 10 / 5 = 2

	error: can't divide by 0

	(2) 10 / 0 = 0

	error: can't divide by 0

	(3) 10 / 0 = 0

Загалом цей підхід дуже схожий на попередній, але може використовуватися у мовах, що не мають підтримки алгебраїчних типів.

Переваги цього підходу:

  • Помилка завжди обробляється і це гарантується самою системою типів;
  • Помилка обробляється беспосередньо на рівні користувача функції;
  • У випадку, коли очікується декілька обробників помилок, можливі помилки абсолютно прозорі і зрозумілі із типу значення, що вертається;
  • Обробка таких помилок за допомогою умовного оператора та додаткової функції більш швидка та потребує менше ресурсів за пошук обробника виняткової ситуації у стеці викликів;

Недоліки такого підходу:

  • У такому підході користувач мусить надавати обробник помилки, що виникла рівнем нижче. Однак, варто зазначити, що цей недолік може бути легко нівельовано за допомогою функції-обгортки, яка бере цю відповідальність на себе.

Кінець

Сподіваюсь, Вам сподобалась ця моя публікація. Якщо так – буду дуже радий, якщо Ви поділитеся посилання на неї з друзями.

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *