I recently had to create my own implementation of a WeakValueMap sort of thing; mine is more of an IterableWeakSet, and the use case is basically for keeping track of event listeners without holding strong references to them. In this case the listeners are custom DOM elements, and they only need to know about a given event if they are still in the DOM tree - the event source shouldn't keep the DOM elements alive if they aren't on the page anymore.
My implementation doesn't use a FinalizationRegistry because the whole collection gets iterated through with some frequency, so I just drop the dead members whenever they are accessed.
I'm of two minds about the WeakValueMap idea. First, from a developer perspective, it feels weird that there are no primitives for holding an iterable collection of values without holding references to them. It feels like a feature gap, in the same way that the lack of a String.prototype.entries() function does. And because there is no native functionality that can accomplish this task, I have no guidelines for how I should implement my custom WeakCollection class in order to achieve best performance and avoid pitfalls.
From an implementation perspective, however, I have to wonder if there are any performance gains to be had. GC is obviously a difficult problem, and while @btakita's results are certainly suggestive that improvements could be made, that's not the same thing as actually modifying an engine to support the functionality. It also doesn't answer an important question, one which I think is central to most if not all discussions about potential GC-related features: "If an implementation were modified to support this feature, what performance impact would it have on code that doesn't use it?"
There's also a third consideration, one which @theScottyJam recently reminded me about: the features available in a language function as guideposts to the users of a language. If a WeakValueMap is added to the language, then people will use WeakValueMaps, and they will do so regardless of whether they are the best or most efficient tool for the job. For example, let's say the WeakValueMap described here were added to the language but it didn't end up offering any performance benefits over the polyfill. If I were writing my WeakCollection class in this hypothetical future, I would use the WeakValueMap as my primitive of choice rather than WeakRef, because I would assume that, as the language-provided feature closest to what I'm trying to do, it's the best tool for the job. I'd be wrong, though, because the map functionality is entirely unnecessary for my use case, and my assumption that a native feature would provide performance benefits over a hand-written one would, in this case, be incorrect.
What if we split the difference? Instead of offering a full-blown WeakValueMap in the language (which, as @mhofman notes, doesn't have obvious and self-evident semantics), how about we provide a primitive that can be used as a building block for libraries to use in providing their own WeakValueMaps or WeakCollections or whatever? All it needs is to provide the one thing that the current primitives don't: the ability to iterate over a collection of objects without holding strong references to them. Ideally it should do so without making assumptions about what kind of data structure is trying to use it as backing, so its API should be as small as possible. Forcing developers to use this as a building block rather than a proper container in its own right would send a strong message that this is not a recommended or necessarily performant pattern; I don't know off the top of my head whether the spec defines any abstract
global classes, but it might be warranted in this case even if it has a strong enough API to be used on its own.
One possible implementation of such a structure-agnostic primitive might be the following:
class WeakObjectRegistry {
#map = new Map()
register(object) {
const ref = new WeakRef(object);
const sym = Symbol(object);
#map.set(sym, ref);
return sym;
}
deregister(symbol) {
return #map.delete(symbol);
}
lookup(symbol) {
const result = #map.get(symbol)?.deref();
if (!result) #map.delete(symbol);
return result;
}
registrationSymbols() {
return #map.keys();
}
*[Symbol.iterator] () {
for (const [sym, ref] of #map.entries()) {
const obj = ref.deref();
if (obj) yield obj;
else #map.delete(sym);
}
}
}
Please forgive syntax and logic errors as I'm writing this on a phone, but I think the point comes across; this would work equally well as a base for a weak-valued map, collection, or even array. To ease the burden on implementations, the contract could be loosened as much as necessary:
- registering an object twice could succeed and return the same symbol (WeakSet semantics), succeed and return a different symbol (WeakArray semantics), or fail (WeakMap-ish semantics).
- The registrationSymbols() method could skip symbols attached to dead objects, or it could return them.
- Iterating the registry could return a double-registered object once or twice.
- Iteration order could be registration order, stable order, or unstable order.
- Iteration could be restricted to registration symbols only, or even omitted altogether to make the registry only usable as a lookup (in which case, the wrapping class would store the symbols it cared about); my intuition is that there would be enough of a benefit to a native iteration method that discards dead refs that it ought to be included if possible, but I could see the argument that including it might edge across to the "attractive nuisance" side of things, if we really want to discourage developers from using this class without cause.
There are only two inviolable guarantees that would need to be respected for this to function as a building block:
- If a registered object is still alive, a strong reference to it can be obtained by calling lookup() on the symbol returned when it was registered.
- Deregistering a symbol causes future lookups on that symbol to fail.
If iteration is provided, I'd add the following guarantees:
- Iteration over registration symbols will yield at least one symbol for each live, registered object. It does not have to match the symbol originally returned at registration (but it probably will).
- Iteration over registered objects will yield every registered object at least once, unless (a) the given object has already been garbage collected, or (b) all that object's registration symbols have been deregistered.
Obviously stronger guarantees are better; for my WeakCollection, for example, since I do care about insertion order, I'd keep a private array of registration symbols and iterate over that rather than using the registry's iterator (if available), but if iteration order were guaranteed to match registration order I wouldn't bother.
What do folks think? For my part, I'd consider this a win even if only the first two guarantees could be met; what's more, if such a low-capability WeakObjectRegistry were presented in the spec, I as a developer would consider that a stronger recommendation against using weak-valued maps than the current lack of functionality.