И снова декларативное разграничение доступа на Haskell
kayo — Вс, 14/09/2014 - 23:54
Казалось бы, мы уже окончательно пережевали эту тему в прошлый раз. Но оказывается, как всегда, мы всё сделали не правильно… Ну ладно, не будем отчаиваться, мы не совсем всё сделали не правильно. Не правильно мы сделали только в одной части, а именно, в части объявления новых операторов.
Нет, конечно, возможность объявлять хитрые специфические операторы — одна из полезнейших возможностей Haskell, и нет ничего плохого в том, чтобы так поступать. Плохо другое, объявив операторы &&@ и ||@, мы по сути продублировали функциональность. Булеву алгебру не мы изобрели, стандартные операторы && и || давно существуют и делают понятные вещи. Так зачем же снова изобретать велосипед, когда правильнее было бы адаптировать то, что уже есть.
Делаем правильно
Классы типов
В языке Haskell есть мощная концепция, которая называется классами типов. Эта концепция связана с ООП, но это не ООП в классическом виде. Я хочу сказать, что это лучше, чем ООП: это просто, понятно и удобно. Классы в Haskell — это сущности, объединяющие типы в группы, по применимости к ним некоторого набора операций. Так, например, стандартный класс Num объединяет все числовые типы тем, что к ним применимы операции +, –, ×. В общем всё то, что вы привыкли делать с числами, вы можете делать с типами класса Num, будь то Int или Float или другой числовой тип, не важно, вы всегда получите ожидаемый результат.
Предположим, для финансовых вычислений мы захотели реализовать тип DecFloat, который реализует числа с плавающей запятой, представленные в десятичном, а не двоичном виде, как стандарный тип Float. Всё, что нам нужно, чтобы наш тип стал на самом деле числовым типом, — это реализовать экземпляр класса Num. Экземпляр класса подразумевает реализацию базового набора операций для нашего типа. Мы знаем, как устроен тип DecFloat, поэтому для нас не будет большой проблемой реализовать сложение, вычитание, умножение и т.д. А на весь остальной код финансовых вычислений никак не повлияет выбор конкретного типа чисел, который мы хотели бы использовать.
Иногда концепцию классов типов отождествляют с понятием интерфейсов (или виртуальных классов) из ООП. Так класс — это интерфейс, а экземпляр класса — это реализация интерфейса. На мой субъективный взгляд, принятая в Haskell терминология лучше отражает суть. Класс — это именно класс, как нечто более абстрактное, чем тип, который является чем-то вполне определённым и конкретным, в отличии от класса, который относится ко многим типам.
В ООП же класс — это на самом деле тип, то есть вполне конкретная вещь с конкретным внутренним наполнением. Вся эта затейливая иерархия наследования — как раз про то, как писать код в виде набора патчей, применяющихся ко вполне конкретным осязаемым вещам, чтобы превращать их в другие конкретные осязаемые вещи. И в этой иерархии патчей надо уметь удачно маневрировать, обходя грабли, и вешать предупреждающие таблички, чтобы другие не наступили на них. По проектированию софта на объектно-ориентированных языках написано много толстых книг, которые необходимо читать, чтобы владеть всеми удачными паттернами, избегая неудачных.
Я полагаю, что наследование — вот что плохо в ООП. Но это основа, если её убрать, то концепция ООП рассыпется. Но если всё же её убрать, то есть запретить наследование реализации, оставив только инкапсуляцию. Интерфейсы заменить классами, а реализацию интерфейсов заменить экземплярами классов. Типы и классы сделать полностью независимыми сущностями, связанными только через экземпляры. На практике это работает куда лучше, чем представляется в теории.
Как всё обстоит на самом деле
В текущей реализации Prelude нет класса Boolean, который мы бы хотели реализовать для нашего типа AccessRule, чтобы применять к нему обычные операции булевой алгебры. Но это не проблема, потому что в репозитории пакетов Hackage нашелся пакет cond, где умные дяди уже всё сделали за нас. В этом пакете определён интересующий нас класс Boolean и его реализация для стандартного типа Bool. Стоит заглянуть в исходник модуля, внутри всё достаточно просто и очевидно. Чтобы не возникло конфликта между операторами булевой алгебры с определениями из Prelude, нужно спрятать последние через hiding.
Поскольку наш тип AccessRule на самом деле синоним типа Reader User Bool, не удастся так просто определить экземпляр класса Boolean для него. В этом нам помогут расширения FlexibleInstances и GADTs, которые в данном случае можно просто включить и радоваться тому, что всё работает ^_^
Делаем правильно наконец таки
Убираем из кода определения операторов &&@ и ||@, то есть всё вот это:
infixr 4 &&@ infixr 3 ||@ (&&@), (||@) :: (Functor m, Applicative m) => m Bool -> m Bool -> m Bool leftRule &&@ rightRule = (&&) <$> leftRule <*> rightRule leftRule ||@ rightRule = (||) <$> leftRule <*> rightRule
И добавляем вот что:
instance Boolean AccessRule where true = return True false = return False --not x = not <$> x not = (not <$>) a && b = (&&) <$> a <*> b a || b = (||) <$> a <*> b
Оператор not я заменил на версию, использующую частичное применение операторов без явного указания аргумента, как это принято в языке и часто встречается в коде, когда смысл аргументов и результата функции очевиден.
На самом деле, как вы уже, наверно, догадываетесь, необходимо реализовать только true или false, not и && или || и это действительно так. Но в таком случае будут использоваться не очень эффективные реализации, выражающие недостающие операции через уже имеющиеся. Реализовывать необходимый и достаточный минимум удобно для быстрого получения рабочего кода, но затем желательно реализовать все операторы из соображений вычислительной эффективности.
В качестве развлечения мы можем реализовать операторы и вовсе без использования <$> и <*>. Это возможно, поскольку Reader это монада и поэтому с ним можно работать как с монадами:
instance Boolean AccessRule where true = return True false = return False -- not a = do -- x <- a -- return $ not x not = (>>= return . not) a && b = do x <- a y <- b return $ x && y a || b = do x <- a y <- b return $ x || y
Здесь я тоже закомментировал более многословный вариант оператора not, использовав частичное применение. Вместо поднятия операторов в монаду, здесь извлекаются значения и производятся операции над ними, возвращая результат обратно в монаду. Нотация do — это лишь синтаксический сахар для последовательного применения операторов >> и >>= к монадическим значениям. Вот как это будет выглядеть без do:
instance Boolean AccessRule where true = return True false = return False not a = a >>= \x -> return $ not x a && b = (a >>= \x -> b >>= \y -> return $ x && y) a || b = (a >>= \x -> b >>= \y -> return $ x || y)
Согласитесь, не очень.
Осталось правильно импортировать модули:
import Prelude hiding ((&&), (||), not) import Data.Algebra.Boolean
Теперь можно переписать наши функции проверки доступа привычным и понятным образом без использования хитрых операторов:
--canView :: Memo -> AccessRule canView memo = hasPerm ViewAnyMemo || hasPerm ViewOwnMemo && isOwner memo || hasPerm ViewPubMemo && isPublic memo --canEdit :: Memo -> AccessRule canEdit memo = hasPerm EditAnyMemo || hasPerm EditOwnMemo && isOwner memo
Я вас не обманываю, это всё, что мы сделали не правильно с первого раза и исправили. Такая огромная принципиальная разница в подходах и такие незначительные изменения в коде.
Собираем, запускаем и радостно хлопаем в ладоши
Для сборки проектов на Haskell удобно использовать Cabal, посему на этот раз я обернул пример в проект. Пробовать так:
cd path/to/example cabal sandbox init cabal install --dependencies-only cabal configure cabal build cabal run
Результат в точности тот же, что и раньше. Для удобства я оформил пример в виде git репозитория, где есть коммит с первоначальной версией и с исправленной. Результат приложен к статье.
Вот и всё, надеюсь, было весело ^_^
Вложение | Размер |
---|---|
AccessExample.tar.xz | 12.88 КБ |

Отправить комментарий