Lambdaカクテル

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

Invite link for Scalaわいわいランド

Lensを始めとするOpticsがプログラミングをどう変えるか / 複雑なデータのモデリングをサボるには

仕事でLensを使う機会があった。Lensは複雑で入り組んだデータ構造の読み書きに非常に効果的な手法であるにもかかわらず、関数型プログラマ以外にはあまり知られていないように思える。

そこでこの記事では、Lensとは何なのか、なにが良いのか、具体的にどのようなケースでLensが役立ったか、そしてLensの亜種について紹介する。業務でも使ってます!

AIくんが考えるLens

前提条件

この記事ではLensを紹介する言語としてScalaを用い、Lensを実現するライブラリとしてMonocleを利用する。本筋とはあまり関係がないが、例示するデータの表現のためにJSONライブラリのuPickleを利用する。

www.optics.dev

全てのコードスニペットはScala CLI*1のScala Scriptで表現され、以下のようなヘッダがついている前提で書かれている:

//> using scala 3.3.3
//> using dep "dev.optics::monocle-core::3.1.0"
//> using dep "com.lihaoyi::upickle::3.2.0"

import monocle.syntax.all._
import upickle.default._

Lensとは

できるだけ形式的な定義を省いて直感的に説明すると、Lensとはある対象に対するgetterとsetterの組を、合成して取り回しやすいようにお膳立てしたものである。オブジェクトAのxフィールドに対するLens、また別のオブジェクトBのyフィールドに対するLens、のように、唯一のLensといったものはなく、注目する部分の単位ごとにLensは存在する。例えばオブジェクトの特定のフィールドを考えることもできるし、おもしろい例だと、数列に対する平均値や分散にもLensを考えることができる(getもsetもできるため)*2。しかも、型安全だ。

Lensができることは以下の通りである。

  • getする
    • getterとsetterの組なので当然
  • setする
    • getterとsetterの組なので当然
  • modify(getした結果をもとに関数に通してset)する
    • getとsetがあるので自動的に得られる
  • Lens同士は合成できる

最後の1つを除けば、getterとsetterがあるのだから当然の話になっている。Lensがとても効果的なのは、合成できるという特性を持っているからだ。この特性のおかげで、とても素晴らしいことが起こる。

Lensの使いどころ

以下のようなJSONオブジェクトがあるとする。DBに保存されていたり、APIが返してきたりする。

{
  "index": 0,
  "tweet": "こんにちは",
  "createdAt": "2024-01-01T00:00:00+09:00",
  "hashTag": [
    {
      "id": 42,
      "name": "greeting",
      "trending": true
    }
  ]
}

これは、どこかのSNSサービスに投稿されたデータの1つという想定だ。現実には、これの何倍も大きな、たくさんのフィールドを抱えたデータがやってくる。

さて、今日はデータのマイグレーションのためにcreatedAtフィールドをUNIX Timeに変換しなければならないとしよう。

データをモデリングする場合 (とても つらい)

一般的なやり方では、Tweetといった感じの型を定義して、JSONをこれに変換し、変換後のデータ型も定義して、最終的にJSONに戻すことになるだろう:

case class Tweet(
  index: Long,
  tweet: String,
  createdAt: String,
  hashTag: Seq[HashTag],
)

case class HashTag(
  id: Long,
  name: String,
  trending: Boolean,
)

case class TweetAfterProcess(
  index: Long,
  tweet: String,
  createdAt: String,
  hashTag: Seq[HashTag],
)

恐ろしいことに、既に3つもcase classを定義している(データから型を逆算して考えるのはもううんざりだ)。もっと恐ろしいのは、これらのうちの殆どは利用されないということだ:

val dataJson = "{...}"

// JSONからcase classに写すために必要な定義を自動でやらせる
import upickle.default.{ReadWriter => RW, macroRW}
object Tweet { implicit val rw: RW[Big] = macroRW }
object HashTag { implicit val rw: RW[Big] = macroRW }
object TweetAfterProcess { implicit val rw: RW[Big] = macroRW }

// readするとJSON文字列がパースされ、Tweet型に変換される
val data = read[Tweet](dataJson)

val createdAt = java.time.OffsetDateTime.parse(data.createdAt)
val createdAtUnix = createdAt.toEpochSecond()

val processedData = TweetAfterProcess(
  data.index,
  data.tweet,
  createdAtUnix.toString,
  data.hashTag,
)
val processedDataJson = write(processedData) // => {...}

なんとか、createdAtフィールドをUNIX Timeに変換することができた。

でも何か変だ。本当に使っているデータは、createdAtフィールドだけのはずだ。しかし我々はHashTagのモデリングもしなければならなくなった。データを読み取って書き込むのだから、書き込むためのデータはモデリングしなければならない……。

でも、このデータがもっと奥にネストしていたら?データが配列の中に収まっていて、その全てに変換を行わないといけないとしたら?考えただけで頭が痛くなる。悲しいことに、実世界のデータはだいたいそうなっているのだ。

もっと動的な言語を使うという手もある。だがしかし、データがどんどん入り組んでくるにつれ、型がないととても辛いことになるということを、誰もが知っている……。

Lensを使う場合: Lensはアクセスパスである

さて、Lensで同じことをしてみよう。Lensを構成するためには、getterとsetterの組を用意してやればいい。ここでは、uPickleが提供するJSON型*3に対するgetterとsetterを定義しよう。

def getter(json: ujson.Value): String = json.obj("createdAt").str

// 第一引数にsetしたい値、第二引数にsetする先のオブジェクトが来るようにする
def setter(s: String)(json: ujson.Value): ujson.Value = {
  // ujson.copyはJSONを複製する(元データを破壊したくないため)
  val j = ujson.copy(json)
  j.obj("createdAt") = s
  j
}

Lensを作るには、monocle.Lensにgetterとsetterとを渡せばいい:

import monocle.Lens
val createdAtLens = Lens[ujson.Value, String](getter)(setter)

これでcreatedAtフィールド用のLensができた。Lensにはgetreplaceメソッドが生えているので、パースしたJSONのcreatedAtフィールドを操作できるようになる。Lensの面白いところは、必ずしも操作対象のデータモデリングをしなければならないわけではないので、createdAtフィールドがあるJSONでさえあればうまく動作するところだ:

val dataJson = """{"createdAt":"2024-01-01T00:00:00+09:00"}"""
val data = ujson.read(dataJson)

createdAtLens.get(data)
// => "2024-01-01T00:00:00+09:00"

// ujson.writeはujson.ValueをStringに変換する
ujson.write(
  createdAtLens.replace("foo")(data)
)
// => {"createdAt":"foo"}

createdAtLensを使ってcreatedAtフィールドを操作できるようになった。

では、UNIX Timeに変換するという作業を実装してみよう:

val dataJson = """
{
  "index": 0,
  "tweet": "こんにちは",
  "createdAt": "2024-01-01T00:00:00+09:00",
  "hashTag": [
    {
      "id": 42,
      "name": "greeting",
      "trending": true
    }
  ]
}
"""

val data = ujson.read(dataJson)
val processed = createdAtLens.modify(
  java.time.OffsetDateTime.parse(_).toEpochSecond().toString
)(data)
ujson.write(processed)
// => { ..., "createdAt": "1704034800", ...}

これだけだ。JSONをパースして、Lensを使ってフィールドを変換し、またJSONに戻した。しかも殆んどの部分で型安全だ。

ここでは触れなかったが、case classのようなよくある対象に対しては、Lensを自動的に生成するマクロが用意されているので、これを使ってもいい。

Lensと愉快な仲間たち

冒頭でLensはgetterとsetterとの組であると書いた。しかし現実にはデータのset/getには様々なバリエーションがあるため、対応する変種が用意されている(名前はMonocleのものを利用しているが、他のライブラリでもだいたい同じはず):

  • Iso: 情報を落とさずに相互に変換できるもの
    • 例: java.time.LocalDateTimeとUNIX Time(精度を無視した場合)
    • 用途: いったんUNIX Timeに変換してから一定秒を足し、またLocalDateTimeに戻す
    • 使いやすい形式にして処理してから戻すというメンタルモデル
  • Optional: getした結果がOption[A]になりうるもの
    • 例: 辞書構造
    • 用途: フィールドがあるならその値を使って処理し、ないなら無視する
    • あるかどうか分からないけれど、よしなに処理したいというメンタルモデル
  • Prism: getした結果の型がいくつかある場合
    • 例: JSON、ただし同じフィールドに文字列か数値が入っている
    • 用途: JSONのフィールドが異種混交(heterogeneous)になっているとき、全パターンに対応する処理を割当てたい
    • 場合ごとの処理を束ねて統一的に扱いたいというメンタルモデル
  • Traversal: getした結果がコレクションになっているもの
    • get/set操作はコレクションの全要素に対して作用する
    • 例: JSON配列
    • 用途: 全ての要素に対してset/getを行う
    • 配列に同じモデルが入ってるとき、それら全てを統一的に扱いたいというメンタルモデル

これらはまとめてOpticsと呼ばれている。これらは全て、あるデータへのアクセスパスを統一的に表現するという目的のために用意されている。組み合わせることで、現実の世界で遭遇する込み入ったデータへのアクセス能力が大きく拡大することだろう。

Lensの合成 -- andThenでひっつけよう

ここまで読んできても、わざわざLensを構成せずに手でやればいいのでは?くらいにしか思わないはずだ。Lensが真価を発揮するのは、Lensが合成できるという特性を使うときだ。

Lens(とその他のOptics)は、andThenメソッドを提供しており、バリエーション豊かな組み合わせで互いに合成できるようになっている。Monocle以外のライブラリ/言語でも、同じような名前で提供されているはずだ:

import monocle.Iso
import monocle.Lens

val jsonString = """[
  { "id": 42, "name": "windymelt", "likes": ["sushi", "beer"] },
  { "id": 43, "name": "rainymelt", "likes": ["book"] },
  { "id": 44, "name": "sunnymelt", "likes": ["football"] },
  { "id": 45, "name": "snowymelt", "likes": ["skate"] }
]"""

// ujson.ArrとListは情報を落とさずに相互変換可能なのでIsoを構成できる
val arrToListIso =
  Iso[ujson.Arr, List[ujson.Value]](_.arr.toList)(ujson.Arr(_*))

// likesフィールドのgetterとsetterとを定義できるので、Lensを構成できる
val likesLens = Lens[ujson.Value, ujson.Arr](_.obj("likes").arr) {
  v => original =>
    val j = ujson.copy(original)
    j.obj("likes") = v
    j
}

// これらのOpticsを合成すると、「全要素のうちlikes要素の全要素」という込み入った場所に統一的にアクセスする手段を得られる
// Monocle 3からは、Opticsの合成にはandThenを使う
val composed =
  arrToListIso
    .each // eachは自動的にTraversalを導出してくれる機構
    .andThen(likesLens)
    .andThen(arrToListIso)
    .each

// modifyを利用して該当部分だけ加工する
val json = ujson.read(jsonString).arr
val got = composed
  .modify(_.str.toUpperCase())(json)

println(got)

これを実行すると以下のような出力が得られる(整形済み):

[
  {"id":42,"name":"windymelt","likes":["SUSHI","BEER"]},
  {"id":43,"name":"rainymelt","likes":["BOOK"]},
  {"id":44,"name":"sunnymelt","likes":["FOOTBALL"]},
  {"id":45,"name":"snowymelt","likes":["SKATE"]}
]

「配列に収められたオブジェクトの特定フィールドに含まれている全ての要素」にtoUpperCase()を適用できた。ここに至るまでに一切モデリングをしていないにもかかわらず、非常に強力なデータ処理が行えることがわかる。

Lensを使わない場合、おそらく各オブジェクトのデータモデリングをしなければならないか、JSONに対して直接ループを回したりすることになっていたはずだ。データに対して直接操作を加えると再利用性を下げてしまい、生産性も悪化してしまう。対してLensは「特定のデータへのアクセスパス」そのものの表現であるため、後から再利用したり、変形して使うことができる。

ほかにもmodifyのかわりにreplaceを使うと、全ての要素を特定の要素で置換できる:

val got = composed
  .replace("secret")(json)
[
  {"id":42,"name":"windymelt","likes":["secret","secret"]},
  {"id":43,"name":"rainymelt","likes":["secret"]},
  {"id":44,"name":"sunnymelt","likes":["secret"]},
  {"id":45,"name":"snowymelt","likes":["secret"]}
]

プライバシー処理などで重宝しそうだ。これに限らず沢山のメソッドが利用できるので、ぜひ確認してみてほしい。

全てのOptics同士がつねに合成できるわけではないが、可能な組み合わせはMonocleのドキュメントに書いてある。「これとこれを合成するとこの型になるよ」といったことが書かれている。

www.optics.dev

Lens Law

(ここは詳細の話なので、別に読み飛ばしていい)

Lensがちゃんと合成可能であるために、Lensは2つの規則を守らなければならない。それをLens Lawと呼ぶ(同様にTraversal Lawなどもある)。

  • getしたものをsetしても、元のオブジェクトは変化していないこと
  • ある値をsetした後でgetしたとき、setした値が得られること

これはgetterとsetterに対する直感がそのまま現れているので、あまり違和感はないはずだ。これを満たしていることによってLensは正しく合成できるようになる。Lensを手で作る場合、Monocle自体はこの保証はしてくれないので実装する側がテストなどで保証しておくことになるだろう。ちなみにMonocleはこれを確認するためのテスト用のモジュールを提供している。

www.javadoc.io

Monocleの便利機能

さて、一連のLensの使い方を紹介した。ここからは、Monocleが提供している便利な機能を紹介する。

Lensの自動生成でサボる

これまではLensをgetter/setterをもとに手で定義していたが、case classといったよくある対象については、Monocleがマクロを使って自動的にLensを生成してくれる機能がある。

マクロはmonocle-macroモジュールで定義されているので、別途依存性に追加する必要がある:

//> using dep "dev.optics::monocle-macro::3.1.0"

import monocle.macros.GenLens
case class Pair[A, B](lhs: A, rhs: B)

val lhsLens = GenLens[Pair](_.lhs) // => Lens[Pair[A, B], A]

同様にGenPrismGenIsoも用意されている。

Focusでもっとサボる

Lensなどを簡単に使うために、MonocleはFocusという仕組みを提供してくれている。 ざっくり言うと、case classやList、MapなどからそのままLensを生成して使える機能だ:

val p = Pair(42, 666)

p.focus(_.lhs).modify(_ * 2)
// => Pair(84, 666)

focusの中で対象となる部分を取り出すようなコードを書くと、自動的にLensが作られる。

focusの中でsomeを使うと、Optionalを作ってくれる:

case class Complex(real: Double, imag: Option[Double])

val i = Complex(0, Some(1))

i.focus(_.imag.some).modify(_ * 2)
// => Complex(0.0,Some(2.0))

val one = Complex(1, None)

one.focus(_.imag.some).modify(_ * 2)
// => Complex(1.0, None)

indexは配列の特定の要素をターゲットにする。

val bingo = List(
  List(1, 2, 3, 4, 5),
  List(1, 2, 3, 4, 5),
  List(1, 2, 3, 4, 5),
  List(1, 2, 3, 4, 5),
  List(1, 2, 3, 4, 5),
)
bingo.focus(_.index(2).index(2)).replace(0)
/* =>
List(
  List(1, 2, 3, 4, 5),
  List(1, 2, 3, 4, 5),
  List(1, 2, 0, 4, 5),
  List(1, 2, 3, 4, 5),
  List(1, 2, 3, 4, 5),
)
*/

Lensがもたらしたもの

僕は仕事で実際にLensを使ったコードを利用し、面倒なデータの変換を劇的に楽にしている。特定のウェブサービスが返すとても複雑(JSON配列に入っているデータの型はバラバラだ!)なデータを、部分的に変換し、また元のデータに返してやったり、結合したりする。似たような処理が頻出する。もしLensがなければ苦虫を噛み潰した顔で淡々とデータモデルを書いていたに違いない。

独自の実装が少ないので、書いたコードの正当性はほとんどJSONライブラリとMonocleが吸収する形で保証してくれる。僕が書かなければならないのは、全体を通した一般的なケースで正常に動くという一種の結合テストと、Lens Lawが満たされていることを確認するテストだけだった。非常に堅牢だし、元気に動いてくれる。

データと、そのうちの望みの部分へのアクセス方法が綺麗に分離されるのも効果的だった。コードの関心の対象はデータそのものではなくデータの一部にアクセスする方法なので、Lensを少しいじれば改修も容易だし、リファクタもしやすい。Lensにすることでアクセス手段が明に操作可能な形で切り出されるから、エンジニアは常にやりたいことに集中できる。

しかも、Lensは型安全なのだ。この記事の中では簡単のために所々意図的に省略している箇所もあるけれど、「きちんと」型安全に実装しても十分簡潔に書けるのがLensひいてはOpticsのすごいところだ。コンパイルさえ通ればつねに動く安心感は、一度味わうと忘れられない。

まとめ

  • Monocleを使って、LensをはじめとするOpticsを実現する方法を学んだ。
  • Opticsを使うと、データモデリングのかわりにデータアクセスパスを使ってデータを操作できることを学んだ。
  • Opticsは合成可能であることを学んだ。
  • Opticsは複雑でネストした構造に効果的に対処できることを学んだ。
  • Monocleは便利なマクロを提供していることを学んだ。

もしよければ今日からMonocleを使ってみませんか?

あわせて読みたい

zenn.dev

blog.3qe.us

blog.3qe.us

blog.3qe.us

www.slideshare.net

*1:ググるとたぶん僕の記事が出てきます

*2:https://zenn.dev/lotz/articles/14458f024674e14f4134

*3:uPickleのうち、JSONの基本的な型と操作を提供する部分はujsonとして分離されている。uPickleを利用するとujsonも同時に付いてくる。

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