ひらめの日常

日常のメモをつらつらと

【Scala】Monocleを使ってみたメモ

何の記事か

こちらの『Scala Design Patterns』の11章前半です。

Lensパターンの実装方法の一つとして scalaz.Lens が紹介されていましたが、他に紹介されていた Monocle を使って書き換えてみようというメモです。

Lensパターンとは

Lensパターンは、ネストが深くなっているオブジェクトの一部だけプロパティを書き換えたい、その一方でイミュータブルなオブジェクトを使用したいときなどに使われます。例えば以下のようなオブジェクトで、User から user.company.address.city.country.code を書き換えようとすると大量のコードを書く必要が出てきます。

classDiagram
  class User {
    +name: String
    +company: Company
    +address: Address
  }
  class Company {
    +name: String
    +address: Address
  }
  class Address {
    +number: Int
    +street: String
    +city: City
  }
  class City {
    +name: String
    +country: Country
  }
  class Country {
    +name: String
    +code: String
  }
  
  User *-- Company
  User *-- Address
  Company *-- Address
  Address *-- City
  City *-- Country

例えばこんな感じにプロパティを一つ書き換えただけなのに、 Country を含むオブジェクトを全てcopyして作り直す必要があります。

val userFixed = user.copy(
  company = user.company.copy(
    address  = user.company.address.copy(
      city = user.company.address.city.copy(
        country = user.company.address.city.country.copy(
          code = user.company.address.city.country.code.toUpperCase
        )
      )
    )
  )
)

そこで役に立つのがLensパターンのようです。変更したいプロパティにレンズを通してフォーカスするイメージらしいのですが、具体的なLensパターンの実装自体は未調査です。

scalaz.Lens

本の中でLensパターンを実現しているライブラリとしてまず紹介されているのは、scalaz.Lens です。Lens[A, B]LensFamily[A, A, B, B]エイリアスとなっていました。 LensFamily[A1, A2, B1, B2] は更新前後で型が変わる場合に対応しているのですが、変わらない場合は基本的に Lens[A, B] を使うのが良さそうです。

/**
 * A Lens Family, offering a purely functional means to access and retrieve
 * a field transitioning from type `B1` to type `B2` in a record simultaneously
 * transitioning from type `A1` to type `A2`.  [[scalaz.Lens]] is a convenient
 * alias for when `A1 === A2`, and `B1 === B2`.
 *
 * The term ''field'' should not be interpreted restrictively to mean a member of a class. For example, a lens
 * family can address membership of a `Set`.
 *
 * @see [[scalaz.PLens]]
 *
 * @tparam A1 The initial type of the record
 * @tparam A2 The final type of the record
 * @tparam B1 The initial type of the field
 * @tparam B2 The final type of the field
 */
sealed abstract class LensFamily[A1, A2, B1, B2]
type Lens[A, B] = LensFamily[A, A, B, B]

Lensのgetterとsetterを Lens.lensu(setter, getter) で定義してあげます。lensu の関数定義は以下の通りです。

def lensu[A, B](set: (A, B) => A, get: A => B): Lens[A, B] 

使用する側ではUser -> Company のように、クラス -> クラスのプロパティ という感じで順にアクセスする方法を定義します。

object User {
  val userCompany: Lens[User, Company] = Lens.lensu[User, Company](
    (u, company) => u.copy(company = company),
    _.company
  )

  val userAddress: Lens[User, Address] = Lens.lensu[User, Address](
    (u, address) => u.copy(address = address),
    _.address
  )

  val companyAddress: Lens[Company, Address] = Lens.lensu[Company, Address](
    (c, address) => c.copy(address = address),
    _.address
  )

  val addressCity: Lens[Address, City] = Lens.lensu[Address, City](
    (a, city) => a.copy(city = city),
    _.city
  )

  val cityCountry: Lens[City, Country] = Lens.lensu[City, Country](
    (c, country) => c.copy(country = country),
    _.country
  )

  val countryCode: Lens[Country, String] = Lens.lensu[Country, String](
    (c, code) => c.copy(code = code),
    _.code
  )
}

そして、andThenエイリアスである >=> を使って繋げてます。こうすることで、User から Country.code までたどることができます。

  val userCompanyCountryCode: Lens[User, String] = userCompany >=> companyAddress >=> addressCity >=> cityCountry >=> countryCode

// こっちは compose を使ったもの。表記が逆になるだけでやってることは同じ。
  val userCompanyCountryCodeCompose: Lens[User, String] = countryCode <=< cityCountry <=< addressCity <=< companyAddress <=< userCompany

Monocle

本の中で具体的に紹介されていたのは scalaz.Lens でしたが、より便利なライブラリとしてお勧めされていた Monocle · Access and transform immutable data を使って書き換えてみようと思います。

まず、Monocleはサイトを見ると Scala 2.13 以降に対応しているようなので、サンプルプロジェクトのScalaのバージョンを2.12から上げます。ScalaTestやMockitoなどのプロジェクトに含まれている他のライブラリのバージョンも、Scalaのバージョンに追従して上げる必要がありました。

scalaVersion := "2.13.8"

libraryDependencies ++=  {
  Seq(
    ...
    "dev.optics" %% "monocle-core"  % "3.1.0",
    "dev.optics" %% "monocle-macro" % "3.1.0",
    ...
  )
}

基本的にはサイトに書いてある通りで、以下のような流れになります。

  • foo.focusfoo オブジェクトの持っているプロパティ内部に注目
  • focus の中で指定したプロパティを、 modify によって変更
  • 変更後のオブジェクトを取得

簡単ですね。

  val ivanFixed = ivan.focus(_.company.address.city.country.code).modify(_.toUpperCase)

暗黙的に AppliedFocusOps に変換しているらしいのですが、中身はマクロで書かれていて理解はまだできませんでした。

出力結果を見ると確かに UK と大文字になっていることが確認できます。

User(Ivan,Company(Castle Builders,Address(1,Buckingham Palace Road,City(London,Country(United Kingdom,uk)))),Address(1,Geneva Lake,City(geneva,Country(Switzerland,CH))))
Capitalize UK code...
User(Ivan,Company(Castle Builders,Address(1,Buckingham Palace Road,City(London,Country(United Kingdom,UK)))),Address(1,Geneva Lake,City(geneva,Country(Switzerland,CH))))