0

Is it possible to make something like a WeakMap that is two way (get a value by its key, or get a key by its value)?

The usage would look like this (in TypeScript syntax to better illustrate):

class TwoWayWeakMap {
  // What goes here?
}

class SomeClass {}

const map = new TwoWayWeakMap<SomeClass, number>()

const o = new SomeClass

map.set(o, 42)

console.log(map.get(o)) // logs "42"
console.log(map.keyFrom(42)) // logs "SomeClass {}" (the `o` object)

At any point later, if o is no longer referenced except inside the TwoWayWeakMap, then the SomeClass object that o was pointing to could be collected.

NOTE! The second argument to map.set(k, v) must be allowed to be anything, not just objects. v can be a number, for example.

trusktr
  • 44,284
  • 53
  • 191
  • 263
  • At 35k rep you should have seen this answer coming a mile away: of course it's possible, JS has been a general purpose programming language for quite a while now. If that's not the answer you wanted, [make sure to ask the right question](/help/how-to-ask) and show what you already implemented, because writing your own classes in JS is pretty simple, and even two arrays with an indexOf will get you to a weak map already. – Mike 'Pomax' Kamermans Jul 26 '21 at 22:28
  • I am, a weakmap at its core is an extremely simple datastructure with parallel lists of keys and values, allowing you to get values by key, and keys by value, with the added benefit of keys being "just more values", allowing for arbitrary data as keys instead of the standard "you only get strings" key that standard JS objects offer. I jumped to conclusions because you clearly know how to program, so "what goes here" doesn't really match up with showing what you've tried already, something we collective ask of nearly every single SO user who posts to SO. Even at 300k rep, you'll get that remark – Mike 'Pomax' Kamermans Jul 27 '21 at 00:37
  • Good point about trying first. Well at that point in time, I hadn't tried yet, but stubbed the question. Sometimes then I post the answer to my own question so others can benefit after. But I've updated my question with the attempt that doesn't work. On another note, WeakMaps aren't like Maps, they only accept objects for keys, not any values (no strings, no numbers, no booleans, no etc). I do know what you're describing for Maps, but implementing a MyWeakMap with an Array of tuples will not provide the same weak referencing ability as an actual WeakMap. – trusktr Jul 27 '21 at 01:18
  • Do you also need the value to be collected? Also what should be the behavior if two "values" overlap? I guess something like this could get you close to something: https://jsfiddle.net/s6pq7d28/ – Kaiido Jul 27 '21 at 02:40
  • @Kaiido Interesting, that one works and seems to be what I was going for: after forcing GC it begins to log false. Seems the only difference is you're using a Map of value to weakref without iteration, while I'm using a Set of weakref and iteration. But it seems they are otherwise essentially the same? What did I miss in my attempt? – trusktr Jul 27 '21 at 07:08
  • Oh I missed your attempt o.O Yes they come to the same. The issue is not in the implementation but in `console.log(map.keyFrom(42))`. The console itself will keep an hard ref to the object. – Kaiido Jul 27 '21 at 07:17
  • @Kaiido Doh! That makes sense. I totally overlooked that. Thanks! Do you want to make an answer? Or I can do it if you prefer. Either way, I can remove my attempt and we can have the solution in the answer. – trusktr Jul 27 '21 at 17:34
  • @Kaiido I updated my attempt so it does not pass `o` into console.log, but instead converts it to a boolean, `!!o`, but the object still seems to stick around after forcing GC and the console always shows `true`. Hmm, what have I missed. – trusktr Jul 28 '21 at 02:20
  • 1
    In `main`, `console.log(map.keyFrom(42));` This still logs `o` in the console. Replacing it with `console.log(map.keyFrom(42) === o);` makes the GC collect `o` for me on Firefox. – Kaiido Jul 28 '21 at 02:46

3 Answers3

1

Here's how to do it one way:

<script type=module>
    let tick = 0
  
    const loop = setInterval(() => {
        const obj = window.map.keyFrom(42)
    
        console.log(`o still exists? (${tick++})`, !!obj)
    
        if (!obj) {
            clearInterval(loop)
            console.log('o was collected!')
        }
    }, 300)
</script>

<script type=module>
    class TwoWayWeakMap /*<K extends object = object, V = unknown>*/
      extends WeakMap /*<K, V>*/ {
    
        #refs /*: Set<WeakRef>*/ = new Set();
    
        constructor() {
            super();
            setInterval(() => this.maybeCleanup(), 1000);
        }
    
        set(k /*: K*/ , v /*: V*/ ) /*: void*/ {
            super.set(k, v);
            this.#refs.add(new WeakRef(k));
        }
    
        keyFrom(v /*: V*/ ) /*: K | undefined*/ {
            for (const ref of this.#refs) {
                const o = ref.deref();
                if (!o) {
                    this.#refs.delete(ref);
                    continue;
                }
                if (this.get(o) === v) return o;
            }
        }
    
        maybeCleanup() {
            for (const ref of this.#refs) {
                const o = ref.deref();
                if (!o) this.#refs.delete(ref);
            }
        }
    }
  
    class SomeClass {}

    function main() {
        const map = (window.map = new TwoWayWeakMap /*<SomeClass, number>*/());
        const o = new SomeClass();
        map.set(o, 42);
        console.log(map.get(o)); // logs "42"
        console.log('Get object from key:', !!map.keyFrom(42)); // logs "true"
    }
  
    main();
  
    // At this point there is no reference to `o`, except by
    // WeakRef and WeakMap, so `o` should be collectable.
</script>

Kaiido provided another way to do it, using a second map to eliminate the need for iteration:

class TwoWayWeakMap extends WeakMap {
  #reverseMap;
  constructor( iterable ) {
    super(iterable);
    this.#reverseMap = new Map();
    if (iterable) {
      for (const [k,v] of iterable ) {
        this.set(k,v);
      }
    }
  }
  set(k,v) {
    super.set(k,v);
    this.#reverseMap.set(v, new WeakRef(k));
  }
  keyFrom(v) {
    const k = this.#reverseMap.get(v)?.deref();
    if (!k) { // suboptimal clean value at getting...
      this.#reverseMap.delete(v);
    };
    return k;
  }
}

class SomeClass {}

const map = new TwoWayWeakMap();
{
  const o = new SomeClass();

  map.set(o, 42)

  console.log(map.get(o)) // logs "42"
  console.log(map.keyFrom(42) === o) // logs "SomeClass {}" (the `o` object)
}
// check it gets collected, eventually
// convert to Boolean to avoid the console keeping an hard reference
setInterval(() => console.log(!!map.keyFrom(42)), 1000 );
Kaiido
  • 123,334
  • 13
  • 219
  • 285
trusktr
  • 44,284
  • 53
  • 191
  • 263
1

FinalizationRegistry makes it more perfect!

class TwoWayWeakMap<K extends object, V extends any = any> {
  _map = new WeakMap<K, V>()
  _keyMap = new Map<V, WeakRef<K>>()
  _registry: FinalizationRegistry<V>

  constructor() {
    this._registry = new FinalizationRegistry<V>((key) => {
      this._keyMap.delete(key)
    })
  }

  set(key: K, value: V) {
    this._map.set(key, value)
    this._keyMap.set(value, new WeakRef(key))
    this._registry.register(key, value)
  }

  get(key: K): V | undefined {
    return this._map.get(key)
  }

  has(key: K): boolean {
    return this._map.has(key)
  }

  keyFrom(value: V): K | undefined {
    const ref = this._keyMap.get(value)
    return ref?.deref()
  }
}

async function main() {
  const map = new TwoWayWeakMap()
  let data = { hello: "world!" } as any
  map.set(data, "something!")

  console.log('---before---')
  console.log(map.keyFrom("something!"))

  data = null
  await new Promise((resolve) => setTimeout(resolve, 0))
  global.gc() // call gc manually
  await new Promise((resolve) => setTimeout(resolve, 0))

  console.log('---after--')
  console.log(map.keyFrom("something!"))
}

main()

It must be run with the --expose-gc option in the node.js environment.

wan2land
  • 74
  • 4
0

Doesn't seem like it ought to be any more complex than

class TwoWayWeakMap extends WeakMap {
  
  constructor(iterable) {
    super(iterable);
    if (iterable) {
      for (const [k,v] of iterable ) {
        this.set(k,v);
      }
    }
  }

  set(k,v) {
    super.set(k,v);
    super.set(v,k);
  }

}

Caveats

  • There is an implicit assumption here that the set union of the keys and values itself forms a set (which is to that that the set of keys is itself unique, the set of values is itself unique, and there is commonality between the two).

  • If either k or v is an object, since the methods of Set, Map, WeakSet, and WeakMap use reference equality, only that exact same object will match. Anything else, even if it is an exact duplicate will not match.

Nicholas Carey
  • 71,308
  • 16
  • 93
  • 135
  • Hello Nicholas, `WeakMaps` can only accept objects as keys, but can accept anything as values. In your example, the `super.set(v, k)` call will throw on non-object values. The solution should be able to allow `map.set(obj, 42)` and then allow `map.keyFrom(42)` (or similarly-named method in the solution, maybe even `get` itseld) to get back the object `obj` if it is not yet garbage collected. – trusktr Jul 26 '21 at 23:26
  • I updated my question to mention that the second argument to `.set` can be of any type, not just objects. – trusktr Jul 26 '21 at 23:37