Skip to content

Instantly share code, notes, and snippets.

@aesnyder
Last active August 29, 2015 14:01
Show Gist options
  • Save aesnyder/9923d5be47cfbc8a1e38 to your computer and use it in GitHub Desktop.
Save aesnyder/9923d5be47cfbc8a1e38 to your computer and use it in GitHub Desktop.
_.chainable
# takes an object of methods and makes them optionally chainable,
# all lodash methods are accessible in the chain too
#
# The following methods are added as well:
# attr: returns value of attribute on given object
# inlcuding: extends either object, or each object in collection
# with attrName: callback(object)
# if no callback is given then attrName is assumed to be a function
# passed to _.chainable: attrName: attrName(object)
#
# playerData = [
# { name: 'Bobby', kills: 125, deaths: 63, shots: 128 }
# { name: 'Annie', kills: 201, deaths: 14, shots: 2432 }
# { name: 'Jacob', kills: 101, deaths: 188, shots: 201 }
# ]
#
# players = _.chainable
# kdr: (p) ->
# p.kills / p.deaths
# accuracy: (p) ->
# p.kills / p.shots
# score: (p) -> @kdr(p) + @accuracy(p)
#
# players(playerData).including('score').max('score').attr('name').value() // Bobby
# players(playerData).find(name: 'Bobby').kdr().value() // 1.9841269841269842
# players(playerData).find(name: 'Bobby').including('kdr').value() // { name: 'Bobby', kills: 125, deaths: 63, shots: 128, kdr: 1.9841269841269842 }
_.mixin 'chainable': (obj) ->
methods = _.extend _.cloneDeep(obj), _,
including: (obj, attrName, value) ->
extObj = (o) ->
val = if _.isFunction(value)
value.call(methods, o)
else if _.isString(value)
value
else
methods[attrName](o)
_.extend o, _.object([attrName], [val])
if _.isArray(obj)
_.map obj, extObj, methods
else if _.isObject(obj)
extObj(obj)
attr: (obj, metric) -> obj[metric]
_.extend (arg) ->
_.extend
__collector: _.cloneDeep(arg)
value: -> @__collector
,
_.mapValues methods, (method) ->
->
args = _.toArray arguments
args.unshift(@__collector)
@__collector = method.apply(methods, args)
this
, methods
@RocketPuppy
Copy link

Perhaps I should explain why all of this is desirable. I did not split the
initial code sample along some arbitrary line. It seemed to me that, looking at
this code from the outside, the original chain was doing two distinct things:
finding the user with the maximum rank (I'm going to call this findMax), and
finding the current user (I'll call this findCurrent). We then render some
things using tap, but the tapoperations are incidental as we can't perform
them without the data we initially acquire using findMax and findCurrent.

So what are the problems I see with this approach? The first is that it is not
as modular as one would first believe. I say this for the following reasons:

  1. We can only use methods that are on our initial chainable object or that
    are existing underscore methods.
  2. We have no obvious way of combining two chains together.

Why is 1) an issue? When our requirements change and we need another method
involved in the chain then we cannot simply define the method and use it as we
can with function composition. We must then also add it to our chainable
object. It's an extra hoop to jump through that can trip up developers new to
the system.

Why is 2) an issue? It hurts modularity. We can currently define one chain as a
sort of prefix chain and use it in other chains as a prefix. We cannot, however,
insert a chain into the middle of a chain, or even at the end of another chain.
Our options are limited precisely because we must call methods on the
chainable object to create chains. This modularity is desireable because it
helps us separate logical components. In this example findMax, findCurrent
and I'd also consider the tap functions as logical components because they
perform side effects.

Now, with those points enumerated, lets look and see if we can solve any of them
within the chainable framework. I don't see any way of solving point 1), so
I'm going to start with point 2). Continuing with the example of returning a
chainable object, we can define doScoreAndSidebar and doUserDetails like
so:

doScoreAndSidebar = (data) ->
  players(data)
    .including('score')
    .including('rank')
    .max('rank')
    .tap(renderMaxScoreTemplate)
    .tap(renderUserStandingsSidebar)

doUserDetails = (data) ->
  players(data) ->
    .find(name: currentUser.name)
    .tap(renderCurrentUserDetails)
    .value()

Now how will we combine them? No immediately obvious way to do this presents
itself to me, but if we reformulate doUserDetails to take a chainable
object, I think we can get something usable.

doUserDetails = (chainable) ->
  chainable
    .find(name: currentUser.name)
    .tap(renderCurrentUserDetails)
    .value()

Now I think we can do the following:

players = _.chainable _.extend players, 'doUserDetails': doUserDetails

doScoreAndSidebar(playerData).doUserDetails().value()

This feels like a kludge to me. There was a fairly laborious process we needed
to go through in order to make a composition of chainable functions that we
could use in other chains. We also need to ensure that the object we are
extending has all of the functions that we plan on using. If it doesn't we could
be in for a nasty runtime bug.

We could do it a different way. Keeping doUserDetails the same, we could
convert doScoreAndSidebar to the same format:

doScoreAndSidebar = (chainable) ->
  chainable
    .including('score')
    .including('rank')
    .max('rank')
    .tap(renderMaxScoreTemplate)
    .tap(renderUserStandingsSidebar)

This would allow us to compose them together fairly simply:

(doUserDetails doScoreAndSidebar players playerData).value()

Now we can insert and change the order of each of these "chain links" easily.
Note we still have the requirement that all of the functions in each link must
be available in the initial chainable object. This can be prohibitive when we
are trying to generate new links as we'll need to keep track of what is in each
chainable that we are using. The end result probably being that there is one
giant chainable with all of the methods we'd need. Note that if you look
closely at our final result it looks remarkably like function composition! Why
don't we just throw out the chainable abstraction then and use function
composition the whole way through? Since we're composing functions, we can do it
on any level. Starting from the functions in players we can progressively
compose larger and larger functions that are maintainable and modular.

I'd like to reformulate my previous composition examples so they might be more
palatable to read, also using the functions I defined in previous comments:

#redefine composition so it's left to right
c = (left, right) ->
  (args...) ->
    right(left(args...))

findMax = c (includeScore),
  c (includeRank),
    (maxBy 'rank')

findMax(playerData)

sE = sideEffect

withSideEffects =
  split (c findMax,
         c (sE renderUserStandingsSidebar),
           (sE renderMaxScoreTemplate)),
        (c findCurrentUser,
           (sE renderCurrentUserDetails))

withSideEffects(playerData)

Note you can trivially switch the order of composition so it reads top-down
instead of bottom-up. I personally find bottom-up nicer since it keeps the
initial arguments close to the initial function.

@aesnyder
Copy link
Author

I guess its a matter of to each his own.

I personally find your composition examples very difficult to read, and I'd say I'm more comfortable with composition than the average developer. For that reason I would have a really hard time allowing that code to make it into my projects.

Maybe for you composition will lead to more flexibility, faster development and less bugs. But I can tell you one thing for sure it will make on-boarding developers to the code base significantly harder. As an agency that ships code for others to maintain we have to be cognizant of that. For that matter we have to have a code base where you can leave the project and another developer can easily jump on and get moving. Your examples would be significantly harder for most developers to jump in and work with. Believe me I've been in projects where one clever developer wrote insanely abstracted and well written code. Something that academics would all drool at. In fact we were all drooling at the code as he was getting the PRs merged. Then when there was a bug found in the code he was the only developer out of 5 who could work on the feature. I'm telling you without a doubt that the code you posted above falls into the same category.

@RocketPuppy
Copy link

There's a paradigm shift here, and it's a big one. Like all big changes it will look odd at first. It's a little like learning to read a new language. There's a different grammar and style of thinking required that takes practice to develop. I have shown tangible gains though. Programming with function composition is more flexible. As for readability, there are a number of ways to improve readability. The easiest is to use a language that was designed from the ground up to support this like Haskell. You could also use something like Purescript, but really Javascript can support this fairly easily.

To increase readability in Javascript (well Coffeescript) we can hang the composition operator off of the Function prototype so our composition looks more like using the dot operator. We can do the same thing with partial application using $ on the Function prototype. The end result being something that could look like:

Function.prototype.c = (g) -> c(this, g)
Function.prototype.$ = (args...) -> _.partial(this, args...)

findCurrentUser = find.$((o) -> o.name == currentUser.name))

#alias sideEffect so it looks more familiar
tap = sideEffect

includeScore.
  c includeRank.
  c maxBy('rank').
  c tap(renderMaxScoreTemplate).
  c tap(renderUserStandingsSidebar).
  c findCurrentUser.
  c tap(renderCurrentUserDetails)

This looks almost the same as your original chain, and it's more modular. We can split it however we want, insert things wherever we want, and manipulate it any way we want. There's no wrapper that we need to go through.

That's all I really have to say. I've shown some tangible gains to using function composition over just chainable. Perhaps my last code sample is more readable than the previous ones. I don't really think so, but I'm used to reading this style of code. Let me know if you have any other questions, I'd be happy to answer them.

EDIT: Updated code to have proper line continuations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment