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


Я думаю, многие из нас были студентами технических ВУЗов и посещали большое количество математических дисциплин, возможно, задаваясь вопросом "зачем". Одной из таких дисциплин, быть может, была алгебра. Нет, это не та книжка Мордковича, переполненная скучными задачами про многочлены и уравнения. Это сложная, объёмная наука с высоким порогом входа, которая требует огромной вовлечённости и погружения. Однако, захватывающая и интересная. Основным предметом этой науки являются так называемые алгебраические структуры. И основной интерес заключается в том, что эти структуры можно строить практически где угодно и из чего угодно. Оказывается, своё применение они могут найти и в создании новых подходов к написанию кода. Прежде чем поговорим о них, небольшое алгебраическое отступление.

Небольшое алгебраическое отступление

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

Начнём с того, что у нас есть X- некоторое произвольное множество. С понятием множества, надеюсь все знакомы. Как и с понятием отображения. Потому что отождествляя некоторое отображение \circ : X \times X \to Xвместе с нашим множеством, мы получаем алгебраическую структуру. То есть, алгебраическая структура - это пара (X, \circ)из множества и замкнутой бинарной операции. Например, алгебраической структурой являются целые числа со сложением (\mathbb{Z} , +).

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

Например, первым естественным ограничением возьмём ассоциативность. Бинарная операция \circна множестве Xназывается ассоциативной, если верно следующее:

(a \circ b) \circ c = a \circ (b \circ c)

При выполнении этой аксиомы (X, \circ)уже становится полугруппой. Чем вообще полезны полугруппы? Если вдуматься в смысл ассоциативности, то это значит ровно то, что группировка для операции не важна, а значит "складывать" элементы полугруппы можно параллельно. При этом хотелось бы иметь возможность какие-то элементы игнорировать. Перейдём на следующий уровень строгости.

Элемент e \in X называется единичным, если верно следующее:

\exists ! \; e \in X : \forall x \in X \; e \circ x = x \circ e = x

Наша полугруппа (X, \circ)с единицей e - уже моноид. Важно понимать, что в аксиоме не случайно два равенства: бывают единицы справа и единицы слева, но это совсем другая история.

Какие мы знаем моноиды? Опять же, целые числа - (\mathbb{Z}, +,0), (\mathbb{Z}, *,1). Но что мы всё про числа?

Моноиды на практике

Какие мы можем построить моноиды на практике?

  • (String, +, "")- строки с конкантенацией;

  • (List<T>, Concat(), \{\})- списки с операцией слияния списков;

  • (Int32, max, -2^{31} +  1)- инты с максимизацией вместо сложения;

  • (Boolean, ||, false);

И так далее. Для начала, оформим полученные в отступлении знания в коде.

public interface ISemiGroup<T>
{
    T Plus(T left, T right);
}

public interface IMonoid<T> : ISemiGroup<T>
{
    T Zero { get; }
}

Update: Как правильно заметил @Googolplex, полученные интерфейсы это type classes. Это замечание привело к переосмыслению кода в статье и его доработке. Идея осталась той же, реализация чуть улучшена. Суть заключается в том, что данные интерфейсы мы можем имплентировать не через классы, а структуры. Таким образом, мы сможем снизить стоимость вызова их функций и использовать ad-hoc полиморфизм.

Я не случайно обозвал операцию "плюсом", а единичный элемент "нулём", потому что (спойлер) в алгебре есть структуры с двумя операциями, как, например, кольцо, где "сложение" от "умножения" надо отличать. Но, не суть важно. Реализуем, например, полугруппу максимизации.

public struct Max<T> : ISemiGroup<T> where T : IComparable<T>
{
    public T Plus(T left, T right) =>
        left.CompareTo(right) > 0
            ? left
            : right;
}

И сделаем под это какую-нибудь dto-шку.

public record Person(string Name, int Money) : IComparable<Person>
{
    public int CompareTo(Person other) => Money.CompareTo(other.Money);
}

И вот тут появляется важная мысль. Я же не буду брать всего два человека и находить богатейшего. Нет. Мне надо обработать много людей. У меня список есть.

var people = new List<Person>
{
    new("Bob", 1000),
    new("Tim", 1239),
    new("Jeff", 2000000000)
};

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

public static class EnumerableExtension
{
    public static T Sum<T, S>(this IEnumerable<T> collection)
        where S : struct, ISemiGroup<T>
    {
        var semiGroup = default(S);
        return collection.Aggregate((x, y) => semiGroup.Plus(x, y));
    }
}

Ну тогда теперь очевидно, что надо делать.

var richest = people.Sum<Person, Max<Person>>();

Магия! Но, какая-то примитивная магия. Пока что. Какая следующая популярная агрегация у нас есть? Среднее! Оказывается его вычисление можно распараллелить. Это не такая очевидная мысль, но если задуматься... Из чего оно складывается: из количества значений и их суммы. То есть эти два параметра нужно разделить и отслеживать. Тогда мы получим структуру аналогичную (\mathbb{Z}^2,+,(0,0)). Оформим эту мысль.

public class AveragedValue
{
    private double _sum;
    private int _count;

    public AveragedValue() : this(0, 0)
    {
    }

    public AveragedValue(double sum, int count = 1)
    {
        _sum = sum;
        _count = count;
    }

    public double Get() => _sum == 0
        ? 0
        : _sum / _count;

    public static AveragedValue operator +(AveragedValue av1, AveragedValue av2)
    {
        var newCount = av1._count + av2._count;
        var newSum = av1._sum + av2._sum;

        return new AveragedValue(newSum, newCount);
    }
}

public struct Avg : IMonoid<AveragedValue>
{
    public AveragedValue Plus(AveragedValue left, AveragedValue right) => left + right;

    public AveragedValue Zero => new ();
}

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

public bool Fits(string text) =>
    text ... ||
    text ... ||
    text ... ||
    ...;

А если заказчик захочет другой фильтр на другое поле? Или большей гибкости фильтра? Не хочется плодить что-то в стиле average javascript enjoyer, поэтому посмотрим на задачу под другим углом. Фильтр, вообще говоря, это предикат, то есть булева функция, или, строго говоря, отображение из заданного типа в множество логических значений. И если "складывать" предикаты логическим ИЛИ, то мы получим моноид!

public struct Any<T> : IMonoid<Predicate<T>>
{
    public Predicate<T> Zero => _ => false;

    public Predicate<T> Plus(Predicate<T> left, Predicate<T> right) =>
        x => left(x) || right(x);
}

Тогда, например, можно написать что-то такое. Всё разнести по сервисам, контейнерам и будет красиво.

var predicates = new List<Predicate<char>>
{
    x => x >= '0' && x <= '9',
    x => x >= 'A' && x <= 'Z',
    x => x >= 'a' && x <= 'z'
};
var digitOrLetter = predicates.Sum<Predicate<char>, Any<char>>();

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

public struct MapMonoid<K, V, S> : IMonoid<Dictionary<K, V>> 
    where S : struct, ISemiGroup<V>
{
    public Dictionary<K, V> Zero => new();
    
    public Dictionary<K, V> Plus(Dictionary<K, V> left, Dictionary<K, V> right)
    {
        var valueSemiGroup = default(S);
        var result = Zero;
        foreach (var (key, value) in left.Concat(right))
        {
            result[key] = result.ContainsKey(key)
                ? valueSemiGroup.Plus(result[key], value)
                : value;
        }
        
        return result;
    }
}

Как это использовать? Например, у нас есть список строк.

var strings = new List<string> {"foo", "foo", "foo", "bar", "bar", "baz", "pipi", "pupu"};

Мы можем с помощью него например сгруппировать строки по их числу вхождений или найти слова одинаковой длины. Или что-нибудь, что вы придумаете.

var dicts = strings
    .Select(x => new Dictionary<string, int> {{x, 1}});
var anotherDicts = strings
    .Select(x => new Dictionary<int, List<string>>
    {
        {x.Length, new List<string> {x}}
    });

Проведём их суммирование с помощью соответствующих полугрупп (числа со сложением, списки с соединением).

private struct IntSemiGroup : ISemiGroup<int>
{
    public int Plus(int left, int right) => left + right;
}

private struct ListSemiGroup<T> : ISemiGroup<List<T>>
{
    public List<T> Plus(List<T> left, List<T> right)
    {
        var result = new List<T>(left);
        result.AddRange(right);
        return result;
    }
}
var map = dicts
    .Sum<Dictionary<string, int>, MapMonoid<string, int, IntSemiGroup>>();
var anotherMap = anotherDicts
    .Sum<Dictionary<int, List<string>>, MapMonoid<int, List<string>, ListSemiGroup<string>>>();

И получим такой вывод.

Итого

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

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


  1. Googolplex
    11.03.2022 02:26
    +4

    Можно было бы упомянуть что ваши IMonoid<T> это классы типов. В языках типа Haskell или Rust (там они называются трейтами, но суть почти та же) они являются одним из основных способов абстракции, да и в других языках типа Scala играют большую роль.


    1. Stefanio Автор
      11.03.2022 13:20

      Спасибо за комментарий! Он помог доработать статью :)


  1. vkni
    11.03.2022 08:17
    +5

    С учётом того, что Робин Милнер предложил язык ML до моего рождения, мы сильно тормозим с математическими веяниями в программировании. :-(


    1. funca
      11.03.2022 09:06
      +4

      Мне кажется, что абстрактная алгебра для программирования не стала каким-то откровением и сейчас просто занимает свое место среди множества других шаблонов проектирования. Примерно как абстрактная живопись стреди всего остального изобразительного искусства.


  1. DarthLexus
    11.03.2022 08:53
    -1

    Мы можем с помощью него например сгруппировать строки по их числу вхождений или найти слова одинаковой длины.

    для группировки я применю LINQ

    var d1 = strings.GroupBy(s => s).ToDictionary(g => g.Key, g => g.Count());

    var d1 = strings.GroupBy(s => s.Length).ToDictionary(g => g.Key, g => g.ToList());

    Или что-нибудь, что вы придумаете

    честно пытался придумать что-нибудь неподвластное LINQ или перегрузке операторов, но увы


  1. funca
    11.03.2022 08:55
    +1

    public class AveragedValue {

    public AveragedValue() : this(0, 0) { }

    public double Get() => _sum / _count;

    }

    public class Avg : IMonoid<AveragedValue> {

    public AveragedValue Zero => new ();

    }

    Ничего, что вызвав Avg.Zero().Get() получим 0/0?


    1. Stefanio Автор
      11.03.2022 11:42
      +1

      Спасибо, не подумал об этом, поправил)


  1. aarmaageedoon
    11.03.2022 09:05
    +1

    Замечательно, спасибо.

    Как раз начал недавно переизучивать алгебру и сразу понял, что её на самом деле можно как то умно применять в программировании.

    Пожалуйста, пишите ещё или, может, поделитесь литературой о том, как алгебру можно применять при программировании.


    1. YBogomolov
      11.03.2022 10:15
      +10

      Почти всё современное ФП построено на применении законов алгебры и некоторых упрощенных конструкций из теории категорий. Если вам интересны эти темы, можете начать с книги Бартоша Милевского «Category theory for programmers», она написана с примерами на Haskell, но есть переводы на Scala и C# + F#. Примеров на пайтоне, увы, мне быстро найти не удалось — возможно, вы сможете.


      Также очень рекомендую посмотреть доклады конференций вроде Strange Loop или Scala Love. Из второй очень советую глянуть доклад Луки Якобовица «Monoids, monoids, monoids», он хорошо дополняет текущую статью.


      1. vkni
        11.03.2022 19:51
        +1

        На Ютубе есть ещё курс лекций Category Theory For Beginners, Ричарда Саусвелла с его чудесным акцентом. А ещё по теоркату есть хорошее введение в книге Голдблатт Р. Топосы. Категорный анализ логики.


    1. Stefanio Автор
      11.03.2022 11:42

      Спасибо! Уже готовлю материал :)


  1. YBogomolov
    11.03.2022 10:31
    +2

    Мы задействовали только определения, а какой прорыв случится, когда в ход пойдут теоремы, боюсь представить

    А вы не бойтесь :) Посмотрите на языки proof assistant'ы Coq, Agda, Lean. Для последнего есть шикарнейшая библиотека с формализацией математики: https://leanprover-community.github.io/index.html. Если вас интересует эта тема, поищите статьи и книги на тему формальной верификации (например, Verified Functional Programming in Agda написана просто отлично).


    1. Stefanio Автор
      11.03.2022 11:40
      +1

      Спасибо большое, обязательно посмотрю!


  1. degor
    11.03.2022 13:22
    +1

    По этой теме есть отличная книга Степанова и Мак-Джонса "Начал программирования".


    1. Stefanio Автор
      11.03.2022 13:22

      Спасибо, почитаю!


    1. sundmoon
      11.03.2022 18:09
      +1

      Беда в том, что авторская номенклатура идей из этой книги "не взлетела". Взлетели их однозначные соответствия из FP и DDD, да и из OOP.

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


      1. funca
        11.03.2022 19:31

        Кто боится когнитивных расходов, тому путь в OOP. Серьезно, как они потом будут читать код?

        var richest = people.Sum<Person, Max<Person>>();

        Магия!


        1. vkni
          11.03.2022 19:53
          +1

          Это просто артефакт неподходящего синтаксиса. :-( Если взять синтаксис ISWIM (ML или Haskell), то будет сильно проще.


          1. funca
            11.03.2022 21:01

            Я к тому, что код делает не то, как это написано: он считает не сумму людей, а ищет самого богатого.

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


            1. vkni
              11.03.2022 22:08
              +1

              Использование каких-либо технических приемуществ языка не отменяет необходимости в том, чтобы писать на нём по-человечески. Например, одна из претензий к Хаскелистам, что в сообществе слишком часто используются однобуквенные сокращениям, в результате код становится непонятным несмотря на простой синтаксис и семантику языка (без расширений язык значительно проще той же Java, а семантически - Питона).

              Увы, "что один человек сделал, другой всегда поломать сможет".


  1. primetalk
    11.03.2022 14:03

    Может, добавить ссылку на какую-нибудь библиотеку наподобие https://typelevel.org/cats/typeclasses/monoid.html (только для .NET)?


  1. dmitryvolochaev
    11.03.2022 22:21
    -1

    Статья 19 Конституции РФ

    public class Person : IComparable<Person>
    {
        public int CompareTo(Person other) => 0;
    }


  1. Naf2000
    12.03.2022 10:14
    +2

    В языках не хватает самого интересного - требования к операциям.

    Например можно реализовать, описанный здесь интерфейс ISemiGroup, но нет никакой гарантии, что операция реализации будет ассоциативной. Аналогично моноид.

    Наконец, можно не вводить новый интерфейс моноид (или даже группа), а проверять полугруппу на соответствие условий.


    1. Stefanio Автор
      12.03.2022 14:50

      На самом деле, устроить проверку ассоциативности, коммутативности и других свойств можно разными способами. Например, через перехват методов инструментами аспектно-ориентированного программирования (PostSharp, Castle). Или через реализацию какой-нибудь такой истории.

      public abstract class SemiGroup<T>
      {
          protected abstract T PlusInternal(T left, T right);
      
          public T Plus(T left, T right)
          {
              AssertAssociative(PlusInternal); 
              ...
              return PlusInternal(left, right);
          }
      }

      Но, во-первых, это сильно снизит производительность и удобство разработки, а, во-вторых, действительно, нет никакой гарантии выполнения свойств, потому что физически не получится перебрать все возможные значения типа T, чтобы удостовериться в удовлетворении аксиоматике.


      1. sundmoon
        12.03.2022 23:47

        Полезно всё-таки сформулировать инварианты и написать Property-Based тесты.