ES6 Map: Why/When to use an object or a function as a key?
Basically if you want to track any information related to an object that should for some reason not be present on the object itself, you could use a map.
A simple example could be to track how many times an operation was done relating to an object.
Here's a demonstration where we keep track of how much food each animal (instance) has eaten without affecting the animal itself:
function Animal(type) {
this.type = type;
}
// now let's say somewhere else in our program
// we want to have a farm where animals can eat.
// We want the farm to keep track of how much each animal ate but
// the animal itself doesn't need to know how much food it has eaten.
const AnimalFarm = (() => {
const mapOfAnimalToAmountOfFood = new Map();
return {
feedAnimal(animal, amountOfFood) {
// if the animal is being fed the first time
// initialize the amount to 0
if (!mapOfAnimalToAmountOfFood.has(animal)) {
mapOfAnimalToAmountOfFood.set(animal, 0)
}
// add amountOfFood to the amount of food already eaten
mapOfAnimalToAmountOfFood.set(
animal,
mapOfAnimalToAmountOfFood.get(animal) + amountOfFood
)
},
getAmountEaten: function(animal) {
return mapOfAnimalToAmountOfFood.get(animal)
}
}
})()
const dog1 = new Animal('dog')
const dog2 = new Animal('dog')
AnimalFarm.feedAnimal(dog1, 300)
AnimalFarm.feedAnimal(dog1, 500)
AnimalFarm.feedAnimal(dog2, 1234)
console.log(
`dog1 ate ${AnimalFarm.getAmountEaten(dog1)} total`
)
console.log(
`dog2 ate ${AnimalFarm.getAmountEaten(dog2)} total`
)
In general, a main reason for creating a map of objects to some data is that you can maintain local information about an object which, although directly related to this object, is fully contained in your own module and doesn't pollute any other parts of the system (Separation of Concerns).
Another example could be a graph which has a map of objects representing the nodes to a list of other nodes they have a connection to (useful for example in Dijkstra's algorithm):
Map<Place, ListOfPlacesICanGoTo>
This allows you to have a more pure Place
object by separating this relationship rather than putting a direct Place.listOfPlaces
link within the object itself. This is particularly useful if a Place
is used in other contexts where listOfPlaces
is not needed or even doesn't make sense.
Another common usage of objects as keys in a map-like structure is when using a WeakMap
, because it's also more memory efficient by allowing each key object to be garbage collected as soon as nothing else references it.
An example could be the underlying implementation for process.on('unhandledRejection')
in node which uses a WeakMap
to keep track of promises that were rejected but no error handlers dealt with the rejection within the current tick.
As far as using a function as a key, I would personally think this is less useful but certainly not useless.
One useful example could be to check if a certain function was already passed in before and not invoke it again but return a cached result. This could prevent repetitive execution of potentially expensive operations.
const map = new Map();
function invokeOrGetFromCache(fn) {
if (map.has(fn)) {
return map.get(fn);
}
const result = fn();
map.set(fn, result);
return result;
}
function exampleFn() {
console.log('start');
for (i = 0; i < 100000; i++);
console.log('done');
return true;
}
console.log(
invokeOrGetFromCache(exampleFn) // runs exampleFn
);
console.log(
invokeOrGetFromCache(exampleFn) // retrieves from cache
);
As with objects, using a WeakMap
could be preferable in these situations as well, for efficiency reasons.
If you write a function that executes an expensive operation to copy / transform / wrap an object or a function, and you expect the function to be called multiple times for the same data, it's typically a performance improvement to do a precursory check in a WeakMap
to make sure you haven't already run the expensive operation.
If you have, then you're able to return the result that's already been calculated, which saves a lot of time.
One real-world example is a utility I've published called di-proxy
, but to demonstrate my point, the syntax is something like this (in Node.js):
const createInjector = require('di-proxy')
// pass dependency resolver to injector factory
const inject = createInjector(require)
// wrap IIFE with dependency injector
inject(({ http, express, 'socket.io': sio }) => {
const app = express()
const server = http.Server(app)
const io = sio(server)
…
})()
Internally, the createInjector()
function will check to make sure it hasn't already generated a wrapper function for require
. If it has, it will use the input function as a key to a WeakMap
and return the wrapper function it already generated, to save time:
function createInjector (require, noCache = false) {
…
// if require function is weakly referenced and memoization is enabled
if (!noCache && this.has(require)) {
// return cached injector
return this.get(require)
}
…
// expensive operation to generate cached injector
…
// weakly reference injector with require function as key
this.set(require, inject)
// return wrapped function
return inject
}.bind(new WeakMap())