Imagine it's been a rough week.
Finally, it's time to refill our weekly chocolate supply. As usual, we're using JavaScript to fill our chocolate supply.
In pseudocode, "a box of 21 of the same kind of chocolate" would be something like:
- Get a box.
- Make sure the box has enough slots for 21 chocolates.
- As long as there are still empty slots:
- Get a chocolate from the giant bag of chocolates.
- Put one chocolate into the slot you're looking at.
- Move on to the next slot.
Pretty reasonable, right? Let's give it a shot!
(Note: all snippets should be runnable as-is in a repl or console as desired, just by copy-pasting if you like.)
Attempt 1: .map
For a first swing, maybe we'd try map
:
let chocolate = {
kind: 'dark',
filling: 'raspberry ganache'
};
// Prep our box and make sure it has 21 slots
let weeklyChocolateSupplyBox = new Array(21);
// Put one chocolate into every slot
weeklyChocolateSupplyBox.map(_emptySlot => chocolate);
(If you're wondering about the underscore (i.e., _emptySlot
), that means the variable is either unimportant or unused. Some languages enforce that as a rule, like Elixir; here, it's purely convention.)
So far, so good: we make an array with 21 slots, we loop over it with map
, and put a chocolate in at each slot.
We actually put the exact same chocolate in each slot, which would be less than ideal in the real world — any changes to any one chocolate would effect EVERY chocolate, as they are all this way the same chocolate.
(╯°□°)╯︵ [Ɛ x ʎʇdɯǝ]
Maybe not too surprisingly, it doesn't work. Instead of an array containing 21 identical chocolates, if you run that snippet in a console, you'll get something like: [empty × 21]
.
Less than ideal, to say the least.
Attempt 2: for (let index ... )
While I love using the various array methods when I can — e.g., forEach
, filter
, map
, etc., I've found that since I learned C-style for
loops first, I often refer back to them when things aren't working. Similarly, as a sanity check, I often log out something before and after the loop, so I can make sure nothing truly whacky is going on like being in the wrong file, etc.
At the end of the day, a loop is a loop, use what is clearest to you and others.
So, we try again!
// same as before
chocolate = {
kind: 'dark',
filling: 'raspberry ganache'
};
// assign the variable a whole new array to reset.
weeklyChocolateSupplyBox = new Array(21);
console.log('before loop');
for (let index = 0; index < weeklyChocolateSupplyBox.length; index += 1) {
console.log('loop number %d', index);
weeklyChocolateSupplyBox[index] = chocolate;
}
console.log(weeklyChocolateSupplyBox);
console.log('after loop');
This time, we succeed. We have a box with 21 chocolates in it, as desired! Awesome.
Attempt 3: for ... of
Say that I didn't use an old-school for
loop: say I had gone ahead with a for ... of
loop — after all, I want to loop over this array and put things into it, right? This way, too, I can eliminate needing to increment the index myself, and not worry about if I forgot a condition or something. Great!
So let's write the code, and use a for ... of
loop instead. We start off the same as before, and sketch out the skeleton of our for
loop.
chocolate = {
kind: 'dark',
filling: 'raspberry ganache'
};
// assign the variable a whole new array to reset.
weeklyChocolateSupplyBox = new Array(21);
console.log('before loop');
for (let emptySlot of weeklyChocolateSupplyBox) {
console.log('emptySlot', emptySlot);
// Put a chocolate into our emptySlot
}
console.log('after loop');
...but what goes inside the loop? We have an emptySlot
— but no way to add a chocolate to it. If we ran this now, we'd just see emptySlot undefined
logged out 21 times. Not helpful.
Attempt 4: for ... in
In JavaScript, everything is an object. Arrays are too — in particular, are an object created by the Array
constructor. By definition, they have a length
property, and numeric, ordered keys.
There's another kind of for
loop we haven't tried: for ... in
, which loops over the properties of an object. For something like an object literal, it loops over the property names; for an array, it loops over the indices. A little weird, but if you think about it, that seems sort of reasonable — we can use both a string key and an array index to set the value, and then later access that value by the key, right?
const dog = { name: 'Simon', age: 13, weight: 50 };
const someNumbers = [3, 1, 4];
for (let key in dog) {
console.log('dog key', key); // 'name', then 'age', then 'weight'
console.log('dog value', dog[key]); // 'Simon', then 13, then 50
}
for (let key in someNumbers) {
console.log('someNumbers key', key); // '0', then '1', then '2'
console.log('someNumbers value', someNumbers[key]); // 3, then 1, then 4
}
Okay, cool, nothing too interesting there, except for maybe being able to do that with arrays as well.
So, let's try the chocolate experiment again. The normal for
loop worked — let's try the same thing but with a for ... in
loop, and we can use the index to add it to the array like before.
chocolate = {
kind: 'dark',
filling: 'raspberry ganache'
};
// assign the variable a whole new array to reset.
weeklyChocolateSupplyBox = new Array(21);
console.log('before loop');
for (let emptySlotIndex in weeklyChocolateSupplyBox) {
console.log('emptySlotIndex', emptySlotIndex);
weeklyChocolateSupplyBox[emptySlotIndex] = chocolate;
}
console.log('after loop');
This time, we see before loop
and after loop
, and ... literally nothing else.
What's the difference?
So, we tried a number of things:
-
map
: failed -- did nothing -
for ... of
loop: failed -- no way to add a chocolate -
for ... in
loop: failed -- never even looped! - basic
for
loop: worked!
None of this answers the question, though: why does a for
loop work and the other options fail, with for ... in
never looping?
The answer lies in the specification of JavaScript itself.
The Array constructor does create an Array
object and set its length
to be the (single, numeric) value given1.
What it does not do, though, is set the indices (which are just keys, remember, which happen to be numbers) on the array object.
// This is about what happens:
const newArray = {
length: 2
};
// NOT this:
const badNewArray = {
length: 2,
'0': undefined,
'1': undefined
};
If you've ever tried to remove something from an object — truly get rid of it, not just give it an undefined
value, but remove the property entirely — you know that chocolate['filling'] = undefined
won't cut it. The property will still be there, just with undefined
as its value.
To remove a property, you have to delete
it: delete chocolate['filling'];
. After that, if you inspect the object, there will be no key called filling
present. If we looked at its keys, we would not see filling
listed.
So, what happens if you delete
an index from an array?
const someOtherArray = ['value at 0', 'value at 1', 'value at 2'];
console.log(someOtherArray); // ["value at 0", "value at 1", "value at 2"]
console.log(someOtherArray.length); // => 3
delete someOtherArray[1];
console.log(someOtherArray.length); // => still 3
console.log(someOtherArray);
// Chrome: ["value at 0", empty, "value at 2"]
// Firefox: ["value at 0", <1 empty slot>, "value at 2"]
// Safari: ["value at 0", 2: "value at 2"]
Each browser shows you the same thing, just differently: an array with length three and only two things in it, at 0 and 2. There's nothing at index 1 anymore — because there is no index 1. Each array still has a length of 3.
This explains why for ... in
failed so badly: the for ... in
loop works over the keys of an object: there were no keys (indices) for it to enumerate over. Similarly, if we had looped above, both before and after deleting the index, we would have gone into the loop 3 times before deleting the index, and twice after its deletion.
A not-so-well-known symbol
Here's another clue: [...new Array(3)]
does what we had probably originally expected — and gives us [undefined, undefined, undefined]
.
The answer is iterators; specifically, the value of the Symbol.iterator
on an object. (Symbol
s are a JavaScript primitive whose value is unique, and are often used as identifiers — much like atoms in other languages.)
If an object has a Symbol.iterator
, that object is iterABLE: it has an iterATOR, an object that adheres to the iterator protocol. Iterators are very neat and very powerful — they're the guts behind async
, await
, generators, promises, the spread operator, for ... of
, etc; they allow for entering and exiting different execution contexts asynchronously.
For our purposes, though, it's enough to know that an iterator essentially keeps track of your place in a loop. Many JavaScript objects have a default iterator — arrays, as well as anything else that you can spread (use ...
as above).
In particular, the default iterator specification2 says something like:
- Start with
index = 0
.- As long as
index
is less thanarray.length
:
Yield
the value ofarray[index]
- Increment the index,
index += 1
- Keep going
Lots of other array methods use similar logic — e.g., toString
uses join
, which has a similar algorithm.
What do you get when you access a property that isn't on an object? In some languages, it wouldn't compile at all; in JavaScript, however, you don't get an error, you just get undefined
— which, of course, can also be the value if the key is there.
const withKeyAndUndefined = { apples: undefined, pears: 3 };
const withKeyAndValue = { apples: 12, pears: 99 };
const withoutKey = { pears: 74 };
console.log(withKeyAndUndefined['apples']); // => undefined
console.log(withKeyAndValue['apples']); // => 12;
console.log(withoutKey['apples']); // => undefined
As for map
failing as well?
Well... The specification3 for map
(and forEach
and other similar methods) spells out that the callback given is only executed for those values "which are not missing" — that is, non-empty slots or where the indices are defined (so, nowhere right after construction).
const yetAnotherArray = new Array(5);
yetAnotherArray.map(
value => {
throw new Error('never gonna happen');
}
).fill(
null // now we put something in every spot
).map(value => {
console.log('now, this will show "null": ', value);
return value;
});
Meanwhile, our basic for
-loop worked right off the bat: because we were creating those indices by setting a value under that key, the same way I can do const dog = {name: 'Simon'}; dog.favoriteFood = 'peanut butter';
without favoriteFood
ever having been defined as being on the original object.
const array = new Array(5);
for (let index = 0; index < array.length; index += 1) {
// does 'index' exist? Yes! It's its own variable, after all
console.log('index', index);
console.log(`before: ${index} in array?`, index in array);
array[index] = 'whee';
console.log(`after: ${index} in array?`, index in array);
}
There is, conveniently, a method to fill
an array with any value. We can use that here, too.
For a simple case, we can just do new Array(5).fill(chocolate)
; for anything more complex, though, we need first to fill
the array with something — anything, even null
or undefined
.
weeklyChocolateSupplyBox = new Array(21).fill(chocolate);
console.log(weeklyChocolateSupplyBox);
const rangeFrom_1_to_10 = new Array(10).fill(null).map((_null,index) => index + 1);
console.log(rangeFrom_1_to_10);
Remember, though, that what we actually end up with here is 21 references to the same chocolate — if we melt one chocolate, they all melt, as what we really did was put the same identical chocolate into every slot through some truly spectacular quantum confectionary. (Chocolate, however, seemed much more enjoyable than an array of strings or numbers.)
Top comments (1)
I think I've come across that issue with mapping over an array of empty slots before and I didn't have the time to dig in and understand it. Thanks for this post!