Search code examples
javascriptlazy-evaluation

JavaScript map & find at the same time: findMap?


How would you rewrite this without using a for loop?

const a = [2, 5, 78, 4];
const expensiveFunction = n => 2 * n;

let result;

// Find the first number 
for (let i = 0; i < a.length; i++) {
    const r = expensiveFunction(a[i]);

    if (r > 100) {
        result = r;
        break;
    }
}

console.log(result);

My naive approach:

const result = a.map(expensiveFunction).find(x => x > 100);
console.log(result);

But this runs expensiveFunction on all the elements, which I would like to avoid. In the above case, we should avoid running expensiveFunction(4).

Some languages have find_map (e.g, Rust), I didn't find it in lodash nor in underscore.


Solution

  • Built-in map is greedy so you have to write your own, lazy version:

    const a = [2, 5, 78, 4];
    const expensiveFunction = n => {
         console.log('expensiveFunction for', n); 
         return 2 * n 
    };
    
    
    function *map(a, fn) {
        for(let x of a)
            yield fn(x);
    }
    
    function find(a, fn) {
        for(let x of a)
            if (fn(x))
                return x;
    }
    
    
    
    r = find(map(a, expensiveFunction), x => x > 100)
    console.log('result', r)

    Unlike the stock map, this map is a generator and returns (yields) results on demand rather than processing the whole array at once. find and map in this example are "coroutines" and play some kind of a ping-pong game where find asks for results and map delivers them when asked. As soon as find is satisfied with what it's got, it quits and so does map, because nobody is asking for its results anymore.

    You can also add map, find and friends to the IteratorPrototype to make them available for all iterators and be able to use dot notation:

    const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()));
    
    Object.defineProperties(IteratorPrototype, {
        map: {
            value: function* (fn) {
                for (let x of this) {
                    yield fn(x);
                }
            },
            enumerable: false
        },
    
        find: {
            value: function (fn) {
                for (let x of this) {
                    if (fn(x))
                        return x;
                }
            },
            enumerable: false
        },
    
    });
    
    //
    
    const a = [2, 5, 78, 4];
    const expensiveFunction = n => {
        console.log('expensiveFunction', n);
        return 2 * n
    };
    
    
    let r = a.values().map(expensiveFunction).find(x => x > 100);
    
    console.log(r)

    Here's a small library based on this technique: https://github.com/gebrkn/armita


    TypeScript Implementation and Tests

    find-map.ts

    function* map<T, U>(a: T[], fn: (x: T) => U) {
      for (let x of a) yield fn(x);
    }
    
    function find<T>(a: Generator<T, void, unknown>, fn: (x: T) => boolean) {
      for (let x of a) if (fn(x)) return x;
    }
    
    export function mapFind<T, U>(
      collection: T[],
      mapper: (item: T) => U,
      finder: (item: U) => boolean
    ): U | undefined {
      const mapperGenerator = map(collection, mapper);
    
      return find(mapperGenerator, finder);
    }
    

    map-find.spec.ts

    Note: these tests utilize Bun, but shouldn't be far off from Jest.

    import { describe, expect, it, mock } from "bun:test";
    import { mapFind } from "./map-find";
    
    describe("findMap", () => {
      const collection = [2, 5, 78, 4];
      const expensiveFunction = mock((n: number) => {
        console.log("expensiveFunction for", n);
        return 2 * n + ""; // Wanting to test with the change of types
      });
      const condition = (x: string) => x.length > 2;
      const result = mapFind(collection, expensiveFunction, condition);
    
      it("only calls the expensive function 3 times", () => {
        expect(expensiveFunction).toHaveBeenCalledTimes(3);
      });
    
      it("returns the expected result", () => {
        expect(result).toEqual("156");
      });
    });