Skip to content

Instantly share code, notes, and snippets.

@ckipp01
Created September 2, 2022 12:30
Show Gist options
  • Save ckipp01/c1ef4fdb7071d6974cef36964a3d177e to your computer and use it in GitHub Desktop.
Save ckipp01/c1ef4fdb7071d6974cef36964a3d177e to your computer and use it in GitHub Desktop.
A mental model to help understand Mill Tasks

Some notes on understanding Mill tasks

Using Mill is incredibly simple to get started with, but if you reach a point where you're defining your own tasks and/or writing plugins you're likely to hit on some instances where you'll encounter an error like this:

Target#apply() call cannot use `value X` defined within the T{...} block

This error alone introduces a lot of questions if you're not familiar with the internals of Mill's tasks. Below are a collection of things that have helped me better understand them.

Resources

Reading

Below is some insight from Olivier Mélois on the topic taken from the Typelevel Discord.

I don't have much video to share, but I'll try to give you an answer : in Scala, we are used to handling sequential logic in various contexts, because the language offers a pluggable syntax for it, that allows to easily clean up a gigantic amount of boilerplate heavy code: for-comprehensions. The standard library does not provide any abstraction for this pluggable mechanism, it just works for any construct that carries map/flatmap methods. This methods combined happen to be structurally capture to this construct called Monad (which cats and other libraries happen to have codified)

Understanding the concept of Monad is basically a rite of passage for everyone who learn semi-seriously the language, but in short, Monads are just an abstraction to sequentiality : ie you can express sequential computation with them, where you'll perform an operation, get the result, use the result in another operation, and so on. Pretty simple stuff, because most software logic happens to be sequential.

Now the thing is, though Monads are great, as they can let you reason the same way about different stuff that happen in massively different contexts (synchronous, asynchronous, error-carrying, etc), they are actually too powerful for a number of usecases, including "predictable computations". For instance, if you have

for {
   x <- f
   y <- if (x < 0) g else h 
} yield y 

you don't know whether g or h will be run UNTIL you have executed f. This means that you cannot easily capture a static "plan" for your Monadic computation that would predict a graph of calls, because the capability of monads to express sequences of calls make the list of calls in question inherently dynamic.

In the context of a build tool like mill, that tries to provide an agressive caching mechanism, this is a problematic, because caching requires being able to predict the structure of the computation

Thankfully, Monad is not the only abstraction that allow for composing computations. You also have Applicative, the difference being that Applicative does not allow for using the result of a computation in a subsequent computation. You can only compose computations horizontally, and map their results. In cats, this happens most of the time via the mapN operator, which mills calls zip

def fa: F[Int] 
def fb: F[Int] 
(fa, fb).mapN(_ + _) 

Now the thing is, Scala doesn't have syntactic sugar for applicatives, like it does for monads, and these mapN calls can become verbose quickly as the bodies of mapN increase in size / complexity :

// see the repetition between `fa, fb, fc` and `a, b, c`
(fa, fb, fc).mapN((a: Int, b: Int, c:Int) => doSomething(a, b, c))  

In order to solve this, people have built macros in userland, that offer applicative composition with a UX that is reminiscent of async/await. In mill, the macro is called T for task or target

// the duplication is eliminated in favour of a synthetised `apply` method which is eliminated at macro-expansion time 
T { doSomething(fa(), fb(), fc())} 
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment