Remix.run Logo
wesselbindt 2 days ago

I do not work in a functional language, but these ideas have helped me a lot anyway. The only idea here that I find less directly applicable outside purely functional languages is the "Errors as values [instead of exceptions]" one.

On the surface, it makes complete sense, the non-locality of exceptions make them hard to reason about for the same reasons that GOTO is hard to reason about, and representing failure modes by values completely eliminates this non-locality. And in purely functional languages, that's the end of the story. But in imperative languages, we can do something like this:

  def my_effectful_function():
    if the_thing_is_bad:
      # do the failure thing
      raise Exception
      # or
      return Failure()
    return Success()
and a client of this function might do something like this:

  def client_function():
    ...
    my_effectful_function()
    ...
and completely ignore the failure case. Now, ignoring the failure is possible with both the exception and the failure value, but in the case of the failure value, it's much more likely to go unnoticed. The exception version is much more in line with the "let it crash" philosophy of Erlang and Elixir, and I'm not sure if the benefits of locality outweigh those of the "let it crash" philosophy.

Have any imperative folks here successfully used the "errors as values" idea?

skirmish 2 days ago | parent | next [-]

Rust does "errors as values" pretty well, see [1]. You can manually handle them if you want, or just apply the '?' operator to auto-propagate them out of the function. In both cases, it is obvious that there was an error handling needed.

[1] https://doc.rust-lang.org/book/ch09-02-recoverable-errors-wi...

wesselbindt 2 days ago | parent [-]

I didn't know that! Every time I hear about Rust my opinion of it grows brighter. Thanks for sharing this!

TeMPOraL 2 days ago | parent | prev | next [-]

Non-locality of exceptions is a feature, not a bug. It's so you can focus on the success case, instead of error case, when reading your code. It's usually, but not always, what you want. "errors as values" is effectively the same thing as exceptions anyway, except it's explicit - meaning it's hurting readability by default and adding extra work[0]; modern languages go to extreme length to try and paper it over with syntactic magic.

Unfortunately, the whole "exceptions vs. expected" issue is fundamentally unsolvable for as long as we stick to working on common single source of truth plaintext code. Explicit and implicit error handling are both useful in different circumstances - it's entirely a presentation issue (i.e. how code looks to you); our current paradigm forces us to precommit to one or the other, which just plain sucks.

--

[0] - See how it works in C++, where you can't easily hide the mechanism.

wesselbindt 2 days ago | parent [-]

In general, I find that explicit code is more easily read than implicit code. I prefer static over dynamic typing, I actually _like_ the explicitness of async/await or the IO monad. If something allows me to find out information about my current context without having to move up or down the stack and reading the code in other functions, I'm pretty happy about that something, because reading code is slow and tedious. What is it about implicit code that makes you feel it's more readable?

TeMPOraL a day ago | parent [-]

> What is it about implicit code that makes you feel it's more readable?

It's more readable when I don't care about the implicit parts at the moment. It's less readable when I do. The key thing is, whether or not I care changes from task to task, or even within the task, possibly many times per day.

The problem with our current paradigm is that we want to work on a shared plaintext artifact (codebase), and we want it to simultaneously:

1) Express everything there is about the program;

2) Be easy to read and understand and modify by humans;

3) Be the same for everyone at all times - a shared single source of truth.

"Success path" logic, error handling, logging, async/await, authentication, etc. are all cross-cutting concerns. Now, 1) means we're forced to include all of them in code simultaneously, but this goes against 2). Like, when I'm trying to understand the overall logic of some business process, then error handling and async/await are irrelevant. They're pure noise. Yet 1) forces me to look and think about them at all times.

So the issue is, 2) is best achieved when you can "filter out" concerns you don't care about at a given moment, and operate on such simplified view of code. But 1) and 3) requires us to spell the all out, everywhere, at all times. The way this is mitigated today, is through ever more complex syntax and advanced mathematical trickery. Like your async/await keywords, or the IO monad. They're ways of compressing some concerns (or classes of concerns) into terse notation, but that comes at the cost of increased complexity and mental demand, too (I mean, explain to me what a monad is, again? :)). I believe that at this point, all modern languages are hitting the Pareto frontier of readability, so whether you use $whatever-routines instead of async, or result types instead of exceptions, it's all just making some cases more readable, at the expense of other cases.

In the same category of problems is also another "holy war": lots of small functions, vs. fewer big ones. There is no right answer here, because it depends on what your goal as a reader is at the moment - e.g. understanding the idea expressed by some logic may benefit from small functions, but debugging it often benefits from the opposite. This is, again, a faux problem, created by our tooling and insistence on the "shared plaintext single source of truth" paradigm.

Compare with code folding in IDEs. It's a view feature that lets you hide blocks of code - like loop bodies, classes, or function definitions - that you don't care about at the moment. Now imagine a similar feature existed, that would let you "fold away" error handling entirely. Or "fold away" the try/catch blocks, or all the mess of dealing with Result types. Or fold away logging. Or async/await. This is the solution we need - the ability to view the shared code through various lenses. Sacrificing 3) lets us get both 1) and 2) at the same time.

It's way better than what we do now, which is precommitting to relative importance of various cross-cutting concerns, by encoding them in how easy or hard they're to write in a given language.

nextaccountic 2 days ago | parent | prev | next [-]

The way Rust solves this is that the type Result is marked with #[must_use] - ignoring it and not deciding what to do with the error raises an warning.

Plus, if you want to bubble the error, you must intentionally use the ? operator, so you are forced to acknowledge the function you called actually may raise an error (as opposed to calling an API you're unfamiliar with and forgetting to check whether it can raise an error, and the compiler not having your back)

galaxyLogic 2 days ago | parent | prev | next [-]

The way to do functional programming in imperative languages is to handle the side-effects as high up in the call-chain as possible. That would mean that you return an instance of Error from lower-level and decide in some higher caller what to do about it.

That as an alternative to throwing the error. This way you get the benefit of being able to follow the flow of control from each called function back to each caller, as opposed to control jumping around wildly because of thrown errors.

In a statically typed imperative language that would need support for sum-types, to be able to return either an error or a non-error-value. Then you would be less likely to ignore the errors by accident because you would always see the return value is maybe an error.

Isn't this also basically how Haskell does it, handling side-effectful values as high up as as possible which in Haskell means moving them into the runtime system above all user-code?

wesselbindt 2 days ago | parent [-]

Right, I understand. But my question is, how do you _ensure_ a failure value is dealt with by clients? In purely functional languages, your clients have no choice, they'll have to do something with it. In imperative languages, they can just ignore it.

tubthumper8 2 days ago | parent | next [-]

In Rust, there's a `#[must_use]` attribute that can be applied to types, such as Result, and on functions. This triggers if the return value is not used. It's only a warning though, but you could imagine a hypothetical imperative language making this a hard error

nextaccountic 2 days ago | parent [-]

#![deny(must_use)] on the root of your crate makes it a hard error for your whole crate.

Typically what happens is that having this set on is very annoying while developing code, because we often want to test incomplete code without proper error handling before we finish it. So sometimes people will block on this kind of issue in CI, but not error out during development (a warning is more than enough)

galaxyLogic 2 days ago | parent | prev [-]

Failing to detect a result as error-value is a good failure case to be aware of. But I think if you throw an error it is also possible for a client to fail to handle it properly. No Silver Bullet.

klysm 2 days ago | parent | prev | next [-]

C# nullable reference types with our parameters can kinda resemble forcing you to at least bind variables to the outputs but there’s nothing really like the linear types that we want and deserve

aiono 2 days ago | parent | prev [-]

Simple, your compiler/type checker/static analyzer/whatever needs to ensure that any value that is not unused gives an error. But you need some sort of static analysis for that. Without type checking you don't do that of course.