← Back to context

Comment by deathanatos

7 hours ago

> Naively composing errors out of ADTs does pessimize the happy path. Error objects recursively composed out of enums tend to be big, which inflates size_of<Result<T, E>>, which pushes functions throughout the call stack to “return large structs through memory” ABI. Error virality is key here — just a single large error on however rare code path leads to worse code everywhere.

This isn't true; proof by counter-example: anyhow::Error.

For example, a lot of Rust code uses "anyhow", a crate which provides sort of a catch-all "anyhow::Error" type. Any other error can be put into an anyhow::Error, and an anyhow::Error is not good for much except displaying, and adding additional context to it. (For that reason, anyhow::Error is usually used at a high-level, where you don't care what specifically went wrong, b/c the only thing you'll use it for is propagation & display.)

No matter what error we put into an anyhow::Error, the stack size is 8 B. (Because it's a pointer to the error E, effectively, though in practice "it's a bit more complicated", but not in any way that harms the argument here.) So clearly, here, we can stuff as much context/data/etc. into the error type E without virally infecting the whole stack with a larger Result<T, E>.

(Rust does allow you to make E larger, and that can mean a Result<T, E> gets larger, yes. But you're one pointer away from moving that to the heap & fixing that. Rust, being a low level language, … permits you that / leaves that up to you. The stack space isn't free — as TFA points out, spilling registers has a cost — but nor are heap allocations free. Rust leaves it up to you, effectively.)

My understanding of Zig the other day is that it doesn't permit associated data at all, and errors are just integer error code, effectively, under the hood. This is a pretty sad state of affairs — I hate the classic unix problem where you get something like,

  $ mkdir $P
  mkdir: no such file or directory

Which I now special path in the neurons in my head so-as to short circuit wandering the desert of "yeah, no such directory … that's why I'm asking you to create it". (And all other variations of this pattern.)

All of that could have been avoided if Unix had the ability to tell us what didn't exist. (And there are so many variants: what exists unexpectedly? what perm did we lack? what device failed I/O?)

(And I suppose you could make Result<T, E> special / known to the compiler, and it could implement stack unwinding specifically. I don't think that leave me with good vibes in the language design dept., and there are other types that have similar stack-propagating behavior to Result (Option, Poll, maybe someday a generator type). What about them?)

The article mentions anyhow:

> That is the reason why mature error handling libraries hide the error behind a thin pointer, approached pioneered in Rust by failure and deployed across the ecosystem in anyhow. But this requires global allocator, which is also not entirely zero cost.

  • Oh oops … IDK how I missed that … but also that seems to really undercut the article's own thesis then if they're aware of it.

    > But this requires global allocator, which is also not entirely zero cost.

    Heap allocs are not free. But then, IDK that the approach of using the unwinding infra is any better. You still have to store the associated data somewhere, & then unwind the stack. That "somewhere" might require a global allocator¹.

    (¹Say you put the associated data on the stack, and unwind, and your "recovery point"/catch/etc. site might get a pointer to it. Put what if that recovery point then calls a function, and that function requires more stack depth that exists prior to the object?

    I supposed you could put it somewhere, and then move it up the stack into the stack frame of the recovery function, but that's more expensive. That might work, though.

    But since C++ impls put it on the heap, that leads me to assume there's a gotcha somewhere here.)

There is a middle ground that I think the post glosses over, which would be to split apart the Result<T,E> value whenever its two cases differ significantly in size. You'd also have to track the discriminant of course.

Basically, supposing T alone fits in a register or two, but E is so big that the union of T and E would spill onto the stack, treat them as two different values instead of one.