最近は Scala 周りのライブラリをよく勉強していて、ShapelessやCatsについて勉強する事が多い。 ところでMonocleというライブラリも気になっていたので、Shapelessの勉強の息抜きに軽く調べることにした。
Monocleとは
Monocleの公式ページによれば、以下のように書かれている:
Monocle is a Scala library which offers a simple yet powerful API to access and transform immutable data.
より端的な表現がページの一番上にも書かれている:
Access and transform immutable data
Monocleは、イミュータブルなデータ構造に対する使い勝手の良いgetter/setterを提供するものらしい。
Monocleをインストールする
Monocleのインストールのために、 build.sbtに以下のような依存性を追加する:
libraryDependencies ++= Seq(
"dev.optics" %% "monocle-core" % "3.1.0",
"dev.optics" %% "monocle-macro" % "3.1.0",
)
必要に応じてマクロアノテーションもインストールできるが、ここでは使わないことにする。
Monocleの各機能の紹介
Monocleはいくつかの主要な部品を持ち、それぞれに名前が付いている。
- Focus
- Iso
- Lens
- Prism
- Optional
- Traversal
これらから Focusを除いたものは Opticsと呼ばれていて、もともとは
Haskell あたりで生まれた概念らしい。これらの部品を組み合わせて、
Monocleはよくできたgetter/setterとして振る舞うということらしい。
まずは Focusから様子を見ていく。
Focus
ドキュメントによれば、 Focusは入門に最適とのことだ。 Focusはイミュータブルなオブジェクトのうち一部に絞り込んだgetter/setterを生成するマクロだ。
だからFocusという名前が付けられているわけだ。
試しに、ただのコンテナの働きをする Boxを定義して Focusを使ってみよう:
import monocle.syntax.all._ case class Box[A](x: A) val box = Box(42) box // => Box[Int](42) box.focus(_.x).replace(84) // => Box[Int](84) box.focus(_.x).modify(_ / 2) // => Box[Int](21)
おおまかな使い方がわかった。とにかく、case classに対して .focus()を呼び出し、
その対象を選択することで Focusが生成され、
あとはそれに対して操作することでイミュータブルなget/setが可能なのだ。
したがって次のようにも書ける:
val f = box.focus(_.x) f.replace(84) f.modify(_ / 2)
もちろん、元の boxには影響を及ぼさない。
ちなみにScala 3だとより複雑な操作が可能になっているようだが、ここでは割愛。
Iso
Isoとは、異なる2つの型を相互に情報を失うことなく変換するようなOpticsである。
例えば Listと Vectorとの相互変換だったり、 case class と Tupleとの相互変換といったかなり多様な Isoを考えることができる。
import monocle.Iso // Tuple2を交換するIso def swapIso[A, B] = Iso[(A, B), (B, A)](pair => (pair._2, pair._1))(pair => (pair._2, pair._1)) val pair = (42, "Foo") swapIso.get(pair) // => ("Foo", 42) swapIso.reverseGet(pair) // => 元に戻る // Seq[Seq[A]]を転置するIso def transposeIso[A] = Iso[Seq[Seq[A]], Seq[Seq[A]]](s => s.transpose)(s => s.transpose) val lis = Seq(Seq(1,2,3), Seq(4,5,6), Seq(7,8,9)) transposeIso.get(lis) // => Seq(Seq(1, 4, 7), Seq(2, 5, 8), Seq(3, 6, 9)) transposeIso.reverseGet(transposeIso.get(lis)) // => 元に戻る
Isoだけだと、あまり特徴が無い感じがする……。
Lens
さて、Opticsの主役である Lensの出番だ。 Lensを適用する対象となる型 Sと、その内部にある型 Aがあるとき、 Sの中の Aをget/setする方法が分かっているならば、これらを組み合わせて Lens[S,
A]を構成することができる。例えば、 Tuple2の先頭要素を操作する Lensを構成してみよう:
import monocle.Lens val headLens = Lens[(Int, String), Int](_._1)(a => pair => (a, pair._2)) val pair = (42, "Foo") headLens.get(pair) // => 42 headLens.replace(24)(pair) // => (24, "Foo")
getと replaceとが定義されたことにより、自動的に modifyが手に入る:
headLens.modify(_ * 3)(pair) // => (126, "Foo")
このように、Opticsをはじめとする型クラスの魅力は、一定の操作を実装すると自動的により便利な操作が無料で手に入ることだ。
より抽象化された便利な操作として、 modifyFというものがある。
大文字のFが登場することからもうっすら分かるように、よりファンクショナルな操作が導入される。 modifyFは、普通の関数 A => Aを受け取るかわりに A => F:
cats.Functorを受け取るものだ。
headLens.modifyF(head => List(head - 1, head, head + 1))(pair) // => List((41, "Foo"), (42, "Foo"), (43, "Foo"))
結構便利そうだ。他にも Futureが Functorであることを利用して、
構造の深い場所にある要素を非同期的に変更するという応用もできる。とにかく、 Lensが定義されていて Functorが定義できていればこのような不思議な操作が手に入るのはかなり不思議だ。
Lens同士の合成がサポートされている。
import monocle.Lens import monocle.macros.GenLens case class Box(x: Int) case class BigBox(box: Box) val boxLens= GenLens[Box](_.x) val bigBoxLens = GenLens[BigBox](_.box) def combinedLens = bigBoxLens andThen boxLens val boxMatryoshka = BigBox(Box(123)) combinedLens.get(boxMatryoshka) // => 123
Prism
さて、次は Prismだ。 Prismは直和型(CoproductとかSumとか呼ばれる)
に対して選択する作用を持った Optics だ。 Lensは直積型に対してその要素を選択するように振る舞ったのに対して、 Prismはそれを直和型に対して行う。
Prismは Lensと同じような型パラメータになっている。 ある直和型 Sがあり、その型の1つに Aがあるとき、 Sから Aを取り出す操作と、 Aを Sにする操作があるならば、 Prism[S, A]を構成できる。
ただし、 Aは失敗する可能性があるため、それぞれの操作の定義は以下のようになる:
getOption: S => Option[A] reverseGet: A => S
例えば、 Shapetraitに Circleと Rectとが所属しているとき、 Circleの半径を取り出すための Prismは以下の通りになる:
import monocle.Prism sealed trait Shape case class Circle(r: Int) extends Shape case class Rect(w: Int, h: Int) extends Shape val circleRadiusPrism = Prism.partial[Shape, Int] { case Circle(r) => r }(Circle) val c = Circle(42) val r = Rect(12, 34) circleRadiusPrism.getOption(c) // => Some(42) circleRadiusPrism.getOption(r) // => None
少しまとめてみよう。
- 型SとAとを常に相互変換するものが Iso である
- 型SとAとを相互変換するがAへの変換に失敗しうるものが Prism である
- 型SをAに変換でき、Sの一部をAで置換できるものが Lens である
これは https://the.igreque.info/posts/2015-06-09-lens-prism.html の受け売り。
失敗する可能性のある Isoを抽象化したものが Prismであるといえる。
ちなみに、 Prismは Lensではない(似ているけれど)。
Optional
さて、 Lensと Prismとを合体させたような Optics が Optionalだ。
Optionalは次の2つの関数から構成できる:
getOption: S => Option[A] replace: A => S => S
確かに、 Lensと Prismとの両方の特徴を備えているように見える。
ドキュメント上では、リストの先頭要素を操作する例が挙げられている:
import monocle.Optional val head = Optional[List[Int], Int] { case Nil => None case x :: xs => Some(x) }{ a => { case Nil => Nil case x :: xs => a :: xs } }
これにより、リストの長さにかかわらず、先頭要素を操作する(先頭要素がない場合は単に無視する) ような操作を定義できる。
ちなみに、 Optionを単一の要素を持つコンテナだと解釈して、
これを多要素に展開したものが Traversalだ(これは割愛)。
まとめ
構造と構造を変換する橋渡し役として Monocle が役立ちそうだ。例えば XML / HTML
を操作するライブラリを考えたりするときに使えるのではないかと思う。
中間的な構造の上での操作を定義しておき、中間的な構造への変換と逆変換とを( Isoなどで)
組み合わせることで効果的な記述ができるようになるかもしれない。
最近別に勉強している Shapeless というライブラリも、構造と構造との関係を問題領域にしている。 Web系のソフトウェアエンジニアをやっていると直接構造を操作するという機会はそんなにないのだが、 例えば JSON をどうこうするとか、 HTTP ヘッダをなんとかする、 といったときに役立たせることができるかもしれない。