Learn JavaScript

Lesson 1 of 9 · Map, Set, WeakMap, Iterators, Generators and Regular Expressions

Module 16 · Map, Set, WeakMap, Iterators, Generators and Regular Expressions

The Picture: A Table With Real Keys, a Bag of Distinct Values, a Function That Pauses, a Pattern for Text

FreeReading

In this lesson

  • Explain why a plain object cannot count things keyed by an object, and how a Map keeps such keys apart.
  • Use a Set to keep each value once, and read its size and membership.
  • Write a generator that hands out values one at a time, and find text with a first regular expression.

Rafi runs a small tea stall near Dhaka University. He keeps a little program that counts how often each regular comes back. Each customer is an object, such as { name: "Mitu" }. So Rafi writes the obvious thing: an object, with the customer as the key and the count as the value.

The program runs. But at the end of the week every count is the same huge number, as if one person drank all the tea. Nothing crashed, and no line looks wrong. The bug is in what an object does with its keys.

This module gives you four tools that fix this kind of problem. A Map is a table whose keys can be anything, objects included. A Set is a bag that keeps each value once. A generator is a function that pauses and hands out one value at a time. A regular expression is a pattern that finds text. This lesson shows each one at work, and the rest of the module takes them apart.

A plain object turns every key into a string

In Module 8 you learned that an object's keys are strings. Write obj[42] and the key is "42". That rule also applies when the key is an object. JavaScript turns the object into a string first, and every plain object becomes the same string.

const rafi = { name: "Rafi" };
const mitu = { name: "Mitu" };
const visits = {};
for (const person of [rafi, mitu, rafi]) {
  visits[person] = (visits[person] ?? 0) + 1;
}
console.log(Object.keys(visits));
console.log(visits[rafi]);
[ '[object Object]' ]
3
Run in Compiler

There is only one key, "[object Object]". Rafi and Mitu both landed on it, so Rafi's count is 3, the total of everyone. That is the bug in the tea stall. An object is a fine table when the keys are words. It cannot tell two objects apart.

Map: a table whose keys can be anything

A Map is a table built for this job. You put a pair in with set(key, value) and read it back with get(key). The key can be any value at all: a string, a number, true, a function, or an object. An object key is compared by identity, which means "is this the very same object?", not by what it contains.

const rafi = { name: "Rafi" };
const mitu = { name: "Mitu" };
const visits = new Map();
for (const person of [rafi, mitu, rafi]) {
  visits.set(person, (visits.get(person) ?? 0) + 1);
}
console.log(visits.size);
console.log(visits.get(rafi), visits.get(mitu));
for (const [person, count] of visits) console.log(person.name, count);
2
2 1
Rafi 2
Mitu 1
Run in Compiler

Now there are two entries, one per person. size counts them, with no Object.keys needed. And a for...of loop over a Map gives you [key, value] pairs, which the loop takes apart into two names.

A Map also keeps its keys in the order they were first set. Setting a key that is already there changes its value but not its place.

const shelf = new Map();
shelf.set("tea", 15).set(7, "seven").set(true, "yes").set("coffee", 40);
shelf.set("tea", 20);
console.log([...shelf.keys()]);
console.log(shelf.get("tea"));
[ 'tea', 7, true, 'coffee' ]
20
Run in Compiler

Each set returns the Map itself, so the calls chain. The keys come back as a string, a number, a boolean and a string, in the order they arrived. tea stays first, even though it was set again with a new price.

The widget below draws a Map after each line. An object key is drawn by its identity, so two objects that look the same are still two boxes.

The same steps as a table, one row per step.

StepLineWhat happened
1const visits = new Map();An empty Map: no entries yet.
2visits.set(rafi, 1);The object rafi is the key itself, drawn as obj1.
3visits.set(mitu, 1);mitu is a different object, so it gets its own entry, obj2. Size is 2.
4visits.set(rafi, visits.get(rafi) + 1);The same object finds the same entry, so its count becomes 2 and it stays first.
5visits.set({ name: "rafi" }, 1);Same contents, new object: a third entry, obj3. Size is 3.
6visits.set("rafi", 5);A string key is another key again. Size is 4.

Look at step 5. The new object holds the same name as rafi, yet it gets its own entry. A Map never looks inside an object key. It only asks whether it is the same object.

Set: each value once

A Set is a Map with no values: a bag that holds each value at most once. You add a value, ask has, and read size. Adding a value that is already inside does nothing. This makes a Set the natural tool for "have I seen this before?" and for removing repeats.

const scans = ["rafi", "mitu", "rafi", "nila", "mitu"];
const unique = new Set(scans);
console.log(unique.size);
console.log(unique.has("nila"), unique.has("tania"));
console.log([...unique]);
3
true false
[ 'rafi', 'mitu', 'nila' ]
Run in Compiler

new Set(scans) adds every name in turn, so the repeats fall away. A Set keeps first-seen order too, so spreading it gives the names in the order they first came. has answers in about the same time whether the set holds five names or five million. Lesson 04 measures that against an array's includes.

A generator hands out values one at a time

So far every function you wrote ran from top to bottom in one go. A generator is a function that can stop in the middle and continue later. You write it with function*, and inside it, yield hands out one value and pauses right there.

Calling a generator function does not run its body. It gives you a generator object. Each time you call its next() method, the body runs until the next yield. Then next() returns { value, done }.

function* threeDays() {
  console.log("  (start)");
  yield "Saturday";
  console.log("  (resumed)");
  yield "Sunday";
  yield "Monday";
}
const days = threeDays();
console.log("made the generator");
console.log(days.next());
console.log(days.next());
console.log(days.next());
console.log(days.next());
made the generator
  (start)
{ value: 'Saturday', done: false }
  (resumed)
{ value: 'Sunday', done: false }
{ value: 'Monday', done: false }
{ value: undefined, done: true }
Run in Compiler

Read the order closely. "made the generator" prints before "(start)", because making the generator ran nothing. The first next() runs up to the first yield. The second resumes right after it and prints "(resumed)". The fourth finds the body finished, so it says done: true.

A for...of loop calls next() for you and stops when done is true. Because the body only runs when asked, a generator can even be endless. The loop just stops asking.

function* countFrom(n) {
  while (true) yield n++;
}
for (const k of countFrom(5)) {
  if (k > 8) break;
  console.log(k);
}
5
6
7
8
Run in Compiler

while (true) looks like a hang, but it is safe here. Each turn of that loop pauses at yield, and break stops asking for more. The widget shows a generator talking both ways: next(value) can also hand a value back in.

The same steps as a table, one row per step.

StepLineStateYieldedReceivedWhat happened
1const it = order();suspendedCalling order() runs none of its body. It only makes a paused generator.
2function* order() {runningThe first next() starts the body at its top.
3it.next();suspendedsize?It pauses at a yield and hands out "size?".
4 const size = yield "size?";runninglargeIt resumes inside the same yield, which now gives back "large".
5it.next("large");suspendeddrink for a large meal?largeIt pauses at a yield and hands out "drink for a large meal?".
6 const drink = yield "drink for a " + size + " meal?";runninglassiIt resumes again: drink is "lassi", and size still holds "large".
7it.next("lassi");donelassiThe body returns "large meal with lassi", so next() says done: true.
8it.next();doneA finished generator only answers done: true, with no value.

Steps 4 and 6 are the surprise. The value passed to next("large") comes out of the yield that paused before. So a yield is a two-way door: a value goes out, and later a value comes back in.

A regular expression is a pattern for text

The last tool works on text. A regular expression, or regex, is a small pattern that describes text you want to find. You write it between two slashes. In /#\w+/, the # means a literal hash sign, \w means one letter, digit or underscore, and + means "one or more of that".

const post = "Fuchka at #Dhanmondi, then tea at #TSC!";
console.log(/#\w+/.test(post));
console.log(post.match(/#\w+/g));
console.log("call 01712-345678".replace(/\d/g, "#"));
true
[ '#Dhanmondi', '#TSC' ]
call #####-######
Run in Compiler

test answers yes or no: does the pattern appear anywhere? match with the g flag, for "global", returns every match as an array. replace with /\d/g swaps every digit, which no plain string method does in one call. Lesson 02 goes through the whole pattern language, piece by piece.

Map, Set, generators and regex at a glance

const m = new Map();             // a table; keys of any type, by identity
m.set(key, value)  m.get(key)    // put and read; set returns m, so calls chain
m.has(key)  m.delete(key)  m.size
for (const [k, v] of m) { }      // pairs, in the order keys were first set
const s = new Set(list);         // each value once; s.add(v), s.has(v), s.size
function* gen() { yield v; }     // gen() makes a paused generator
g.next()                         // runs to the next yield: { value, done }
/#\w+/g                          // a regex: # then one or more word characters, every match
text.match(re)  re.test(text)  text.replace(re, "new")
Example 1: a word count in first-seen order

Mitu wants to know which words her customers type most in their reviews. A Map counts them, and its order gives the words as they first appeared.

const reviews = "good tea good price slow service good tea";
const counts = new Map();
for (const word of reviews.split(" ")) {
  counts.set(word, (counts.get(word) ?? 0) + 1);
}
for (const [word, count] of counts) console.log(word, count);
const top = [...counts].sort((a, b) => b[1] - a[1])[0];
console.log("most used:", top[0]);
good 3
tea 2
price 1
slow 1
service 1
most used: good

(counts.get(word) ?? 0) + 1 is the counting idiom of this module. A missing key reads as undefined, which ?? turns into 0. Spreading the Map gives an array of pairs, and Module 7's sort orders them by count.

Run in Compiler
Example 2: an endless Fibonacci, taken lazily

A generator can describe a sequence that never ends, and a small helper takes as many values as you want. Nothing is computed until it is asked for.

function* fibonacci() {
  let [a, b] = [0, 1];
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}
function* take(n, items) {
  for (const item of items) {
    if (n-- <= 0) return;
    yield item;
  }
}
console.log([...take(10, fibonacci())]);
[
  0, 1,  1,  2,  3,
  5, 8, 13, 21, 34
]

take is itself a generator that wraps another one. It passes values through and stops after n. Spreading take(10, ...) asks for values until take is done. So the endless generator only ever runs ten steps.

Run in Compiler

Where this is used

  • Redux-Saga: this library for React apps writes each side effect as a generator function that yields instructions for the library to run.
  • Node.js readline: an interface over a file can be read line by line with for await, because it is an async iterable, which Lesson 02 covers.
  • VS Code: the search box has a regex button, and with it on, a pattern like \d{4} finds every four-digit number in a project.
  • lodash _.memoize: it keeps past results in a Map-like cache keyed by the first argument, so an object argument works as a key.

Common mistakes

1. Reading a Map with brackets

const prices = new Map([["tea", 15]]);
console.log(prices["tea"]);
prices["coffee"] = 40;
console.log(prices.size, prices.get("coffee"));
undefined
1 undefined

Square brackets read and write plain object properties, not Map entries. So prices["tea"] is undefined, even though the Map holds tea. And prices["coffee"] = 40 sticks a property on the Map object, which size and get never see. Always use get and set.

2. Calling a generator function and expecting it to run

function* greet() {
  console.log("hello from the generator");
  yield 1;
}
greet();
console.log("done");
done

Only "done" prints. Calling greet() makes a paused generator object and throws it away, without running a single line of the body. The body runs only when something calls next(), directly or through a for...of loop or a spread.

3. Forgetting the g flag

const order = "2 tea, 3 singara, 10 samosa";
console.log(order.match(/\d+/));
console.log(order.match(/\d+/g));
[
  '2',
  index: 0,
  input: '2 tea, 3 singara, 10 samosa',
  groups: undefined
]
[ '2', '3', '10' ]

Without g, match stops at the first match and returns it with extra details: where it was found, and the whole input. Only with g do you get every number. When a result has one item and an index, check the flags first.

Brain teaser

How big is this Set?

Nadia builds a Set from seven values. She says the answer must be 7, because a Set only drops values that are written the same way.

const s = new Set([1, "1", 1.0, NaN, NaN, 0, -0]);
console.log(s.size);

Is she right? Think about which of the seven are really the same value.

Exercise 1Easy

Nadia keeps the visitor book at a book fair stall. Every visitor writes a name when they walk in. Nadia wants to greet first-time visitors and regulars differently. At closing time she also wants to know how many different people came.

Input. The first token is n. Then come n names, in the order the visitors arrived.

Output. For each name, one line: new NAME if this name has not appeared before today, otherwise again NAME. After that, one line distinct D, the number of different names.

Constraints. 1 <= n <= 60000. Every name is 1 to 11 lowercase English letters..

Sample.

6
rafi
mitu
rafi
constructor
mitu
rafi
new rafi
new mitu
again rafi
new constructor
again mitu
again rafi
distinct 3
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out

console.log(out.join("\n"));
Run in Compiler

Graded as first-visits, a free problem in this module's problem set.

Exercise 2Easy

A clinic in Mirpur has several counters: one for doctors, one for tests, one for the pharmacy. Each counter hands out its own serial tokens, starting from its own first number. Shuvo writes the machine that prints them. Every patient names a counter and gets that counter's next token.

Input. The first token is m. Then m pairs follow, each a counter name and the first number it hands out. Then comes n, and then n counter names, one per patient.

Output. For each patient, one line NAME-K: the counter's name, a hyphen, and the token number. A counter's tokens go up by one each time.

Constraints. 1 <= m <= 100. 1 <= n <= 60000. Every first number is 1 to 1000. Every name is 1 to 8 lowercase letters, the m names are different, and every patient names one of them..

Sample.

2
doc 1
lab 50
5
doc
lab
doc
doc
lab
doc-1
lab-50
doc-2
doc-3
lab-51
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out

console.log(out.join("\n"));
Run in Compiler

Graded as token-counters, a free problem in this module's problem set.

Exercise 3Easy

Mitu runs the page of a small cafe and wants to know which hashtags people use in their posts. A hashtag is a # followed by one or more letters, digits or underscores. Case does not matter, so #Fuchka and #fuchka are the same tag. She wants each tag once, with how many times it was used.

Input. The first line holds n. Each of the next n lines is one post, a line of text with spaces. A post may hold no hashtag, or several, even two in a row such as #a#b.

Output. One line per distinct tag, written in lowercase with its #, a space, and its count. List the tags in the order they first appear. If no post holds a tag, print the single line none.

Constraints. 1 <= n <= 5000. Every post is 1 to 150 characters: printable ASCII and spaces. The last line of the input is never empty..

Sample.

3
Best #fuchka in #Dhaka! #fuchka
no tags here, just 100% love
#dhaka#rain and #FUCHKA_time
#fuchka 2
#dhaka 2
#rain 1
#fuchka_time 1
const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out

console.log(out.join("\n"));
Run in Compiler

Graded as hashtag-count, a free problem in this module's problem set.

Exercise 4Hard

Arif's radio station must not repeat a song too soon. Given today's playlist, find the longest stretch of songs played in a row with no song repeated inside it.

Input. n, then n song names in the order they were played.

Output. one number, the length of the longest stretch with no repeat.

Constraints. 1 <= n <= 100000. Each name is 1 to 10 lowercase letters.

Sample. the input below.

7
dhaka nodi rain dhaka hawa nodi sky
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];

// your code: read with next() and nextInt(), push every line of output to out

console.log(out.join("\n"));

Sample output.

5
Run in Compiler

Common doubts

  • Should I stop using objects as tables?

    No. When the keys are a fixed set of words, like the fields of a user, an object is the right tool and JSON loves it. Reach for a Map when the keys are objects, when they come from outside, or when you add and delete many of them. Lesson 04 gives the full rule.

  • Is a Set just an array without repeats?

    It behaves like one in a for...of loop, but it is built differently. A Set answers has without walking its values, so membership stays fast as it grows. It has no index, though: there is no s[0]. Spread it into an array when you need positions.

  • Why would I ever want a function that pauses?

    Because some sequences are too long, or endless, to build in full. A generator makes the next value only when asked. It also keeps its place between calls, which is what a paged feed or a counter needs.

  • Are regular expressions only for experts?

    The full language is large, but you will use a small part of it every week. Finding numbers, checking a format or splitting on several separators each takes one short pattern. Lesson 02 builds it up one piece at a time.

Key takeaways

  • A plain object turns every key into a string, so two object keys collide as "[object Object]".
  • A Map keeps keys of any type, compares objects by identity, counts with size and keeps insertion order.
  • A Set holds each value once; has and add are the tools for "seen before?" and for removing repeats.
  • A generator, written function*, runs only when next() asks, pauses at each yield, and can be endless.
  • A regex like /#\w+/g describes text; test, match and replace use it.
  • Go deeper: Under the Hood, V8's Map, When a WeakMap Lets Go, Paused Generators and Regex Backtracking (Pro).

Next: every method of Map, Set, WeakMap, iterators, generators and regular expressions, one by one.

End of lesson 1

Mark it done, and your progress moves with you.

Next: Every Method: Map, Set, WeakMap, Iterators, Generators and the Regex Language, One by One