Another Underscore

Here's an attempt to reproduce the functionality of the Underscore Javascript library. I've not looked at the source code, so all the code here is based on the documentation for the individual functions at that link. I'll be working, roughly, from the top to the bottom of the functions listed in the sidebar.


Firstly, create a class to hold the functionality. I've decided that the only method that I need initially is a poor copy of jQuery $.extend(). This will allow me to add the new functionality a small chunk at a time. The first thing that I'll add is a simple log function. Note that, from this point, the _ object exists in the global namespace, and I'll be using extend to add the new functionality.

  function Underscore() {
    // Provide the facility to acquire other objects'
    // properties...
    this.extend = function(target, extension) {
      if (! extension) {
        extension = target;
        target = this;
      }
      
      for (let index in extension) {
        target[index] = extension[index];
      }
      
      return target;
    }
    
    // Use given arguments to extend this class...
    for (let index in arguments) {
      this.extend(arguments[index]);
    }
  }
  
  // Instantiate the Underscore container...
  let _ = new Underscore({
    log: function(message) {
      if (typeof(message) === "string") {
        console.log("log: " + message);
      }
      else {
        var properties = "";
        for (let index in message) {
          properties += "[key=" + index + "][value=" + message[index] + "]";
        }
        
        console.log("log: " + properties);
      }
    }
  });
  
  _.log("hello, world!");
    

The first function to be added is each. This is an easy one, and the same code iterates over both the values in an array, and the properties of an object. So, no special-case work needs to be done. Like all the functions that take a function argument, the context/scope is optional and, if not given, will default to this. Having defined each, another call to extend is made to add the forEach alias.

 _.extend({
    each: function(list, iteratee, context) {
      for (let index in list) {
        iteratee.call(context || this, list[index], index, list);
      }
    }
  });
  
  _.extend({
    forEach: _.each
  });
  
  _.forEach([9, 8, 7], function(value, index, list) {
    _.log("forEach [index=" + index + "][value=" + value + "]");
  });
  

  _.forEach({one: 1, two: 2, three: 3}, function(value, index, list) {
    _.log("forEach [index=" + index + "][value=" + value + "]");
  });
});
    

Next is map. Very similar to each, but if the iteratee returns anything, it is added to the array of mapped values that is returned. Note that I could have used the previously defined each to do the iteration.

  _.extend({
    map: function(list, iteratee, context) {
      let mapped = [];
      
      for (let index in list) {
        let v = iteratee.call(context || this, list[index], index, list);
        if (v) {
          mapped.push(v);
        }
      }
      
      return mapped;
    }
  });
  
  _.extend({
    collect: _.map
  });
  
  let x = _.collect([1, 2, 3], function(value, index) {
    if ((index % 2) === 0) {
      return value * 10;
    }
  });
  
  _.log(x);
    

A bit more complicated is reduce. This involves chaining a calculation through an array of values. I've added a guard against the iteratee failing to return a value, otherwise the memo field would be reset to the next (if any) value in the list. Not sure if this is the correct way of dealing with this.

  _.extend({
    reduce: function(list, iteratee, memo, context) {
      for (let index in list) {
        if (! memo) {
          memo = list[index];
          continue;
        }
        
        reduction = iteratee.call(context || this,
                              memo, list[index], index, list);
        if (reduction !== undefined) {
          memo = reduction;
        }
      }
      
      return memo;
    }
  });
  
  _.extend({
    inject: _.reduce,
    foldl: _.reduce
  });
  
  let sum = _.reduce([1, 2, 3, 4, 5, 6], function(sum, val) {
    return sum + val;
  });
  
  _.log("reduce [sum=" + sum + "]");
    

The reduceRight function does the same thing as reduce, except that it starts at the tail of the list and works up to the head.

  _.extend({
    reduceRight: function(list, iteratee, memo, context) {
      for (let index = list.length - 1; index >= 0; --index) {
        if (! memo) {
          memo = list[index];
          continue;
        }
        
        reduction = iteratee.call(context || this,
                              memo, list[index], index, list);
        if (reduction !== undefined) {
          memo = reduction;
        }
      }
      
      return memo;
    }
  });
  
  _.extend({
    foldr: _.reduceRight
  });

  let list = [[0, 1], [2, 3], [4, 5]];
  let flat = _.reduceRight(list, function(a, b) { return a.concat(b); }, []);
  
  _.log("reduceRight - " + flat);
    

Next up is find. Simple.

  _.extend({
    find: function(list, predicate, context) {
      for (let index in list) {
        if (predicate.call(context || this, list[index], index, list)) {
          return list[index];
        }
      }
    }
  });
  
  _.extend({
    detect: _.find
  });
  
  let even = _.find([1, 2, 3, 4, 5, 6],
                      function(num){ return num % 2 == 0; });
  
  _.log("find - " + even);
    

The filter function is similar to find except that it scans the entire list and returns all matches.

  _.extend({
    filter: function(list, predicate, context) {
      let filtered = [];
      
      for (let index in list) {
        if (predicate.call(context || this, list[index], index, list)) {
          filtered.push(list[index]);
        }
      }
      
      return filtered;
    }
  });
  
  _.extend({
    select: _.filter
  });
  
  let evens = _.filter([1, 2, 3, 4, 5, 6],
                        function(num){ return num % 2 == 0; });
  
  _.log("filter - " + evens);
    

The where function is a bit trickier. It uses a nested loop to test the properties to be matched against each item in the list.

  _.extend({
    where: function(list, properties) {
      let matched = [];
      
      for (let index in list) {
        let item = list[index];
        
        var equal = true;
        for (let match in properties) {
          if (item[match] !== properties[match]) {
            equal = false;
            break;
          }
        }
        
        if (equal) {
          matched.push(item);
        }
      }
      
      return matched;
    }
  });
  
  let plays = [
    {title: "Twelfth Night", author: "Shakespeare", year: 1609},
    {title: "Cymbeline", author: "Shakespeare", year: 1611},
    {title: "Death of a Salesman", author: "Miller", year: 1950},
    {title: "The Tempest", author: "Shakespeare", year: 1611}
  ];

  let shakespeare = _.where(plays,
                            {author: "Shakespeare", year: 1611});
  _.each(shakespeare, function(play) {
    console.log("where - [play=" + play.title + "]");
  });
    

The findWhere function is like the where function, except that it returns after find the first match.

  _.extend({
    findWhere: function(list, properties) {
      for (let index in list) {
        let item = list[index];
        
        var equal = true;
        for (let match in properties) {
          if (item[match] !== properties[match]) {
            equal = false;
            break;
          }
        }
        
        if (equal) {
          return item;
        }
      }
    }
  });
  
  let play = _.findWhere(plays,
                      {author: "Shakespeare", year: 1611});
  console.log("findWhere - [play=" + play.title + "]");
    

The reject function is the oppposite of filter, so the code is very similar.

  _.extend({
    reject: function(list, predicate, context) {
      let rejected = [];
      
      for (let index in list) {
        if (! predicate.call(context || this,
                                list[index], index, list)) {
          rejected.push(list[index]);
        }
      }
      
      return rejected;
    }
  });
  
  let odds = _.reject([1, 2, 3, 4, 5, 6], function(num){ return num % 2 == 0; });
  
  _.log("reject - " + odds);
    

The every function. According to the documentation, the predicate is optional. So, in the case where no predicate is given, true is returned. I don't know whether this is correct.

  _.extend({
    every: function(list, predicate, context) {
      if (predicate) {
        for (let index in list) {
          if (! predicate.call(context || this,
                                list[index], index, list)) {
            return false;
          }
        }
      }
      
      return true;
    }
  });
  
  _.extend({
    all: _.every
  });
  
  let result = _.every([2, 4, 5],
                        function(num) { return num % 2 == 0; });
  _.log("every - " + result);
    

The some function. Again, the predicate is optional. Where no predicate is given, defaults to returning false.

  _.extend({
    some: function(list, predicate, context) {
      if (predicate) {
        for (let index in list) {
          if (predicate.call(context || this,
                                list[index], index, list)) {
            return true;
          }
        }
      }
      
      return false;
    }
  });
  
  _.extend({
    any: _.some
  });
  
  let result = _.every([2, 4, 5],
                        function(num) { return num % 2 == 0; });
  _.log("some - " + result);
    

The contains function. Uses 2 different styles of loop, depending upon whether fromIndex is supplied. Have to do this, because starting from a specific index isn't possible (is it?) when iterating through the properties of an object.

  _.extend({
    contains: function(list, value, fromIndex) {
      if (fromIndex) {
        for (let index = fromIndex; index < list.length; ++index) {
          if (list[index] === value) {
            return true;
          }
        }
      }
      else {
        for (let index in list) {
          if (list[index] === value) {
            return true;
          }
        }
      }
      
      return false;
    }
  });
  
  _.extend({
    includes: _.contains
  });
  
  let has = _.contains([1, 2, 3, 4, 5], 3);
  _.log("contains - " + has);
  
  let hasO = _.contains({"one":1, "two":2, "three":3, "four":4, "five":5}, 7);
  _.log("containsO - " + hasO);
    

The invoke function. I need to figure out the correct way to pass the arguments to the invoked method. If the invoked method does not return a value, the results array that is returned remains unchanged for that invocation.

  _.extend({
    invoke: function(list, methodName, arguments) {
      let results = [];
      
      for (let index in list) {
        let item = list[index];
        
        let result = item[methodName](arguments);
        if (result !== undefined) {
          results.push(result);
        }
      }
      
      return results;
    }
  });
  
  let sorted = _.invoke([[5, 1, 7], [3, 2, 1]], 'sort');
  _.log("invoke - " + sorted);
    

The pluck method. Only the values of properties that exist and are defined will be added to the returned array, so it's possible (if the property name is not applicable to any object in the list) that an empty array may be returned. The returned array may contain duplicate values.

  _.extend({
    pluck: function(list, propertyName) {
      let plucked = [];
      
      for (let index in list) {
        let item = list[index];
        
        let property = item[propertyName];
        if (property !== undefined) {
          plucked.push(property);
        }
      }
      
      return plucked;
    }
  });
  
  let stooges = [{name: 'moe', age: 40},
                  {name: 'larry', age: 50},
                  {name: 'curly', age: 60}];
  let p = _.pluck(stooges, 'name');
  _.log("pluck - " + p);
    

The min and max methods are so similar that it makes sense to hoist the bulk of the code into a common function. This could have been done using the previously defined reduce function, but I'm using a specially created listCompare function because I want to be able to handle the special case where only number values are compared. Whilst reduce could deal with this, it's simpler to isolate the code here. I have to call _.extend() twice here, so that the listCompare method is in scope at the point it's needed.

  _.extend({
    listCompare: function(list, iteratee, context, startValue, predicate) {
      var result = startValue;

      for (let index in list) {
        let value = iteratee
                    ? iteratee.call(context || this, list[index])
                    : list[index];
        
        if (typeof(value) === "number") {
          result = predicate(result, value) ? result : value;
        }
      }

      return result;
    }
  });
  
  _.extend({
    max: function(list, iteratee, context) {
      return _.listCompare(list, iteratee, context,
                         -Infinity, function(a, b) { return a > b; } )
    },
  
    min: function(list, iteratee, context) {
        return _.listCompare(list, iteratee, context,
                           Infinity, function(a, b) { return a < b; } )
      }
  });

  let stooges = [{name: 'moe', age: 40},
                 {name: 'larry', age: 50},
                 {name: 'jerry', age: 80},
                 {name: 'billy', age: 20},
                 {name: 'curly', age: 60}];
  
  let max = _.max(stooges, function(stooge){ return stooge.age; });
  _.log("max - " + max);

  let min = _.min(stooges, function(stooge){ return stooge.age; });
  _.log("min - " + min);
    

The sortBy function operates on objects. The field to be sorted by can be specified by name (as a string), or by a selector function that operates on each member of the list. There's a few holes in this implementation; if a named field is not present on the object being sorted, or the named field isn't a string or number, or the selector function returns something that is not a string or number, then the result of the sort will be unpredictable. Note that this implementation uses the underlying Javascript sort method for arrays, and so the array that is passed in will be modified; this could be prevented by cloning the array argument but, obviously, that has some overhead.

  _.extend({
    sortBy: function(list, iteratee, context) {
      list.sort(function(a, b) {
        var valueA, valueB;
        if (typeof(iteratee) === "string") {
          valueA = a[iteratee];
          valueB = b[iteratee];
        }
        else if (typeof(iteratee) === "function") {
          valueA = iteratee.call(context || this, a, list);
          valueB = iteratee.call(context || this, b, list);
        }
        else {
          valueA = a;
          valueB = b;
        }
        
        return (typeof(valueA) === "string")
                ? valueA.localeCompare(valueB)
                : (valueA - valueB);
      });
      
      return list;
    }
  });
  
  let stooges = [{name: 'moe', age: 40},
                 {name: 'larry', age: 50},
                 {name: 'jerry', age: 80},
                 {name: 'billy', age: 20},
                 {name: 'curly', age: 60}];
  let sorted = _.sortBy(stooges, function(item) { return item.name });
  _.each(sorted, function(item) {
    _.log("sortBy - [name=" + item.name + "][age=" + item.age + "]");
  });
    

The groupBy function. As with sortBy, if the iteratee is incorrect, the results will be unspecified.

  _.extend({
    groupBy: function(list, iteratee, context) {
      let results = {};
      
      for (let index in list) {
        let item = list[index];
        
        var key;
        if (typeof(iteratee) === "string") {
          key = item[iteratee];
        }
        else if (typeof(iteratee) === "function") {
          key = iteratee.call(context || this, item, list);
        }
        else {
          key = item;
        }
        
        let group = results[key];
        if (group) {
          group.push(item);
        }
        else {
          results[key] = [item];
        }
      }
      
      return results;
    }
  });
  
  //let groups = _.groupBy(['one', 'two', 'three'], 'length');
  let groups =_.groupBy([1.3, 2.1, 2.4],
                        function(num){ return Math.floor(num); });
  for (let key in groups) {
    _.log("groupBy - [key=" + key + "][group=" + groups[key] + "]");
  }
    

The indexBy function is similar to groupBy, except that the field to group/index by should be unique across the list.

  _.extend({
    indexBy: function(list, iteratee, context) {
      let results = {};
      
      for (let index in list) {
        let item = list[index];
        
        var key;
        if (typeof(iteratee) === "string") {
          key = item[iteratee];
        }
        else if (typeof(iteratee) === "function") {
          key = iteratee.call(context || this, item, list);
        }
        else {
          key = item;
        }
        
        results[key] = item;
      }
      
      return results;
    }
  });
  
  let stooges = [{name: 'moe', age: 40},
                 {name: 'larry', age: 50},
                 {name: 'jerry', age: 80},
                 {name: 'billy', age: 20},
                 {name: 'curly', age: 60}];
  let indexed = _.indexBy(stooges, "age");
  for (let key in indexed) {
    _.log("indexBy - [key=" + key + "][item=" + indexed[key].name + "]");
  }
    

The countBy function.

  _.extend({
    countBy: function(list, iteratee, context) {
      let results = {};
      
      for (let index in list) {
        let item = list[index];
        
        var key;
        if (typeof(iteratee) === "string") {
          key = item[iteratee];
        }
        else if (typeof(iteratee) === "function") {
          key = iteratee.call(context || this, item, list);
        }
        else {
          key = item;
        }
        
        results[key] = (results[key] || 0) + 1;
      }
      
      return results;
    }
  });
  
  let counted = _.countBy([1, 2, 3, 4, 5], function(num) {
    return num % 2 == 0 ? 'even': 'odd';
  });
  for (let key in counted) {
    _.log("indexBy - [key=" + key + "][count=" + counted[key] + "]");
  }