複雑なデータ構造の内部への読み書きを抽象化するライブラリLensの練習メモです。言語はPureScriptを使っています。この記事はHaskellな人も読めなくはないと思いますが、PureScriptとHaskellでは異なる部分が結構あるので注意してください。パッケージはpurescript-profunctor-lenses
を使っています1。
単純なオブジェクトのプロパティの読み書き
-- 読み書きしたいデータの型
type Task = { name :: String, completed :: Boolean }
-- サンプルデータ
task :: Task
task = { name: "meeting", completed: false }
-- Lensの定義
name :: forall a b . Lens { name :: a | b } { name :: a | b } a a
name = lens _.name _ { name = _ }
completed :: forall a b . Lens { completed :: a | b } { completed :: a | b } a a
completed = lens _.completed _ { completed = _ }
-- lensを使わない通常のプロパティ読み取り
-- ここの『name』はLensの`name`じゃなくて、JavaScriptと同様のそういう専用の構文です
task.name -- "meeting"
-- view関数と先ほど定義したLensのnameを使ったプロパティ読み取り
view name task -- "meeting"
-- ^.演算子(viewの別名)によるのプロパティ読み取り
-- viewとは引数の順序が逆転しているのに注意
task ^. name -- "meeting"
-- 通常のプロパティ書き込み構文。
-- ここのcompletedもLensじゃなくてそういう構文です
-- JavaScriptでいう task.completed = true; というコードの意図に近い操作です。
task { completed = true } -- { name: "meeting", completed: true }
-- set関数とLensのcompleteを使った書き込み
set completed true task -- { name: "meeting", completed: true }
-- .~演算子
(completed .~ true) task -- { name: "meeting", completed: true }
-- #演算子も使うと語順が自然に
task # completed .~ true -- { name: "meeting", completed: true }
配列の読み書き
-- サンプルデータ
xs :: Array String
xs = ["zero", "one", "two"]
ys :: Array Int
ys = [10, 20, 30]
-- 通常の!!演算子でのアクセス
xs !! 1 -- Just "one"
-- ix関数で指定したインデックスへのアクセサを作れます
-- 基本的には^.演算子ではなく^?演算子のほうを使います
xs ^? ix 1 -- Just "one"
-- 範囲外へのアクセスはNothingが返ります
xs ^? ix 100 -- Nothing
-- view関数や^.演算子でもアクセスできますが、その場合はMaybeじゃないナマの値が直接返ります
xs ^. ix 1 -- "one"
-- ^.演算子で範囲外にアクセスするとmemptyが返ります
xs ^. ix 100 -- ""
-- ^.演算子は要素がMonoidの配列でないと使えません
ys ^. ix 100 -- コンパイルエラー
-- 通常のupdateAtによる書き込み
-- updateAtはMaybeで結果を返してくるので、ここではfromMaybeで失敗を握りつぶしています
fromMaybe xs $ updateAt 1 "foo" xs -- ["zero", "foo", "two"]
-- ixでの指定インデックスへの書き込み
xs # ix 1 .~ "foo" -- ["zero", "foo", "two"]
-- 範囲外なら何もなかったことになる
xs # ix 100 .~ "foo" -- ["zero", "one", "two"]
StrMapの読み書き
-- サンプルデータ
-- fromFoldableでタプルの配列から文字列のマップを作れます。
-- 内部的には { one: 1, two: 2, three: 3 } というようなJavaScriptのオブジェクトと同じものです
nmap :: StrMap Int
nmap = fromFoldable [
Tuple "one" 1,
Tuple "two" 2,
Tuple "three" 3
]
-- 通常のlookupによる読み取り
lookup "one" nmap -- Just 1
-- StrMapの場合でも、ixでインデックスに文字列を使うだけ
nmap ^? ix "one" -- Just 1
-- 通常のinsertによる書き込み
insert "two" 222 nmap -- { one:1, two:222, three:3 }
-- ixを使った書き込み
-- JavaScriptの nmap["two"] = 222; という式と字面がよく対応していて直感的です
nmap # ix "two" .~ 222 -- { one:1, two:222, three:3 }
-- ixのほかにatというものもあります。
-- ixはキーが存在しなかったときには何も起きませんが、atはその値を挿入するという違いがあります。
-- そのためatが使えるコンテナにも違いがあり、配列やリストではatは使えません
-- .~の代わりに?~を使うことにも注意
nmap # at "ten" ?~ 10 -- { one:1, two:2, three:3, ten: 10 }
配列の配列の読み書き
-- サンプルデータ
zs :: Array (Array String)
zs = [["zero"], ["one"], ["two"]]
-- ベタな方法での読み取り
-- 一回目の配列アクセスは単に!!演算子の適用なのに、
-- 2回目のアクセスは<#>を噛ませることになる一貫性のなさがちょっと直感的でないと思います
zs !! 1 <#> (!! 0) -- Just "one"
-- 人によってはFunctorよりdo記法のほうが好きかもしれません
do xs <- zs !! 1
xs !! 0 -- Just "one"
-- >=>演算子でつなげると、<#>よりはいくらか対称性があります
zs # ((!! 1) >=> (!! 0)) -- Just "one"
-- lensは<<<で合成できます。配列の配列の内部を読むにはixの2個のLensを合成すればいい
-- ただしうまく型推論ができず、型注釈を加えないとコンパイルできません
-- PureScriptのコンパイラの問題なのかどうかまだよくわかりません
zs ^? (ix 1 <<< ix 0) -- Just "one"
-- 書き込みのほうはあんまりいい方法が思いつきません
-- ホント汚い
do xs <- zs !! 1
ys <- updateAt 0 "foo" xs
updateAt 1 ys zs -- [["zero"], ["foo"], ["two"]]
-- lensだと割りと素直に書けます
-- JavaScriptで zs[1][0] = "foo"; とするのと同じ思考で書けます
zs # (ix 1 <<< ix 0) .~ "foo" -- [["zero"], ["foo"], ["two"]]
配列を含むより複雑なオブジェクトの読み書き
-- 型
type Task = { name :: String, completed :: Boolean }
type TodoList = { title :: String, tasks :: Array Task }
-- サンプルデータ
todoList :: TodoList
todoList = {
title: "Today's todo",
tasks: [
{ name: "meeting at 15:00", completed: false },
{ name: "purchase a milk", completed: true },
{ name: "dog walk", completed: false }
]
}
-- 通常のプロパティアクセス。
-- 基本的にはa.b.c.dとドットで繋げばいいですが、Maybeが絡むと<#>が必要になって読みにくくなります
todoList.tasks !! 1 <#> _.name -- Just "purchase a milk"
-- Lensなら<<<演算子でtasksやnameのような自分で定義したLensとixを自在に合成できます
-- ただしこれも現状型注釈を加えないと通りません
todoList ^? (tasks <<< ix 1 <<< name) -- Just "purchase a milk"
-- こういう複雑なオブジェクトの内部を書き換えようとすると、素朴な方法では一気に可読性が悪化します
todoList { tasks = fromMaybe todoList.tasks $ modifyAt 1 (_ { name = "purchase two bottles of milk" }) todoList.tasks }
-- {
-- title: "Today's todo",
-- tasks: [
-- { name: "meeting at 15:00", completed: false },
-- { name: "purchase two bottles of milk", completed: true },
-- { name: "dog walk", completed: false }
-- ]
-- }
-- Lensなら短いくてJSのコードにもよく似ている語順で直感的
-- JavaScriptだと todoList.tasks[1].name = "purchase two bottles of milk"; と書くのと同じイメージ
todoList # (tasks <<< ix 1 <<< name) .~ "purchase two bottles of milk"
-- {
-- title: "Today's todo",
-- tasks: [
-- { name: "meeting at 15:00", completed: false },
-- { name: "purchase two bottles of milk", completed: true },
-- { name: "dog walk", completed: false }
-- ]
-- }
-- 値の置き換えではなく、値に対して例えば配列の要素を追加したいなどの操作をするときは、
-- overもしくは別名の%~を使います。
-- JavaScriptでいう todoList.tasks.push({ name: "have a lunch with her", completed: false }) のような操作は次のようになります
todoList # tasks %~ (++ [{ name: "have a lunch with her", completed: false }])
-- {
-- title: "Today's todo",
-- tasks: [
-- { name: "meeting at 15:00", completed: false },
-- { name: "purchase two bottles of milk", completed: true },
-- { name: "dog walk", completed: false },
-- { name: "have a lunch with her", completed: false }
-- ]
-- }
直和型を含むデータ構造の読み書き
-- データ型
type Element = { name :: String, attributes :: StrMap String, children :: Array XML }
data XML = Text String | Element Element
-- LensとPrism
-- XMLのように複数のデータコンストラクタが含まれる直和型的なデータ型は、
-- prismでアクセサを表現していきます
element :: Prism XML XML Element Element
element = prism Element \s -> case s of
Text str -> Left (Text str)
Element e -> Right e
text :: Prism XML XML String String
text = prism Text \v -> case v of
Element elem -> Left (Element elem)
Text str -> Right str
children :: forall a b . Lens { children :: a | b } { children :: a | b } a a
children = lens _.children _ { children = _ }
-- データ
xml :: XML
xml = Element { name: "div", attributes: empty, children: [
Element { name: "hr", attributes: empty, children: [] },
Text "Hello"
] }
-- <<<で合成していけば中のほうに触れるのも簡単
-- JavaScriptの xml.children[1] は安全でないですが、Lensの方は型安全です
xml ^? (element <<< children <<< ix 1 <<< text) -- Just "Hello"
-- 書き込みも簡単
-- JavaScriptで xml.children[1] = "See you"; と書くのと同じ思考で書けます
xml # (element <<< children <<< ix 1 <<< text) .~ "See you"
-- Element { name: "div", attributes: empty, children: [
-- Element { name: "hr", attributes: empty, children: [] },
-- Text "See you"
-- ] }
さいごに
自分はTemplate Haskellみたいな黒魔術は好きじゃないし、PureScriptにはJavaScriptに似たオブジェクト型があって構文が多少マシなのでLensなしでもいいかと思っていましたが、このあいだLensには単なるプロパティアクセス以外の面白い使い方があることを知って少し興味がわいたので、自分も練習してみることにしました。
Lensはあまりに抽象的すぎるので、APIドキュメントや定義そのものとにらめっこしても使い方は理解できないと思います。そういうライブラリは、とにかく具体的な使用例をたくさん覚えていくのがいいのではないでしょうか。もちろん定義が理解に役に立たないというわけではなく、Lensのさまざまな関数の使い方がわかったあとで定義を見返してみると理解が深まります。それに、使い道のない抽象化は、数学ならともかくプログラミングでは意味がありません。コードをどのように改善するのかという具体的な比較があってこそ、このライブラリは役に立つといえるわけです。
ただこのライブラリ、抽象的な型クラスが多すぎ、型の別名が多すぎ、型変数が多すぎ、抽象的すぎてドキュメント見ても関数の使い方がわからなすぎと、とにかく難易度が凄まじいライブラリです。自分が今まで触ったライブラリの中でも軽々トップ3には入るであろう難易度だと思います。うかつに手を出すと手首ごと食いちぎられます。その難易度のわりにコードの改善は局所的ですし、別にLensを使わなくてもちょっとコードが冗長で少し可読性が落ちるだけの話ですし、別にコードの型安全性などには変化はないし、アプリケーション全体の設計に影響するようなライブラリでもありません。Lensより優先して学ぶべきライブラリは他にたくさんあると思います。
PureScriptだと手作業でLensやPrismを定義する面倒さはもとより、型推論でちょっとハマる場面があったのもつらみを感じました。上で比較したとおり明らかに構文は改善されるので使ってみる価値はあると思いますが、他にお勧めしたいライブラリはいくらでもあるので、Lensに取り組むのは目ぼしいライブラリにひと通り触れてからでいいと思います。上で試した以外にも大量の関数や型が用意されているので、また理解が進み次第追記していこうかと思います。
参考文献
- https://github.com/ekmett/lens/wiki/Overview
- http://tokiwoousaka.github.io/takahashi/contents/20150530LensPrism.html
- http://stackoverflow.com/questions/29742634/could-someone-explain-the-diagram-about-the-lens-library
- http://stackoverflow.com/questions/18414177/what-is-the-difference-between-ix-and-at-in-the-lens-library-of-haskell
- http://www.haskellforall.com/2013/05/program-imperatively-using-haskell.html
-
purescript-lens
というライブラリもあるのですが、こちらは古いライブラリでもう使われていないようです。 ↩