DEV Community

Cover image for Why I'm excited about Roc
Zelenya
Zelenya

Posted on

Why I'm excited about Roc

Disclaimer: Roc is pre-0.1, and everything can change (the first milestone release, 0.1.0, is on the horizon)

People usually don't associate functional programming with being fast. There is no technical reason not to have both. Multiple communities are working on this...



One of them is Roc. Roc probably started from making something functional + friendly and kept asking "why not both"? If we can do friendly and functional, why not fast and functional too? Some snippets from that page:

Roc is best described as a pure functional programming language. Most Roc code is written with immutable values and pure functions, while effects are kept explicit and separate.

Roc also offers locally mutable variables and imperative control flow—including forwhilebreak, and return. These features make Roc more approachable for people coming from imperative languages and can make a few algorithms clearer even for experienced Roc developers.


Roc values are semantically immutable.

And

For intentional reassignment, declare a variable with var and use its $ prefix at every subsequent reference:

var $count = 0
$count = $count + 1

Pure function types use ->. Effectful function types use =>, and effectful function names end in !:

format_name : Str -> Str
format_name = |name| "Hello, ${name.trim()}!"

announce! : Str => {}
announce! = |name| echo!(format_name(name))

And

Unlike the former Task-based design, current Roc code calls effectful functions directly.

If you're Haskell-wired, this might feel unfamiliar (or even odd). This is not a Haskell way/school. This is not not-functional. I think it's really smart.

I'm not going in depth here because I'm working on an hour-long video about this; let's look at something else

Platforms and Types

Let's look at platforms (referenced on the functional page).

I have a little project. It takes EDL files exported from DaVinci Resolve and converts them into YouTube chapters.

web app screenshot

I made a web app in Roc (using wasm).

And I also made a cli app in Roc that reuses the same parser.

Oh, and I have a build script for that web app that also uses Roc.

Should we use Roc for the BE or FE? Why not both?

Should we use Roc for one-off scripts or for maintainable apps? Also, why not both?

So let's talk about errors: error types, error handling, and everything in between.

intro: "errors"

I'm obsessed with the ergonomics of error handling. I've shared some opinions and approaches before. But we need a recap, so there is enough context to see why Roc's approach is so cool.

First, the word "error": exception != error != failure != fault != bug. These are often used interchangeably, referring to anything outside of the happy (normal) path. It's often fine, but it's not fine when we try to have a nuanced conversation, and the pictures aren't coming through correctly. There have been many attempts to nail down precise terminology.

But there are many problems; for example, subjectivity. Imagine we have two different definitions for a programmer's mistake (bug) and an actual exceptional behavior.

What if I misspell the command or an argument, or what if the operating system couldn't spawn a server? Resources are exhausted for some reason (could also be an implicit programming error). Which one is it?

code:

port = Env.var_str!("JOY_WATCH_PORT") ?? "8000"

spawned = Cmd.new_str("caddy")
    .args_str(["run", "--config", "Caddyfile", "--adapter", "caddyfile"])
    .env_str("JOY_WATCH_PORT", port)
    .spawn!()

caddy = match spawned {
    Ok(child) => child
    Err(SpawnFailed(NotFound)) => {
        Stderr.line!("error: caddy not found (is it installed and available on PATH?)")?
        Err(Exit(1))?
    }
    Err(SpawnFailed(err)) => {
        Stderr.line!("error: could not run caddy: ${IOErr.to_str(err)}")?
        Err(Exit(1))?
    }
}
Enter fullscreen mode Exit fullscreen mode

Does it matter which one it is? Does the end user care if I made a mistake? Do they care about the distinction?

A user only wants to be happy.

intro: "exceptions"

If we focus on the word "Exception" alone, it's also very interesting. In the '70s, it started with really good intentions. In the midst of it (there were similar ideas and proposals across multiple papers):

In referring to the condition as exceptions rather than errors, we are following Goodenough. The term "exception" is chosen because, unlike the term "error," it does not imply that anything is wrong; this connotation is appropriate because an event that is viewed as an error by one procedure may not be viewed that way by another. In fact, the term "exception" indicates that something unusual has occurred, and even this may be misleading: if the exception handling mechanism were efficient enough, exceptions might be used to convey information about normal and usual situations.

Exception Handling in CLU. Barbara H. Liskov and Alan Snyder

Even though those were foundational, it seems like decades later, "exceptions" lost this "original" meaning and transitioned to being associated with "exceptional behavior" (we'll come back to it in a bit).

code:

port = Env.var_str!("JOY_WATCH_PORT") ?? "8000"
Enter fullscreen mode Exit fullscreen mode

?? is a fallback - try to parse env vars, and if it fails, default to 8000. That doesn't feel exceptional. What about this one?

spawned = Cmd.new_str("caddy")
    .args_str(["run", "--config", "Caddyfile", "--adapter", "caddyfile"])
    .env_str("JOY_WATCH_PORT", port)
    .spawn!()

caddy = match spawned {
    Ok(child) => child
    Err(SpawnFailed(NotFound)) => {
        Stderr.line!("error: caddy not found (is it installed and available on PATH?)")?
        Err(Exit(1))?
    }
    Err(SpawnFailed(err)) => {
        Stderr.line!("error: could not run caddy: ${IOErr.to_str(err)}")?
        Err(Exit(1))?
    }
}
Enter fullscreen mode Exit fullscreen mode

Is this really exceptional? Isn't it normal that the thing (caddy, in this case) is not installed or unavailable?

In case of port forwarding, what if the user made a typo? It might not be cool that we decide for the user to fall back even though it wasn't "exceptional".

intro: (un)recoverable errors

The other way we can communicate about these errors is by labelling them recoverable or unrecoverable. Sometimes we can program a fallback; other times there's nothing we can do.

This is not precise terminology. In older literature/papers (for example, in the '70s papers we referenced in the prev. part), they argued about termination and resumption semantics (or modes). Not the same, but related. For me, their definitions are too constrained and limiting, but the history is fascinating (promise it all ties back together later).

The second question, whether the signaler should continue to exist after the exception is signaled, involves a tradeoff between expressive power and the complexity of the semantics. If the signaler can continue to exist after signaling, then it is possible that a catcher may fix up the exceptional condition so that processing of the signaler may be resumed. For this reason, we refer to this model as the resumption model. The model in which the signaling activation ceases to exist we refer to as the termination model.

Exception Handling in CLU. Barbara H. Liskov and Alan Snyder

Resumption: we get an option to restart the computation, resume, or unwind. The exception handler should do something about the situation and continue the execution where it left off.

Termination: there is nothing we can do to get back to where the exception happened; unwind the stack, propagate the error.

From the other paper, my favorite part is this:

The various situations in which exceptions are useful present different possibilities for resuming or terminating an operation. To guard against error, each exception should have its resumption or termination constraints specified explicitly and in a way that permits violations of these constraints to be detected at compile time

Exception handling: Issues and a proposed notation. J. B. Goodenough

This idea was lost somewhere down the road.

We don't even talk about those theoretical distinctions. Probably because in the 90s, the "modern languages" (Java, C++, JavaScript, etc.) settled on "termination" semantics as superior and that's it. Case closed.

“termination is preferred over resumption; this is not a matter of opinion but a matter of years of experience. Resumption is seductive, but not valid.”

Good news: we got some powerful exception-handling mechanism, and we can simulate resumption "mode" by using termination-based try-catches to recover from errors when/where needed. And maybe that was the right call, but I think we lost something. That's what I've alluded to before: "exceptions" are associated with these exception-handling mechanisms (usually with try-catches), exceptional behavior, and termination-first semantics.

How I interpret this: the prevalent meme was "you don’t recover from errors often" anyway, so the languages were designed to have less friction for unrecoverable errors. There is a bit of a chicken-and-egg between the language design and the culture around it, but for me it doesn't really matter. What matters is the result: dominance of exception mechanisms that don't strictly distinguish recoverable and unrecoverable errors but are optimized for the latter.

And then, golang was like: try-catches suck and obscure the control flow; let's have errors as explicit return types.

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

Go takes a different approach. For plain error handling, Go’s multi-value returns make it easy to report an error without overloading the return value. A canonical error type, coupled with Go’s other features, makes error handling pleasant but quite different from that in other languages.

Why Go doesn't have exceptions

This is very important. These days, we are not building small programs as much; we are building complex (and over-complicated) systems, and we acknowledge that failure and exceptions are not exceptional. To emphasize: unhappy-path control flow is also important.

Somewhere under the radar, the functional world was cooking other options for moving unhappy-path into explicit form and making it pleasant. Which later resulted in rust (and modern C++) going mainstream with the Result<T, E> type for recoverable errors and panic (which stops program execution) for unrecoverable errors.

composition and friction

Introducing result type, doesn't magically solve error-handling. One of the frictions with that is composition:

fn broken_div(x: String, y: String) -> Result<i32, ???> { // What is the result?
  let parsedX: i32 = x.parse()?; // Result<i32, ParseIntError>
  let parsedY: i32 = y.parse()?; // Result<i32, ParseIntError>
  cheeky_div(parsedX, parsedY)   // Result<i32, DivisionError>
}

fn parse(num: String) -> Result<i32, ParseIntError>

fn cheeky_div(x: i32, y: i32) -> Result<i32, DivisionError>
Enter fullscreen mode Exit fullscreen mode

What's ??? - String? Throwable/Error? Hierarchy of errors (one big one or multiple ones)? union types or polymorphic variants or sets? I'd argue that if the language supports the later that the only sane approach. Even then, we can't simplify and pretend like it's a technical issue and depends on the language support. Underneath, it's also rooted in design and culture.

encapsulation

The last controversial thing on errors we'll cover ties it all together and is related to API design. Do errors leak implementation details? Is it a design failure if the user needs to know your internals? Two (and a half) extremes:

  1. The error type is an encapsulation leak, which forces a tax on all implementations / the user has to deal with complexity
  2. Errors should be structured and precise because generic exceptions (Exception / String) are often irrelevant and useless
  3. (A third camp we can't ignore) Designing errors out of existence

I think there is truth to all. It's important to remember or ask: who are the errors for? for the programmer? the owner of the function or caller of the function? or for the end users?

conclusion on errors

We haven't even talked about library code vs application code, working by yourself vs team vs external users ... We also haven't talked about asynchronous exceptions/errors, error accumulation, and tons of other things. Long story short, there are a lot of flavors and styles, and we should let people paint whatever they want. And I think Roc delivers here.

what about panic?

I don't trust those and don't believe in "this should not happen" kind of things if the compiler can't verify it. But for completeness, roc has "crash": it crashes the currently-running Roc application.

digits_to_num = |digits| {
    if digits.is_empty() {
        crash "TODO add proper error handling."
    }

    # ...the rest of the function would go here
}
Enter fullscreen mode Exit fullscreen mode

crash is not for error handling.

Top comments (0)