Comment by woodruffw

Comment by woodruffw 20 hours ago

109 replies

I think this design is very reasonable. However, I find Zig's explanation of it pretty confusing: they've taken pains to emphasize that it solves the function coloring problem, which it doesn't: it pushes I/O into an effect type, which essentially behaves as a token that callers need to retain. This is a form of coloring, albeit one that's much more ergonomic.

(To my understanding this is pretty similar to how Go solves asynchronicity, expect that in Go's case the "token" is managed by the runtime.)

flohofwoe 20 hours ago

If calling the same function with a different argument would be considered 'function coloring', every function in a program is 'colored' and the word loses its meaning ;)

Zig actually also had solved the coloring problem in the old and abandondend async-await solution because the compiler simply stamped out a sync- or async-version of the same function based on the calling context (this works because everything is a single compilation unit).

  • zarzavat 12 hours ago

    In that case JS is not colored either because an async function is simply a normal function that returns a Promise.

    As far as I understand, coloring refers to async and sync functions having the same calling syntax and interface, I.e.

        b = readFileAsync(p)
        b = readFileSync(p)
    
    share the same calling syntax. Whereas

        b = await readFileAsync(p)
        readFileAsync(p).then(b => ...)
        
        b = readFileSync(b)
    
    are different.

    If you have to call async functions with a different syntax or interface, then it's colored.

    • flohofwoe 2 hours ago

      > In that case JS is not colored either because an async function is simply a normal function that returns a Promise.

      Exactly, IMHO at least, JS doesn't suffer from the coloring problem because you can call async functions from sync functions (because the JS Promise machinery allows to fall back to completion callbacks instead of using await). It's the 'virality' of await which causes the coloring problem, but in JS you can freely mix await and completion callbacks for async operations).

      • zarzavat 2 hours ago

        await isn't viral per se, it's a purely local transformation. The virality is from CPS/callbacks and Promise.

  • unscaled 5 hours ago

    Let's revisit the original article[1]. It was not about arguments, but about the pain of writing callbacks and even async/await compared to writing the same code in Go. It had 5 well-defined claims about languages with colored functions:

    1. Every function has a color.

    This is true for the new zig approach: functions that deal with IO are red, functions that do not need to deal with IO are blue.

    2. The way you call a function depends on its color.

    This is also true for Zig: Red functions require an Io argument. Blue functions do not. Calling a red function means you need to have an Io argument.

    3. You can only call a red function from within another red function.

    You cannot call a function that requires an Io object in Zig without having an Io in context.

    Yes, in theory you can use a global variable or initialize a new Io instance, but this is the same as the workarounds you can do for calling an async function from a non-async function For instance, in C# you can write 'Task.Run(() -> MyAsyncMethod()).Wait()'.

    4. Red functions are more painful to call.

    This is true in Zig again, since you have to pass down an Io instance.

    You might say this is not a big nuisance and almost all functions require some argument or another... But by this measure, async/await is even less troublesome. Compare calling an async function in Javascript to an Io-colored function in Zig:

      function foo() {
        blueFunction(); // We don't add anything
      }
    
      async function bar() {
        await redFunction(); // We just add "await"
      }
    
    And in Zig:

      fn foo() void {
        blueFunction()
      }
    
      fn bar(io: Io) void {
        redFunction(io); // We just add "io".
      }
    
    
    Zig is more troublesome since you don't just add a fixed keyword: you need a add a variable that is passed along through somewhere.

    5. Some core library functions are red.

    This is also true in Zig: Some core library functions require an Io instance.

    I'm not saying Zig has made the wrong choice here, but this is clearly not colorless I/O. And it's ok, since colorless I/O was always just hype.

    ---

    [1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

    • flohofwoe an hour ago

      > This is also true for Zig: Red functions require an Io argument. Blue functions do not. Calling a red function means you need to have an Io argument.

      I don't think that's necessarily true. Like with allocators, it should be possible to pass the IO pointer into a library's init function once, and then use that pointer in any library function that needs to do IO. The Zig stdlib doesn't use that approach anymore for allocators, but not because of technical restrictions but for 'transparency' (it's immediately obvious which function allocates under the hood and which doesn't).

      Now the question is, does an IO parameter in a library's init function color the entire library, or only the init function? ;P

      PS: you could even store the IO pointer in a public global making it visible to all code that needs to do IO, which makes the coloring question even murkier. It will be interesting though how the not-yet-implemented stackless coroutine (e.g. 'code-transform-async') IO system will deal with such situations.

    • ghosty141 4 hours ago

      In my opinion you must have function coloring, it's impossible to do async (in the common sense) without it. If you break it down one function has a dependency on the async execution engine, the other one doesn't, and that alone colors them. Most languages just change the way that dependency is expressed and that can have impacts on the ergonomics.

  • woodruffw 19 hours ago

    > If calling the same function with a different argument would be considered 'function coloring', than every function in a program is 'colored' and the word loses its meaning ;)

    Well, yes, but in this case the colors (= effects) are actually important. The implications of passing an effect through a system are nontrivial, which is why some languages choose to promote that effect to syntax (Rust) and others choose to make it a latent invariant (Java, with runtime exceptions). Zig chooses another path not unlike Haskell's IO.

  • SkiFire13 17 hours ago

    > Zig actually also had solved the coloring problem in the old and abandondend async-await solution because the compiler simply stamped out a sync- or async-version of the same function based on the calling context (this works because everything is a single compilation unit).

    AFAIK this still leaked through function pointers, which were still sync or async (and this was not visible in their type)

    • throwawaymaths 13 hours ago

      Pretty sure the Zig team is aware of this and has plans to fix it before they re-release async.

  • adamwk 19 hours ago

    The subject of the function coloring article was callback APIs in Node, so an argument you need to pass to your IO functions is very much in the spirit of colored functions and has the same limitations.

    • jakelazaroff 19 hours ago

      In Zig's case you pass the argument whether or not it's asynchronous, though. The caller controls the behavior, not the function being called.

      • layer8 18 hours ago

        The coloring is not the concrete argument (Io implementation) that is passed, but whether the function has an Io parameter in the first place. Whether the implementation of a function performs IO is in principle an implementation detail that can change in the future. A function that doesn't take an Io argument but wants to call another function that requires an Io argument can't. So you end up adding Io parameters just in case, and in turn require all callers to do the same. This is very much like function coloring.

        In a language with objects or closures (which Zig doesn't have first-class support for), one flexibility benefit of the Io object approach is that you can move it to object/closure creation and keep the function/method signature free from it. Still, you have to pass it somewhere.

  • jcranmer 19 hours ago

    > If calling the same function with a different argument would be considered 'function coloring', than every function in a program is 'colored' and the word loses its meaning ;)

    I mean, the concept of "function coloring" in the first place is itself an artificial distinction invented to complain about the incongruent methods of dealing with "do I/O immediately" versus "tell me when the I/O is done"--two methods of I/O that are so very different that it really requires very different designs of your application on top of those I/O methods: in a sync I/O case, I'm going to design my parser to output a DOM because there's little benefit to not doing so; in an async I/O case, I'm instead going to have a streaming API.

    I'm still somewhat surprised that "function coloring" has become the default lens to understand the semantics of async, because it's a rather big misdirection from the fundamental tradeoffs of different implementation designs.

    • omnicognate 13 hours ago

      100% agree, but fortunately I don't think it is the "default lens". If it were nobody would be adding new async mechanisms to languages, because "what color is your function" was a self-described rant against async, in favour of lightweight threads. It does seem to have established itself as an unusually persistent meme, though.

    • conradev 7 hours ago

      My understanding of this design is that you can write the logic separately from the decision to "do I/O immediately" versus "tell me when the I/O is done"

      You can write a parser thats outputs a DOM and run it on a stream, or write a parser with a streaming API and run it synchronously on a buffer. You should pick the optimal tool for the situation, but there is no path dependence anymore.

    • zelphirkalt 12 hours ago

      Function coloring is the issue, that arises in practice, which is why people discuss, whether some approach solves it or does not.

      Why do you think it automatically follows, that with an async I/O you are going to have a streaming API? An async I/O can just like the sync I/O return a whole complete result, only that you are not waiting for that to happen, but the called async procedure will call you back once the result is calculated. I think a streaming API requires additional implementation effort, not merely async.

  • [removed] 19 hours ago
    [deleted]
  • rowanG077 19 hours ago

    If your functions suddenly requires (currently)unconstructable instance "Magic" which you now have to pass in from somewhere top level, that indeed suffers from the same issue as async/await. Aka function coloring.

    But most functions don't. They require some POD or float, string or whatever that can be easily and cheaply constructed in place.

    • mk12 10 hours ago

      Colors for 2 ways of doing IO vs colors for doing IO or not are so different that it’s confusing to call both of them “function coloring problem”. Only the former leads to having to duplicate everything (sync version and async version). If only the latter was a thing, no one would have coined the term and written the blog post.

      • rowanG077 10 hours ago

        IMO the problem was never about it actually doing IO or an async actions or whatever. It's about not being able to a call a async function from a sync function. Because in my experience you almost never wholesale move from sync to async everywhere. In fact I would consider that an extremely dangerous practice.

throwawaymaths 13 hours ago

1) zig's io is not a viral effect type, you can in principle declare a global io variable and use it everywhere that any library calls for it. Not best practice for a library writer, but if you're building an app, do what you want.

2) There are two things here, there is function coloring and the function coloring problem. The function coloring problem is five things:

https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

1. Every function has a color.

2. The way you call a function depends on its color.

3. You can only call a red function from within another red function.

4. Red functions are more painful to call.

5. Some core library functions are red.

You'll have some convincing to do that zig's plan satisfies 4. It's almost certain that it won't satisfy 5.

It's open to debate if zig's plan will work at all, of course.

  • woodruffw 13 hours ago

    > 1) zig's io is not an effect type, you can in principle declare a global io variable and use it everywhere that any library calls for it.

    That's an effect, akin to globally intermediated I/O in a managed runtime.

    To make it intuitive: if you have a global token for I/O, does your concurrent program need to synchronize on it in order to operate soundly? Do programs that fail to obtain the token behave correctly?

    • throwawaymaths 13 hours ago

      how do you "fail to obtain the token"?

      • woodruffw 13 hours ago

        The token guards a fallible resource (I/O). You can (temporarily or permanently) fail to obtain it for any reason that would affect the underlying I/O.

        • throwawaymaths 9 hours ago

          the io isnt a single resource? it's a module grouping together a pile of code. and you can swap out implementations. the io modules should be responsible for handing out many failable resources, and synchronization is going to be up to the io module code, and thats whether or not it's globalized or passed.

jayd16 19 hours ago

Actually it seems like they just colored everything async and you pick whether you have worker threads or not.

I do wonder if there's more magic to it than that because it's not like that isn't trivially possible in other languages. The issue is it's actually a huge foot gun when you mix things like this.

For example your code can run fine synchronously but will deadlock asynchronously because you don't account for methods running in parallel.

Or said another way, some code is thread safe and some code isn't. Coloring actually helps with that.

  • flohofwoe 19 hours ago

    > Actually it seems like they just colored everything async and you pick whether you have worker threads or not.

    There is no 'async' anywhere yet in the new Zig IO system (in the sense of the compiler doing the 'state machine code transform' on async functions).

    AFAIK the current IO runtimes simply use traditional threads or coroutines with stack switching. Bringing code-transform-async-await back is still on the todo-list.

    The basic idea is that the code which calls into IO interface doesn't need to know how the IO runtime implements concurrency. I guess though that the function that's called through the `.async()` wrapper is expected to work properly both in multi- and single-threaded contexts.

    • jayd16 19 hours ago

      > There is no 'async'

      I meant this more as simply an analogy to the devX of other languages.

      >Bringing code-transform-async-await back is still on the todo-list.

      The article makes it seem like "the plan is set" so I do wonder what that Todo looks like. Is this simply the plan for async IO?

      > is expected to work properly both in multi- and single-threaded contexts.

      Yeah... about that....

      I'm also interested in how that will be solved. RTFM? I suppose a convention could be that your public API must be thread safe and if you have a thread-unsafe pattern it must be private? Maybe something else is planned?

      • messe 19 hours ago

        > The article makes it seem like "the plan is set" so I do wonder what that Todo looks like. Is this simply the plan for async IO?

        There's currently a proposal for stackless coroutines as a language primitive: https://github.com/ziglang/zig/issues/23446

  • [removed] 13 hours ago
    [deleted]
doyougnu 19 hours ago

Agreed. the Haskeller in me screams "You've just implemented the IO monad without language support".

  • AndyKelley 16 hours ago

    It's not a monad because it doesn't return a description of how to carry out I/O that is performed by a separate system; it does the I/O inside the function before returning. That's a regular old interface, not a monad.

  • throwawaymaths 10 hours ago

    i mean not really? it absolutely does nothing to segregate stateful impurity into a type theoretically stateless token

dan-robertson 10 hours ago

This solves a problem for library authors which is that blocking and event-based io implementations of functionality look the same but are not actually the same so users end up complaining when you do one but not the other.

It adds a problem of needing to pass the global kind of io through a program. I think this mostly isn’t a huge problem because typical good program design has io on the periphery and so you don’t tend to need to pass this io object that ‘deep’. This is not too different from the type-system effect of IO in Haskell (except that one only does evented IO IIRC). It isn’t as bad because it only affects input types (data which can be closed over, I assume) rather than output types. Eg in Haskell you need various special functions to change from [ IO a ] to IO [ a ] but in the zig model you iterate over your list in the normal way using an io value from an outer scope.

The one case where Io-colouring was annoying to me in Haskell was adding printf debugging (there is a function to cheat the type system for this). Zig may have other solutions to that, eg a global io value for blocking io in debug builds or some global logging system.

  • themk 8 hours ago

    There is nothing special about the [IO a] -> IO [a] in Haskell. You can iterate over it using the "normal" methods of iterating just fine.

        forM ios $ \io -> io
    
    But there are better ways to do it (e.g. sequence), but those are also not "special" to IO in any way. They are common abstractions usable by any Monad.
SkiFire13 16 hours ago

The function coloring problem actually comes up when you implement the async part using stackless coroutines (e.g. in Rust) or callbacks (e.g. in Javascript).

Zig's new I/O does neither of those for now, so hence why it doesn't suffer from it, but at the same time it didn't "solve" the problem, it just sidestepped it by providing an implementation that has similar features but not exactly the same tradeoffs.

  • bloppe 16 hours ago

    How are the tradeoffs meaningfully different? Imagine that, instead of passing an `Io` object around, you just had to add an `async` keyword to the function, and that was simply syntactic sugar for an implied `Io` argument, and you could use an `await` keyword as syntactic sugar to pass whatever `Io` object the caller has to the callee.

    I don't see how that's not the exact same situation.

    • bevr1337 15 hours ago

      In the JS example, a synchronous function cannot poll the result of a Promise. This is meaningfully different when implementing loops and streams. Ex, game loop, an animation frame, polling a stream.

      A great example is React Suspense. To suspend a component, the render function throws a Promise. To trigger a parent Error Boundary, the render function throws an error. To resume a component, the render function returns a result. React never made the suspense API public because it's a footgun.

      If a JS Promise were inspectable, a synchronous render function could poll its result, and suspended components would not need to use throw to try and extend the language.

      • int_19h 13 hours ago

        .NET has promises that you can poll synchronously. The problem with them is that if you have a single thread, then by definition while your synchronous code is running, none of the async callbacks can be running. So if you poll a Task and it's not complete yet, there's nothing you can do to wait for its completion.

        Well, technically you can run a nested event loop, I guess. But that's such a heavy sync-wrapping-async solution that it's rarely used other than as a temporary hack in legacy code.

      • bloppe 15 hours ago

        I see. I guess JS is the only language with the coloring problem, then, which is strange because it's one of the few with a built-in event loop.

        This Io business is isomorphic to async/await in Rust or Python [1]. Go also has a built-in "event loop"-type thing, but decidedly does not have a coloring problem. I can't think of any languages besides JS that do.

        [1]: https://news.ycombinator.com/item?id=46126310

        • unbrice 13 hours ago

          > Go also has a built-in "event loop"-type thing, but decidedly does not have a coloring problem.

          context is kind of a function color in go, and it's also a function argument.

    • mk12 10 hours ago

      It’s not the same situation because with async/await you end up with two versions of every function or library (see Rust’s std and crates like async_std, Node’s readFile and readFileSync). In Zig you always pass the “io” parameter to do I/O and you don’t have to duplicate everything.

    • VMG 16 hours ago

      Maybe I have this wrong, but I believe the difference is that you can create an Io instance in a function that has none

      • bloppe 15 hours ago

        In Rust, you can always create a new tokio runtime and use that to call an async function from a sync function. Ditto with Python: just create a new asyncio event loop and call `run`. That's actually exactly what an Io object in Zig is, but with a new name.

        Looking back at the original function coloring post [1], it says:

        > It is better. I will take async-await over bare callbacks or futures any day of the week. But we’re lying to ourselves if we think all of our troubles are gone. As soon as you start trying to write higher-order functions, or reuse code, you’re right back to realizing color is still there, bleeding all over your codebase.

        So if this is isomorphic to async/await, it does not "solve" the coloring problem as originally stated, but I'm starting to think it's not much of a problem at all. Some functions just have different signatures from other functions. It was only a huge problem for JavaScript because the ecosystem at large decided to change the type signatures of some giant portion of all functions at once, migrating from callbacks to async.

        [1]: https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

  • zamalek 16 hours ago

    It's sans-io at the language level, I like the concept.

    So I did a bit of research into how this works in Zig under the hood, in terms of compilation.

    First things first, Zig does compile async fns to a state machine: https://github.com/ziglang/zig/issues/23446

    The compiler decides at compile time which color to compile the function as (potentially both). That's a neat idea, but... https://github.com/ziglang/zig/issues/23367

    > It would be checked illegal behavior to make an indirect call through a pointer to a restricted function type when the value of that pointer is not in the set of possible callees that were analyzed during compilation.

    That's... a pretty nasty trade-off. Object safety in Rust is really annoying for async, and this smells a lot like it. The main difference is that it's vaguely late-bound in a magical way; you might get an unexpected runtime error and - even worse - potentially not have the tools to force the compiler to add a fn to the set of callees.

    I still think sans-io at the language level might be the future, but this isn't a complete solution. Maybe we should be simply compiling all fns to state machines (with the Rust polling implementation detail, a sans-io interface could be used to make such functions trivially sync - just do the syscall and return a completed future).

    • matu3ba 13 hours ago

      > I still think sans-io at the language level might be the future, but this isn't a complete solution. Maybe we should be simply compiling all fns to state machines (with the Rust polling implementation detail, a sans-io interface could be used to make such functions trivially sync - just do the syscall and return a completed future).

      Can you be more specific what is missing in sans-io with explicit state machine for static and dynamic analysis would not be a complete solution? Serializing the state machine sounds excellent for static and dynamic analysis. I'd guess the debugging infrastructure for optimization passes and run-time debugging are missing or is there more?

      • zamalek 12 hours ago

        Exactly the caveat that they themselves disclose: some scenarios are too dynamic for static analysis.

    • algesten 14 hours ago

      I wouldn't define it as Sans-IO if you take an IO argument and block/wait on reading/writing, whether that be via threads or an event loop.

      Sans-IO the IO is _outside_ completely. No read/write at all.

      • zamalek 12 hours ago

        Oof, you're completely right. I'm not sure where I got that wire crossed.

dundarious 19 hours ago

There is a token you must pass around, sure, but because you use the same token for both async and sync code, I think analogizing with the typical async function color problem is incorrect.

rowanG077 20 hours ago

Having used zig a bit as a hobby. Why is it more ergonomic? Using await vs passing a token have similar ergonomics to me. The one thing you could say is that using some kind of token makes it dead simple to have different tokens. But that's really not something I run into often at all when using async.

  • messe 19 hours ago

    > The one thing you could say is that using some kind of token makes it dead simple to have different tokens. But that's really not something I run into often at all when using async.

    It's valuable to library authors who can now write code that's agnostic of the users' choice of runtime, while still being able to express that asynchronicity is possible for certain code paths.

    • rowanG077 19 hours ago

      But that can already be done using async await. If you write an async function in Rust for example you are free to call it with any async runtime you want.

      • messe 19 hours ago

        But you can't call it from synchronous rust. Zig is moving toward all sync code also using the Io interface.

  • hansvm 9 hours ago

    Making it dead simple to have different tokens is exactly the goal. A smattering of examples recently on my mind:

    As a background, you might ask why you need different runtimes ever. Why not just make everything async and be done with it, especially if the language is able to hide that complexity?

    1. In the context of a systems language that's not an option. You might be writing an OS, embedded code, a game with atypical performance demands requiring more care with the IO, some kernel-bypass shenanigan, etc. Even just selecting between a few builtin choices (like single-threaded async vs multi-threaded async vs single-threaded sync) doesn't provide enough flexibility for the range of programs you're trying to allow a user to write.

    2. Similarly, even initializing a truly arbitrary IO effect once at compile-time doesn't always suffice. Maybe you normally want a multi-threaded solution but need more care with respect to concurrency in some critical section and need to swap in a different IO. Maybe you normally get to interact with the normal internet but have a mode/section/interface/etc where you need to send messages through stranger networking conditions (20s ping, 99% packet loss, 0.1kbps upload on the far side, custom hardware, etc). Maybe some part of your application needs bounded latency and is fine dropping packets but some other part needs high throughput and no dropped packets at any latency cost. Maybe your disk hardware is such that it makes sense for networking to be async and disk to be sync. And so on. You can potentially work around that in a world with a single IO implementation if you can hack around it with different compilation units or something, but it gets complicated.

    Part of the answer then is that you need (or really want) something equivalent to different IO runtimes, hot-swappable for each function call. I gave some high-level ideas as to why that might be the case, but high-level observations often don't resonate, so let's look at a concrete case where `await` is less ergonomic:

    1. Take something like TLS as an example (stdlib or 3rd-party, doesn't really matter). The handshake code is complicated, so a normal implementation calls into an IO abstraction layer and physically does reads and writes (as opposed to, e.g., a pure state-machine implementation which returns some metadata about which action to perform next -- I hacked together a terrible version of that at one point [0] if you want to see what I mean). What if you want to run it on an embedded device? If it were written with async it would likely have enough other baggage that it wouldn't fit or otherwise wouldn't work. What if you want to hide your transmission in other data to sneak it past prying eyes (steganography, nowadays that's relatively easy to do via LLMs interestingly enough, and you can embed arbitrary data in messages which are human-readable and purport to discuss completely other things without exposing hi/lo-bit patterns or other such things that normally break steganography)? Then the kernel socket abstraction doesn't work at all, and "just using await" doesn't fix the problem. Basically, any place you want to use that library (and, arguably, that's the sort of code where you should absolutely use a library rather than rolling it yourself), if the implementer had a "just use await" mentality then you're SOL if you need to use it in literally any other context.

    I was going to write more concrete cases, but this comment is getting to be too long. The general observation is that "just use await" hinders code re-use. If you're writing code for your own consumption and also never need those other uses then it's a non-issue, but with a clever choice of abstraction it _might_ be possible (old Zig had a solution that didn't quite hit the mark IMO, and time will tell if this one is good enough, but I'm optimistic) to enable the IO code people naturally write to be appropriately generic by default and thus empower future developers via a more composable set of primitives.

    They really nailed that with the allocator interface, and if this works then my only real concern is a generic "what next" -- it's pushing toward an effect system, but integrating those with a systems language is mostly an unsolved problem, and adding a 3rd, 4th, etc explicit parameter to nearly every function is going to get unwieldy in a hurry (back-of-the-envelope idea I've had stewing if I ever write a whole "major" language is to basically do what Zig currently does and pack all those "effects" into a single effect parameter that you pass into each function, still allowing you to customize each function call, still allowing you to inspect which functions require allocators or whatever, but making the experience more pleasant if you have a little syntactic sugar around sub-effects and if the parent type class is comptime-known).

    [0] https://github.com/hmusgrave/rayloop/blob/d5e797967c42b9c891...

    • rowanG077 7 hours ago

      The case I'm making is not that different Io context are good. The point I'm making is that mixing them is almost never what is needed. I have seen valid cases that do it, but it's not in the "used all the time" path. So I'm more then happy with the better ergonomics of traditional async await in the style of Rust , that sacrifices super easy runtime switching. Because the former is used thousands of times more.

      • hansvm 6 hours ago

        If I'm understanding correctly (that most code and/or most code you personally write doesn't need that flexibility) then that's a valid use case.

        In practice it should just be a po-tay-to/po-tah-to scenario, swapping around a few symbols and keywords vs calls to functions with names similar to those keywords. If that's all you're doing then passing around something like IO (or, depending on your app, just storing one once globally and not bothering to adhere to the convention of passing it around) is not actually more ergonomic than the alternative. It's not worse (give or take a bunch of bike-shedding on a few characters here and there), but it's not better either.

        Things get more intriguing when you consider that most nontrivial projects have _something_ interesting going on. As soon as your language/framework/runtime/etc makes one-way-door assumptions about your use case, you're definitionally unable to handle those interesting things within the confines of the walls you've built.

        Maybe .NET Framework has an unavoidable memory leak under certain usage patterns forcing you to completely circumvent their dependency-injection code in your app. Maybe your GraphQL library has constrained socket assumptions forcing you to re-write a thousand lines of entrypoint code into the library (or, worse, re-write the entire library). Maybe the stdlib doesn't have enough flexibility to accomodate your atypical IO use-case.

        In any one app you're perhaps not incredibly likely to see that with IO in particular (an off-the-cuff guesstimate says that for apps needing _something_ interesting you'll need IO to be more flexible 30% of the time). However, when working in a language/framework/runtime/etc which makes one-way-door assumptions frequently, you _are_ very likely to find yourself having to hack around deficiencies of some form. Making IO more robust is just one of many choices enabling people to write the software they want to write. When asking why an argument-based IO is more ergonomic, it's precisely because it satisfies those sorts of use cases. If you literally never need them (even transitively) then maybe actually you don't care, but a lot of people do still want that, and even more people want a language which "just works" in any scenario they might find themselves in, including when handling those sorts of issues.

        === Rust async rant starts here ===

        You also called out Rust's async/await as having good ergonomics as a contrast against TFA, and ... I think it's worth making this comment much longer to talk about that?

        (1) Suppose your goal is to write a vanilla application doing IO stuff. You're forced to use Tokio and learn more than you want about the impact of static lifetimes and other Rust shenanigans, else you're forced to ignore most of the ecosystem (function coloring, yada yada). Those are workable constraints, but they're not exactly a paragon of a good developer experience. You're either forced to learn stuff you don't care about, or you're forced to write stuff you don't think you should have to write. The lack of composability of async Rust as it's usually practiced is common knowledge and one of the most popularly talked about pain points of the language.

        (2) Suppose your goal is to write a vanilla _async_ application doing IO stuff. At least now something like Tokio makes sense in your vision, but it's still not exactly easy. The particular implementation of async used by Tokio forces a litany of undesirable traits and lifetime issues into your application code. That code is hard to write. Moreover, the issues aren't really Rust-specific. Rust surfaces those issues early in the development cycle, but the problem is that Tokio has a lot of assumptions about your code which must be satisfied for it to work correctly, and equivalent libraries (and ecosystem problems) in other langugages will make those same assumptions and require the same kinds of code modifications from you, the end user. Contrasted with, e.g., Python's model of single-threaded async "just working" (or C#'s or something if you prefer multi-threaded stuff and ignore the syntactic sharp edges), a Tokio-style development process is brutally difficult and arguably not worth the squeeze if you also don't have the flexbility to do the async things your application actually demands. Just write golang greenthreads and move on with your life.

        (3) Suppose your goal is something more complicated. You're totally fucked. That capability isn't exposed to you (it's exposed a little, but you have to write every fucking thing yourself, removing one of the major appeals of choosing a popular language).

        I get that Zig is verbose and doesn't appeal to everyone, and I really don't want to turn this into Rust vs Zig, but Rust's async is one of the worst parts of the language and one of the worst async implementations I've ever seen anywhere. I don't have a lot of comment on TFA's implementation (seems reasonable, but I might change my mind after I try using it for awhile), but I'm shocked reading that Rust has a good async model. What am I missing?

eikenberry 14 hours ago

Function coloring is specifically about requiring syntax for a function, eg. the async keyword. So if you want an async and non-async function you need to write both in code. If you pass the "coloring" as an argument you avoid the need for extra syntax and multiple function definitions and therefor the function has no color. You can solve this in various ways with various tradeoffs but as long as there is a single function (syntactically) is all that matters for coloring.

  • woodruffw 13 hours ago

    > Function coloring is specifically about requiring syntax for a function, eg. the async keyword.

    Someone should tell the inventor of the phrase, because they don't mention the async keyword at all[1]. As-written, function coloring is about callbacks (since that's semantic mechanism that JavaScript happens to pick for their asynchronous model).

    Function coloring is just an informal way to describe encoding a function's effect. You can encode that in syntax if you want (an `async` keyword), or in the type system (returning `() -> T` instead of `T`), or in the runtime itself (by controlling all I/O and treating it the same). But you can't avoid it.

    [1]: https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

    • eikenberry 12 hours ago

      They specifically called it out as a syntactical issue, where the issue was based around the requirement to have the 'red' or 'blue' keyword. The section on "2. The way you call a function depends on its color." makes this pretty explicit...

          2. The way you call a function depends on its color.
      
          Imagine a “blue call” syntax and a “red call” syntax. Something like:
      
          doSomethingAzure()blue;
          doSomethingCarnelian()red;
      
          When calling a function, you need to use the call that corresponds to its color.
      • woodruffw 10 hours ago

        I don’t think so? The implication is that it’s a callback, which of course is going to require another call to realize the evaluation. But it’s not inherently another keyword; the keyword is just sugar for deferred evaluation.

  • IshKebab 14 hours ago

    > Function coloring is specifically about requiring syntax for a function, eg. the async keyword.

    It isn't really. It's about having two classes of functions (async and sync), and not being able to await async functions from sync ones.

    It was originally about Javascript, where it is the case due to how the runtime works. In a sync function you can technically call an async one, but it returns a promise. There's no way to get the actual result before you return from your sync function.

    That isn't the case for all languages though. E.g. in Rust: https://docs.rs/futures/latest/futures/executor/fn.block_on....

    I think maybe Python can do something similar but don't quote me on that.

    There's a closely related problem about making functions generic over synchronicity, which people try and solve with effects, monads, etc. Maybe people call that "function colouring" now, but that wasn't exactly the original meaning.