This chapter answers the following questions:
Shared mutable state works as follows:
Note that this definition applies to function calls, cooperative multitasking (e.g., async functions in JavaScript), etc. The risks are similar in each case.
The following code is an example. The example is not realistic, but it demonstrates the risks and is easy to understand:
function logElements(arr) {
while (arr.length > 0) {
console.log(arr.shift());
}
}
function main() {
const arr = ['banana', 'orange', 'apple'];
console.log('Before sorting:');
logElements(arr);
arr.sort(); // changes arr
console.log('After sorting:');
logElements(arr); // (A)
}
main();
// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
In this case, there are two independent parties:
main()
wants to log an Array before and after sorting it.logElements()
logs the elements of its parameter arr
, but removes them while doing so.logElements()
breaks main()
and causes it to log an empty Array in line A.
In the remainder of this chapter, we look at three ways of avoiding the problems of shared mutable state:
In particular, we will come back to the example that we’ve just seen and fix it.
Copying data is one way of avoiding sharing it.
Background
For background on copying data in JavaScript, please refer to the following two chapters in this book:
As long as we only read from shared state, we don’t have any problems. Before modifying it, we need to “un-share” it, by copying it (as deeply as necessary).
Defensive copying is a technique to always copy when issues might arise. Its objective is to keep the current entity (function, class, etc.) safe:
Note that these measures protect us from other parties, but they also protect other parties from us.
The next sections illustrate both kinds of defensive copying.
Remember that in the motivating example at the beginning of this chapter, we got into trouble because logElements()
modified its parameter arr
:
Let’s add defensive copying to this function:
function logElements(arr) {
arr = [...arr]; // defensive copy
while (arr.length > 0) {
console.log(arr.shift());
}
}
Now logElements()
doesn’t cause problems anymore, if it is called inside main()
:
function main() {
const arr = ['banana', 'orange', 'apple'];
console.log('Before sorting:');
logElements(arr);
arr.sort(); // changes arr
console.log('After sorting:');
logElements(arr); // (A)
}
main();
// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'
Let’s start with a class StringBuilder
that doesn’t copy internal data it exposes (line A):
class StringBuilder {
_data = [];
add(str) {
this._data.push(str);
}
getParts() {
// We expose internals without copying them:
return this._data; // (A)
}
toString() {
return this._data.join('');
}
}
As long as .getParts()
isn’t used, everything works well:
const sb1 = new StringBuilder();
sb1.add('Hello');
sb1.add(' world!');
assert.equal(sb1.toString(), 'Hello world!');
If, however, the result of .getParts()
is changed (line A), then the StringBuilder
ceases to work correctly:
const sb2 = new StringBuilder();
sb2.add('Hello');
sb2.add(' world!');
sb2.getParts().length = 0; // (A)
assert.equal(sb2.toString(), ''); // not OK
The solution is to copy the internal ._data
defensively before it is exposed (line A):
class StringBuilder {
this._data = [];
add(str) {
this._data.push(str);
}
getParts() {
// Copy defensively
return [...this._data]; // (A)
}
toString() {
return this._data.join('');
}
}
Now changing the result of .getParts()
doesn’t interfere with the operation of sb
anymore:
const sb = new StringBuilder();
sb.add('Hello');
sb.add(' world!');
sb.getParts().length = 0;
assert.equal(sb.toString(), 'Hello world!'); // OK
We can avoid mutations if we only update data non-destructively.
Background
For more information on updating data, see §7 “Updating data destructively and non-destructively”.
With non-destructive updating, sharing data becomes unproblematic, because we never mutate the shared data. (This only works if everyone that accesses the data does that!)
Intriguingly, copying data becomes trivially simple:
This works, because we are only making non-destructive changes and are therefore copying the data on demand.
We can prevent mutations of shared data by making that data immutable.
Background
For background on how to make data immutable in JavaScript, please refer to the following two chapters in this book:
If data is immutable, it can be shared without any risks. In particular, there is no need to copy defensively.
Non-destructive updating is an important complement to immutable data
If we combine the two, immutable data becomes virtually as versatile as mutable data but without the associated risks.
There are several libraries available for JavaScript that support immutable data with non-destructive updating. Two popular ones are:
These libraries are described in more detail in the next two sections.
In its repository, the library Immutable.js is described as:
Immutable persistent data collections for JavaScript which increase efficiency and simplicity.
Immutable.js provides immutable data structures such as:
List
Stack
Set
(which is different from JavaScript’s built-in Set
)Map
(which is different from JavaScript’s built-in Map
)In the following example, we use an immutable Map
:
import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
[false, 'no'],
[true, 'yes'],
]);
// We create a modified version of map0:
const map1 = map0.set(true, 'maybe');
// The modified version is different from the original:
assert.ok(map1 !== map0);
assert.equal(map1.equals(map0), false); // (A)
// We undo the change we just made:
const map2 = map1.set(true, 'yes');
// map2 is a different object than map0,
// but it has the same content
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (B)
Notes:
.set()
return modified copies..equals()
method (line A and line B).In its repository, the library Immer is described as:
Create the next immutable state by mutating the current one.
Immer helps with non-destructively updating (potentially nested) plain objects, Arrays, Sets, and Maps. That is, there are no custom data structures involved.
This is what using Immer looks like:
import {produce} from 'immer/dist/immer.module.js';
const people = [
{name: 'Jane', work: {employer: 'Acme'}},
];
const modifiedPeople = produce(people, (draft) => {
draft[0].work.employer = 'Cyberdyne';
draft.push({name: 'John', work: {employer: 'Spectre'}});
});
assert.deepEqual(modifiedPeople, [
{name: 'Jane', work: {employer: 'Cyberdyne'}},
{name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
{name: 'Jane', work: {employer: 'Acme'}},
]);
The original data is stored in people
. produce()
provides us with a variable draft
. We pretend that this variable is people
and use operations with which we would normally make destructive changes. Immer intercepts these operations. Instead of mutating draft
, it non-destructively changes people
. The result is referenced by modifiedPeople
. As a bonus, it is deeply immutable.
assert.deepEqual()
works because Immer returns plain objects and Arrays.