Gopher Wrangling: Effective error handling in Go(stephenn.com) |
Gopher Wrangling: Effective error handling in Go(stephenn.com) |
1. It's easy to ignore returned errors without any compiler warnings. You have to rely on third party tools such as golangci-lint to report missing error handling.
2. Errors don't carry stack traces with them, you have to rely on third party libraries or custom errors to get that functionality and you will only get it for your own code, not in other libraries you are using.
3. It's unclear who should add context to error messages is it the caller or callee? Usually it gets skipped, leading to useless error messages.
4. Errors are untyped. If you want to decide based on error types, you have to use errors.Is or errors.As, which, surprise, is roughly as expensive computationally as panic-recover. (Source: I did a performance tests on this with Go 1.18) Go might as well add a simpler way to create exceptions. (I wrote a prototype library to that effect a while ago: https://github.com/APItalist/lang )
5. Error messages are too terse and hard to read when using the recommended semantic of "message (cause(cause(cause)))". I'd rather see stack traces, that's much more useful.
6. Most loggers are globally scoped and cannot be injected into code, leading to an all-or-nothing approach. It is not uncommon that you have 3-4 logging libraries as dependencies, which you need to configure separately (if you even can). Also, good luck securing this mess.
Why is that unclear?
Let's say you are writting a db client package and a service around it.
The package's db.Exec(query) method should return and error that will have an error text received from db if any AND\OR context from the package itself.
Then in your service you add additional context to this error if needed.
Finally you log your typical "failed to write HackerNews comment do db with err: %db_package_context: db_error_text_here%"
>6
Not sure about "most" loggers, but I have no problem with zap. Popular, definetelly can be injected etc.
> Why is that unclear?
The usual advice is to follow what the stdlib does. Let's look at an example. Let's say we close a file and then try to set a deadline on it:
f, _ := os.Create("/tmp/filename")
f.Close()
fmt.Printf("%v", f.SetDeadline(time.Now()))
// output: use of closed file
Okay, so in this case, it's the caller's responsibility to keep track of the filename and add the context of what file was already closed, resulting in that error.However, what about the error for trying to write to a closed file?
_, err := f.Write(nil)
fmt.Printf("%v", err)
// output: write /tmp/filename: file already closed
Oh, I see, it's Write's responsibility to add the context of the filename. Huh.This is a clear example of the problem the parent is talking about. The 'os.File' construct knows the filename. Sometimes it adds that as context to errors, sometimes it doesn't. Sometimes the caller needs to add it in, sometimes the callee has already added it.
> Not sure about "most" loggers, but I have no problem with zap. Popular, definetelly can be injected etc.
That may be so, but a lot of libraries use a logger that isn't zap and isn't injectable, or the library doesn't expose a way to inject a logger even if the logger itself supports it. Plus, if you have 3 dependencies, you'll end up with 4 different logging libraries you need to worry about. In the end, you end up having a mess around logging unless you only rely on your own code and don't use libraries.
(Should have phrased the parent better.)
This is one part I like about Go.
You can’t reasonably handle an error condition on a local basis, that’s why exceptions (especially checked ones) are superior. They do the correct thing — either bubble up if it doesn’t make sense to handle them in place, or have them in as broad of a scope as it makes sense with try-catches. Oh and they store the stacktrace, so when an exception does inevitably happen in your software you will actually have a decent shot of fixing it instead of grepping for that generic error message throughout the program (especially if it’s not even written by you!). I swear people lie to themselves with all those if-errs believing they have properly handled an error condition because it took effort.
Also worth noting that Rust doesn’t require you to use errors either; unused errors are a warning in the compiler unless the return type is a result AND you’re trying to access the valid data. This is a better than Go, but not by much in practice.
The error interface doesn’t bother me too much either. Just use errors.Is/As to determine the type of you’re going to do something special with it. It’s way better than having to create unique error/result types for every function.
Who should add context is definitely a problem, but I’ve settled on “the callee”, but in cases where you’re calling something that doesn’t add context you will need to add it in the immediate caller.
Adding context in Go is significantly easier than in other languages (yes, you can use anyhow in Rust, but it’s not considered good practice to put this in library code), and good context largely obviates the need for stack traces anyway. Context is nicer because it can tell you, for example, which loop iteration you were in when things blew up or what the salient parameter values were—stuff you don’t get from a stack trace. Of course, you have to do a bit of work for this benefit, but fmt.Errorf makes this super easy.
Logging also irks me. You can pass a logger like any other data, but mostly people just use global loggers. I haven’t had the multiple loggers problem, but that’s because library authors in Go idiomatically do not add their own logging. What are the languages that do logging well? I’ve had a horrible time with Python (and I think Java but it’s been 10 years).
Is there a language that has error handling "done well " that you like?
However, I wouldn't pick a language purely based on its error handling capabilities. That's treating everything like a nail just because you have a hammer. I'd pick a language that's suitable for the task at hand. Go is suitable for making small(ish) webservices. Over 10k lines of code it becomes really hard to keep things straight. However, that's more due to its very limited scoping abilities.
As far as Go is concerned, you can make the error handling work. In ContainerSSH, we built our own logging overlay, which you can find here: https://github.com/ContainerSSH/libcontainerssh/tree/main/lo... This companion message library has a custom error structure that carries along an error code, which uniquely allows identifying the cause of the error: https://github.com/ContainerSSH/libcontainerssh/blob/main/me... Errors can be wrapped and we added tools to determine, if a certain error has an ancestor with a specific code, allowing for tailored error handling cases. We also added a tool that gathers the comments from the error code constants and adds them to the documentation: https://github.com/ContainerSSH/libcontainerssh/blob/main/cm...
I hope this helps.
It's pretty much Go with Option/Result that forces you to handle errors:
f := os.create('foo.txt') or { println(err) return }
I understand that maybe the language authors in the early days didn’t want to lock anyone into a strict paradigm for how to deal with errors. Like I’m not thrilled about Java’s approach either, but that can never change. But Go is a very popular and established language now. It’s time to fix the error handling mess. There are so many good examples out there to get inspiration from. F#, Swift and Rust have a perfect error handling mechanism.
In my personal opinion it is just not a good language, and I think many judge it based on some false basis that it is somehow “close to the hardware” because it produces a binary. Like, the amount of time it is put next to Rust when the two have almost nothing in common..
It is very verbose, yet Java is the one that is called that, often by Gophers, which is much more concise. It has terrible expressivity, a managed language which is a perfectly fine design choice, yet seemingly every other language with a GC is somehow living in sin.
And still, it doesn’t fail to show up each day on HN.
Many people coming into Go as a new language immediately start bickering about how they want their previous language features in Go rather than accept what Go has to offer and at least try to understand it. This is the equivalent of moving to another country and then refusing to integrate but being very vocal about how said country sucks.
I genuinely appreciate Go's error handling because it's clean and on the nose. It's not hidden behind weird syntax/values that you have to unpack. It's right in your face all the time. When you read the code, it reads cleanly and understandably, even for a beginner. They don't have to adapt to some weird combination of failures / unpacking/choosing something different when there is an error; you immediately see that there could be an error.
And regarding stack traces, wrapping errors will provide you with failure locations to the line code. You can have all sorts of nice output for errors you can later parse and identify.
I get that some people go into Go because of a shift in the company and have no choice; I feel you. For me, it was a life changer. I learned to love coding again after 15 years of writing Java Beans, Spring annotations, CreateMyFriggingObjectFactorySingletonBuilderFactoryBuilders.
2005 called, they want their Enterprise Java™ jokes back.
I still know modern Java codebases where long descriptive class names are a must. So sadly, while I understand your sarcasm, it is not the case.
This is a terrible advice. Wrapping is extremely helpful in providing additional context for the error travelling up the call stack. Without wrapping, one typically ends up with software logging generic errors like "file not found" , which you can't act on because... you don't know where it's coming from. If you skip error wrapping, better be ready to enjoy quality time when production crashes.
Still, this doesn't mean that Go does not have stack traces. It does have stack traces for panics, and you can create stack traces by wrapping errors.
I'm aware that it has stack traces for panics, but those should be rare in practice. Day to day debugging was more tedious in golang.
I've tried to like go's verbose error handling (follow the “happy path”) but the error handling signal to noise ratio is skewed in a way that makes developing in go feel slow and boring.
the "sad path" of error handling is equally as important as the "happy path"
Not referring to you personally, but I've heard that sentiment several times now, and I have not seen anything to back it up (as with several other golang claims).
But the provided example is wrong - it is synchronous, as it awaits the computations to finish; and it is broken, because if either `refresh` call panics the caller will hang indefinitely. So it needs some extra defers and maybe a sync.WaitGroup
Also, example 5 is also somewhat not good, because it uses `if err == context.DeadlineExceeded` where it should've said `errors.Is(err, context.DeadlineExceeded)` as it's a good practice to always assume that exceptions may get wrapped (#4 just mentioned that).
It's definitely the canonical way, but communicating errors via channels feels very.. weird, for the lack of a better word (hence why I don't find it idiomatic).
For #4 wrapping your errors creates pretty and logical error messages for free. It should be done in most cases.
I added .Empty() and .Partial() because if you're returning "string, error" from a function, for example, then "" doesn't cut it for me and instead of checking for "" in the calling function, I can instead check for err.Empty(). This doesn't seem like it's useful, but take that idea and apply it to two additional scenarios: a non-pointer to a struct{} with 10 fields (are they all empty?), and partial return values i.e. the function you called threw a warning and only partially populated the return value. Now the calling function can shift the "is empty" checks to the function that actually constructs the return value (or not.)
Now I can call a function, get my custom error type back, and I can determine if there was an issue and whether or not the value is empty or partial regardless of the type (and its complexity.) This paid me back in dividends the moment I wanted to be able to return a warning and a partial result - so not workflow breaking, but also not everything the caller asked for... it's up to the caller to determine if it has what it needs to continue.
In reality, the only reason why errors in Go work the way they do is that it kept the runtime simpler by offloading checking to the developer. The alternative would've been for Go to support sum types, which would've helped make error handling a lot saner, but that was dismissed because they overlapped a little with structurally-typed interfaces (Go's one really good idea). Oh, and the stupid hack that is 'iota'.
And then Go eventually ended up badly re-inventing most of what exceptions do with errors.Is(), errors.As(), and fmt.Errorf("%w", err).
It's such a hot mess.
it turns out that treating errors the same as normal values makes programs more reliable
lots of people get salty about it, for sure
So no, Go's error handling isn't at all good. 1.13 might've made them less execrable, but it didn't make it good.
> Make sure your logging framework is including stack traces so you can trace the error to its cause.
> For example in a web app you would log the error in the http handler when returning the Internal Server status code.
This is different from how I do it, am I doing anything wrong?
I prefer to make it the bottom layer’s responsibility - so, the first source of the error at the boundary of my application and the library that produces the error, rather than the top level of the http handler.
Go errors infamously don’t include stack traces, so how are you supposed to know where your error originated from if you log it from the top level of the http handler?
All in all, errors-as-values is a calm way to deal with unhappy code paths. A clear renunciation of longjump.
(Golang system-originated panics are excepted from this gloss, but they are defined quite narrowly, and ofc catchable.)
func foo() (err error) {
var x any
if x, err = bar(); err == nil {
err = baz(x)
}
if err == nil {
err = bat()
}
if err != nil {
err = fmt.Errorf("%w doing foo <additional info here>", err)
}
return
}
This feels somewhat cleaner to me, in particular by combining error handling (in this case just a simple wrap) in a single place at the end of the function.add them to flycheck or similar, and go is a fantastic experience.
should they be part of the compiler? maybe. i’m not losing sleep over it.
If fmt.Print doesn't work, you should probably just kill the process.
Though, continuing to spend time generating more output that goes nowhere may not be particularly useful either, depending on what the program does.
Still I think ignoring errors writing to stdout is better as a general default. If nothing else, it’s the most common behavior and is thus more likely to fit the user’s expectations.
This is a good rule for any language, because you always ensure an error is logged once. In Go, you can add additional info from the caller to the Context to log higher level info, e.g. a trace span Id.
- logged (and control flow continues)
- returned (and control flow returns)
- managed (and control flow (probably) continues)
if you log an error, then you should not return it
if you return an error, then you should not log it
etc.
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => // handle err
};
It isn't much different from: file, err := os.Open("hello.txt")
if err != nil {
// handle error
}
I've come to appreciate Go's simple solution as you get pretty much 90% of what you want from an operation that might produce an error (either the value or an error), and the flow control aspect of it is more explicit than with exceptions.Maybe I don't get Monads, but it seems pretty much equivalent for the common use case.
Go's encodes instead "you may or may not have a file" and "you may or may not have an error". Not the same thing, and extremely rarely what you want, IME.
Other languages also do a better job of helping you verify that you actually handled both cases too.
By the way I wouldn't say we need Monad here, I'd be happy if Go could at least encode Sum types.
let greeting_file = File::open("hello.txt")?l
vs file, err := os.Open("hello.txt")
if err != nil {
return fmt.errorf("faild to open hello; %w, err)
}
However, I would argue the distinction isn't just cosmetic. The compiler prevents me from not checking the error and blowing up the application with a nil pointer exception.The TFA even capes this pattern:
type Result[T any] struct {
Value T
Error error
}
and I wonder if we will start to see it more now that generics are in Go.Rust's is good but not perfect. I often find myself missing stack traces (there are solutions but they're not easy to use), and you're still constrained to a single type of error per function, which means you see a proliferation of specialized error types that are mutually incompatible and have to be converted back and forth.
This is true, but the ? operator expands into a form that does `.into()` conversions of the error variants. If there's a implementation of `From`/`Into` between the error type you're unwrapping and the error type on the function, it automatically converts. This is aided by the "thiserror" crate which provides a derive macro that can generate these automatically.
I can't help but feel there is an even greater generalization here. The error problem you speak of actually applies to every type. To zoom in on errors alone may be missing the forest for the trees. It seems what is special is the need to handle values returned by a function, which includes, but is not limited to, error values. It is something 100% of Go users will have to do almost every time they call a function. Even those which do not return errors.
> Swift and Rust have a perfect error handling mechanism.
Within their respective languages they may be a good fit, but those languages are producer centric. Go is consumer centric. That leaves an impedance mismatch. I do think there is something better out there for Go, but I'm not sure that is where we are going to find it.
_how_ you deal with that failure is a separate question
but it's critical that every fallible expression explicitly and visibly demonstrates the possibility of failure
this is in no way an "error handling mess" -- on the contrary, it is basically the only way to produce robust and reliable software at scale
Me personally: I appreciate the simplicity of it. It's a great language for working with in a team. I wish it was more functional, and had better ways to handle errors, but the simplicity of it all was a breath of fresh air using it in a working environment.
* Relatively simple syntax.
* "Good enough" expressivity-- nothing that's considered "missing" has been a true blocker for most projects.
* An easily accessible concurrency primitive, with the bonus that the runtime can choose to execute goroutines in parallel (when able)-- this comes with no required function coloring or split in a code base.
* A well opinionated environment packaged with the compiler: default formatter, default method for fetching remote deps, default documentation generator, default race detector, default profiler, default testing system.
* Decent portability-- can cross compile relatively easily from one platform to another, doesn't require a larger runtime pre-installed on the foreign host.
* "Batteries included" standard library.
* Inertia-- enough of an active community to pull what you need from the Internet, whether it's guides or code.
* A "good enough" type system to catch some errors before they become runtime errors.
* A "good enough" abstraction for operating on data with: structs, interfaces, and methods. With composition being preferred over inheritance, and embedding bringing handy sugar.
No language is perfect, everyone has an opinion, but for many people this is "close enough" to what they prefer to work with.
Gophers may just be a bit more vocal about it.
Most people are not going to care enough (or have the time) to enumerate every single difference between go and Java, and why they prefer the trade off go makes. I use Java professionally, and go where I can, and there is a lot I prefer about go (I won’t pretend it’s uniformly better, though.)
And fwiw, me and you have gone back and forth about this question in many threads previously. I think asking it from such a high level is not going to very effectively get into the details that actually matter to people.
Finally, I also think a lot of what makes go preferable is not in the realm of language design (at least not the algebraic type theory kind) or specific features. It’s much squishier than that, and involves feelings about how teams work and what developers do in practice (and why they fail).
This is not true of Go, and we could just as well see just as many D, Java, C#, Haskell, OCaml posts, yet they combined are not as frequent “visitors”.
Sure.
Difference is go does not have the complexity you can find in Java and quite opinionated. So you don't have to spend as much time working with the language inself and can focus on getting the job done.
Go is not as expressive and some other languages and does not have the same abstractions that make other languages more suited to be used while developing comlex software.
Thing is - in many cases you simple do not need any of it, but need a fast verbose language with good tooling.
Call it Java Light or something.
I fail to see why I would go with go over java, besides.. perhaps some CLI app? With Graal even that can be implemented in Java.
By having an overly simplistic language, you end up pushing more complexity onto the programmer and into the code base. There is no free lunch.
I find it much more sane to solve and express code in Java. You get terser, more to the point code that reflects the underlying logic more clearly, compared to having to read many lines or pages to understand what's going on.
Java learned the right lessons and I'm quite excited to see their structured concurrency approach. No need to pass channels and contexts everywhere to manually manage hierarchies from what I gather.
I believe that uncolored async (Erlang) precedes colored async (python)
Correction: c# was the first language with colored aaync, still way after Erlang
I've even got a library for using futures in Go. https://stephenn.com/2022/05/simplifying-go-concurrency-with...
Another point is that they do share similarities, which might we might now just describe as being 'modern': They're generally procedual -- you organize your code into modules (not classes) with structs and functions, they generally like static linking, type inference for greater ergonomics, the compiler includes the build system and a packager manager, there's a good formatter.
The above are points for both rust and go compared to C/C++, Python, Java, etc.
So why do I like go? I think mostly it's that it makes some strong engineering trade-offs, trying to get 80% for 20% of the price. That manifests itself in a number of ways.
It's not the fastest language, but neither is it slow.
I really dislike exceptions because there's no documentation for how a function can fail. For this reason I prefer go style errors, which are an improvement on the C error story. Yes it has warts, but it's 80% good enough.
It's a simple language with batteries included. You can generally follow the direction set and be happy. It leads itself to simple, getting-things-done kind of code, rather than being over-abstracted. Being simple also makes for great compile times.
That I agree with.
But Go is anything but modern on a language front. It shares almost nothing with Rust, which actually has a modern type system (from ML/Haskell).
Even if we disagree about exceptions (I do like them as they do the correct thing most of the time, while they don’t mask errors, but include a proper stacktrace), go’s error handling is just catastrophic, being an update from c which is even worse is not a positive.
I’m not a go developer. How does go document how a function can fail?
A Java developer can use checked exceptions so that some information is in the signature. For unchecked exceptions the documentation must explain.
I guess in Go the type of the error return value provides some information but the rest needs to be filled in by the documentation, just like the Java checked exceptions case.
To me, there were a lot of obvious reasons to choose Go in a corporate environment where my success is graded on my ability to deliver and the quality of what I deliver.
For every popular Google project you read about there are many flops, including ones they appear to develop in spite of.
I think Googles stamp gives it some legitimacy, but I think the much likelier explanation is that the values in Go and its design speak to frustrations a lot of people actually have. This thread is full of people arguing in favor of gos error handling. You can dismiss them all as cranks or sheep if you want, but I think that would be misunderstanding something.
I suspect it's a bit more than the Google stamp of approval - the innate simplicity of the language is attractive, it has almost Python-like simplicity without the performance concerns, and it has a "one true way" approach to formatting that settles any bikeshedding arguments in dev teams.
It's not my personal favourite language - the poor error handling discussed in this thread, the mess of the package management system (I mean, Python has a mess of a package management system, but that's more forgivable in a language from the 1990s, not the 2010s), the lack of decent standard library collections, etc. But I can see the appeal.
Go is a language made by Googlers, so its design helps with Google problems. And many of Google's problems are ones of scale.
* Go is straightforward to read. Any reasonably-competent college graduate should have little trouble understanding it and be able to become productive quickly.
* Go compiles into completely static binaries. You don't have issues like "oops, the build system runs CentOS 7 but we're deploying it to Ubuntu 18.04 and their libc's aren't compatible." With containers, you can copy many Go programs into `FROM scratch` images and they will work fine, greatly reducing the attack surface area.
I used to dislike for Go for similar reasons to you and others, but after using it for a few years at $employer, I've come to appreciate its merits. Sure, it can be a bit annoying to write
if err != nil {
return err
}
again and again at first, but I just type 3yy7jp to yank+paste it as needed. You could also configure some editors to detect when you type `if err` and generate it automatically. It's also not uncommon for editors to fold the lines down into a single line.Errors in golang do not have stack traces, and wrapping errors is just an error prone way of manually (and apparently nonperformant way of) generating stack traces.
I had to add `CGO_ENABLED=0`
* sometimes.
It can also dynamically link stuff on occasions, depending what options you use.
You can write huge code bases with it, the tooling is good.
You can use a moderately skilled work force to achieve good results. When some team members leave, you are not left with some wizardry code base behind.
If you view the language as a safer super shell script, it becomes more obvious.
Go was hyped in the beginning, but I get the impression that now it isn't. The most persistently hyped language here is Python. Fortunately, we see more Elixir, Ruby and Go posts lately.
For me, it's:
1) channels (and goroutines, of course)
2) explicit error handling (panics are actually fatal, in contrast to exceptions which are often even used for flow control)
3) easy (cross-)compilation - just go build
And probably a few more reasons I can't remember at the moment. It's just fun to write Go!
The more generic approach to error handling, using do monads (in Haskell and Scala) require some sort of do-notation (Scala's "for comprehensions") to be convenient. And I think this is a step that most mainstream languages are still too afraid to take. I would personally be glad for mainstream and some sort of monadic comprehension to become a mainstream language feature the same way closures became, but this is far from the reality.
So we are left with special-case solutions for specific problems like error-handling, iteration and nullability. Kotlin made it very easy do deal with nulls without a much ceremony (this is slightly more troublesome in Rust or Scala, for instance), while Rust chose to make error handling easier. Of course, they both repurposed the same operator ("?") for this purpose.
What Kotlin does with nullability and what Rust does with error-handling are both becoming quite palatable for mainstream language developers, but it's quite late to change language which have used exceptions (like Kotlin, Java and Python) or error values (like Go) to use monads right now. Entire APIs are built on the existing (and insufficient) error handling scheme.
For instance, we're using Arrow's Either on most new projects at work, but still have to deal with a lot of existing Java APIs, which are exception-based.
This is not a "problem" as much as a conscious philosophical stance:
Errors don't actually exist, only conditions that you dislike. All the error handling you need is if/else. Everything else is unnecessary emotional baggage on some conditions that should not pollute your language. And even less so, gasp, your types (!).
it obfuscates the control flow, specifically the value that is actually returned
early returns on errors are good, not bad
edit you want
func foo() error {
x, err := bar()
if err != nil {
return fmt.Errorf("bar: %w", err)
}
if err := baz(x); err != nil {
return fmt.Errorf("baz: %w", err)
}
if err := bat(); err != nil {
return fmt.Errorf("bat: %w", err)
}
return nil
}And I think you're going to have problems with this pattern if you join a team using Go in an organisation. The `if err != nil` pattern is the norm, and everyone's used to it (and the regular cadence of Go code; "do the thing, check the error, do the thing, check the error" is very readable).
func foo() (err error) {
var x any
if x, err = bar(); err != nil {
goto fooError
}
if err = baz(x); err != nil {
goto fooError
}
if err = bat(); err != nil {
goto fooError
}
return
fooError:
return fmt.Errorf("%w doing foo <additional info here>", err)
}
Or: func foo() (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("%w doing foo <additional info here>", err)
}
}()
var x any
if x, err = bar(); err != nil {
return
}
if err = baz(x); err != nil {
return
}
err = bat()
return
}
Seeking out different patterns is obviously most applicable in cases where error handling is actually doing something useful or more complicated than just wrapping the error.(My comment was meant to spur first principles discussion from intellectually curious folks, not "nobody does it that way" or "don't do that" edicts. Much of the argument against adding additional language features for error handling is that many of them aren't any better than what can be accomplished already, using existing syntax but different code style conventions. The goto pattern in particular is found all over the stdlib.)
in generated code, sure -- that's why it exists, to support codegen
it's sometimes abused to manage for loop control flow
but the stdlib is definitely not some platonic ideal -- it's a decade+ old code base which has suffered all of the indignities of organic growth
it's full of bad code and terrible anti-patterns
(good stuff, too!)
This seems to be a significant problem in general, because gophers want clear direction (after all the language was created specifically for… choices to be limited) so they take quips as gospels, but robpike, rsc, etc… take them more as suggestions / guidance (90/10, possibly even 80/20) to be moderated by taste.
I don’t remember which one but I think it was robpike who expressed frustration on one of the recently popular issues / proposals, because the proposal was essentially to legislate one of the more common quips, and they like being able to break those when useful or convenient.
I think there was also something similar to your exploration here with zero values, where despite the quip that you should “make the zero value meaningful” multiple standard library modules will straight up panic if fed zero values (a classic example being the File struct, `File.Name()` panics and pretty much every other method returns ErrInvalid, so a zero-valued File is useless, actively problematic, and the source of unnecessary overheads).
An other fun one is that you can’t call IsZero on a zero `reflect.Value`, and the error message is quite amazing:
panic: call of reflect.Value.IsZero on zero Value
You need to carefully read the doc and notice that th middle paragraph documenting Value itself says:> The zero Value represents no value. Its IsValid method returns false, its Kind method returns invalid, its String method returns “<invalid Value>”, and all other methods panic.
There are two significant problems with the API:
An errgroup.WithContext today cancels the Context when its Wait method returns, which makes it easier to avoid leaking resources but somewhat prone to bugs involving accidental reuse of the Context after the call to Wait.
The need to call Wait in order to free resources makes errgroup.WithContext unfortunately still somewhat prone to leaks. If you start a bunch of goroutines and then encounter an error while setting up another one, it's easy to accidentally leak all of the goroutines started so far — along with their associated Context — by writing
it's important that i see the `return` keyword in the source code
This differentiates go, rust, zig, odin etc., from languages like C++, Java, C#, Python etc. I think it makes sense to describe that difference as one of modern sensibilities.
inspecting the err value returned by a function call is in fact error handling
the point of this design is to keep control flow "on the page"
exceptions do not keep control flow "on the page"
This is great since it's enforced on a type-level that every result or option has to be unwrapped before it can be used. Unwrapping is explicit, the code panics if something goes wrong, and the function signature makes it easy to see what sort of errors to expect (especially when using custom error enums for the Err value).
There is one major caveat here, which is that Rust's type system only forces you to check for errors if you plan to use the return value of a function.
An example: You have a function write(), which writes to a file, and which returns Result<(), Err>. (Here `()` is the zero-sized type, ie. an empty tuple).
If you call this function in your code, it might return an error, but you can just silently drop this error. Your linter is going to complain, though. This issue could be fixed with proper linear types (ie. types which must be used once), but adding them to the language would afaik be really difficult at this point.
But other than that Rust is doing pretty well, honestly, and (imo) Go would be a significantly better language if it had a proper Result type, and just used it for all of its error-handling. Sadly, we can't change history.
if err != nil {
return err
}
If is equally useless as "letting if fly" in Java. However, in Go, developers seem to have more of an awareness for the need for proper error handling, which is not the case with most Java devs. So your problem is cultural, not technical.I, for one, really like the idea of checked exceptions, forcing you to document and handle your exeptions. However, that idea has turned out to be too tedious for most people, so it didn't catch on.
However, part of the issue is the tolerance or maturity necessary for being able to handle a different opinion. That someone gives a different preference, for instance Vlang or Odin (that has its own views), can make evangelists or "corporate machinery" upset. Then we can witness mob or anger downvoting. This then limits the debate or creates more of a barrier for different opinions to want to contribute or ever be seen.
[1]: "Is Vlang better than Golang in error handling?" (https://towardsdev.com/is-vlang-better-than-golang-in-error-...).
Actually, I'm reminded of certain errors I've seen along the lines of "exception raised while handing exception".
if so, get the error and evaluate it -- like if json.Marshal fails in your http.Handler
if not, (shrug) -- like (maybe) if your fmt.Printf fails
panics are for core assertion violations, not an ersatz error reporting mechanism
The thing is in real life you have to deal with this kind of code most of the time. Because most of the time you deal with the old code or people who are used to some patterns you have to use too because you are part of the team.
Go enforces (kind of) a more barebones approach by default.
PS: I think it's poinless to compare two languages. We should rather compare real practices and read examples.
>> You can find a gun to shoot yourself in the foot in any language.
Sure, but finding that one isn't particularly hard, which is what GP was responding to
If anything, they have become successful in spite of C.
There's no magic to it. Errors are values, so it's a part of the function signature that there's an error code to check. In C++ any function can throw an exception and there's no way of knowing that it wont.
It's true that go doesn't document what _kinds_ of errors it can throw, but at least I know there's something to check.
error handling (as expressed here) is equivalent in priority to core business logic
it absolutely belongs in source, because it is important for developers to see
Could you please elaborate more on this?
In larger projects, especially with lots of contributors, I found that you'll want to practice some level of defensive programming because code review can only catch so many errors. In other words, you'll want to make sure that parts of your program only talk to each other via "sanctioned" APIs. Go makes this difficult because anything in the same folder can access data in structures defined in the same folder.
For example, the Kubernets backend for ContainerSSH (https://github.com/ContainerSSH/libcontainerssh/tree/main/in...) has two parts, one dealing with the integration with the rest of ContainerSSH, while the other deals with Kubernetes. In order to provide some level of separation, we added an interface in the middle to document the package-internal API somewhat: https://github.com/ContainerSSH/libcontainerssh/blob/main/in... However, this is suboptimal and leads to unnecessary boilerplate code.
You could, of course, forego the interface, but that would mean that code reviewers would have to make sure nobody is taking the easy route and messing with the internal state of a struct that they have no business messing with. In other words, you'd want some level of encapsulation which is not easily doable in Go.
In other languages you have much more granular visibility options, either in an OOP way (private, protected, public, package-private, friend classes, etc), or by providing things like header files that describe an interface between parts of the application.
Stack traces can also point to code that is not in the master branch anymore, so it's not like they are immune from it. In both cases (Java and Go), you can git-checkout the deployed commit and then locate the error.
I guess we just have very different experiences. I worked with a large Java codebase in the past, and there is no way in the world I am going back to Java now that I tried Go.
body, err := readFile(fileName)
if err != nil {
return "", err
}
If the error returned by readFile is just "not found", it would indeed be very vague. This is still poor error handling, in my opinion, since a lot of the context is lost. Yes, they are "handling" the error, but only enough to stop the linter from complaining. I prefer something like this: body, err := readFile(fileName)
if err != nil {
return "", errors.Wrapf(err, "readFile(%s)", fileName)
}
This would result inan error like this: readFile(file.txt): not found
This way I get all the context I need to know where the error happened and the arguments that caused the error.If the error happened not because of a function call, but, say, an invalid value, instead of this:
if n < 10 {
return fmt.Error("invalid argument")
}
Do this: if n < 10 {
return fmt.Errorf("invalid argument n=%d is less than 10", n)
}
In languages like Java, it feels very tempting to let errors bubble up and then let the stack trace take care of explaining what went wrong, but it is often insufficient and may result in hours of debugging. I feel like Go makes it very easy to add extra context to errors, and if you foster the practice of adding context every time you return an errlr, it will be much richer than a stack trace.users should not see stack traces
> There's some tricks that have to be done to make them work in a eager evaluation context...
Monads have nothing to do with laziness, though. In Haskell, IO actions are used with laziness and to work around purity, and IO actions form a monad. But that's just one instance of monad.
I believe what I had originally told that makes them not monads is that because Rust goes through some convlutions to fake the laziness of Haskell monads, it makes them not be typed like Haskell monads.
For example, the declaration of the `.flat_map` on an iterator is actually `fn flat_map<U, F>(self, f: F) -> FlatMap<Self, U, F> where Self: Sized, U: IntoIterator, F: FnMut(Self::Item) -> U`, which uses a "do-er" struct instance of `FlatMap` and is itself another iterator. Evaluating the entire monad-ish iterator combinator chain with something like `.collect()` or `.last()` or something is what triggers the evaluation.
OP is correct. If it has a bind, pure and map (which it does!) then for all intents it forms a monad.
Now the fact is you can't _manipulate_ monads in Rust easily (see kinds and discussions wrt GAT), but nonetheless monads are present in all languages with ADTs in the same way that rings are present in all languages with addition.
So even in weaker languages you get monads the difference is that you can’t abstract over them. Your Haskell code can take a Monad, and work with those, most other languages (I believe Rust included) can’t handle them uniformly all over the language. But they can have types that are mathematically speaking monads.
No, one real issue that can happen if one is not careful (but fortunately linters help) is variable shadowing which may lead to some errors being unchecked.
In general, I find that error handling is not as horrible as some seem to purport.
Only if the function returns more than the error. You can happily do this without errors:
fh = os.Create("/some/file")
defer fh.Close()
Needless to say, this is a terrible idea if the underlying filesystem can give you an error at close time, e.g. on NFS. The correct way to write the above code would be: fh = os.Create("/some/file")
defer func() {
if err := fh.Close(); err != nil {
// Do something with the error
}
}()
Yet, I see a lot of the former and very few instances of the latter.I think it's mostly an API legacy mistake. Close should probably return (bool, error).
Probably a remnant of coding in C wrt sentinel values.
Nope. Go errors on dead variables, not on dead stores.
And because idiomatic go tends to reassign errors to the same variable if you have multiple error-returning functions in the same scope any one of them being checked will make the compiler happy.
You also do not have to assign the error at all, you can just ignore the entire thing.
Rust might force you to access a value. Big. Fucking. Deal.
None of these things were technically bankrupt (including Go), but their adoption curve would not have been what they have been without Googles name on it.
There's likely other OSS that's technically better, but did not have the network effects google-backed software had from day 1.
Assuredly not C++, nor Rust... No javascript, swift has become more complex apparently, not haskell... Not C... OCaml? No it has a weird syntax, French people and wanting to be different... Etc...
So no, Go has genuinely appreciable qualities that are not found anywhere else :o)
This is equivalent to the problem of throwing new Exception("some message") or new RuntimeException("some message") in Java, to be fair. But this is more rarely done there in my experience, especially because people are more used to defining new exception types since the stdlib has many examples of that, unlike Go (which all returns `error` for all error types).
You still need a stable error to compare against in errors.Is. fmt.Errorf is not going to provide that if the message is dynamic - which is almost always.
[0] https://doc.rust-lang.org/book/ch09-02-recoverable-errors-wi...
Sure, I just don't see this as a problem.
As a result you have
a) a number of third party packages to chose from depending on your needs\opinions
b) you have a more verbose codebase, some people find it harder to deal witih while I find it easier to deal with.
>compared to having to read many lines or pages to understand what's going on.
Different people different ways of thinking I guess.
In my eyes Java code looks to much like a specification in for of a code. Easier to do a code review but harder to actually understand how it works. And I personally need this dive into internals to actually feel confident about the code.
>Could you elaborate on exactly what complexity in Java you're referring to?
I don't have too much experience in Java, but from what I've seen - Java has too many abstractions and OOP for the sake of paradigm and nothing else.
UPD: adezxc's comment is a good addition to mine
I don't want to learn about Gradle or Maven to understand how a package is working, I'd rather do it in code.
Consider even the current "Hello, world" example in Java (Yes, I know about the proposal about simplifying it), it is tedious, why would I need to understand public/private and classes before launching a simple program?
I fully agree it is a terrific piece of software, especially for industry-grade applications, yet it just isn't attractive.
Main thing IMO, is that you can start out writing pretty good Go code after 24 hours and just improve on your skills as a general programmer. With Java, after a few months you would still need to know about some methods or OOP tips/tricks, design patterns etc. to become proficient.
Many of the design patterns are useless bullshit, that is long superseded by a language feature, so that point doesn’t stand imo.
Go also has public/protected, it is just case-specific. If anything, that makes it harder to understand, how should I knew that Asd is different from asd beforehand?
Go reserved keywords: break, default, func, interface, select, case, defer, go, map, struct, chan, else, goto, package, switch, const, fallthrough, if, range, type, continue, for, import, return, var
Java reserved keywords: abstract, continue, for, new, switch, assert, default, goto*, package, synchronized, boolean, do, if, private, this, break, double, implements, protected, throw, byte, else, import, public, throws, case, enum, instanceof, return, transient, catch, extends, int, short, try, char, final, interface, static, void, class, finally, long, strictfp, volatile, const, float, native, super, while.
By reading docs or some kind of "Go by Example"? The same way you learned the difference between `private` and `protected`.
a_method().context("Failed to complete the work")?;
a_method().context(FailedToCompleteTheWork)?;
[0]: https://docs.rs/anyhow/latest/anyhow/trait.Context.htmlyou want to be able to read code and see a single control flow
? subverts that core requirement
Therefore, you can always see see the control flow of a function.
Moreover, you can go even further if you really really really want a single control flow. You can write a clippy lint to disallow early returns (ban "return" keyword) and question marks. That said, I really don't think this is a good idea. The "return" keyword exists for a good reason.
ideally, control flow goes 'down' via func calls, and 'up' via return statements
this is the "trivial to see pattern" -- the code as written
exceptions subvert those simple rules, they say any expression can potentially be a return statement, and recursively so!
So, in practice, neither errors.Is nor errors.As are terribly useful, and %w just gives you a false sense of usefulness.
The way code usually looks like is this:
func foo(arg int) error {
if arg < 0 {
return fmt.Errorf("expected >0, got %d", arg)
}
if arg > 8 {
return fmt.Errorf("expected >8, got %d", arg)
}
//do stuff
}
or func foo(arg int) error {
if arg < 0 {
return errors.New(fmt.Sprintf("expected >0, got %d", arg))
}
if arg > 8 {
return errors.New(fmt.Sprintf("expected >8, got %d", arg))
}
//do stuff
}
In either of these cases, a caller of foo() can at best parse err.String() to see why foo() complained. But there is no way that errors.As or errors.Is help with the majority of errors returned by Go programs. result, error = fn(...)
calling this function should yield to the caller two possibilities, somehow: a success value _or_ a failure errorthe important thing is that in both cases, the control flow is visible in the source code as written
result, error = fn(...)
if there was an error, ...
if it was successful, ...
when an expression fails, you want to see the consequence in-linethe success path and the failure path are equally important
and in the case where returning both is OK, then documentation makes that clear
this is not difficult
in practice, go code bases that are subject to even minimal code review have basically no ignored errors
More generally, user error messages and dev error messages are just fundamentally at odds, there is no way to have messages that are good for both cases. User error messages should explain what went wrong, and what they can do differently to workaround the issue (if anything). Dev error messages should explain what the code was doing when something went wrong, to help with figuring out what code needs to be modified.
go code tends to be robust because the authors of the code and the community are the sort that worry about each err value, like Linux and like Python and like C itself.
go error messages are clear to users without being cryptic
The proper error message for a user in this situation would be something like "Couldn't read required file /home/user/program-name/abcd.xml. Please try to create the file by hand." or "Couldn't read list of entities. Try reinstalling the program or contact support@program-name.com".
What I like better about Rust, and what I think most people are actually complaining about with Go, is that syntactic sugar like the ? operator and functions like unwrap(). It’s a lot more concise and your application logic doesn’t get lost in verbose error checking code.
That's kind of the point. The type system should be powerful enough to disallow those cases then.
In practice, I've seen both, always accidentally. I've also (more commonly) seen a lot of confusion and annoyance around:
Okay, so this has to return a pointer for the error case, should the caller check that? If not, how do we square that with checking for nil pointers being generally a pretty good rule? If we do check, our unit test coverage has a blemish for every call since nothing can hit that. If we skip it being a pointer, then it's a zombie object.
It's just a lot of cognitive load and bikeshedding around an issue that shouldn't exist.
Like...this makes no difference to my ergonomics at all. In many cases it's arguably worse because now 1 token is potentially representing two very different types I want to deal with.
The primary benefit to me seems like it's more to do with the ease of generic code handling: everything can be Results, and then I can evaluate them all to see if any of them are errors, which in turn makes failing out of many different operations easier - I'm not handling a file type, a string, some numbers etc.
> In many cases it's arguably worse because now 1 token is potentially representing two very different types I want to deal with.
Yes, that's what an enum/sum type is? That's the whole point.
I just don’t find the point about what is possible interesting. The other trade offs around readability, ergonomics, and so on seem more impactful.
I'm not sure one is better than the other, just different tradeoffs.
The caller trying to pretend that the success object is there isn't a freedom the caller gets in the current system, it's an artifact of the type system not being powerful enough to encode the situation accurately.
In practice (for the success object) it means you need to check for a nil pointer, make sure you don't use a zombie object, or just rely on an assumption that it's not nil, depending on which poor choice the producer function went for.
If you have a function that can return both an object and an error, there still should be a way to represent that (exactly the current way). Having Sum types would just allow a way to represent the common case accurately.
But doesn't know how the return values will be used by the caller. What is perhaps lost in this is where Go says that values should always be useful?
> If you have a function that can return both an object and an error, there still should be a way to represent that (exactly the current way).
Exactly the current way is what is said to be deficient, though. A function of this type is naturally going to return a file every time because a file is always useful, even when there is failure. Whereas Result assumes that you won't find the file useful when there is failure.
If you know the callers you can discuss if the file will ever be useful to the callers who use it under failure condition. Always useful does not mean always used. But Go, no doubt of a product of Google's organizational structure, believes that you cannot get to know your callers. You have to give them what you've got and let them decide what and what isn't important to their specific needs.
Tradeoffs, as always.
By day I work with a team in a language that sees errors ride on the exception handling system. Staying within the original example, I see code like this all the time (too often, even, but that's another topic for another day):
try {
file = getFile()
} catch(/* ... */) {
fileUnavailable()
}
Here, the assumption of getFile that the caller wanted an error was incorrect. A Result-using language would end up in a similar place.Idiomatic Go says leave it to the caller. Like above, when only wants to know if there is "file or no file" without concern for why there is no file, then:
file, _ := getFile() // The second return argument is an error.
if file == nil {
fileUnavailable()
}
I doubt either way makes much difference in this contrived example, but the difference shows up when it extends out into real code. There are plusses and minuses to each way of seeing the world. Tradeoffs, as always.; doesn't do this, afaict
when you're writing imperative code it's important that control flow (return) is explicitly visible
> ; doesn't do this, afaict
It's chaining together transitions in the state machine that is your program.
> when you're writing imperative code it's important that control flow (return) is explicitly visible
Control flow is visible with `?` or other do-notation variants. If I want to error out in a `Result` context, I explicitly return `Err(bad stuff)`. And if I don't, I explicitly return `Ok(return value)` instead. If I want to introduce a new asynchronous value in js, I explicitly call `new Promise`. And so on.
What's not visible in a do-block is the implementation of control flow. Which is fine, because this isn't the code that controls it - `Ok(Err(x))` is reduced in exactly the same way no matter what `x` is or where it came from. Traditional imperative code is the same way: the runtime system always works the same way, no matter which statements you ask it to execute.
If you do choose to expose the underlying mechanisms of your control flow everywhere, you get continuation-passing style, which is useful in small doses but more or less impossible to reason about at scale.
const true = false
in golang
The number of reserved keywords is not a bad thing. For example, I constantly missed `final` when I worked in golang. Just because a keyword doesn't exist does not make its usecase disappear. Same with other features like `enum` (extremely useful) and visibility rules. golang only has package private and public, not nearly as granular as one needs in practice, not to mention generating large CL's when changing the visibility rules of a function, as opposed to simply having a 1 line change. Sure you can ignore those use cases, but it doesn't mean that their usefulness disappears.
I'm not saying reserved keywords are bad. I'm saying there's much more to learn about Java to learn programming, Go is limited with its' keywords and 'features', which often results in more LoC, but makes it super easy to get going, run into general programming problems like using a variable instead of a reference to it, etc.
In Java, you spend much more time learning the features of the language itself, even simple things like (s)Strings are not easy to understand, then add classes, inheritance, UTF-16, adding other libraries, build tools, JUnit and many, many other things that are given to you with Go.
I don't see why learning language features is an issue. The language offers features to provide more correct and more expressive code.
golang does not solve the variable vs reference issue at all by the way, and in fact, introduces weird edge cases, such as nil interface not being equal to nil.
Why do you say Strings not easy to understand?
types do not enter the discussion
(spoiler: no)
If the language "addresses" it by convention then it is not addressing it at a language level at all
first()?.second()?.third()?
good: a = first()
if a failed, handle that error
b = second(a)
if b failed, handle that error
c = third(b)
if c failed, handle that error
yield cIn Rust/Haskell I can write a type with three values like Success(file), PartialSuccess(file, error), or Failure(error). Callers must then handle all of these. In Go I always have four cases for a simple function including those three and the final case of neither file nor error. Most Go callers will not handle the case where err != nil and file != nil and often the case where err == nil and file == nil will cause a panic and crash the program.
There was a tradeoff for this, but in this case the tradeoff was entirely in making the Go compiler simpler at the cost of making the Go language weaker and Go code more error-prone.
It doesn't matter how it will be used by the caller. If I'm writing a function that can fail, no magic in existence can create a success object out of nothing, especially one that "should always be useful". At that point you're stuck either returning a nil pointer or a zombie object (along with the error).
> Exactly the current way is what is said to be deficient, though.
It's deficient because it's modelling the wrong (in the common case) thing. I'm saying if you're in the uncommon case and that actually _is_ what you're trying to model, then you still can.
> A function of this type is naturally going to return a file every time because a file is always useful, even when there is failure.
What? No. It's not useful if there is no file, if the error is "wtf, that file doesn't exist".
You might want to look at what Go does with zero-valued File. At best it ignores them and returns an error, at worst it panics.
There is no situation where a zero-valued file is useful.
like, it's not as if the go type `float64` is also a go keyword
but i guess the java type `byte` is a java keyword? according to https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_k...
but i'm sure i won't convince you of anything here, so good luck to you
I want to learn programming before becoming an expert in the language. I feel like it's easier to do using Go. In Java you have to become a bit of both to be efficient.
let _ = fs::mkdir_all() // Error ignored, Rust will not complain because you explicitly assigned to _
Or if the function returns something I need but I don't care about the error: let Ok(file) = get_file() else {
file_unavailable();
return
}
upload_file(file);
Or this: let file = get_file().unwrap_or_default()>
file, _ := getFile() // The second return argument is an error.
if file == nil {
fileUnavailable()
}
That very often doesn't work in Go. Most functions which return errors offer no guarantee whatsoever about the return value if there is an error. No one would consider it a breaking change to modify the return in case of error. And many functions return a struct, where Go offers no way to compare two arbitrary struct values for equality, or to check if an arbitrary struct value is that struct's "zero value".So no, Go does not recommend (or even endorse) this pattern.
Ah yes, like it "never happened" in the Kubernetes project?
- https://github.com/kubernetes/kubernetes/pull/60962
- https://github.com/kubernetes/kubernetes/pull/80700
- https://github.com/kubernetes/kubernetes/pull/27793
- https://github.com/kubernetes/kubernetes/pull/110879
I can find tons of these, just by searching any larger Go project's Github.
Here's one from docker too: https://github.com/moby/moby/pull/10321
What about from CockroachDB? https://github.com/cockroachdb/cockroach/pull/74743 Even the linter missed this one!
It happens in personal projects, small teams and big ones too. The linter errors are too often ignored by developers. Also, it should help embrace in developer the need to explicitly name some error to be ignored.
CockroachDB is a better example.
OP demonstrated that failing to handle errors does in fact happen in the wild, while in Rust the compiler enforces that you must handle them. The question is whether this difference between the systems has practical implications, and it seems it does. The existence of community idioms that help avoid the problem doesn't change the fact that the languages themselves are meaningfully different.
If you knew for sure that it virtually never happened, maybe not. But you don't. At best you know that a few particular individuals you're familiar with never mess it up (but then again, consider the people who "virtually never" write incorrect C). You can't trust random packages you haven't vetted, and you certainly can't trust code written by your junior software engineers.
> The other trade offs around readability, ergonomics, and so on seem more impactful.
Sum types have significantly better ergonomics. `(Result | null, Error | null)` takes four branches to handle properly, whereas `Either Result Error` takes two. And of course things get much worse once you're more than one layer deep, which in a language as procedural as Go you almost always are.
From years of using C's switch statements, I'm not going back.