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.
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"
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-...).
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.
Could you please elaborate more on this?
It's pretty much Go with Option/Result that forces you to handle errors:
f := os.create('foo.txt') or { println(err) return }
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.
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.
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.
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.
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.
I'm not sure one is better than the other, just different tradeoffs.
you want to be able to read code and see a single control flow
? subverts that core requirement
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 might force you to access a value. Big. Fucking. Deal.
> 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.
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
it's important that i see the `return` keyword in the source code
If anything, they have become successful in spite of C.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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!
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.
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 (!).
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.
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.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.
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.
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).
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
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.
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
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.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
}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
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!)
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.
Actually, I'm reminded of certain errors I've seen along the lines of "exception raised while handing exception".
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.