Sets in ECMAScript 6
ECMAScript 6 adds a Set
type that is an ordered list of values without duplicates. Sets allow fast access to the data they contain, adding a more efficient manner of tracking discrete values.
Creating Sets and Adding Items
Sets are created using new Set()
and items are added to a set by calling the add()
method. You can see how many items are in a set by checking the size
property:
let set = new Set();
set.add(5);
set.add("5");
console.log(set.size); // 2
Sets do not coerce values to determine whether they are the same. That means a set can contain both the number 5
and the string "5"
as two separate items. (The only exception is that -0 and +0 are considered to be the same.) You can also add multiple objects to the set, and those objects will remain distinct:
let set = new Set(),
key1 = {},
key2 = {};
set.add(key1);
set.add(key2);
console.log(set.size); // 2
Because key1
and key2
are not converted to strings, they count as two unique items in the set. (Remember, if they were converted to strings, they would both be equal to "[object Object]"
.)
If the add()
method is called more than once with the same value, all calls after the first one are effectively ignored:
let set = new Set();
set.add(5);
set.add("5");
set.add(5); // duplicate - this is ignored
console.log(set.size); // 2
You can initialize a set using an array, and the Set
constructor will ensure that only unique values are used. For instance:
let set = new Set([1, 2, 3, 4, 5, 5, 5, 5]);
console.log(set.size); // 5
In this example, an array with duplicate values is used to initialize the set. The number 5
only appears once in the set even though it appears four times in the array. This functionality makes converting existing code or JSON structures to use sets easy.
I> The Set
constructor actually accepts any iterable object as an argument. Arrays work because they are iterable by default, as are sets and maps. The Set
constructor uses an iterator to extract values from the argument. (Iterables and iterators are discussed in detail in Chapter 8.)
You can test which values are in a set using the has()
method, like this:
let set = new Set();
set.add(5);
set.add("5");
console.log(set.has(5)); // true
console.log(set.has(6)); // false
Here, set.has(6)
would return false because the set doesn’t have that value.
Removing Values
It’s also possible to remove values from a set. You can remove single value by using the delete()
method, or you can remove all values from the set by calling the clear()
method. This code shows both in action:
let set = new Set();
set.add(5);
set.add("5");
console.log(set.has(5)); // true
set.delete(5);
console.log(set.has(5)); // false
console.log(set.size); // 1
set.clear();
console.log(set.has("5")); // false
console.log(set.size); // 0
After the delete()
call, only 5
is gone; after the clear()
method executes, set
is empty.
All of this amounts to a very easy mechanism for tracking unique ordered values. However, what if you want to add items to a set and then perform some operation on each item? That’s where the forEach()
method comes in.
The forEach() Method for Sets
If you’re used to working with arrays, then you may already be familiar with the forEach()
method. ECMAScript 5 added forEach()
to arrays to make working on each item in an array without setting up a for
loop easier. The method proved popular among developers, and so the same method is available on sets and works the same way.
The forEach()
method is passed a callback function that accepts three arguments:
- The value from the next position in the set
- The same value as the first argument
- The set from which the value is read
The strange difference between the set version of forEach()
and the array version is that the first and second arguments to the callback function are the same. While this might look like a mistake, there’s a good reason for the behavior.
The other objects that have forEach()
methods (arrays and maps) pass three arguments to their callback functions. The first two arguments for arrays and maps are the value and the key (the numeric index for arrays).
Sets do not have keys, however. The people behind the ECMAScript 6 standard could have made the callback function in the set version of forEach()
accept two arguments, but that would have made it different from the other two. Instead, they found a way to keep the callback function the same and accept three arguments: each value in a set is considered to be both the key and the value. As such, the first and second argument are always the same in forEach()
on sets to keep this functionality consistent with the other forEach()
methods on arrays and maps.
Other than the difference in arguments, using forEach()
is basically the same for a set as it is for an array. Here’s some code that shows the method at work:
let set = new Set([1, 2]);
set.forEach(function(value, key, ownerSet) {
console.log(key + " " + value);
console.log(ownerSet === set);
});
This code iterates over each item in the set and outputs the values passed to the forEach()
callback function. Each time the callback function executes, key
and value
are the same, and ownerSet
is always equal to set
. This code outputs:
1 1
true
2 2
true
Also the same as arrays, you can pass a this
value as the second argument to forEach()
if you need to use this
in your callback function:
let set = new Set([1, 2]);
let processor = {
output(value) {
console.log(value);
},
process(dataSet) {
dataSet.forEach(function(value) {
this.output(value);
}, this);
}
};
processor.process(set);
In this example, the processor.process()
method calls forEach()
on the set and passes this
as the this
value for the callback. That’s necessary so this.output()
will correctly resolve to the processor.output()
method. The forEach()
callback function only makes use of the first argument, value
, so the others are omitted. You can also use an arrow function to get the same effect without passing the second argument, like this:
let set = new Set([1, 2]);
let processor = {
output(value) {
console.log(value);
},
process(dataSet) {
dataSet.forEach((value) => this.output(value));
}
};
processor.process(set);
The arrow function in this example reads this
from the containing process()
function, and so it should correctly resolve this.output()
to a processor.output()
call.
Keep in mind that while sets are great for tracking values and forEach()
lets you work on each value sequentially, you can’t directly access a value by index like you can with an array. If you need to do so, then the best option is to convert the set into an array.
Converting a Set to an Array
It’s easy to convert an array into a set because you can pass the array to the Set
constructor. It’s also easy to convert a set back into an array using the spread operator. Chapter 3 introduced the spread operator (...
) as a way to split items in an array into separate function parameters. You can also use the spread operator to work on iterable objects, such as sets, to convert them into arrays. For example:
let set = new Set([1, 2, 3, 3, 3, 4, 5]),
array = [...set];
console.log(array); // [1,2,3,4,5]
Here, a set is initially loaded with an array that contains duplicates. The set removes the duplicates, and then the items are placed into a new array using the spread operator. The set itself still contains the same items (1
, 2
, 3
, 4
, and 5
) it received when it was created. They’ve just been copied to a new array.
This approach is useful when you already have an array and want to create an array without duplicates. For example:
function eliminateDuplicates(items) {
return [...new Set(items)];
}
let numbers = [1, 2, 3, 3, 3, 4, 5],
noDuplicates = eliminateDuplicates(numbers);
console.log(noDuplicates); // [1,2,3,4,5]
In the eliminateDuplicates()
function, the set is just a temporary intermediary used to filter out duplicate values before creating a new array that has no duplicates.
Weak Sets
The Set
type could alternately be called a strong set, because of the way it stores object references. An object stored in an instance of Set
is effectively the same as storing that object inside a variable. As long as a reference to that Set
instance exists, the object cannot be garbage collected to free memory. For example:
let set = new Set(),
key = {};
set.add(key);
console.log(set.size); // 1
// eliminate original reference
key = null;
console.log(set.size); // 1
// get the original reference back
key = [...set][0];
In this example, setting key
to null
clears one reference of the key
object, but another remains inside set
. You can still retrieve key
by converting the set to an array with the spread operator and accessing the first item. That result is fine for most programs, but sometimes, it’s better for references in a set to disappear when all other references disappear. For instance, if your JavaScript code is running in a web page and wants to keep track of DOM elements that might be removed by another script, you don’t want your code holding onto the last reference to a DOM element. (That situation is called a memory leak.)
To alleviate such issues, ECMAScript 6 also includes weak sets, which only store weak object references and cannot store primitive values. A weak reference to an object does not prevent garbage collection if it is the only remaining reference.
Creating a Weak Set
Weak sets are created using the WeakSet
constructor and have an add()
method, a has()
method, and a delete()
method. Here’s an example that uses all three:
let set = new WeakSet(),
key = {};
// add the object to the set
set.add(key);
console.log(set.has(key)); // true
set.delete(key);
console.log(set.has(key)); // false
Using a weak set is a lot like using a regular set. You can add, remove, and check for references in the weak set. You can also seed a weak set with values by passing an iterable to the constructor:
let key1 = {},
key2 = {},
set = new WeakSet([key1, key2]);
console.log(set.has(key1)); // true
console.log(set.has(key2)); // true
In this example, an array is passed to the WeakSet
constructor. Since this array contains two objects, those objects are added into the weak set. Keep in mind that an error will be thrown if the array contains any non-object values, since WeakSet
can’t accept primitive values.
Key Differences Between Set Types
The biggest difference between weak sets and regular sets is the weak reference held to the object value. Here’s an example that demonstrates that difference:
let set = new WeakSet(),
key = {};
// add the object to the set
set.add(key);
console.log(set.has(key)); // true
// remove the last strong reference to key, also removes from weak set
key = null;
After this code executes, the reference to key
in the weak set is no longer accessible. It is not possible to verify its removal because you would need one reference to that object to pass to the has()
method. This can make testing weak sets a little confusing, but you can trust that the reference has been properly removed by the JavaScript engine.
These examples show that weak sets share some characteristics with regular sets, but there are some key differences. Those are:
- In a
WeakSet
instance, theadd()
method throws an error when passed a non-object (has()
anddelete()
always returnfalse
for non-object arguments). - Weak sets are not iterables and therefore cannot be used in a
for-of
loop. - Weak sets do not expose any iterators (such as the
keys()
andvalues()
methods), so there is no way to programmatically determine the contents of a weak set. - Weak sets do not have a
forEach()
method. - Weak sets do not have a
size
property.
The seemingly limited functionality of weak sets is necessary in order to properly handle memory. In general, if you only need to track object references, then you should use a weak set instead of a regular set.
Sets give you a new way to handle lists of values, but they aren’t useful when you need to associate additional information with those values. That’s why ECMAScript 6 also adds maps.