Что общего у нормального распределения, конечных автоматов, хеш-таблиц, произвольных предикатов, строк, выпуклых оболочек, афинных преобразований, файлов конфигураций и стилей CSS? А что объединяет целые числа, типы в Haskell, произвольные графы, альтернативные функторы, матрицы, регулярные выражения и статистические выборки? Наконец, можно ли как-то связать между собой булеву алгебру, электрические цепи, прямоугольные таблицы, теплоизоляцию труб или зданий и изображения на плоскости? На эти вопросы есть два важных ответа: 1) со всеми этими объектами работают программисты, 2) эти объекты имеют сходную алгебраическую структуру: первые являются моноидами, вторые — полукольцами, третьи — алгебрами де Моргана.
Если мы, программисты, научимся сами и научим компьютер работать с моноидами, полукольцами и прочими структурами, то всем нам работать станет легче. Потому что на алгебраические структуры накладываются определённые ограничения, а из ограничений следуют и определённые гарантии. Все эти ограничения и гарантии справедливы для всех представителей структуры, а это значит, что можно строить универсальный инструментарий для работы с моноидами, полукольцами, алгебрами и, следовательно, со всеми перечисленными выше и многими другими объектами. Посмотрите, сколько математиками определено алгебраических структур! И ведь для них есть теоремы, какие-то для программистов полезные, какие-то пока не очень, но все они надёжны, как самые лучшие библиотеки программ!
Всё становится ещё вкуснее, когда внутри структуры строятся гомоморфизмы — преобразования, сохраняющие структуру. Гомоморфизмы дают возможность "переключаться" с одного объекта структуры на другой — со списков на числа, с графов на матрицы, с регулярных выражений на графы, с электрических цепей на булевы выражения или на графическое изображения этих цепей! Ну а уж изоморфизмы — это вообще песня! Если программист считает что математика ему не нужна и что все эти морфизмы-шморфизмы это просто абстрактная чушь, то он лишает себя не только прекрасного, надёжного инструмента, но, самое главное, существенной части удовольствия от своей работы.
В этой статье мы покажем на примере алгебр де Моргана, как строить алгебраические абстракции, как определять для них гомоморфизмы и свободные алгебры, и как это можно использовать, конечно.
Статья рассчитана на тех, кто уже знает что такое классы типов, функторы, моноиды и, в целом, хорошо представляет себе что такое программирование на Haskell. С другой стороны, она может быть интересна всем, кто интересуется как в программировании строятся абстракции, и как их можно применить к реальным задачам.
Немного про моноиды
Моноиды — это абстракция композиции. В свою очередь, композиция — ключевое понятие в функциональном программировании. Именно поэтому моноиды встречаются здесь так часто и играют настолько важные роли. Про ценность и богатство применения моноидов говорилось и писалось много. Здесь мы перечислим основные их свойства, которые нам пригодятся в дальнейшем изложении.
Определение
Моноидом называется очень простая структура: это множество, на котором определена ассоциативная бинарная операция, имеющая единственный нейтральный элемент. Коммутативность операции не требуется, однако нейтральный элемент должен быть нейтральным, будучи как первым, так и вторым операндом.
В языке Haskell для моноидов определён класс Monoid
:
class Monoid a where
mempty :: a
mappend :: a -> a -> a
а библиотека Data.Monoid
предоставляет ряд полезных экземпляров этого класса, таких как Sum
, Product
, First
, Last
, Endo
и т.п.
Ключевыми свойствами моноида являются ассоциативность и единственность нейтрального элемента. Эти ограничения позволяют обобщать моноидальные операции для произвольного порядка выполнения (в том числе, и для параллельного).
Дуальный моноид
Для любого моноида можно определить дуальный ему моноид, в котором бинарная операция принимает аргументы в обратном порядке:
> Dual [1,2,3] <> Dual [5,6]
Dual { getDual = [5,6,1,2,3] }
Связь со свёрткой
Хорошо известно, насколько полезным и универсальным инструментом является свёртка: с одной стороны, через свёртку выражается дюжина полезных универсальных функций, обрабатывающих коллекции, а с другой, сворачивать можно списки, деревья и прочие индуктивные коллекции. Свёрток определено достаточно много. Сворачивать коллекцию можно как справа, так и слева, это может повлиять и на результат (в зависимости от ассоциативности сворачивающей функции), и на эффективность вычислений. Сворачивать можно вводя начальный результат свёртки, на случай пустой коллекции, или обходясь без него, если есть гарантии, что коллекция не пуста. Поэтому в стандартной библиотеке Haskell и библиотеке Data.Foldable
так много различных свёрток:
foldr, foldl, foldr1, foldl1, foldl', foldl1'
Если же речь идёт о свёртке в моноид, достаточно одной функции, в силу ассоциативности моноидальной операции и гарантии наличия нейтрального элемента.
fold :: (Monoid m, Foldable t) => t m -> m
Чаще используется вариант, позволяющий явно указывать какой именно моноид следует использовать для свёртки:
foldMap :: (Monoid m, Foldable t) => (a -> m) -> t a -> m
Если коллекция является функтором, то определение функции foldMap
можно дать такое:
foldMap f = fold . fmap f
Свободный моноид
Иногда существует возможность "переходить" между моноидами, напомним, такие преобразования называются гомоморфизмами. При этом существует такой моноид, из которого можно построить гомоморфизм в любой другой. Такой моноид называется свободным, его роль в Haskell играют списки.
Свободные структуры отражают свойства алгебраической системы, но ничего "не вычисляют", не изменяют данные и не теряют информации. В известном смысле, они представляют собой формальный язык для описания элемента алгебраической системы или категории, при этом гомоморфизмы можно рассматривать в качестве интерпретаторов этого языка. Такая аналогия нам пригодится в дальнейшем.
Подробнее о моноидах и их применении в Haskell можно почитать в статьях:
- Monoids Tour
- Monoids: Theme and Variations (Functional Pearl)
- Monoids and Finger Trees
- Finger Trees: A Simple General-purpose Data Structure
Алгебры де Моргана
Для многих объектов существует несколько способов композиции и одним моноидом тут не обойтись. Если таких способов два, то говорят о кольцах, полукольцах, решётках и различных алгебрах. Рассмотрим несколько примеров, наводящих на мысль об общности алгебраической структуры, лежащей в их основе.
Булева алгебра
Что мы знаем о булевой алгебре:
- Определена на множестве значений
{True, False}
и описывается с помощью трёх операций: конъюнкции, дизъюнкции и отрицания. - Конъюнкция и дизъюнкция формируют моноиды.
- Элемент
False
является нейтральным для дизъюнкции и поглощающим для конъюнкции. - Элемент
True
, в свою очередь, нейтральный элемент для конъюнкции и поглощающий для дизъюнкции. - Отрицание является инволюцией, то есть, эта операция обратна самой себе.
- Выполняется закон де Моргана.
Алгебра электрических сопротивлений
Давайте рассмотрим задачу расчёта произвольной двухполюсной электрической цепи, которая может состоять из сопротивлений и допускает последовательное или параллельное соединение элементов цепи.
- Для них определены две операции последовательного () и параллельного () соединения.
- Соединения формируют моноиды (они ассоциативны и имеют нейтральные элементы).
- Разрыв цепи является нейтральным для параллельного и поглощающим для последовательного соединений.
- Короткое замыкание является нейтральным для последовательного и поглощающим для параллельного соединений.
- Сопротивлениям соответствует обратная величина — проводимость и переход от сопротивлений к проводимости является инволюцией.
- Эффективное сопротивление для последовательного и параллельного соединений вычисляется по формулам:
Эти выражения можно переписать симметричным способом:
в котором узнаются законы де Моргана!
Алгебра таблиц
Прямоугольные таблицы также можно комбинировать двумя основными способами: объединяя строки () или столбцы ():
1 2 3 a b 1 2 3 a b 4 5 6 <-> c d = 4 5 6 c d 7 8 9 e f 7 8 9 e f 1 2 3 a b c 1 2 3 4 5 6 <|> d e f = 4 5 6 7 8 9 7 8 9 a b c d e f
Кроме того, для таблиц можно определить инволюцию — транспонирование, и нетрудно проверить, что оба способа комбинирования таблиц образуют моноиды с одинаковым нейтральным элементом — пустой таблицей. И, наконец, один способ комбинирования можно получить из другого: например, объединить две таблицы по вертикали можно сначала транспонировав обе таблицы, затем объединив их по горизонтали и транспонировав результат.
Используя свойства инволюции, опять получаем законы де Моргана:
Определение алгебры де Моргана
Пора дать формальное определение для структуры, с которой мы работаем:
Алгеброй де Моргана называется структура, состоящая из множества, на котором определены две бинарные операции и , условно, называющиеся сложением и умножением, а также операция инволюции , для которых выполняются следующие условия:
- операция образует моноид с нейтральным элементом :
операция образует моноид с нейтральным элементом :
выполняется мультипликативное свойство нуля:
свойство инволюции:
дуальность нуля и единицы относительно инволюции:
законы де Моргана:
Строго говоря, умножение ещё должно быть дистрибутивно по отношению к сложению, но в некоторых случаях (например для электрических цепей) это может не выполняться. Зато если выполняется, то можно сложное выражение, состоящее из комбинации сложений и умножений привести к форме суммы одночленов, раскрыв все скобки, или, наоборот, упростить выражение, вынося за скобки общие множители.
Иногда инволюция выполняет роль инверсии, то есть, порождает обратный элемент для операции умножения. Тогда выполняется следующее тождество:
Это выполняется для булевой алгебры и алгебры сопротивлений, но неверно для таблиц.
Абстракция алгебры де Моргана на Haskell
Перед началом работы подключим несколько расширений:
{-# LANGUAGE DeriveFunctor, FlexibleInstances, GeneralizedNewtypeDeriving #-}
Определим для алгебр де Моргана класс типов:
class DeMorgan a where {-# MINIMAL inv,((<->)|(<|>)),(zero|one) #-} inv :: a -> a zero :: a zero = inv one one :: a one = inv zero (<->) :: a -> a -> a a <-> b = inv (inv a <|> inv b) (<|>) :: a -> a -> a a <|> b = inv (inv a <-> inv b)
В этом определении мы уже используем законы де Моргана для того, чтобы упростить работу с классом. В описании экземпляра достаточно определить только одну из двух моноидальных операций, её нейтральный элемент и инволюцию.
Создадим первый экземпляр класса
DeMorgan
для логических данных:
instance DeMorgan Bool where zero = False inv = not (<->) = (&&)
Из соображений эффективности, конечно, стоит явно определить и оператор
<|>
, но мы оставим это лаконичное определение, чтобы убедиться в том, что оно в полной мере задаёт алгебру для типаBool
:
> True <|> False True > one :: Bool True
Булева алгебра, это хорошо, но уж больно просто. Давайте её обобщим, построив алгебру для нечёткой логики в духе Лотфи Заде. Для этого определим тип-обёртку
Fuzzy
и потребуем у компилятора вывести для него числовые свойства:
newtype Fuzzy = Fuzzy { deFuzzify :: Double } deriving (Show, Num, Fractional, Ord, Eq) instance DeMorgan Fuzzy where zero = 0 inv x = fuzzify $ 1 - x a <|> b = fuzzify $ max a b fuzzify x = 0 `max` x `min` 1
> deFuzzify $ one 1 > deFuzzify $ 0.2 <-> 0.4 0.2 > deFuzzify $ 0.8 <|> 0.4 <-> 0.5 0.5 > deFuzzify $ 0.3 <|> 0.4 <-> 0.5 0.4
Определим простейшую алгебру де Моргана для таблиц, представленных вложенными списками:
instance DeMorgan [[a]] where zero = [] (<|>) = (++) inv = transpose
здесь мы использовали функцию
transpose
из библиотекиData.List
. Вот как работает эта алгебра:
> inv [[1,2],[3,4]] [[1,3],[2,4]] > [[1,2],[3,4]] <-> [[5],[6]] [[1,2,5],[3,4,6]] > [[1,2],[3,4]] <|> [[5,6]] [[1,2],[3,4],[5,6]]
Давайте применим её для написания простенькой табулирующей функции
table
table :: (Show a, Show b) => (a -> a -> b) -> [a] -> [[String]] table f vals = ([[" "]] <-> h) <|> (inv h <-> c) where h = [show <$> vals] c = [show <$> [ f x y | x <- vals] | y <- vals] showTable tbl = putStrLn $ unlines $ unwords <$> tbl
> showTable $ table mod [1..6] 1 2 3 4 5 6 1 0 0 0 0 0 0 2 1 0 1 0 1 0 3 1 2 0 1 2 0 4 1 2 3 0 1 2 5 1 2 3 4 0 1 6 1 2 3 4 5 0
Наконец, вспомним о сопротивлениях и построим алгебру де Моргана для чисел, точнее, для числовых типов, для которых определена операция деления. Для этого определим тип-обёртку
Frac
:
newtype Frac a = Frac {getFrac :: a} deriving (Show, Num, Fractional, Functor) instance Fractional a => DeMorgan (Frac a) where zero = 0 inv = (1/) (<->) = (+)
> getFrac $ 2 <-> 6 8.0 > getFrac $ 2 <|> 6 1.5 > getFrac $ 1 <-> (2 <|> (1 <-> 1)) 2.0
В качестве примера, выразим эффективное сопротивление цепи, показанной на рисунке и вычислим силу тока при напряжении 12 V:
> getFrac $ ((6 <|> 12) <-> 4) <|> (3 <-> 5) 4.0 > getFrac $ 12 / ((6 <|> 12) <-> 4) <|> (3 <-> 5) 3.0
Дуальная алгебра
Для расчёта эффективной жёсткости сложной системы пружин, или ёмкости батареи конденсаторов, тоже годится тип
Frac
, но для этих расчётов нужно поменять местами операторы<|>
и<->
, а также элементыzero
иone
. Математики с радостью узнают в этом преобразовании переход к дуальной алгебре. Звучит красиво, но не хотелось бы писать2 <-> 3
имея в виду параллельное соединение пружин. Было бы хорошо сохранить семантику операторов, но изменить способ вычислений.
Тут математика снова даёт нам возможность воспользоваться её плодами. Как и для любого моноида, для любой алгебры де Моргана определена дуальная ей алгебра, в которой "всё наоборот". Опишем это обстоятельство на языке Haskell. Создадим новый тип-обёртку
Dual
для дуальных алгебр и сообщим, что если алгебраa
является алгеброй де Моргана, то и дуальная ей тоже будет алгеброй де Моргана.
newtype Dual a = Dual {getDual :: a} deriving (Show, Num, Fractional, Eq, Ord, Functor) instance DeMorgan a => DeMorgan (Dual a) where zero = Dual one one = Dual zero inv x = inv <$> x Dual a <|> Dual b = Dual $ a <-> b Dual a <-> Dual b = Dual $ a <|> b
Теперь можно рассчитать эффективную жёсткость системы из трёх пружин, одна из которых последовательно соединяется с парой других, соединёных параллельно:
> getFrac . getDual $ 600 <-> (200 <|> 300) 450.0
Вывод типов позволяет указывать какую именно алгебру мы используем только "на выходе" из вычислений. Так мы можем явно и, в то же время, просто, изменять способ вычисления выражения.
Свободная алгебра
А что если мы захотим представлять и рассчитывать цепи, содержащие не только сопротивления, но и ключи, конденсаторы, катушки и т.д.? И не только рассчитывать, но и изображать их или сохранять в файл? Для этого нам понадобится свободная алгебра.
Цепь будем представлять некоторой структурой данных, ничего не вычисляющей, но отражающей алгебраическую структуру цепи и образующей алгебру де Моргана:
data Circuit a = Zero | One | Elem a | Inv (Circuit a) | Par (Circuit a) (Circuit a) | Seq (Circuit a) (Circuit a) deriving (Show, Functor)</hs></pre> instance DeMorgan (Circuit a) where zero = Zero one = One (<->) = Seq (<|>) = Par inv = Inv
Инволюция или инверсия для элемента цепи не имеет определённого смысла, но для корректности и общности мы включили её в описание типа.
Самый первый и естественный гомоморфизм — это преобразование типа
Circuit
в произвольный тип, для которого определена алгебра де Моргана:
reduce :: DeMorgan a => Circuit a -> a reduce circ = case circ of Zero -> zero One -> one Elem b -> b Inv a -> inv (reduce a) Par a b -> reduce a <|> reduce b Seq a b -> reduce a <-> reduce b
Функция
reduce
подобна функцииfold
, которая сворачивает список моноидов в произвольный моноид. Таким же образом, как от функцииfold
для функторов можно произвести функциюfoldMap
, можно образовать функциюreduceMap
:
reduceMap :: DeMorgan b => (a -> b) -> Circuit a -> b reduceMap = reduce . fmap f
Эта функция позволяет явно указать какую именно алгебру де Моргана следует использовать при интерпретации цепи.
Согласитесь, обнаружение такой красивой симметрии в определениях функций свёртки для моноидов и нашей алгебры доставляет эстетическое удовольствие!
Различные задачи расчёта цепей
Для построения цепей организуем предметно-ориентированный тип
Lumped
, который позволит нам кроме значений параметров элементов цепиValue a
, явно представить короткое замыканиеShort
и разрыв цепиBreak
:
data Lumped a = Short | Value a | Break deriving (Show, Functor) instance DeMorgan s => DeMorgan (Lumped s) where zero = Break Value r1 <-> Value r2 = Value $ r1 <-> r2 Short <-> r = r r <-> Short = r _ <-> Break = Break Break <-> _ = Break inv Short = Break inv Break = Short inv (Value r) = Value (inv r)
Введём тип для элементов: сопротивлений, ёмкостей и индуктивностей, а также конструкторы элементов цепи:
data Element = R Double | C Double | L Double deriving Show res = Elem . R cap = Elem . C coil = Elem . L key True = Zero key False = One
Наконец, напишем простенькую цепь для опытов.
s :: Bool -> Circuit Element s k = res 10 <-> ((res 2 <-> coil 5e-3 <-> key k) <|> cap 10e-9)
Теперь мы готовы строить различные интерпретации нашей свободной алгебры.
Функция
connect
определяет является ли цепь замкнутой, используя булеву алгебру:
connected :: Circuit Element -> Bool connected = reduceMap f where f el = case el of R _ -> True C _ -> False L _ -> True
> connected (s True) True > connected (s False) False
Функция
resistance
определяет эффективное сопротивление цепи для постоянного тока, используя алгебру для дробных чисел:
resistance :: Circuit Element -> Lumped Double resistance = fmap getFrac . reduceMap (fmap Frac . f) where f el = case el of R r -> Value r C _ -> Break L _ -> Short
Здесь вычислительная работа производится внутри типа
Lumped
, Который мы объявили функтором.
> resistance (s True) Value 12 > resistance (s False) Break
Для расчёта импеданса цепи потребуетcя подключить модуль
Data.Complex
:
impedance :: Double -> Circuit Element -> Lumped (Complex Double) impedance w = fmap getFrac . reduceMap (fmap Frac . f) where f el = Value $ case el of R r -> r :+ 0 C c -> 1 / (0 :+ w*c) L l -> 0 :+ w*l
> impedance 1e3 (s False) Value (10.0 :+ (-99999.99999999997)) > impedance 1e3 (s True) Value (12.00020001420084 :+ 5.0002100065000405) > impedance 1e6 (s False) Value (10.0 :+ (-100.0)) > impedance 1e6 (s True) Value (10.000832986116954 :+ (-102.04081598653629))
Если речь идёт о расчёте батареи конденсаторов, то нужно поменять алгебру на дуальную:
capacity :: Circuit Element -> Lumped Double capacity = fmap (getFrac . getDual) . reduceMap (fmap (Dual . Frac) . f) where f el = case el of R _ -> Short C c -> Value c L _ -> Short
Обратите внимание на то, как во всех этих интерпретациях мы указывали способ расчёта характеристик цепи, определяя лишь способ расчёта для отдельных элементов и выбирая подходящую алгебру для свёртки.
А если нам нужно будет изобразить цепь, то можно воспользоваться великолепной библиотекой Diagrams для построения векторной графики. Она предоставляет два комбинатора для относительного расположения изображений:
(|||)
— рядом по горизонтали и(===)
— рядом по вертикали. Это значит, что для изображений можно построить алгебру, подобную алгебре таблиц, а потом, описав как изображаются отдельные элементы цепи, построить гомоморфизм из свободной алгебры в алгебру изображений и нарисовать произвольную схему. Кстати, если вы хотите увидеть филигранное и нетривиальное использование моноидов, обратите внимание на эту библиотеку и на статьи, с ней связанные.
Заключение
Мы не рассмотрели всех возможностей, которые даёт использование абстракции для алгебры де Моргана. Можно было привести пример расчёта теплоизоляции здания с окнами и сложным покрытием стен; или моделирование неньютоновских жидкостей цепями из элементов жёсткости и вязкости.
Для моноидов и алгебры де Моргана выполняется ряд простых теорем, например, известно, что произведение типов-моноидов тоже образует моноид. Эвивалентное утверждение верно для алгебр де Моргана, оно даёт возможность за один проход по структуре вычислять сразу несколько характеристик. Более того, легко можно построить эпиморфизм для алгебры в моноид и вычислять не только алгебраические характеристики, но и любые моноидальные. Как самый простой пример: одновременно с расчётом сопротивлений можно вычислить суммарную мощность выделяемую цепью, которая будет описываться простым суммирующим моноидом.
Свободная алгебра даёт ещё одну возможность, существенную, например, для задач нечёткой логики. Ещё не вычисленное выражение можно упростить, если воспользоваться дистрибутивностью алгебры. Эту процедуру можно выполнить во время выполнения программы, то есть с произвольными данными, но перед интенсивными вычислениями. На что способны свободные структуры показывает Wolfram Mathematica, в которой все выражения, по существу, представляют собой свободные алгебры.
По сравнению с лошадью или с автомобилем, поезд имеет существенное ограничение — он может двигаться только по железной дороге. И у железной дороги есть существенное ограничение — по ней могут двигаться только поезда. Но эти ограничения дают новые возможности — можно существенно снизить трение, увеличить скорость и построить сложную и эффективную автоматическую систему управления движением сотен составов. На автомагистрали нельзя ездить на мопеде или на тракторе. Это ограничение. Но если мы гарантируем его, то можем рассчитывать на высокие скорости и высокую пропускную способность трассы.
Функциональное программирование добавляет в повседневную практику программиста различные ограничения, и законы. Изменять состояние нельзя, композиция должна быть ассоциативной, аппликативные функторы и монады должны удовлетворять законам аппликативных функторов и монад и т.д. Это может раздражать, но вместе с этими ограничениями и законами приходят гарантии, что код будет работать именно так, а не иначе. А самое главное, именно неукоснительное следование этим законам, в принципе, и позволяет говорить в программе о функторах, монадах, ассоциативности и дистрибутивности, алгебрах и прочих категориях, а значит и пользоваться наработками многих поколений математиков и применять их в повседневной работе. Так что все эти сложные штуки — не костыли ФП, как может показаться, а ставшие доступными благодаря развитию аппаратной части компьютеров, новые возможности. Не стоит ими пренебрегать.
- операция образует моноид с нейтральным элементом :