Lambdaカクテル

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

Invite link for Scalaわいわいランド

Optics: 「パス」に型を付ければ、データ全体に型を付ける必要はない

【注意】この記事は1年以上前に書かれた記事です。最新の情報ではない可能性があります。

あまり知られていない関数型言語のおもしろ概念として、Opticsというものがある。

Opticsとは、オブジェクト指向言語で言うところのSetter/Getterを一種の関数として捉え、いくつかの便利な特性を付与したものの総称だ。この便利な特性によって、Setter/Getter以上のことをパワフルにこなせる。

最も有名なOpticsはLensであり、色々な解説資料が(主にHaskell向けに)出ている。

blog.recruit.co.jp

さて、これまでの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)

circe.github.io

一見動的言語のようだが、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にアクセスしようとしてうんざりした人は著者だけではないだろう。

他方でOpticsはボトムアップのアプローチを採る。あるフィールドにアクセスする方法、配列の要素にアクセスする方法、それを書き換える方法といったミニマルかつ型安全に振る舞う部品を、合成可能な形で提供するのだ。全体としての型には一切関知しない。「データ全体に型を付けるのが大変なら、それを辿るためのパスに型を付ければいいじゃない」というわけ。

  • JSONに型を付けたい
  • なんで?→型安全にデータを取り出したいから
  • なぜ型が付くと安全にデータを取り出せるのか?→データがそこにあることを保証できるから。実行時にクラッシュしないから
  • では実際に必要なデータにだけ注目すればよい。必要なデータに型安全にアクセスできればよい

例として、あるデータに含まれるフィールド(ネストしていて深い場所にある)の書き換えについて考えてみよう。

  • case classを使ったトップダウンのアプローチ:
    • 各フィールドに対応するcase classを定義する
    • copyメソッドをネストさせて深いフィールドを変更する
  • Opticsを使ったボトムアップのアプローチ:
    • 各フィールドにアクセス(書き換え)するためのLensを定義する
    • 各Lensを合成して深いフィールドを変更するためのLensを作る
    • それを適用して値を書き換える

case classはデータ構造を上から覆い尽くすように型を付けることで型安全にデータに到達しようとするのに対して、Opticsはプリミティブな部品を使って型安全にデータに到達しようとする。これは、図形の線と点を入れ替えたような面白さがある。

commons.wikimedia.org

「まずデータを表現する型を付けてから扱う」というお作法は、必然ではないのだ。

Opticsがうまくいくとき、うまくいかないとき

Opticsを使ったアプローチが有効なのは、大規模なデータ構造、または深くネストしたフィールドや、あるかもしれないし無いかもしれないフィールド、配列といった微妙に注意が必要なフィールドが重なりあっているようなデータ構造だ。しかしながらOptics自体はデータ構造にアクセスするためのパスでしかないため、明にデータ構造がどのような形をしているかを示すことができない。Opticsはあくまで、小さな部品を組み合わせて柔軟性を生み出すボトムアップのアプローチだ。

Opticsを使ったアプローチがあまりうまくいかないのは、浅くて簡単なcase classで表現できるようなデータ構造を操作するような場合だ。既にScalaコード上にcase classなどの形でデータ構造が定まっている場合は、Opticsの定義は単なる冗長なボイラープレートにしか見えないだろう。

また、ボトムアップなアプローチは覚えることが増えて認知的負荷を増やしてしまうかもしれない。

あわせて読みたい

Lensは双方向のデータフローを合成可能な形で抽象化したものであるという見方もできて、これでNNを作っている事例もある

zenn.dev

Monocleは、ScalaのOpticsライブラリ。

www.optics.dev

blog.3qe.us

メモ

Opticsは一種の射なのだから、ArrowとかArrowChoiceで遊べるかもしれない。

Opticsは、データの取得方法の定義と、実際の取得とを分離して記述できるようにする。 これは一種のDSLを構築するともいえて、例えばxpathとかjqとかに対応したopticsを書けば言語内で安全に、しかし一見動的に見えるデータ取り出しができるようになる。ある特定のデータを扱うのではなく、あるデータフォーマットに対応したDSLを構築すると便利だろう(CSSセレクタとか、XPathとか)。

直接Lensを書くのではなく、Lens GeneratorのようなものをDSLとして用意すると一気に便利になる。

*1:もちろんフィールド名を間違えたらうまく動かないが

*2:関数型エンジニアは合成可能なものを見ると興奮するのだ

*3:参考文献では、平均値や分散に対してLensを定義している!

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