-
-
Save aesnyder/9923d5be47cfbc8a1e38 to your computer and use it in GitHub Desktop.
# 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 |
I see that all the same things are possible, but does compose
offer some benefit over individual, chained functions, or is it personal preference?
How do you combine two chains together?
For example:
players(playerData)
.including('score')
.including('rank')
.max('rank')
.tap(renderMaxScoreTemplate)
.tap(renderUserStandingsSidebar)
.value()
players(playerData)
.find(name: currentUser.name)
.tap(renderCurrentUserDetails)
.value()
I want to do both of those at separate points, but occasionally I also want to do them at the same time. If everything was a function and I was using normal, generic function composition it's easy. Using the definitions above:
doScoreAndSidebar = c (sideEffect renderMaxScoreTemplate), c (sideEffect renderUserStandingsSidebar), c (maxBy 'rank'), c (including 'rank' rank), (including 'score' score)
doUserDetails = c (sideEffect renderCurrentUserDetails), findCurrentUser
doScoreAndSidebarAndUserDetails = c doUserDetails, doScoreAndSidebar
doScoreAndSidebarAndUserDetails(playerData)
Bron said that you can do this using chainable
, but I'm not sure how. I'm interesting in seeing how that would work.
The point that I'm trying to make is not that chainable is bad, it's that chainable is unnecessary. When everything is a function, composing them is simple. We don't need chainable.
To address readability, when everything is a function and you can easily compose them defining new functions is cheap. We can use that to really DRY up code and keep some of the more dense bits by themselves.
To address the objection against the length of my code: I redefined some functions because their implementation is simple, and more importantly, easily done in terms of map
or reduce
. Additionally it was to show just how easy and modular function composition is to use. Keep in mind you can also use _.compose
instead of c
. I prefer c
because it acts almost like an operator when in coffeescript, but you might prefer _.compose
instead. They do the same thing.
The process of wrapping up a value and then defining a composition operator for it is similar to creating a Category, which is a set of things that can be composed. These things don't have to be functions, but in this case they are.
players(playerData)
.including('score')
.including('rank')
.max('rank')
.tap(renderMaxScoreTemplate)
.tap(renderUserStandingsSidebar)
.find(name: currentUser.name)
.tap(renderCurrentUserDetails)
.value()
I'm not sure I understand the question
How do you combine two chains together?
The code here is the combined version of your separated one. To combine two chains, you just take the pieces of one chain and add them to the other, as easily as dragging and dropping.
players(playerData)
.including('score')
.including('rank')
.max('rank')
.tap(renderMaxScoreTemplate)
.tap(renderUserStandingsSidebar)
.find(name: currentUser.name)
.tap(renderCurrentUserDetails)
.value()
To follow your example,
doScoreAndSidebar = (data) ->
players(data)
.including('score')
.including('rank')
.max('rank')
.tap(renderMaxScoreTemplate)
.tap(renderUserStandingsSidebar)
Since this would return an instance of the chain, I'd just call doScoreAndData(var).value()
if I needed it. To combine the two you'd do
doScoreAndSidebar(playerData)
.find(name: currentUser.name)
.tap(renderCurrentUserDetails)
.value()
But this doesn't realistically offer anything over the first way, as far as I can tell.
The thing that chaining offers is very easily edited, very modular, and very readable transformations. Composing functions doesn't appear to offer any of these benefits.
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 tap
operations 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:
- We can only use methods that are on our initial
chainable
object or that
are existing underscore methods. - 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.
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.
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.
It certainly got longer and reduced readability, forgive me but I don't see any advantages to what you've shown. What am I missing?