2

To the best of my understanding Monads were created to allow for composing functions with those that had potential side-effects - loosely speaking.

For me composition implies code like so:

f(g(h(x)))

In order to achieve this in a programming language one has to "line up the types" correctly, so that the output of h(x) is an input to g(...). Implying that such a function chaining would require all functions in the chain to work at the "monadic level of abstraction" for the types to line up correctly.

However, at my workplace (and some library code) I see a lot of code that looks more like "function chaining" like so:

h(x).flatMap(g).map(f)

This is NOT function composition AFAIK and this probably makes code harder to read IMHO since there's cognitive overload in understanding "type translation" with flatMap/map thrown into the mix. One has to mentally unravel the computations to see how they all "line up".

What is the common convention in the FP-world? I had a few discussions with my peers and got extremely strong push back for the compositional style f(g(h(... - almost everyone preferred the "chaining style". Is there a common "style guide" that's advocated for something like this?

From my POV, I've been exposed to LISP/Scheme and f(g(h... isn't really alien and is rather more clean and reads like a DSL. The chaining is rather hacky.

Question: Should functions work at the monadic level to allow for composition or is the suggestion to work at the level of the wrapped value?

Concrete example:

checkForBlanks(csvRows).flatMap(checkForUniqueIds).map(buildCache)) 

vs

buildCache(checkForUniqueIds(checkForBlanks(csvRows)))

Method signatures (Non-monadic):

def checkForBlanks(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]

def checkForUniqueIds(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]

def buildCache(csvRows: Vector[Record]): MyCache 

Method Signatures (Monadic):

def checkForBlanks(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]

def checkForUniqueIds(data: Either[InternalDomainError, Vector[Record]]): Either[InternalDomainError, Vector[Record]]

def buildCache(data: Either[InternalDomainError, Vector[Record]]): MyCache 

Common points for pushback:

  • Composing forces reading right to left
  • Composing makes functions think of Monads and will clutter responsibility of handling wrappers
  • It's easier if a function just works on the "actual value" vs. a monadic wrapper since it's "easier to reason"
  • It's way more flexible to "chain" than compose
  • If you really want to "compose" add additional methods that "call out" to pure methods and interally wrap monads - unnecessarily complicated so don't do it: E.g.:
def uniq(data: Either[InternalDomainError, Vector[Record]]): Either[InternalDomainError, Vector[Record]] =            data.flatMap(checkForUniqueIds)

From an FP-adherence and best practices POV what's the recommendation on should one do this for readability/maintainability?

  • There isn't any right or wrong answer here. Each method has its specific use cases and pros/cons, some of which you've already stated in your question. – Robert Harvey Apr 26 at 21:57
  • @RobertHarvey - I understand there isn't really a right/wrong - I just want to know what is commonly practiced/touted...is one approach more favored than the other? – PhD Apr 26 at 21:59
2

Although mathematicians may define the term differently, functional programmers tend to think of "composition" as any means of feeding the output from one function into the input of the next. maps and flatMaps are some of the most useful ways to do so.

I don't know if it was unintentional, but your monadic and non-monadic method signatures are actually swapped. Monadic functions have a signature like A => F[B]. If you actually implement the non-monadic versions of those functions, what you'll find is you end up repeating a lot of code to pull a value out of the monad, checking for errors, then performing the next step. You're mixing two abstraction layers and constantly reimplementing code that you would get for free with flatMap.

There are some monads that you can't effectively pull the value out of the monad at all, or at least once you pull it out you can't put it back in. For example, you can get the value out of a Future by calling Await.result, but that causes it to stop being asynchronous. Even for monads like List where that isn't an issue, staying "inside" the monad for as long as possible generally makes for the cleanest code. That means using map and flatMap.

Additionally, there are ways to do even regular function composition that are preferred over the h(g(f(x))) formulation. Scala has the f andThen g andThen h. Haskell has h . g . f. F# has a forward composition operator f >> g >> h that returns a composed function and something similar called the forward pipe operator (|>). Functional programmers prefer these formulations because of the lack of nesting. Nesting is a lot harder cognitively to track than juxtaposition.

So while h(g(f(x))) might be more familiar to imperative programmers, functional programmers very quickly come to prefer other styles.

1

The method used is the one most appropriate for the task at hand.

To put some of this into perspective, I recognize both map and flatmap as well-known, well-understood functional programming mechanisms for operating on collections, even though I'm not a functional programming expert. Chaining makes sense in this context. In C#, these concepts are used in Linq, where method chaining is used in a very powerful way to compose set operations. Data types don't matter, because everything in the call chain is the same type IEnumerable<T>. There are even state-engine-like ways to defer execution.

But if you're just working with ordinary functional composition (and not composing sets), f(g(h(x))) probably makes more sense. While Linq uses method chaining rather effectively, there are many other scenarios where it's ... shall we say, gratuitous? Fluent interfaces in C# and Java are considered (for the most part) boutique creations, because it's easier, cleaner and generally better overall to simply call a well-designed function or constructor.

  • 1
    The answer "it depends" can be considered a blanket answer for almost all concerns in software engineering. My ask is very specific to functional programming's "suggested" practices of composition. I understand that you "can" chain. But just because one "can" do it doesn't imply it's "best practice". Yes, limitations may dictate suboptimal practices but there aren't any technical limitations per se. I want to look at this from an abstraction POV - which approach has more merit and if I'm missing any other pro/con? Or as a functional programmer what's the most common route encountered? – PhD Apr 27 at 5:27
  • (1) "Fluent interfaces in C# and Java are considered (for the most part) boutique creations" -- yes, with the emphasis on locking in :-) (2) "because it's easier, cleaner and generally better overall to simply call a well-designed function or constructor." -- I would put it to dispute, see my answer. – Dmitri Zaitsev Aug 7 at 1:13
0

There is also the pipeline style:

pipeline(x)(h, g, f)

keeping the natural flow of the data: start with x, send through h, then g, then f. It is easy to switch to from the fluent style, as the order is the same:

wrapper(x).h().g().f()

Beside unnecessary parentheses, there are several other issues with this style:

  1. You can't reuse plain functions like x=>x+1 (the biggest issue): you can easily insert it into pipelines but there is nowhere to put it in the fluent line.

  2. The data x to start with can't be arbitrary. It can only be of the kind that the wrapper accepts, sometimes involving complex object packaging with clumsy names. Or else your code will break. While you can easily drop generic data x like numbers, strings, arrays directly into pipelines.

  3. As soon as you change the wrapper type, you need to ensure all your methods still work correctly, or your code will break. Needless to say, that is the path to get locked into the API, unsurprisingly common amongst proprietary software.

  4. Performance and code size issues due to importing too many methods that you don't need. With pipeline you only import functions you needed.

So your fluent style line

h(x).flatMap(g).map(f)

can be rewritten as

pipeline(x)(h, flatMap(g), map(f))

or in the functional point-free style as

map(f)(flatMap(g)(h(x)))

which doesn't have any of the above issues with fluent but it reverses the data flow and introduces more nested parens, while no nesting is necessary with pipelines. And the reverse order makes it harder to refactor when coming from the fluent style.

While you can easily refactor fluent into pipelines with little change. For instance, you can easily reuse/export all native methods via regular (curried) functions:

const map = f => array => array.map(f)

But you can't easily introduce new methods on-the-fly into your objects without patching prototypes or worrying about name collisions.

This is NOT function composition AFAIK and this probably makes code harder to read IMHO since there's cognitive overload in understanding "type translation" with flatMap/map thrown into the mix.

Indeed, this pattern is not directly expressible as composition of functions transforming data. And it is much harder to think of it that way, because every step changes your object, whose function is to hide its data, so you don't even see what is transformed and how.

Composing forces reading right to left

Not really, you can pick the data flow direction of your choice by choosing between functional and pipeline styles.

Composing makes functions think of Monads and will clutter responsibility of handling wrappers

Not if you think of Monadic instances as data that you can pass around with functions.

It's easier if a function just works on the "actual value" vs. a monadic wrapper since it's "easier to reason"

You seem to connect monads with your specific wrappers, but you don't have to. Even with fluent style, the wrappers only need to conform to monadic interface. And with pipeline/functional styles the flatMap operates directly on your data so you don't need any wrappers.

It's way more flexible to "chain" than compose

Actually the opposite, see the above issues 1-4.

If you really want to "compose" add additional methods that "call out" to pure methods and interally wrap monads - unnecessarily complicated so don't do it.

This is the complexity only present when using methods with fluent style, not with pelines or functional style.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.