image Некоторое время назад в одном уютном камерном собрании я делал доклад о своей разработке — скриптовом лиспоподобном языке Liscript. Начал с азов — семантики вычисления списков, префиксной нотации… Дошел до произвольной арности стандартных операций:

+ 1 2 3
=> 6

все интуитивно понятно, вопросов не возникает. Рассказываю про булевские значения, привожу пример:

< 1 2
=> true

тоже все понятно. И тут вопрос из зала: «а если 3 аргумента передать, как будет вычисляться?» Я решаю, что это хороший повод выпендриться умными терминами, и отвечаю: «точно так же — как свертка по моноиду» :) И тут же поправляясь — «хотя операция сравнения не является моноидом», пишу пример:

< 1 2 3
=> true
< 1 2 3 4 1 2
=> false

Все так же интуитивно понятно, вопросов не возникает и продолжаем дальше (благоразумно оставляя без рассмотрения вычисления примитивных операций на одном аргументе и вообще при отсутствии оных, а также вычитание/деление и прочие немоноидальные операции :)). Успешно миновав в докладе подобных камней, через некоторое время я подумал — а можно ли как-то изловчиться, и все-таки сделать операцию сравнения моноидом (в каком-либо смысле)? И мне кажется, мне это удалось. Заинтересовавшихся темой прошу под кат.

Вступление про моноиды для самых маленьких


Как всем хорошо известно, моноидом называется ассоциативная бинарная операция на заданном множестве, имеющая нейтральный элемент. Значит, для определения моноида нам надо задать 3 вещи:

  • множество, на котором определена наша операция
  • саму операцию, обладающую ассоциативностью
  • нейтральный элемент (из вышеопределенного множества)

Освежим в памяти термины «нейтральный» и «ассоциативный». Обозначим нашу бинарную операцию mappend, нейтральный элемент mempty (чтобы потом было проще читать хаскельный кот и чтобы латекс на маркдаун не натягивать), тогда нейтральность элемента определяется как:

mappend mempty x = x
mappend x mempty = x

что можно можно перевести на пальцах как: если один из операндов нашей операции является нейтральным элементом, то результат операции является вторым операндом, другими словами — нейтральный элемент не изменяет результат. Ассоциативность же определяется как:

mappend x (mappend y z) = mappend (mappend x y) z

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

Вообще говоря, моноид — это довольно общее понятие абстрактной алгебры и теории категорий (ведь широко известно, что даже те самые монады — это моноиды по определению), но в данной статье мы не будем глубоко погружаться в теоретические аспекты. Начнем с пары простых примеров: операция сложения на множестве целых (или натуральных/вещественных/комплексных) чисел. Ассоциативность присутствует, в качестве нейтрального элемента берем 0 из заданного множества. Операция умножения также является моноидом, с нейтральным элементом 1. Еще пример — операция конкатенации на множестве строк, с нейтральным элементом в виде пустой строки. Так же все законы моноида выполняются, хотя операция конкатенации не является коммутативной — но это и не требуется. Реализация моноидов сложения/умножения на языке Haskell тривиальна:

newtype Sum a = Sum a  deriving (Eq, Ord, Show)

instance Num a => Monoid (Sum a) where
    mempty = Sum 0
    mappend (Sum x) (Sum y) = Sum $ x + y


newtype Product a = Product a  deriving (Eq, Ord, Show)

instance Num a => Monoid (Product a) where
    mempty = Product 1
    mappend (Product x) (Product y) = Product $ x * y

Здесь мы фактически определили конкретные реализации mempty и mappend по описанным выше правилам. Класс типов Monoid автоматически предоставляет нам операцию свертки mconcat любого контейнера (списка, дерева — любого, на котором определена операция свертки) по любому моноиду: на примере списка — мы начинаем с нейтрального элемента mempty в качестве результата свертки и идем последовательно по списку, обновляя результат через нашу моноидальную бинарную операцию mappend с текущим элементом списка. На примере числового списка, свертка его по моноиду Sum даст нам сумму элементов списка, а Product — произведение. Точно таким же образом вычисляются конструкции:

+ 4 5 6 7
=> 22
* 2 3 4 5
=> 120

В лиспах (оставляя в стороне вопрос о вычислении без аргументов). Проверим хаскельные реализации:

*MyMonoid> mconcat $ map Sum []
Sum 0
*MyMonoid> mconcat $ map Sum [1..10]
Sum 55
*MyMonoid> mconcat $ map Product []
Product 1
*MyMonoid> mconcat $ map Product [1..10]
Product 3628800

Как видим, все работает.

Операция минимум


Теперь возьмем пример поинтереснее — бинарную операцию минимума на числовом множестве. Является ли она моноидом? Операция ассоциативна, множество задано. Единственная проблема — определение нейтрального элемента. Нам нужен такой элемент в нашем множестве, который был бы не меньше любого другого элемента. Если наше множество имеет точную верхнюю грань (например, тип uint8 имеет максимальное значение 255), то мы можем взять это значение в качестве нейтрального элемента нашего моноида. Но если у нас тип неограниченных целых чисел? Решение довольно простое — мы расширяем наш тип еще одним специальным значением (условно назовем его «плюс бесконечность») и зададим операцию так, чтобы выполнялись законы.

Реализуем это в коде и проверим на пустом и непустом списках

data Min a =
    HighInf  -- нейтральный элемент моноида (+ бесконечность)
  | Min a    -- упакованное значение
  deriving (Eq, Ord, Show)

instance Ord a => Monoid (Min a) where
    mempty = HighInf

    mappend HighInf v = v
    mappend v HighInf = v
    mappend (Min x) (Min y) = Min $ min x y

*MyMonoid> mconcat $ map Min []
HighInf
*MyMonoid> mconcat $ map Min [5..10]
Min 5

Все как и ожидалось — минимум на пустом списке дает плюс бесконечность, на непустом — минимальный элемент.

Операция сравнения


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

Давайте подумаем, какие значения должен предоставлять этот новый тип. Нам нужно значение нейтрального элемента, с выполнением соответствующих законов. Нам нужно значение, характеризующее ложный результат какого-либо сравнения — если у нас при свертке в цепочке моноидальных операций сравнения встретилось это значение — мы протаскиваем его до конца, игнорируя другие сравнения. И нам нужно значение, характеризующее истинный результат сравнения. Оно должно содержать в себе значение нашего исходного типа — для последующих сравнений. Но поскольку последующие сравнения могут использовать его как слева (от условного знака <), так и справа, то нам недостаточно одного значения — нам нужны крайние значения обработанного диапазона (минимальное и максимальное за всю цепочку сравнений). Таким образом, значение нашего типа будет представлять собой пару из 2 элементов, задающую свернутый диапазон. Напишем реализацию моноида и всех служебных функций:

data Asc a =
    EmptyAsc      -- нейтральный элемент моноида
  | RangeAsc a a  -- диапазон значений (сравнение истинно)
  | FailAsc       -- сравнение ложно

  deriving (Eq, Ord, Show)

instance Ord a => Monoid (Asc a) where
    mempty = EmptyAsc

    mappend EmptyAsc v = v
    mappend v EmptyAsc = v

    mappend FailAsc v = FailAsc
    mappend v FailAsc = FailAsc

    mappend (RangeAsc a b) (RangeAsc c d)
        | b < c = RangeAsc a d
        | otherwise = FailAsc


valToAsc x = RangeAsc x x

ascToBool FailAsc = False
ascToBool _       = True

и проверим работу:

*MyMonoid> mconcat $ map valToAsc []
EmptyAsc
*MyMonoid> mconcat $ map valToAsc [3,4,7,10]
RangeAsc 3 10
*MyMonoid> mconcat $ map valToAsc [1,2,3,4,1,2]
FailAsc
*MyMonoid> isAscList []
True
*MyMonoid> isAscList [1..10]
True
*MyMonoid> isAscList $ [1,2,3,4,1,2]
False

Все работает как и требуется — наш моноид проверяет упорядоченность списка по возрастанию — то есть ровно то, что и делает аналогичная конструкция в лиспах. Нетрудно написать еще несколько аналогичных моноидов, проверяющих список на нестрогое возрастание (по операции нестрогого сравнения <=), убывание (строгое и нестрогое) и равенство всех элементов списка. Для этого достаточно только заменить соответствующий бинарный предикат в определении функции mappend.

Долгожданное заключение


Теперь я спокоен — я не обманул слушателей во время доклада :) Операция сравнения сама по себе не является моноидом, но можно ввести уровень абстракции, в котором ее обобщенный аналог будет являться таковым.

Если кому интересно — видео доклада (стрим на ютубе) Ссылки на мой гитхаб-репозиторий с реализацией интерпретаторов можно найти в моих предыдущих статьях на Хабре.
Поделиться с друзьями
-->

Комментарии (12)


  1. mayorovp
    06.05.2017 11:15

    Это бинарный предикат, а не операция. Но, как всегда, мы можем решить эту проблему с помощью введения дополнительного уровня абстракции (похоже на то, как мы делали в прошлом примере с минимумом): надо сконструировать такой тип, чтобы обобщенная операция сравнения была определена на множестве этого типа.

    Вы пропустили важный для понимания шаг. Перед тем как расширять тип и определять обобщенную операцию на нем — надо бы придумать как вообще операция сравнения должна выполняться на трех аргументах.


    Насколько я понимаю, вы решили определить ее как проверку составного неравенства:


    x < y < z < t = (x < y) && (y < z) && (z < t)


    1. IIvana
      06.05.2017 17:31
      +1

      Ну да, как в любом лиспе. Проверка возрастающего порядка любого количества операндов. И бинарный вариант автоматически получается частным случаем.


  1. devpony
    06.05.2017 21:58

    Спасибо, интересно.


  1. popolznev
    09.05.2017 21:07

    для определения моноида нам надо задать 3 вещи:
    множество, на котором определена наша операция
    саму операцию, обладающую ассоциативностью
    нейтральный элемент (из вышеопределенного множества)

    Нейтральный элемент не надо задавать. После того как вы зафиксировали множество и операцию — нейтральный элемент либо есть, либо нет.


    1. IIvana
      09.05.2017 21:12

      Вот придет к вам следователь и скажет — предъявите моноид, гражданин. И будет по списку проверять комплектность и исправность (соблюдение законов). Вы ему скажете — вот мое множество и операция, а нейтральный элемент — либо есть, либо нет. И он вас задержит до выяснения обстоятельств. А если вы ему предъявите всю троицу, да еще продемонстрируете соблюдение законов — тогда и вопросов к вам никаких не будет. Так что дело не в глаголах — задавать или предъявлять, а 3 составляющих моноида должны быть — и все тут. По закону положено.


      1. popolznev
        09.05.2017 22:06

        Не надо приписывать мне нелепостей. Зачем же я буду говорить "нейтральный элемент либо есть, либо нет", если он у меня есть?


        Вы неточно говорите о "комплектности" моноида. Нет там "троицы", "трёх составляющих". Есть двойка {множество, операция} и два требования: ассоциативность и наличие нейтрального элемента. Выполнение этих требований проверяется, а не "задаётся". "Задать нейтральный элемент операции" — примерно то же самое, что "задать ассоциативность операции", то есть бессмыслица.


        И напрасно вы говорите, что дело не в глаголах. В математике дело и в глаголах тоже. И в существительных, и в прилагательных. Хотя, конечно, не только в них.


  1. WarKnife
    10.05.2017 22:29
    +1

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


    1. IIvana
      10.05.2017 22:37
      -4

      Надеюсь, вы не хотите, чтобы я комментировал ваши думы.


      1. WarKnife
        11.05.2017 08:50
        +2

        Не буду, как вы, переходить на личности. Я не спорю, что в алгоритме нахождения минимума брать наибольшее значение вполне логично, но только в том случае, если множество непустое (собвственно, так как «Минимум — n-арная операция, операция над n числами, возвращающая наименьшее из чисел.», то минимум от пустого множества неопределим). Я считаю, что необходимо использовать как нейтральный элемен либо неопределенность (ввиду того, что пользователь не предоставил выборку из множества типа, т.е. то самое перечисление чисел, а значит логика операции нарушена), либо минимальный элемент множества типа, тот самый minBound (для мн-ва целых чисел — минус бесконечность). Или вы считаете, что минимальным элементом множества целых чисел является плюс бесконечность?

        Теперь возьмем пример поинтереснее — бинарную операцию минимума на числовом множестве. Является ли она моноидом? Операция ассоциативна, множество задано. Единственная проблема — определение нейтрального элемента. Нам нужен такой элемент в нашем множестве, который был бы не меньше любого другого элемента. Если наше множество имеет точную верхнюю грань (например, тип uint8 имеет максимальное значение 255), то мы можем взять это значение в качестве нейтрального элемента нашего моноида. Но если у нас тип неограниченных целых чисел? Решение довольно простое — мы расширяем наш тип еще одним специальным значением (условно назовем его «плюс бесконечность») и зададим операцию так, чтобы выполнялись законы


        Prelude> mconcat $ map Min ['a', 'b', 'c']
        Min 'a'
        Prelude> mconcat $ map Min ([] :: [Char])
        HighInf
        

        Я думал, что операция определена «на числовом множестве»? Наверно тоже моя дума. Хотя нет, всё нормально, ведь
        Все как и ожидалось — минимум на пустом списке дает плюс бесконечность, на непустом — минимальный элемент.

        Как всем хорошо известно, моноидом называется ассоциативная бинарная операция на заданном множестве, имеющая нейтральный элемент.

        Думаю, стоит поправить. Моноид — это множество, на котором задана бинарная ассоциативная операция, а не сама операция (к тому же, операция не может иметь нейтральный элемент).

        Что же касается сути статьи, а именно реализации полиадической функции сравнения — могу сказать лишь одно: желание реализовать такое возникает только у «лисперов», в силу определенных тенденций.


        1. mayorovp
          11.05.2017 14:37

          Нейтральный элемент — это как раз свойство бинарной операции, а не моноида, и он определяется как элемент a, такой что для любого допустимого x выполняется


          a * x = x * a = x, где * - обсуждаемая операция

          Для любой операции может существовать не больше одного нейтрального элемента, что очень легко доказывается:


          пусть a и b - нейтральные элементы операции *
          тогда a = a * b = b

          Для сложения нейтральный элемент — 0, для умножения — 1, а для минимума — плюс бесконечность, как бы лично вам не хотелось неопределенности или минус бесконечности.


          1. WarKnife
            11.05.2017 18:16

            Элемент множества — это объект множества. И принадлежит он множеству, а не операции. Операция обладает лишь операндами — элементами множества, учавствующими в операции. Нейтральность — свойство самого элемента, проявляющееся в условиях применения операции, для которой он собственно и является нейтральным. Нейтральных элементов может быть и два, если операция некоммутативна…

            Хотя о чем это я.

            *MyMonoid> mconcat $ map Min []
            HighInf
            

            Я так понимаю вы хотите доказать, что минимальным элементом пустого множества является плюс бесконечность?


            1. devpony
              12.05.2017 14:44

              Ну как-бы


              Prelude Data.Monoid> getSum $ mconcat $ map Sum []
              0
              Prelude Data.Monoid> getProduct $ mconcat $ map Product []
              1

              С другой стороны я согласен, что вводить HighInf подобным способом — грубо и некрасиво, я бы использовал для этих целей maxBound.