jashkenas / underscore

JavaScript's utility _ belt
https://underscorejs.org
MIT License
27.33k stars 5.53k forks source link

unary map, filter, etc. #1762

Closed davidchambers closed 10 years ago

davidchambers commented 10 years ago

The additional arguments passed by .map, .filter, etc. are problematic for variadic functions and functions with optional arguments:

> _.map([1, 2, 3], _.range)
RangeError: Invalid array length

There's a workaround, but it's icky:

> _.map([1, 2, 3], _.compose(_.range, _.identity))
[ [0], [0, 1], [0, 1, 2] ]

Am I the only one who often finds it necessary to _.compose with _.identity in order to strip unwanted arguments?

Assuming breaking changes are off the cards, perhaps _.unaryMap and friends could be added.

benperez commented 10 years ago

:+1: To this idea. I'd also like to see unary versions of these functions.

jdalton commented 10 years ago

I've tackled this by using _.partial with placeholders. See demo.

_.map([1,2,3], _.partial(_.range, _, null, 1)));
// => [[0],[0,1],[0,1,2]]

I've also specialized some methods (clone, extend, defaults, flatten, uniq, max, min, merge, sample) to work out of the box with smth like this. Underscore does this with _.first too.

With placeholders and _.partial there's no need for dup-ish method implementations.

davidchambers commented 10 years ago

Here's a concrete example:

_.chain($('a'))
.map(cheerio)
.invoke('attr', 'href')
.filter(Boolean)
.reject(_.compose(
  _.partial(nucleotides.string.indexOf, _, 'quux.do?'),
  _.identity))
.sort()
.uniq(true)
.map(_.compose(
  _.partial(nucleotides.string.concat, origin, '/foo/bar/'),
  _.identity))
.value()

What I'd like to write:

_.chain($('a'))
.map(cheerio)
.invoke('attr', 'href')
.filter(Boolean)
.reject(_.partial(nucleotides.string.indexOf, _, 'quux.do?'))
.sort()
.uniq(true)
.map(_.partial(nucleotides.string.concat, origin, '/foo/bar/'))
.value()
benperez commented 10 years ago

Having "dupish" implementations of these functions (unaryMap, etc.) does seem a bit unwieldy, but so does partially applying every argument to a function. This requires me to know exactly how many arguments each function might take and also how the number of arguments affects their behavior (the _.range function is a good example).

A good compromise might be to come up with an _.unary function that accepts a multiary function and returns a unary version. The above example could get pretty close to what's desired:

_.chain($('a'))
.map(cheerio)
.invoke('attr', 'href')
.filter(Boolean)
.reject(_.unary(_.partial(nucleotides.string.indexOf, _, 'quux.do?')))
.sort()
.uniq(true)
.map(_.unary(_.partial(nucleotides.string.concat, origin, '/foo/bar/')))
.value()
jdalton commented 10 years ago

@davidchambers smth like is doable now (pseudo code)

_.chain($('a'))
.map(cheerio)
.invoke('attr', 'href')
.compact()
.reject(_.partial(nucleotides.string.indexOf, _, 'quux.do?', 0))
.sort()
.uniq(true)
.map(_.partial(nucleotides.string.concat, origin, '/foo/bar/', ''))
.value()

@Stankalank I'm not sure the callback arg case is complex enough to warrant a core solution. The _.partial route seems to handle it well enough.

davidchambers commented 10 years ago

@jdalton, nucleotides.string.concat is variadic (like String.prototype.concat), so no amount of partial application will prevent the extra args from interfering. ;)

jdalton commented 10 years ago

nucleotides.string.concat is variadic

It's fine because you just have to cover the 3 callback args and a concat of '' will do.

davidchambers commented 10 years ago

That's not how _.partial works, though:

_.map(['foo', 'bar', 'baz'], _.partial(nucleotides.string.concat, 'prefix-', '', ''))
// => [
//   'prefix-foo0foo,bar,baz',
//   'prefix-bar1foo,bar,baz',
//   'prefix-baz2foo,bar,baz',
// ]

Essentially, we're doing this:

[ nucleotides.string.concat('prefix-', '', '', 'foo', 0, ['foo', 'bar', 'baz']),
  nucleotides.string.concat('prefix-', '', '', 'bar', 1, ['foo', 'bar', 'baz']),
  nucleotides.string.concat('prefix-', '', '', 'baz', 2, ['foo', 'bar', 'baz']) ]
jdalton commented 10 years ago

Essentially, we're doing this:

Ah, you're correct. Then your _.compose + _.partial alternative seems fine for variadic cases.

.map(_.compose(_.partial(nucleotides.string.concat, origin, '/foo/bar/'), _.identity))
jashkenas commented 10 years ago

Then your .compose + .partial alternative seems fine for variadic cases.

That, and that in many of these cases -- just go ahead and write out the function you're going to use ... it's much easier to read, ultimately:

.reject(_.compose(
  _.partial(nucleotides.string.indexOf, _, 'quux.do?'),
  _.identity))

vs.

.reject(function(s) {
  return nucleotides.string.indexOf(s, 'quux.do?') >= 0;
})

or.

.reject (s) -> nucleotides.string.indexOf(s, 'quux.do?') >= 0

Regardless, I don't think we should have unary versions of all the functions. I could imagine an _.fixArgs function that returns a version of a function that only calls the wrapped function with the exactly the specified number of arguments — but I think that's more Underscore-Contrib territory.

joshuacc commented 10 years ago

Yep. Contrib has _.fix which can do something like that: http://documentcloud.github.io/underscore-contrib/#fix