Tuesday, April 1, 2025

Applicative functors are cooler than monads

These notes were written a few years ago to test my own understanding. The notes are not rigorous and are hand-wavy and probably wrong. Hopefully they at least capture some useful intuition. The notation is informal.

In this post I use “applicative” as shorthand for “applicative functor”. 

I’ve noticed that, in most explanations on the hierarchy of functors < applicatives < monads, attention is paid mostly to the first and the third, whereas applicatives are glossed over and unmotivated, treated as merely a stepping stone in the construction the monad machinery. As such, I found it difficult to develop an intuition for them for their own sake. This post outlines some of notes on the properties of applicatives, how they differ from functors and monads, and why we might care. 

Functors are the most straightforward of the three. They are containers — here, I use “container” hand-wavily to mean a derived type that conceptually represents a basic type that's been augmented/lifted/tagged with some information or behaviour. Other tutorials call this a context for the type, or a box, or a computation which evaluates to the underlying value. 

Functors have a “map” mechanism that essentially forwards function calls on the functor instance to function calls on the underlying value. Whenever we map a function onto a container in Python, we are conceptually treating the container as a functor. Similarly, numpy arrays/vectors act in a functor-like fashion when we invoke functions and arithmetic operations on them that execute element-wise. We can map several functions in sequence onto a functor, and this corresponds to performing a single map of the composition of those functions onto the sequence. Importantly, the mapping function of a functor cannot modify its functor structure — it can’t change a three-element list to a four-element list, or convert an empty Optional<T>  to a populated one.

Monads are… well, I’m not going to write a monad tutorial here, but their usefulness derives solely from their behaviour in pipelines of functions. Monads are derived types which can behave in interesting and dynamic ways when piped through a chain of composed functions; their values can change at any point in the pipeline, as can the monadic structure itself, in response to partial results from earlier in the pipeline. For the list monad, the values in the list can change (as in the list functor), but also the length of the list can change dynamically based on earlier operations applied to the list. Monads provide a lot of flexibility.

HOT TAKE: I think monads are massively overrated – at least, in the way that they're often presented in online tech communities: as a fundamental "change-your-brain-and-code-better" concept that is worthy of being learned by your typical procedural programmer. I spent many many hours grappling with monad tutorials and Reddit explanations, and working through the underlying theory of monoids and endofunctors and all that jazz. By the end, I felt like I had a working grasp of the core principles. And my reaction was... that's it? I might be missing something, but to me, monads appear to be a useful nifty abstraction for when you're working in a purely function language with chains of function calls, where you're otherwise limited in your ability to maintain state. Learning about monads did not change the way I think or the way I program in non-purely-functional languages, where you can just have state.

What about applicatives? Most explanations describe applicatives purely in terms of their mechanics: whereas a functor container accepts a function which it "forwards" onto its "contents", an applicative container accepts a container of functions which it forwards onto its contents (in a manner that obeys certain laws). What often isn’t explained is why we would even want to do this. To me, it feels like an unnatural thing to do — is it just applying multiple functions at once? If so, couldn’t we achieve using the functor machinery, by applying functions one by one? How does a function even get into a container in the first place? 

In a currying environment, applicatives generalise functors to multi-parameter functions 

A functor takes a function of one argument and maps it “element-wise” to each “element” of the container. For instance, we can do map(double, [1,2,3]) and the double function is forwarded element-wise to the list to get [2,4,6]. Or map(double, Optional(5)) which forwards double to the single “element” of the optional container. But what if we want to map the binary function multiply(a,b) element-wise to two parallel lists? We can’t do map(multiply, [1,2,3], [5,7,9]) because map takes two parameters. We could try map(map(multiply, [1,2,3]), [5,7,9]), but due to currying, map(multiply, [1,2,3]) produces the list of partial functions [multiply(1,…), multiply(2,…), multiply(3,…)] and we can’t then map this list onto [5,7,9]. But wait — what if we could? This is exactly the power that the applicative map function gives us — we have a container of functions and a container of elements, and the applicative map function gives us a mechanism to apply the functions to the elements.

So this is one way to think of applicatives: they are a simple and natural extension to functors, a sort of theoretical bit of glue/machinery, which provide a way to map multi-parameter functions to containers in a currying-based programming environment.

Unlike functors, applicatives can induce structural changes to the container. Unlike monads, these changes can be determined statically (i.e. ahead of time, without running the program)

In the former example, we were applying multiply to a pair of three-element lists, element-wise, to produce a three-element result. This inner product is one valid way to map a binary function onto two lists, but it is not the only one. We could instead define our applicative map function to be an outer product (i.e. Cartesian product), producing a nine-element result instead: the result would contain the product of each element of the first list with each element of the second.

If we define the applicative like this, then the mapping on applicatives can produce a result that is structurally different to either of the inputs. Importantly, the mapped function still doesn’t know anything about the structure of the applicative type. It’s the logic built into the applicative map function itself that defines how its structure changes, and it always changes in a manner that is known ahead of time based on the structure of the inputs.

This is not the case with monads, because the structure of the result of a monadic bind can change dynamically based on the values of the monads as well as their monadic structure. For example, considering a "list-shaped" applicative with instances a,b,c. Then map([a,b,c], [1,2,3]) could, I think, have either three elements or nine elements or no elements (are there other possibilities that satisfy the applicative laws? I don't think so, but I haven't thought too deeply about it), and we would know which one of these is the case by looking at the definition of map. But if a,b,c were monads, then we cannot  reason in the same way about the size of the result without knowing the values of a,b,c.

Summary

  • Functors cannot change their container structure under mapping — a three-element list remains three elements under any mapped function. 
  • Applicatives can change their structure under mapping, in a way that can always be known ahead of time (i.e. statically)  according to the fixed structures of the inputs to the map. 
  • Monads can change their structure under mapping dynamically according to the structures and the values of the inputs. As such, the structure of the result of a monadic operation can’t generally be determined statically because we don’t know the values of the inputs to the operation until we actually compute them. 

Unlike monads, applicatives are always composable 

Applicatives and monads sit in a type hierarchy in which all monads are also applicatives. From this, one might conclude that everything that works for an applicative must also work for a monad. But this would be wrong. 

It is a fact about applicatives that they always compose: if we have two applicative types A,B, then the composition of the types A B is also an applicative. It may be helpful to play around  a concrete example of composed applicatives such as Optional<List<T>>, and considering the ways we can act on it. 

But monads do not, in general, compose. For details on this, see https://stackoverflow.com/questions/7040844/applicatives-compose-monads-dont . My first thought was: how is this possible? Applicatives compose, and all monads are applicatives, so all monads must compose. 

What is actually happening here is that applicative composition can be considered as a function A -> B -> C, which maps two applicatives to their composition. We wish to push this function down the type hierarchy to monads. But, as I wrote on this blog a few years ago, functions are contravariant in their input values. So if M is a subtype of A, then a function which requires an M is *not* a subtype of a function that returns a type A. So composition of applicatives can’t be “pushed down” to composition of monads. 

This is quite technical and I only mention it because it helped clarify things for me personally. Generally, we can interpret the non-composability of monads as a result of the two bind functions of the input applicative not being combinable in a natural way to form the bind function of the composed applicative. 

Maybe this is a mainstream view now?


So just as I finished pasting and formatting this post, I Googled "applicatives". And just one day ago on the orange website, there is a great discussion where someone else makes the point I'm trying to make here:

For some reason everyone likes to talk about Monads, but really the other types here are just as interesting. For example, Applicatives are less dynamic than Monads in that you can't `flatMap`/`bind` to decide on the "next" thing to evaluate based on the previous value, but in exchange you get a "static" tree (or graph) of Applicatives that lends itself much better to static analysis, optimization, parallelism, and so on.

[...] Monads seem to have this strange aura around them that attracts certain kinds of personalities, but really they're just one abstraction in a whole toolkit of useful abstractions, and there are many cases where Applicative or some other construct are much more suited

The whole thread has other great comments too. Here's one:

I like to characterize the high level difference as something like "Monads are computations that can _call out to_ other computations and integrate their results", "Applicatives can run computations in parallel and combine the resulting structures / side effects", and "Functors allow you to lift pure functions into a computation". At each step, highlighting both how you are getting less powerful (because you can implement functors in terms of applicatives, and applicatives in terms of monads, but not the other way around), and how having less power can help you reason better about your programs (pros/cons of applicative vs. monadic parsers are a good example here).

I was just about to type something like this out here before I saw this comment – that there is in general a really interesting inverse relationship between the power of a machine and how easy it is to reason about it. Great stuff. And how this is why, these days, I wish I could stop using Python and just use Go instead ...

Stop using inheritance. Use composition instead.

These are notes from a tech talk I gave at work in Jan 2024. I've removed some details specific to the company.
Introduction
  • Inheritance and composition are two popular methods of code reuse.

  • Inheritance is when you reuse a class by inheriting from it and extending/modifying it.

  • Composition is when you reuse a class by creating and storing an instance as a variable or attribute, and interacting with it via its public interface.

  • Aim: to convince you that inheritance is fundamentally toxic and that composition is almost always the right choice.

The scenario
  • Scenario: implement "OrderedDict" ("ODict"): a dict type that maintains key insertion ordering, and exposes methods like getNthInsertion(n), popfirst(), poplast(), …

  • How it might look:

Composition

Inheritance

class ODict:

  def init():

    self.base_dict = dict()

    <initialise ordering data here>


  def insert(k,v):

    self.base_dict.insert(k,v)

    <update ordering data here>

class ODict:

  def init():

    super.init()

    <initialise ordering data here>


  def insert(k,v):

    superclass.insert(k,v)

    <update ordering data here>


  • By OOP theory, this is a valid use of inheritance! Satisfies "is-a" principle and Liskov substitution.

  • They are superficially very similar approaches, but with big differences:


Composition

Inheritance

User knows nothing about relationship between ODict and dict.

New subtype relationship between ODict and dict, affecting all ambient code

Public interface of ODict is isolated to class definition.

Method delegation means that public interface of ODict is the superposition of public interfaces of ODict, dict, superclass of dict, …, spread across different classes and files.

One-way communication. Base dict doesn't know about ODict. All interaction is by ODict invoking methods of dict.

Bi-directional communication due to "open recursion" feature of inheritance. dict can invoke methods on ODict without the user initiating it.


  • These differences cause consequences that make dealing with inheritance-based code a living nightmare.

Consequence 1: Loss of encapsulation ⇒ refactoring is harder

  • In general, reducing the interconnectedness of systems improves maintainability and comprehensibility

  • Encapsulation definition 1: hiding the information internal to a system, e.g. by making the implementation details private.

  • Encapsulation reduces and controls the size of the surface at which the external world can connect and interact with a system, which protects against over-interconnectedness.

  • Encapsulation applies to structs, classes, modules, services, systems, software teams, …

  • In composition, ODict connects to base dict only via dict's public interface. And base dict doesn't connect to ODict.

  • In inheritance, ODict connects to base dict via dict's internal implementation details. And base dict can interact with ODict in unclear/unpredictable ways.

  • Encapsulation is the mortal enemy of refactoring: the difficulty of refactoring is proportional to the amount of interconnectedness of a system, and inheritance leads to more interconnectedness.

  • "Fragile base class" problem: if you inherit from a class, then that base class becomes fragile because its descendants will depend on its implementation details

Consequence 2: Loss of abstraction ⇒ testing is hard

  • Abstraction = exposing only the essential characteristics of a system, so that users focus on what's important.

  • Abstraction is the most important concept in software design.

  • Encapsulation is an implementation-level concern. Abstraction is a design-level concern.

  • But encapsulation can be a tool to achieve abstraction, by forcing the user to depend only on a public interface that may not represent the true implementation details hidden beneath.

  • Example: a spreadsheet in Google Sheets isn't implemented as a 2D array under the hood. But the 2D array representation is what matters to the user.

  • Well-abstracted code is easy to test, because the underlying implementation can be replaced with a simplified or faked version, so that we isolate our testing to the code we wish to test.

  • This is an example of the design principle of inversion of control. Composition adapts easily to inversion of control, inheritance doesn't.

  • In practice, composition can take advantage of language/library features like monkey-patching or dependency injection.

  • Due to the subclass's potential dependence on implementation details of the superclass, it's hard to replace or mock the functionality of a superclass.

  • Even if the subclass interacted with the superclass in a clear way, most languages do not provide support for mocking/faking a superclass.

  • This is why <SubclassExample> tools are difficult to test! They all inherit from <SuperclassExample>, which has an enormous interface which <SubclassExample> tools depend on. If we could rewrite the <SubclassExample> interface now, we would use a composition-based approach.

Consequence 3: The yo-yo problem

  • Encapsulation definition 2: bundling of related data and functionality.

  • Encapsulation reduces cognitive load for the user/developer.

  • A class written with composition groups all its data and functionality in the class definition, and all interactions with underlying classes takes place via their public interfaces.

  • A class in an inheritance hierarchy has its data and functionality spread over its entire inheritance tree.

  • Thus, the experience of working with the code involves yo-yoing up and down the inheritance tree, usually across different files, just to build a mental map of the logic or to make any changes.

  • A key aspect of clean design is small focused public interfaces. But when we overuse inheritance, our class inherits the interface of all parent classes. This obscures the public interface of the class and makes good design harder to practice.

  • You might insist that you're a good developer, and you use inheritance just enough but never overdo it, and that your inheritance trees will never be more than 2-3 deep. But usually you're not the only one working on a codebase, and inheritance for developers like a drug: it's easy and enticing and it solves the problem you have right now, so devs reach for it without considering the long-term harm.

  • The <xxx> codebase is an example: it was built by 4 talented senior developers in a small span of time, but these developers didn't have an aversion to inheritance. Within a couple of years, the codebase had inheritance trees of hundreds of classes, including trees eight levels deep. This means you need to have eight files open at once just to understand what the class does.

  • Just say no to inheritance!

Consequence 4: Multiple inheritance

  • The ODict implemented with inheritance achieves code reuse by inheriting from one underlying class: dict.

  • But eventually, we might want to implement its ordering logic in terms of some other class. Say, a binary tree ("bintree").

  • At this point we must decide between:

    • we can inherit from both dict and bintree. But then we deal with the many many serious pitfalls of multiple inheritance.

    • or we can inherit from dict, and keep bintree as an attribute. But now we are inheriting and composing, and suffer the downsides of both. And how do we decide whether we inherit from dict and compose with bintree, or vice versa?

  • You might say that an ordered dict "is not" a binary tree, so it shouldn't inherit from bintree. But in general, as a design develops, you will eventually encounter classes that satisfy the "is-a" relationship for two different things.

  • Composition simply doesn't have this problem. It is more flexible than inheritance by its very nature. And we get this flexibility "for free", without any disadvantages.

Consequence 5: The single-instance problem

  • When ODict inherits from dict, the inheritance mechanism creates one instance of the underlying dict (the one that you get via super() in Python).

  • But as a design develops, you will often find that it naturally generalises to be implemented in terms of more than one instance.

  • E.g. maybe you want to store the reverse dictionary mapping (value->key) as well, in which case ODict is naturally expressed in terms of two dictionary instances.

  • With composition, you just add another attribute! dict1, dict2. Free flexibility once again.

  • With inheritance, you can add dict2 as an attribute, but once again you now have a Frankenstein combination of inheritance and composition. Your code ends up interacting with one of its underlying objects via super(), and the others via attribute access.

Conclusion

  • Each of these consequences cause severe and long-term maintenance impacts on the code:

    • Loss of encapsulation ⇒ code becomes harder to refactor

    • Loss of abstraction ⇒ code becomes harder to test

    • Yo-yo ⇒ code becomes harder to understand

    • Multiple inheritance ⇒ code becomes harder to extend

    • Single-instance problem ⇒ code doesn't generalise to >1 instance

  • Implementation inheritance is a fundamentally bad feature. Even when used "correctly" – i.e. obeying "is-a" and Liskov substitution etc. –  it still incurs all these problems.

  • It follows that you should only use inheritance over composition if its advantages outweigh all of these disadvantages.

  • It's very rare that this is the case. It generally only happens when you're interacting with external classes that you don't control, where you might be forced to inherit from those classes to work with some interface, or where the consequence of not inheriting might be that you have to implement dozens of delegation methods.

  • Interface inheritance (i.e. inheriting from a pure interface class that has no data or functionality) is great! It enables polymorphism, which is a powerful tool for abstraction. Use interface inheritance to your heart's content – it does not suffer from any of the problems we've mentioned.

  • So, next time you see a problem, and you think "I'll use inheritance" – stop. Take your hands off the keyboard. Think really really hard about whether that's what you want to do to yourself and to the people around you. Consider all alternatives. Reach for inheritance as a last resort.

Friday, December 17, 2021

Farmer (IOI 2004) solution and proof

Easy- and medium-difficulty competitive programming problems generally involve a single core technique, for example one graph algorithm, or one computational geometry tool, or one greedy method. Harder problems generally involve more than one technique.

Farmer is probably the easiest problem on the IOI 2004 paper. The task goes: fences consist of fenceposts and the segments in between the posts. Some fences are closed, and these have equal numbers of fenceposts and segments. Other fences are open , and these consist of one less segment than the number of fenceposts. You are given $M$ closed fences (loops) with $m_1, \dots, m_M$ fenceposts each and $K$ open fences (strips) of $k_1, \dots, k_K$ fenceposts each. You must choose any $Q$ fenceposts from among these fences. Whenever you pick two consecutive fenceposts, you earn the the segment in between. What is the maximum number of segments you can earn?

When presented with a non-trivial problem, it's a good idea to try make some observations.

  • We obviously want to always choose sequences of consecutive posts where possible.
  • Loops are "high-value", in the sense that if we pick all the posts in a loop with $n$ posts we earn $n$ segments. But if we pick a strip of $n$ posts, or a portion of a loop with $n$ posts, we earn just $n-1$ segments.
  • Loops are equal value regardless of size. Two loops of size 3 each has the same value as one loop of size 6.
  • When presented with a long strip and a short strip, we can always take a portion of the long strip of equal size to the short strip, earning the same number of segments. So there is never a situation where picking a short strip is advantageous to picking a portion of a long strip.
  • We earn more segments by taking $n$ posts from a long strip than by taking $n$ posts from two or more short strips. In combination with the previous observation, this means we should always take posts from long strips over short strips.

At this point we can start forming an algorithm. The best case scenario would clearly be to take only full loops, in which case we earn exactly $Q$ segments. To determine if this is possible, we must determine if some subset $m_1, \dots, m_M$ sums to $Q$. This is the subset sum problem which can be solved by dynamic programming. So this is what we check first, and if it's possible than we're done. If not, then we cannot achieve $Q$, since any other selection of posts must select at least one strip or at least one portion of a loop.

If we can't achieve $Q$, then there are two cases to consider:

  1. Suppose $\sum_i m_i > Q$. Loops are higher-value than strips, and all loops are equal value, so suppose we just keep taking full loops in any order while we haven't depleted the quota. For each of these loops, we get a number of segments equal to the number of posts. Eventually we will have taken a total of say $u$ posts, earning $u$ segments, and we will reach a loop which has more posts than our remaining quota $Q-u$. If we take a strip of $q$ posts from this loop, we earn $Q-u-1$ segments, for a total of $Q-1$ segments earned. We know that $Q$ segments are unattainable, so if we earn $Q-1$ we have achieved the best possible result. Hence this greedy algorithm solves this case.
  2. Suppose instead $\sum_i m_i < Q$. Start by taking all the loops, because they are highest-value. Now only strips remain. By our earlier observations on strips of differing lengths, we can just greedily consume strips in descending order of length while quota remains.

Even in this relatively simple problem, there are two intertwined components that require different techniques: a dynamic programming component followed by a greedy component. One of the most important skills demanded of a competitive programmer is to be able to disentangle a seemingly intractable problem into its components in this way.

Monday, December 13, 2021

2048 in Python

Let's write 2048 in Python. Disclaimer: this is 2am bored-on-the-couch code and is not representative of my professional coding style ;)

First let's implement the action of the left arrowkey. This isn't the shortest or most elegant code, but it is easy to reason about. We treat each row individually, ignore the blank elements in it, and compress the remaining elements into the left. For the element-combining logic, we first decide if anything needs to be combined, and if anything does, we consider cases. There are actually very few cases that provoke combination, and these can be enumerated as: AA, AAB, ABB, AABB, AABC, ABBC, ABCC. We can handle AAAA, ABAA, etc. through these cases too.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def left(x):
    news = []
    score = 0
    for row in x:
        c = [v for v in row if v]
        if all(a != b for a, b in zip(c, c[1:])):
            new = c[:]
        elif len(c) == 2:
            new = [c[0]*2]; score = c[0]*2
        elif len(c) == 3:
            if c[0] == c[1]:
                new = [c[0]*2, c[2]]; score = c[0]*2
            elif c[1] == c[2]:
                new = [c[0], c[1]*2]; score = c[1]*2
        else:
            if c[0] == c[1] and c[2] == c[3]:
                new = [c[0]*2, c[1]*2]; score = c[0]*2 + c[1]*2
            elif c[0] == c[1]:
                new = [c[0]*2, c[2], c[3]]; score = c[0]*2
            elif c[1] == c[2]:
                new = [c[0], c[1]*2, c[3]]; score = c[1]*2
            elif c[2] == c[3]:
                new = [c[0], c[1], c[2]*2]; score = c[2]*2
        new += [None] * (4 - len(new))
        news.append(new)
    return news, score

The remaining arrowkey actions can be implemented by their symmetry with the left arrowkey action, via rotation. The do method will carry out the arrowkey actions. We only place a new tile on the board if the action resulted in a state change.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def cw(x):
    return [[x[3-j][i] for j in range(4)] for i in range(4)]

def do(x, d):
    if d == 'l':
        a, s = left(x)
    if d == 'd':
        a, s = left(cw(x))
        a = cw(cw(cw(a)))
    if d == 'r':
        a, s = left(cw(cw(x)))
        a = cw(cw(a))
    if d == 'u':
        a, s = left(cw(cw(cw(x))))
        a = cw(a)
    return (placenew(a) if a != x else a), s

The placenew function puts a new 2 or 4 into an empty square, following the probabilities of the standard web version of the game.

1
2
3
4
5
6
def placenew(x):
    es = [(r,c) for r in range(4) for c in range(4) if x[r][c] is None]
    r, c = random.choice(es)
    y = deepcopy(x)
    y[r][c] = 4 if random.random() < 0.1 else 2
    return y

This function determines if our position can be escaped. If it can't be escaped with any actions, the game is over.

1
2
def escapable(x, dirs='udlr'):
    return any(do(x,d)[0] != x for d in dirs)

Let's code up a play strategy where we pick actions at random.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
x = [
    [None, None, 2, None],
    [None, None, None, None],
    [None, None, None, None],
    [None, 2, None, None],
]

def playrandom(x):
    ts = 0
    while escapable(x):
        x, s = do(x, random.choice('udlr'))
        ts += s
    return ts

How successful is this strategy? Let's play lots of random games and see the average score.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def mean(v):
    return sum(v) / len(v)

def stdev(v):
    u = mean(v)
    return (sum([(k-u)**2 for k in v]) / (len(v)-1))**0.5

scores = []
while True:
    s = playrandom(x)
    scores.append(s)
    if len(scores) > 1:
        print(int(mean(scores)), int(stdev(scores)), max(scores))

This shows that the mean score at endgame with the random strategy is about 810. Can we do better? Anyone who's played the game a couple times will know that ideally you wanna keep your big tiles in one corner, say the bottom left corner. So alternating down and left actions might prove more effective. We can add in right actions if we get stuck with downs and lefts. In a true disaster we can try go up and back down.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def playwell(x):
    ts = 0
    while escapable(x):
        start = ts
        x, s = do(x, 'd'); ts += s
        x, s = do(x, 'l'); ts += s
        if not escapable(x, 'dl'):
            x, s = do(x, 'r'); ts += s
            if not escapable(x, 'dlr'):
                x, s = do(x, 'u'); ts += s
                x, s = do(x, 'd'); ts += s
    return ts

This gives a mean endgame score of about 2400. Nice!