Why Function Arguments Are Not Function Colors
In several debates online about function colors over the years, people have argued the apparently reasonable position that function arguments can constitute colors as well.
For instance,
in Go, there is a context.Context
value, which manages timeouts,
cancellation, and a small amount of data that can be threaded through
various functions. It is, by design, intended to be something passed
through functions even if the receiver doesn’t use it directly but
only passes it along. Generally, once one function starts using a
context, you should thread it along to all called functions that could
conceivably have a use for it.
So, superficially, this appears to be a color. Once you have this particular argument in hand, you “have” to call all future functions of that type with that argument.
Extending this out you can end up arguing that all function parameters are “colors”, as some do.
A quick, though still important, objection is to observe that if this was the case, we wouldn’t be talking about this at all. If async was merely one “color” in a dazzling chaotic rainbow of thousands of other colors, this “color” concept wouldn’t have been invented 50+ years after the invention of programming language functions. There is still something distinct about how async works in some languages that is worth trying to capture and understand.
But understanding the situation deeply from one example is difficult. Here is my attempt to turn it into a criterion rather than a single example:
Color As A Change Dependency Graph Shape
Let’s phrase the problem this way:
Suppose I have to change an attribute of some function 4 levels buried in a call stack, be that a parameter that it takes, whether it is “async” or not, or some other thing like “whether or not it has ASM in it” or any other attribute of the function. Any change to the function at all, depending on what characteristics the local language permits functions to have.
This change could result in having to change the callers as well.
There’s three basic cases that can emerge:
-
No change to any caller is necessary.
-
The change may require a change to the direct caller.
-
The change may require a change to all functions in the stack.
Graphically, we have the following. For the no-color, function parameter case, the “need to change” relationship may look like this:
For the color case, it looks like this:
The difference between a “color” and a “normal” change is that in the case of the color, the change to the bottom function in the stack does not just affect its parent. It immediately and inescapably affects every function above it in the call stack, and therefore, they all must react to the change.
For a “normal” change, it only propagates to the caller. It may then chain to its caller. If it propagates all the way up to the top-level function, as demonstrated in the diagram, this may seem like a distinction without a difference.
But the key is that the diagram isn’t showing the common case, it is showing the worst case. In practice, functions almost always break the chain before it reaches to the top of the call stack. Any function can “catch” the propagating change and encapsulate it so that functions above it won’t see it anymore.
You can measure this by how often you do in fact end up threading a change up the stack… well… you could back in the Bad Old Days when we were programming by hand. You know it’s something you do, but it’s certainly not something you do all the time, and it’s not something you often did all the way up to the main function at the top.
Change Isolation
We humans pervasively use structured programming precisely because of its ability to do this for most function changes, to isolate higher functions from needing to know every detail of the lower functions.
What would it even look like for the top-most function to have to know “everything” that all the child functions end up ever needing? For every function parameter change in the entire program to somehow force a change all the way up the entire call stack?
Clearly this does not happen. It so thoroughly does not happen that you may be having difficulty even imagining what it is I am talking about.
For instance,
in the case of Go’s context, which I keep bringing up not just because
Go is one of my main programming languages but because this is the
example that keeps coming up, any function anywhere can call
context.Background() to obtain a degenerate, but fully functional,
context value.
So a function that is changed to take a context can
still be called by any other normal function just by passing the
result of context.Background(). Moreover this is not a hypothetical
situation; it is very common for me, when I add a context.Context
parameter to a function that did not previously have one, to do
exactly that in real code, to adapt testing code or other callers
that don’t care about the context to the new parameter.
So this “linked list” relationship in practice doesn’t just tend to break the chain before it hits main, it tends to break the chain very, very early. It hurts to have to thread changes all the way up, so we’ve developed a lot of ways of not having to do that and improving the encapsulation our functions provide. While you may have to do that sometimes, it’s the sort of rare event that is very cognitively available and easy to remember and notice, but as a result, is completely outsized in your memory. In reality it is a very rare case, not the common one.
A normal change can be encapsulated. A very specific change may for some reason need to go all the way up to main, but this is clearly the exception, and even then, the rest of the program may use this ability to encasulate in other ways.
A color change escapes this encapsulation. All functions above it in the stack must match its requirements.
Color Definition
Any change to a function with the “color” shape of dependencies is a color. Any change to a function that is shaped like the linked list is not a color.
While the original defining essay used async as its canonical example,
that does not mean all async in all languages is always a color.
You must examine the specifics of the language’s async support to
see if it matches. In many languages it is not a color. I think with
some current changes to Zig a function that does IO either is
or is not a color depending on
which Io value you pass to it,
or perhaps rather, the color is an attribute of the Io value rather than
the function itself. Async is a color based on whether it fits the definition; the definition
is not that async is always a color.
This is further complicated by the fact that it isn’t always quite as bright and shining a line as we’d like even in the canonical “Javascript async” case the original essay is about. It is still possible to run an async action in a synchronous context is Javascript. It just comes at the cost of the rest of the async actions that should be running at the same time. I would consider this “not really running async at all”, given the staggering nature of the cost and how it destroys the essential functionality of the feature and just barely works. However, your mileage may vary. As with everything, the closer you look, the more it seems to become a spectrum rather than a binary and the more complicated the spectrum becomes.
However, just because things are spectrums and drawing the line ends up more complicated than it may have initially seemed doesn’t mean there aren’t real differences between color changes and normal changes.
Advanced Topic: Partial Coloring
For an advanced understanding, from this point of view we can also imagine partial colors. Haskell is the king of partial colors, using them to make powerful guarantees about what the intervening functions can do by using the color to constrain them somehow.
For instance, the Software Transaction Memory monad
STM
implements software transactional memory by allowing the creation of
transactional values. These values can only be accessed via functions
like
readTVar
from within an STM monad context.
An STM function can call a pure function which calls a pure function,
but if that pure function needs to then use something in STM, that
propagates up the entire stack between that call and the previous use
of STM, forcing the lifting of every intervening function into the STM
“color”. The color is used to enforce the guarantee that only code in
the STM monad, running through the STM machinery that guarantees
transactionality of the result, can access transactional variables.
However, while that is a non-local, color-like interaction with some of the the
functions above it in the call stack, it does not propagate all the
way to the top of the call stack. The type of a top-level Haskell
program is IO (). STM does not appear in that type signature. Anything
other than IO is guaranteed to be encapsulated somewhere in the
program below the main function itself.
This is indeed a color because this rides on Haskell’s IO color; an
STM transaction can only itself be done in IO, so while the type
system will allow you to pass a TVar a to a pure function, the pure
function will not be able to do anything with it without
unsafePerformIO. Haskell permits you to add “shades” of color to
IO that you can enforce on portions of your codebase.
Originally I tried to write this in terms of the simpler
State
but as I was working through it I realized that State is not a
color. Nothing stops a function from being passed the entire state,
calling some other pure function with that state, and then that pure
function can construct its own State-based computation with it and
if you like you can thread that new state all the way back to the
original context with no problems. That would be klunky and
unidiomatic, but it is both legal and moral.
So not all monads inside of Haskell are colors, just the ones that
can only be resolved within IO somehow. But Haskell does allow you
to create additional “colors” with that mechanism.
I believe it is reasonable to say that most languages do not permit you to add your own colors to it in this manner. However, again, I would leave it to the experts in each language to debate whether or not this is the case.
To close, let me reiterate a color must have this characteristic where
it jumps over intervening calls and forces the change on all other
functions within the relevant island of color. In the STM case above,
anything that wants to access the transactional variables must be in
the STM monad; the type checker will rigidly prevent any attempt to
encapsulate the change to a portion of the calls in between, modulo
unsafe. There is no way for one of the pure functions in the middle
to somehow satisfy the STM constraint for everything below it (modulo
unsafe), which is why it is reasonable to say that it isn’t just
being transmitted up as a linked requirement but actually directly
imposed on all intervening stack frames. If something in the middle
can satisfy the requirement and encapsulate it from functions above
it in principle, even if for some particular code change it may not
be practical for a specific instance, it is not a color.