How does the function composition (.) operator work with multiple compositions?

206 Views Asked by At

I know that the dot (.) operator is defined to take three arguments(two functions and a value of type "a"), and works by the application of applying the argument to the function on the right, and the function on the left is applied to the output of the application of the left side, like follows:

-- Associativity: infixr 9
(.) :: (b -> c) -> (a -> b) -> a -> c
(f . g) x = f(g x)
-- Or in another format
f . g = \x -> f(g x)

Now, that's what's confusing with more than one application with the dot operator in a function composition, consider this:

With an infix operator like (+), it takes two arguments of types within the "Num" class.

(+) :: Num a => a -> a -> a

-- An application would look like this:
> 4 + 3
7

-- With more applications composed with (+)
> 4 + 3 + 2
9

-- Which is fine, since the last application
-- still takes two arguments of the legitimate types,
-- which is a value of type within "Num" class, as follows
> (4 + 3) + 2
-- 7 + 2
9

But with the dot operator, as follows. The last function composition with the dot operator should instead take the output of the application "reverse . take 3 $ map (*2) [1..]", which is "[6,4,2]" of the previous function composition.

If that's what I said, the dot operator takes a value instead of a function and a value on the right side, how is it supposed to work?

> replicate 4 . reverse . take 3 $ map (*2) [1..]
[[6,4,2],[6,4,2],[6,4,2],[6,4,2]]

Isn't it supposed to be like follows for its middle step?

-- And it's not going to work, since the dot operator
-- is defined to take three arguments of two functions
-- "(b -> c)" and "(a -> b)"
-- and the value of type "a"
replicate 4 . [6,4,2]

Since an application of dot operator takes three arguments and then returns a result of a value of type "a".

So the part "reverse . take 3 $ map (*2) [1..]" on the left side should be evaluated to "[6,4,2]"

Why is it not?

2

There are 2 best solutions below

7
lsmor On BEST ANSWER

You are getting confused by currification (more on this later). You can think of (.) as taking two arguments (as (+) does).

-- Associativity: infixr 9
--                             |- this parens. aren't necessary, but It helps to make them explicit
(.) :: (b -> c) -> (a -> b) -> (a -> c)
--     |- input 1  |- input 2  |- result function 

Now, because (.) it is right assoc the two lines of code below are equivalent

replicate 4 . reverse . take 3   
replicate 4 . (reverse . take 3) -- by right assoc. property

in order to make this more explicit let me rewrite the code above

-- original
doSomething = replicate 4 . reverse . take 3 $ map (*2) [1..]

-- original with explicit parenthesis
doSomething = ( replicate 4 . (reverse . take 3) ) (map (*2) [1..])
              |               |- right assoc  -| | |              |
              |                                  | |              |
              |- these four are the result of deleting $ symbol  -|

-- factor out each parenthesis. Types are monomorphise for clarity.
doSomething = (replicateFour . takeThreeAndReverse) evenNumbers 
  where replicateFour       = replicate 4      -- :: [Int] -> [[Int]]  
        takeThreeAndReverse = reverse . take 3 -- :: [Int] -> [Int]
        evenNumbers         = map (*2) [1..]   -- :: [Int]

Now, the technique we are applying here is called currying. Which can be summarize as "a function which takes two arguments can be re-written as a function taking one argument and returning another function taking one argument."

Some python code for the sake of understanding

# The following two are equivalent functions. 
def add(x, y):
  return x + y

def add_curry(x):
  def inner(y):
    return x + y
  return inner # we return a function not a value

add(3, 4) == add(3)(4) # notice double function call in RHS

Now, all haskell functions are curried, this actually make a lot of sense on a functional language because if simplifies a lot composition and makes the language more ergonomic in most situations (altough, this is an opinion and someothers may ague differently)

Currying can be a headache in the beginning but eventually It fells pretty natural. The best way to overcome it is by doing a lot of exercises and trying to use a lot of partial application

0
Akari On

In short, in an application of a multi function composition said "(t . z . f. g)" with an argument said "x", each single composition of the function composition will be evaluated first from right to left, by the precedence of "infixr 9" of the dot operator, before getting applied to the argument "x", since the composition and the given argument are separated by the dollar operator which is defined with the precedence of "infixr 0".

The composition: (t . z . f . g)

Each function's type:
t :: (d -> e)
z :: (c -> d)
f :: (b -> c)
g :: (a -> b)

Actual type: 
(t . z . f . g) :: a -> e

Now, because of currying, the dot operator is defined to take three arguments of two functions and a value, said "(b -> c)", "(a -> b)" and "a", if the dot operator only takes these two functions, it will then return a function "(a -> c)" as the right operand of the next dot operator, and so on in a multi function composition.

Type in process for each composition with the dot operator:
(d -> e) . (c -> d) . (b -> c) . (a -> b) $ a
((d -> e) . (c -> d) . (b -> c) . (a -> b)) a
((d -> e) . (c -> d) . (a -> c)) a
((d -> e) . (a -> d)) a
(a -> e) a

In a real case:

> :t (replicate 3 . reverse . take 3 . map (*3))
(replicate 3 . reverse . take 3 . map (*3))
  :: Num a => [a] -> [[a]]
> replicate 3 . reverse . take 3 . map (*3) $ [1..]
[[9,6,3],[9,6,3],[9,6,3]]

It works as expected :)

Thanks @lsmor for the detailed & inspirational answer. This post is my way of understanding, not about which is better. Thanks a lot!