HecateDK

Functional pattern: flatMap

HecateDK · 2017-05-26推荐 · 212阅读 CET/4 CET/6 原文链接

In this blog post, we look at the operation flatMap, which is similar to the Array method map(), but more versatile.

flatMap() #

Both map() and flatMap() take a function f as a parameter that controls how an input Array is translated to an output Array:

  • With map(), each input Array element is translated to exactly one output element. That is, f returns a single value.

  • With flatMap(), each input Array element is translated to zero or more output elements. That is, f returns an Array of values.

This is an implementation of flatMap():

function flatMap(arr, mapFunc) {
    const result = [];
    for (const [index, elem] of arr.entries()) {
        const x = mapFunc(elem, index, arr);
        // We allow mapFunc() to return non-Arrays
        if (Array.isArray(x)) {
            result.push(...x);
        } else {
            result.push(x);
        }
    }
    return result;
}

flatMap() is simpler if mapFunc() is only allowed to return Arrays, but we don’t impose this restriction here, because non-Array values are occasionally useful (see the section on flatten() for an example).

To demonstrate how flatMap() works, we use the helper function fillArray:

function fillArray(x) {
    return new Array(x).fill(x);
}

This is fillArray() in action:

> fillArray(1)
[ 1 ]
> fillArray(2)
[ 2, 2 ]
> fillArray(3)
[ 3, 3, 3 ]

This is flatMap() in action:

> flatMap([1,2,3], fillArray)
[ 1, 2, 2, 3, 3, 3 ]

What is flatMap() good for? Let’s look at use cases!

Use case: filtering and mapping at the same time #

The result of the Array method map() always has the same length as the Array it is invoked on. That is, its callback can’t skip Array elements it isn’t interested in.

The ability of flatMap() to do so is useful in the next example: processArray() returns an Array where each element is either a wrapped value or a wrapped error.

function processArray(arr, process) {
    return arr.map(x => {
        try {
            return { value: process(x) };
        } catch (e) {
            return { error: e };
        }
    });
}
const results = processArray(myArray, myFunc);

flatMap() enables us to extract just the values or just the errors from results:

const values = flatMap(results,
    result => result.value ? [result.value] : []);
const errors = flatMap(results,
    result => result.error ? [result.error] : []);

Use case: mapping to multiple values #

The Array method map() maps each input Array element to one output element. But what if we want to map it to multiple output elements?

That becomes necessary in the following example: The React component TagList is invoked with two attributes.

<TagList tags={['foo', 'bar', 'baz']}
             handleClick={x => console.log(x)} />

The attributes are:

  • An Array of tags, each tag being a string.

  • A callback for handling clicks on tags.

TagList is rendered as a series of links separated by commas:

class TagList extends React.Component {
    render() {
        const {tags, handleClick} = this.props;
        return flatMap(tags,
            (tag, index) => [
                ...(index > 0 ? [', '] : []), // (A)
                <a href> handleClick(tag, e)}&gt;
                   {tag}
                </a>,
            ]);
    }
}

In line A, we are conditionally inserting the Array element ', ' via the spread operator (...). This trick is explained in another blog post.

Due to flatMap(), TagList is rendered as a single flat Array. The first tag contributes one element to this Array (a link); each of the remaining tags contributes two elements (comma and link).

Other versions of flatMap() #

Arbitrary iterables #

flatMap() can be generalized to work with arbitrary iterables:

function* flatMapIter(iterable, mapFunc) {
    let index = 0;
    for (const x of iterable) {
        yield* mapFunc(x, index);
        index++;
    }
}

flatMapIter() function works with Arrays:

function fillArray(x) {
    return new Array(x).fill(x);
}
console.log([...flatMapIter([1,2,3], fillArray)]); // (A)
    // [1, 2, 2, 3, 3, 3]

In line A, we translate the iterable returned by flatMapIter() into an Array, via the spread operator (...).

One nice trait of flatMapIter() is that it works incrementally: as soon as the first input value is available, output is produced. In contrast, the Array-based flatMap() needs all of its input to produce its output.

That can be demonstrated via the infinite iterable created by the generator function naturalNumbers():

function* naturalNumbers() {
    for (let n=0;; n++) {
        yield n;
    }
}
const infiniteInput = naturalNumbers();
const infiniteOutput = flatMapIter(infiniteInput, fillArray);
const [a,b,c,d,e] = infiniteOutput; // (A)
console.log(a,b,c,d,e);
    // 1 2 2 3 3

In line A, we extract the first 5 values of infiniteOutput via destructuring.

Implementing flatMap() via reduce() #

You can use the Array method reduce() to implement a simple version of flatMap():

function flatMap(arr, mapFunc) {
    return arr.reduce(
        (prev, x) =&gt; prev.concat(mapFunc(x)),
        []
    );
}

I prefer the original version of flatMap(), because it is easier to understand.

flatten() is an operation that concatenates all the elements of an Array:

&gt; flatten(['a', ['b','c'], ['d']])
[ 'a', 'b', 'c', 'd' ]

It can be implemented as follows:

`const flatten = (arr) =&gt; [].concat(...arr)`

Using map() and flattening the result is the same as using flatMap(). That is, the following two expressions are equivalent:

flatten(arr.map(func))
flatMap(arr, func)

Similarly, using flatMap() with the identity function (x =&gt; x) is the same as using flatten(). That is, the following two expressions are equivalent:

flatMap(arr, x =&gt; x)
flatten(arr)

The next subsections cover use cases for flatten().

Use case: conditionally inserting values into an Array #

The following code only inserts 'a' if cond is true:

const cond = false;
const arr = flatten([
  (cond ? ['a'] : []),
  'b',
]);
    // ['b']

For more information on conditionally inserting Array elements, consult the blog post “Conditionally adding entries inside Array and object literals”.

Use case: filtering out failures #

In the following example, downloadFiles() only returns the texts that could be downloaded.

async function downloadFiles(urls) {
    const downloadAttempts = await Promises.all( // (A)
        urls.map(url =&gt; downloadFile(url)));
    return flatten(downloadAttempts); // (B)
}
async function downloadFile(url) {
    try {
        const response = await fetch(url);
        const text = await response.text();
        return [text]; // (C)
    } catch (err) {
        return []; // (D)
    }
}

downloadFiles() first maps each URL to a Promise resolving to either:

  • An Array with the successfully downloaded text (line C)

  • An empty Array (line D)

Promises.all() (line A) converts the Array of Promises into a Promise that resolves to a nested Array. await (line A) unwraps that Promise and flatten() un-nests the Array (line B).

Note that we couldn’t have used flatMap() here, because of the barrier imposed by the Promises returned by downloadFile(): when it returns a value, it doesn’t know yet if it will be a text or an empty Array.

An ECMAScript proposal for Array.prototype.flatMap() #

Brian Terlson has written a proposal for an Array method flatMap(). Alas, it’s currently at stage −1. More work is needed: it might make more sense as a method for iterables (or a function operating on them), etc.

Further reading #

相关文章