I decided to relearn JavaScript after not having worked with it since school so I'm going through the JavaScript track at exercism.io. Yesterday, I ran into behavior I didn't expect with Array.prototype.every(), and after getting help from some actual JS developers, they were surprised too.
The Exercism project I was working on was to create a class to check if a sentence is a pangram. (A pangram is a sentence that contains every letter of the alphabet.)
My approach was simple: create a sparse array with 26 elements (one for each letter) walk the sentence, and for each letter, look up its index in the alphabet, then set that index in my array to true. After I was done walking the sentence, I then used Array.prototype.every() to check the results. Array.prototype.every() seemed perfect for this: it takes a callback function, and applies it to every element of an array. If it gets back a falsy return value from one an element, it stops processing and returns false. If it makes it through the list, it returns true. In my case, the callback was a simple lambda that I hesitate to even call a function - it simply returns the value it's given right back. This is approximately what I did the first time around:
I thought this looked pretty good, maybe even idiomatic, but it didn't work. Every sentence returned true.
To try to figure out what was going on, I replaced the lambda function in foundletters.every with a function that printed each element in the array before returning it. My expectation was to get a bunch of undefineds and a bunch of trues, but instead I got just a bunch of trues. At this point, it should've been pretty obvious what was going on, but I decided not to think about it any more and replaced it with for..of loop:
My mentor on Exercism sent feedback suggesting I use Array.prototype.every(), though, so I pressed on to figure out a solution. (It's probably worth mentioning at this point that MDN documents this behavior pretty clearly...). I asked some very smart coworkers who use JavaScript, and they had the same expectation as me, but helped me get to the right solution.
First: what's going on here. MDN says the "callback is invoked only for indexes of the array which have assigned values; it is not invoked for indexes which have been deleted or which have never been assigned values."
Ok. That makes perfect sense, sort of.
It's not what I want it to do, but now that I know that it does, it explains the behavior I'm seeing. Does this mean we can't use Array.prototype.every() anywhere where we might run into undefined? Yes. And also no.
This behavior only happens if the array slot is undefined - if the array slot is defined, and points to undefined, then Array.prototype.every() will process the item. In short:
For my final solution, I arrived at this:
Notice on line 8 I've added a call to Array.prototype.fill(), so foundLetters starts filled with falses, making this all work.
Interestingly, this code is probably very slightly slower to run, since we have to iterate over the whole array one more time. This is really not a meaningful performance change, since both solutions are O(N) in all cases.
The Exercism project I was working on was to create a class to check if a sentence is a pangram. (A pangram is a sentence that contains every letter of the alphabet.)
My approach was simple: create a sparse array with 26 elements (one for each letter) walk the sentence, and for each letter, look up its index in the alphabet, then set that index in my array to true. After I was done walking the sentence, I then used Array.prototype.every() to check the results. Array.prototype.every() seemed perfect for this: it takes a callback function, and applies it to every element of an array. If it gets back a falsy return value from one an element, it stops processing and returns false. If it makes it through the list, it returns true. In my case, the callback was a simple lambda that I hesitate to even call a function - it simply returns the value it's given right back. This is approximately what I did the first time around:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | export default class Pangram { constructor(sentence) { this.maybePangram = sentence; } isPangram() { const foundLetters = Array(ALPHABET_LENGTH); for (const letter of this.maybePangram.toLowerCase()) { if ('a' <= letter && letter <= 'z') { foundLetters[letter.codePointAt(0) - OFFSET] = true; } } return foundLetters.every(letter => letter); } } const OFFSET = 'a'.codePointAt(0); const ALPHABET_LENGTH = 26; |
I thought this looked pretty good, maybe even idiomatic, but it didn't work. Every sentence returned true.
To try to figure out what was going on, I replaced the lambda function in foundletters.every with a function that printed each element in the array before returning it. My expectation was to get a bunch of undefineds and a bunch of trues, but instead I got just a bunch of trues. At this point, it should've been pretty obvious what was going on, but I decided not to think about it any more and replaced it with for..of loop:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | export default class Pangram { constructor(sentence) { this.maybePangram = sentence; } isPangram() { const foundLetters = Array(ALPHABET_LENGTH); for (const letter of this.maybePangram.toLowerCase()) { if ('a' <= letter && letter <= 'z') { foundLetters[letter.codePointAt(0) - OFFSET] = true; } } for (const found of foundLetters) { if (!found) return false; } return true; } } const OFFSET = 'a'.codePointAt(0); const ALPHABET_LENGTH = 26; |
First: what's going on here. MDN says the "callback is invoked only for indexes of the array which have assigned values; it is not invoked for indexes which have been deleted or which have never been assigned values."
Ok. That makes perfect sense, sort of.
It's not what I want it to do, but now that I know that it does, it explains the behavior I'm seeing. Does this mean we can't use Array.prototype.every() anywhere where we might run into undefined? Yes. And also no.
This behavior only happens if the array slot is undefined - if the array slot is defined, and points to undefined, then Array.prototype.every() will process the item. In short:
1 2 3 4 | console.log(Array(1)); // [undefined] console.log(Array(1).fill(undefined)); // [undefined] console.log(Array(1).every(i => i)); // true console.log(Array(1).fill(undefined).every(i => i)); // false |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | export default class Pangram { constructor(sentence) { this.maybePangram = sentence; } isPangram() { const foundLetters = Array(ALPHABET_LENGTH).fill(false); for (const letter of this.maybePangram.toLowerCase()) { if ('a' <= letter && letter <= 'z') { foundLetters[letter.codePointAt(0) - OFFSET] = true; } } return foundLetters.every(letter => letter); } } const OFFSET = 'a'.codePointAt(0); const ALPHABET_LENGTH = 26; |
Comments
Post a Comment