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

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

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's update-in) обновить такие вложенные структуры?

7 ответов

Решение

Молнии

Застежка-молния Huet обеспечивает удобный обход и "мутацию" неизменной структуры данных. Скалаз предоставляет молнии для 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'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))

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

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

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

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

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

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
}

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

Просто хочу добавить, что проекты 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 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

Я искал, какая библиотека 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
)

Бесформенный трюк

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

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

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

Я просто пишу несколько 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)

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

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

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

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)
Другие вопросы по тегам