JavaScriptは標準機能がショボすぎる

私がインターネットに触れ始めてからJavaScriptはずっとそばにいた。最初は簡単なダイアログを出すとか、マウスに追従するパーティクルを表示するとか、そういった簡単な用途に使われていたが、今やみんなが使うので、ウェブの一級言語になっただけでは留まらず、Node.jsの登場もあり、JavaScript一本で飯が食えるようになってしまった。

しかし、いざ実際にコードを書くとなると思いのほか苦しめられることが多い。クライアントで動くJavaScriptはその性質から、後方互換性を保ち成長をし続ける狂気の産物だ。当初の時代背景や設計思想に加えて、現代で好まれるパラダイムも取り込まれて混沌を極めている。

最近JavaScriptを公私ともに書くようになってきたことと、これまで様々なプログラミング言語のパラダイムを経験したことから、ここではJavaScriptの同値判定について考えてみたいと思う。

JavaScriptにはインタフェースがない

Dartにはインタフェース扱いできるクラスがあるし、TypeScriptにはインタフェースがあるので、特定の操作を保証することができる。ただ、JavaScriptにはそれがない。ES6からはクラスが導入されたが、多重継承ができないので、複数のインタフェースを実装することができない。

ではJavaScriptではその問題をどう回避しているかというと、特定のメソッドやプロパティがあるかどうかで判断している。Array-likeであるとは「.length プロパティがあること」ということになっているし、Thenableであるとは「then() メソッドがあること」としている。

それに従うと、比較可能(Comparable)であるとは compare() ないし compareTo() メソッドがあること定義できるのだが、これの判定は typeof x?.compare == 'function' となるし、コード全体として「比較可能であれば compare() がある」ということを認識しておかなければならない。自分だけがコードを書いているのであれば問題ないが、チーム開発をしているときには課題になりうる。

ユーティリティ関数 vs 追加定義

ほかのプログラミング言語を見回してみれば、普通にやれていることがJavaScriptではできないことが多い。個人的には新しいJavaScript実装を出して欲しいものだが、GoogleによるDartの試みは失敗に終わったし、CoffeeScriptやTypeScriptもトランスパイルの形をとっている。きっとJavaScriptはこれからもこのまま進化し続けるし、足りないものは足りないまま進んでいくだろう。

そのため、見通しがよく、複雑さもなく、保守性を維持したコードを書くには、いくつか拡張を導入しなければならない。jQueryやlodash, UnderScore.jsではグローバル空間にユーティリティ関数を公開している。これはJavaScriptの基本機能を一切変更しないので、最小限のwindowオブジェクトの汚染に留まるが、関数呼出しは入れ子になるにつれて見通しが悪くなりがちだ。それよりも既存のメソッドを拡張してメソッドチェーンによる操作ができるほうが宣言的でもあるし、実際にjQueryでは $ オブジェクトこそ定義はするが、その後はメソッドチェーンによる操作ができるようになっている。

ユーティリティ関数にせよ、追加定義による拡張にせよ、汚染は避けられない。どちらを採用するにせよコード中で使えばそれに依存することは変わりなく、そうなればコーディングスタイルに合っているほうを選択したほうがよい。ユーティリティ関数方式では、新しい関数を追加することでよりよい機能を提供しやすいが、程度問題のような気もする。

個人的には追加定義のほうが使いやすいように感じている。これはVue.jsを使っている都合で、グローバル環境にあるオブジェクトは data() メソッドで利用することを宣言しなければならない。追加定義であればこれを回避できるしコード量も削減できるのだ。

同値判定になにが必要か?

それではJavaScriptを書いているときに具体的にどのような機能があると便利なのかについて論じていく。表現としては a.method(b) のようなメソッド形式になっているが、どうしてもプロトタイプへの追加定義が嫌だというのであれば、method(a, b) といった関数形式で捉えてもらっても構わない。

equals(other: any): boolean

同値判定をするメソッドである。プリミティブ型は == または === 演算子で判定できるが、オブジェクト型やユーザー定義のクラスではオブジェクトが同一であるかどうかの判定になる。通常のユースケースではundefined, null, boolean, number, string, Date, Array, Set, Mapに対して定義されていれば困らない。

メソッド形式の定義はオブジェクトに対して定義されていれば十分で、プリミティブに対しては必要ないことのほうが多い。ただ、どんな値が来ても判定でするための関数形式の定義はあってもよい。

Objectはほかの言語で言うところの辞書やHashMapとして利用されることが多いが、どういう意図で作られたオブジェクトであるかはわからないので、「Equatableであるとは equals() メソッドを持つこと」と考えるとうまくいく。

/*
 * @param {any} value
 * @returns {boolean}
 */
function isEquatable(value) {
    return typeof value?.equals == 'function'
}

NaNの取扱い

numberについてはNaNが NaN != NaN であるので、反射律1を満たさない。このため equals(NaN, NaN)true であるべきかは悩ましいところだが、個人的には true でいいように感じる。

Array, Set, Mapの取扱い

Array.isArray() で配列かどうかは調べられる。あとは同じ長さを持ち、各要素が equals() を満たすかどうかを再帰的に確認すればよい。

SetとMapのキーは ===等価性を確認するので、プリミティブ型を期待している。挿入順で反復処理が行えることから、厳密さを求めるのであれば順序も考慮すべきだが、そこまでは必要ないことが多いし、集合という概念的に順序関係を期待することはナンセンスでもある。

Symbolの取扱い

Symbolの中身(.description)を見て比較すべきではない。これはSymbolが一意であることを保証しているからで、同じキーのSymbolの比較結果を真にしたいのであれば、Symbol() コンストラクタではなく、Symbol.for() 静的メソッドを使うべきである。

compare(other: any): number?

Array.sort()の比較器に期待される返値は、歴史的な経緯により、, , によって大小関係を判定している。boolean, number, Date2は単純に減算で求まるし、stringには localeCompare() がある。Arrayは要素を順番に見ていき、比較結果が初めて0でない値を返せばよい。

比較不能はどうする?

ここでもnumberのNaNは問題となる。compare(42, NaN) は明らかに比較不可能だ。SetやMapも順序関係がないので求められない。こういうときはnullを返すべきなので、compare() の返値のシグネチャはnumber?となっている。

ComparableであればEquatableである

比較可能であれば同値判定ができることが普通だ。つまり、あるオブジェクトの同値判定をしたいときに、equals() があればそれを使い、なければ compare() にフォールバックすることもできる。ただし、多くの場合では equals() を定義しておいたほうが、高いパフォーマンスを期待できる。

オブジェクト型の性質によって、ComparableであればEquatableであることを満たさないオブジェクトもあり、代表的なものにDateがある。Dateは比較演算はできるものの、== 演算子はオブジェクトが同じかどうかを判定するので、正しくはふたつのオブジェクトがDateであることを確認した上で +a === +b としなければならない。

Orderingは必要か?

Rustでは比較をすると数値の代わりにOrderingが返されるようになっていて、マジックナンバー的な比較結果よりも具体的な表現になっている。JavaScriptでもOrderingクラスを作成して、Lessは , Equalは , Greaterは と同値になるようにもできるが、anOrdering.compare(aNumber) はともかく、aNumber.compare(anOrdering) は標準ライブラリを汚染するので好ましくはない。

あるいは、Orderingに変換する方法はわかりやすくはあるが、やりすぎ感も否めない。

class Ordering {
    static less = Symbol.for('Ordering.less')
    static equal = Symbol.for('Ordering.equals')
    static greater = Symbol.for('Ordering.greater')
 
    /*
     * @param {number} value
     */
    static from(value) {
        if (value < 0) {
            return this.less
        } else if (value > 0) {
            return this.greater
        }
        return this.equal
    }
    
    /*
     * @returns {number}
     */
    valueOf() {
        switch (this) {
            case Ordering.less: return -1
            case Ordering.equal: return 0
            case Ordering.greater: return 1
        }
    }
}
 
console.log(Ordering.less == Ordering.from(-1))

clone(): T

同値判定には直接関係しないが、同じ値を作り出すという点では重要な機能である。言語によってはクローンを作り出すことが難しいことがあるが、JavaScriptも難しい言語のひとつだ。歴史的経緯から破壊的なメソッドが多いJavaScriptは、クローンメソッドがないとコード量が増えたり、操作の意味を捉えにくくなったりと苦労する場面が多い。

例えばDateオブジェクトは、その日時を変更するときに setMonth()setDate() のようなメソッドを使用するが、これらはすべて自分自身の値を変更するため、元の日時が必要であれば、クローンを残しておかなければならず、非破壊的な addDays() があったならと思うことも多い。さらにDateオブジェクトをクローンしようとすると、こんなコードを書くことになるが、視覚的にクローンしていることが分かりづらい。

const a = new Date()
const b = new Date(a) // あるいは new Date(a.getTime())

JSON.parse(JSON.stringify(x))

よくオブジェクトのディープコピー方法として採用されているコードだが、これはJSONで利用できる値でしか動作しない。状況的に保証されているのであれば悪くない選択ではあるものの、ドメインモデルを取り入れたコードでは、メソッドやプロパティが欠落するので採用できない。

structuredClone()

JSONオブジェクトを使うよりも適用範囲は広いものの、オブジェクトやユーザー定義クラスに対応しないのでやはり採用は難しい。

replace(args: Object): T

クローンするときに特定のプロパティだけ挿げ替えたいこともある。そういったときはclone() に任意のパラメータを指定できるようにしてもいいが、もっとお行儀がいい方法は、cloneWith()replace() メソッドを用意して期待する役割を分ける方法だ。

これまでJavaScriptはMutableなオブジェクトしか作れず、外から直接プロパティを書き換えることができたが、プライベート要素の導入や、Immutableなオブジェクトを是とするパラダイムの普及もあるので、こうした宣言的なインタフェースを用意しておくのは悪くない選択だ。

Footnotes

  1. T型のあらゆる値a, bの組について、a == b を満たすこと。

  2. Dateを数値扱いするとミリ秒単位のUNIXタイムスタンプを返す。