Search code examples
javascriptnode.jsecmascript-5

Why is the wrapper function required for the predicate function in reduce?


I was playing around with the interaction between Array.reduce and Set and I noticed the following strange behavior.

Normally this works:

console.log(
  Set.prototype.add.call(new Set(), 1, 0, [])
);
// Set { 1 }

But if I were to combine that with reduce, the following does not work:

console.log(
  [1,2,3].reduce(Set.prototype.add.call, new Set())
);
// TypeError: undefined is not a function
//     at Array.reduce (<anonymous>)

However if I were to wrap the predicate function in a wrapper, this will work:

console.log(
  [1,2,3].reduce((...args) => Set.prototype.add.call(...args), new Set())
);
// Set { 1, 2, 3 }

I tried this on different JS engines (Chrome and Safari) and got the same result so its probably not an engine specific behavior. The same applies to a Map object as well. What I can't figure out is why that is the case.


Solution

  • There are actually two parts of the script that need the proper calling context (or this value) in order to work properly. The first part, which you've already figured out, is that you need to call Set.prototype.add with a calling context of the newly created Set, by passing that Set as the first argument to .call:

    // works:
    Set.prototype.add.call(new Set(), 1, 0, []);
    // works, args[0] is the new Set:
    [1,2,3].reduce((..args) => Set.prototype.add.call(..args), new Set());
    

    But the other issue is that the .call needs to be called with the approprite calling context. Set.prototype.add.call refers to the same function as Function.prototype.call:

    console.log(Set.prototype.add.call === Function.prototype.call);

    The function that Function.prototype.call calls is based on its calling context. For example

    someObject.someMethod.call(< args >)
    

    The calling context of a function is everything that comes before the final . in the function call. So, for the above, the calling context for .call is someObject.someMethod. That's how .call knows which function to run. Without a calling context, .call won't work:

    const obj = {
      method(arg) {
        console.log('method running ' + arg);
      }
    };
    
    // Works, because `.call` has a calling context of `obj.method`:
    obj.method.call(['foo'], 'bar');
    
    const methodCall = obj.method.call;
    // Doesn't work, because methodCall is being called without a calling context:
    methodCall(['foo'], 'bar');

    The error in the snippet above is somewhat misleading. methodCall is a function - specifically Function.prototype.call - it just doesn't have a calling context, so an error is thrown. This behavior is identical to the below snippet, where Function.prototype.call is being called without a calling context:

    console.log(typeof Function.prototype.call.call);
    Function.prototype.call.call(
      undefined,
    );

    Hopefully this should make it clear that when using .call, you need to use it with the proper calling context, or it'll fail. So, to get back to the original question:

    [1,2,3].reduce(Set.prototype.add.call, new Set());
    

    fails because the internals of reduce calls Set.prototype.add.call without a calling context. It's similar to the second snippet in this answer - it's like if Set.prototype.add.call is put into a standalone variable, and then called.

    // essential behavior of the below function is identical to Array.prototype.reduce:
    Array.prototype.customReduce = function(callback, initialValue) {
      let accum = initialValue;
      for (let i = 0; i < this.length; i++) {
        accum = callback(accum, this[i]);
        // note: "callback" above is being called without a calling context
      }
      return accum;
    };
    
    // demonstration that the function works like reduce:
    // sum:
    console.log(
      [1, 2, 3].customReduce((a, b) => a + b, 0)
    );
    // multiply:
    console.log(
      [1, 2, 3, 4].customReduce((a, b) => a * b, 1)
    );
    
    // your working Set code:
    console.log(
      [1,2,3].customReduce((...args) => Set.prototype.add.call(...args), new Set())
    );
    // but because "callback" isn't being called with a calling context, the following fails
    // for the same reason that your original code with "reduce" fails:
    [1,2,3].customReduce(Set.prototype.add.call, new Set());

    In contrast, the

    (..args) => Set.prototype.add.call(..args)
    

    works (in both .reduce and .customReduce) because the .call is being called with a calling context of Set.prototype.add, rather than being saved in a variable first (which would lose the calling context).