DEV Community

Angelo Sciarra
Angelo Sciarra

Posted on • Edited on

A matter of purity

Graal

Hello folks,
for my second post I've chosen a more philosophical topic: pure functions.

I ended my first article talking about the concept of functions in FP, saying that:

functions in FP are just mapping between inputs and outputs (and nothing more)

This is an informal definition of pure functions.

Other definitions you may have heard are:

  • pure functions are functions in the mathematical sense
  • pure functions are functions that will return the same output anytime they are given the same input
  • pure functions are functions without side effects

The mathematical definition of a function is the following:

Given two non-empty sets X and Y, a function f from X to Y is a relation such that for all x in X there exists only one y in Y such that (x, y) belongs to f, or, said otherwise, such that y = f (x)

(note the for all is crucial because what we are looking for are total functions)

So, why should you care about pure functions?

Well, the reason is that the use of pure functions will help you write correct programs.

How? (you might be wondering)

Let's see.

Local reasoning

You may have heard about local reasoning or not. What it refers to is the ability to reason about what a piece of code does in isolation, meaning you don't need any knowledge about the context in which said piece of code is executed to understand what it does.

How pure functions relate to local reasoning? Well, if a function takes care only of transforming an input value in an output one, without relying on any other external piece of information, that function enables of course local reasoning about what it is doing.

Let's look at an example of a function that does not allow local reasoning

var counter = 1; 

fun `not easy to reason about`(anInt: Int) =
    if (counter < 5) {
        counter = counter + 1
        anInt * 2
    } else {
        counter = counter + 1
        anInt * 3
    }
}
Enter fullscreen mode Exit fullscreen mode

Why is that? Of course, because the logic of the function relies on the mutable, global counter variable.

The same example can be translated into a more familiar setting for those of you who are more accustomed to Java and OOP (instead of Kotlin)

public class TheOne {

    private int counter = 0;

    public notEasyToReasonAbout(int anInt) {
        int result;     
        if (counter < 5) {
            result = anInt * 2;
        } else {
            result = anInt * 3;
        }
        counter = counter + 1;

        return result;
    }

}
Enter fullscreen mode Exit fullscreen mode

I guess you have seen many classes like TheOne shown here.

What is the problem? Again the fact the method notEasyToReasonAbout is relying on the value of the mutable counter that is out of its scope (this time is not global but still has a lifespan that goes beyond that of the method execution and it can mutate over it).

DISCLAIMER

Am I saying that the OOP way of doing things is intrinsically impure? Not at all.
It is just that languages like Java tend to make it easier to use constructs that work against local reasoning (like mutability) while others (think of modern languages like Kotlin, or Scala) choose to make it easier to use constructs that make it easier to apply local reasoning (examples are the val keyword that introduces immutable references, the default choice of immutable collections over mutable ones and so on).

Referential transparency

Once you start adopting pure functions in your codebase you gain another capability you were missing before, referential transparency. What is it about?

Say you have a piece of code like

val x = aFunction(y)
Enter fullscreen mode Exit fullscreen mode

If aFunction is a pure function you gain the ability to safely replace any occurrence of aFunction(y), provided that y is immutable, with x, because x will always be the result of applying aFunction to y.

Have you ever heard of temporal coupling? Well if you use pure functions you can forget about it and move around your values as you please.

Question time

Do you think the following function is referentially transparent?

fun `read file lines`(path: String): List<String> = 
    File(path).readLines()
Enter fullscreen mode Exit fullscreen mode

Answer

No. The reason is that anytime you call this function, even with the same path as input, you are not sure you will get the same results. Reasons may be

  • the file doesn't exist anymore (then you'll get an exception)
  • the file has been modified (so the content will differ)
  • the file permissions have been changed and you no longer have read permission (once again you'll get an exception)

You may be wondering how can one write pure functions that do things like IO but I won't talk about this topic right now (you will have to wait for a new article 😏).

Function composition

The beauty of pure functions is that you can compose them or, going the other way around, you can split a big function into pieces and then compose them together to obtain the initial function.

If you think about it, writing a program to solve a need can be abstracted to writing one big function that turns our program input in its outputs.

As usual, the approach one takes is: divide et impera! But what's the point of splitting a problem in subproblems if you cannot glue back the solutions to the subproblems into an overall solution?

With pure functions, function compositions is a matter of having matching types.
Given f: B -> C and g: A -> B, they can be composed to obtain h: A -> C, because the output type of g matches the input type of f, so h = f . g

Conclusion

Once you start using pure functions, you will start to think more and more in terms of types and of functions as mappings between types.

Your workflows/use cases (whatever you are calling them) will naturally emerge from the types of the functions you have developed to solve the smaller bits of your problem domain.

Follow the types!

You will end up doing TDD: Types Driven Development!

Top comments (0)