Более чистый способ обновления вложенных структур

124

Скажем, у меня есть следующие два case classes:

case class Address(street: String, city: String, state: String, zipCode: Int)
case class Person(firstName: String, lastName: String, address: Address)

и следующий экземпляр Personкласса:

val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg", 
                                           "Mumbai", 
                                           "Maharashtra", 
                                           411342))

Теперь , если я хочу , чтобы обновление zipCodeв rajто я должен сделать:

val updatedRaj = raj.copy(address = raj.address.copy(zipCode = raj.address.zipCode + 1))

С большим количеством уровней вложенности это становится еще более уродливым. Есть ли более чистый способ (что-то вроде Clojure update-in) для обновления таких вложенных структур?

missingfaktor
источник
1
Я предполагаю, что вы хотите сохранить неизменность, в противном случае просто вставьте var перед объявлением адреса Persons.
GClaramunt
8
@GClaramunt: Да, я хочу сохранить неизменность.
missingfaktor

Ответы:

94

Молнии

Программа Huet's Zipper обеспечивает удобный обход и «мутацию» неизменной структуры данных. Scalaz предоставляет молнии для Stream( scalaz.Zipper ) и Tree( scalaz.TreeLoc ). Оказывается, структура застежки-молнии автоматически выводится из исходной структуры данных способом, который напоминает символическое дифференцирование алгебраического выражения.

Но как это поможет вам с классами кейсов Scala? Что ж, Лукас Ритц недавно создал прототип расширения для scalac, которое будет автоматически создавать молнии для аннотированных классов case. Я воспроизведу его пример здесь:

scala> @zip case class Pacman(lives: Int = 3, superMode: Boolean = false) 
scala> @zip case class Game(state: String = "pause", pacman: Pacman = Pacman()) 
scala> val g = Game() 
g: Game = Game("pause",Pacman(3,false))

// Changing the game state to "run" is simple using the copy method:
scala> val g1 = g.copy(state = "run") 
g1: Game = Game("run",Pacman(3,false))

// However, changing pacman's super mode is much more cumbersome (and it gets worse for deeper structures):
scala> val g2 = g1.copy(pacman = g1.pacman.copy(superMode = true))
g2: Game = Game("run",Pacman(3,true))

// Using the compiler-generated location classes this gets much easier: 
scala> val g3 = g1.loc.pacman.superMode set true
g3: Game = Game("run",Pacman(3,true)

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

Между прочим, Лукас недавно опубликовал версию Pacman, программируемую пользователем через DSL. Однако не похоже, что он использовал модифицированный компилятор, поскольку я не вижу никаких @zipаннотаций.

Переписывание дерева

В других обстоятельствах вы можете применить какое-то преобразование ко всей структуре данных в соответствии с какой-либо стратегией (сверху вниз, снизу вверх) и на основе правил, которые соответствуют значению в некоторой точке структуры. Классический пример - преобразование AST для языка, возможно, для оценки, упрощения или сбора информации. Kiama поддерживает перезапись , посмотрите примеры в RewriterTests и посмотрите это видео . Вот отрывок, чтобы подогреть аппетит:

// Test expression
val e = Mul (Num (1), Add (Sub (Var ("hello"), Num (2)), Var ("harold")))

// Increment every double
val incint = everywheretd (rule { case d : Double => d + 1 })
val r1 = Mul (Num (2), Add (Sub (Var ("hello"), Num (3)), Var ("harold")))
expect (r1) (rewrite (incint) (e))

Обратите внимание, что для этого Kiama выходит за рамки системы типов.

ретроним
источник
2
Для тех, кто ищет коммит. Вот он: github.com/soundrabbit/scala/commit/… (я думаю ..)
IttayD
15
Эй, а где линзы?
Дэниел С. Собрал,
Я только что столкнулся с этой проблемой, и идея @zip звучит действительно фантастически, может быть, стоит даже зайти так далеко, что она есть во всех классах case? Почему это не реализовано? Линзы хороши, но с большими и многими классами / классами случаев это просто шаблон, если вам просто нужен сеттер и ничего особенного, как инкремент.
Johan S
186

Забавно, что никто не добавил линзы, поскольку они были СДЕЛАНЫ для таких вещей. Итак, вот справочная статья CS по этому поводу , вот блог, в котором кратко говорится об использовании линз в Scala, вот реализация линз для Scalaz, и вот некоторый код, использующий ее, что удивительно похоже на ваш вопрос. И, чтобы сократить количество шаблонов , вот плагин, который генерирует линзы Scalaz для классов корпусов.

Что касается бонусных очков, вот еще один вопрос SO, который касается линз, и статья Тони Морриса.

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

Итак, исходя из реализации, представленной в конце этого ответа, вот как вы бы сделали это с линзами. Сначала объявите линзы, чтобы изменить почтовый индекс в адресе и адрес в человеке:

val addressZipCodeLens = Lens(
    get = (_: Address).zipCode,
    set = (addr: Address, zipCode: Int) => addr.copy(zipCode = zipCode))

val personAddressLens = Lens(
    get = (_: Person).address, 
    set = (p: Person, addr: Address) => p.copy(address = addr))

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

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

Наконец, используйте эту линзу, чтобы изменить raj:

val updatedRaj = personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)

Или, используя синтаксический сахар:

val updatedRaj = personZipCodeLens.set(raj, personZipCodeLens(raj) + 1)

Или даже:

val updatedRaj = personZipCodeLens.mod(raj, zip => zip + 1)

Вот простая реализация, взятая из Scalaz, использованная для этого примера:

case class Lens[A,B](get: A => B, set: (A,B) => A) extends Function1[A,B] with Immutable {
  def apply(whole: A): B   = get(whole)
  def updated(whole: A, part: B): A = set(whole, part) // like on immutable maps
  def mod(a: A, f: B => B) = set(a, f(this(a)))
  def compose[C](that: Lens[C,A]) = Lens[C,B](
    c => this(that(c)),
    (c, b) => that.mod(c, set(_, b))
  )
  def andThen[C](that: Lens[B,C]) = that compose this
}
Дэниел С. Собрал
источник
1
Возможно, вы захотите обновить этот ответ описанием плагина линз Герольфа Зейтца.
missingfaktor
@missingfaktor Конечно. Ссылка на сайт? Я не знал о таком плагине.
Дэниел С. Собрал,
1
Код personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)такой же, какpersonZipCodeLens mod (raj, _ + 1)
ron
Однако @ron modне является примитивом для линз.
Дэниел С. Собрал,
Тони Моррис написал отличную статью по этому поводу. Я думаю, вам стоит указать это в своем ответе.
missingfaktor
11

Полезные инструменты для использования линз:

Сразу хочу добавить, что проекты Macrocosm и Rillit , основанные на макросах Scala 2.10, обеспечивают динамическое создание линз.


Использование Rillit:

case class Email(user: String, domain: String)
case class Contact(email: Email, web: String)
case class Person(name: String, contact: Contact)

val person = Person(
  name = "Aki Saarinen",
  contact = Contact(
    email = Email("aki", "akisaarinen.fi"),
    web   = "http://akisaarinen.fi"
  )
)

scala> Lenser[Person].contact.email.user.set(person, "john")
res1: Person = Person(Aki Saarinen,Contact(Email(john,akisaarinen.fi),http://akisaarinen.fi))

Использование Macrocosm:

Это работает даже для классов case, определенных в текущем запуске компиляции.

case class Person(name: String, age: Int)

val p = Person("brett", 21)

scala> lens[Person].name._1(p)
res1: String = brett

scala> lens[Person].name._2(p, "bill")
res2: Person = Person(bill,21)

scala> lens[Person].namexx(()) // Compilation error
Себастьян Лорбер
источник
Вы, наверное, скучали по Риллиту, что даже лучше. :-) github.com/akisaarinen/rillit
missingfaktor
Неплохо, проверим
Себастьян Лорбер
1
Кстати, я отредактировал свой ответ, включив в него Rillit, но я действительно не понимаю, почему Rillit лучше, они, кажется, предоставляют те же функции с той же многословностью на первый взгляд @missingfaktor
Себастьен Лорбер
@SebastienLorber Забавный факт: Rillit по-фински и означает линзы :)
Kai Sellgren
И Macrocosm, и Rillit, похоже, не обновлялись за последние 4 года.
Эрик ван Остен,
9

Я искал, какая библиотека Scala имеет лучший синтаксис и лучшую функциональность, и одна библиотека, не упомянутая здесь, - это монокль, который для меня был действительно хорош. Пример ниже:

import monocle.Macro._
import monocle.syntax._

case class A(s: String)
case class B(a: A)

val aLens = mkLens[B, A]("a")
val sLens = aLens |-> mkLens[A, String]("s")

//Usage
val b = B(A("hi"))
val newB = b |-> sLens set("goodbye") // gives B(A("goodbye"))

Это очень красиво, и есть много способов комбинировать линзы. Scalaz, например, требует большого количества шаблонов, он быстро компилируется и отлично работает.

Чтобы использовать их в своем проекте, просто добавьте это в свои зависимости:

resolvers ++= Seq(
  "Sonatype OSS Releases"  at "http://oss.sonatype.org/content/repositories/releases/",
  "Sonatype OSS Snapshots" at "http://oss.sonatype.org/content/repositories/snapshots/"
)

val scalaVersion   = "2.11.0" // or "2.10.4"
val libraryVersion = "0.4.0"  // or "0.5-SNAPSHOT"

libraryDependencies ++= Seq(
  "com.github.julien-truffaut"  %%  "monocle-core"    % libraryVersion,
  "com.github.julien-truffaut"  %%  "monocle-generic" % libraryVersion,
  "com.github.julien-truffaut"  %%  "monocle-macro"   % libraryVersion,       // since 0.4.0
  "com.github.julien-truffaut"  %%  "monocle-law"     % libraryVersion % test // since 0.4.0
)
Йохан С
источник
7

Shapeless делает свое дело:

"com.chuusai" % "shapeless_2.11" % "2.0.0"

с участием:

case class Address(street: String, city: String, state: String, zipCode: Int)
case class Person(firstName: String, lastName: String, address: Address)

object LensSpec {
      import shapeless._
      val zipLens = lens[Person] >> 'address >> 'zipCode  
      val surnameLens = lens[Person] >> 'firstName
      val surnameZipLens = surnameLens ~ zipLens
}

class LensSpec extends WordSpecLike with Matchers {
  import LensSpec._
  "Shapless Lens" should {
    "do the trick" in {

      // given some values to recreate
      val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg",
        "Mumbai",
        "Maharashtra",
        411342))
      val updatedRaj = raj.copy(address = raj.address.copy(zipCode = raj.address.zipCode + 1))

      // when we use a lens
      val lensUpdatedRaj = zipLens.set(raj)(raj.address.zipCode + 1)

      // then it matches the explicit copy
      assert(lensUpdatedRaj == updatedRaj)
    }

    "better yet chain them together as a template of values to set" in {

      // given some values to recreate
      val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg",
        "Mumbai",
        "Maharashtra",
        411342))

      val updatedRaj = raj.copy(firstName="Rajendra", address = raj.address.copy(zipCode = raj.address.zipCode + 1))

      // when we use a compound lens
      val lensUpdatedRaj = surnameZipLens.set(raj)("Rajendra", raj.address.zipCode+1)

      // then it matches the explicit copy
      assert(lensUpdatedRaj == updatedRaj)
    }
  }
}

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

simbo1905
источник
Обратите внимание, что в конечном итоге я использовал Lensкод из ответа Дэниела С. Собрала и поэтому избегал добавления внешней зависимости.
simbo1905
7

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

Я просто написал несколько modify...вспомогательных функций в структуре верхнего уровня, которые имеют дело с уродливой вложенной копией. Например:

case class Person(firstName: String, lastName: String, address: Address) {
  def modifyZipCode(modifier: Int => Int) = 
    this.copy(address = address.copy(zipCode = modifier(address.zipCode)))
}

Моя основная цель (упрощение обновления на стороне клиента) достигнута:

val updatedRaj = raj.modifyZipCode(_ => 41).modifyZipCode(_ + 1)

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

bluenote10
источник
4

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

Учитывая два примера классов case:

case class Address(street: String, city: String, state: String, zipCode: Int)
case class Person(firstName: String, lastName: String, address: Address)

и экземпляр класса Person:

val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg", 
                                           "Mumbai", 
                                           "Maharashtra", 
                                           411342))

вы можете обновить zipCode raj с помощью:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
Эрик ван Остен
источник