Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Monocleというライブラリについて軽く調べたメモ

最近は Scala 周りのライブラリをよく勉強していて、ShapelessやCatsについて勉強する事が多い。 ところでMonocleというライブラリも気になっていたので、Shapelessの勉強の息抜きに軽く調べることにした。

Monocleとは

www.optics.dev

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である。 例えば ListVectorとの相互変換だったり、 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")

getreplaceとが定義されたことにより、自動的に 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"))

結構便利そうだ。他にも FutureFunctorであることを利用して、 構造の深い場所にある要素を非同期的に変更するという応用もできる。とにかく、 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はそれを直和型に対して行う。

PrismLensと同じような型パラメータになっている。 ある直和型 Sがあり、その型の1つに Aがあるとき、 Sから Aを取り出す操作と、 ASにする操作があるならば、 Prism[S, A]を構成できる。

ただし、 Aは失敗する可能性があるため、それぞれの操作の定義は以下のようになる:

getOption: S => Option[A]
reverseGet: A => S

例えば、 Shapetraitに CircleRectとが所属しているとき、 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であるといえる。 ちなみに、 PrismLensではない(似ているけれど)。

Optional

さて、 LensPrismとを合体させたような Optics が Optionalだ。

Optionalは次の2つの関数から構成できる:

getOption: S => Option[A]
replace: A => S => S

確かに、 LensPrismとの両方の特徴を備えているように見える。 ドキュメント上では、リストの先頭要素を操作する例が挙げられている:

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

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?