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.
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.
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.
> 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.
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-...).
It's pretty much Go with Option/Result that forces you to handle errors:
f := os.create('foo.txt') or { println(err) return }
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.
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. 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.
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).
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.
So, in practice, neither errors.Is nor errors.As are terribly useful, and %w just gives you a false sense of usefulness.
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).
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.
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.