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

118

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

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) для обновления таких вложенных структур?

  • 1
    Я предполагаю, что вы хотите сохранить неизменность, в противном случае просто добавьте переменную перед объявлением адреса Persons.
  • 7
    @GClaramunt: Да, я хочу сохранить неизменность.
Теги:
case-class
zipper

7 ответов

94
Лучший ответ

Молния

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

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

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 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.

Перезапись дерева

В других случаях вам может потребоваться применить некоторое преобразование по всей структуре данных в соответствии с некоторой стратегией (сверху вниз, снизу вверх) и на основе правил, которые сопоставляются со значением в какой-то момент в структуре. Классический пример - преобразование АСТ для языка, возможно, для оценки, упрощения или сбора информации. Kiama поддерживает Rewriting, см. примеры в 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 выходит за пределы системы типов для достижения этой цели.

  • 0
    о боже ... upvoted :-)
  • 1
    +1 за молнии, являющиеся вашим первым ответом на это.
Показать ещё 4 комментария
183

Забавно, что никто не добавил линзы, так как они были созданы для такого рода вещей. Итак, здесь является справочным документом CS на нем, здесь - это блог, который кратко коснулся линз, используемых в Scala, здесь - это реализация объективов для Scalaz и здесь - это какой-то код, использующий его, который выглядит на удивление похожим на ваш вопрос. И, чтобы срубить плиту котла, вот плагин, который генерирует объективы Scalaz для классов case.

Для бонусных очков здесь другой S.O. вопрос, касающийся линз, и статья Тони Морриса.

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

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

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
    Возможно, вы захотите обновить этот ответ описанием плагина линз Герольфа Зейца.
  • 0
    @missingfaktor Конечно. Ссылка на сайт? Я не знал о таком плагине.
Показать ещё 9 комментариев
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))

Использование макрокосма:

Это даже работает для классов 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
  • 0
    Вы, вероятно, скучали по Rillit, что даже лучше. :-) github.com/akisaarinen/rillit
  • 0
    Здорово, проверим что
Показать ещё 3 комментария
7

Безшовный делает трюк:

"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)
    }
  }
}

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

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

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

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"))

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

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

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
)
5

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

Я просто хочу написать несколько вспомогательных функций 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)

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

2

Возможно, 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)

Ещё вопросы

Сообщество Overcoder
Наверх
Меню