При манипулировании неизменяемыми структурами данных, в чем разница между помощником Clojure и линзами Haskell?

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

Clojure assoc-in
Haskell lens


person Dustin Getz    schedule 22.01.2014    source источник


Ответы (4)


Clojure assoc-in позволяет указать путь через вложенную структуру данных, используя целые числа и ключевые слова, и ввести новое значение в этот путь. У него есть партнеры dissoc-in, get-in и update-in, которые удаляют элементы, получают их без удаления или модифицируют соответственно.

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

Здесь есть аналогия. Если мы посмотрим на использование assoc-in, оно будет написано как

(assoc-in whole path subpart)

и мы могли бы получить некоторое представление, думая о path как о линзе, а assoc-in как о комбинаторе линз. Аналогичным образом вы можете написать (используя пакет Haskell lens)

set lens subpart whole

так что мы соединяем assoc-in с set и path с lens. Мы также можем заполнить таблицу

set          assoc-in
view         get-in
over         update-in
(unneeded)   dissoc-in       -- this is special because `at` and `over`
                             -- strictly generalize dissoc-in

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

Во-первых, давайте встроим типы Clojure в Haskell и напишем семейство функций *-in.

type Dict a = Map String a

data Clj 
  = CljVal             -- Dynamically typed Clojure value, 
                       -- not an array or dictionary
  | CljAry  [Clj]      -- Array of Clojure types
  | CljDict (Dict Clj) -- Dictionary of Clojure types

makePrisms ''Clj

Теперь мы можем использовать set как assoc-in почти напрямую.

(assoc-in whole [1 :foo :bar 3] part)

set ( _CljAry  . ix 1 
    . _CljDict . ix "foo" 
    . _CljDict . ix "bar" 
    . _CljAry  . ix 3
    ) part whole

Это несколько очевидно имеет гораздо больше синтаксического шума, но это означает более высокую степень эксплицитности того, что означает «путь» к типу данных, в частности, он указывает, спускаемся ли мы к массиву или словарю. Мы могли бы, если бы захотели, устранить часть этого лишнего шума, создав экземпляр Clj в классе типов Haskell Ixed, но на данный момент это вряд ли стоит того.

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

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

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

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

data Point = 
  Point { _latitude  :: Double
        , _longitude :: Double
        , _meta      :: Map String String }
  deriving Show

makeLenses ''Point

> let p0 = Point 0 0
> let p1 = set latitude 3 p0
> view latitude p1
3.0
> view longitude p1
0.0
> let p2 = set (meta . ix "foo") "bar" p1
> preview (meta . ix "bar") p2
Nothing
> preview (meta . ix "foo") p2 
Just "bar"

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

dimensions :: Lens Point Double

> let p3 = over dimensions (+ 10) p0
> get latitude p3
10.0
> get longitude p3
10.0
> toListOf dimensions p3
[10.0, 10.0]

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

eulerAnglePhi   :: Lens Point Double
eulerAngleTheta :: Lens Point Double
eulerAnglePsi   :: Lens Point Double

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

person J. Abrahamson    schedule 22.01.2014

Вы говорите о двух совершенно разных вещах.

Вы можете использовать линзу для решения тех же проблем, что и assoc-in, где вы используете типы коллекций (Data.Map, Data.Vector), которые соответствуют семантике, но есть различия.

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

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

Одной из библиотек, на которую стоит обратить внимание, будет http://hackage.haskell.org/package/lens-aeson, где вы имеют документы JSON, которые могут иметь различное содержимое.

Примеры демонстрируют, что когда ваш путь и тип не соответствуют структуре/данным, выбрасывается Nothing вместо Just a.

Lens не делает ничего, кроме обеспечения нормального поведения получателя/установщика. Он не выражает особых ожиданий относительно того, как выглядят ваши данные, тогда как ассоциирование имеет смысл только с ассоциативными коллекциями с, возможно, недетерминированным содержимым.

Еще одно отличие здесь — чистота и лень против строгой и нечистой семантики. В Haskell, если вы никогда не использовали «старые» состояния, а только самое последнее, тогда будет реализовано только это значение.

Линзы tl;dr, найденные в Lens и других подобных библиотеках, являются более общими, более полезными, типобезопасными и особенно хороши для ленивых/чистых языков FP.

person bitemyapp    schedule 22.01.2014

assoc-in в некоторых случаях может быть более универсальным, чем lens, потому что он может создавать уровни в структуре, если они не существуют.

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

Еще одно отличие, которое я вижу в assoc-in-подобных функциях в Clojure, заключается в том, что они, по-видимому, связаны только с получением и установкой значений, в то время как само определение линзы поддерживает «что-то делать со значением», что, возможно, связано с побочными эффектами.

Например, предположим, что у нас есть кортеж (1,Right "ab"). Второй компонент представляет собой тип суммы, который может содержать строку. Мы хотим изменить первый символ строки, прочитав его из консоли. Это можно сделать с линзами следующим образом:

(_2._Right._Cons._1) (\_ -> getChar) (1,Right "ab")
-- reads char from console and returns the updated structure

Если строка отсутствует или пуста, ничего не делается:

(_2._Right._Cons._1) (\_ -> getChar) (1,Left 5)
-- nothing read

(_2._Right._Cons._1) (\_ -> getChar) (1,Right "")
-- nothing read
person danidiaz    schedule 22.01.2014

Этот вопрос несколько аналогичен вопросу о том, в чем разница между for Clojure и монадами Haskell. Пока я буду подражать ответам: конечно, for похожа на монаду List, но монады гораздо более общие и мощные.

Но это как-то глупо, правда? Монады были реализованы в Clojure. Почему они не используются постоянно? В основе Clojure лежит другая философия обработки состояния, но он по-прежнему может заимствовать хорошие идеи из великих языков, таких как Haskell, в своих библиотеках.

Итак, конечно, assoc-in, get-in, update-in и т. д. — это своего рода линзы для ассоциативных структур данных. И вообще существуют реализации линз в Clojure. Почему они не используются постоянно? Это разница в философии (и, возможно, жуткое ощущение, что со всеми сеттерами и геттерами мы будем делать еще одну Java внутри Clojure и каким-то образом в конечном итоге женимся на нашей матери). Но Clojure не стесняется заимствовать хорошие идеи, и вы можете видеть, как подходы, вдохновленные линзами, пробиваются в крутые проекты, такие как Om и Enliven.

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

person A. Webb    schedule 23.01.2014
comment
каким образом Ом использует линзы? Мой вопрос в более широком контексте использования React для чистого рендеринга DOM с очень сложным состоянием, хранящимся в атоме в верхней части приложения. - person Dustin Getz; 23.01.2014
comment
@DustinGetz См. концептуальный обзор Ома. - person A. Webb; 23.01.2014
comment
Я не уверен, что могу утверждать, что линзы и курсоры очень похожи. Хотя они решают похожую проблему. - person J. Abrahamson; 24.01.2014
comment
Линзы и монады без типов и классов типов — это безумие. Я опытный программист Clojure, и я бы не стал с этим заморачиваться. - person bitemyapp; 24.01.2014
comment
Если хотите, смягчены до подходов, вдохновленных линзами. - person A. Webb; 24.01.2014