Reputation: 5573
I have a filter function which has essentially multiple variables that determine filtering logic. If the variable is defined, I want to filter—if not, I don't want to filter (i.e. execute the function in the pipeline). More generally, having a predicate that I can check on each argument to the pipe to determine if I should call it or just pass on to the next function.
I'm doing this to prevent complex branching logic, but being pretty new to functional programming, I thought this would be the best way to refactor.
For example:
resources = R.pipe(
filterWithRadius(lat, lng, radius), // if any of these arguments are nil, act like R.identity
filterWithOptions(filterOptions)(keyword), // if either filterOptions or keyword is nil, act like R.identity
filterWithOptions(tagOptions)(tag) // same as above.
)(resources);
I was looking into using R.unless
/R.when
but it doesn't seem to work with functions of more than one argument. R.pipeWith
would be useful here if it dealt with the function arguments instead.
As an example implementation:
const filterWithRadius = R.curry((lat, long, radius, resources) =>
R.pipe(
filterByDistance(lat, long, radius), // simply filters down a geographic location, will fail if any of lat/long/radius are not defined
R.map(addDistanceToObject(lat, long)), // adds distance to the lat and long to prop distanceFromCenter
R.sortBy(R.prop("distanceFromCenter")) // sorts by distance
)(resources)
);
resources
is an array of these resource objects. In essence, each of the functions, filterRadius
and filterOptions
are pure functions expecting an array of resources and valid arguments (not undefined) and output a new, filtered list. So the goal here is to somehow compose (or refactor) such that if the parameters are all undefined, it will run the function, else just act as the identity.
Is there a cleaner/better way than this?
resources = R.pipe(
lat && lng && radius
? filterWithRadius(lat, lng, radius)
: R.identity,
keyword ? filterWithOptions(filterOptions)(keyword) : R.identity,
tag ? filterWithOptions(tagOptions)(tag) : R.identity
)(resources);
Upvotes: 4
Views: 3219
Reputation: 14199
Is there a cleaner/better way than this?
R.pipe(
lat && lng && radius
? filterWithRadius(lat, lng, radius)
: R.identity,
keyword ? filterWithOptions(filterOptions)(keyword) : R.identity,
tag ? filterWithOptions(tagOptions)(tag) : R.identity
)
It looks like every function can be applied if their arguments call are not-nil. Given that, it could be up the the filter functions to decide whether to apply the filtering or not.
This pattern is called call guard
, where basically the first instructions of the function's body are used to guard the function application from any unusable value.
const filterWithRadius = (lat, lng, radius) => {
if (!lat || !lng || !radius) {
return R.identity;
}
return R.filter((item) => 'doSomething');
}
const foo = R.pipe(
filterWithRadius(5, 1, 60),
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.min.js" integrity="sha256-43x9r7YRdZpZqTjDT5E0Vfrxn1ajIZLyYWtfAXsargA=" crossorigin="anonymous"></script>
you could do a more intensive use of ramda by:
const filterWithFoo = R.unless(
(a, b, c) => R.isNil(a) || R.isNil(b) || R.isNil(c),
R.filter(...)
);
Upvotes: 1
Reputation: 11
I just want to point out this anti-pattern:
// inline use of R.pipe
someVar = R.pipe(...)(someVar)
Not only is this a mutation of someVar
which goes against a fundamental principle of functional programming, but it's also a misuse of R.pipe
, which is intended to create a new function, such as -
const someProcess = R.pipe(...)
const someNewVar = someProcess(someVar)
I understand you're using R.pipe
in order to make the code read nicer and flow from top-to-bottom, but your specific use is counterproductive. There's no reason to create an intermediate function if you're going to dispose of it right away.
Consider representing your intentions in a more straightforward manner -
const output =
$ ( input // starting with input,
, filterWithRadius (lat, lng, radius) // filterWithRadius then,
, filterWithOptions (filterOptions, keyword) // filterWithOptions then,
, filterWithOptions (tagOptions, tag) // filterWithOptions then,
, // ... // ...
)
Just as a carpenter crafts jigs and templates specific to his/her project, it's the programmer's job to invent any utility that makes his/her job easier. All you need to make this possible is an intelligent $
. Here's a complete example -
const $ = (input, ...operations) =>
operations .reduce (R.applyTo, input)
const add1 = x =>
x + 1
const square = x =>
x * x
const result =
$ ( 10 // input of 10
, add1 // 10 + 1 = 11
, add1 // 11 + 1 = 12
, square // 12 * 12 = 144
)
console .log (result) // 144
<script src="https://unpkg.com/[email protected]/dist/ramda.min.js"></script>
Your program is not limited to three (3) operations. We can chain thousands without worry -
$ (2, square, square, square, square, square)
// => 4294967296
As for making functions behave like R.identity
when certain arguments are nil (undefined
), I would suggest using default arguments as a best practice -
const filterWithRadius = (lat = 0, lng = 0, radius = 0, input) =>
// ...
Now if lat
and lng
are undefined, 0
will be supplied instead, which is a valid location known as the Prime Meridian. A search radius
of 0
however should return no results. So we can finish our function easily -
const filterWithRadius = (lat = 0, lng = 0, radius = 0, input) =>
radius <= 0 // if radius is less than or equal to 0,
? input // return the input, unmodified
: ... // otherwise perform the filter using lat, lng, and radius
This makes filterWithRadius
more robust without introducing null-check complexities. This is a clear win because the function is more self-documenting, produces a valid result in more cases, and doesn't involve writing more code to "fix" problems.
I see you used the inline R.pipe
anti-pattern in your filterWithRadius
function too. We could use $
to help us again here -
const filterWithRadius = (lat = 0, lng = 0, radius = 0, input) =>
radius <= 0
? input
: $ ( input
, filterByDistance (lat, lng, radius)
, map (addDistanceToObject (lat, lng))
, sortBy (prop ("distanceFromCenter"))
)
I hope this opens your eyes to some of the possibilities that are available to you.
Upvotes: 1
Reputation: 192317
For this solution to work, all your functions need to be curried, with the resources
as the final param.
The is to create a function (passIfNil
) that takes a function (fn
), and the params (in the right order for fn
). If any of this params are nil, R.identity is returned. If not, the original fn, but with the args
applied to it is returned.
Example (not tested):
const passIfNil = (fn, ...args) => R.ifElse(
R.any(R.isNil),
R.always(R.identity),
R.always(fn(...args))
);
resources = R.pipe(
passIfNil(filterWithRadius, lat, lng, radius), // if any of these arguments are nil, act like R.identity
passIfNil(filterWithOptions, filterOptions, keyword), // if either filterOptions or keyword is nil, act like R.identity
passIfNil(filterWithOptions, tagOptions, tag) // same as above.
)(resources);
Upvotes: 1
Reputation: 50807
I think that you're looking to put the responsibility for this behavior in the wrong place. If you want your pipeline functions to have one behavior with certain data, and different behavior with other data (or in this case, with missing data), then those individual functions should handle it, and not the pipeline function that wraps them.
But, as Ori Drori pointed out, you can write a function decorator to make this happen.
Here's one suggestion:
// Dummy implementations
const filterWithRadius = (lat, lng, radius, resources) =>
({...resources, radiusFilter: `${lat}-${lng}-${radius}`})
const filterWithOptions = (opts, val, resources) =>
({...resources, [`optsFilter-${opts}`]: val})
// Test function (to be used in pipelines, but more general)
const ifNonNil = (fn) => (...args) => any(isNil, args)
? identity
: (data) => fn (...[...args, data])
// alternately, for variadic result : (...newArgs) => fn (...[...args, ...newArgs])
// Pipeline call
const getUpdatedResources = (
{lat, lng, radius, filterOptions, keyword, tagOptions, tag}
) => pipe (
ifNonNil (filterWithRadius) (lat, lng, radius),
ifNonNil (filterWithOptions) (filterOptions, keyword),
ifNonNil (filterWithOptions) (tagOptions, tag)
)
// Test data
const resources = {foo: 'bar'}
const query1 = {
lat: 48.8584, lng: 2.2945, radius: 10,
filterOptions: 'baz', keyword: 'qux',
tagOptions: 'grault', tag: 'corge'
}
const query2 = {
lat: 48.8584, lng: 2.2945, radius: 10,
tagOptions: 'grault', tag: 'corge'
}
const query3 = {
lat: 48.8584, lng: 2.2945, radius: 10,
filterOptions: 'baz', keyword: 'qux',
}
const query4 = {
filterOptions: 'baz', keyword: 'qux',
tagOptions: 'grault', tag: 'corge'
}
const query5 = {
lat: 48.8584/*, lng: 2.2945*/, radius: 10,
filterOptions: 'baz', keyword: 'qux',
tagOptions: 'grault', tag: 'corge'
}
const query6 = {}
// Demo
console .log (getUpdatedResources (query1) (resources))
console .log (getUpdatedResources (query2) (resources))
console .log (getUpdatedResources (query3) (resources))
console .log (getUpdatedResources (query4) (resources))
console .log (getUpdatedResources (query5) (resources))
console .log (getUpdatedResources (query6) (resources))
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script>
<script> const {any, isNil, pipe, identity} = R </script>
We start with dummy implementations of your filter*
functions, ones which simply add a property to the input object.
The important function here is ifNotNil
. It takes a function of n
arguments, returning a function of n - 1
arguments that when called, checks if any of those arguments is nil
. If any are, it returns the identity function; otherwise it returns a function of one argument, which in turns calls the original function with the n - 1
arguments and this latest one.
We use this to build a pipeline which will be returned from a function which accepts the variables required (here destructured naively from a potential query object.) This function is called by passing the query and then the actual data to be transformed.
The examples show various combinations of parameters included and excluded.
This makes the assumption that your functions are not curried, that, say, filterWithRadius
looks like (lat, lng, radius, resources) => ...
If they are curried, we might write this instead:
const ifNonNil = (fn) => (...args) => any(isNil, args)
? identity
: reduce ((f, arg) => f(arg), fn, args)
used with
const filterWithRadius = (lat) => (lng) => (radius) => (resources) =>
({...resources, radiusFilter: `${lat}-${lng}-${radius}`})
but still called in the pipeline as
pipe (
ifNonNil (filterWithRadius) (lat, lng, radius),
// ...
)
You could even mix and match the curried and non-curried versions in the same pipeline, although I would expect that to add confusion.
Upvotes: 1