◆ WeakRef のおかげで 他に参照がなくなったら GC されてほしいかつ key がなくても参照したい 物が作れる

これまでの問題

過去記事をみてると JavaScript でタプルを作っていたのを見つけました
今では分割代入機能などがありますし 基本は配列で十分ですが 配列だと別のところで同じ中身のものを作っても新しい参照になってしまいます

[1, 2] === [1, 2]
// false

こういう場合でも同じ参照になるタプルがほしいなと思います

このタプルを作る場合 複数の参照を 1 つの参照にする必要があります
そして すでに作ったことがあるタプルの場合は同じ参照を返す必要があります

すでに作ったことがあるかを知るためには チェックのために作ったことがあるものを内部的に保持しなければいけません
そうするとタプルの種類だけ内部データが増えていき 解放できないのでメモリリークになります
動的に頻繁に作る場合は問題点も多いです

WeakSet で保存すれば他で使われなくなれば自動で解放できるのですが 今回の場合は保持したものを forEach などですべて参照できないといけません
WeakMap や WeakSet では key をもとに取得できるだけで key なしに参照することはできません
なのでこれまではどうしようもないものでした

WeakRef のおかげで

しかし最近は WeakRef が使えます
WeakRef を使って保持したいデータをラップしておけば 他に参照がなくなると自動で解放してくれます

作ったものはこれです

const wmap = new WeakMap()
const rmap = new WeakMap()

let data = []
const registry = new FinalizationRegistry(() => {
data = data.filter(ref => ref.deref())
})

const get = (a) => {
const item = data.find(ref => {
const item = ref.deref()
return item && item.length === a.length && item.every((x, i) => a[i] === x)
})
return wmap.get(item)
}

const create = (a) => {
const ref = new WeakRef(a)
registry.register(a, null)
data.push(ref)
const t = {}
wmap.set(a, t)
rmap.set(t, a)
return t
}

const rget = t => {
return rmap.get(t)
}

const tuple = (...a) => {
return get(a) || create(a)
}

const untuple = t => {
return rget(t)
}

使い方は

tuple(1, obj1, "a")

のようにまとめたいデータを引数に入れます
すると参照が返ってきます

同じものなので引数が同じなら結果も同じです

tuple(2, obj1) === tuple(2, obj1)
// true

値のチェックは浅い比較です
オブジェクトの中身が一緒でもオブジェクト自身の参照が別なら別物になります

tuple(3, {a: 1}) === tuple(3, {a: 1})
// false

untuple を使えば tuple の返り値の参照から引数として渡したもとの値を配列として受け取れます

const t = tuple({foo: 1}, {bar: 2})
const [{ foo }, { bar }] = untuple(t)
foo // 1
bar // 2

tuple の返り値の t ですが これの実体は中身のないオブジェクトです
untuple で取得する配列そのものの方がデバッグ時にわかりやすいかなと思いましたが タプルって基本編集できないものですし簡単に編集してしまえないように新しい参照にしました

FinalizationRegistry

もう一つ工夫があって 一致チェックのための値の WeakRef は data 変数に配列で保持しています
他に参照がなければ自動で GC で消えるのですが deref メソッドで参照できなくなるだけであって WeakRef インスタンス自体は残ります
それだと何千種類もタプルを作ると data にそれだけの WeakRef が溜まります
全部に中身がある状態ならそれでいいのですが GC で中身が消えた空の WeakRef が残っていてもチェック処理が遅くなるだけです

FinalizationRegistry を使えば GC で消されたときにコールバック関数を呼び出せます
コールバック関数の処理で GC された WeakRef を data から削除し data には余計な WeakRef が残らないようにします

確認

まず 2 つのタプルを作ります

tuple(1, {a: 1})
const t = tuple(2, {a: 2})

2 つめだけ変数に保持します
この時点で data をみると要素は 2 つあるはずです

あとは GC が発生するまで待ちます
メモリを多めに確保する処理をすると早めに実行されるかも

Array.from(Array(100000), (x, i) => ({v: i}))

devtools を使う場合は devtools の機能で参照が保持されてるかもしれないので一旦閉じたり console をクリアしたほうが良いかもしれないです

そんなことをしてる内に GC されて data をみると WeakRef が 1 つだけになってるはずです

WeakRef や FinalizationRegistry はあまり使うことなさそうと思ってましたが結構便利ですね

追記

tuple の返り値は新しい空のオブジェクトにして参照を作っていましたが Object.freeze すれば編集できなくなるのでこっちにしました
そのまま tuple の結果が変更不可能な配列ですし untuple は不要になりコードも短くなりました
空オブジェクトとの対応を保持する WeakMap が不要になったのも大きいです

let data = []
const registry = new FinalizationRegistry(() => {
data = data.filter(ref => ref.deref())
})

const get = (a) => {
const item = data.find(ref => {
const item = ref.deref()
return item && item.length === a.length && item.every((x, i) => a[i] === x)
})
return item?.deref()
}

const create = (a) => {
const t = Object.freeze(a)
const ref = new WeakRef(t)
registry.register(t, null)
data.push(ref)
return t
}

const tuple = (...a) => {
return get(a) || create(a)
}