Мне нужно манипулировать и изменять глубоко вложенные неизменяемые коллекции (карты и списки), и я хотел бы лучше понять различные подходы. Эти две библиотеки решают более или менее одну и ту же проблему, верно? Чем они отличаются, для каких типов задач один подход больше подходит, чем другой?
При манипулировании неизменяемыми структурами данных, в чем разница между помощником Clojure и линзами Haskell?
Ответы (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
.
Вы говорите о двух совершенно разных вещах.
Вы можете использовать линзу для решения тех же проблем, что и 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.
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
Этот вопрос несколько аналогичен вопросу о том, в чем разница между for
Clojure и монадами Haskell. Пока я буду подражать ответам: конечно, for
похожа на монаду List
, но монады гораздо более общие и мощные.
Но это как-то глупо, правда? Монады были реализованы в Clojure. Почему они не используются постоянно? В основе Clojure лежит другая философия обработки состояния, но он по-прежнему может заимствовать хорошие идеи из великих языков, таких как Haskell, в своих библиотеках.
Итак, конечно, assoc-in
, get-in
, update-in
и т. д. — это своего рода линзы для ассоциативных структур данных. И вообще существуют реализации линз в Clojure. Почему они не используются постоянно? Это разница в философии (и, возможно, жуткое ощущение, что со всеми сеттерами и геттерами мы будем делать еще одну Java внутри Clojure и каким-то образом в конечном итоге женимся на нашей матери). Но Clojure не стесняется заимствовать хорошие идеи, и вы можете видеть, как подходы, вдохновленные линзами, пробиваются в крутые проекты, такие как Om и Enliven.
Вы должны быть осторожны, задавая такие вопросы, потому что Clojure и Haskell, подобно сводным братьям и сестрам, которые занимают часть одного и того же места, вынуждены заимствовать друг у друга и немного ссориться из-за того, кто прав.