Всем привет.
В последнее время я работаю с распределенными системами и часто встречаюсь с проблемами работы с данными, части которых могут находиться в различных местах. Ну и так как я уже продолжительное время пишу на Haskell, описание проблемы и мощная система типов здорово помогли в дальнейшем развитии этой идеи. Речь пойдет о том, как всего одна несущая алгебраическая конструкция позволила решить задачу рециркулирующих данных в общем виде.
Введение и мотивация
По ходу повествования мы встретим три основных тезиса, которые дадут представление о желаемом результате.
Представьте, что вам нужно написать программу, которая работает с какими-то данными. Если данных слишком много, то вам придется задумываться об организации и хранении — так как хранить их в оперативной памяти слишком ненадежно, может не хватить места или во время аварийного завершения программы вы их полностью потеряете.
Использовать базу данных? Какие? Реляционные, документоориентированные, хранилища типа "ключ-значение" или что-нибудь попроще — просто писать в файлик? Что же, каждая из этих опций имеет собственные достоинства и недостатки.
Где бы мы их не хранили, чтобы сделать с этими данными что-то, что выходит за пределы возможностей выбранного нами способа, нам в любом случае необходимо загрузить их обратно в оперативную память. Разумеется, только какую-то часть, а не все подряд, по причинам описанным выше.
Тезис 1. Нам не нужно хранить все данные в пямяти, а только какую-то часть.
И правда, когда мы работаем с базами данных, мы пишем запросы, по которым мы эти данные можем получать в память. Запись в базе данных — это ведь только кусочек от всего нашего общего массива с информацией. В случае реляционных — это записи в таблицах. В случае хранилищ типа "ключ-значение" — пара "ключ-значение".
Когда вы пишете приложения для реального мира, вам приходится подстраивать схему организации данных из своей предметной области под ограничения выбранного вами способа. Это будет зависеть от степени связности, аспектов производительности и многих других факторов.
Тезис 2. Мы хотим абстрагироваться от способа хранения и обработки информации.
Основываясь на двух предыдущих свойствах, нам нужен способ разделения данных, с которыми мы сейчас работаем и которые фактически находятся в оперативной памяти, и данными, которые законсервированы где-нибудь на жестком диске. Нам нужен способ их разделения.
Как же мы будем их разделять? Нам нужна такая структура, которая позволит безболезненно разделять и соединять данные. Давайте поразмышляем на эту тему?
- Списки? Можно, но есть проблемы. Стоимость доступа к произвольному элементу — O(n). Стоимость объединения двух списков такая же.
- Какие-нибудь деревья? Если это бинарные, то стоимость доступа в хорошем случае снижается до O(log n). Но нам не всегда будет требоваться хранить отсортированные данные. Слишком частный случай, нам не подходит.
- Массивы? Стоимость доступа — O(1). Но тоже частный случай, просто столкнемся с другими проблемами — эта структура вообще не алгебраическая.
Тезис 3. Нам нужна несущая конструкция, способная охватить в своем описании многие другие структуры данных.
И такая структура есть!
Несущая конструкция Cofree
Многие Haskell-программисты знакомы с типом Free. Но почему-то его дуальности, Cofree, уделено не так много внимания. А разница между ними составляет одну деталь: Free тип — это сумма из некоторого а и t (Free t a), а Cofree — произведение:
Free t a = a + t (Free t a)
Cofree t a = a ? t (Cofree t a)Это означает, что если мы выберем Cofree в качестве нашей несущей конструкции, у структуры данных, определенной через последнюю, появятся несколько особенностей:
- Эта структура всегда будет непустой, она всегда будет иметь по крайней мере один элемент.
- Так как Cofree также имеет экземпляр класса типов Comonad, мы уже имеем много полезных методов задаром:
 - extract— Получить значение, которое находится в фокусе.
- extend— Обновить значения во всей структуре в зависимости от контекста.
- unwrap— Получить множитель произведения, сегмент информации.
- coiter— Сгенерировать структуру из начального значения.
 
Так каким образом мы собираемся собирать различные структуры данных с помощью Cofree? Нам всего-то и нужно что инстанциировать тип t в Cofree t a, имеющий экземпляр класса типов Functor.
Представим, что нам нужен стэк или непустой список — простая структура данных. Тут нам подойдет Maybe, в этом случае, конструкторы последнего выполняют роль генератора — Just позволят продолжить описывать структуру, а Nothing является терминирующим инвариантом.: 
data Maybe a = Just a | Nothing
type Stack = Cofree Maybe
example :: Stack Int
example = 1 :< Just (2 :< Just (3 :< Nothing))Вспомогательная конструкция Shape
Хорошо, мы разобрались как можно описывать структуры данных на Cofree. Мы затеяли этот разговор для нахождения способа разделения данных с точки зрения типов, находящихся в разных местах. Для этого мы снабдим Cofree еще одной конструкцией:
data Shape t raw value
    = Ready (t value) -- ^ Сегмент данных в оперативной памяти
    | Converted raw -- ^ Cегмент данных где-нибудь в другом месте
data Apart t raw value = Apart (Cofree (Shape t raw) value)И получаем замечательный тип Apart, который будет контролировать, какая часть данных где находится.
Пример использования Apart
А теперь, давайте составим иллюстрированный пример. Представьте, что мы хотим поработать с бинарным деревом. Как мы можем описать его через Cofree? Через "функтор ветвления". Узел дерева может не иметь потомков, иметь левого потомка, правого потомка или обоих. Давайте прямо так и закодируем:
data Crotch a = End | Less a | Greater a | Crotch a a
type Binary = Cofree CrotchОтлично, теперь мы можем написать значение для этого типа, пример бинарного дерева поиска возьмем с одноименной статьи Википедии:
example :: Binary Int
example = 8 :< Crotch
    (3:< Crotch 
        (1 :< End) 
        (6 :< Crotch 
            (4 :< End) 
            (7 :< End)))
    (10 :< Greater 
        (14 :< Less 
            (13 :< End)))Опробуем первый комбинатор — limit, он позволит нам обрезать наше дерево по высоте:
limit 3 do_something_with_the_rest exampleЯ намеренно абстрагировался от способа сохранения, чтобы не заострять на этом внимание — мы можем хранить не вошедшие в диапазон сегменты в файл и функция do_something_with_rest может возвращать нам название файла и номер строки. Или вовсе класть в Redis/Memcashed/Tarantool и возвращать параметры соединения и ключ для сохраненного сегмента. В общем, не так важно.
scattered :: Scattered Binary Int _
scattered = 8 :< Crotch 
    (3 :< Crotch 
        (1 :< {RESTORE_INFO}) 
        (6 :< {RESTORE_INFO})) 
    (10 :< Greater 
        (14 :< {RESTORE_INFO}))И вот, что осталось от нашего дерева — оно обрезалось по высоте. Но информация для восстановления осталась на месте исчезнувших трех сегментов. Представление выше на самом деле скрывает конструктор Ready, а Converted заменен на фигурные скобочки (спасибо хитрому экземпляру класса Show).
С помощью комбинатора recover мы можем вернуть всю структуру данных в память:
recover back_to_RAM scatteredИли и вовсе пройтись с эффектом по разбросанной структурой данных, по ходу дела восстанавливая сегменты в памяти и применяя к ним такую же функцию как и к значениям в памяти.
fluent do_something_whereever_they_are scatteredВ качестве заключения
Вот так алгебраические структуры данных и понятия из теории категорий позволили сначала описать, а после и решить задачу рециркулирующих данных в самом общем виде.
Идеи, описанные выше, были реализованы в библиотеке, которой пока нет на Hackage, но находящейся в фазе активного развития.
На данный момент мне удалось описать направленный ацикличный граф, бинарные, префиксные, розовые, АВЛ-деревья и немного отдельных функций для работы с ними.
Сама идея использования Cofree в качестве несущей конструкции для других структур данных была мною подхвачена из описания к модулю Control.Comonad.Cofree в пакете free Эдварда Кметта.
Идея алгебраического описания графов была использована здесь из работы Андрея Мохова.
В планах также остается:
- Реализовать пальчиковые, Splay-деревья и другие структуры посложнее.
- Написать побольше функций для работы с ними (вставки, удаления, балансировки и т.п.).
- Естественные преобразования между ними (так как функтор как раз и определяет особенности отдельной структуры).
- Оптические интерфейсы для работы с внутренностями структур.
- Изучить способы совместимости комбинаторов со стриминговыми библиотеками (pipes,conduit,io-streams,machines).
- Написать полноценные тесты свойств отдельных структур данных.
- Бенчмарки, в первую очередь с популярными containers.
Пишите в комментариях, какие структуры данных вам бы хотелось использовать таким образом и какие комбинаторы могли бы пригодиться в повседневной практике. Буду рад любым замечаниям и критике.
Спасибо за внимание.
 
          