How to understand curry and function composition using Lodash flow?

前端 未结 1 1967
慢半拍i
慢半拍i 2020-12-03 04:00
import {flow, curry} from \'lodash\';

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

const square = n => n * n;

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


        
相关标签:
1条回答
  • 2020-12-03 04:39

    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.

    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

    • 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.

    0 讨论(0)
提交回复
热议问题