Обновить

Комментарии 36

Вот сколько я уже пытался понять, что такое "функциональное программирование", в том числе путём чтения статей на Хабре - безуспешно... Я понимаю, конечно, что, возможно, мне не хватает образования и опыта, но вот какая штука - чтобы понять, что такое ООП, хотя бы на практическом уровне, мне было достаточно весьма незначительного опыта, буквально пары книг и некоторого практического опыта использования С++. Я не долбил паттерны, SOLID и прочее - поскольку это всё в ООП вторичные вещи, проистекающие из самой парадигмы... Когда же я сталкиваюсь с попытками объяснения, что такое ФП, и на кой оно, собственно, нужно при наличии ООП, - хотя бы на базовом уровне - начинаются какие-то огромные статьи, со схемами и стрелками, совершенно зубодробительными примерами кода, отсылками в весьма тяжёлые для несильно посвященного (ну, не всем же фанатеть от теории категорий или каких-нибудь канторовых пространств) области математики. Создаётся ощущение, что без этого понять что-либо в ФП совершенно невозможно. Тем не менее ФП весьма активно проталкивается во вполне себе ОО ЯП (типа того же C#), превращая их в какую-то смесь бульдога с носорогом, подобно похожей истории, когда во вполне себе языки со строгой типизацией начали впихивать скриптовые Var'ы.

Я далек от критики - чтобы критиковать, надо понимать предмет, а вот с пониманием, как раз, проблемы - и внятного ответа в довольно-таки большом количестве текстов пока нет, ИМХО. А статья, наверное, прекрасная...

Мне тоже поначалу казалось, что я понимаю ООП. Но знакомство с ФП и сравнение их друг с другом внезапно дало больше понимания парадигмы ООП. "Полиморфизм" там понимается крайне ущербно и вообще извращённо, инкапсуляция полезна лишь для весьма узкого класса задач (а вовсе не повсеместно!), параноидальное "сокрытие" призвано покрывать архитектурные косяки и привычные мутабельные переменные, а "наследование" - вообще мертворождённая идея, привносящая огромное количество проблем, но с которыми все уже так привыкли бороться... И поверх всего это понапридумывали костыльные "паттерны"... ООП оказалось реально переусложнённым по сравнению в ФП, как синтаксически, так и семантически. Со всеми вытекающими (привычными для всех!) проблемами разработки.

А вот ФП концептуально гораздо проще. Мы можем определять функции и вызывать их. И на базовом уровне - это всё! Пожалуй, самой характерной особенностью является то, что функции можно комбинировать друг с другом не усложняя код упоминанием промежуточных значений (`h = f andThen g`).

Для лучшего обеспечения качества программ ещё на этапе компиляции нам требуются статическая типизация, иммутабельность переменных (в ФП - по умолчанию) и "чистые" реализации функций.

Значительное усложнение происходит лишь когда дело доходит до обобщённых типов. А они реально полезны - позволяют значительно упросить код - не зря их добавляют во все популярные (оопшные!) языки. Но только делают это в крайне урезанном виде упуская большое количество возможностей, которые есть в том же Scala.

Именно обобщённым типам посвящен мой цикл обзоров. Их иногда обзывают и "функторами" и "монадами" - эти загадочные слова отпугивают многих программистов. В данном обзоре я хочу разобраться во всех этих деталях.

ООП и функциональное программирование это два ортогональных способа защитить состояние от несанкционированного изменения. В ООП состояние спрятали в приватных полях. А в функциональном программировании вообще от состояния отказались.

Защита состояния от несанкционированного изменения - это лишь одна небольшая задача, которую нужно решать программистам. ОПП и ФП гораздо больше, чем просто разные способы решения этой задачи.

В ФП не "отказывались" от состояний. Там их нет изначально. А задача "хранения состояния" - это лишь один из редковостребованных побочных эффектов, с каждым из которых приходится работать с особой осторожностью.

По сути, ООП это надстройка из граблей, появившаяся из-за необходимости, о которой уже мало кто помнит, и распространившаяся из-за колоссальных финансовых вливаний одной корпорации в её популяризацию. Просто у большинства программистов уже выработались крепкие мозоли от этих граблей - не так то просто от них отказаться))

Первая глава SICP, а лучше первые три главы очень сильно помогают понять что хорошего в функциональном программировании. Но очень рекомендую делать все упражнения.

Монады не композируются, но есть интересное направление с алгебраическими эффектами, для которых такого ограничения вроде бы нет: https://en.m.wikipedia.org/wiki/Effect_system

Причём если посмотреть на язык Koka, то там эффектами выражаются штуки типа чистоты функций, возможности расходимости (незавершения), бросание исключений и т.п.: https://koka-lang.github.io/koka/doc/book.html#sec-effect-types
И эффекты позволяют выразить то же самое, что делают монады типа Try или Option.

Так вот, можно ли смотреть на алгебраические эффекты как на альтернативу монадам в общем случае?

Альтернативы собирался рассмотреть в самой последней части обзора (ориентировочно в седьмой))). И конечно же, собираюсь рассказать там про алгебраические эффекты. Но маленький спойлер - радикального устранения всех ограничений нет нигде. Инструментарий монад достаточно фундаментален, и любые другие системы будут либо хуже, либо изоморфны монадам. Разве что возможно какими-то выкрутасами получится уменьшить боли. Посмотрим))

А про язык Koka не слышал. Навскидку выглядит так, что это какое-то усложнение - полиморфные типы отдельны, а контейнеры эффектов отдельно... Обязательно посмотрю получше. Спасибо!

Вроде бы всё понятно... и дальше понятно...

потом появилась строчка

val morphism3: Hom[Double, Int ] = morphism1 andThen identity[String] andThen morphism2

и стало непонятно. Ничего не понятно. Зачем тут identity?

Ну а дальше вообще. Более чем непонятно.

andThen (также как и compose) - это встроенный в Scala комбинатор функций. Он из двух функций делает одну, передающую выход первой функции на вход второй. Встроенную функцию identity я упомянул только для демонстрации того, как функция композиции (andThen) работает с тривиальным морфизмом. identity просто пробрасывает на выход то, что прилетело на вход. Композиция с тривиальным морфизмом просто сохраняет другой морфизм без изменений.

Про identity теперь понятно, она там не обязательно. Уже легче.

Все эти диаграммы типов и функций прекрасным образом укладываются в систему \lambda^\rightarrow — просто типизированного лямбда-исчисления. Эта система полная, она позволяет успешно решать все задачи программирования.

Ну ведь нет, в просто типизированном лямбда исчислении нельзя формализовать понятие бесконечного цикла т.к. просто нет типа соответствующего такого рода функции, что делает систему не полной по Тьюрингу(если это имелось ввиду).

Да, верно. Сам же писал об этом в обзоре про рекурсивные типы)) Спасибо, чуть позже исправлю этот фрагмент.

Спасибо за Ваши обзоры! Было бы здорово если бы Вы давали больше примеров. Например я бы обязательно пояснил зачем вообще нужен Identity элемент. Понятно что это значительно увеличит обьём. Но очень много людей буду Вам за это всю жизнь благодарны.

Спасибо за ваш отзыв. Подобные публикации - это всегда компромисс между полнотой и лаконичным выражением основных идей. Аудитории обычно лучше заходит второе. В своих работах стараюсь не повторять материал, который уже доступен в сети, но какие-то вводные всё равно приходится давать. Пытаюсь сместить акцент на неизбежность появления тех или иных понятий в процессе решения каких-то типовых задач. Хотя получается так себе...

Про "Аудиторию" есть хорошее детское стихотворение .... "Мельник на ослике ехал верхом.Мальчик за мельником плёлся пешком. ". Пишите так как считаете правильным. Спасибо.

А можно вот эту магию подробнее "разъяснить"? Спасибо.

type Hom = [A, B] =>> A match case B => Nothing => (Nothing & A) case _ => Nothingextension [A] (id: Hom[A, A]) infix def compose(sameId: Hom[A, A]): Hom[A, A] = iddef identity[A]: Hom[A, A] = scala.Predef.identity[Nothing]

Тут моя ошибка. Я посчитал, что тип Nothing => (Nothing & A) должен быть уникален для разных A, но не проверил это (оказалось что не так).
Сейчас исправлю статью. Спасибо!

Суть в том, что Hom-тип дискретной категории типов обязан быть необитаемым для разных A и B, но единичен для одинаковых.

Суть была с самого начала понятна. Но хочется ещё понять как код работает....Вы же дали пример, значит его тоже понять надо. Если Вы не возражаете я почитаю статью дальше и если будут вопросы, напишу комментарии. Еще раз Вам большое спасибо за статью.

summon[Hom[Int, String] =:= Nothing]

Я бы добавил сюда коментарий "compile-time проверка (доказательство) равенства типов." Большая часть читателей не знает хорошо синтаксис scala. (Я например). Такой бы коментарий возможно помог бы им.

Вот эта мысль мне совершенно непонятна. Если возможно дайте какой нибудь пример или пояснение.

"Одним из самых распространённых вариантов категорий являются дискретные категории, в которых определены лишь тождественные морфизмы. По сути такие морфизмы являются свидетельством рефлексивности отношения эквивалентности объектов — они просто идентифицируют объекты!"

К сожалению, у меня пока хватает косяков в изложении. Сложно доносить свои мысли...

В последнем процитированном предложении смело можно убрать слова "рефлексивности отношения". Эти слова скорее путают и ничего полезного не привносят.

А так идея простая - совокупность тождественных морфизмов дискретной категории изоморфна совокупности её объектов. Т.е. каждый морфизм дискретной категории, будучи тождественным, идентифицирует свой собственный объект в ней. В других категориях морфизмов больше, и о подобной "идентификации" уже не говорят.

Спасибо! Теперь понял!

Это не "косяки" это важная часть процесса создания статьи. Вы написали очень хорошо. Но всегда можно сделать лучше! Идеал не достижим. )

Я бы дал здесь ссылку на определение мономорфного типа. "Функция compose, работая с мономорфными типом Hom представляет собой обычную бинарную операцию (Hom, Hom) => Hom. "

Здесь сначала применяется G, затем F

type Id[A] = A

infix type ∘[F[_], G[_]] = [X] =>> F[G[X]]

// (IO ∘ Id ∘ Option)[A] = IO[Option[A]]

В начале статьи где был рисунок про комутацию морфизмов у было наоборот. Сначала F, потом G. Я бы везде так и оставил, что бы не путать читателя.

Я бы после этого кода

type Category[->[a, b]] = (

identity: [A] => () => A -> A,

compose : [A, B, C] => (B -> C, A -> B) => A -> C)

добавил бы примерно такой комментарий:

->[a, b] — абстрактный параметр для Category ("морфизм" (стрелка) между двумя объектами категории, которые здесь представлены типами Scala a и b (может быть функцией A => B, доказательством A <:< B и т.п.).

Здесь как то все очень запутано. Цель "запутанности" мне не понятна.

extension [A, B, ->[_, ]: Category as cat](morph: A -> B)

inline infix def ∘[C](prevMorph: C -> A): C -> B = cat.compose(morph, prevMorph)

inline def identity[->[, _]: Category as cat, A] = cat.identity[A]()

почему мы бы не переименвать morf в f:A-B, prevMorph в g:B->C,

а дальше

inline infix def ∘[C](g: B -> C): A -> C = cat.compose(g, f)

Не понимаю, что именно тут кажется запутанным. Идентификатор `morph` обозначает "морфизм", который далеко не всегда бывает "функцией с привычным обозначением". Именно это и хотелось подчеркнуть.

compose определена как выдающая: A->C

теперь у нас ∘[C] выдает вдруг: C->B.

Сразу возникает вопрос: всегда было A->C, а теперь вдруг C->B. Зачем. Начинаешь перепроверять. И в конце концов выясняешь что это одно и то же просто мы параметры по другому используем. Понятно что это параметры и работать будет. Но даже для параметров хотелось бы иметь согласованность.

Что касается prevMorph. Это возможно моё лично восприятие. Когда я вижу Prev, мой мозг считает что надо вернуться назад. И делать этого не хочет. Поэтому я бы обозвал morph1 и morph2, или morph_ab и morph_bc.

Здесь не понятно "кто на ком стоял" (морфизмы или функции)

F-подкатегории типов

В различных приложениях теории категорий (в том числе и в программировании) выясняется, что очень удобно работать с подкатегориями какой-то одной категории. В программировании нам особенно полезны подкатегории типов. Объекты таких подкатегорий — это все типы, а морфизмы образуют подмножества функций.

Возможно так было бы лучше:

Объектами таких подкатегория являются все возможные типы. Морфизмами являются функции для которых типы служат параметрами.

В привычных категориях типов морфизмами выступают функции. Работая с подкатегориями можно говорить о подмножествах функций. Не вижу причины что-то менять..

У этой фразы в русском языке может быть два значения "Объекты таких подкатегорий — это все типы, а морфизмы образуют подмножества функций. "

Мне бы читалось легче если бы было написано "Морфизмы состоят из подмножества функций"

"Композиция таких морфизмов позволяет писать программы с побочными эффектами (т.е. все))) более безопасно, чем без её использования."

Если возможно дайте пожалуйста какой либо пример "Безопасно". Особенно интересует A => IO[B]. Те примеры которые я нашел (retry) меня как то не убедили. Хотя кончено работать при таком подходе удобнее. Спасибо!

"Композициея таких морфизмов" работает через flatMap, позволяя комбинировать вычисления в контексте эффекта до финальной распаковки. В результате мы получаем чистые функции с предсказуемым поведением, в отличие от техники, подразумевающей "грязную" и зачастую небезопасную распаковку контейнеров после каждого шага. Такое бывает если выбрасывается исключение или блокируется поток вычислений (меняется глобальное состояние исполнителя).

Спасибо! Я это примерно так и понимал. Теперь я уверен что понимаю правильно. Если возможно добавьте уточнения в статью. Возможно это поможет другим читателям.

Раз это кому-то нужно, внёс некоторые правки)
Спасибо!

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации