Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

And why restrict oneself to just two colours? Haskell monads also allow one to abstract over the "colour", such that one can write polymorphic code that works for any colour, which I think was the main objection from the original red/blue post.

Microsoft's Koka is an example of a language that further empraces effect types and makes them easier to use: https://koka-lang.github.io/koka/doc/index.html



Don't monads in themselves have the same problem of introducing "coloring" (where each monad is a different color) , so that functions which work for one monad won't work with easily compose with functions which work with another monad?

For example, isn't it true that you can't easily compose a function f :: a -> IO b with a function g :: b -> [c] (assuming you don't use unsafePerformIO, of course)?

Of course, there will be ways to do it (just as you can wrap a sync function in an async function, or .Wait() on an async function result to get a sync function), and Haskell's very high level of abstraction will make that wrapping easier than in a language like C#.


> Don't monads in themselves have the same problem of introducing "coloring" (where each monad is a different color) , so that functions which work for one monad won't work with easily compose with functions which work with another monad?

I'd say that monads surface a problem that was already there, and give you a vocabulary to talk about it and potentially work with it. Composing effectful functions is still surprising even if you don't keep track of those effects in your type system (for example, combining async functions with mutable variables, or either with an Amb-style backtracking/nondeterminism function, or any of those with error propagation).

But yeah, monads are an incomplete solution to the problem of effects, and when writing composable libraries you often want to abstract a bit further. In the case of Haskell-style languages we have MTL-style typeclasses, or Free coproducts, or various more experimental approaches.


Yes, they do. (That's why there's the monad transformer.)

But this is fundamentally a very simple thing. If you use an async framework, you have to think inside that. It's just as fundamental as CPU architecture, or operating system. If you have an Intel CPU you need a corresponding motherboard. If you have Windows you need programs that use the Win32 API (or use the necessary translation layer, like WSL).

Similarly, if you use - let's say - libuv in C, you can't just expect to call into the kernel and then merrily compose that with whatever libuv is doing.

This "coloring" is a suddenly overhyped aspect of programming (software engineering).

And of course fundamentally both are wrappable from the other, but it's good practice to don't mix sync/async libs and frameworks and components. (Or do it the right way, with separate threadpools, queues, pipes, or whatnot.)


> you can't just expect to call into the kernel and then merrily compose that with whatever libuv is doing.

Quasar Fibers, which eliminated function coloring for Java async versus blocking IO, I wound up doing the equivalent of that in the Java ecosystem, and it works in production extremely well.

Defeating the coloring problem makes gluing all sorts of really valuable stuff together much easier. In a world where you can’t just rewrite from scratch the things you’re gluing.


This kind of "transparent async" (like what golang also does) always has the danger of creating race-condition-style bugs, because some synchronous-looking piece of code doesn't run synchronously so some state at the beginning of the function has a different value at the end of the function. Have you had those kind of problems with your Java codebase?


This problem can also easily happen with async code, if you have any shared state with the async code, especially if you are running multiple async calls in parallel. It can even happen in single-threaded runtimes like Node, where people are even less likely to expect it.


My point is that "transparent async" code can lull the programmer into a false sense of security.

Contrived example:

    void ChargeAccount(Account account, int cents) {
        int balance = account.balance;
        int newBalance = balance - cents;

        Logger.Write("changing account from {balance} to {newBalance}");

        account.balance = newBalance;
    }
This works fine because it's all synchronous code. Then someone classloads a Logger implementation that's "transparent async" because it logs to a network sink in addition to synchronous stderr, and now there's a chance two calls to ChargeAccount interleave and one of the charges is lost.

If it's explicitly async with async-await keywords etc, then the race is easily noticeable and the programmer will know they have to use a mutex or atomics or whatever.


I don't think I agree.

If there is any other code that touches account.balance from any other thread of execution (OS thread, coroutine, etc), then this code is already not safe.

If there isn't any other thread of execution, then this is safe regardless of whether the call to Logger.Write is sync or async.

For example, the following program would be safe regardless of whether Logger.Write is sync or async:

  int main() {
    InitLogger();
    ChargeAccount(acc, 10); 
    // the program will only ever get here after
    // account.balance = newBalance has finished executing
    ChargeAccount(acc, 10);
  }
While the following program is not safe even if Logger.Write is sync:

  int main() {
    InitLogger();
    launchInNewThread(() => ChargeAccount(acc, 10));
    launchInNewThread(() => ChargeAccount(acc, 10));
  }
Transparent async a la Java Loom or Go does not spawn extra threads - it just pauses the current thread and schedules a new thread whenever you're waiting for a sync call (which normally means waiting for the kernel, either in IO or some kind of lock).

In fact, the first program if written with something like Loom would be perfectly equivalent to the following C#:

  async void ChargeAccount(Account account, int cents) {
    int balance = account.balance;
    int newBalance = balance - cents;

    await Logger.Write("changing account from {balance} to {newBalance}");

    account.balance = newBalance;
  }
  public static void Main(string[] args) {
    InitLogger();
    await ChargeAccount(account, 5);
    await ChargeAccount(account, 5);
  }
No concurrency regardless of underlying thread pools.

You seem to assume it is equivalent to something like this, which would indeed be unsafe, but is not the case:

  public static void Main(string[] args) {
    InitLogger();
    var t1 = ChargeAccount(account, 5);
    var t2 = ChargeAccount(account, 5);
    await Tasks.AwaitAll(t1, t2);
  }


>If there is any other code that touches account.balance from any other thread of execution (OS thread, coroutine, etc), then this code is already not safe.

My point is about concurrency, not parallelism. I didn't mention anything about threads, let alone that there's more than one thread involved or that threads are being spawned.

Take a simple node.js program or a Rust program that has a single-threaded tokio executor, or a C# WinForm program that reuses the UI thread for running Tasks, and everything I wrote applies. A single Account value used to be fine to be shared between coroutines because only one invocation of ChargeAccount would be active at a time, but the "transparent async" breaks that assumption.

Explicit async-await makes it more obvious that multiple invocations of ChargeAccount might interleave unless proven otherwise, and the programmer has a reason to confirm that it is not the case, or use locking / atomics as necessary.

>You seem to assume it is equivalent to something like this, which would indeed be unsafe, but is not the case:

That `Task.WaitAll` program is indeed what I'm talking about, though the part of you thinking that I was talking about it being equivalent to a multi-threaded program is bogus.


> Take a simple node.js program or a Rust program that has a single-threaded tokio executor, or a C# WinForm program that reuses the UI thread for running Tasks, and everything I wrote applies. A single Account value used to be fine to be shared between coroutines because only one invocation of ChargeAccount would be active at a time, but the "transparent async" breaks that assumption.

That's not true at all. Here is a simple NodeJS implementation that is completely unsafe despite having non-transparent async:

  function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  async function LoggerWrite(s) {
    await delay(Math.random() * 100);
    console.log(s);
  }
  async function ChargeAccount(account, cents) {
    let balance = account.balance;
    let newBalance = balance - cents;
    await LoggerWrite("changing account from "+ balance + " to " + newBalance);
    account.balance = newBalance;
  }    
  async function Main() {
    let account = {balance: 0};
    let t1 = ChargeAccount(account, 1);
    let t2 = ChargeAccount(account, 20);
    let t3 = ChargeAccount(account, 18);
    let t4 = ChargeAccount(account, 12);
    await Promise.all([t1, t2, t3, t4]);
    console.log(account);
  }
  Main() 
  //will randomly end up with -1 or -12 or -18 or -20, instead of the expected -51
And here is a Go program that does the right thing even with transparent async:

  func LoggerWrite(s string) {
    <-time.After(100) //this means "block for 100 ms"
    fmt.Println(s)
  }
  func ChargeAccount(account *account, cents int) {
    balance := account.balance
    newBalance := balance - cents
    LoggerWrite(fmt.Sprintf("changing account from %d to %d", balance, newBalance))
    account.balance = newBalance
  }

  func main() {
    account := account{balance: 0}
    ChargeAccount(&account, 1)
    ChargeAccount(&account, 20)
    ChargeAccount(&account, 18)
    ChargeAccount(&account, 12)
    fmt.Printf("%+v\n", account)
  }
  //will ultimately print -51
You can try these yourself:

NodeJS: https://replit.com/talk/share/Async-race-condition/138001

Go: https://play.golang.org/p/ypKfQFUF5M2

Edit: > Explicit async-await makes it more obvious that multiple invocations of ChargeAccount might interleave unless proven otherwise, and the programmer has a reason to confirm that it is not the case, or use locking / atomics as necessary.

As shown by the example above, I think almost the opposite is true: async/await take code that looks linear and single-threaded and make it run concurrently. It's easy to accidentally end up with code like the JS one. Even worse, it's easy to accidentally call async code without await-ing it at all, and have unexpected behavior.

By contrast, transparent async + cheap coroutines means that you can make all of your APIs blocking, and rely on the user explicitly spawning a coroutine if they want any kind of parallel/concurrent behavior, at the call site. In the Go code, I could have called `go ChargeAccount(...)` if I wanted the ChargeAccount calls to happen in parallel - I would have explicitly created parallelism, and I would have known to be careful of sharing the same pointer with the coroutines running concurrently.


>Here is a simple NodeJS implementation that is completely unsafe despite having non-transparent async:

What does that have to do with my comment?

I said (paraphrased) "Transparent async is bad because sync code can end up being wrong when async-ness is silently introduced into it". You keep responding with (paraphrased) "Explicit async code can also be bad." Do you understand how this does nothing to negate what I said?

(This discussion is frustrating, and the walls of irrelevant text you keep posting aren't helping.)


Yeah, I think this is right: concurrency issues have to be solved by using data structures with concurrency semantics (software transactional memory or atomic refs/ints) and can’t really be solved by something like async/await. Go making you use channels to express asynchronous execution here is a feature, not a bug.

async/await and friends (generators, callback-passing, etc.) all provide some clues that are sometimes useful for solving concurrency problems, but they aren’t able to make unsafe programs safe.


>async/await and friends (generators, callback-passing, etc.) all provide some clues that are sometimes useful for solving concurrency problems,

Exactly what I said.

>but they aren’t able to make unsafe programs safe.

Exactly not what I said either.


> some synchronous-looking piece of code doesn't run synchronously so some state at the beginning of the function has a different value at the end of the function

Does function coloring solve this? I don't think it's the case. Python, JS, etc. don't see this issue much because the interpreters tend to be single threaded or have GILs. C#, for instance, would see all the same issues a Java or Go program might with respect to variables changing over time.


Oh, JS is full of such bugs, because it's trivial to write a loop that fires off async closures (Promises) that capture something by reference, so they then end up trampling all over each other.



It's not entirely clear what you want to happen here with `f` and `g`. It's trivially easy to apply `g` to the `b` in the result of f. You just use `fmap`.

    -- suppose we have some x :: a
    fmap g (f x) :: IO [c]
Of course, you wouldn't want the [c] to be able to escape the IO container, because that would break referential transparency - you could no longer guarantee that a function returns the same result given the same arguments. Of course, that's no problem for manipulating the [c] value by passing it to pure code.

It's true that "monad's don't compose" in the sense that you need to manually write monad transformers to correctly combine monadic effects, whereas functors compose automatically. However, that's not the same thing as saying it's difficult to use functions which happen to return different monadic results together, if the types match.


You can,

   h :: a -> IO [c]
   h = liftM g . f
Or fmap, or use various arrow operators, or <$>. I might describe it as “pathologically easy” to compose functions in Haskell, since it’s so much easier in Haskell than other languages (and mixing monads & pure code is still very easy).


Sure, but that's not a feature of Monads themselves, but a feature of the excellent generic programming support in Haskell, along with polymorphic return types that makes the boilerplate really easy.

Still, there is a slight bit more to the story if you actually had to pass a [c] to another function ( you would have to liftM that function as well, I believe, and so on for every function in a chain).


So I don’t think that this criticism is quite fair, and I’ll explain why. When you choose to make something implicit/easy in a language, it often comes with the cost of making other things explicit/hard. The best example of this, in my mind, is the choice to use Hindley-Milner style type inference (ML, Haskell) versus forward-only type inference (C, Java). HM style type inference comes with its advantages, but introduces limitations that affect polymorphism.

In Haskell, you can’t automatically lift something into a Monad because this would require a type of ad-hoc polymorphism that is impossible with a coherent Hindley-Milner type of type inference system.

However, that exact type inference system is what lets you just write “liftM”, and it automatically does the Right Thing based on the type of monad expected at the call site.

I think ultimately—Haskell is in a sense an experiment to see if these alternative tradeoffs are viable, and it’s a massive success in that regard.

(Also: you wouldn’t need to liftM every function in a chain. Just liftM the chain at the end.)


> Sure, but that's not a feature of Monads themselves, but a feature of the excellent generic programming support in Haskell

This easiness comes from the existence of fmap, which is defined for any functor (monads are all functors). To instance it and get haskell's lovely syntax, any given "container" has to define its own fmap implementation. So it is a feature of monads. If you implement a monad in another language, you're still going to be defining fmap (or an equivalent), which lets you use this.


> I might describe it as “pathologically easy” to compose functions in Haskell

Maybe because "composing functions" is the whole point of category theory, monads and Haskell?


The point of category theory is composing morphisms! Functions are morphisms in the Set category. Haskell has something which looks very similar to category theory, but the functions and types in Haskell do not form a category. I would call it “category theory inspired”.


Thank you for the clarification.

But still, since it's all about composition, the simplicity is a direct consequence IMHO.


The idea is that your individual monad has to say how to compose those functions (and package up values). The type signature of bind and return are is

    (>>=)  :: m a -> (  a -> m b) -> m b
    return ::   a                 -> m a

In your case f gives you an IO b, which is the first argument to bind (your m a). Then g's output needs packing up, so you use return to take [c] to m [c], giving `return . g :: b -> m [c]`, which will be the second argument to bind (your a -> m b).

That gives you `composed_function x = (f x) >>= (return . g)`, which a "do" block makes potentially clearer :`do {y <- f x; return (g y)}`


(Too late to edit) I'm an idiot. All monads are functors, for which you can just use fmap. `composed_function x = fmap g (f x)` which you can also write as `composed_function x = g <$> f x`, which I now realize someone else has already said. Oops.

Anyways the idea is that each sort of "container" defines how to apply functions to it.


Of course the functionality that abstracts over coloring has the same problems coloring has on any other language. If you don't want coloring to happen on your code, you shouldn't declare it by using a monad.


Fun Fact: The author of Koka (Daan Leijen) wrote a LaTeX-flavored Markdown editor called Madoko [1] using Koka! It is used to render the "Programming Z3" book [2], for instance.

[1] http://madoko.org/reference.html

[2] https://theory.stanford.edu/~nikolaj/programmingz3.html


Monads are not the same thing as effect types - the latter are most commonly modeled as Lawvere theories. They are at a different level of composability.

Regardless, this blogpost is about Rust which has yet to gain either feature, for well-known reasons (for one thing, monadic bind notation interacts badly w/ the multiple closure types in Rust). Talking about Haskell monads just does not seem all that relevant in this context.


> Monads are not the same thing as effect types

Monads can be used to implement "effect types" and define the semantics of them. I believe this is how Koka works.

> Talking about Haskell monads just does not seem all that relevant in this context.

I respectfully disagree. Async/Await first appeared in F# as a Monad (Computational Workflow). Monads also do not necessarily have to involve closures at runtime, they can be useful just to define semantics and build type-checkers.


Monads are not the same as effect types, but Haskell affords implementing effects using monads, so I'd argue the comparison is relevant.


Could you elaborate? Firstly, I'm not aware of any programming languages that has Lawvere theories as a first-class concept. Secondly, when I last looked into Lawvere theories I couldn't work out any way of making them into a first-class concept in any way that would make them distinct from monads.

(On the other hand Lawvere theories may be useful for modelling effects in type theories. That may be so (I don't know) but is an orthogonal issue.)


I don't really know much about this area, but there's a language literally called Lawvere.

https://github.com/jameshaydon/lawvere


Haskell-like monads are absolutely relevant. They're a common solution to this problem in Rust-like languages, and while there may be some Rust-specific concerns to be addressed it's not at all clear that any of them are real blockers.


The problem is that it is unclear that effect systems are something worth having in the first place. A type system could introduce many kinds of distinctions -- the unit allocates memory or not, the unit performs in a given worst-case time or not, etc. etc. -- but not everything it could do it also should. Every such syntactic distinction carries a cost with it (it tends to be viral), and for each specific distinction, we should first establish that the benefits gained outweigh those costs. Otherwise, it's just another property among many that a programming language may have, whose value is questionable.


> the unit allocates memory or not, the unit performs in a given worst-case time or not, etc. etc. -- but not everything it could do it also should

Strong agree.

This list could also be extended endlessly, and with continuous dimensions: how much L1i/L2i/L1d/L2d/L3 cache does this consume? How many instructions will it generate? For AMD64, ARM? How well can it be reordered wrt this other function? Is it superscalar? It becomes dimensionally intractable, because these properties do interact with each other.

IMHO, all side-effects considerations should be (easily) surfaced as hints to the programmer, but not as constraints. At least that's what a language/runtime should aim for.

For example: Rust's asyncs can be called synchronously. That's fair enough (and ok), but the programmer should be made aware by the compiler that it will block the thread.

To me, the best thing to aim for is a language that gets out of the way (correctness in changing requirements context is difficult enough already), but informs you on what you are trading off.

Say your wrote a sequential part. A hypothetical 'parallelization opportunities' tab of your IDE should ask you the question: 'Do your business requirements allow that this part be made parallel?'.


Even if you could do it in a non-obtrusive way, I would argue that this is a very wrong direction for many languages. The reason is that the static information is only the worst-case. How much a cache a subroutine consumes is a dynamic property, as is how long it runs or whether or not it blocks a thread. This worst-case information, reflected statically at the language level, is useful only when you're aiming to optimise for the worst-case, as you do in hard realtime domains; in most other domains, you might want to optimise amortised or even average costs.

The reason this is being considered for removal is that even though the mechanism is very flexible and powerful in theory, it is too elaborate to be used correctly by most programmers in practice.


The Unison language [1] also has a very interesting effect system.

[1] https://github.com/unisonweb/unison


Basically, in JS you could have multiple colors (with generators that imitate `do` notation pretty well) and you can obviously implement any monadic structure with bind you'd want.

The thing is: having less generic abstractions (and fewer colors) makes certain things (like debugging tooling and assumptions when reading code) much much nicer.


Javascript must solve the fundamental problem that generators are (by implementation) not clonable. So generators can not be used generally as do notation.


I'm not sure it's correct to understand monads as a kind of function coloring mechanism. Two reasons. First, monads aren't functions.

Second, the whole function coloring thing wasn't a complaint about limits on function composition. If it were, we might have to raise a complaint that arity is a kind of color. It was a complaint that functions of one color can't be called from inside functions of another color. And you don't really have an equivalent problem with using one monad inside the implementation of another monad.


You have the same problem with monads. You cannot have a (pure) function, that calls another function which returns an IO monad, without returning an IO monad itself _or_ ignoring the result of the function call (which is exactly the same as not calling it from the beginning).

Hence the semantics are the same - just without any compiler feature like async/await.


It's true that you can't extract a value obtained from the outside world from the IO type by design, but, I don't think I'd characterize that as a problem. For any piece of code someone thinks they want to write that involves turning an `IO something` to a `something`, I can show how to solve the problem without doing that with a minimum of fuss. It's actually quite difficult for me to remember why someone would want to do that, having been immersed in this paradigm for so long.

For example, if you have, say,

`getIntFromSomewhere :: IO Int`

and you want to apply a pure function of type `Int -> Int` to it. Supppose the pure function adds one. You might initially think that that's all there is to it, you don't want IO involved in what happens next. But of course, the only reason you would ever do some pure computation is to use the result to have some effect on the world, eg printing it to a console. So actually in a sense, you do want to stay in IO.

    myIOAction :: IO ()
    myIOAction = do
      myInt <- getIntFromSomewhere
      print (addOne myInt)
As you say, you can't pull the int out of getIntFromSomewhere without operating in an IO action. But why would we want to, since we obviously want to make use of that value to affect the outside world? And of course, that doesn't mean we have any issues getting that int into a pure function `addOne :: Int -> Int` first, before eventually (immediately, in this case) making use of the result to perform some IO.


That's a special case for IO, isn't it? I guess I've never actually tried this, but I don't think it's generally the case for all monads.

IOW, Haskell does have coloring, and its coloring does manifest through the IO monad, but that does not mean that all monads have color. Sort of like how you can't do, "Socrates is a man, Socrates is dead, therefore all men are dead."


I think I see what you mean. However, it is the other way around: some monads are special cases in the way that they have extra methods that allow you to "escape" their context. Like lists: you can fold over them and get out a different result type.

But in general you can't do that with monads, in particular not if you just have "any" monad at hand but you don't know which one it is. And IO is one example, parsers would be another one.


I agree with what you’re saying, but I think calling them “special cases” takes away from the elegance of the system. Haskell users frequently say things like “the IO monad” or “the list monad”, but probably the more accurate way would be to say the “the monad instance of IO”.

Monad is a typeclass (analogous to an interface in OOP or traits in Rust). IO is a type. To make some type a member of monad (or any other typeclass), you just need to write the required functions in an instance declaration.

You can make a type a member of as many typeclasses as you want. List is a member of Foldable, Monad, etc. Calling that a special isn’t quite right as it is very common for a type to be a member of many typeclasses.

You’re entirely correct in saying the Monad typeclass offers no way to extract values. But there actually is an extract function called unsafePerformIO with type “IO a -> a”. The name says it all: use it and you’ll lose all of the safety normally afforded by keeping data derived from effects inside the IO type.


You are totally right, I was a bit sloppy with my language there. Thank you for the detailed explanation.


> First, monads aren't functions.

Monads aren't functions[0]; in the analogy, monads are colors.

> [F]unctions of one color can't be called from inside functions of another color. And you don't really have an equivalent problem with using one monad inside the implementation of another monad.

If you have a bunch of `a -> M b` and one `a -> N b`, it's much more straightforward to interleave calls to any group of the former than to incorporate one of the latter. You encounter this when writing a new `a -> M b`, much like you might encounter the corresponding issue when writing a new blue function.

I should point out that there is usually good reason for this in the implementation, and often good reason for it in the domain. When there is good reason in the domain, that's great - our type system is surfacing the places we need to make decisions, rather than letting us mash things together in ways that will cause problems. When there is only good reason in the implementation, or no good reason at all, it's unfortunate, but certainly something that can be worked around.

Unlike the original setting, Haskell provides the ability to write functions polymorphic in "color" ("polychromatic"?), but for the (unfortunately necessary?) distinction between `a -> b` and `a -> Identity b`, and a lot of colors can be easily removed or transformed.

[0]: Some types which are monads are functions but that's not the sense in which we're coloring functions.




Consider applying for YC's Winter 2026 batch! Applications are open till Nov 10

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: