あまり知られていない関数型言語のおもしろ概念として、Opticsというものがある。
Opticsとは、オブジェクト指向言語で言うところのSetter/Getterを一種の関数として捉え、いくつかの便利な特性を付与したものの総称だ。この便利な特性によって、Setter/Getter以上のことをパワフルにこなせる。
最も有名なOpticsはLensであり、色々な解説資料が(主にHaskell向けに)出ている。
さて、これまでのOpticsを紹介する資料はSetterとGetterとしての側面に注目しがちだったので、じゃあOpticsの何が良いのか、Scalaでやる意義は何か、という側面をこの記事で紹介しようと思う。
Optics -- vs. copyメソッド地獄
Opticsが何なのか形式的に知るよりも、Opticsで何ができるのかをまず説明したほうが良いだろう。四角四面な説明を排してざっくばらんに表現するならば、OpticsとはJSONやネストしたクラスのフィールドにアクセスする際のパス的な概念を独立して取出し、型安全にして便利にしたものだ。例えばcirce-optics
というライブラリを使って、以下のような表現でJSONのフィールドを取出せる:
val items: List[Int] = root.order.items.each.quantity.int.getAll(json)
一見動的言語のようだが、Scalaのコードなので型が付いている*1。root
がJSONの頂点を表現していて、そこからどのようにパスが伸びていくかを教え、最終的にgetAll
に処理対象となるJSONを渡すことでデータが得られる。JSONに対して直接処理を加えていくのではなく、まずパスを作ってからJSONを渡すのが特徴的だ。
これだけだとフィールドを読み取っているだけなのでそんなに面白くない。最初から型が付いていればgetFoo()
みたいなゲッターを呼べばいいだけだからだ。それを独立して扱って何が面白いのか?
ほんとうに面白いのは、同様の書き味でデータの書き換え(イミュータブルなので必要に応じてデータが複製される)も可能なところ。
val doubleQuantities: Json => Json = root.order.items.each.quantity.int.modify(_ * 2) val modifiedJson = doubleQuantities(json)
これで特定のフィールドが2倍されたJSONが得られる。
これを素のcase class
で書こうと思ったらまず各レイヤーごとのcase class
を定義しなければならないし、copy
メソッド地獄になるであろうことは容易に想像がつく。Opticsは、「まずパスを組み立て、出来上がったパスに実際のデータを入れて処理する」という順序を徹底しているので、copy
地獄をうまく回避している。copy
はOpticsの内部で勝手に行なわれ、適切に処理されている。
関数型言語における定番の技巧として、「宣言と実行を分離する」というものがある。Opticsの発想もその例に漏れず、パスの定義とその実行を分離している。似たような発想はFreeモナドなどにも出現する。
Opticsは合成可能である
Opticsのうれしい特性の一つに、合成可能であるというものがある*2。例えば、「foo
フィールドを取出す」というLensと、「bar
フィールドを取出す」というLensとを組み合わせて、「foo
フィールドの中のbar
フィールドを取出す」というLensが常に得られる。このような合成は他のOpticsに対しても定義されている。いくつか例を挙げよう:
- Setter/Getterの抽象化である
Lens
同士を合成する- ネストしたフィールドに対してset/getできるようになる。
- 取出しに失敗するかもしれないOpticsである
Optional
同士を合成するOption
同士の合成のように振る舞う。フィールド取出しができなければ後続のOpticsは呼ばずにNone
を返すだけ
- 配列の各要素を表現するOpticsである
Traversal
とLens
同士を合成する- 配列の各要素のオブジェクトに生えている特定のフィールドを一気に取出したり書き換えたりできる
これらの非常に優れた合成メカニズム(たいていのOptics同士は合成可能)が、Opticsをいっそう便利にしている。例えば、「foo
フィールドに入っている配列の各オブジェクトについて、bar
フィールドがもしあれば2倍せよ」という処理をワンライナーで書き下せるメカニズムを提供できるのはOpticsくらいだ。しかもデータ全体に型を付ける必要が無いため、動的型付け言語のように小回りが効く。また、OpticsはJSON限定のメカニズムではなく、getとsetのような双方向のデータフローがあるようなデータ構造であれば何にでも*3実装できる。
Opticsはボトムアップのアプローチである
Opticsは徹底して「データにどうアクセスするか」のみを気にしていることにお気付きだろうか。実際のデータ構造が全体としてどうなっているかはOpticsにとってはどうでも良いことで、データ構造はパスの構築によって間接的に読み取れるだけだ。でもそれでうまくいく。
我々がよくやる、Scalaでcase class
を定義するような、データに型を付けるアプローチはトップダウン的だ。しかし複雑にネストした大規模なデータ、例えば巨大JSONなどで同じアプローチを採るとうんざりするようなボイラープレートを書くことになる。Scalaを使ってどこかのAPIにアクセスしようとしてうんざりした人は著者だけではないだろう。
Haskellで小さいシェルスクリプト書こうとしたら型があって面倒だったので、その経験は無駄にしないよう念頭に置いてる
— ⿻あいや⿻数学の入門書を書いています! (@public_ai000ya) 2023年6月5日
他方でOpticsはボトムアップのアプローチを採る。あるフィールドにアクセスする方法、配列の要素にアクセスする方法、それを書き換える方法といったミニマルかつ型安全に振る舞う部品を、合成可能な形で提供するのだ。全体としての型には一切関知しない。「データ全体に型を付けるのが大変なら、それを辿るためのパスに型を付ければいいじゃない」というわけ。
- JSONに型を付けたい
- なんで?→型安全にデータを取り出したいから
- なぜ型が付くと安全にデータを取り出せるのか?→データがそこにあることを保証できるから。実行時にクラッシュしないから
- では実際に必要なデータにだけ注目すればよい。必要なデータに型安全にアクセスできればよい
例として、あるデータに含まれるフィールド(ネストしていて深い場所にある)の書き換えについて考えてみよう。
case class
を使ったトップダウンのアプローチ:- 各フィールドに対応する
case class
を定義する copy
メソッドをネストさせて深いフィールドを変更する
- 各フィールドに対応する
- Opticsを使ったボトムアップのアプローチ:
- 各フィールドにアクセス(書き換え)するための
Lens
を定義する - 各
Lens
を合成して深いフィールドを変更するためのLens
を作る - それを適用して値を書き換える
- 各フィールドにアクセス(書き換え)するための
case class
はデータ構造を上から覆い尽くすように型を付けることで型安全にデータに到達しようとするのに対して、Opticsはプリミティブな部品を使って型安全にデータに到達しようとする。これは、図形の線と点を入れ替えたような面白さがある。
「まずデータを表現する型を付けてから扱う」というお作法は、必然ではないのだ。
Opticsがうまくいくとき、うまくいかないとき
Opticsを使ったアプローチが有効なのは、大規模なデータ構造、または深くネストしたフィールドや、あるかもしれないし無いかもしれないフィールド、配列といった微妙に注意が必要なフィールドが重なりあっているようなデータ構造だ。しかしながらOptics自体はデータ構造にアクセスするためのパスでしかないため、明にデータ構造がどのような形をしているかを示すことができない。Opticsはあくまで、小さな部品を組み合わせて柔軟性を生み出すボトムアップのアプローチだ。
Opticsを使ったアプローチがあまりうまくいかないのは、浅くて簡単なcase class
で表現できるようなデータ構造を操作するような場合だ。既にScalaコード上にcase class
などの形でデータ構造が定まっている場合は、Opticsの定義は単なる冗長なボイラープレートにしか見えないだろう。
また、ボトムアップなアプローチは覚えることが増えて認知的負荷を増やしてしまうかもしれない。
あわせて読みたい
Lensは双方向のデータフローを合成可能な形で抽象化したものであるという見方もできて、これでNNを作っている事例もある
Monocleは、ScalaのOpticsライブラリ。
メモ
Opticsは一種の射なのだから、ArrowとかArrowChoiceで遊べるかもしれない。
Opticsは、データの取得方法の定義と、実際の取得とを分離して記述できるようにする。 これは一種のDSLを構築するともいえて、例えばxpathとかjqとかに対応したopticsを書けば言語内で安全に、しかし一見動的に見えるデータ取り出しができるようになる。ある特定のデータを扱うのではなく、あるデータフォーマットに対応したDSLを構築すると便利だろう(CSSセレクタとか、XPathとか)。
直接Lensを書くのではなく、Lens GeneratorのようなものをDSLとして用意すると一気に便利になる。