Posted in scala, monocle

Scala Lens Library, Monocle



Comment

Optics Library

MonocleOptics 라이브러리입니다. 그게 무엇인고 하니,

Monocle is a Lens library, or more generally an Optics library where Optics gather the concepts of Lens, Traversal, Optional, Prism and Iso. Monocle is strongly inspired by Haskell Lens.

Optics are a set of purely functional abstractions to manipulate (get, set, modify) immutable objects. Optics compose between each other and particularly shine with nested objects.


길고 복잡한데, 한 문장으로 요약하면 composable immutable getter, setter 입니다.


The Problem

일반적으로 스칼라에서 case class 를 복사할 때는 copy 메소드를 사용합니다. 그런데, case class 가 깊게 중첩되면 문제가 발생하기 시작합니다.

// https://github.com/julien-truffaut/Monocle

case class Street(name: String, ...)     // ... means it contains other fields  
case class Address(street: Street, ...)  
case class Company(address: Address, ...)  
case class Employee(company: Company, ...)

val employee: Employee = ...

employee.copy(  
  company = employee.company.copy(
    address = employee.company.address.copy(
      street = employee.company.address.street.copy(
        name = employee.company.address.street.name.capitalize // luckily capitalize exists
      }
    )
  )
)

이런상황에서 Monocle 을 활용할 수 있습니다.

Monocle Typeclass Hierarchy

Monocle 은 다음과 같은 타입클래스를 제공하는데요, 위쪽은 나중에 살펴보고 기본적인 타입클래스인 Iso, Lens, Optional, Prism 부터 살펴보겠습니다.


Iso

Isoisomorphism 을 나타냅니다. 저는 수학자도 아니요, 수학을 전공한것도 아니기 때문에 인용하고 간단히 넘어가겠습니다.

An isomorphism u:: a -> b is function that has an inverse. i.e, another function v:: b -> a such that the relationships u . v = id, v . u = id (Isomorphism)

즉 하나의 함수에 대해 그 역함수가 존재한다는 것인데 스칼라로 표기하면 다음과 같습니다.

case class Iso[S, A](get: S => A)(reverseGet: A => S)

// For all s: S, reverseGet(get(s)) == s
// For all a: A, get(reverseGet(a)) == a

아까 서두에서 composable immutable setter, getter 라고 언급했던 것 기억 나시나요? Iso 에서 제공되는 몇몇 함수를 이용해 여러 Iso 를 조합할 수 있습니다.

case class Iso[S, A](get: S => A)(reverseGet: A => S) {  
  def modify(m: A => A): S => S
  def reverse: Iso[A, S]
  def composeIso[B](other: Iso[A, B]): Iso[S, B]
}

그런고로 Iso[S, A] composeIso Iso[A, B]Iso[S, B] 입니다.

Iso Example

Iso 를 생성하는 법은 여러가지가(macro, annotation) 있지만, case class 생성자를 이용하는것부터 시작해보지요.

sealed trait Volume  
case class Bytes(size: Long) extends Volume  
case class KiloBytes(size: Long) extends Volume  
case class Megabytes(size: Long) extends Volume  

간단한 모델을 만들었습니다. 바이트와 킬로바이트, 메가바이트는 서로서로 변환 가능한 타입이므로 (isomorphic) Iso 만들어 보면,

def byteToKilo =  
    Iso[Bytes, KiloBytes](b => KiloBytes(b.size / 1024))(k => Bytes(k.size * 1024))

def kiloToMega =  
    Iso[KiloBytes, Megabytes](k => Megabytes(k.size / 1024))(m => KiloBytes(m.size * 1024))

버려지는 값을 허용할 수 있다 가정하면 kiloToByte, megaToByte 는 쉽게 만들 수 있습니다. 뒤집으면 되니까요.

def kiloToByte: Iso[KiloBytes, Bytes] = byteToKilo.reverse  
def megaToByte: Iso[Megabytes, Bytes] = kiloToMega.reverse composeIso kiloToByte  

Lens 와 섞으면 이렇게도 쓸 수 있습니다. (Lens 부분을 읽은 후에 다시 보시면 이해가 더 쉽습니다.)

// ref - https://github.com/julien-truffaut/Monocle/blob/master/example/src/test/scala/monocle/IsoExample.scala
case class Point(_x: Int, _y: Int)  
val pointToPair = Iso{l: Point => (l._x, l._y) }((Point.apply _).tupled)

test("Iso get transforms a S into an A") {  
  (Point(3, 5) applyIso pointToPair get) shouldEqual ((3, 5))
}

test("Iso reverse reverses the transformation") {  
  ((3, 5) applyIso pointToPair.reverse get) shouldEqual Point(3, 5)
}

test("Iso composition can limit the need of ad-hoc Lens") {  
  // here we use tuple Lens on Pair via pointToPair Iso
  (Point(3, 5) applyIso pointToPair composeLens first get)   shouldEqual 3
  (Point(3, 5) applyIso pointToPair composeLens first set 4) shouldEqual Point(4, 5)
}


Product, Sum(Coproduct)

LensPrism 을 설명하기 전에 ProductSum (=Coproduct) 에 대해서 먼저 간단히 설명을 하면,

  • Product복합 데이터 타입입니다. TupleProduct 의 한 예 인데, P: (A, B) 에 대해서 P 의 경우의 수는 A x B 만큼 됩니다. case class, Map, HList (in Shapeless) 모두 Product 입니다.
  • 반면 Sum선택 가능한 타입입니다. sealed trait, Enum 등이 모두 그 예입니다.

이걸 설명하는 이유는

  • LensProduct 에 대한 composable setter, getter 이고
  • PrismCoproduct 에 대한 composable setter, getter 이기 때문이지요.

Lens

Monocle - Lens

A Lens is an Optic used to zoom inside a Product

스칼라 타입으로 나타내면 다음과 같습니다.

case class Lens[S, A](get: S => A)(set: A => S => S)  

요약하면

  • get 은 복합타입 S 에 대해 A 를 돌려주고
  • setA 를 받아 복합타입 S 에 그 값을 세팅하고 돌려줍니다.

간단히 모델을 만들어 보지요.

case class Address(streetNumber: Int, streetName: String)  
case class Person(name: String, age: Int, address: Address)  

이제 Lens 를 만들어 보면 아래와 같습니다.

val _streetNumLens = Lens[Address, Int](_.streetNumber)(num => a => a.copy(streetNumber = num))  
val _streetNameLens = Lens[Address, String](_.streetName)(name => a => a.copy(streetName = name))  
val _addressLens = Lens[Person, Address](_.address)(a => p => p.copy(address = a))  

아까 위에서 언급했던 nested case class 문제를 풀기 위해 Lens 를 다음처럼 조합할 수 있습니다.

val _personStreetNum  = (_addressLens composeLens _streetNumLens)  
val _personStreetName = (_addressLens composeLens _streetNameLens)

val a1 = Address(10, "High Street")  
val p1 = Person("John", 20, a1)

val p2 = _personStreetName.modify(_ + " 2")(p1)

p2 shouldBe p1.copy(address = p1.address.copy(streetName = p1.address.streetName + " 2"))  

매크로를 이용하면 더욱 쉽게 생성할 수 있습니다.

import monocle.macros.GenLens

val _ageLens1 = GenLens[Person](_.age)  
val _ageLens2 = Lens[Person, Int](_.age)(a => p => p.copy(age = a))

val p2 = _ageLens1.modify(_ + 1)(p1)  
val p3 = _ageLens2.modify(_ + 1)(p1)

p2 shouldBe p3  

어노테이션도 사용할 수 있습니다.

// ref - http://julien-truffaut.github.io/Monocle//tut/lens.html

@Lenses case class Point(x: Int, y: Int)

val p = Point(5, 3)  
Point.x.get(p) shouldBe 5  
Point.y.set(0)(p) shouldBe Point(5, 0)  

이따 보실테지만 IsoLensget 은 같고 set 은 비슷하므로 다음처럼 조합할 수 있습니다.

def isoToLens[S, A](iso: Iso[S, A]): Lens[S, A] =  
  Lens(iso.get)(a => _ => iso.reverseGet(a)))

Prism

Monocle - Prism

A Prism is an Optic used to select part of a Sum type (also know as Coproduct)

Prism 을 스칼라로 표현하면 다음과 같습니다.

case class Prism[S, A](getOption: S => Option[A])(reverseGet: A => S)

// For all s: S, getOption(s) map reverseGet == Some(s) || None
// For all a: A, getOption(revereGet(a)) == Some(a)

Iso 와는 달리 getterOption 을 돌려줄 수 있는데요, 이것은 이전에 언급했듯이 Prism선택 가능한 타입 을 나타내는 Sum 에 대한 getter, setter 이기 때문입니다.

// ref - http://julien-truffaut.github.io/Monocle//tut/prism.html

sealed trait Day  
case object Monday    extends Day  
case object Tuesday   extends Day  
case object Wednesday extends Day  
case object Thursday  extends Day  
case object Friday    extends Day  
case object Saturday  extends Day  
case object Sunday    extends Day

"Prism Basics" in {   
  /* Since `Tuesday is a singleton, it is isomorphic to `Unit` */
  val _tuesday = Prism[Day, Unit] {
    case Tuesday => Some(())
    case _       => None
  }(_ => Tuesday)

  _tuesday.reverseGet(()) shouldBe Tuesday
  _tuesday.getOption(Monday) shouldBe None
  _tuesday.getOption(Tuesday) shouldBe Some(())
}

원래는 매크로를 이용하면 더 쉽게 생성할 수 있습니다.

val tuesday = GenPrism[Day, Tuesday.type] composeIso GenIso.unit[Tuesday.type]

tuesday.reverseGet(()) shouldBe Tuesday  
tuesday.getOption(Monday) shouldBe None  
tuesday.getOption(Tuesday) shouldBe Some(())  

Monocle 에 포함되어 있는 Lens, Prism 과 이렇게 섞어 쓸 수 있습니다.

// ref - http://julien-truffaut.github.io/Monocle//tut/prism.html
sealed trait LinkedList[A]  
case class Nil[A]() extends LinkedList[A]  
case class Cons[A](head: A, tail: LinkedList[A]) extends LinkedList[A]

def _nil[A] = Prism[LinkedList[A], Unit]{  
  case Nil()      => Some(())
  case Cons(_, _) => None
}(_ => Nil())

def _cons[A] = Prism[LinkedList[A], (A, LinkedList[A])]{  
  case Nil()      => None
  case Cons(h, t) => Some((h, t)) 
}{ case (h, t) => Cons(h, t)}

import monocle.function.Fields._ /* first, second */  
import monocle.std.tuple2._      /* tuple2 instance */

(_cons[Int] composeLens first).set(5)(l1) shouldBe
  Cons(5,Cons(2,Cons(3,Nil())))

(_cons[Int] composeLens first).set(5)(l2) shouldBe
  Nil()

Optional

Iso     [S, A] (S => A)          (A => S)  
Prism   [S, A] (S => Option[A])  (A => S)  
Lens    [S, A] (S => A)          ((A, S) => S)  
Optional[S, A] (S = Optional[A]) ((A, S) => S)  

Optional 은 스칼라로 나타내면 다음과 같습니다.

case class Optional[S, A](getOption: S => Option[A])(set: (A, S) => S)

// getOption(s) map set(_, s) == Some(s)
// getOption(set(a, s)) == Some(a) | None  

슬슬 감이 오시죠?

def cons[A]: Prism[List[A], (A, List[A])]  
def first[A, B]: Lens[(A, B), A]  
def head[A]: Optional[List[A], A] = cons compose first  
def void[S, A]: Optional[S, A] = (s => None)((a, s) => s)  
def index[A](i: Int): Optional[List[A], A] =  
  if (i < 0) void
  else if (i == 0) head
  else cons compose second compose index(i - 1)

이렇게 정의하면, 아래처럼 쓸 수 있습니다.

Optional.void.getOption("Hello") shouldBe None  
Optional.void.set(1)("Hello") shouldBe "Hello"  
Optional.void.setOption(1)("Hello") shouldBe None

val l = List(1, 2, 3)  
(l applyOptional index(-1) getOption) shouldBe None
(l applyOptional index(1) getOption) shouldBe Some(2)
(l applyOptional index(5) getOption) shouldBe None

(l applyOptional index(1) set(10)) shouldBe List(10, 2, 3)


HttpRequest Example

이제 조금 더 긴 예제를 살펴볼까요?

/* ref - http://www.slideshare.net/JulienTruffaut/beyond-scala-lens */
/* https://github.com/1ambda/Monocle/commit/234097ce1f8601eab8ab47e6610d56aea59acce4 */
class HttpRequestSpec extends WordSpec with Matchers {  
  import HttpRequestSpec._

  val r1 = HttpRequest(
    GET,
    URI("localhost", 8080, "/ping", Map("hop" -> "5")),
    Map("socket_timeout" -> "20", "connection_timeout" -> "10"),
    "")

  val r2 = HttpRequest(
    POST,
    URI("gooogle.com", 443, "/search", Map("keyword" -> "monocle")),
    Map.empty,
    "")

  val method = GenLens[HttpRequest](_.method)
  val uri = GenLens[HttpRequest](_.uri)
  val headers = GenLens[HttpRequest](_.headers)
  val body = GenLens[HttpRequest](_.body)

  val host = GenLens[URI](_.host)
  val query = GenLens[URI](_.query)

  val get: Prism[HttpMethod, Unit] = GenPrism[HttpMethod, GET.type] composeIso GenIso.unit[GET.type]
  val post = GenPrism[HttpMethod, POST.type] composeIso GenIso.unit[POST.type]

  "get and post" in {
    (method composePrism get).isMatching(r1) shouldBe true
    (method composePrism post).isMatching(r1) shouldBe false
    (method composePrism post).getOption(r2) shouldBe Some(())
  }

  "host" in {
    (uri composeLens host).set("google.com")(r2) shouldBe
      r2.copy(uri = r2.uri.copy(host = "google.com"))
  }

  "query using index" in {
    val r = (uri
      composeLens query
      composeOptional index("hop")
      composePrism stringToInt).modify(_ + 10)(r1)

    r.uri.query.get("hop") shouldBe Some("15")
  }

  "query using at" in {

    /**
     *  `at` returns Lens[S, Option[A]] while `index` returns Optional[S, A]
     *  So that we need the `some: Prism[Option[A], A]` for further investigation
     */
    val r = (uri
      composeLens query
      composeLens at("hop")
      composePrism some
      composePrism stringToInt).modify(_ + 10)(r1)

    r.uri.query.get("hop") shouldBe Some("15")
  }

  "headers" in {
    val r = (headers composeLens at("Content-Type")).set(Some("text/plain; utf-8"))(r2)
    r.headers.get("Content-Type") shouldBe Some("text/plain; utf-8")
  }

  "headers with filterIndex" in {
    val r = (headers
      composeTraversal filterIndex { h: String => h.contains("timeout") }
      composePrism stringToInt).modify(_ * 2)(r1)

    println(r)
    r.headers.get("socket_timeout") shouldBe Some("40")
    r.headers.get("connection_timeout") shouldBe Some("20")
  }
}

object HttpRequestSpec {  
  sealed trait HttpMethod
  case object GET   extends HttpMethod
  case object POST  extends HttpMethod

  case class URI(host: String, port: Int, path: String, query: Map[String, String])
  case class HttpRequest(method: HttpMethod, uri: URI, headers: Map[String, String], body: String)
}


References

Author

1ambda

Functional, Scala, Akka, Rx and Haskell