Как найти и изменить поле во вложенных классах?

Определены некоторые вложенные классы дел с List поля:

@Lenses("_") case class Version(version: Int, content: String)
@Lenses("_") case class Doc(path: String, versions: List[Version])
@Lenses("_") case class Project(name: String, docs: List[Doc])
@Lenses("_") case class Workspace(projects: List[Project])

И образец workspace:

val workspace = Workspace(List(
  Project("scala", List(
    Doc("src/a.scala", List(Version(1, "a11"), Version(2, "a22"))),
    Doc("src/b.scala", List(Version(1, "b11"), Version(2, "b22"))))),
  Project("java", List(
    Doc("src/a.java", List(Version(1, "a11"), Version(2, "a22"))),
    Doc("src/b.java", List(Version(1, "b11"), Version(2, "b22"))))),
  Project("javascript", List(
    Doc("src/a.js", List(Version(1, "a11"), Version(2, "a22"))),
    Doc("src/b.js", List(Version(1, "b11"), Version(2, "b22")))))
))

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

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace = {
  ???
}

Я буду использоваться следующим образом:

  val newWorkspace = addNewVersion(workspace, "scala", "src/b.scala", Version(3, "b33"))

  println(newWorkspace == Workspace(List(
    Project("scala", List(
      Doc("src/a.scala", List(Version(1, "a11"), Version(2, "a22"))),
      Doc("src/b.scala", List(Version(1, "b11"), Version(2, "b22"), Version(3, "b33"))))),
    Project("java", List(
      Doc("src/a.java", List(Version(1, "a11"), Version(2, "a22"))),
      Doc("src/b.java", List(Version(1, "b11"), Version(2, "b22"))))),
    Project("javascript", List(
      Doc("src/a.js", List(Version(1, "a11"), Version(2, "a22"))),
      Doc("src/b.js", List(Version(1, "b11"), Version(2, "b22")))))
  )))

Я не уверен, как это реализовать элегантно. Я пробовал с моноклем, но это не обеспечивает filter или же find, Мое неловкое решение:

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace = {
  (_projects composeTraversal each).modify(project => {
    if (project.name == projectName) {
      (_docs composeTraversal each).modify(doc => {
        if (doc.path == docPath) {
          _versions.modify(_ ::: List(version))(doc)
        } else doc
      })(project)
    } else project
  })(workspace)
}

Есть ли лучшее решение? (Можно использовать любые библиотеки, не только monocle)

3 ответа

Решение

Я только что расширил Quicklens с eachWhere метод для обработки такого сценария, этот конкретный метод будет выглядеть следующим образом:

import com.softwaremill.quicklens._

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace = {
  workspace
    .modify(_.projects.eachWhere(_.name == projectName)
             .docs.eachWhere(_.path == docPath).versions)
    .using(vs => version :: vs)
}

Мы можем реализовать addNewVersion с оптикой довольно красиво но есть гуча

import monocle._
import monocle.macros.Lenses
import monocle.function._
import monocle.std.list._ 
import Workspace._, Project._, Doc._

def select[S](p: S => Boolean): Prism[S, S] =
   Prism[S, S](s => if(p(s)) Some(s) else None)(identity)

 def workspaceToVersions(projectName: String, docPath: String): Traversal[Workspace, List[Version]] =
  _projects composeTraversal each composePrism select(_.name == projectName) composeLens
    _docs composeTraversal each composePrism select(_.path == docPath) composeLens
    _versions

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace =
  workspaceToVersions(projectName, docPath).modify(_ :+ version)(workspace)

Это будет работать, но вы могли заметить использование selectPrism который не предоставлен Monocle. Это потому что select не удовлетворяет Traversal законы, которые утверждают, что для всех t, t.modify(f) compose t.modify(g) == t.modify(f compose g),

Контрпримером является:

val negative: Prism[Int, Int] = select[Int](_ < 0)
(negative.modify(_ + 1) compose negative.modify(_ - 1))(-1) == 0

Тем не менее, использование select в workspaceToVersions полностью действителен, потому что мы фильтруем другое поле, которое мы модифицируем. Таким образом, мы не можем аннулировать предикат.

Вы можете использовать Монокль Index типа, чтобы сделать ваше решение более чистым и общим.

import monocle._, monocle.function.Index, monocle.function.all.index

def indexListBy[A, B, I](l: Lens[A, List[B]])(f: B => I): Index[A, I, B] =
  new Index[A, I, B] {
    def index(i: I): Optional[A, B] = l.composeOptional(
      Optional((_: List[B]).find(a => f(a) == i))(newA => as =>
        as.map {
          case a if f(a) == i => newA
          case a => a
        }
      )
    )
  }

implicit val projectNameIndex: Index[Workspace, String, Project] =
  indexListBy(Workspace._projects)(_.name)

implicit val docPathIndex: Index[Project, String, Doc] =
  indexListBy(Project._docs)(_.path)

Это говорит: я знаю, как искать проект в рабочей области, используя строку (имя), и документ в проекте по строке (путь). Вы могли бы также поставить Index примеры как Index[List[Project], String, Project], но так как ты не владеешь List это возможно не идеально.

Далее вы можете определить Optional который объединяет два поиска:

def docLens(projectName: String, docPath: String): Optional[Workspace, Doc] =
  index[Workspace, String, Project](projectName).composeOptional(index(docPath))

И тогда ваш метод:

def addNewVersion(
  workspace: Workspace,
  projectName: String,
  docPath: String,
  version: Version
): Workspace =
  docLens(projectName, docPath).modify(doc =>
    doc.copy(versions = doc.versions :+ version)
  )(workspace)

И вы сделали. На самом деле это не так кратко, как ваша реализация, но она состоит из более хорошо сочетаемых частей.

Другие вопросы по тегам