Как стать автором
Обновить

Легкая прогулка от функтора через монаду к стрелке

Время на прочтение8 мин
Количество просмотров28K

Давайте совершим прогулку по цепочке Pointed, Functor, Applicative Functor, Monad, Category, Arrow, в процессе которой я попытаюсь показать что все это придумано не для того что бы взорвать мозг, а для решения вполне реальных проблем, притом существующих не только в haskell. Большая часть кода написана на C#, но думаю и без его знания можно будет понять что к чему.

Примеры


Хотя в примерах используются примитивные операции над int, при желании, в место них можно придумать что нибудь более жизненное, например в аппликативном функторе, можно рассмотреть такой вариант
Maybe<int> userId = UserService.GetUserId(email)
Maybe<int> productId = ProductService.GetProductId(productCode)
Maybe<int> discount = Maybe<int>.Nothing;
if(userId.HasValue && porductId.HasValue)
    discount = new Maybe(DiscountService.GetDiscount(userId.Value, productId.Value));


Введение


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

	public class Maybe<T> {
		public static readonly Maybe<T> Nothing = new Maybe<T>();
		public T Value { get; private set; }
		public bool HasValue { get; private set; }

		private Maybe() { }

		public Maybe(T value) {
			Value = value;
			HasValue = true;
		}

		public override string ToString() {
			if (HasValue)
				return Value.ToString();
			return "Nothing";
		}
	}


Пример

Maybe<User> GetUserById(int id){...}

Что есть класс Maybe<A>? Можно считать его функцией над типом, которая все обычные типы A переводит в специализированные типы Maybe<A>, int -> Maybe<int>, будем называть их уровнями, а преобразование — переходом между ними.

Pointed


Проблема номер один: нам необходима функция которая позволит сделать переход A -> Maybe<A>, напишем ее.

	static class Pointed {
		public static Maybe<A> Pure<A>(this A value) {
			return new Maybe<A>(value);
		}
	}


Для тех кто не знаком с C#, ключевое слово this позволяет использовать функцию через точку после объекта

Пример

	void Pointed() {
		int x = 1;
		Maybe<int> y = x.Pure();
	}


Теперь мы можем все что угодно поднять на уровень Maybe.

Функтор


Хорошо, поднять мы подняли, но у нас осталось куча функций которая работает для предыдущего уровня, то есть все функции вида A → B, и просто так мы их уже использовать не можем. Что же делать? Очевидно, можно каждый раз проверять есть ли в Maybe что либо, и если есть, применять функцию к тому что в нем содержится, если нет, возвращать Nothing, собственно тот сценарий который используется в случае проверки на null. Что бы придерживаться DRYйя поместим эту логику в отдельную функцию.

	static class Functor {
		public static Maybe<B> Map<A,B>(this Maybe<A> maybe, Func<A,B> f) {
			if(maybe.HasValue) return f(maybe.Value).Pure();
			return Maybe<B>.Nothing;
		}
	}


Пример

	void Functor() {
		Func<int, int> inc = y => y + 1;//обычная функция int -> int
		var x = 1.Pure();//получаем переменную на уровне Maybe
		var r = x.Map(inc); //применяем обычную функцию к переменной на новом уровне, r == Some(2)
	}



Таким образом мы подружили старые функции с новым уровнем.

Аппликативный функтор


Функции одной переменной нам покорилась, что делать с функциями принимающих несколько параметров (A,B) → C? Очевидно map нам тут особо не поможет. Решение есть, но для начало вспомним что такое каррирование — это преобразование функции таким образом что бы ее возможно было частично применить и получить на выходе новую функцию. add(x,y) = x + y; inc = add(1); inc(10) == 11;
Напишем вспомогательную функцию для перевода Func к такому виду.

	static class CurryFunc {
		public static Func<T1,Func<T2,R>> Curry<T1,T2,R>(this Func<T1,T2,R> f) {
			return x => y => f(x, y);
		}
	}

Пример

	void Curry() {
		Func<int,int, int> add = (y,z) => y + z;
		var inc = add.Curry()(1);
		var r = inc(1); // r == 2
	}


И напишем метод apply который поможет решить нашу первоначальную проблему.

	static class Applicative {
		public static Maybe<B> Apply<A,B>(this Maybe<Func<A,B>> f, Maybe<A> maybe) {
			if(f.HasValue) return maybe.Map(f.Value); // если функция есть, значит применяем ее с помощью Map
			return Maybe<B>.Nothing;
		}
	}


Пример

	void Applicative() {
		Func<int, int, int> addF = (y,z) => y + z;
		var add = addF.Curry();
		var x1 = 1.Pure();
		var x2 = 2.Pure();
		var r = add.Pure().Apply(x1).Apply(x2); // Скаррировав функцию и подняв до уровня Maybe мы можем ее применить,  r == Some(3)
		Func<int,int,int> f = DiscountService.GetDiscount;
		var discount = f.Curry().Pure().Apply(userId).Apply(productId); //Пример из самого начала
	}


Подход работает для любого количества переменный, достаточно иметь соответствующую функцию curry.
Func<int, int, int, int, int> addF = (a,b,c,d) => a + b + c + d;
addF.Curry().Apply(x1).Apply(x2).Apply(x3).Apply(x4);


Монада


С обычными функциями окончательно разобрались, но раз у нас есть два уровня, то значит возможны и функции вида A → Maybeнапример тот самый GetUserById :: int -> Maybe<User>
Решить с помощью map не выйдет. так как в результате получим Maybe<Maybe<B>>, поэтому нам нужна еще одна функция

	static class Monad {
		public static Maybe<B> Bind<A,B>(this Maybe<A> maybe, Func<A,Maybe<B>> f) {
			if(maybe.HasValue) return f(maybe.Value);
			return Maybe<B>.Nothing;
		}
	}

Пример

	void Monad() {
		Func<int, Maybe<int>> solve = y => y == 0 ? Maybe<int>.Nothing : (y + 1).Pure();
		var x = 1.Pure();
		var y = 0.Pure();
		var r1 = x.Bind(solve); // r1 == Some(2)
		var r2 = y.Bind(solve); // r2 == Nothing
	}



Итого, мы решили практически все проблемы которые касаются применения функций, возникшие при создании Maybe.

Категория


Давайте перейдем к вопросу композиционного стиля программирования. Посмотрим какие здесь нам ставить проблемы Maybe и как их можно решить.
Что такое композиционный стиль? Подход когда мы вместо того что бы применять функции одна за другой к результату вычисления, объединяем их в некое подобие конвейера который образует в свою очередь новую функцию, например
f = length . filter (>3) . map (+1) . skip 3  

(.) оператор композиции в хаскеле.
f - функцию пропускает первые три элемента, прибавляет 1 ко всем остальным, фильтрует все что больше трех и возвращает длину полученного списка.
Стиль отличается удобством и наглядность, поэтому достаточно популярен в хаскеле.

Полагаю у вас в подсознии уже начал мелькать образ теории категорий, и это не с проста. Давайте вспомним о чем там идет речь. У нас есть некий класс объектов, для каждой пары из которого задано множество морфизмов. Эти объекты с ихними морфизмами и являются категорией. Для простоты можно представить как направленный граф, где объекты это узлы, а морфизмы это ребра. Так вот для морфизмов как раз и определено понятие композиции, f: A → B, g:B → C получаем f compose g: A → C. Можно определить категорию для языка программирования, где объекты это простые типы, а морфизмы это функции.

Вернемся к композиционному стилю. У обычных функций с ним нет никаких проблем.

	static class Category {
		public static Func<A,C> Compose<A,B,C>(this Func<B,C>f1, Func<A,B> f2) {
			return x => f1(f2(x));
		}
	}

Пример

	void Category() {
		Func<int, int> f1 = y => y + 1;
		Func<int, int> f2 = y => y * 2;
		var fr = f2.Compose(f1);
		var r = fr(1);// r == 4
	}


Однако у функций вида A → Maybe<B>, B → Maybe<C> проблема есть и заключается она в том что выход одной не состыкуется с входом другой.

Так как у нас сейчас пойдет много сигнатур вида A → Maybe<B> давайте напишем для них обертку. Можно и без нее, но с ней как то по нагляднее. Категория Клейсли - категория где морфизмы имеют вид A → M<B>

	class Kleisli<A,B> {
		public Func<A,Maybe<B>> Func { get; set; }
		public Kleisli(Func<A,Maybe<B>> f) {
			Func = f;
		}
	}



Напишем функцию для их композиции.

static class Category {
		public static Kleisli<A,B> ToKleisli<A,B>(this Func<A,Maybe<B>> f) {
			return new Kleisli<A, B>(f);
		}

		public static Kleisli<A,C> Compose<A,B,C>(this Kleisli<B,C> f1, 
			Kleisli<A,B> f2) {
			return ToKleisli<A,C>(x => f2.Func(x).Bind(f1.Func));
		}
	}


Пример


	void Category2() {
		Func<int, Maybe<int>> f1 = y => (y + 1).Pure();
		Func<int, Maybe<int>> f2 = y => (y * 2).Pure();
		var fr = f2.ToKleisli().Compose(f1.ToKleisli());
		var r = fr.Func(1); //r == Some(4)
	}

Если не использовать оберток, запись была бы точно такое же как и у обычных функций f2.Compose(f1)
Теперь мы можем создавать композиции из Клейсли морфизмов.

Стрелка


Вот мы и подошли к последней проблеме. У нас есть функции двух видом А -> B, B -> Maybe<C> и законное желание соединять их между собой. При текущем раскладе это у нас не получится, но как всегда можно написать функцию которая решит все наши проблемы

	static class Arrow {
		public static Kleisli<A,B> Arr<A,B>(this Func<A,B> f) {
			return Category.ToKleisli<A,B>(x => f(x).Pure());// Преобразуем обычную функцию в Клейсли
		}
	}


Пример

	void Arrow() {
		Func<int, int> f1 = y => y + 1;
		Func<int, int> f2 = y => y * 10;

		Func<int, Maybe<int>> fm1 = y => (y * 2).Pure();
		Func<int, Maybe<int>> fm2 = y => (y - 5).Pure();
		var fa1 = f1.Arr();
		var fa2 = f2.Arr();
		var fk1 = fm1.ToKleisli();
		var fk2 = fm2.ToKleisli();
		var fr = fk2.Compose(fa1).Compose(fk1).Compose(fa2);
		var r1 = fr.Func(2); // r1 == Some(36)
		// опять же без обертки можно было бы написать var r1 = f2.Compose(f1.Arr()).Compose(f1).Compose(f2.Arr())(2)
	}

Итого: мы можем комбинировать функции обоих видом, как нам заблагорассудится.

У стрелок еще есть много интересных функций, с помощью которых можно виртуозно жонглировать конвейерами практически любой сложности. Например, как так compose по сути последовательное вычисление, то можно предположить что есть и параллельное: (&&&) - распаралеливает конвейер, (***) - производить параллельные вычисления и пр. Но пожалуй на этом мы остановимся.

Заключение


Надеюсь я смог передать некую красоту и логичность всех вышеописанных конструкций, и возможно у вас возникнет вопрос, почему же такая иерархия практически ни в каких языках не встречается. Ответ скорее всего в том что, для нормальной реализовать нужна система типов сложнее чем в том же C#. Например если мы заходим написать все тоже для списков, которые являются точно таким же переходом между уровнями A -> List<A>, то нам придется продублировать ~90% кода, так как возможности для обобщения практически никакой нет. Для примера можно посмотреть как это все может выглядеть на хаскеле, где за счет типов классов и полиморфизма высшего порядка существенная часть абстракций выделена, и вместо того что бы работать с Maybe все манипуляции происходят над обобщенным типом M<A>где M может быть любым типом с одним параметром Maybe<A> List<A> и пр.

Для желающих углубиться в затронутую тему, рекомендую учебник http://anton-k.github.com/ru-haskell-book/book/toc.html, в нем уделено внимание как практической составляющей хаскеля, так и теоретическим основаниям, плюс это возможно единственных учебник(или по крайней мере один из не многих) на русском где описывается теория категорий в контексте программирования.

Код на haskell



import Prelude hiding (Functor,map,Monad)

class Pointed f where
	pure :: a -> f a

instance Pointed Maybe where
	pure = Just

class Functor f where
	map :: (a -> b) -> f a -> f b

instance Functor Maybe where  
	map f (Just x) = Just (f x)  
	map f Nothing = Nothing  

class (Functor f, Pointed f) => Applicative f where
	apply :: f (a -> b) -> f a -> f b

instance Applicative Maybe where  
	apply Nothing  _ = Nothing  
	apply (Just f) something = map f something  

class Applicative f => Monad f where
	bind :: f a -> (a -> f b) -> f b

instance Monad Maybe  where
	bind (Just x)  k      = k x
	bind Nothing   _      = Nothing

newtype Kleisli m a b = Kleisli { runKleisli :: a -> m b }

class Category cat where
	compose :: cat b c -> cat a b -> cat a c

instance Category (->) where
	compose f g = \x -> f (g x) 

instance Monad m => Category (Kleisli m) where
	compose (Kleisli f) (Kleisli g) = Kleisli (\b -> bind (g b) f)

class Category a => Arrow a where
	arr :: (b -> c) -> a b c

instance Arrow (->) where
	arr f = f

instance Monad m => Arrow (Kleisli m) where
	arr f = Kleisli (compose pure f)



Upd: Давайте еще раз выпишем наш как бы реальный пример из жизни и рассмотрим два способа решения.
Классическое решение данной проблемы:
Maybe<int> userId = UserService.GetUserId(email);
Maybe<int> productId = ProductService.GetProductId(productCode);
Maybe<int> discount = Maybe<int>.Nothing;
if(userId.HasValue && porductId.HasValue)
    discount = new Maybe(DiscountService.GetDiscount(userId.Value, productId.Value));

Что в нем плохого? Мне кажется явное выписывание if, которое усложняет восприятие кода.
Давайте посмотрим на второе решение:
Func<int,int,int> f = DiscountService.GetDiscount;
Maybe<int> discount = f.Curry().Pure().Apply(userId).Apply(productId);

В чем его плюс? Думаю в том что нам совершенно не нужно задумываться, а что там у нас в userId и productId, код получается более декларативным.
Теги:
Хабы:
Всего голосов 55: ↑43 и ↓12+31
Комментарии152

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань