• No results found

Toward modular, reusable code

4.6 Managing control flow with functional combinators

In chapter 3, I gave a comparison of a program’s control flow in both imperative and functional paradigms and highlighted the significant differences between them.

Imperative code uses procedural control mechanisms like if-else and for to drive a program’s flow, but functional programming doesn’t. As we leave the imperative world behind, we need to find alternatives to fill in that gap; for this, we can use func-tion combinators.

Combinators are higher-order functions that can combine primitive artifacts like other functions (or other combinators) and behave as control logic. Combinators typ-ically don’t declare any variables of their own or contain any business logic; they’re meant to orchestrate the flow of a functional program. In addition to compose and pipe, there’s an infinite number of combinators, but we’ll look at some of the most common ones:

identity

tap

alternation

sequence

fork (join) 4.6.1 Identity (I-combinator)

The identity combinator is a function that returns the same value it was provided as an argument:

identity :: (a) -> a

It’s used extensively when examining the mathematical properties of functions, but it has other practical applications as well:

Supplying data to higher-order functions that expect it when evaluating a func-tion argument, as you did earlier when writing point-free code (listing 4.12).

Unit testing the flow of function combinators where you need a simple function result on which to make assertions (you’ll see this in chapter 6). For instance, you could write a unit test for compose that uses identity functions.

Extracting data functionally from encapsulated types (more on this in the next chapter).

4.6.2 Tap (K-combinator)

tap is extremely useful to bridge void functions (such as logging or writing a file or an HTML page) into your composition without having to any create additional code.

It does this by passing itself into a function and returning itself. Here’s the function signature:

tap :: (a -> *) -> a -> a

This function takes an input object a and a function that performs some action on a.

It runs the given function with the supplied object and then returns the object. For instance, using R.tap, you can take a void function like debugLog

const debugLog = _.partial(logger, 'console', 'basic', 'MyLogger', 'DEBUG');

and embed it within the composition of other functions. Here are some examples:

const debug = R.tap(debugLog);

const cleanInput = R.compose(normalize, debug, trim);

const isValidSsn = R.compose(debug, checkLengthSsn, debug, cleanInput);

Having the call to debug (based on R.tap) won’t alter the result of the program in any way. In fact, this combinator throws away the result of the function passed into it (if any). This will compute the result and also perform debugging along the way:

isValidSsn('444-44-4444');

// output

MyLogger [DEBUG] 444-44-4444 // clean input MyLogger [DEBUG] 444444444 // check length MyLogger [DEBUG] true // final result

4.6.3 Alternation (OR-combinator)

The alt combinator allows you to perform simple conditional logic when providing default behavior in response to a function call. This combinator takes two functions and returns the result of the first one if the value is defined (not false, null, or undefined); otherwise, it returns the result of the second function. Let’s implement it here:

const alt = function (func1, func2) { return function (val) {

return func1(val) || func2(val);

} };

Alternatively, you could also write this function succinctly using curry and lambdas:

const alt = R.curry((func1, func2, val) => func1(val) || func2(val));

You can use this combinator as part of the showStudent program to handle the case when the fetch operation returns unsuccessfully, so that you can create a new student:

const showStudent = R.compose(

append('#student-info'), csv,

alt(findStudent, createNewStudent));

showStudent('444-44-4444');

To understand what’s happening, think of this code emulating a simple if-else state-ment equivalent to the imperative conditional logic:

var student = findStudent('444-44-4444');

if(student !== null) { let info = csv(student);

append('#student-info', info);

} else {

let newStudent = createNewStudent('444-44-4444');

let info = csv(newStudent);

append('#student-info', info);

}

4.6.4 Sequence (S-combinator)

The seq combinator is used to loop over a sequence of functions. It takes two or more functions as parameters and returns a new function, which runs all of them in sequence against the same value. This is the implementation:

const seq = function(/*funcs*/) {

const funcs = Array.prototype.slice.call(arguments);

return function (val) {

funcs.forEach(function (fn) { fn(val);

});

};

};

With it, you can perform a sequence of related, yet independent, operations. For instance, after finding the student object, you can use seq to both render it on the HTML page and log it to the console. All functions will run in that order against the same stu-dent object:

const showStudent = R.compose(

seq(

append('#student-info'), consoleLog),

csv,

findStudent));

The seq combinator doesn’t return a value; it just performs a set of actions one after the other. If you want to inject it into the middle of a composition, you can use R.tap to bridge the function with the rest.

4.6.5 Fork (join) combinator

The fork combinator is useful in cases where you need to process a single resource in two different ways and then combine the results. This combinator takes three func-tions: a join function and two terminal functions that process the provided input. The result of each forked function is ultimately passed in to a join function of two argu-ments, as shown in figure 4.14.

NOTE This isn’t to be confused with the Java fork-join framework, which helps with multiprocessing. This comes as a fork combinator implementation in Haskell and other functional toolkits.

This is the implementation:

const fork = function(join, func1, func2){

return function(val) {

return join(func1(val), func2(val));

};

};

Now let’s see it in action. Let’s revisit computing the average letter grade from an array of numbers. You can use fork to coordinate the evaluation of three utility functions:

const computeAverageGrade =

R.compose(getLetterGrade, fork(R.divide, R.sum, R.length));

computeAverageGrade([99, 80, 89]); //-> 'B'

The next example checks whether the mean and median of a collection of grades are equal:

const eqMedianAverage = fork(R.equals, R.median, R.mean);

eqMedianAverage([80, 90, 100])); //-> True eqMedianAverage([81, 90, 100])); //-> False

func1 func2

fork

join input

Figure 4.14 The fork combinator receives three functions: a join and two fork functions. The fork functions are executed against the supplied input, and then the final result is combined via join.

Some people view composition as restrictive, but you can see for yourself that it’s quite the opposite: combinators unlock freedom and facilitate point-free programming.

Because combinators are pure, they can be composed into other combinators, provid-ing an infinite number of alternatives to express and reduce the complexity of writprovid-ing any type of application. You’ll see them used again in the following chapters.

Through the basic principles of immutability and purity, functional programming enables a fine level of modularity and reusability of the functions that make up your program. In chapter 2, you learned that in JavaScript, functions can also be used to implement modules. Using these same principles, you can also compose and reuse entire modules. I’ll leave this idea for you to contemplate on your own.

Modular functional programs consist of abstract functions that can be understood and reused independently and whose meaning is derived from rules governing their composition. In this chapter, you learned that composing pure functions is the back-bone of functional programming. These techniques take advantage of the abstraction (via currying and partial application) of pure functions with the goal of making them composable. So far, I haven’t talked about error handling, which is a critical part of any robust, fault-tolerant application; that’s what we’ll visit next.

4.7 Summary

Functional chains and pipelines connect reusable and modular componen-tized programs.

Ramda.js is a functional library adapted for currying and composition, with a powerful arsenal of utility functions.

Currying and partial evaluation can be used to reduce the arity of pure func-tions by partially evaluating a subset of a function’s arguments and transform-ing them into unary functions.

You can break a task into simple functions and compose them together to arrive at the entire solution.

Using function combinators allows you to orchestrate complicated program flows to tackle any real-world problem as well as write in a point-free manner.

117

Design patterns