Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to understand curry and function composition using Lodash flow?

import {flow, curry} from 'lodash';

const add = (a, b) => a + b;

const square = n => n * n;

const tap = curry((interceptor, n) => {
    interceptor(n);
    return n;
});

const trace2 = curry((message, n) => {
    return tap((n) => console.log(`${message} is  ${n}`), n);
});

const trace = label => {
    return tap(x => console.log(`== ${ label }:  ${ x }`));
};


const addSquare = flow([add, trace('after add'), square]);
console.log(addSquare(3, 1));

I started by writing trace2 thinking that trace wouldn't work because "How can tap possibly know about n or x whatever?".

But trace does work and I do not understand how it can “inject” the x coming from the flow into the tap call. Any explanation will be greatly appreciated :)

like image 295
Tirke Avatar asked Feb 09 '17 07:02

Tirke


People also ask

What is curry in Lodash?

_.curry(func, [arity=func.length]) Creates a function that accepts arguments of func and either invokes func returning its result, if at least arity number of arguments have been provided, or returns a function that accepts the remaining func arguments, and so on. The arity of func may be specified if func.

What does Lodash compose do?

compose — a function that accepts any number of functions as arguments. It then calls them from right to left, just the same as functions are called when you pass them as an argument. Fp. SortBy, uniqBy, filter, and map all accept the data last.

Is Lodash functional programming?

Lodash also provides a Functional Programming Module, which, according to them: promotes a more functional programming (FP) friendly style by exporting an instance of Lodash with its methods wrapped to produce immutable auto-curried iteratee-first data-last methods.


1 Answers

Silver Spoon Evaluation

We'll just start with tracing the evaluation of

addSquare(3, 1) // ...

Ok, here goes

= flow([add, trace('after add'), square]) (3, 1)
        add(3,1)
        4
             trace('after add') (4)
             tap(x => console.log(`== ${ 'after add' }:  ${ x }`)) (4)
             curry((interceptor, n) => { interceptor(n); return n; }) (x => console.log(`== ${ 'after add' }:  ${ x }`)) (4)
             (x => console.log(`== ${ 'after add' }:  ${ x }`)) (4); return 4;
             console.log(`== ${ 'after add' }:  ${ 4 }`); return 4;
~log effect~ "== after add: 4"; return 4
             4
                                 square(4)
                                 4 * 4
                                 16
= 16

So the basic "trick" you're having trouble seeing is that trace('after add') returns a function that's waiting for the last argument. This is because trace is a 2-parameter function that was curried.


Futility

I can't express how useless and misunderstood the flow function is

function flow(funcs) {
  const length = funcs ? funcs.length : 0
  let index = length
  while (index--) {
    if (typeof funcs[index] != 'function') {
      throw new TypeError('Expected a function')
    }
  }
  return function(...args) {
    let index = 0
    let result = length ? funcs[index].apply(this, args) : args[0]
    while (++index < length) {
      result = funcs[index].call(this, result)
    }
    return result
  }
}

Sure, it "works" as it's described to work, but it allows you to create awful, fragile code.

  • loop thru all provided functions to type check them
  • loop thru all provided functions again to apply them
  • for some reason, allow for the first function (and only the first function) to have special behaviour of accepting 1 or more arguments passed in
  • all non-first functions will only accept 1 argument at most
  • in the event an empty flow is used, all but your first input argument is discarded

Pretty weird f' contract, if you ask me. You should be asking:

  • why are we looping thru twice ?
  • why does the first function get special exceptions ?
  • what do I gain with at the cost of this complexity ?

Classic Function Composition

Composition of two functions, f and g – allows data to seemingly teleport from state A directly to state C. Of course state B still happens behind the scenes, but the fact that we can remove this from our cognitive load is a tremendous gift.

function composition

Composition and currying play so well together because

  1. function composition works best with unary (single-argument) functions
  2. curried functions accept 1 argument per application

Let's rewrite your code now

const add = a => b => a + b

const square = n => n * n;

const comp = f => g => x => f(g(x))

const comp2 = comp (comp) (comp)

const addSquare = comp2 (square) (add)

console.log(addSquare(3)(1)) // 16

"Hey you tricked me! That comp2 wasn't easy to follow at all!" – and I'm sorry. But it's because the function was doomed from the start. Why tho?

Because composition works best with unary functions! We tried composing a binary function add with a unary function square.

To better illustrate classical composition and how simple it can be, let's look at a sequence using just unary functions.

const mult = x => y => x * y

const square = n => n * n;

const tap = f => x => (f(x), x)

const trace = str => tap (x => console.log(`== ${str}: ${x}`))

const flow = ([f,...fs]) => x =>
  f === undefined ? x : flow (fs) (f(x))

const tripleSquare = flow([mult(3), trace('triple'), square])

console.log(tripleSquare(2))
// == "triple: 6"
// => 36

Oh, by the way, we reimplemented flow with a single line of code, too.


Tricked again

OK, so you probably noticed that the 3 and 2 arguments were passed in separate places. You'll think you've been cheated again.

const tripleSquare = flow([mult(3), trace('triple'), square])

console.log(tripleSquare(2)) //=> 36

But the fact of the matter is this: As soon as you introduce a single non-unary function into your function composition, you might as well refactor your code. Readability plummets immediately. There's absolutely no point in trying to keep the code point-free if it's going to hurt readability.

Let's say we had to keep both arguments available to your original addSquare function … what would that look like ?

const add = x => y => x + y

const square = n => n * n;

const tap = f => x => (f(x), x)

const trace = str => tap (x => console.log(`== ${str}: ${x}`))

const flow = ([f,...fs]) => x =>
  f === undefined ? x : flow (fs) (f(x))

const addSquare = (x,y) => flow([add(x), trace('add'), square]) (y)

console.log(addSquare(3,1))
// == "add: 4"
// => 16

OK, so we had to define addSquare as this

const addSquare = (x,y) => flow([add(x), trace('add'), square]) (y)

It's certainly not as clever as the lodash version, but it's explicit in how the terms are combined and there is virtually zero complexity.

In fact, the 7 lines of code here implements your entire program in less than it takes to implement just the lodash flow function alone.


The fuss, and why

Everything in your program is a trade off. I hate to see beginners struggle with things that should be simple. Working with libraries that make these things so complex is extremely disheartening – and don't even get me started on Lodash's curry implementation (including it's insanely complex createWrap)

My 2 cents: if you're just starting out with this stuff, libraries are a sledgehammer. They have their reasons for every choice they made, but know that each one involved a trade off. All of that complexity is not totally unwarranted, but it's not something you need to be concerned with as a beginner. Cut your teeth on basic functions and work your way up from there.


Curry

Since I mentioned curry, here's 3 lines of code that replace pretty much any practical use of Lodash's curry.

If you later trade these for a more complex implementation of curry, make sure you know what you're getting out of the deal – otherwise you just take on more overhead with little-to-no gain.

// for binary (2-arity) functions
const curry2 = f => x => y => f(x,y)

// for ternary (3-arity) functions
const curry3 = f => x => y => z => f(x,y,z)

// for arbitrary arity
const partial = (f, ...xs) => (...ys) => f(...xs, ...ys)

Two types of function composition

One more thing I should mention: classic function composition applies functions right-to-left. Because some people find that hard to read/reason about, left-to-right function composers like flow and pipe have showed up in popular libs

rtl vs ltr function composition

  • Left-to-right composer, flow, is aptly named because your eyes will flow in a spaghetti shape as your try to trace the data as it moves thru your program. (LOL)

  • Right-to-left composer, composer, will make you feel like you're reading backwards at first, but after a little practice, it begins to feel very natural. It does not suffer from spaghetti shape data tracing.

like image 112
Mulan Avatar answered Oct 29 '22 03:10

Mulan