Some JavaScript Generators & Memoization interview questions

Mayank C
Tech Tonic

--

1 What is the main difference between a generator and a regular function in JavaScript?

The main difference between a generator and a regular function in JavaScript is that a generator can be paused and resumed, whereas a regular function cannot.

A regular function runs to completion once it’s called, and it returns a value. A generator, on the other hand, returns an iterator object that can be used to generate a sequence of values over time.

Here are some key differences:

  • Yield keyword: Generators use the yield keyword to produce a value, whereas regular functions use the return keyword.
  • Pause and resume: Generators can be paused and resumed using the next() method, whereas regular functions cannot be paused or resumed.
  • Iterator object: Generators return an iterator object, whereas regular functions return a value.
  • Async behavior: Generators can be used to create asynchronous behavior, whereas regular functions are synchronous.
  • Memory management: Generators can help with memory management by only creating objects when needed, whereas regular functions create all objects at once.

Here’s an example of a generator:

function* generator() {
yield 1;
yield 2;
yield 3;
}

const gen = generator();
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
console.log(gen.next().value); // 3

And here’s an example of a regular function:

function regular() {
return [1, 2, 3];
}

const arr = regular();
console.log(arr[0]); // 1
console.log(arr[1]); // 2
console.log(arr[2]); // 3

In summary, generators provide more control over the execution of code and can be used to create asynchronous behavior, whereas regular functions run to completion and return a value.

2 What is the purpose of the yield* keyword in generators? Provide an example.

The yield* keyword in generators is used to delegate the iteration to another generator or iterable object. It allows a generator to yield values from another generator or iterable, effectively "flattening" the iteration.

Here’s an example:

function* generator1() {
yield 1;
yield 2;
yield* generator2(); // delegate to generator2
yield 4;
}

function* generator2() {
yield 3;
yield 5;
}

const gen = generator1();
for (const num of gen) {
console.log(num);
}
// Output:
// 1
// 2
// 3
// 5
// 4

In this example, generator1 yields values from generator2 using yield*. This allows generator1 to "flatten" the iteration and yield all the values from both generators in a single sequence.

Without yield*, you would have to use nested loops or other workarounds to achieve this behavior. yield* makes it easy to compose and delegate iteration to other generators or iterables.

Note that yield* can also be used with arrays, sets, or other iterables, not just generators. It's a powerful feature for controlling and delegating iteration in JavaScript.

3 Write a generator that produces the Fibonacci sequence up to a given number.

Here is a generator that produces the Fibonacci sequence up to a given number:

function* fibonacciGenerator(n) {
let a = 0;
let b = 1;
while (a <= n) {
yield a;
[a, b] = [b, a + b];
}
}

// Usage:
const fibGen = fibonacciGenerator(10);
for (const num of fibGen) {
console.log(num);
}
// Output:
// 0
// 1
// 1
// 2
// 3
// 5
// 8

This generator uses the yield keyword to produce the next number in the Fibonacci sequence. It starts with the first two numbers in the sequence (0 and 1) and then uses a while loop to generate the rest of the sequence until it reaches the given number.

The [a, b] = [b, a + b] line is a clever way to update the values of a and b in a single statement. It’s called a “destructuring assignment” and it’s a feature of modern JavaScript.

You can use this generator to produce the Fibonacci sequence up to any given number. Just pass the number as an argument to the generator function and iterate over the generator to get the sequence.

Note: This generator uses a recursive approach to generate the Fibonacci sequence, which can be less efficient than an iterative approach for large numbers. If you need to generate a very large Fibonacci sequence, you may want to consider using an iterative approach instead.

4 How would you use a generator to implement a lazy loading mechanism for an array of data?

Here’s an example of how you can use a generator to implement a lazy loading mechanism for an array of data:

function* lazyLoadArray(data, chunkSize = 10) {
for (let i = 0; i < data.length; i += chunkSize) {
yield data.slice(i, i + chunkSize);
}
}

const dataArray = [...Array(100).keys()]; // example data array
const lazyLoader = lazyLoadArray(dataArray);

// Usage:
for (const chunk of lazyLoader) {
console.log(chunk);
// process the chunk
}

In this example, the lazyLoadArray generator takes an array of data and a chunk size as arguments. It yields chunks of the data array, starting from the beginning, in increments of the chunk size.

The lazyLoader generator is created by calling lazyLoadArray with the data array and optional chunk size. You can then iterate over the generator using a for...of loop, which will yield each chunk of data as needed.

This implementation allows you to:

  • Load large datasets in smaller chunks, reducing memory usage
  • Process each chunk individually, without having to load the entire dataset at once
  • Implement pagination or infinite scrolling with ease

By using a generator, you can control the iteration and loading of data on demand, making it a great solution for lazy loading mechanisms.

Note: You can also add additional logic to the generator to handle errors, caching, or other optimization techniques as needed.

5 Write a generator that takes an array and produces each element of the array, but with a 1-second delay between each yield.

Here is a generator that takes an array and produces each element of the array, but with a 1-second delay between each yield:

function* delayedGenerator(arr) {
for (const element of arr) {
yield element;
yield new Promise(resolve => setTimeout(resolve, 1000));
}
}

const arr = [1, 2, 3, 4, 5];
const gen = delayedGenerator(arr);

for (const element of gen) {
if (typeof element === 'number') {
console.log(element);
}
}

This generator uses the yield keyword to produce each element of the array, and then uses yield again to produce a promise that resolves after a 1-second delay. The for...of loop that consumes the generator will pause execution for 1 second between each element.

Note that the if (typeof element === 'number') check is used to ignore the promises yielded by the generator and only log the actual elements of the array.

Also, keep in mind that this implementation uses the setTimeout function, which is not exact and may have some variation in the delay time. If you need a more precise delay, you may want to consider using a more advanced scheduling library or technique.

6 Write a generator that implements a simple state machine, transitioning between states based on input values.

Here is a generator that implements a simple state machine, transitioning between states based on input values:

function* stateMachine() {
let state = 'start';

while (true) {
switch (state) {
case 'start':
state = yield 'start';
break;
case 'idle':
if (yield 'idle') {
state = 'processing';
}
break;
case 'processing':
if (!(yield 'processing')) {
state = 'idle';
}
break;
default:
throw new Error(`Unknown state: ${state}`);
}
}
}

const machine = stateMachine();

// Send input values to the state machine
machine.next(); // 'start'
machine.next('idle'); // 'idle'
machine.next(true); // 'processing'
machine.next(false); // 'idle'
machine.next('invalid'); // Error: Unknown state: invalid

This generator uses a while loop to continuously yield values and transition between states based on the input values. The switch statement determines the next state based on the current state and the input value.

In this example, the state machine has three states: ‘start’, ‘idle’, and ‘processing’. The state transitions are as follows:

  • From ‘start’ to ‘idle’ with any input value
  • From ‘idle’ to ‘processing’ with a truthy input value
  • From ‘processing’ to ‘idle’ with a falsy input value

If an unknown state or invalid input value is encountered, an error is thrown.

Note that this is a very basic example of a state machine, and in a real-world scenario, you would likely want to add more features, such as handling errors, adding more states, and so on.

7 What is memoization, and how does it improve performance in JavaScript?

Memoization is an optimization technique used to speed up applications by storing the results of expensive function calls and reusing them when the same inputs occur again. In JavaScript, memoization can be implemented using caching, where the results of a function call are stored in an object or array, and then retrieved from the cache instead of recalculating the result.

Here’s a step-by-step explanation of how memoization works and improves performance in JavaScript:

  • Expensive function calls: Certain functions in your code may perform complex calculations, database queries, or API requests, which can be time-consuming and resource-intensive.
  • Cache creation: A cache is created to store the results of these expensive function calls. The cache can be an object, array, or even a dedicated data structure like a Map.
  • Function call with inputs: When a function is called with a set of inputs, the memoization mechanism checks the cache to see if the result for those exact inputs already exists.
  • Cache hit: If the result is found in the cache, the cached value is returned immediately, bypassing the need to recalculate the result.
  • Cache miss: If the result is not found in the cache, the function is executed, and the result is stored in the cache for future reference.
  • Subsequent calls: When the same function is called again with the same inputs, the memoization mechanism retrieves the cached result instead of recalculating it.

By implementing memoization, you can achieve significant performance improvements in JavaScript by:

  • Reducing the number of repeated calculations: Memoization avoids redundant calculations, saving time and resources.
  • Faster function calls: Memoization provides instant access to cached results, making subsequent function calls with the same inputs much faster.
  • Improved responsiveness: By reducing the time spent on calculations, memoization enhances the overall responsiveness and user experience of an application.
  • Efficient use of resources: Memoization helps conserve memory and CPU resources by avoiding unnecessary recalculations.

Common use cases for memoization in JavaScript include:

  • Calculating complex mathematical formulas
  • Fetching data from APIs or databases
  • Processing large datasets
  • Rendering components in React or other frameworks

To implement memoization in JavaScript, you can use techniques such as:

  • Caching results in an object or array
  • Using a memoization library or utility function
  • Creating a closure to store cached results
  • Utilizing JavaScript’s built-in caching mechanisms, like the Map data structure

Remember to carefully consider the trade-offs between memory usage and performance gains when implementing memoization, as excessive caching can lead to memory issues.

8 How would you memoize a function that takes an object as an argument?

Memoizing a function that takes an object as an argument requires a slightly different approach than memoizing a function with primitive arguments. This is because objects are reference types, and their equality is determined by reference rather than value.

Here’s a step-by-step guide on how to memoize a function that takes an object as an argument:

  • Stringify the object: Convert the object to a string using JSON.stringify(). This will create a unique string representation of the object that can be used as a cache key.
  • Create a cache: Use an object or a Map to store the cached results. The cache key will be the stringified object, and the value will be the result of the function call.
  • Check the cache: Before calling the function, check the cache to see if a result already exists for the given object. If a result is found, return the cached value instead of calling the function.
  • Call the function and cache the result: If no result is found in the cache, call the function with the given object and cache the result using the stringified object as the key.

Here’s an example implementation:

const memoize = (fn) => {
const cache = new Map();

return (obj) => {
const key = JSON.stringify(obj);
if (cache.has(key)) {
return cache.get(key);
}

const result = fn(obj);
cache.set(key, result);
return result;
};
};

// Example usage:
const add = (obj) => obj.x + obj.y;
const memoizedAdd = memoize(add);

const obj1 = { x: 2, y: 3 };
const obj2 = { x: 2, y: 3 };

console.log(memoizedAdd(obj1)); // 5
console.log(memoizedAdd(obj2)); // 5 ( cached result )

In this example, the memoize function takes a function fn as an argument and returns a new memoized function. The memoized function stringifies the object argument using JSON.stringify() and uses the resulting string as a cache key. If a result is found in the cache, it returns the cached value. Otherwise, it calls the original function and caches the result.

Note that this implementation uses a simple object as the cache, but you can use a more advanced caching mechanism like a Least Recently Used (LRU) cache or a cache with expiration times depending on your specific requirements.

Also, keep in mind that this implementation assumes that the objects passed as arguments have a consistent string representation. If the objects have a different string representation each time they are created, even if they have the same properties and values, the memoization will not work as expected.

9 What is the difference between memoization and caching? Provide an example.

Memoization and caching are both optimization techniques used to improve performance by storing and reusing results, but they differ in their scope, implementation, and usage.

Memoization

Memoization is a specific type of caching that focuses on storing the results of expensive function calls. It’s a technique used to optimize function execution by storing the results of previous function calls with specific inputs, so that if the same inputs occur again, the result can be retrieved from the cache instead of recalculating it.

Memoization is typically implemented within a function or a small scope, and the cache is usually stored in a local object or array. The cache is specific to the function or scope, and the cached results are only valid for that specific context.

Caching

Caching is a broader concept that involves storing data or results in a temporary storage area (cache) to speed up subsequent access to that data. Caching can be applied to various levels, such as:

  • Function calls (memoization)
  • Database queries
  • API requests
  • Web pages
  • Data storage

Caching is often implemented at a higher level, such as in a web application or a database, and the cache can be stored in various formats, like memory, disk, or even distributed across multiple machines.

Example

Suppose we have a function calculateArea that takes a radius as input and returns the area of a circle. We want to optimize this function using memoization and caching.

Memoization:

const memoizedCalculateArea = (radius) => {
const cache = {};
if (cache[radius]) {
return cache[radius];
}
const result = Math.PI * radius * radius;
cache[radius] = result;
return result;
};

In this example, we create a local cache object cache within the memoizedCalculateArea function. We check if the result for the given radius is already in the cache, and if so, we return the cached result. If not, we calculate the result, store it in the cache, and return it.

Caching:

const cache = {};
const calculateArea = (radius) => {
if (cache[radius]) {
return cache[radius];
}
const result = Math.PI * radius * radius;
cache[radius] = result;
return result;
};

In this example, we create a global cache object cache outside the calculateArea function. We use the same logic as in the memoization example, but the cache is stored in a global object, making it accessible across multiple function calls and scopes.

In summary:

  • Memoization is a specific type of caching that focuses on optimizing function calls by storing results in a local cache.
  • Caching is a broader concept that involves storing data or results in a temporary storage area to speed up subsequent access, and can be applied to various levels and scopes.

While memoization is a technique used to optimize function execution, caching is a more general concept that can be applied to various areas, including function calls, database queries, and web page loading.

10 Write a memoized function that calculates the Fibonacci sequence.

Here is a memoized function that calculates the Fibonacci sequence:

const memoizedFibonacci = (n) => {
const memo = [0, 1];
const fibonacci = (n) => {
if (memo[n]) {
return memo[n];
}
const result = fibonacci(n - 1) + fibonacci(n - 2);
memo[n] = result;
return result;
};
return fibonacci(n);
};

This function uses a memoization technique to store the results of previous Fibonacci calculations in an array called memo. If the result for a given n is already in the memo array, it returns the cached result instead of recalculating it.

Here’s how it works:

  • The memo array is initialized with the first two Fibonacci numbers, 0 and 1.
  • The fibonacci function is defined inside the memoizedFibonacci function. This allows us to create a closure that has access to the memo array.
  • If the result for a given n is already in the memo array, the function returns the cached result.
  • If the result is not in the memo array, the function calculates the result using the standard Fibonacci formula: fibonacci(n - 1) + fibonacci(n - 2).
  • The result is then stored in the memo array for future reference.
  • Finally, the memoizedFibonacci function returns the result of the fibonacci function.

By using memoization, this function avoids redundant calculations and improves performance for large values of n.

Example usage:

console.log(memoizedFibonacci(5)); // 5
console.log(memoizedFibonacci(8)); // 21
console.log(memoizedFibonacci(13)); // 233

Note that this implementation has a time complexity of O(n) and a space complexity of O(n), making it efficient for large values of n. Try improving this further.

11 Write a memoized function that fetches data from an API, using a cache to avoid repeated requests.

Here is a memoized function that fetches data from an API, using a cache to avoid repeated requests:

const memoizedFetch = (url) => {
const cache = {};
const fetchApi = async (url) => {
if (cache[url]) {
return cache[url];
}
const response = await fetch(url);
const data = await response.json();
cache[url] = data;
return data;
};
return fetchApi(url);
};

This function uses a memoization technique to store the results of previous API fetches in an object called cache. If the data for a given url is already in the cache object, it returns the cached result instead of making a new request to the API.

Here’s how it works:

  • The cache object is initialized as an empty object.
  • The fetchApi function is defined inside the memoizedFetch function. This allows us to create a closure that has access to the cache object.
  • If the data for a given url is already in the cache object, the function returns the cached result.
  • If the data is not in the cache object, the function makes a request to the API using the fetch function.
  • The response is parsed as JSON and stored in the cache object for future reference.
  • Finally, the memoizedFetch function returns the result of the fetchApi function.

By using memoization, this function avoids repeated requests to the API and improves performance by reusing cached data.

Example usage:

const url = 'https://api.example.com/data';
const data = await memoizedFetch(url);
console.log(data); // fetched data

// Repeat the request, but this time it will return the cached result
const dataAgain = await memoizedFetch(url);
console.log(dataAgain); // same data as before, returned from cache

Note that this implementation assumes that the API returns JSON data. If your API returns data in a different format, you may need to modify the fetchApi function accordingly. Additionally, you may want to consider implementing a cache expiration mechanism to ensure that stale data is not returned.

12 Can you explain how memoization relates to the concept of “pure functions”?

Memoization and pure functions are closely related concepts in programming, particularly in functional programming.

Pure functions

A pure function is a function that has the following properties:

  • Idempotence: Given the same inputs, a pure function always returns the same output.
  • No side effects: A pure function does not have any side effects, meaning it does not modify external state or perform any I/O operations.
  • Only depends on inputs: A pure function’s output only depends on its inputs and not on any external factors.

Examples of pure functions include mathematical functions like sin(x), sqrt(x), and x + y.

Memoization

Memoization is an optimization technique that stores the results of expensive function calls so that if the same inputs occur again, the result can be retrieved from the cache instead of recalculating it.

Relationship between memoization and pure functions

Memoization relies on the properties of pure functions to work correctly. Here’s how:

  • Idempotence: Since pure functions always return the same output given the same inputs, memoization can safely store the result and return it without recalculating it.
  • No side effects: Pure functions do not have side effects, which means that memoization can cache the result without worrying about modifying external state or performing I/O operations.
  • Only depends on inputs: Since pure functions only depend on their inputs, memoization can cache the result based on the input parameters, knowing that the output will always be the same for the same inputs.

In other words, memoization takes advantage of the predictability and determinism of pure functions to optimize performance. By caching the results of pure functions, memoization can avoid redundant calculations and improve performance.

Implications

The relationship between memoization and pure functions has important implications:

  • Memoization only works with pure functions: If a function has side effects or is not idempotent, memoization may not work correctly or may even introduce bugs.
  • Pure functions enable memoization: By writing pure functions, you enable memoization to optimize performance, making your code more efficient and scalable.

In summary, memoization and pure functions are closely related concepts that enable performance optimization and efficient coding practices. By understanding the properties of pure functions, you can effectively use memoization to improve the performance of your code.

13 Write a memoized function that takes a function as an argument and returns a memoized version of that function.

Here is a memoized function that takes a function as an argument and returns a memoized version of that function:

const memoize = (fn) => {
const cache = {};
return (...args) => {
const key = JSON.stringify(args);
if (cache[key]) {
return cache[key];
}
const result = fn(...args);
cache[key] = result;
return result;
};
};

This function, called memoize, takes a function fn as an argument and returns a new memoized function. The memoized function uses a cache object to store the results of previous function calls. If the result for a given set of arguments is already in the cache, it returns the cached result instead of calling the original function again.

Here’s how it works:

  • The cache object is initialized as an empty object.
  • The memoized function is defined, which takes a variable number of arguments (...args).
  • The key variable is created by stringifying the arguments using JSON.stringify(). This creates a unique key for each set of arguments.
  • If the result for the given key is already in the cache object, the memoized function returns the cached result.
  • If the result is not in the cache, the memoized function calls the original function fn with the given arguments and stores the result in the cache using the key.
  • Finally, the memoized function returns the result.

By using this memoize function, you can memoize any function and improve its performance by avoiding redundant calculations.

Example usage:

const add = (x, y) => x + y;
const memoizedAdd = memoize(add);

console.log(memoizedAdd(2, 3)); // 5
console.log(memoizedAdd(2, 3)); // 5 (cached result)

Note that this implementation uses a simple object as the cache, but you can use a more advanced caching mechanism like a Least Recently Used (LRU) cache or a cache with expiration times depending on your specific requirements.

Thanks for reading!

--

--