Yea, I am not really fan of 1000 line stack trace for an error in single line. I like Go's error handling better. Also it is clear you like error handling in Rust/Java etc which is fine. Exaggerating over how bad error handling in Go is hardly productive when a) many people do like it b) people can switch language when error handling primary concern over everything else.
Well as I said people do like stack traces. Hell, I work on language which just has exception stack traces as error handling. I find it hit and miss not godsend when fixing production issues.
However, the topic is errors, not exceptions. Those are very different concepts. Of what use is stack trace information in debugging values that you have assigned error meaning to when not other types of values?
If you had a function
func add(a, b int) int { return a * b }
there would be no expectation of carrying a stack trace to debug it. So what's different about
func add(a, b int) error { return errors.New("cannot add") }
func read_file(filename string) (string, error) {
return "", errors.New("oops")
}
func foo() error {
a, err := read_file("a.txt")
if err != nil {
return errors.New(fmt.Sprintf("read a: %s", err))
}
b, err := read_file("b.txt")
if err != nil {
return errors.New(fmt.Sprintf("read b: %s", err))
}
// do stuff with a and b
return nil
}
func main() {
err := foo()
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
In a language with exceptions:
func read_file(filename string) string {
throw FileNotFound(filename)
}
func foo() {
a := read_file("a.txt")
b := read_file("b.txt")
// do stuff with a and b
}
func main() {
try {
foo()
}
catch (err FileNotFound) {
fmt.Fprintf(os.Stderr, "file not found: %s\n%s\n", err.filename, err.Stacktrace())
}
}
With the current go error handling, you need to add the informations yourself in the string, not as a real data structure.
And before you say "you can add the filename to the error message in read_file()", what if the function is defined in a dependency you have no control over?
An exception is a typed data structure that contains way more informations and value to automate rescuing.
Delegating error handling to a try/catch block with a typed data structure allows the caller to care for certain type of errors and delegate the others to its own caller. With the current error type in Go, what would you do? parse the error message?
This is definitely not the current state of the art. Errors are values with types just like anything else in the language; dynamically populating calls to errors.New() is super weird (the point of errors.New is mostly to create global errors you can compare against), your errors nest without using %w, and there are a bunch of simple libraries that wrap typed stack traces in errors, just like there are a bunch of simple libraries in Rust that exist to make error types compatible with each other.
Your "state of the art" Go code is not really a good example of how that functionality would be written.
If you want to check for a specific error condition, then just define a value for that error and use `errors.Is` to check for it. This works as you'd expect with wrapping: https://go.dev/play/p/rJIlKKSYn9Q
> With the current go error handling, you need to add the informations yourself in the string, not as a real data structure.
This is completely false! If you want to provide a structured error, then you just need to define a type for it. In your example, a Go programmer might use errors.Is(err, fs.ErrNotExist) and errors.As if they wanted to retrieve the specific file path that does not exist in a strongly-typed way, something like https://go.dev/play/p/hdHPLAVbQuW.
> Delegating error handling to a try/catch block with a typed data structure allows the caller to care for certain type of errors and delegate the others to its own caller. With the current error type in Go, what would you do? parse the error message?
Certainly not! I think there is a misconception that "an error is a string" -- in Go, an error is actually any type that satisfies the error interface, i.e. has an `Error() string` method. It can be any type at all, and have as many other methods as you like in order to provide the functionality you need.
> what if the function is defined in a dependency you have no control over?
There's nothing stopping you from writing `throw new Exception(String.format("file not found: %s", filename))` in languages with exceptions either. In both cases, it would be recognized as poor API design.
Regarding stack traces, Go makes a strong distinction between errors (generally a deviation from the happy path) and panics (a true programming error, e.g. nil pointer dereference, where the program must exit). Errors do not provide stack traces since there is no need for them in a flow control context, panics do provide stack traces for useful debugging information.
Your code is wrong. It would normally be written something like this:
func read_file(filename string) string {
panic(FileNotFound{filename})
}
func foo() {
a := read_file("a.txt")
b := read_file("b.txt")
// do stuff with a and b
}
func main() {
defer func() {
if err, ok := recover().(FileNotFound); ok {
fmt.Fprintf(os.Stderr, "file not found: %s\n%s\n", err.filename, err.Stacktrace())
}
}
foo()
}
However, exceptions are meant for exceptional circumstances (hence the name), not errors. A file error is not exceptional in the slightest. It is very much expected.
While you can overload exceptions to pass errors (or any other value), that does not mean you should. Your use of exceptions for flow control (i.e. goto) is considered harmful.
Ok I was wrongly assuming that panic was expecting an error type, in fact it's an interface{}.
> Your use of exceptions for flow control (i.e. goto) is considered harmful
Exceptions are a way to delegate error handling to the caller by giving them informations about the unexpected behavior. It implies that the expected behavior is the "happy path" (everything went well) and any deviations (errors) is unexpected.
This is far from a goto because you can have `try/finally` blocks without catch (or defer in golang).
This is also easier to test. assertRaises(ErrorType, func() { code... })
Almost every Go library I've seen just return an error (which is just a string), you'd need to parse it to assert that the correct error is returned in special conditions.
> It implies that the expected behavior is the "happy path" (everything went well) and any deviations (errors) is unexpected.
Errors are the "happy path", though. Your network connection was lost for the data you were trying to transmit so you saved it to your hard drive instead means that everything went well! Throwing your hands up in the air and crashing your program because you had to make a decision is not something you would normally want to do. If statements are present in most happy paths for good reason. That the inputs presented you with a choice does not remove you from the happy path.
Now, if you made a programming mistake and tried to access an array index that is out of bounds, then there isn't much else you can do but crash. Exceptions are appropriate for that kind of problem. They are exceptional in that they should never happen. Errors, on the other hand, are expected to happen and you can happily deal with them.
> Throwing your hands up in the air and crashing your program because you had to make a decision is not something you would normally want to do
And that's not something you do thanks to try/catch. You just handle the error where it's meaningful to handle it.
The happy path of "make a request" is that there is no network error.
The happy path of "make sure this request is sent" is that you handle the unexpected network error to save the request to disk for further retry.
If the disk is full, you're not on the happy path anymore.
In Erlang/Elixir, there is a philosophy of "let it crash" which is basically "delegate the error handling/recovery to where it's meaningful to do so". For example:
start a supervised process to send a request
if there is an unexpected network failure, let it crash
the supervisor retries on its own
Or:
start a process to send a request
if there is an unexpected network failure, let it crash
monitor the process to be notified when it crash
do something if it happens, like saving the request to disk
A "write_file" function can return many kind of errors:
- file not found (some folder in the path does not exist)
- permission denied
- disk full
When you call write_file, you might want to handle some of those errors, and delegate the handling of others to your caller.
You're still not addressing my main point. How do you check which errors you want to handle and which one you want to propagate with just a string describing your error ?
You don't. That's why Go errors are typed. You use `errors.Is` to check if any of the errors in a chain of wrapped errors is of a particular kind. It doesn't grovel through strings to answer that question.
It is very weird that this subthread starts with "current state of the art".
> And that's not something you do thanks to try/catch. You just handle the error where it's meaningful to handle it.
And it is always most meaningful to handle it immediately, so what's the point of introducing a application-wide goto jump, amid Dijkstra's warnings that doing so is harmful when you're just going to catch right away anyway?
> The happy path of "make a request" is that there is no network error.
If there is a network error, you're still happy. It is not like you screwed up as a programmer. What is there to be unhappy about? The network error input to your function is very much expected and part of the "happy path" as much as any other input to your application.
> if there is an unexpected network failure, let it crash
Network failures are never unexpected. It would be exceptional to never experience a network failure.
> How do you check which errors you want to handle and which one you want to propagate with just a string describing your error?
Why would your errors be strings? When errors are just plain old values like any other it is true that you could resort to using strings to represent errors, but it would be quite unidiomatic to do so. Kind of like how you can overload exceptions to handle errors, but just because you can does not mean you should.
That is also true of most languages. Java (and Javascript in its attempt to copy it) are about the only languages that actually promote using exceptions for errors, and in hindsight I think we can agree it was a poor design decision. That doesn't stop people from trying to overload exceptions in other languages, Go included, but in terms of what is idiomatic...
However, the question was asking what is different about errors compared to other values. The add function above contains an error, yet I don't know of any language in existence where you would expect a stack trace bundled alongside the result to help you debug it. Why would that need change just because you decided to return a struct instead of an int? And actually, many APIs in the wild do represent errors as integers.
> Java (and Javascript in its attempt to copy it) are about the only languages that actually promote using exceptions for errors
Python does. Ruby does. It's not just Java and JS. Go is very open about its approach being a departure.
> And actually, many APIs in the wild do represent errors as integers.
Many, many APIs in the wild are implemented in (or meant to be consumed from) C, which doesn't even have exceptions, so not using exceptions makes sense for them.
Very often idiomatic non-C host language wrappers for those APIs will fire exceptions when they get an error return.
And it's awful. I use EAFP locally (to avoid TOCTOU and the like) at low level interfaces but I don't let it bubble up out of a function scope, because it is a goto in all but name.
I've also been increasingly using the `result` library/data structure. It's incredibly liberating to return an error as an object you can compose into other functions, vs try/catch, which does not compose.
Yes I write python almost like rust, and it's great. Strong types, interfaces, immutable types. It looks nothing like "old school python" but also behaves nothing like it. Gone are the day of "oh it crashed again, fix one line and rerun".
Exceptions should be for exceptional circumstances, not errors.
Edit: I see this is controversial. What do you take objection to? Making your python look less dynamic and more like rust? Try it before you knock it. Python's my favorite language, but I do not agree that many of the common "pythonic" patterns are good at scale.
Maybe I'm just too inexperienced, but I don't see the point of this library.
The linked example shows a really basic sqlalchemy model lookup. What does spewing these new types all over my code get me that returning None or an empty dict/list doesn't without the overhead?
def find_user(user_id: int) -> Optional[User]:
user = User.objects.filter(id=user_id)
if user.exists():
return user[0]
else:
return None
Not only is this idiomatic, it conveys the same semantic meaning. I'm using an IDE, as is anyone else working in a large codebase. I'll be told at the point of invocation that find_user could return None and I need to possibly deal with that.
> Not only is this idiomatic, it conveys the same semantic meaning
No, it doesn’t. For a single computation like this, that pattern is roughly equivalent to Maybe (which contains no information about the case where there is no success result besides that it is absent) rather than Result (which has error information, kind of like an exception, but in the normal return path.)
For a series of computations, the composability of both Maybe and Result means that they are semantically richer.
Couldn't I just return a tuple instead if I wanted to give the caller information on the nature of the success or failure?
Also, both Django and SQLAlchemy throw proper exceptions on bad queries or DB errors, which is probably the right thing to do in the average app using these libraries (the exception bubbles up, getting logged, returning the appropriate http error, etc).
I'm not crapping on this library, mind, I just can't find the use case that justifies it.
How do you compose a function that returns an int or an error with a function that takes an int as parameter?
Yes you can split this in multiple steps, or you can use monads to handle the composition for you, making you type less code, giving more information to the typesystem (mypy for python for example) about what is valid and what's not.
This is a completely different programming style, it's functional programming, aka: "how to make functions by composing other functions".
I find, that the problem with exceptions in Python is not so much that it is a goto, but that they are a bit like dynamic scoping (vs static scoping of variables), so you can not reason about them statically.
An example: suppose you have a function that does some work with the filesystem, and also calls some user-supplied code. (Perhaps because the user can subclass something etc, or because you are getting a callback, the details don't matter.)
Naturally your function might have some idea how to handle its own filesystem trouble, but you have no clue how to handle any filesystem exceptions that come from user provided code.
Definitely not. Especially because early Ruby implementations brought huge overhead when exceptions were used, you were strongly advised to only use exceptions for actual exceptions. Ruby was one of the first languages that really started pushing the idea that exceptions should be reserved for exceptions, even if was just for technical reasons.
Those overhead problems have been addressed and are no longer a problem, but the sentiment has continued to ring true. I agree that doesn't stop people from trying to overload them, as I said earlier. But idiomatic? Not at all.
Ruby has two kinds of exception handling. There is try/catch, this is used for flow control and should not be used for error handling (it doesn't have stack traces). And then there is raise/rescue.
Using try/catch as intended is a bit of an art, but raise/rescue is everywhere. It is absolutely the primary way of handling errors. I think you might be confusing these two.
Stack trace was part of a proposal to add to the error package but it didn't happen. You can use third party errors packages like https://github.com/pkg/errors which wrap errors with stack traces.
People makes it a big deal, in reality it's not.