Hacker News new | ask | show | jobs
by blindseer 990 days ago
That seems like a fair article.

The error handling in Go is SO verbose. When reading my code (or even reviewing other people's code) in order to understand at a high level what is going on, I feel like I'm squinting through a mesh wire window.

Compare this example in Go:

    city := c.Query("city")
    latlong, err := getLatLong(city)
    if err != nil {
     c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
     return
    }

    weather, err := getWeather(*latlong)
    if err != nil {
     c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
     return
    }

    weatherDisplay, err := extractWeatherData(city, weather)
    if err != nil {
     c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
     return
    }
    c.HTML(http.StatusOK, "weather.html", weatherDisplay)
To this code in Rust:

    let lat_long = fetch_lat_long(&params.city).await?;
    let weather = fetch_weather(lat_long).await?;
    let display = WeatherDisplay::new(params.city, weather);
Maybe on first glance the Rust code can seem alien (what is a `?` doing there, what is actually going on with `.await`, etc) but when you are writing a 100k line application in Rust, you learn the patterns and want to be able to see the domain application logic clearly. And yet, there's no hidden errors or exceptions. When this code fails, you will be able to clearly identify what happened and which line the error occurred on.

Prototyping even small applications in Go is verbose. And worse still, error prone. It's easy to be lazy and not check for errors and oops 3 months in your code fails catastrophically.

I know a lot of people like Go on here but in my mind Go only makes sense as a replacement for Python (static compilation, better error handling than Python, faster etc). If you don't know exactly what you want to build, maybe it is faster to prototype it in Go? I still would reach for Rust in those instances but that's just me. For large applications there's no question in my mind that Rust is a better choice.

Edit:

people have pointed out that I'm not comparing the same thing, which is true, I apologize for the confusing. But even code where Go propagates the errors, it is much more verbose (and my point still stands)

    err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name)
    if err == nil {
     return latLong, nil
    }

    latLong, err = fetchLatLong(name)
    if err != nil {
     return nil, err
    }

    err = insertCity(db, name, *latLong)
    if err != nil {
     return nil, err
    }
And this is extremely common. More discussion in the thread below.
8 comments

Go is a great replacement for Python as a web backend language (which Python really is not). I'm not sold on Rust as a web backend language, though: It ends up being a little too hard to work with (hello `async`) in that application, and you need to import a lot of 3rd party dependencies that are very opinionated. That stuff and the complexities of working with the borrow checker and async adds a lot of complexity to your large, long-running applications that you don't have to manage in Go.

I think Rust is a fantastic systems language that is misapplied to the web. I think Python was a fantastic scripting language that is misapplied to the web, too, so you can put that in context.

I agree that Go's web backend features make it fun to prototype an application. But the moment I want to do anything more complicated, then I'm not sure.

I counted the number of lines in my work projects, and I have $WORK projects that are 100k lines of code. Maintaining that in Go would seem like a nightmare to me, but in Rust that is so much nicer. My personal projects range from 10k - 35k and in all of those I much prefer the ones where I'm writing and maintaining Rust vs Go when it comes to similar complexity.

It sounds like you have a strong personal preference for Rust, which is fine. I'm pretty sure nobody loves Go as much as many people love Rust.

Even 100k LOC is pretty small for a software project, and likely doesn't need more than a few engineers. The advantage of the simplicity of Go shows up when you have to coordinate across >100 people, many of which are kind of mediocre, and you need all of them to ship features. If everyone in the world were a genius who is obsessed with writing clean code, Rust would be a fantastic language to work in at that scale, but they are not.

For clarification, these are 100k LOC projects where I'm the only software engineer. I've worked on larger projects in C++ with other engineers, and would absolutely continue to prefer Rust as the size of the codebase increases. I guess my point is that Rust scales in a way that few languages do. Go comes close though :)
This has been my primary objection with Go, as well. I wonder if it's just a lack of practice and that I'd eventually git gud, but I find it so hard to flow through code to get a general idea of what's going on. It's basically impossible to use code "paragraphs" to separate logical groupings of functionality because of the `if err != nil` blocks, and leads to a very choppy reading experience. With any non-trivial logic, I've found Go to be detrimental to my understanding of what's going on.
This seems strange for me, though everyone jumps on this. Most Go projects will use something like:

Handle(err)

which may take a line, but isn't the 3 lines everyone refers to, and often has a lot of extra logic.

But you're not comparing the same thing, Go handle errors and act on it, Rust code just "forward" the error.

Control flow wise, Go is much easier to understand than chains of ? and await.

Rust doesn't just forward the error, it forwards and converts the error. ? is effectively semantic sugar for:

`match result { Ok(v) => v, Err(e) => return e.into() }`

This means that the code related to error handling is all in one place, and your business logic can just focus on the happy path.

The Rust code doesn't handle errors. It just propagates them.
Sure, but this code propagates the errors and that has the same problem:

    err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name)
    if err == nil {
     return latLong, nil
    }

    latLong, err = fetchLatLong(name)
    if err != nil {
     return nil, err
    }

    err = insertCity(db, name, *latLong)
    if err != nil {
     return nil, err
    }
In Rust propagating errors is a lot more succinct and easy to do. It is usually what you want to do as well (you can think of Python and C++ exceptions as essentially propagating errors). The special case can be handled explicitly. In Go, you have to handle everything explicitly, and if you don't you can fail catastrophically.

I guess it comes down to what features the language provides that makes it easy to do "the right thing" (where "the right thing" may depend on where your values lie; for example, I value correctness, readability of domain logic, easy debugging etc). And in my opinion, it's easy to do what I consider bad software engineering in languages like Go.

The point of verbosity in Go error handling is context. In Go, you rarely just propagate errors, you prepend them with context information:

    val, err := someOperation(arg)
    if err != nil {
        return nil, fmt.Errorf("some operation with arg %v: %v", arg, err)
    }
It really sucks when you're debugging an application, and the only information you have is "read failed" because you just propagated errors. Where did the read happen? In what context?

Go errors usually contain enough context that they're good enough to print to console in CLI applications. Docker does exactly that - you've seen one if you've ever tried executing it as a user that isn't in "docker" group:

    docker: Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post http://%2Fvar%2Frun%2Fdocker.sock/v1.35/containers/create: dial unix /var/run/docker.sock: connect: permission denied. See 'docker run --help'.
For what it's worth, in Rust, this pretty much translates to

   some_operation(&arg)
      .with_context(|| format!("Some operation failed: {arg}"))?;
or, in most cases, the shorter

   some_operation(&arg)
      .context("Some operation failed")?;
If you forgot to call `with_context` and simply write

   some_operation(&arg)?;
the error is still propagated, it's just missing whatever context you wanted to add.

Whether that's better or worse than the Go snippet is something I'll let the reader decide.

If your error is 5 or 10 levels deep, do you prepend contextual information every time? External libraries typically have good error messages already, why do I have to prepend basically useless information in front of it?

Not to pick on any of these projects, but this pattern is way too common to not have a some sugar:

https://github.com/goodlang/good/blob/3780b8d17edf14988777d3...

https://github.com/kubernetes/kubernetes/blob/1020678366f741...

https://github.com/golang/go/blob/6cf6067d4eb20dfb3d31c0a8cc...

> If your error is 5 or 10 levels deep, do you prepend contextual information every time?

Yes, because each level is (hopefully) there for a reason, and provides a deeper context about where and why the error occurred.

If your contextual information is too repetitive and redundant, it may be the time to refactor your code and remove the unnecessary layers.

Wait that’s interesting and I haven’t formulated it this way.

It reminds me of A Philosophy of Software Design:

The utility of a module (or any form of encapsulation such as a function) is greater, the smaller the interface relative to the implementation. Shallow ve deep modules.

Error handling might be a proxy to determine the semantic depth of a module.

Another perspective: (real) errors typically occur when a program interacts with the outside world AKA side effects etc.

Perhaps it’s more useful to lift effects up instead of burying them. That will automatically put errors up the stack, where you actually want to handle them.

It does the same thing as the Go version. The Go version requires you to pass into the context the data to be sent back to the client, while the Rust version uses the return value of the function as the data to be sent back. The framework then serializes that appropriately.

The only issue there is if you want to return the error with code 200 (which you shouldn't, but it's been known to happen). In that case the Go code and the Rust code will look a bit closer to each other because then you can't use `?` this way (without writing some more boilerplate elsewhere).

It's handling them to the same level the go code is.
Funny, I have the exact opposite reaction to those examples. I look at the Rust code and think "what happens when something goes wrong?" There's no way to tell from the code you gave. The error handling is somewhere else. Whereas, I can see exactly how the Go code is going to behave.
One reason I like Golang is that errors are on your face. Sure, it's annoying, but it really helps robustness.

(I like Rust, C, C++ etc. as well.)

I like that errors are in your face too, but with the caveat that only when they matter. And in Go, the lazy thing will result is a bad time. You can always bet on people being lazy.

Like take a look at this pattern:

    err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name)
    if err == nil {
     return latLong, nil
    }

    latLong, err = fetchLatLong(name)
    if err != nil {
     return nil, err
    }

    err = insertCity(db, name, *latLong)
    if err != nil {
     return nil, err
    }
Is it really necessary to have the error handling explicit in this case? Go through any of your go code. I find this kind of error handling is 90% of the error handling I write.
Only one of the calls in the block is an actual irrecoverable error that you should propagate up to the parent caller.

If you just append if err != nil blocks everywhere without reasoning about the context you're probably doing it wrong.

If those calls can cause errors, then yes, it's necessary to handle them. Maybe you're content to have a contextless stack trace printed whenever things fail, but I like a little order.
No, it isn't necessary. Most go projects implement error handling functions, so you only have single error lines:

Handle(err)

1k, 100k, 10m loc does not change anything because no project depends on all the loc as a single unit, everything is split into modules / packages / class / functions.

Kubernetes is over 1.5M loc and I've not seen problem with error handling.

How many of those are if err == nil … ? Would be interesting to know.
Then what? how many ? there is in Rust code?
> Compare this example in Go:

can you build some utility function check_r, which will panic if found error code, so your Go code will look like:

latlong := check_r(getLatLong(city))