超強力な関数型プログラミング用ライブラリ Ramda.js を学ぼう #2 - lens でオブジェクト操作も思いのまま

lens とは?

端的に言うと、 gettersetter を関数型プログラミング的に抽象化したものです。対象となるオブジェクトや配列の特定のプロパティやインデックスからデータを取得したり変換することが出来ます。と、これだけ聞くと何の凄さも伝わってきませんが、ネストの激しい複雑なデータ構造に対して不変性を保ちつつアクセスできるというのが一番の特徴として挙げられます。

元々は Haskell に lens というパッケージがあり、これを Ramda.js の API として実装したものだと思っていただければ OK です。

諸説ありますが、対象のプロパティに対し『レンズのようにフォーカスする』というのが語源のようです。

オブジェクトのプロパティにアクセスする lens 関数を作ってみる

それでは実際に Ramda.js の lens を使ってみましょう。以下のようなオブジェクトデータを用意します。

type User = {
  name: string;
  email: string;
  age: number;
  address: {
    zipcode: string;
  };
};
const user: User = {
  name: 'Bret',
  email: 'bred@april.biz',
  age: 22,
  address: {
    zipcode: '92998-3874',
  },
};

この user オブジェクトの age プロパティにアクセスする lens 関数を作成してみましょう。

import { assoc, lens, prop } from 'ramda';
const ageLens = lens(prop<string>('age'), assoc<string>('age'));

lens 関数は第一引数に getter 関数、第二引数に setter 関数を受け取って Lens インスタンスを返します。prop は Ramda.js の標準的な getter 関数です。通常は prop(key, object) とすることで object にある key の値を返しますが、ここでは key のみをしていすることでカリー化された関数 ( getter ) を返すようにします。assoc は Ramda.js の setter 関数です。通常は assoc(key, value, object) と三つの引数を受け取って結果値を返しますが、 key のみを受け取るとカリー化された関数 ( setter ) を戻り値として返します。

では実際に user オブジェクトに lens 関数でアクセスしてみましょう。

getter

import { assoc, lens, prop, view } from 'ramda';
const ageLens = lens(prop<string>('age'), assoc<string>('age'));
view(ageLens, users);  //=> 22

view 関数はいわゆる Lens の getter を実行する API です。渡された第二引数に渡したデータ構造に対して第一引数に渡した Lens インスタンスの getter を実行した結果を返します。 ageLensage というプロパティに対する Lens インスタンスなので、 user オブジェクトの age プロパティにアクセスし、その値を取得します。

setter

import { assoc, lens, prop, set } from 'ramda';
const ageLens = lens(prop<string>('age'), assoc<string>('age'));
const newUser = set(ageLens, 23, users);
console.log(newUser);
{
  "name": "Bret",
  "email": "bred@april.biz",
  "age": 23,
  "address": {
    "zipcode": "92998-3874"
  },
}

set 関数は Lens の setter を実行する API です。渡された Lens インスタンスによってフォーカスされたデータ構造のプロパティ値を更新した結果を返します。ちなみに 引数に渡した user 自体はそのままであり、 newUser は完全に新規に作られたデータ構造です ( 不変性の担保 ) 。

lens すごい!!』と言いたいところですが、正直これだけではそこまで旨味を感じられないことでしょう。そもそも nameage など第一階層でプリミティブなプロパティならまだしも address.zipcode のようなネストしたプロパティに対してはどうアクセスすれば良いのでしょうか?propassoc も単一の key 名しか指定できないため、これでは zipcode にアクセスするのが非常に大変なことになってしまいます。

ネストしたプロパティに直接アクセスするには

lensPath

ネストしたプロパティに直接アクセスするには lensPath という API を使います。lensPath は、その名の通り対象となるデータ構造のプロパティ名をパスに見立てて配列で指定することでダイレクトにフォーカス出来る API です。

import { lensPath, view } from 'ramda';
const zipcodeLens = lensPath(['address', 'zipcode']);
view(zipcodeLens, user);  //=> '92998-3874'

パスにはプロパティ名 ( string ) だけでなく配列のインデックスも指定出来るので、以下のようなデータ構造に対しても簡単にアクセス出来ます。

import { lensPath, view } from 'ramda';
const post = {
  id: 1,
  title: 'ポラーノの広場',
  body: `あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。`,
  tags: [
    { name: '小説', slug: 'novel' },
    { name: '宮沢賢治', slug: 'kenji-miyazawa' },
  ],
};
const tagLens = lensPath(['tags', 1, 'name']);
view(tagLens, post); //=> '宮沢賢治'

lensIndex と lensProp

lensPath はネストしたオブジェクトハッシュに対して使えるものですが、対象のデータ構造が配列であれば lensIndex という API でもアクセス可能です。lensIndex はアクセスしたいインデックス値を渡して使います。

const headLens = lensIndex(0);
view(headLens, ['foo', 'bar', 'baz']); //=> 'foo';
set(headLens, 'hoge', ['foo', 'bar', 'baz']);  //=> ['hoge', 'bar', 'baz']

この他にも lensProp という lensprop の組み合わせを一括で指定できるシンタックスシュガー的な API もあります。

import { assoc, prop, lens, lensProp, view } from 'ramda';
// どちらも同じ
const ageLens1 = lens(prop<string>('age'), assoc<string>('age'));
const ageLens2 = lensProp('age');
view(ageLens1, user);  //=> 22
view(ageLens2, user);  //=> 22

もともと Ramda.js には lens API しかなかったのですが、 v0.14.0lensIndexlensProp が、 v0.19.0lensPath が追加されました。よって今となっては lensPath さえあれば全てまかなえることになります。

僕個人としては、アクセスしたいプロパティがネストしてるか否かで lensProplensPath を使い分けるようにしています。

締め

lens のお陰でどんなに複雑なデータ構造であっても非常に簡単な記述でアクセス出来ます。しかも setter においては、フォーカスした箇所を更新するだけでなく元のデータ自体には一切変更を加えずに完全に同じ構造のデータを新規に生成して返すという、関数型プログラミングの不変性まで担保されているのは驚くべきことです。lens を使いこなすことで JavaScript でのオブジェクト操作はより高次元なものへと昇華します。覚えておいて損はありません。