When I'm writing Go, it returns where it says "return".
Beyond that, you don't need to care if the function will terminate early because of an exception - as long as all necessary cleanup is handled regardless of exit reason, you don't care that the function will finish early if an HTTP request fails before using the value of the request.
And usually you only want to catch and re-throw exceptions around module (in the broad sense) boundaries. If you catch and re-throw exceptions in every function, you're either losing lower-level context completely OR you're leaking implementation details anyway if you're chaining up the old error.
In Go, the error needs to be manually propagated through every function, with enough context to understand what's going on. In Java, the error needs to be manually handled at every module boundary; function-level context is given automatically by the call-stack.
The fact is that even code that does no resource acquiring could throw an exception. It might not even be a documented exception. And when it throws that exception that you may or may not have been expecting some code somewhere is going to have to figure out what to do. In java the only real way to communicate an error is to throw an exception. For some reason culturally Java has decided that it's too much work to distinguish between errors that should crash the app and errors that should be handled by the caller in some way. As a result when an exception outside of your code happens in production and you read the exception in your logs it will:
1. Not have a significant part of the information you need to debug the problem.
2. Be in code you may not have easy access to go read.
3. You will probably have no idea why it's happening.
Eventually for a long enough lived codebase you will have encountered and documented all of these in a runbook somewhere or you won't and everyone will have to have learned them over and over again.
If you are on an exceptional team you'll have caught and then wrapped or handled these exceptions to make operating the software in production less painful. But all of it could have been avoided if java had chosen a better path.
Make it impossible trap runtime exceptions. They just crash the program. Make trappable exceptions checked exceptions. The code would get more verbose. But the operational pain would have been vastly reduced.
Errors are a part of your API. Unchecked Runtime exceptions pretend like 50% of your API surface doesn't exist.
If these errors are things that the system administrator can handle, they will find them in the logs, the cause of the error will hopefully be clear enough if enough effort was spent on making it that way, and the sysadmin will fix the cause. If the error is something that the programmers never anticipated, nothing more can be done and a bug needs to be open with the developer. In some cases, even though the problem was with the environment, error reporting may have been fudged and the sysadmin may not be able to understand what they are suppose to fix - bad error reporting.
I fail to see how exceptions hurt with any of these cases. I have no more or less information about error cases seeing `func foo() error` or `void foo() throws Exception`, so neither helps me know what errors I should handle. It's no harder in Java to add context to errors when you really want/can - in fact, it's easier:
try{
stuff1();
stuff2();
} catch (Exception e) {
throw SpecificError("I was doing this when something else happened", e);
}
vs err = stuff1();
if err != nil {
return fmt.Errorf("I was doing this with stuff1 when something else happened: %w", err)
}
err = stuff2();
if err != nil {
return fmt.Errorf("I was doing this with stuff2 when something else happened: %w", err)
//note: different error message, since we're missing call stacks to know where this actually failed
}
Errors are part of your API - agreed. In C# or Java without checked exceptions, you have to assume that any function can throw any exception. In Go, you get exactly 1 more bit of information: a function tells you IF it returns an error or not - same as modern C++ with `noexcept`. But if you actually want to know what errors are returned, you're SOL in most languages (Java with checked exceptions helps, but causes other problems). And none of these languages helps you with adding context to errors, except the so so context of stack traces in C# and Java.I find that extra bit of information to be crucial you do not. I think you are perhaps discounting the value that Go allows you to add in your apis by making the error type explicit which then does tell you what error you might be getting. Go forces the programmer to make the fact that there is an error explicit which is good. If it also forced the developer to be explicit in which type of error it can return that would also be good since it would enforce API boundaries for errors. But I think Go is still ahead of the game compared to Java on this one.
For example, this code:
void foo() {
var res = acquireResource();
useResource(res);
releaseResource(res);
}
is wrong even if no errors can be thrown. By contrast, this code is correct, regardless of whether any of these functions can throw: void foo() {
try {
var res = acquireResource();
useResource(res);
} finally {
releaseResource(res);
}
}
Similarly, in Go this function is wrong: func foo() error {
res, err := acquireResource()
if err != nil {
return fmt.Errorf("error acquiring resources while foo-ing: %w", err)
}
err = useResource(res)
if err != nil {
err = fmt.Errorf("error using resource while foo-ing: %w", err)
}
releaseErr := releaseResource(res)
if releaseErr != nil {
err = fmt.Errorf("%w; failed to releasing resource while foo-ing: %w", err, errW)
}
return err
}
even though I don't have any exceptions.The correct way to do this in Go would be:
func foo() error {
res, err := acquireResource()
if err != nil {
return fmt.Errorf("error acquiring resources while foo-ing: %w", err)
}
defer func() {
err := releaseResource(res)
if releaseErr != nil {
logInSomeWay(fmt.Errorf("failed to releasing resource while foo-ing: %w", err))
}
}()
err = useResource(res)
if err != nil {
return fmt.Errorf("error using resource while foo-ing: %w", err)
}
}
So overall, whether a function finishes early SHOULD be irrelevant.If you want to see what throws "FooException", you need an IDE to do it, unless you have the whole Javadoc memorised. It might be a hundred lines up and in the middle of a foo().bar().baz() chained series of method calls.
And yes, you need to know. The same exception in two different places might mean two very different things in terms of what actually broke, or what you should do to recover or fail the task at hand. Because of this, bubbling up raw exceptions out of deeply nested functions is generally a code smell, because by the time they get to the top level of even a module, all you know is "it broke". Unless you did the donkey work of wrapping the raw exception in something with more semantic context. The same work you'd do in Go.
This isn't any different in Go. Tracing Exceptions is actually often easier since they have unique names that you can grep for, whereas err is always err, so your only recourse is to trace across the callstack.
It's, like, actually worse.
> Because of this, bubbling up raw exceptions out of deeply nested functions is generally a code smell, because by the time they get to the top level of even a module, all you know is "it broke". Unless you did the donkey work of wrapping the raw exception in something with more semantic context.
What? If you have a decent root level error message, that + a stack trace is almost always enough, and is as good or better than what you get in go, since the errors are more structured. I'd generally consider the donkey work you're describing an antipattern. Adding extra context or re-raising an exception should be done only rarely, when you have confidence that the new context you're providing is more useful than the prior (and lots of good languages support exception chaining so you get all of the context from many errors, instead of just one string).
That's a completely different problem, and one that is not unique to errors or exceptions. For any polymorphic type where you want to handle different variants differently, you need to know the possible variants, and this information is, by definition, not present in the code sending the polymorphic value. This is true whether you want to handle exceptions in C++, Optional t in Haskell, or interface values in Go.
> Because of this, bubbling up raw exceptions out of deeply nested functions is generally a code smell, because by the time they get to the top level of even a module, all you know is "it broke". Unless you did the donkey work of wrapping the raw exception in something with more semantic context.
Wrapping up exceptions at every function is a huge code smell. You sometimes want some level of wrapping, especially around module boundaries or in functions with highly relevant context along the way, but generally most functions along a call stack don't have anything to add to a lower level exception - especially if the lower-level library is well made. For example, fs.PathError in Go and System.IO.FileNotFoundException in C# include a way to programatically find the name of the file that was not found, so even if thrown from a low level, a higher level still has the most relevant context available without wrapping. In contrast, java.io.FileNotFoundException does not include this information unless you parse the error string, so you would have a reason to wrap it with more relevant context.
1. https://www.infoq.com/news/2019/07/go-try-proposal-rejected/
Resources that need to be closed at function exit must be wrapped with `using` or `try/finally` in Java; with `defer` in Go.
Functions that can handle specific errors from a sub-function need to know about that error type in Java, error type or error value in Go.
Functions that can't handle any specific errors from their callees don't need to do anything in Java; they need to propagate any error in Go.
It's true that in Java it's less clear where a function can end, since any statement can potentially throw. This is technically true in Go as well with panic(), but let's accept that that is much more rarely used. But either way, if the function has any cleanup to do, that cleanup must be done in try/finally or defer, otherwise the function is brittle. So, why do I care if the function can throw an exception in the middle?