Як спроектувати і написати повноцінну програму

«Інструкція створення функціонального програми», частина 1.

«Мені здається, що розумію функціональне програмування на базовому рівні, і я навіть писав прості програми, але як мені створити повноцінну програму, з реальними даними, з обробкою помилок і іншим?»

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

Спочатку, кілька коментарів і застережень:
  • Я буду описувати тільки один сценарій, а не додаток. Сподіваюся, буде очевидно як розширити код при необхідності.
  • Це навмисно дуже проста інструкція без особливих хитрувань і просунутої техніки, орієнтована на потокову обробку даних. Але якщо ви початківець, я думаю, вам буде корисно мати послідовність простих кроків, які ви зможете повторити і отримати очікуваний результат. Я не стверджую, що це єдиний вірний спосіб. Різні сценарії будуть вимагати різних підходів, і звичайно з ростом власної експертизи ви можете виявити, що ця інструкція занадто проста і обмежена.
  • Щоб полегшити перехід з об'єктно-орієнтованого проектування, я постараюся використовувати знайомі концепції такі як «шаблони», «сервіси», «впровадження залежності» і т. д., а також пояснювати, як вони співвідносяться з функціональним підходом.
  • Інструкція також навмисно зроблена в деякій мірі імперативною, тобто використовується явний покроковий процес. Я сподіваюся, цей підхід полегшить перехід від ООП до ФП.
  • Для простоти (і можливості використовувати F# script) я встановлю заглушку на всю інфраструктуру і ухилюся від взаємодії з UI безпосередньо.
Огляд
Огляд того, що я планую описати в цій серії статей:
  • Перетворення сценарію в функцію. В першій статті ми розглянемо простий сценарій і побачимо як він може бути реалізований за допомогою функціонального підходу.
  • Об'єднання невеликих функцій. У наступній статті, ми обговоримо просту метафору про об'єднання невеликих функцій в більш великі.
  • Проектування з допомогою типів і типи помилки. В третьої статті ми створимо необхідні для сценарію типи і обговоримо спеціальні типи обробки помилок.
  • Настройка і управління залежностями. В цій статті ми поговоримо про те, як зв'язати всі функції.
  • Валідація. В цій статті ми обговоримо різні шляхи реалізації перевірок і перетворення з небезпечного зовнішнього світу у теплий пухнастий світ типобезопасности.
  • Інфраструктура. В цій статті ми обговоримо різні компоненти інфраструктури, такі як облік, робота з зовнішнім кодом і т. д.
  • Предметний рівень. В цій статті ми обговоримо, як предметно-орієнтоване проектування працює у функціональному світі.
  • Рівень подання. В цій статті ми обговоримо, як вивести у UI результати і помилки.
  • Робота з мінливими вимогами. В цій статті ми обговоримо, що робити з мінливими вимогами і як вони впливають на код.
Приступимо
Давайте візьмемо дуже простий приклад, а саме оновлення деякої інформації про клієнта через веб-сервіс.

І так, наші основні вимоги:
  • Користувач надсилає деякі дані (ім'я користувача, ім'я та адресу поштової скриньки).
  • Ми перевіряємо коректність імені і адреси скриньки.
  • В базі даних у відповідній користувача запису оновлюються ім'я та адресу поштової скриньки.
  • Якщо адресу поштової скриньки змінено, відправляємо на цю адресу перевірочний лист.
  • Виводимо користувачеві результат операції.
Це звичайний сценарій обробки даних. Тут присутній певний запит, який запускає сценарій, після чого дані з запиту «протікають» через систему, піддаючись обробці на кожному кроці. Я використовую цей сценарій в якості прикладу, тому що він поширений в корпоративному ПО.

Ось діаграма складових частин процесу:


Але це тільки опис успішного варіанти подій. Реальність ніколи не буває настільки простий! Що станеться, якщо ідентифікатор користувача не знайдеться в базі даних, або поштову адресу буде некоректний, або у базі даних є помилка?

Давайте змінимо діаграму і відзначимо, що може піти не так.


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

Функціональне мислення
Тепер, коли ми розібралися з етапами нашого сценарію, як його реалізувати з допомогою функціонального підходу?

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

У сценарії ми зазвичай маємо на увазі модель запит-відповідь. Відправляється запит, назад приходить відповідь. Якщо щось пішло не так, то потік дій завершується і відповідь приходить «достроково» (прим. перекладача: Йдеться виключно про процесі, не про витрачений час.).

Що я маю на увазі, що можна побачити на діаграмі спрощеної версії сценарію.


Але у функціональній моделі, функція — це чорний ящик з входом і виходом, як тут:


Як ми можемо пристосувати наш сценарій до такої моделі?

Односпрямований потік
По-перше, ви повинні усвідомити, що функціональний потік даних поширюється тільки вперед. Ви не можете повернутися «достроково».

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


Як тільки ми це зробимо, у нас з'явиться можливість перетворити весь потік у єдину функцію — чорний ящик:


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


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

Що ми можемо з цим зробити?

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

Ось приклад можливого визначення типу для виведення результату:
type UseCaseResult = 
| Success
| ValidationError 
| UpdateError 
| SmtpError

І ось перероблена діаграма, на якій зображений єдиний вихід з чотирма різними варіантами, включеними в нього:


Спрощення управління помилками
Це вирішує проблему, але наявність помилки для кожного кроку — це тендітна і мало придатна для повторного використання конструкція. Чи можемо ми зробити краще?

Так! Нам насправді потрібні тільки два методу. Один для успішного випадку та інший для всіх помилкових:
type UseCaseResult = 
| Success 
| Failure



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

Ще один момент — у результаті, який повертає функція, зовсім немає даних, статус успіх/невдача. Нам потрібно дещо поправити, щоб результат функції містив фактичний успішний або зіпсований об'єкт. Ми оголосимо успішний і збійний типи універсальні (за допомогою параметрів типів).

Нарешті, наша загальна, універсальна версія:
type Result<'TSuccess,'TFailure> = 
| Success of 'TSuccess
| Failure of 'TFailure

Насправді, в бібліотеці F# вже є подібний тип. Він називається Choice. Для ясності я все ж продовжу використовувати у цій та наступних статтях створений раніше тип Result. Ми повернемося до цього питання, коли підійдемо до більш серйозних завдань.

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


Як це зробити — тема наступної статті.

Підсумок і методичні вказівки
Отже, у нас є такі положення до інструкції:

Методичні вказівки
  • Кожен сценарій рівносильний елементарної функції.
  • Зворотний тип сценарної функції — об'єднання з двома варіантами: Success і Failure.
  • Сценарна функція будується з ряду невеликих функцій, які представляють окремі кроки в потоці даних.
  • Помилки всіх етапів об'єднуються в єдиний шлях помилок.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.