Posted in scala, monocle
Scala Lens Library, Monocle
Comment
Optics Library
Monocle 은 Optics 라이브러리입니다. 그게 무엇인고 하니,
Monocle is a Lens library, or more generally an Optics library where Optics gather the concepts of
Lens
,Traversal
,Optional
,Prism
andIso
. 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
Iso
는 isomorphism 을 나타냅니다. 저는 수학자도 아니요, 수학을 전공한것도 아니기 때문에 인용하고 간단히 넘어가겠습니다.
An isomorphism
u:: a -> b
is function that has an inverse. i.e, another functionv:: b -> a
such that the relationshipsu . 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)
Lens
와 Prism
을 설명하기 전에 Product
와 Sum (=Coproduct)
에 대해서 먼저 간단히 설명을 하면,
Product
는 복합 데이터 타입입니다. Tuple 이Product
의 한 예 인데,P: (A, B)
에 대해서P
의 경우의 수는A x B
만큼 됩니다.case class
,Map
,HList
(in Shapeless) 모두Product
입니다.- 반면
Sum
은 선택 가능한 타입입니다.sealed trait
,Enum
등이 모두 그 예입니다.
이걸 설명하는 이유는
Lens
가Product
에 대한 composable setter, getter 이고Prism
이Coproduct
에 대한 composable setter, getter 이기 때문이지요.
Lens
A
Lens
is an Optic used to zoom inside aProduct
스칼라 타입으로 나타내면 다음과 같습니다.
case class Lens[S, A](get: S => A)(set: A => S => S)
요약하면
get
은 복합타입S
에 대해A
를 돌려주고set
은A
를 받아 복합타입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)
이따 보실테지만 Iso
와 Lens
의 get
은 같고 set
은 비슷하므로 다음처럼 조합할 수 있습니다.
def isoToLens[S, A](iso: Iso[S, A]): Lens[S, A] =
Lens(iso.get)(a => _ => iso.reverseGet(a)))
Prism
A
Prism
is an Optic used to select part of aSum
type (also know asCoproduct
)
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
와는 달리 getter 가 Option
을 돌려줄 수 있는데요, 이것은 이전에 언급했듯이 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)
}