Monday, August 28, 2017

Lodash Logic

If you're using Lodash, you have a handful of tools at your disposal for organizing logic into functions. This is a nice alternative to imperative if statements sprinkled throughout your code. For example, let's say that you only want to execute code if one of several choices is true. You could use the some() function as follows:

const choices = [
  { name: 'choice 1', value: true },
  { name: 'choice 2', value: true },
  { name: 'choice 3', value: false }
];

if (_(choices).map('value').some()) {
  console.log('true');
else {
  console.log('false');
}

The choices array represents the choices that we have available. The name property isn't actually used for anything in this code. The value property is what we're interested in here. We want to run some code if any value is true.

To do so, we're using an if statement. With the help of the map() and some() Lodash functions, we can easily check for this condition. In this case, the statement evaluates to true because there's two true values in the array. We can also check to make sure that every value is true before executing a piece of code:

if (_(choices).map('value').every()) {
  console.log('true');
else {
  console.log('false');
}

In this case, the else path is followed because not every value is true.

The _.some() and _.every() functions are helpful with simplifying the conditions evaluated by if statements. For example, _.some() provides the same result as chaining together a bunch of logical or (||) operators. Likewise, _.every() replaces logical and (&&) operators.

The result is that instead of having to maintain the condition that's evaluated in the if statement, we can simply add new values to the choices collection. Essentially, this is a step toward declarative programming, away from imperative programming.

Let's think about the if statement used above, and what it's actually doing. It's calling console.log() when some condition is true, and it's calling console.log() again when the condition is false. The problem with if statements like this is that they're not very portable. It'd be much easier to call a function with the possible choices as an argument, and the correct behavior is invoked.

Here's what this might look like:

const some = (yes, no) => (...values) =>
  new Map([
    [true, yes],
    [false, no]
  ]).get(_.some(values))();

Let's break this code down:
  • We've created a higher-order function called some() that returns a new function.
  • The returned function accepts an arbitrary number of values. These are tested with Lodash's _.some().
  • The some() function accepts yes() and no() functions to run based on the result of calling _.some(values)
  • A Map is used in place of an if statement to call the appropriate logging function.
With this utility, we can now compose our own functions that values as arguments, and based on those arguments, run the appropriate function. Let's compose a function using some():

const hasSome = some(
  () => console.log('has some'),
  () => console.log('nope')
);

Now we have a hasSome() function will log either "has some" or "nope", depending on what values are passed to it:

hasSome(0, 0, 0, 1, 0);
// -> has some
hasSome(0, 0, 0, 0);
// -> nope

Now any time that you want an if statement that evaluates simple boolean expressions and runs one piece of code or another, depending on the result, you can use some() to compose a new function. You then call this new function with the choices as the arguments.

Let's create an every() function now that works the same way as some() except that it tests that every value is true:

const every = (yes, no) => (...values) =>
  new Map([
    [true, yes],
    [false, no]
  ]).get(_.every(values))();

The only difference between every() and some() is that we're using _.every() instead of _.some(). The approach is identical: supply yes() and no() functions to call depending on result of _.every().

Now we can compose a hasEvery() function, just like we did with the hasSome() function:

const hasEvery = every(
  () => console.log('has every'),
  () => console.log('nope')
);

Once again, we've avoided imperative if statements in favor of functions. Now we can call hasEvery() from anywhere, and pass in some values to check:

hasEvery(1, 1, 1, 1, 1)
// -> has every
hasEvery(1, 1, 1, 1, 0)
// -> nope

Lodash has a _.cond() function that works similarly to our Map approach in some() and every(), only more powerful. Instead of mapping static values, such as true and false, to functions to run, it maps functions to functions. This allows you to compute values to test on-the-fly.

Before we get too fancy, let's rewrite our some() and every() functions using _.cond():

const some = (yes, no) => _.flow(
  _.rest(_.some, 0),
  _.cond([
    [_.partial(_.eq, true), yes],
    [_.stubTrue, no]
  ])
);

Let's break this down:
  • The _.flow() function creates a new function by calling the first function, then passing it's return value to the next function, and so on.
  • The _.rest() function creates a new function that passes argument values as an array to it's wrapped function. We're doing this with _.some() because it expects an array, but we just want to be able to pass it argument values instead.
  • The _.cond() function takes an array of pairs. A pair is a condition function, and a function to call if the condition function returns true. The first pair that evaluates to true is run, and no other pairs are evaluated.
  • The _.partial(_.eq, true) call makes a new function that tests the output of _.some().
  • The _.stubTrue() function will always evaluate to true, unless something above it evaluates to true first. Think of this as the else in an if statement.
We can use this new implementation of some() to compose the same hasSome() function that we created earlier and it will work the same way. Likewise, we can implement the every() function using the same approach.

For something as simple as the some() and every() functions, the _.cond() approach doesn't present any clear advantage over the Map approach. This is because there are exactly two paths. Either the condition evaluates to true, or it doesn't. Often, we're not working with simple yes/no logical conditions. Rather, there are a number of potential paths.

Think of this as a an if-else statement with lots of conditions. Suppose we had the following conditions:

const condition1 = false;
const condition2 = true;
const condition3 = false;

Instead of a simple yes/no question with two potential paths, now we have 3. Later on, we might have 4, and so on. This is how software grows to be complex. Here's how we would evaluate these conditions and execute corresponding code using _.cond():

const doStuff = _.cond([
  [_.constant(condition1), () => console.log('Condition 1')],
  [_.constant(condition2), () => console.log('Condition 2')], 
  [_.constant(condition3), () => console.log('Condition 3')]
]);

doStuff();
// -> Condition 2

For each of the condition constants that we defined above, we're using the _.constant() function in _.cond(). This creates a function that just returns the argument that is passed to it. As you can see, console.log('Condition 2') is called because the function returned by _.constant(condition2) returns true.

It's easy to add new pairs to _.cond() as the need arises. You can have 20 different execution paths, and it's just as easy to maintain as 2 paths.

In this example, we're using static values as our conditions. This means that doStuff() will always follow the same path, which kind of defeats the purpose of this type of code. Instead, we want the path chosen by _.cond() to reflect the current state of the app:

const app = {
  condition1: false,
  condition2: false,
  condition3: true
};

Instead of using _.const(), we'll have to somehow pass the app into each evaluator function in _.cond():

const doStuff = _.cond([
  [_.property('condition1'), () => console.log('Condition 1')], 
  [_.property('condition2'), () => console.log('Condition 2')], 
  [_.property('condition3'), () => console.log('Condition 3')]
]);

The _.property() function creates a new function that returns the given property value of an argument. This is where the _.cond() approach really shines: we can pass arguments to the function that it creates. Here, we want to pass it the app object so that we can process its state:

doStuff(app);
// -> Condition 3

app.condition1 = true;
app.condition3 = false;

doStuff(app);
// -> Condition 1

When doStuff() is called the first time, the console.log('Condition 3') path is executed. Then, we change the state of app so that condition1 is true and condition3 is false. When doStuff() is called again with app as the argument, the console.log('Condition 1') path is executed.

So far, we've been composing functions that use console.log() to print values. If you write smaller functions that return values instead of simply printing them, you can combine them to build more complex logic. Think of this as an alternative to implementing nested if statements.

As an example, suppose we have the following two functions:

const cond1 = _.cond([
  [_.partial(_.eq, 1), _.constant('got 1')],
  [_.partial(_.eq, 2), _.constant('got 2')]
]);

const cond2 = _.cond([
  [_.partial(_.eq, 'one'), _.constant('got one')], 
  [_.partial(_.eq, 'two'), _.constant('got two')]
]);

These functions themselves follow the same implementation approach, using _.cond(). For example, cond1() will return the string 'got 1' or 'got 2', depending on the number supplied as an argument. Likewise, cond2() will return 'got one' or 'got two', depending on the string argument value.

While we can use both of these functions on their own, we can also use them to compose another function. For example, we could write an if statement that would determine which one of these functions to call:

if (_.isFinite(val)) {
  cond1(val);
} else if (_.isString(val)) {
  cond2(val);
}

Remember, this approach isn't very portable. To make it portable, in the sense that we don't have to write the same if statement all over the place, we could wrap the whole thing in a function. Or, we could just use _.cond() to compose it:

const cond3 = _.cond([
  [_.isFinite, cond1],
  [_.isString, cond2]
]);

cond3(1);
// -> "got 1"
cond3(2);
// -> "got 2"
cond3('one');
// -> "got one"
cond3('two');
// -> "got two"

Using _.cond(), you can compose complex logic by reusing existing functions. This means that you can keep using these smaller functions where they're needed, and you can use them as pieces of larger functions.

No comments :

Post a Comment