Introduction to Functors With Scala 3
You’ve recently started learning functional programming and have been successful in writing functions, but then something unexpected happens. A wild functor appears! What is that? Why is it here?
Don’t be scared, functors are a mathematical structure that arises from category theory, and they can be found everywhere in functional programming. For those of you who don’t know category theory, this will give you enough knowledge to understand functors and use them to program.
Do you prefer to watch a presentation of this blog post instead of reading it? Check out the companion Youtube video.
Motivation
Let’s imagine that we have some library that parses JSON and returns a JSONReader[A]
object. The object has some method to extract the value. Our logic will get the configuration reader for the timeout, and then pass that configuration reader to the function that sets the time out. A very simple illustration of what happens is below:
// we put in in a trait so that it compiles
trait Example:
// trivial definition, to illustrate
type JSONReader[A] = A
// some provided business service
def setTimeoutFromConfig(r: JSONReader[Int]): Unit
// some library provides this
def getConfigReader: JSONReader[String]
// we get the reader, but it is the wrong type
val configReader = getConfigReader
// we define the transformation
def transform(in: JSONReader[String]): JSONReader[Int]
// we can now call our function
setTimeoutFromConfig(transform(configReader))
end Example
This kind of problem arises al the time. We need to adapt a type to be used in another context. Furthermore, we do not realize it but we have certain expectations of this transformation. To behave as expected it would be like extracting the String
, applying to _.toInt
to it, and wrapping it back into the JSONReader
object. We expect that only the contents or result of the JSONReader
object will be modified, but the computation itself remains unchanged.
The questions that arises is: How can I define my JSONReader
type and my transform function in a way that is both:
- captures my intuition that only the contents but not the container is modified
- captures my intuition that the container is not modifying the contents when apply several transformations to it.
This is the question that functors provide the answer to. Functors are data structures that represent computations or containers and that allow the contents or result to be modified without modifying the structure itself.
Definition
A functor F
has two parts:
- a part that transforms a type into another type. For example, if we have type
A
, we will call the new typeF[A]
. In our example above, it would be the type constructor ofJSONReader[A]
. - A part that transforms functions into other functions. Lets write it like this:
def map[A, B](f: A => B): F[A] => F[B]
. This is a slightly more generic version of thetransform
function from above. In this case, we take a function from the original type and create a new function from it.
There are several ways to write map
, so there are some restrictions on it. Our new type needs to fulfil two equations, called the functor laws. The laws say that:
map[A, A](identity[A]) == identity[F[A]]
- Given
f: A => B
andg: B => C
,map(f.compose(g)) == map(f).compose(g))
The two functor laws are written in Scala code, but they are still so abstract that we cannot expect the compiler to check them when written like that. Let’s write a function to check them, that the compiler can compile and execute:
def check1stFunctorLaw[A, F[_]](fa: F[A])(map: (A => A) => (F[A] => F[A])): Boolean =
map(identity[A])(fa) == identity(fa)
def check2ndFunctorLaw[A, B, C, F[_]] (fa: F[A])(f: A => B, g: B => C)(map: [X, Y] => (X => Y) => (F[X] => F[Y])): Boolean =
map((g.compose(f)))(fa) == map(g).compose(map(f))(fa)
Now, even though the code above compiles and actually checks the two functor laws, it is still abstract. Indeed, to actually use them, we need to first define our first functor.
Some Concrete Functors
To define a functor, we have several ways of achieving that in Scala. We can, for example, define a new type using the type
. Let’s define a very simple functor, the identity functor.
type IdFunctor[A] = A
The identity functor feels like cheating. We are just transforming A
into A
? That is not even a transformation. But we have fulfilled the requirements. The second part is easy to implement, we have:
def mapId[A, B](f: A => B): IdFunctor[A] => IdFunctor[B] = f
The functor laws are vacuously verified. We can say that our type IdFunctor
with the function map
forms a functor.
Let’s use our functions to test the two function laws for some values:
// we can check for example that for fa: IdFunctor[Int] = 2
// the first law holds
check1stFunctorLaw[Int, IdFunctor](2)(mapId)
// we can check fhat for fa: IdFunctor[String], and f(x) = x.toInt and g(x) = x + 2
// the second law holds
check2ndFunctorLaw[String, Int, Int, IdFunctor]("5")(_.toInt, _ + 2)([X, Y] => (f: X => Y) => mapId(f))
A lot is happening here - we instantiated a functor with a type, created a value for that functor, and now we can run it.
We are using a nice feature of Scala 3 here that allows using polymorphic function types in check2ndFunctorLaw
. This way we can directly take the map function as parameter using the syntax [X, Y] => (X => Y) => (F[X] => F[Y])
and pass a value using the syntax [X, Y] => (f: X => Y) => mapId(f)
.
Now let’s create another functor. This time we use an ADT. This is the constant functor. We define the constant functor as follows:
case class CstFunctor[C, A](c: C):
def getConstant: C = c
def map[B](f: A => B): CstFunctor[C, B] = CstFunctor.map(f)(this) // this is the normal way you will see functors defined, as a method.
object CstFunctor:
def map[A, B](f: A => B): [C] => CstFunctor[C, A] => CstFunctor[C, B] =
[C] => ((cst: CstFunctor[C, A]) => CstFunctor[C, B](cst.getConstant))
Once again, we are almost cheating here. This functor will pass all checks because it actually never changes, but a functor it is, and you can create an instance of it and check that it passes all laws.
Now, let us define a functor that is actually used in the real world, and a lot. The next functor is a sort of combination of the identity functor and the constant functor, and believe it or not, we use it all over the place. Let’s define the Option
functor:
enum MyOption[+A]:
def map[B](f: A => B) = MyOption.map(f)(this)
case MyNone
case MySome(a: A)
object MyOption:
def map[A, B](f: A => B): MyOption[A] => MyOption[B] =
_ match
case MyNone => MyNone
case MySome(a) => MySome(f(a))
If you remove the MyNone
part, you end up with a sort of identity functor. If you remove the MySome
part, you end up with a sort of constant functor. This is no other than the well known Option
type. You can, once again, instantiate it and check that it always passes the functor laws.
Exercise
Try to define a functor for the list type as defined here:
enum MyList[+A]:
case MyNil
case MyCons(h: A, tail: MyList[A])
What is The Intuition Behind All This?
We can see that the Option
functor is a data structure that either contains one value or none. A list is a data structure that contains several values. We can also see a function as a functor, for example:
type MyFunctionFunctor[X, Y] = X => Y
This case shows a function from something to be defined (X
) to A
. We can define a map function for that as:
def map[A, B](f: A => B): [X] => MyFunctionFunctor[X, A] => MyFunctionFunctor[X, B] =
[X] => (g: X => A) => f.compose(g)
Futures, tasks and other effects form a functor. Indeed, they will produce a value and we can always define a map function that can transform the value. The added value of functors is that a library can give you a value wrapped in some unknown container, but if it is a functor, then you know that:
- you can modify the value without affecting the container
- the container will not affect the operations on the values
We observe functors are data structures where the container or computation does not interact with the result type or computed value. You can change the value inside the computation or container without worrying about changing the structure or computation.
Let us define something that does not respect the functor rules:
enum MyOptionOnce[+A]:
def map[B](f: A => B) = MyOptionOnce.map(f)(this)
case MyNoneOnce
case MySomeOnce(a: A)
object MyOptionOnce:
def map[A, B](f: A => B): MyOptionOnce[A] => MyOptionOnce[B] =
_ match
case MyNoneOnce => MyNoneOnce
case MySomeOnce(a) => MyNoneOnce
import MyOptionOnce.*
// you can easily check that it fails for the first functor law
check1stFunctorLaw[Int, MyOptionOnce](MySomeOnce(2))(MyOptionOnce.map) == false
This structure self-destructs after a map. As we can see, if you try to change the value inside, it forgets about it and the structure collapses. We can do infinite variations on this concept, the main point being that somehow by manipulating the content, we did something to the container. Funnily, MyOptionOnce
will always pass the second functor law.
The other law concerns more with the fact that we want our container to respect the operations of its contained type. This means that after I have moved an operation to the new type of map, the container (or the map implementation may not mess with the operations). Let us define a new version of the option type that will mess with the operations when they return a 0:
enum MyOptionSneaky[+A]:
def map[B](f: A => B) = MyOptionSneaky.map(f)(this)
case MyNoneSneaky
case MySomeSneaky(a: A)
object MyOptionSneaky:
def map[A, B](f: A => B): MyOptionSneaky[A] => MyOptionSneaky[B] =
_ match
case MyNoneSneaky => MyNoneSneaky
case MySomeSneaky(a) =>
MySomeSneaky(f(a)) match
case MySomeSneaky(b) if a == b => MySomeSneaky(b) // keep identity
case MySomeSneaky(0) => // never return 0
MySomeSneaky(1.asInstanceOf[B])
case _ => MySomeSneaky(f(a)) // the standard case
import MyOptionSneaky.*
// it passes for the first functor law because it checks that the function changes something
check1stFunctorLaw[Int, MyOptionSneaky](MySomeSneaky(0))(MyOptionSneaky.map)
// it will fail only if the first function returns 0
check2ndFunctorLaw[String, Int, Int, MyOptionSneaky](MySomeSneaky("0"))(_.toInt, _ + 2)([X, Y] => (f: X => Y) => MyOptionSneaky.map(f))
check2ndFunctorLaw[String, Int, Int, MyOptionSneaky](MySomeSneaky("1"))(_.toInt, _ + 2)([X, Y] => (f: X => Y) => MyOptionSneaky.map(f))
My point here, however, is that if you write your own functors, or try to redefine something that you already had, as a functor, special cases are not your friends. It is not hard to imagine a case where you have some computation that checks that nothing has changed and then does something in just one particular case like setting a default value. Something as simple as that can break the functor rules.
Conclusion
We have learned about functor, implemented some of them and understood how they work and how to use them. You have also seen how to break the functor laws and are now ready to use them in your code.
If you have read until here and liked this content please consider subscribing to my mailing list. I write regularly about Blockchain Technology, Scala and Language Engineering. Have questions? Leave a comment below and I’ll try to answer!