Working with Maps and WeakMaps Flashcards

Maps and Sets introduction

1
Q

What is a Map object?

A

A Map object is a simple key/value map and can iterate its elements in insertion order.

The following code shows some basic operations with a Map. You can use a for...of loop to return an array of [key, value] for each iteration.

const sayings = new Map();
sayings.set("dog", "woof");
sayings.set("cat", "meow");
sayings.set("elephant", "toot");
sayings.size; // 3
sayings.get("dog"); // woof
sayings.get("fox"); // undefined
sayings.has("bird"); // false
sayings.delete("dog");
sayings.has("dog"); // false

for (const [key, value] of sayings) {
  console.log(`${key} goes ${value}`);
}
// "cat goes meow"
// "elephant goes toot"

sayings.clear();
sayings.size; // 0

“Map object” (MDN Web Docs). Retrieved April 24, 2024.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
2
Q

Advantages of using Map objects over Object to store key/value pairs?

A

Traditionally, objects have been used to map strings to values. Objects allow you to set keys to values, retrieve those values, delete keys, and detect whether something is stored at a key. Map objects, however, have a few more advantages that make them better maps.

  • The keys of an Object are strings or symbols, whereas they can be of any value for a Map.
  • You can get the size of a Map easily, while you have to manually keep track of size for an Object.
  • The iteration of maps is in insertion order of the elements.
  • An Object has a prototype, so there are default keys in the map. (This can be bypassed using map = Object.create(null).)
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
3
Q

When should you use Map object over an Object?

A

These three tips can help you to decide whether to use a Map or an Object:

  • Use maps over objects when keys are unknown until run time, and when all keys are the same type and all values are the same type.
  • Use maps if there is a need to store primitive values as keys because object treats each key as a string whether it’s a number value, boolean value or any other primitive value.
  • Use objects when there is logic that operates on individual elements.
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
4
Q

What is a WeakMap object?

A

A WeakMap is a map (dictionary) where the keys are weak - that is, if all references to the key are lost and there are no more references to the value - the value can be garbage collected.

The only primitive type that can be used as a WeakMap key is symbol — more specifically, non-registered symbols — because non-registered symbols are guaranteed to be unique and cannot be re-created.

The WeakMap API is essentially the same as the Map API. However, a WeakMap doesn’t allow observing the liveness of its keys, which is why it doesn’t allow enumeration. So there is no method to obtain a list of the keys in a WeakMap. If there were, the list would depend on the state of garbage collection, introducing non-determinism.

“WeakMap object” (MDN Web Docs). Retrieved April 25, 2024.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
5
Q

“Why WeakMap?

A

A map API could be implemented in JavaScript with two arrays (one for keys, one for values) shared by the four API methods. Setting elements on this map would involve pushing a key and value onto the end of each of those arrays simultaneously. As a result, the indices of the key and value would correspond to both arrays. Getting values from the map would involve iterating through all keys to find a match, then using the index of this match to retrieve the corresponding value from the array of values.

Such an implementation would have two main inconveniences:

  1. The first one is an O(n) set and search (n being the number of keys in the map) since both operations must iterate through the list of keys to find a matching value.
  2. The second inconvenience is a memory leak because the arrays ensure that references to each key and each value are maintained indefinitely. These references prevent the keys from being garbage collected, even if there are no other references to the object. This would also prevent the corresponding values from being garbage collected.

By contrast, in a WeakMap, a key object refers strongly to its contents as long as the key is not garbage collected, but weakly from then on. As such, a WeakMap:

  • does not prevent garbage collection, which eventually removes references to the key object
  • allows garbage collection of any values if their key objects are not referenced from somewhere other than a WeakMap

A WeakMap can be a particularly useful construct when mapping keys to information about the key that is valuable only if the key has not been garbage collected.

But because a WeakMap doesn’t allow observing the liveness of its keys, its keys are not enumerable. There is no method to obtain a list of the keys. If there were, the list would depend on the state of garbage collection, introducing non-determinism. If you want to have a list of keys, you should use a Map.

“Why WeakMap?” (MDN Web Docs). Retrieved April 27, 2024.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
6
Q

List some WeakMap use cases

A

Use cases

Some use cases that would otherwise cause a memory leak and are enabled by WeakMaps include:

  • Keeping private data about a specific object and only giving access to it to people with a reference to the Map.
  • Keeping data about library objects without changing them or incurring overhead.
  • Keeping data about host objects like DOM nodes in the browser.
    Adding a capability to an object from the outside (like the event emitter example in the other answer).
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
7
Q

How can you use a WeakMap to associate metadata to an object?

A

A WeakMap can be used to associate metadata with an object, without affecting the lifetime of the object itself.

For example, on the web, we may want to associate extra data with a DOM element, which the DOM element may access later. A common approach is to attach the data as a property:

const buttons = document.querySelectorAll(".button");
buttons.forEach((button) => {
  button.clicked = false;
  button.addEventListener("click", () => {
    button.clicked = true;
    const currentButtons = [...document.querySelectorAll(".button")];
    if (currentButtons.every((button) => button.clicked)) {
      console.log("All buttons have been clicked!");
    }
  });
});

This approach works, but it has a few pitfalls:

Using a WeakMap fixes these:

const buttons = document.querySelectorAll(".button");
const clicked = new WeakMap();
buttons.forEach((button) => {
  clicked.set(button, false);
  button.addEventListener("click", () => {
    clicked.set(button, true);
    const currentButtons = [...document.querySelectorAll(".button")];
    if (currentButtons.every((button) => clicked.get(button))) {
      console.log("All buttons have been clicked!");
    }
  });
});

Here, only code that has access to clicked knows the clicked state of each button, and external code can’t modify the states. In addition, if any of the buttons gets removed from the DOM, the associated metadata will automatically get garbage-collected.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
8
Q

How can you use WeakMap for caching?

A

With WeakMaps, you can associate previously computed results with objects without having to worry about memory management. The following function countOwnKeys() is an example: it caches previous results in the WeakMap cache.

const cache = new WeakMap();
function countOwnKeys(obj) {
  if (cache.has(obj)) {
    return [cache.get(obj), 'cached'];
  } else {
    const count = Object.keys(obj).length;
    cache.set(obj, count);
    return [count, 'computed'];
  }
}

If we use this function with an object obj, you can see that the result is only computed for the first invocation, while a cached value is used for the second invocation:

> const obj = { foo: 1, bar: 2};
> countOwnKeys(obj)
[2, 'computed']
> countOwnKeys(obj)
[2, 'cached']
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
9
Q

How can you keep private data on WeakMaps?

A

In the following code, the WeakMaps _counter and _action are used to store the values of virtual properties of instances of Countdown:

const _counter = new WeakMap();
const _action = new WeakMap();

class Countdown {
  constructor(counter, action) {
    _counter.set(this, counter);
    _action.set(this, action);
  }
  dec() {
    let counter = _counter.get(this);
    counter--;
    _counter.set(this, counter);
    if (counter === 0) {
      _action.get(this)();
    }
  }
}

// The two pseudo-properties are truly private:
assert.deepEqual(
  Object.keys(new Countdown()),
  []);

This is how Countdown is used:

let invoked = false;

const cd = new Countdown(3, () => invoked = true);

cd.dec(); assert.equal(invoked, false);
cd.dec(); assert.equal(invoked, false);
cd.dec(); assert.equal(invoked, true)

[ “Keeping private data in WeakMaps”].](https://exploringjs.com/impatient-js/ch_weakmaps.html#private-data-in-weakmaps) Retrieved May 1, 2024.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
10
Q

What is the problem with this code?

const wm = new WeakMap();
wm.set(123, 'test')
A

All WeakMap keys must be objects. You get an error if you use a primitive value:

> const wm = new WeakMap();
> wm.set(123, 'test')
TypeError: Invalid value used as weak map key

With primitive values as keys, WeakMaps wouldn’t be black boxes anymore. But given that primitive values are never garbage-collected, you don’t profit from weakly held keys anyway, and can just as well use a normal Map.

[ “All WeakMap keys must be objects”].](https://exploringjs.com/impatient-js/ch_weakmaps.html#all-weakmap-keys-must-be-objects) Retrieved May 1, 2024.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
11
Q

Explain why WeakMaps are black boxes

A

It is impossible to inspect what’s inside a WeakMap:

For example,

  • you can’t iterate or loop over keys, values or entries. And you can’t compute the size.
  • Additionally, you can’t clear a WeakMap either – you have to create a fresh instance.

These restrictions enable a security property. Quoting Mark Miller:

The mapping from weakmap/key pair value can only be observed or affected by someone who has both the weakmap and the key. With clear(), someone with only the WeakMap would’ve been able to affect the WeakMap-and-key-to-value mapping.

[ “WeakMass are black boxes”].](https://exploringjs.com/impatient-js/ch_weakmaps.html#weakmaps-as-black-boxes) Retrieved May 1, 2024.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
12
Q

How do you create a Map?

A

There are three common ways of creating Maps.

First, you can use the constructor without any parameters to create an empty Map:

const emptyMap = new Map();
assert.equal(emptyMap.size, 0);

Second, you can pass an iterable (e.g., an Array) over key-value “pairs” (Arrays with two elements) to the constructor:

const map = new Map([
  [1, 'one'],
  [2, 'two'],
  [3, 'three'], // trailing comma is ignored
]);

Third, the .set() method adds entries to a Map and is chainable:

const map = new Map()
  .set(1, 'one')
  .set(2, 'two')
  .set(3, 'three');
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
13
Q

How can you copy a Map?

A

Maps are also iterables over key-value pairs. Therefore, you can use the constructor to create a copy of a Map. That copy is shallow: keys and values are the same; they are not duplicated.

const original = new Map()
  .set(false, 'no')
  .set(true, 'yes');

const copy = new Map(original);
assert.deepEqual(original, copy);
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
14
Q

How can you get, set, delete and check key-value pairs in a Map?

A

.set() and .get() are for writing and reading values (given keys).

const map = new Map();

map.set('foo', 123);

assert.equal(map.get('foo'), 123);
// Unknown key:
assert.equal(map.get('bar'), undefined);
// Use the default value '' if an entry is missing:
assert.equal(map.get('bar') ?? '', '');

.has() checks if a Map has an entry with a given key. .delete() removes entries.

const map = new Map([['foo', 123]]);

assert.equal(map.has('foo'), true);
assert.equal(map.delete('foo'), true)
assert.equal(map.has('foo'), false)
How well did you know this?
1
Not at all
2
3
4
5
Perfectly
15
Q

How can you get the keys and values of a Map?

A

.keys() returns an iterable over the keys of a Map:

const map = new Map()
  .set(false, 'no')
  .set(true, 'yes')
;

for (const key of map.keys()) {
  console.log(key);
}
// Output:
// false
// true

We use Array.from() to convert the iterable returned by .keys() to an Array:

assert.deepEqual(
  Array.from(map.keys()),
  [false, true]);	

.values() works like .keys(), but for values instead of keys.

How well did you know this?
1
Not at all
2
3
4
5
Perfectly
16
Q

How can you get the entries of a Map?

A

.entries() returns an iterable over the entries of a Map:

const map = new Map()
  .set(false, 'no')
  .set(true, 'yes')
;

for (const entry of map.entries()) {
  console.log(entry);
}
// Output:
// [false, 'no']
// [true, 'yes']

Array.from() converts the iterable returned by .entries() to an Array:

assert.deepEqual(
  Array.from(map.entries()),
  [[false, 'no'], [true, 'yes']]);
Map instances are also iterables over entries. In the following code, we use destructuring to access the keys and values of map:

for (const [key, value] of map) {
  console.log(key, value);
}
// Output:
// false, 'no'
// true, 'yes'
17
Q

How can you convert between Maps and Objects?

A

As long as a Map only uses strings and symbols as keys, you can convert it to an object (via Object.fromEntries()):

const map = new Map([
  ['a', 1],
  ['b', 2],
]);
const obj = Object.fromEntries(map);
assert.deepEqual(
  obj, {a: 1, b: 2});

You can also convert an object to a Map with string or symbol keys (via Object.entries()):

const obj = {
  a: 1,
  b: 2,
};
const map = new Map(Object.entries(obj));
assert.deepEqual(
  map, new Map([['a', 1], ['b', 2]]));
18
Q

What values can you set as a Map key?

A

Any value can be a key, even an object:

const map = new Map();

const KEY1 = {};
const KEY2 = {};

map.set(KEY1, 'hello');
map.set(KEY2, 'world');

assert.equal(map.get(KEY1), 'hello');
assert.equal(map.get(KEY2), 'world');
19
Q

What keys are considered equal in a Map?

A

Most Map operations need to check whether a value is equal to one of the keys. They do so via the internal operation SameValueZero, which works like === but considers NaN to be equal to itself.

As a consequence, you can use NaN as a key in Maps, just like any other value:

> const map = new Map();

> map.set(NaN, 123);
> map.get(NaN)
123

Different objects are always considered to be different. That is something that can’t be changed (yet – configuring key equality is on TC39’s long-term roadmap).

> new Map().set({}, 1).set({}, 2).size
2
20
Q

How can you map and filter a Map?

A

You can .map() and .filter() an Array, but there are no such operations for a Map. The solution is:

  1. Convert the Map to an Array of [key, value] pairs.
  2. Map or filter the Array.
  3. Convert the result back to a Map.

I’ll use the following Map to demonstrate how that works.

const originalMap = new Map()
.set(1, 'a')
.set(2, 'b')
.set(3, 'c');
Mapping originalMap:

const mappedMap = new Map( // step 3
  Array.from(originalMap) // step 1
  .map(([k, v]) => [k * 2, '_' + v]) // step 2
);
assert.deepEqual(
  Array.from(mappedMap),
  [[2,'_a'], [4,'_b'], [6,'_c']]);
Filtering originalMap:

const filteredMap = new Map( // step 3
  Array.from(originalMap) // step 1
  .filter(([k, v]) => k < 3) // step 2
);
assert.deepEqual(Array.from(filteredMap),
  [[1,'a'], [2,'b']]);
Array.from() converts any iterable to an Array.
21
Q

How can you combine 2 Maps?

A

There are no methods for combining Maps, which is why we must use a workaround.

Let’s combine the following two Maps:

const map1 = new Map()
  .set(1, '1a')
  .set(2, '1b')
  .set(3, '1c')
;

const map2 = new Map()
  .set(2, '2b')
  .set(3, '2c')
  .set(4, '2d')
;

To combine map1 and map2 we create a new Array and spread (...) the entries (key-value pairs) of map1 and map2 into it (via iteration). Then we convert the Array back into a Map. All of that is done in line A:

const combinedMap = new Map([...map1, ...map2]); // (A)
assert.deepEqual(
  Array.from(combinedMap), // convert to Array for comparison
  [ [ 1, '1a' ],
    [ 2, '2b' ],
    [ 3, '2c' ],
    [ 4, '2d' ] ]