独学大学情報学部

ただのノート。主にプログラミング。

Monocleとかいうのがありまして

この記事はScala Advent Calendar 2014の22日目です。
日付変わっちゃいました、すみません。

今回はちょっとMonocle触ってみました。

Monocleとは

Julien Truffaut氏がメインで開発してるScalaでLensなライブラリです。

最新安定版はv1.0.1(2014.12.22 現在)で、つい先日リリースされたばかりのピチピチです。

もともとはHaskellのLensパッケージがあって、それをScalaで実装してみたみたいな感じです。 MonocleではLens, Traversal, Optional, Prism, Isoを提供していて、今回はその中からLensとPrismを紹介します。

あと、Lensに関しては圏論的なバックグラウンドがあるそうですが全然詳しくないのでこの記事では触れないことにします。

Lensはどういう時に欲しくなるのか

LensはJavaなどで言うgetterやsetterを抽象化した概念で、不変性を保ちつつネストしたデータ構造に対するアクセスをLensの合成で表現できるようにしたものになります。どういう事か見ていきましょう。

公式READMEのサンプルをちょいとお借りして、下記のようなネストしたデータ構造があったとします。

case class Street(name: String)
case class Address(street: Street)
case class Company(address: Address)
case class Employee(company: Company)

さて、Streetのnameを不変性を保ったまま書き換えたいときにどういったアプローチがあるでしょうか? いろいろな方法があると思いますが、case classであればcopyメソッドを使うのがいいでしょう。

val employee = Employee(Company(Address(Street("chuodori"))))

// Streetのnameをcapitalizeするっ
employee.copy(
  company = employee.company.copy(
    address = employee.company.address.copy(
      street = employee.company.address.street.copy(
        name = employee.company.address.street.name.capitalize
      )
    )
  )
)

おっと、不変性を保ったばかりにとても冗長なコードになってしまいました。 ちなみにvarだった場合はご想像の通りさっきのコードに比べてすっきりかけます。

// もし、変数がvarで再代入可能だったら...
case class Street(var name: String)
case class Address(var street: Street)
case class Company(var address: Address)
case class Employee(var company: Company)

val employee = Employee(Company(Address(Street("chuodori"))))

val capitalizedName = employee.company.address.street.name.capitalize
employee.company.address.street.name = capitalizedName

scala> employee
res0: Employee = Employee(Company(Address(Street(Chuodori))))

ちょいと長いので二行に分けちゃいましたが、copyメソッドを使ってた時よりも遥かにすっきり直感的です。

あぁ...これでは「varで良くね?」って言われても返す言葉がありません.....。不変性を保ちつつ上記varの時と同様にすっきりかけないものでしょうか.....。

そう!ここでLensの出番です!!

MonocleでLens

まずは、対象のデータに対してLensを定義してやる必要があります。
MonocleでのLensの定義方法は以下の3種類です。

  1. 手動で明示的に定義する
  2. Lenser マクロを使う
  3. @Lenses アノテーションを使う

ではそれぞれ試してみましょう。 ※Lensの定義はREPL上ではできないのでsbtにMonocleの依存追加してcompileしてやります。*1

1. 手動で明示的に定義する

import monocle.Lens

object SampleLens {

  // 対象のデータに対するgetterとsetterを引数に渡します。
  val _name = Lens[Street, String](_.name)(str => s => s.copy(name = str))
  val _street = Lens[Address, Street](_.street)(s => a => a.copy(street = s))
  val _address = Lens[Company, Address](_.address)(a => c => c.copy(address = a))
  val _company = Lens[Employee, Company](_.company)(c => e => e.copy(company = c))

}

さぁ、定義ができたら'sbt console'でREPLを立ち上げて実行してみましょう。

scala> import SampleLens._
import SampleLens._

scala> val employee = Employee(Company(Address(Street("chuodori"))))
employee: sample.SampleLens.Employee = Employee(Company(Address(Street(chuodori))))

// 値取得
scala> (_company composeLens _address composeLens _street composeLens _name) get employee
res1: String = chuodori

// 値書き換え
scala> (_company composeLens _address composeLens _street composeLens _name).set("hogedori")(employee)
res2: sample.SampleLens.Employee = Employee(Company(Address(Street(hogedori))))

// 関数の適用(capitalize)
scala> (_company composeLens _address composeLens _street composeLens _name).modify(_.capitalize)(employee)
res3: sample.SampleLens.Employee = Employee(Company(Address(Street(Chuodori))))

これが冒頭で言っていた「データに対するアクセスをLensの合成で表現できる」って話です。 ちなみに「composeLens」のエイリアスとして「^|->」が定義されているので7文字分すっきりできます。

scala> (_company ^|-> _address ^|-> _street ^|-> _name).modify(_.capitalize)(employee)
res5: sample.SampleLens.Employee = Employee(Company(Address(Street(Chuodori))))

不変性を保ちつつ、すっきり書けるようになりました!

2. Lenserマクロを使う

v0.5.1より追加された機能です。 手動で定義するよりも簡単にLensを定義してやることができます。

import monocle.macro.Lenser

object SampleLenserMacro {

  val lenser = Lenser[Employee]

  val _name = lenser(_.name)
  val _street = lenser(_.street)
  val _address = lenser(_.address)
  val _company = lenser(_.company)

}

使い方は1. 手動で明示的に定義した場合と同じなので省略します。

3. @Lensesアノテーションを使う

もっとも簡単にLensを「生成」してやる方法です。 case classの宣言に対して@Lensesアノテーションをつけると各変数に対してLensが定義されます。やばいです。

import monocle.macro.Lenses

object SampleLensesMacro {

  @Lenses
  case class Street(name: String)

  @Lenses
  case class Address(street: Street)

  @Lenses
  case class Company(address: Address)

  @Lenses
  case class Employee(company: Company)

}

ただし、この方法を使うには色々注意が必要です。 内部的にはマクロパラダイスマクロアノテーションを使っていて、プラグインの追加が必要だったり、そもそもcase classの定義に手を加える必要があるので、既に定義済みのcase class(ライブラリの方で定義されてるものなど)に使えなかったりします。 一応、実験的な機能と捉える方がいいでしょう。

また、基本的な使い方は1, 2と同じですが、Lensの生成先がコンパニオンオブジェクト内なのでちょっと面倒になります。

scala> import SampleLensesMacro._
import SampleLensesMacro._

scala> import Street._, Address._, Company._, Employee._
import Street._
import Address._
import Company._
import Employee._

scala> val employee = Employee(Company(Address(Street("chuodori"))))
employee: sample.SampleLenses.Employee = Employee(Company(Address(Street(chuodori))))

scala> (company ^|-> address ^|-> street ^|-> name).modify(_.capitalize)(employee)
res5: sample.SampleLenses.Employee = Employee(Company(Address(Street(Chuodori))))

Prismもあるんだよ?

さて、冒頭で述べましたが、Monocleが提供してるのはLensだけではありません。 Prismもその一つです。Prismはざっくりイメージで言うと「失敗を表現できるようになったLens」です。*2

実際のコード見てもらった方が早いですね。 Lensの時と同様にPrismの定義はREPL上ではできないのでsbtにMonocleの依存追加してcompileしてやります。*3

import monocle.Prism
import scalaz.Maybe

object SamplePrism {

  val strToInt: Prism[String, Int] = 
    Prism { str: String => Maybe.fromTryCatchNonFatal(str.toInt) }(_.toString)
}

'sbt console'でREPLを立ち上げて試してみましょう。

scala> import SamplePrism._
import SamplePrism._

scala> strToInt getMaybe ("1")
res1: scalaz.Maybe[Int] = Just(1)

scala> strToInt getMaybe ("")
res2: scalaz.Maybe[Int] = Empty()

res2でtoIntに失敗した場合はEmpty()が帰ってきているのがわかります。
ちなみに、以下のような使い方もできます。

scala> strToInt.reverseGet(1)
res12: String = 1

scala> strToInt.modify(_ + 100)("1")
res13: String = 101

scala> strToInt.modify(_ + 100)("")
res14: String = ""

reverseGetでInt => Stringをさせることができます。*4
res13は内部的には"1"をIntに変換した後100を足して再度文字列にしています。何も知らずに見ると結構やばいですね。 なお、今回自分で上記のstrToIntを定義しましたが、Scalaの基本型に対するPrismは既にMonocle側で定義されてたりします。

まとめとか

この記事を書き上げる前にMonocle v1.0.1がリリースされました。 自分が触りだした時はv0.5.1でしたが、絶賛開発中の1.x系と比べると引数の順序とかpackage構成とか結構違っていて、 その辺のハマりどころとか書いて「もうすぐリリースされるはずなので、1.x系の安定版を待ちましょう!!」みたいな感じで締めようと思ってたんですけど、そんな必要なくなっちゃいました。安心してv1.0.1を使いましょう!!

とかいいつつ、まだまだ全然全く本当の美味さを把握できてる気がしないので引き続き試してみたいです、はい。

あ、あとargonautの中でも使われていて面白そうなんですけど、全然追えてないのでまたの機会に。*5

 
 


*1:定義時に「object Lens does not take type parameters.」とかってエラー吐く.....

*2:概念的にはLensとPrismはそれぞれ別な方向へIsoを特化させたものであり、「Lensを拡張したのがPrismである」というわけではありません。とはいえ、Monocle内でIsoをベースにLens, Prismが実装されているのかって言うとそうでもないです。Isoに関しては別の機会に...

*3:strToIntが「monocle.Prism.type does not take parameters」とかってエラー吐く.....

*4:Prism定義時、第二引数に渡した関数が呼ばれてます。

*5:今度こそ.....更新頻度増やすんだ.....