Функционально процедурная альтернатива ООП, разработанная для мультипарадигмальных языков
(λ) ФОП – методология, основанная на Процедурном Программировании (ПП) с использованием техник Функционального Программирования (ФП), разработанная для мультипарадигмальных языков.
Для работы по (λ) ФОП в вашем языке должна быть возможность написать 2 вещи:
Схема данных (Data) – в идеале, используя типизацию, но возможно и использование классовой нотации.
// В идеале это должна быть типизация type User = { id: string email: string password: string } // ... но также, подойдёт и классовая нотация class User { constructor( public id, public email, public password, ) {} }
Функция (Function) – старая добрая конструкция, умеющая (1) принимать на вход структуры данных, (2) возвращать результат, (3) передаваться в качестве аргумента.
// Функция / процедура / лямба, в зависимости от языка function changeUserEmail( user: User, newEmail: string, sendEmailNotification: (email: string, body: string) => void ) { if (user.email === newEmail) { throw new Error(`Email must be different`) } user.email = newEmail sendEmailNotification(user.email, `New email assigned`) }
Если попробовать визуализировать (λ) ФОП приложение, то получатся цепочки функци, трансформирующие данные:
То есть основной принцип ФОП – данные и функции существуют раздельно друг от друга и любая функция может использовать в любые данные.
Оглавление
Вместо гигантского лонг-рида (в pdf формате), я решил спрятать содержание глав под раскрывающиеся списки, а самые длинные вынес на отдельные страницы.
Вам достаточно последовательно переходить от раздела к разделу (”Версусы”, “Истоки ФОП” и т.д.), раскрывать главы и читать их.
А на всех отдельных страницах я расположил внизу кнопки “предыдущая / следующая глава”.
Приятного чтения.
1. Версусы
Если у вас чисто ООП-шный язык – пишите на ООП. Если у вас чисто ФП язык – пишите на ФП.
ФОП занимается нишу мультипарадигмальных языков, в которых у вас есть выбор между разными методологиями, например Javascript, TypeScript, Rust, Go, Python, Kotlin, etc.
🥊 ФОП vs ООП
- Вы итак никогда не писали на «чистом ООП»
Редкий backend разработчик писал на каноническом ООП (за исключением Java, C# и людей, использующих DDD).
Проверить это просто:
Как много у вас классов, которые просто держат в себе зависимости и оперируют над моделями? Скорее всего, они еще у вас называются
…Service
или…Manager
. Если много, значит это уже не канонический ООП, а его смесь с процедурным / функциональным программированием. Основная опасность этой смеси – вы потеряете основное преимущество ООП (все операции над «объектом» находятся в нем же) и при этом огребете все проблемы ООП. ФОП, в свою очередь, очень четко описывает свои границы и правила, поэтому вы не столкнётесь с подобной проблемой.
- Каноничные ООП не даёт программе быстро развиваться
При использовании ООП, если вы неправильно разделили данные или спроектировали связи, вам придётся много рефакторить или пилить костыли, типа десятка классов
…Service
/…Manager
. Проблема не в том что вы могли допустить эту ошибку при создании системы, нет, основная проблема в том, ваша система будет продолжать развиваться и вместе с этим будут приходить новые требования, которые не укладываются в текущий граф «объектов». В ФОП вы не столкнётесь с подобной проблемой: каждая отдельная функция оперирует над нужным ей набором данных и при появлении новых требований, вы просто поправите логику в нужных функциях или напишите новые.
- Забудьте про Наследование, Классовой полиморфизм и Абстракцию, а также принципы типа SOLID, KISS, DRY и все 23 паттерна проектирования. Эта картинка была создана как шутка, проблема оказалась в том, что она правда:
Все эти паттерны нужны для того, чтобы решать проблемы ООП, которые он сам и создал... И если нет ООП, значит и нет этих проблем.
ООП настолько абстрактный и сложный, что сложность приходится сначала решить сложность самого ООП, чтобы потом начать решать сложность программы:
ООП: “Keep It Simple Stupid” Также ООП: “Запомни 4 столпа, 1 так и так есть в программировании, 2 антипаттерны, а последний настолько абстрактный, что каждый толкует его как хочет. Еще надо выучить KISS, DRY и SOLID, где все забывают смысл 3-ей буквы, а еще не стоит игнорировать 4-5, так что поркой каждый чих кода интерфейсами. Еще есть шаблоны проектирования, их 36, выучи и не постоянно помни про разницу между виртуальным и абстрактным конструктором, хотя можешь иногда забыть, чтобы оставить немного места на diamond problem и cross cutting concern.”
Подробнее мы разбираем этот вопрос начиная с главы 2.1. Вы не знаете что такое ООП, но если вкратце:
🥋 ФОП vs ФП
- Низкий порог входа
В ФОП вообще не требуются монады, функторы, семигруппы и сетоиды, а каррирование, иммутабельность и чистые функции опциональны.
- Наличие ФП-шных фишек
При этом в ФОП активно используются такие вещи, как ADT, функциональная композиция, карирование, полиморфизм на интерфейсах, декларативный стиль и другие приёмы, позволяющие писать красивый и эффективный код.
- Знакомый
Одной из самых частых фраз при работе с ФОП, которые я слышал: «Ого, оказывается, я практически так и писал свой код!» – поэтому любому разработчику, писавшему на ООП или ФП, ФОП покажется крайне понятным.
- Доступен на любом мультипарадигмальном языке
Языки, которые не были разработаны под модель ФП, или просто не дадут преимуществ ФП.
ФОП же в свою очередь минималистичен и работает в любом языке, в котором есть функции и описание структуры данных.
Подробнее в главе 2.4. Почему ФП и ПП – не выход, а вкратце:
🧑🦽 ФОП vs ПП
- Best-practice Процедурное программирование достаточно устаревшая парадигма, в которой в том числе нет каких-либо современных приёмов по написанию гибкого и удобного кода. ФОП использует современные приёмы такие как ADT, Branded Types, композиция, каррирование и опционально чистые функции.
- Декларативный стиль Процедурное программирование полностью основано на императивном стиле. Это неплохо и в ФОП мы часто будем использовать императившину, но декларативный подход даёт больше возможности писать гибкий и человекопонятный код.
Подробнее в главе 2.4. Почему ФП и ПП – не выход
2. Истоки (λ) ФОП
Проблемы, которые привели к появлению ФОП:
3. Почему (λ) ФОП работает
В предыдущем разделе я рассказал почему ООП не работает, а здесь расскажу почему ФОП работает:
4. Столпы
Для лучше понимания, сравним столпы ФОП со столпами ООП:
🌗 Разделение Данных и Поведения
Поведение (функции) и Данные (объекты, типы, примитивы, etc.) в ООП объединены друг с другом в рамках Инкапсуляции:
// В ООП Данные и Поведение живут в рамках классов // /user.ts class User { // Данные private _id: string private _email: string private _password: string // Поведение setNewPassord(newPassword: string) { const newEncryptedPassword = encrypt(newPassword) if (this._password === newEncryptedPassword) { throw new Error(`New password must not be the same`) } this._password = newEncryptedPassword } }
В ФОП используется обратный принцип, где Данные и Поведение (функции) существуют отдельно друг от друга и при этом любая функция может использовать любые данные:
// В ФОП Данные и Поведение живут отдельно друг от друга // (даже с точки зрения фалойовой системы) // ./user.ts // Данные type User = { id: string email: string password: string } // ./set-user-new-password.ts // Поведение const setUserNewPassword = (user: User, newPassword: string) { const newEncryptedPassword = encrypt(newPassword) if (this._password === newEncryptedPassword) { throw new Error(`New password must not be the same`) } this._password = newEncryptedPassword }
Следствием этого является:
- Все свойства Данных должны быть публичными (никаких
private / protected
)
- Достигается «Натуральная инкапсуляция»
- Достигается «Контекстозависимость»
- Не существует проблемы Cross Cutting Concern
- Достигается настоящий DRY
🍔 Композиция над наследованием
Наследование из ООП предполагает создание обобщенных сущностей и детализацию их через "наследование" более узкоспециализированными сущности.
Если взять и отразить Наследование визуально, получится Дерево, где корнем будет самый высокий родительский класс:
// Родительский класс class Animal { constructor( private position: number = 0, ) {} walk() { this.position += 1; } } // Дочерные классы class Dog extends Animal { constructor( private voice: string ) {} bark() { console.log(this.voice) } } class Cat extends Animal { constructor( private jumpHeight: number ) {} jump(obstacleHeight: number) { if (this.jumpHeight > obstacleHeight) { console.log(`Success`) } else { console.log(`Oooops`) } } }
А вот Композиция предполагает создание узкоспециализированные сущности из которых собирается более обобщенные.
То есть, у нас получается перевернутое Дерево:
Причем это относится как к Данным, так и Поведению:
// КОМПОЗИЦИЯ ДАННЫХ // Из более маленьких Данных ... type Walker = { position: number } // ... мы собираем большие Данные ... type Dog = { walker: Walker voice: string } type Cat = { walker: Walker jumpHeight: number } // ... и пишем для них поведение const walk = (walker: Walker) => { walker.position += 1 } const bark = (dog: Dog) => { console.log(dog.voice) } const jump = (cat: Cat, obstacleHeight: number) => { if (cat.jumpHeight > obstacleHeight) { console.log(`Success`) } else { console.log(`Oooops`) } } // КОМПОЗИЦИЯ ПОВЕДЕНИЯ // Функции работы с числами const addOne = (value: number) => value + 1 const multiplyByTwo = (value: number) => value * 2 // А вот композиция, создающая новую из существующих функций const addOneAndMultiplyByTwo = (value: number) => multiplyByTwo(addOne(value))
Основным преимуществом Композиции, является возможность абсолютная гибкость в расширении данных и поведения, при уменьшении сложности каждой отдельной составляющей.
Для примера силы Композиции и недостатков Наследования:
// Мы должны создать 3-х собак: // 1. Живую Собаку, которая ходит, гавкает и пердит // 2. Мертвую собаку, которая только пердит // 3. Собаку-робота, которая ходит и гавкает // Реализация в стиле Композиции type Walker = { position: number } const walk = (walker: Walker) => { walker.position += 1 } type Farter = { noise: string } const fart = (farter: Farter) => { console.log(farter.noise) } type Barker = { voice: string } const bark = (barker: Barker) => { console.log(barker.voice) } type Dog = { walker: Walker farter: Farter barker: Barker } type DeadDog = { farter: Farter } type RobotDog = { walker: Walker barker: Barker } // . А теперь попробуйте сделать это, используя Наследование и вы удивитесь // насколько сложная реализация у вас получится. // ...
🧬 Полиморфизм на интерфейсах
Полиморфизм – механизм, позволяющий нам структурировать логику для ее дальнейшего переиспользования.
В ООП используется “Полиморфизм на наследовании”:
// Класс, описывающий наличие тестикул и возможности их отрезать class BallsOwner { _balls: boolean cutBallsOff() { this._balls = false console.log("😢") } } // Классы, владеющий тестикулами (и своими дополнительными свойствами) class Cat extends BallsOwner { _clawsLength: number } class Dog extends BallsOwner { _teethLength: number } // Класс, который будет использовать родительский класс, используя полиморфизм class Villain { constructor( name: string, ) {} castrate(ballsOwner: BallsOwner) => { ballsOwner.cutBallsOff() } } const cat = new Cat() const dog = new Dog() const villain = new Villain("Valera") villain.castrate(cat) villain.castrate(dog)
В ФОП используется множество вариантов “Полиморфизма на интерфейсах”:
// 1. ПОЛИМОРФИЗМ НА КОМПОЗИЦИИ type BallsOwner = { balls: boolean } type Cat = { ballsOwner: BallsOwner clawsLength: number } type Dog = { ballsOwner: BallsOwner teethLength: number } const cat: Cat = { ballsOwner: { balls: true }, clawsLength: 10 } const dog: Dog = { ballsOwner: { balls: true }, clawsLength: 10 } const castrate = (ballsOwner: BallsOwner) => { ballsOwner.balls = false console.log("😢") } castrate(cat.ballsOwner) castrate(dog.ballsOwner) // 2. ПОЛИМОРФИЗМ НА UNION TYPE type Cat = { clawsLength: number balls: boolean } type Dog = { teethLength: number balls: boolean } type Fish = { finLength: number balls: boolean } // Вот этот тип использует Union Type (|) type BallsOwner = Cat | Dog | Fish const castrate = (ballsOwner: BallsOwner) => { ballsOwner.balls = false console.log("😢") } // 3. ПОЛИМОРФИЗМ НА DISCRIMINANT UNION type Cat = { type: "Cat" balls: boolean } type Dog = { type: "Dog" balls: boolean } type Fish = { type: "Fish" balls: boolean } // Вот этот тип использует Discriminated Union (|) type BallsOwner = Cat | Dog | Fish const castrate = (ballsOwner: BallsOwner) => { ballsOwner.balls = false // . Сверяем по типу switch ballsOwner.type { case "Cat": console.log("MEOW 😢") case "Dog": console.log("BARK 😢") case "Fish": console.log("... 😢") } } // 4. ПОЛИМОРФИЗМ ПОВЕДЕНИЯ type Cat = { clawsLength: number balls: boolean } type Dog = { teethLength: number balls: boolean } type Fish = { finLength: number balls: boolean } // . Реализация функции фразы про потерю шариков const catBallsLoosingPhrase = (cat: Cat) => if (!cat.balls) "MEOW 😢" const dogBallsLoosingPhrase = (dog: Dog) => if (!dog.balls) "BARK 😢" const fishBallsLoosingPhrase = (fish: Fish) => if (!fish.balls) "... 😢" // Вот этот тип использует Union Type (|) type BallsOwner = Cat | Dog | Fish // Тип, описывающий полисорфизм поведения type BallsLoosingPhrase = (ballsOwner: BallsOwner) => string const castrate = (ballsOwner: BallsOwner, phrase: BallsLoosingPhrase) => { console.log(phrase(ballsOwner)) ballsOwner.balls = false }
🥚 Натуральная Инкапсуляция
“Объектная” инкапсуляция – создается из-за замыкания данных и поведения в “объекты”:
Такие барьеры доступа данных и поведения друг между другом приводят к тому, что
- Все “объекты” начинают ссылаться и использовать методы друг друга, превращая дебагинг в кошмар
- При появлении новых сущностей, приходится придумывать и встраивать в этот граф связей новые “объекты”
- При рефакторинге 1 фичи может быть затронуто огромное кол-во “объектов”, потому что она проходит сквозь них
- Большинство методом классов будут существовать, чтобы их дернул 1 раз 1 другой класс.
Это также называется: “повесить замок на правый карман для левой руки” – зачем это делать, когда левая рука это ваша же рука?
ООП разработчик может возразить и сказать: “Все это просто означает, что вы допустили ошибку в проектировании и пора менять структуру объектов, используя десятки паттернов созданных специально для таких случаев”
Но на самом деле, если бы ООП не был использован в первую очередь, этих проблем просто не возникло, а значит и никакого рефакторинга не потребовалось бы.
Натуральная инкапсуляция – это сокрытие, кода, которое происходит само собой.
Например, натуральная инкапсуляция существует на уровне любого сервиса: вся кодовая база ваша приложения скрыта внутри него самого и наружу оно отдает только некоторое API.
Также хорошим примером будет код библиотеки библиотек – любая библиотека отдает наружу только конкретные методы и сущности, которыми вы будете пользоваться, оставляя кучу реализации в своих закромах.
Можно представить целый сервис или библиотеку как один большой “объект”, где все свойства (данные) и методы (функции) доступны друг другу.
Использовать натуральную инкапсуляцию очень просто – описываете данные и даете функциям использовать все эти данные. Если хотите добавить границ в коде, начните разбивать его на библиотеки / сервисы / модули / доменные области / что угодно.
- Независимые функции можно выстроить в ацикличный граф, поэтому дедлупов не будет.
- При появлении новых сущностей достаточно просто описать структуры данных и написать функции, работающие с ними.
- По ФОП одна фича, скорее всего, будет в 1 функции, которая использует множество данных, поэтому для рефакторинга можно затронуть всего 1 функцию.
- Если что-то нужно сделать всего 1 раз в 1 функции, это будет написано прямо в этой функции, потому что она имеет право работать с любыми данными (а не только теми, что у нее в свойствах “объекта”)
Таким образом, отказавшись от ООП мы на корню решаем проблемы, созданные самим же ООП.
5. Полезные техники
Необязательные, но очень удобные техники для написания функционально-процедурного кода:
Брендированные типы / Кастомные примитивы
Типизация сама по себе является валидацией типа при компиляции и заставляет нас преобразовывать и убеждаться в корректности типа:
const someFn = (val: string) => { //... } const someOtherFn = (val: string | number | null) => { // ... // Придется сначала сделать проверку, чтобы убедиться в типе данных // иначе программа не скомпилируется if (typeof val === number || val === null) { throw new Error(`Not correct type`) } someFn(val) }
Так почему бы нам не развить эту мысль дальше и расширить наши типы:
// Наш кастомный тип type Email = string // Наша функция, проверяющая, что строчка является Email const emailFromString = (val: string): Email => { if (!val.inludes("@") { throw new Error(`Not correct email`) } return val as Email } // Пример другой функции, принимающей наш брендированный тип const someFn = (val: Email) => { //... } const val: string = "[email protected]" someFn( val // Здесь будет ошибка при компиляции / в IDE // потому что string нельзя использовать вместо Email ) someFn( emailFromString(val) // А здесь все норм )
Так можно создавать огромное кол-во разных типов:
UUID
, UserId
, StringMin50Symbols
, etc.Единственная проблема в том, способен ли правильно ваш язык интерпретировать, что кастомный тип
Email
, на самом деле не является string
?Например, в примере выше можно было в
someFn
просто передать строчку и он бы это принял.Чтобы решить это проблему используется техника
Branded types
, которая предполагает превращение типа в условноуникальный (то есть, уникальный только на уровне компиляции):// На Go очень просто type Email = string // На TypeScript type Email = string & { readonly "Email": unique symbol }
Теперь код выше будет работать корректно.
Способы реализации
Branded type
для каждого отдельного языка мы рассмотрим в ниже главе «💬 Языки программирования».Pattern Matching
Задача такая: “у нас есть парсеры данных двух видов устройств, нужно сделать функцию парсинга, которая будет использовать тот или иной вид парсера”.
Как подобная задача решается в ООП:
// # Создаем реализации первого и второго парсера class FirstTerminalParser { parse: (data: string): void { // ... } } class SecondTerminalParser { parse: (data: string): void { // ... } } // # Описываем общий интерфейс type Parser = { parse: (data: string) => void } // # Реализуем нашу функцию const someFunction = (data: string, parser: Parser) => { parser.parse(data) }
Мы воспользовались такой техникой как “Интерфейсный полиморфизм”: поскольку наши классы подходят под интерфейс аргумента, мы можем их использовать в этом месте.
Это одно из множества решений, доступных в ООП, но как и в большинстве из них (особенно, в самых распространенных), здесь используется “абстракция”, в виде интерфейса.
А вот как та же самая задача решается в Функциональном стиле:
// # Мы точно также можем использовать классовую нотацию, если хотим class FirstTerminalParser { parse: (data: string): void { // ... } } class SecondTerminalParser { parse: (data: string): void { // ... } } // # В этот раз наш интерфейс не "обобщает", а конкретно указывает "или/или" через Union Type type Parser = FirstTerminalParser | SecondTerminalParser // # Реализуем нашу функцию const someFunction = (data: string, parser: Parser) => { parser.parse(data) }
Как видите разница в 1 строчку, но на деле глубина в этом разнице невероятная:
- Во-первых, теперь “Parser” это не нечто абстрактное, а конкретно один из двух типов (а значит все подсказки по типам буду работать еще лучше)
- Во-вторых, мы теперь можем использовать Pattern Matching
class FirstTerminalParser { name: "FirstTerminalParser"; // # Добавляем поле, по которому будем делать Pattern Matching parse: (data: string): void { // ... }; firstTerminalSpecificFunction: (): string { // ... }; } class SecondTerminalParser { name: "SecondTerminalParser"; // # Такойже ключ, но другое значение parse: (data: string): void { // ... }; secondTerminalSpecificFunction: (): number { // ... }; } type Parser = FirstTerminalParser | SecondTerminalParser; const someFunction = (data: string, parser: Parser) => { switch (parser.name) { case "FirstTerminalParser": return parser.firstTerminalSpecificFunction(); case "SecondTerminalParser": return parser.secondTerminalSpecificFunction(); // Switch guard или "проверка на конечность вариантов" default: const arg: never = parser; // # Это так называемый Switch Safe Guard throw new Error(`Undefined ${parser}`) } };
Что позволяет нам в нужный момент использовать специфичные функции конкретной реализации.
А еще ОГРОМНОЕ преимущество в том, что благодаря проверки на конечность вариантов (конструкция в default), если мы добавим новый тип в
Parser
и забудем добавить case
на его обработку, то ошибка выскочит еще на моменте компиляции.По-началу может быть не очень очевидно, но мой огромный совет: попробуйте вместо абстракции, там где у вас есть выбор между несколькими вариантами воспользоваться
Union Type
и Pattern Matching
и тогда вы сможете понять всю силу этого подхода.ADT и Инварианты
Можете ли вы смотря на вот эту структуру сказать какие ее вариации будут нормальными для бизнес-логики:
type User = { id: string email: string | undefined isEmailActivated: boolean }
Вы можете предположить, что
isEmailActivated
не может быть true
, если email: undefined
, НО ЭТО ЛИШЬ ПРЕДПОЛОЖЕНИЕ.А что если мы сделаем так:
type User = { id: string email: undefined isEmailActivated: false } | { id: string email: string isEmailActivated: false } | { id: string email: string isEmailActivated: true }
Теперь мы четко видим какие вариации подходят нашей логике приложения.
Инварианты – это вариации значений данных, которые корректны для нашей логики приложения.
А давайте сделаем это более удобным и человекочитаемым:
type UserEmailEmpty = { email: undefined isEmailActivated: false } type UserEmailUnactivated = { email: string isEmailActivated: false } type UserEmailActivated = { email: string isEmailActivated: true } type UserEmail = UserEmailEmpty | UserEmailUnactivated | UserEmailActivated type User = { id: string userEmail: UserEmail }
Теперь, мы четко видим все инварианты состояний почты пользователя.
Algebraic Data Type (ADT) – это описание каждого отдельного инварианта (как
UserEmailEmpty
, UserEmailUnactivated
, UserEmailActivated
), и их сочетаний (UserEmail
).Чисто для развития: описание отдельного варианта в ADT называется “product type”, а их сочетания, называется “sum type”, можете подробнее почитать википедию, если поймете что там написано)
Также, ADT дает нам потрясающие возможности pattern matching.
Но реализация ADT супер-сильно отличается от языка к языку, поэтому более подробно мы ознакомимся с техниками ADT уже в разделе “💬 Языки программирования”.
! ВАЖНОЕ УТОЧНЕНИЕ ! Чем сложнее ваши инварианты, тем более надёжная программа, но при этом намного сложнее их рефакторить. Поэтому мой совет: пишите комплексные инварианты только тогда, когда логика определённого места уже достаточно хорошо закрепилась и не собирается часто меняться.
Декларативное программирование
Императивное программирование описывает "как что-то сделать":
Чтобы достать информацию о пользователе с id = 1, надо открыть файл, лежащий в определенной папке, далее прочитать содержимое, распарсить его, ..., собрать структуру пользователя и отдать").
В декларативной программировании вы описываете "что надо сделать":
Достань мне пользователя с id = 1
Самый очевидный пример декларативного языка – SQL.
Вы же не говорите SQL: "чтобы достать информацию о пользователе с id = 1, надо открыть файл, ..." – вы говорите: "Достань мне пользователя с id = 1", или по-другому:
SELECT * FROM USER WHERE id = 1
– а вот уже движок базы данных под собой производит все нужные императивные действия.С SQL понятно, но как писать "декларативный код" на каком-нибудь JS или схожем языке?
Я долго думал, как упростить ответ и понял, что самое простое описание:
В декларативной стилистике на каждую операцию у вас должна быть своя функция
Пример с
try-catch
:// Императивный try catch let result: string try { result = await someFn("Hello world!") } catch (e) { console.log(e) } // Декларативный try catch const tryCatch = async( tryFn: () => any, catchFn: (e: any) => any ): Promise<R> => { try { await tryFn() } catch (e) { await catchFn(e) throw e } } let result await tryCatch( () => result = someFn("Hello world!"), (e) => console.log(e) )
Может показаться: «Что это за хрень такая? Зачем это» – а вот вам сразу и ответ:
// 1. Декларативный try catch с возвратом (<R> – это generic для типизации результата) const tryCatchReturn = async <R>( tryFn: () => R, catchFn: (e: any) => any ): Promise<R> => { try { return await tryFn() } catch (e) { await catchFn(e) throw e } } // Это может быть очень удобной альтернативой вместо написания // let result каждый раз вне try catch const result = await tryCatchReturn( () => someFn("Hello world!"), (e) => console.log(e) ) // 2. А еще можно возвращать результат или функцию const tryCatchEither = async <R>( tryFn: () => R ): Promise<R | Error> => { try { return await tryFn() } catch (e) { if (e instanceof Error) return e throw e } } // Это бывает полезно, когда мы хотим быть уверенны, что // обработали ошибку const result = await tryCatchEither(() => someFn("Hello world!")) if (result instanceof Error) { // ... } // 3. А еще можно удобным образом создать функцию tryCatch, // которая всегда будет логировать ошибку и пробрасывать дальше const tryCatchLog = (logger: Logger) => { return (tryFn: () => any) => { return tryCatchReturn( tryFn, (e) => { logger.error(e) } ) } } // Теперь где-то в начале программы добавим в нее логгер const logger = new Logger() const tcl = tryCatchLog(logger) // И можем теперь использовать вот так const result = tcl(() => someFn("Hello world"))
Вот еще пример с валидаторами:
// Как мы можем императивно проверить правильность логина const login = "HelloZalupa" const imperativeCheckLogin = (login: string) => { if (login === "" || login.length < 6 || login.includes("Zalupa")) { throw new Error(`Incorrect login`) } } // Теперь сделаем это деклапативно const isNotEmptyString = (val: string): string => { if (val === "") throw new Error(`String must not be empty`) return string } const stringMin6Symbols = (val: string): string => { if (val.length < 6) throw new Error(`String must be at least 6 symbols`) return string } const stringHasNoBadWord = (val: string): string => { if (val.includes("Zalupa")) throw new Error(`String must not include zalupa`) return string } const declarativeCheckLogin = (val: string) => { return stringHasNoBadWord( stringMin6Symbols( isNotEmptyString( val ) ) ) }
И таких функций и их композиций могут быть просто сотни.
Главное, что каждая функция выполняет какую-то небольшую операцию и из нее можно составить более комплексную функцию.
И именно это является главным преимуществом декларативного подхода – максимальная гибкость и переиспользуемость.
Поэтому по факту, если ваш язык сам по себе не декларативный (как SQL), то вы по-прежнему можете писать декларативный код, просто это будет куча функций, где в каждой будет немного императивного кода.
P.S.
Несомненно нужно аккуратно пользоваться декларативностью, потому что можно нагородить такую маленькую кучу функций, что с ними вообще неудобно будет работать. Поэтому в ФОП мы предпочитаем декларативный подход, но абсолютно без проблем можем писать императивно.
Иммутабельность и Чистые функции
Чистые функции – это функции удовлетворяющие 2-м свойствам: (1) при одинаковом вводе, выдает одинаковый результат, (2) не делает side-effects (изменение внешнего состояния или обращение к системе).
Теперь давайте тест:
// Какие функции являются чистыми, а какие нет const fn1 = (a: string): string => { return a + ", hi!" } const fn2 = (a: { name: string }) => { a.name += ", hi!" return a } const fn3 = (a: { name: string }) => { fn2(a) return a.name + ", hi!" } const fn4 = (a: { name: string }) => { return { name: a.name + ", hi!" } } const fn5 = (a: { name: string }) => { const newName = fn4(a) newName.name += " Bye!" reutrn newName } let counter = 0 const fn6 = () => { counter += 1 return `Updated ${counter} times` } const fn7 = () => { let counter = 0 return () => { counter += 1 return `Updated ${counter} times` } } const fn8 = (a: number): number => { console.log(Math.random()) return a * 2 } const fn9 = ( db: {update: (byId: string, email: string) => void}, id: string, newEmail: srtring ) => { return db.update(id, newEmail) } const fn10 = (db: {getById: (id: string) => User}, id: string) => { return db.getById(id) }
А вот ответы:
// Чистая const fn1 = (a: string): string => { return a + ", hi!" } // Нечистая, потому что меняет внешнее состояние (аргумент) const fn2 = (a: { name: string }): { name: string } => { a.name += ", h1!" return a } // Нечистая, потому что использует другую нечистую (fn2) const fn3 = (a: { name: string }) => { fn2(a) return a.name + ", hi!" } // Чистая, потому что ничего не изменяет const fn4 = (a: { name: string }) => { return { name: a.name + ", hi!" } } // Нечистая, потому что изменяет возврат внутренней чистой функции (fn4) const fn5 = (a: { name: string }) => { const newName = fn4(a) newName.name += " Bye!" reutrn newName } // Нечистая, потому что меняет внешнее состояние (внешнюю переменную) let counter = 0 const fn6 = () => { counter += 1 return `Updated ${counter} times` } // Эту можно назвать чистой const fn7 = () => { let counter = 0 // А вот это нечистая функция, потому что меняет внешнее состояние (внешнюю переменную) return () => { counter += 1 return `Updated ${counter} times` } } // Нечистая, потому что делает сразу 2 side-effect: // 1. console.log – это изменение внешнего состояния (stdout.write) // 2. Math.random() – это сайд-эффект, потому что он обращается к ОС, чтобы взять сид для рандома const fn8 = (a: number): number => { console.log(Math.random()) return a * 2 } // Нечистая, потому-что делает 2 side-effect: // 1. Обращается к внешней системе (db) // 2. Меняет состояние (db.update) const fn9 = ( db: {update: (byId: string, email: string) => void}, id: string, newEmail: srtring ) => { return db.update(id, newEmail) } // Нечистая, потому-что делает side-effect: обращается к внешней системе (db) const fn10 = (db: {getById: (id: string) => User}, id: string) => { return db.getById(id) }
Встает 2 вопроса:
- Что если нам нужно обращаться к внешним системам (
db
)?
- Что если нам все-таки надо что-то поменять?
На первый ответ следующий: мы выделяем “нечистые” функции, которые будут работать с внешними системами, от “чистых” функций, которые будут трансформировать эти данные.
// Типы type User = { id: string email: string // pasword: string – это поле лежит в БД, но мы не хотим его доставать в программу } type DB = { updateById: (id: string, user: User) => void getById: (id: string) => User } // Нечистая функция работы с бд const updateUser = ( db: db, user: User, ) => { return db.updateById(user.id, user) } // Нечистая функция работы с бд const getUserById = (db: DB, id: string) => { const res = db.getById(id) return { id: res.id, email: res.email } } // Чистая функция бизнес-логики const assignNewUserEmail = (user: User, newEmail: string) => { if (!newEmail.includes("@") { throw new Error(`Email is invalid`) } if (user.email === newEmail) { throw new Error(`New email must be different`) } return { ...user, email: newEmail, } } // А теперь собираем их вместе const updateUserEmailController = (db: DB, userId: string, newEmail: string) => { const user = getUserById(db, userId) const updatedUser = assignNewUserEmail(user, newEmail) updateUser(db, updatedUser) }
По факту, у нас получается слоенный пирог: IO → Бизнес-логика → IO
И так можно делать сколько угодно слоев.
Что если нам все-таки надо что-то поменять?
Тогда мы должны создать копию этого аргумента, изменить эту копию и вернуть ее:
type User = { firstName: string; secondName: string; fio: string | undefined; } const addFIO = (user: User): User => { return { ...user, // Копируем все свойства fio: user.firstName + user.secondName // добавляем новое поле } }
Это и называется Иммутабельность – вместо изменения самой сущности, мы создаём ее копию и изменяем ее.
Важный лайфхак: в большинстве языков не оптимизирована работа с иммутабельными структурами, поэтому абсолютно нормальным будет если вы будете мутировать те сущности, которые создали в этой же функции:
const someFn = (someArray: string[]) => { const array = [] for (let i = 0; i < someArray.length; i++) { // Мы можем изменять array, потому что он был создан в этой же функции array.push(someArray[i] + ", hi!") } return array }
Преимущества иммутабельности и чистых функций:
Предсказуемость
Часто недооцененное, но крайне полезное свойство кода.
Вот вам пример:
const predictabilityExaple = async (db: DB) => { const user = await db.getUser() const userSubscriptions = await db.getUserSubscriptions(user.id) const userWallet = await db.getUserWallet(user.id) await changeUserSubscriptionPlan(user, userSubscriptions, userWallet) }
Что именно из
user
, userSubscriptions
и userWallet
измениться в процессе выполнения changeUserSubscriptionPlan
?Вопрос, ответ на который мы можем получить только заглянув в сам
changeUserSubscriptionPlan
.А если там 1000 строчек кода, вызов других функций и библиотек? Тогда нам будет тяжело.
А как нам поможет иммутабельность?
А вот так:
const predictabilityExaple = async (db: DB) => { const user = await db.getUser() const userSubscriptions = await db.getUserSubscriptions(user.id) const userWallet = await db.getUserWallet(user.id) const [updatedUserSubscriptions, updatedUserWallet] = await changeUserSubscriptionPlan(user, userSubscriptions, userWallet) }
Теперь мы точно знаем, что изменения произойдут над сущностями
userSubscriptions
и userWallet
, а сам user
не будет изменяться в процессе выполнения этого кода.Это следствие можно описать следующим образом:
Если функция возвращает структуру, которая передана ей в аргументы, значит где-то внутри происходила трансформация этой структуры
Преимущество этого следствия сложно раскрыть на небольшом примере, но чем больше кода вы пишете, чем больше трансформаций он производит, тем сложнее уследить что вы не произвели трансформацию там, где не хотели этого и наоборот.
А если вам еще нам нужно проверить, что именно изменилось, то мы можем воспользоваться следующим преимуществом иммутабельности:
Delta comparison
delta – изменение
Механизм на котором построены React и Redux.
Их суть следующая: если вам нужно проверить "а произошло ли изменение", то использованием иммутабльности это сделать очень просто, потому что если изменилось хотябы одно поле, лежащие глубоко глубокого в нашей ссылочной переменной, то сама ссылка тоже поменяется и можно сделать просто сравнение по ссылке:
type User = { id: string username: string team: { id: string name: string members: User[] } } const shallowComparisonExample = async (db: DB) => { const user: User = await db.getUser() const updatedUser = await someFunctionThatUpdateSomethingInUser(user) if (user !== updatedUser) { // Не важно что изменилось и насколько глубоко в user, если // хоть какое-то изменение произошло, ссылки у user и updatedUser // будут разные } }
Так ко всему прочему, мы таким же образом спокойно можем проверить и что именно изменилось:
type User = { id: string username: string team: { id: string name: string members: User[] } } // Ооочень утрированная функция проверки на изменения const compare = (recordA: Record<any, any>, recordB: Record<any, any>) => { // Итерируем по свойствам Record for (const property in userB) { if (recordA[property] !== recordB[property]) { console.log(`Property ${property} changed from ${recordA[property]} to ${recordB[property]}`) // Утрированная проверка, что поле тоже является if (typeof userB[property] === 'object') { compare(recordA[property], recordB[property]) } } } } const shallowComparisonExample = async (db: DB) => { const user: User = await db.getUser() const updatedUser = someFunctionThatUpdateSomethingInUser(user) if (user !== updatedUser) { compare(user, updatedUser) } }
Этим свойством иммутабельности пользуются React и Redux.
А к примеру на backend, мне приходилось реализовывать упрощенный паттерн Unit of Work:
const UserDataService = (db: DB) => { let users = {} return { getUserById: (id: string): User => { const user = await db.getUser({id: id}) users = { ...users, [user.id]: user, } }, saveUser: (updatedUser: User): Promise<User> => { // Функция getChangedProperiesAndValues возвращает мне объект только с измененными // полями и их значениями. Она отрабатывает очень быстро, потому что я знаю, что мой код // иммутабельный, а значит, если user хоть како-то изменился, достаточно сделать простую проверку const delta = getChangedProperiesAndValues(updatedUser, users[updatedUser.id]) if (delta) { await db.saveUser(delta) } return updatedUser } } } const uowExample = async (db: DB, userId: string) => { const userDS = UserDataService(db) const user: User = await userDS.getUserById(userId) const updatedUser = someFunctionThatUpdateSomethingInUser(user) await userDS.saveUser(updatedUser) }
Race-conditions
Их вроде бы не существует в языках без параллелизма, но это не так. И вот вам пример на JS:
const raceConditionCounter = async () => { let counter = { value: 1 } await Promise.all([ async () => counter.value = counter.value - 1, async () => counter.value = counter.value * 4, async () => { if (counter.value === 0) throw new Error() counter.value = counter.value / 2 }, ]) console.log(counter.value) }
Какое значение будет выведено в консоль?
Правильный ответ: всегда разное, потому что мы не знаем порядка исполнения данных промиссов.
Поэтому, если вам нужно написать логику кода, которая запустит набор процессов над сущностью "параллельно", чтобы быть уверенным, что результат каждой из них не будет влиять на другой, мы воспользоваться иммутабельностью:
const raceConditionCounter = async () => { let counter = { value: 1 } const [decrementedCounter, mulipliedCounter, dividedCounter] = await Promise.all([ async () => { value: counter.value - 1 }, async () => { value: counter.value * 4 }, async () => { if (counter.value === 0) throw new Error() return { value: counter.value / 2 }, }, ]) console.log(counter.value, decrementedCounter.value, mulipliedCounter.value, dividedCounter.value) // 1, 0, 4, 0.5 }
Из личного опыта могу сказать, что сталкивался с такими ситуациями при работе с парсерами и ETL.
История изменений
Предположим вам нужно иметь "историю изменений" и возможность откатиться к предыдущему состоянию.
Если каждое изменение создает новый объект, значит, мы можем хранить каждую версию объекта в массиве. А значит в нужный момент, откатиться в его предыдущие состояния.
Да да,
Ctrl+Z
чаще работает с использованием иммутабельности.Простота тестирования
Тестировать чистые функции – одно удовольствие.
В них не происходит ничего лишнего, для них не надо ничего заранее инициализировать, вы просто описываете разные варианты входных аргументов и проверяете с тем, что хотите увидеть на выходе.
Недостатки иммутабельности и чистых функций:
Дизайн кода
Во-первых, если вы будете мутировать сущности и при этом не умеете / ваш язык не дает возможность писать pipe-ы, то получится следующее:
const user = getUser() const userUpdatedEmail = updateUserEmail(user) const userUpdatedEmailAndAge = updateUserAge(userUpdatedEmail) const userUpdatedEmailAgeAddedRewards = addRewards(userUpdatedEmailAndAge) const userToSave = updateUserAge(userUpdatedEmailAgeAddedRewards) saveUser(userToSave)
Запутаться в таком ситуации ооочень просто. Это выглядит абсолютно ужасно.
Pipe могут улучшить ситуацию:
// Скоро такой оператор появиться в JS, // но он есть в функциальных языках типа Elixir const user = getUser(userId) |-> updateUserEmail |-> updateUserAge |-> addRewards |-> updateUserAge |-> saveUser // Альтернативой может быть композиция const user = saveUser( updateUserAge( addRewards( updateUserAge( updateUserEmail( getUser(userId) ) ) ) ) ) // Или использование сторонних библиотек, НО сразу скажу, // на TS например, такие функции очень тяжело типизировать // если они используют generic const user = pipe( getUser, updateUserEmail, updateUserAge, addRewards, updateUserAge, saveUser, )(userId)
Пример не из реального кода, но даже он выглядит все-равно ужасно.
Хуже еще то, что редко когда мы можем удобно разделить функции на небольшие кусочки, поэтому придется приложить реальные усилия, чтобы написать такой код.
Работа со слоистым IO
Нужно очень четко делить функции, которые будут работать с IO, а какие будут работать с бизнес-логикой.
Звучит просто, но попробуйте переписать какой-нибудь кусок кода в похожей манере и вы поймете в чем реальная проблема, а она достаточно серьезная.
Performance
Как бы то ни было, если в вашем языке нет встроенных иммутабельных структур, то иммутабельность всегда будет медленее мутабельности.
Вот вам статья, которая описывает как работают иммутабельные структуры.
Да, можно, конечно, использовать специальные библиотеки, но прирост в скорости все равно не превысит скорость мутабельности.
Для многих программ и программистов, недостатки перевешивают преимущества, поэтому в ФОП, вы можете использовать иммутабельность и чистые функции по своему усмотрению.
Рекомендацией будет все-таки использовать их, но это на вкус и цвет.
Closureful & Closureless Functions
Если функция использует переменные замыкания тогда она называется называются
closureful
, если же нет тогда closureless
:// counter – замыкание для функции statefulFn, let counter = 0; const closurfulFn = () => { counter = counter + 1; } // а в данной ситуации функция работает только с входными аргументами, // поэтому она не использует замыкание const closurelessFn = (counter: number) => { return counter + 1; }
На протяжении всей кодовой базы мы должны будем с вами решать каким из подходов пользоваться, особенно когда мы хотим написать какой-нибудь “модуль” / “библиотеку” или нечто подобное.
Вот пример closureful модуля Vector2:
// 1. Closureful поведение, замыкающее состояние // 1.1. В классовой нотации class Vector2 { constructor( public x: number, public y: number, ) {} add(vec: Vector2): Vector2 { return new Vector2(this.x + vec.x, this.y + vec.y) } } const firstVector = new Vector2(1,2) const secondVector = new Vector2(3,4) const summedVector = firstVector.add(secondVector) // 1.2. Или в функциональной нотации type Vector2 = { x: number y: number add: (vec: Vector2) => Vector2 } const newVector2 = (x: number, y: number) => { return { x, y, add: (vec: Vector2): Vector2 => { return Vector2.new(x + vec.x, y + vec.y) } } } const firstVector = newVector2(1,2) const secondVector = newVector2(3,4) const summedVector = firstVector.add(secondVector)
А вот в Closureless:
// 2. Closureless поведение // 2.1. В классовой нотации class Vector2 { constructor( public x: number, public y: number, ) {} // Статические методы позволяют избежать использовать замыкания static add(firstVec: Vector2, secondVec: Vector2): Vector2 { return new Vector2(firstVec.x + secondVec.x, firstVec.y + secondVec.y) } } const firstVector = new Vector2(1,2) const secondVector = new Vector2(3,4) const summedVector = Vector2.add(firstVector, secondVector) // 1.2. Или в функциональной нотации type Vector2 = { x: number y: number } const newVector2 = (x: number, y: number): Vector2 => { return {x, y} } const add = (firstVec: Vector2, secondVec: Vector2): Vector2 => { return newVector2(firstVec.x + secondVec.x, firstVec.y + secondVec.y) } const firstVector = newVector2(1,2) const secondVector = newVector2(3,4) const summedVector = add(firstVector, secondVector)
Чтобы понять где выбирать Closureless, а где Closureful, достаточно использовать 2 правила:
- Если вы создаёте библиотеку для других разработчиков, используйте тот подход, который распространён в вашем языке. Например, для JS, Python, PHP это будет Closureful подход.
- Во всех остальных случаях всегда используйте Closureless функции.
Это следует из применения приципа 3.4. What you need, where you need it (WYNWYN): Контекстозависимость и Связанность кода: поскольку функции библиотек должен использовать во множестве мест (контекстов), он может существовать рядом с данными (closureful), но в остальным случаях стоит предпочитать Closureless.
Очень хорошо этот принцип иллюстрирует следующая техника: “Utils & App Specific Data”
Utils & App Specific Data
В любой программе существует 2 вида данных:
- Utils (Утилитарные) – это данные, которые можно было бы использовать в абсолютно любой программе:
- Email – потому что Email имеет корректный и понятную форму
- Vector – потому что это научное понятие, с описанной формой и операциями, которые мы можем над ним проводить
- GOST132 – это форма расчёта прибыли, которая задокументировала на государственном уровне
- PostgreSQL Driver – код, который позволяет нам общаться с нашей PSQL базой
- Logger – код, позволяющий нам создавать логи
- и так далее
- App Specific – данные и поведение, которое уникально для нашего приложения / системы
- User – хоть и в 70% случаев эти данные похожи между приложениями, оно будет уникально для вашего
- Order – форма и действия, которые мы будем производить над каким-то «заказом» очень сильно отличаются от приложения, к приложению
- и так далее
И именно то, где вы будете располагать функции, работающие с App Specific Data будет определяется в какой стилистике написан ваш код: ООП, ФОП, ФП, etc.
В ФОП принцип такой:
- Функции Utils Данных можно писать прямо рядом с ними
Дело в том, что в 90% случаев Поведение Utils Данных достаточно хорошо понятно и не будет слишком сильно расширяться, поэтому мы можем описать его полностью в одном месте:
// Файл ./vector.ts // Мы можем не стесняться и полностью описать Поведение Vector2 прямо в // файле с описание Vector2 // В Stateful стилистике type Vector2 = { x: number y: number add: (vec: Vector2) => Vector2 } const newVector2 = (x: number, y: number): Vector2 => { return { x, y, add: (vec: Vector2): Vector2 => { return Vector2.new(x + vec.x, y + vec.y) } } } // ... или в Stateless type Vector2 = { x: number y: number } const add = (firstVec: Vector2, secondVec: Vector2): Vector2 => { return Vector2.new(firstVec.x + secondVec.x, firstVec.y + secondVec.y) }
- Функции App Specific Данных НЕ должны находиться рядом с ними
А вот логика Поведения App Specific Данных ооочень сильно зависит от контекста конкретной функции, поэтому мы и не пытаемся ее «обобщать» и складывать ближе к этим данным, а наоборот пишем эту логику там, где нас это конкретно интересует (даже функции-конструкторы):
// Файл ./database-schema.ts type User = { id: string email: string password: string } // Где-то там ./register-user.ts const registerUser = (email: string, password: string) => { // ... const user: User = { id: uuid.v4(), email, password, } // ... } // Где-то там ./change-user-email.ts const changeUserEmail = (user: User, newEmail: string) => { // ... user.email = newEmail; // ... }
Именно благодаря этому подходу поддерживается «Контекстозависимость» и уменьшается связанность кода.
Следуя этим правилам вы получите все преимущества функционально ориентированного подхода.
6. Языки Программирования
Как использовать ФОП с примерами реального кода на:
6.1. Что требуется от языка для ФОП
Набор требований к языкам крайне небольшой и подходит под все популярные мультипарадигмальные языки программирования.
Обязательно
Для работы с ФОП в вашем языке обязательно должно быть всего 2 сущности:
Описание структуры данных – описание структур с которыми будут работать наши функции:
// Это может быть type / interface type User = { id: string email: string password: string } // И даже class, если ваш язык не поддерживает типизацию class User { constructor( public id: string public email: string public password: string ) {} }
Функция – старая добрая конструкция, умеющая (1) принимать на вход структуры данных, (2) возвращать результат, (3) передаваться в качестве аргумента.
// Функция / процедура / лямба, в зависимости от языка function changeUserEmail( user: User, newEmail: string, sendEmailNotification: (email: string, body: string) => void ) { if (user.email === newEmail) { throw new Error(`Email must be different`) } user.email = newEmail sendEmailNotification(user.email, `New email assigned`) }
Важно понимать, что ФОП как и ООП – это не синтаксис (подробнее эта мысль раскрывается в разделе «Истоки ФОП»).
Поэтому обе парадигмы можно писать, как с, так и без классового синтаксиса (
class
).Тут зависит от личных предпочтений и особенностей языка, но общая рекомендация – НЕ используйте классовый синтаксис в ФОП.
Будет большим плюсом
Необязательные, но крайне удобные
- Модули. Возможность группировать функции для удобного экспорта и использования в коде.
- Брендированные типы. Возможность валидировать специфичные типы данных еще во время компиляции.
- ADT – возможность описывать инварианты через типы.
Необязательно
Этот функционал поможет вам делать некоторые трюки, но можно программировать на ФОП и без него:
- Наследование типов. Возможность наследовать типы друг от друга.
- Иммутабельность и Чистые функции. Этот термин говорит сам за себя.
- Stateful поведение. Возможность замыкать состояние и методы работы с ним.
Все эти конструкции точно доступны в TypeScript, поэтому советую прочитать его, чтобы понимать как они устроены.
(coming soon) 6.2. TypeScript
(coming soon) 6.3. Rust
(coming soon) 6.4. Golang
(coming soon) 6.5. Python
(coming soon) 6.6. Kotlin
Ищу контрибьютов, которые готовы помочь перенести ФОП на данные языки, поскольку у меня просто не хватает времени этим заняться. Все заинтересованные, пишите в ТГ @davidshekunts
7. Полезные ссылки
Здесь вы найдете реализации, которые помогут вам применять практики из ФОП в ваших проектах:
- λ FapFop.ts – библиотека ФОП паттернов на TypeScript
- 🛌 Fatigue Driven Development – best-practise разработки backend приложений
8. Об авторе
Привет! Меня зовут Давид Шекунц и я Full-Stack Tech Lead на Go & TS (а еще обучаю разработке в 🦾 IT-Качалке 💪)
Всю свою карьеру я чувствовал, что ООП мне не заходит, он был по ощущениям “черезчурным”. ФП замечательный, но пока слишком маргинальный и требует заточенных под него языков, на которых очень сложно собрать команду.
И только когда я столкнулся с Go и его простейшим и очень выразительным процедурным подходом, я наконец-то нашел то, что было максимально близко к удобному и при этом эффективному вижу кодинга, который я искал.
НО уже имея опыт в ФП, я не хотел терять такие фишки как разделение данных и поведения, disjoint unions, pattern matching, каррирование, безопасность иммутабельности и так далее.
Поэтому на столкновении Процедурного и Функционального программирования и зародился ФОП, как методология, позволяющая мне и моим коллегам писать понятный и гибкий код, основанный на всего нескольких простых правилах и концепциях, описанных выше.
Я и все команды, с которыми я работал или обучал используют ФОП каждый день. Среди них:
(если ваша команда тоже использует ФОП, напишите мне, добавлю в список)
И пока ФОП показывает себя ультимативно хорошо: не было задачи, которую мы не могли решить по ФОП и сделать это быстро и просто (как для разработки новых фич, так и для рефакторинга существующих).
Причем переход от ООП, ПП и ФП к ФОП-у, очень простой, потому что в нем собраны самые простые и понятные механики, которые знакомы разработчику каждого из стилей.
Надеюсь, ваш путь будет таким же приятным.
Всем мощной прокачки 💪