Project Fx 2.0

怪文書と備忘録を書きます

超・宣言的プログラミングから始めるJavaScript Proxy入門

この記事は、「LipersInSlums Advent Calendar 2023」第15日目の記事です。

昨日の記事は、stepney141さんの「読書メーターとGitHub Actionsで、大学図書館の本を『積読』しよう」でした。

adventar.org

知らない人のために触れておくと、LipersInSlums とは謎の Discord サーバです。 旧 Twitter ヘビーユーザーの計算機オタクがなぜかいっぱい集まっているという特徴があります。住民からは「スラム」と通称されています。

具体的にどんなところがスラム街っぽいのかについては、melovilijuさんの5日目の記事を読めば何となくわかると思います。 lipersinslums.github.io

超・宣言的プログラミングとは?

突然ですが、皆さんは宣言的プログラミングをしていますか? 私はしてます。

フロントエンド界隈の関数型プログラミングブームの影響からか、現在では誰もが「純粋関数」「宣言的プログラミング」という言葉を唱えながらコードを書くようになりました[要出典]。

zenn.dev

qiita.com

しかし「宣言的プログラミング」とは、具体的に何をもってして「宣言的」と言われているのでしょうか?

宣言的プログラミングの厳密な定義には諸説ありますが、カジュアルには「『具体的にどのような手続きを踏んで計算するか』ではなく『やりたい計算とはどんなものであればいいか』を書き連ねること」などと解されるのが一般的です。

qiita.com

zenn.dev

JavaScriptにおける宣言的プログラミングの例として、以下の persons の中から「15歳未満の東京都民」を検索せねばならない状況を考えてみましょう。 ただし、

  • 人の手で書かれた温もりのあるデータなので、元のオブジェクトを破壊しないでほしい
  • プロパティを gender に書き換えた上でデータを出力してほしい

という要件が課されているものとします。

const persons = [
  { name: "Fuyuko", age: 19, sex: "F", address: { city: "Ibaraki"} },
  { name: "Asahi", age: 14, sex: "F", address: { city: "Tokyo"} },
  { name: "Mei", age: 18, sex: "F", address: { city: "Saitama"} }
];

データの人間的な温かみを損なうわけにはいきませんから、persons を破壊することなく宣言的に書いてみましょう。

const result = persons
  .filter(p => p.age < 15)
  .filter(p => p.address.city == 'Tokyo')
  .map(p => ({name: p.name, gender: p.sex}));
//  result には [ { "name": "Asahi" , "gender": "F"} ] が入る

このように Array#mapArray#filter などの高階関数系メソッドを使うことによって、「15歳未満の人」「東京に住んでる人」などの条件が明確になり、『取ってきたいデータとはどんなものであればいいか』を書き連ねるだけで所望の処理を記述することに成功しています。 こうしたユースケースは、「関数型っぽくスマートにJS/TSを書こうぜ」的な記事・書籍のどれを見ても、大抵真っ先に紹介されているものです。

しかし、これは果たして本当に「宣言的」と言えるでしょうか。 メソッドチェーンでいくつも処理を繋げてるし、検索条件1つに対してfilter1つを費やさなきゃいけないし、mapのところとかなんかグチャグチャと命令的チックなことやってるし…。 はっきり言って美しくありません。 「宣言的」という言葉を仰々しく使うのであれば、filterやらmapやらをゴチャゴチャ書き連ねるのではなく、最初っから「15歳未満かつ東京に住んでる人」を決め打ちでスパァーッと取得させてほしいものです。

そんな憤りを抱えながらTypeScriptでエセ関数型プログラミングをしている皆さんにオススメしたいのが、こちらの「Declaraoids」というライブラリです(※作者 !== 筆者)。

github.com

Declaraoidsを使うと、上のように煩雑で命令的なことをしなくても、なんと1回のメソッドアクセスだけで同じことができるんです!!!!!!

これはもはや宣言的プログラミングを超えた「超・宣言的プログラミング」と言っても過言ではありません!!!!!!!!!!!!!!!!!

const declaraoids = require('./declaraoids');
const result = declaraoids.findNameAndSexAsGenderWhereAgeLessThanXAndAddress_CityEqualsCity(persons, {x: 15, city: 'Tokyo'});
// result には [ { "name": "Asahi" , "gender": "F"} ] が入る

種明かし

はい、皆さん「それはないだろ!」と思いましたよね。私もそう思います。 ここまで宣言的プログラミングについて色々とおかしなイチャモンをつけてしまいましたが、本気で上のような「超・宣言的プログラミング」を布教しようとしているわけではありません。

ここで重要なのは、実は上のコードはちゃんと完動するということです。

一見すると何かの冗談のようなコードですが、これがどうしてちゃんと動くのか、その原理を確かめていきましょう。

まずは declaroids のモジュールが何をエクスポートしているのかを見てみます。

github.com

module.exports = new Proxy({}, {
    get (target, property) {
        return finder(property);
    }
});

Proxy という組み込みオブジェクトのインスタンスをエクスポートしているのが分かりますね。 つまり、declaraoids.findNameAndSexAsGenderWhereAgeLessThanXAndAddress_CityEqualsCity() というとんでもないメソッドは、組み込みオブジェクト「Proxy」によって実現されていたのです。

今回の記事の本題はここからです。

Proxy

Proxyとは、ES2015から導入されたJavaScriptの言語機能です。簡単に言うと、JavaScript本来のオブジェクトの挙動を上書きできるという機能です。

もう少し詳しく言うと、あるオブジェクトに対して、特定の操作をした際の挙動をカスタマイズした新しいオブジェクトを作れる機能です。

developer.mozilla.org

tc39.es

ただし、どんな挙動でも際限なく上書きできるというわけではなく、カスタマイズできる操作の種類は言語仕様できっちり制限されています(それでも十分に色々なことができますが)。 非常に雑なたとえですが、「gettersetter をハチャメチャに高機能化したもの」というイメージを持つと分かりやすいと思います。

例として、MDN から引用してきたコードを見てみましょう。

// カスタマイズの対象になるオブジェクト
const target = {
  message1: "hello",
  message2: "everyone",
};

// カスタマイズ内容を記述したオブジェクト
const handler = {
  get(target, prop, receiver) {
    return "world";
  },
};

// カスタマイズの実行
const proxyObj = new Proxy(target, handler);

// カスタマイズされたオブジェクトは、どんなプロパティアクセスに対しても "world" とだけ返すようになる!

console.log(proxyObj.message1, proxyObj.message2);
// -> "world", "world"

console.log(proxyObj.undefinedProperty);
// -> "world"

上の例では、target オブジェクトへ get という操作を行った時の挙動を改造しています。 ここでの改造内容は、『どんなプロパティへのアクセスに対しても "world" という文字列を返す』というものです。

handler オブジェクトは、「何の操作をどのように改造するか」の情報を表すオブジェクトです。 改造内容は handler オブジェクトのメソッドとして記述されます(このメソッドのことを『トラップ』と呼びます)。 この例では get メソッド(get トラップ)に改造内容が記述されていますが、指定するメソッド(トラップ)の名前は「どの操作を改造するか」によって変わります。 また、1つの handler に 2つ以上の異なるトラップを記述する(=異なる2つ以上の操作を同時に改造する)こともできます。

そして、改造元の target オブジェクト・改造内容の handler オブジェクトを組み込みオブジェクト Proxy に渡すと、改造されたオブジェクト proxyObj が作成されるというわけです。

さらに詳しい使い方については、先駆者の方々による様々な解説記事を参照してください。

uhyohyo.net

ja.javascript.info

zenn.dev

個人的な所感ですが、Proxyはライブラリやフレームワークを使う時というよりも、作る時に活躍する機能だと思います。 Vue 3 のリアクティビティシステムも、Proxy を使って実装されていることで有名です(詳しい説明は下記リンク先に譲ります)。

ja.vuejs.org

qiita.com

qiita.com

しかし、なんとなく「Proxyはこういう風に書く」「Proxyはこんなことに使われている」ということは知っていても、「具体的にどんなことができるのか」という話になるとピンと来ない人もいるのではないでしょうか。 その点において、declaroids はものすごく面白く、考えようによっては教育的な例であると個人的には思います。

改めて、先ほどのエクスポート元のコードを見てみましょう。

var parser = require('./parser');

module.exports = new Proxy({}, {
    get (target, property) {
        return finder(property);
    }
});

function finder(query) {
    var parsed = parser(query);

    var mapFunc = generateMapFunction(parsed);
    var filterFunc = generateFilterFunction(parsed);

    return (array, args) => {
        return array
            .filter(filterFunc(args))
            .map(mapFunc);
    }
}

function generateMapFunction(parsed) {  /*... */ }
function generateFilterFunction(parsed) { /*... */ }

declaraoids の正体は、get トラップを仕掛けた Proxy オブジェクトです。

README.md 内の "Syntax" 解説 で説明されている通りにメソッド(というかクエリ)を書き、Proxy オブジェクトのメソッドとして呼び出すと、内部的にパーサが走ってクエリを解釈し、その通りにオブジェクトを操作してくれる仕組みになっています。

「こんなんどこで使うねん」というボヤきを聞くことも多い [要出典][誰に?] Proxy ですが、このようなこともできるというお話でした。

頑張って TypeScript で declaraoids に型を付けてみようかとも思っていたのですが、記事の公開日には間に合いませんでした。 もしもうまく行ったら続編として記事にします。

Proxyの欠点と実用性

速度

Proxy オブジェクトの操作は、通常のオブジェクト操作に比べてかなり遅くなることが知られています。

thecodebarbarian.com

www.romaintrotard.com

declaraoids にはベンチマーク用コードも同梱されていたので、手元の環境 (Node.js v20.10.0) で実行して計測してみました。

❯ npx mocha test/speed.spec.js


  Speed comparison
Result with list of 50 items
Simple filter&map x 7,282,059 ops/sec ±0.78% (81 runs sampled)
Simple declaraoids x 486,464 ops/sec ±6.08% (71 runs sampled)
Fastest is Simple filter&map
Result with list of 100000 items
Simple filter&map x 236 ops/sec ±1.24% (85 runs sampled)
Simple declaraoids x 126 ops/sec ±1.66% (79 runs sampled)
Fastest is Simple filter&map
    ✓ Simple query (22549ms)
Result with list of 50 items
Advanced filter&map x 19,447,091 ops/sec ±0.57% (97 runs sampled)
Advanced declaraoids x 286,637 ops/sec ±0.49% (97 runs sampled)
Advanced declaraoids CACHED x 872,852 ops/sec ±0.31% (96 runs sampled)
Fastest is Advanced filter&map
Result with list of 100000 items
Advanced filter&map x 369 ops/sec ±1.96% (84 runs sampled)
Advanced declaraoids x 31.05 ops/sec ±0.43% (55 runs sampled)
Advanced declaraoids CACHED x 30.87 ops/sec ±0.27% (54 runs sampled)
Fastest is Advanced filter&map
    ✓ Advanced query (32881ms)
    Check that they return equal
      ✓ Simple query
      ✓ Advanced query


  4 passing (55s)

実行環境の違いもあり、declaraoids作者による計測結果とはだいぶ異なる数値が出ていますが、やはりバニラの機能よりも Proxy の方が圧倒的に遅いことが分かります。

速度が重要なアプリケーションでは Proxy を多用すべきでないと思います。

エコシステムについて

Proxy は非常に強力な機能ではあるのですが、強力すぎて ES5 までの機能だけでは再現できません。言い換えると、完全な Polyfill を作ることができません

一応、Google Chrome の開発チームが Polyfill を公開していますが、 getsetapplyconstruct の4種類のトラップにしか対応していません。

github.com

declaraoids の場合は get トラップしか使用していないので Polyfill で何とかなりそうですが、一般論として、とても古いブラウザでの動作を未だに保証しなければならないアプリケーションでは Proxy オブジェクトを使うべきではありません(今どきあまりない事とは思いますが)。

TypeScriptとの食い合わせもあまり良くなく、Proxy を乱用すると型が付けられなくなる場合があります。

わかりやすさ

わかりやすさの観点で言えば確実にアウトです。

が、このようなメソッドの動的生成は「メタプログラミング」と呼ばれる重要なプログラミング技法の一種です。

私自身はあまり詳しくないですが、特に Ruby のライブラリではこのような動的メタプログラミングを多用する場合があると聞き及んでいます。

最後に

皆もJavaScriptメタプログラミング、しよう!

明日は skht777 さんの記事です。