最近は 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
例えば、 Shape
traitに 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 ヘッダをなんとかする、 といったときに役立たせることができるかもしれない。