Чому програмісти мають вивчити мову з розвиненою системою типів, навіть якщо нічого не будуть на ній писати
UPD
Пару днів тому на форумі був пост Чому програмісти мають вивчити Haskell, навіть якщо нічого не будуть на ньому писати. Де автору у коментах закидали, мовляв, знову функціональне программування з факторіалами, Фібоначчі та іншим дітячим садком, де реальне життя?
Я заходився писати коментар, але він виріс до таких розмірів, що можна його оформити окремим постом. Поїхали. Нащо мене слухати? Я займався комерційною розробкою на Haskell та останні 13 років комерційно пишу на OCaml.
Що я намагаюсь сказати? Дивіться на фунціональні мови не тому, що там є рекурсія і pattern matching (цим в 2024 вже нікого не здивуєш), а тому, що там є розвинені сучасні системи типизації коду. Саме з них найбільший практичний зиск, і саме у цих мовах простіше всьго користуватись усіма можливостями цих систем типізацій.
Що раніше ви знаходите помилки, то дешевше їх виправити. Динамічні мови як Python, або статичні як C++ і Java, значною мірою покладаються на перевірки під час виконання або на багатослівні анотації типів для запобігання помилкам. Однак система типізації Гіндлі-Мілнера (Hindley-Milner, далі HM) та її варіації, що широко використовуються в функціональних мовах, таких як OCaml, Haskell, Eml, F#, Scala, Rust, пропонує радикально інший та потужний підхід: використання типів для усунення цілих класів помилок на етапі компіляції.
Основою HM є поєднання виведення типів з алгебраїчними типами даних (ADT та GADT). Виведення типів означає, що компілятор автоматично визначає типи будь-якого виразу та усієї програми загалом, роблячи код менш багатослівним, але при цьому строго типізованим. Це дає змогу розробникам зосереджуватися на логіці програми без компромісів у безпеці типів. Проте справжня сила HM полягає в її здатності усувати потребу в розлогому коді, наприклад, перевірках на null або захисному програмуванні. HM дає змогу кодувати інваріанти та усувати недійсні стани безпосередньо в типовій системі.
Але чому варто вивчати HM, якщо ви не використовуєте OCaml чи Haskell? Тому що ці принципи є універсальними. Розуміння HM допомагає по-іншому мислити про типи та їхнє використання для безпечного моделювання доменної логіки. Навіть у таких мовах, як C++ і Java, ви можете застосовувати подібні патерни для більш строгого кодування обмежень.
Але досить пропаганди, вона нікому не цікава без коду. Приклади будуть на OCaml, бо у коментах усі люблять правити код, а так більше ймовірність, що я не зроблю якусь тупу помилку :)
Мотивуючий приклад 1: система обліку чогось
Якщо ви вже знайомі з ADT та pattern matching, можна одразу гортати до прикладу 2.
Беремо умовну систему обліку чогось (що ми будемо називати assets). Спочатку в усіх assets був серійний номер і ми фіксували його в нашій системі. Згодом асортимент побільшав і з’явились assets без серійого номера. На них ліплять наліпки з «inventory ID», і це стало новим стандартом — на все вішають inventory ID. Інколи і на старі assets, які нам відомі під серійним номером. Теперь у нас є зоопарк: частина assets лише з серійним номером, частина — з inventory ID, частина — з тим і тим.
Можна це оформити так:
module Asset_v1 = struct type t = {serial:string option; inventory_id:string option; other_data:string} let set_inventory_id asset inv_id = {asset with inventory_id = Some inv_id} let needs_inventory_id asset = match asset.inventory_id with | None -> true | Some _ -> false end
Дуже просто, дуже компактно, не дуже гарно. Так як обидва види ID є optional, то у нашому коді технічно можливі assets, у яких вони обидва відсутні, що є проблемою.
Замість цього зробимо ID алгебраїчним типом, якій кодує лише три допустимих комбінації:
module Asset_v2 = struct type id = | Serial of string | Inventory_id of string | Serial_and_inventory_id of {serial:string; inventory_id: string} type t = { id : id; other_data : string } let set_inventory_id asset inv_id = let new_id = match asset.id with | Inventory_id _ -> Inventory_id inv_id | Serial serial -> Serial_and_inventory_id {serial; inventory_id=inv_id} | Serial_and_inventory_id info -> Serial_and_inventory_id {info with inventory_id = inv_id} in {asset with id = new_id} let needs_inventory_id asset = match asset.id with | Inventory_id _ | Serial_and_inventory_id _ -> false | Serial _ -> true end
Код став більше за обсягом, але ми позбулися будь-якої можливості мати assets без serial та без inventory id у нашому коді. Крім того, обидві наші функції за рахунок pattern matching (або ще кажуть «деструкції») по типу ID тепер будуь перевірятися компілятором на тотальність покриття всіх варіантів («конструкторів») типу ID.
Наприклад, ми купили ще один склад у конкурентів, і в них була своя особа система з використанням RFID. Тепер у нашому типі ID будуть чотири варіанти:
module Asset_v3 = struct type id = | Serial of string | Inventory_id of string | Serial_and_inventory_id of {serial:string; inventory_id: string} | RFID of string type t = { id : id; other_data : string } end
Як тільки ми це зробимо, отримаємо помилки компіляції в обох функціях «set_inventory_id» та «needs_inventory_id». Які вкажуть на те, що ці функції не обробляють assets, у яких ID — це RFID. І нам потрібно буде додати цю підтримку, наприклад, так:
let set_inventory_id asset inv_id = let new_id = match asset.id with | Inventory_id _ -> Inventory_id inv_id | Serial serial -> Serial_and_inventory_id {serial; inventory_id=inv_id} | Serial_and_inventory_id info -> Serial_and_inventory_id {info with inventory_id = inv_id} | RFID _ -> Inventory_id inv_id in {asset with id = new_id} let needs_inventory_id asset = match asset.id with | Inventory_id _ | Serial_and_inventory_id _ -> false | Serial _ | RFID _ -> true
Код можна взяти із gist і погратися з ним на tryocaml.
Мотивуючий приклад 2: API для баз даних
Уявимо собі просте API для бази даних:
module Jdbc : sig type connection val connect : config:string -> connection val execute : connection -> string -> unit val query : connection -> string -> string list (* wraps [f] in BEGIN ... END, does error handling/cleanup *) val in_transaction : connection -> f:(connection -> 'a) -> 'a end
З його допомогою інші люди пишуть усілякі бібліотеки для Великого Бізнесу™:
module Account_management : sig (* IMPORTANT: always do these in transaction! *) val process1 : Jdbc.connection -> unit val process2 : Jdbc.connection -> unit (* IMPORTANT: This will fail to run in transaction! *) val update_report1 : Jdbc.connection -> unit (* Ok to run either in transaction, or outside of one *) val report2 : Jdbc.connection -> string list end
Припустимо, що process1 та process2 виконують купу пов’язаних операцій в базі, і повинні виконуватись в рамках транзакції. У той же час update_report1 робить щось на кшталт «REFRESH MATERIALIZED VIEW CONURRENTY ...
» в Postgres, і тому не буде працювати в рамках транзакції. Але також буде і велика кількість операцій, які, наприклад, цілком read only, і їх можна використовувати як в рамках транзакції, так і без.
У цьому вигляді API дозволяє користувачам припускатися чисельних помилок, наприклад:
let () = let open Jdbc in let open Account_management in let conn = connect ~config:"jdbc://dbhost/dbname" in (* BAD: User forgot to run the [process1] in transaction *) process1 conn; (* This is actually fine *) update_report1 conn; in_transaction conn ~f:(fun conn -> process2 conn; List.iter print_endline (report2 conn)); (* BAD: user does nested transactions, but our database does not support them. *) in_transaction conn ~f:(fun conn -> process1 conn; in_transaction conn ~f:(fun conn -> process2 conn)); (* BAD: Report1 cannot be ran in transaction! *) in_transaction conn ~f:(fun conn -> update_report1 conn) ;;
(Повна версія коду с іграшковою реалізацією цих API на gist , можете погратися з нею на tryocaml)
Як ми можемо покращити цю ситуацію? Наприклад, можна додати до Jdbc.connection якійсь boolean, який вказує, чи ми зараз в транзакціі, чи ні. І потім в рамках Account_management можемо на цей boolean дивитсь, і кидати exception при некоректному користуванні. Але це будуть перевірки часу виконання.
Чи можемо ми виключити можливість некоректного використання функцій з Account_management на етапі компіляції? Так, можемо. Ми можемо зробити тип Jdbc.connection параметричним, і у якості параметра використовувати тип з двома конструкторами regular та in_transaction, які будуть кодувати поточний стан з’єднання:
module Jdbc : sig type 'a connection val connect : config:string -> [`regular] connection val execute : _ connection -> string -> unit val query : _ connection -> string -> string list val in_transaction : [`regular] connection -> f:([`in_transaction] connection -> 'a) -> 'a end
Ці зміни — лише в сигнатурі фунцій, нам не потрібно додавати жодного коду в імплементацію! Тепер система типів «знає», що для in_transaction потрібне з’єдння, яки ще не є в транзакції, а для execute та query можно використовувати connections як в транзакції, так і ні.
Теперь функції з Account_management можуть вказувати, якого виду connection ім потрібен:
module Account_management : sig (* IMPORTANT: always do these in transaction! *) val process1 : [`in_transaction] Jdbc.connection -> unit val process2 : [`in_transaction] Jdbc.connection -> unit (* IMPORTANT: This will fail to run in transaction! *) val update_report1 : [`regular] Jdbc.connection -> unit (* Ok to run either in transaction, or outside of one *) val report2 : _ Jdbc.connection -> string list end
Знову ж таки, ці зміни — тількі в сигнатурі, нам не потрібно нічого міняти в імплементації чи додавати код, який перевіряє, чи в транзакції ми зараз, чи ні.
Тепер усі «погані» приклади просто перестають компілюватись:
Line 61, characters 11-15: "process1 conn" Error: This expression has type [ `regular ] Jdbc.connection but an expression was expected of type [ `in_transaction ] Jdbc.connection These two variant types have no intersection
Ми виключили можливість некоректного використання функцій з Account_management, це перевіряється на етапі компіляції. І було досягнуто лише за рахунок сигнатур функцій, без написання додаткового коду і без будь-яких runtime перевірок.
(Тут можна погратись з повним кодом після додавання type parameter і переконатись, що некоректні варіанти дійсно не компілюються)
Таким самим чином ми можемо розрізняти файли, відкриті на запис та на читання, і не давали писати в r/o файли на етапі компіляції:
module type File = sig type 'a file val open_ro : string -> [`ro] file val open_rw : string -> [`rw] file val read : _ file -> len:int -> bytes val write : [`rw] file -> bytes -> unit end
Або розрізняти empty, sparse та compact буфери в реалізації якогось формату сериалізації, і не давати (на етапі компіляції) писати на диск sparse та empty буфери:
module type Buffer = sig type 'a buffer val create : unit -> [`empty] buffer (* [delete] removes [len] bytes at [offset] and marks buffer as sparse *) val delete : _ buffer -> offset:int -> len:int -> [`sparse] buffer (* [compact] removes gaps introduced by [delete] *) val compact : [`sparse] buffer -> [`compact] buffer (* [append] does not change the sparseness of the buffer, but it becomes non-empty if it was empty *) val append : [`empty|`compact|`sparse] buffer -> bytes -> [`compact |`sparse] buffer (* you can only save [`compact] buffers *) val write_to_disk : [`compact] buffer -> unit end
Використання потужних систем типізації унеможливлює некоретні стани в вашій програмі, економить вам час і нервові клітини. Ось чому корисно вчити Haskell/OCaml/Scala/...
Сподіваюсь, що це було достатньо близько до реального життя :)
164 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів