It would be great if mods edit the title. There's no mention of "Wouldnt wish this on my enemies" in the post, yet @bheni has added this to the title while posting to HN.
Aside from that, I think that's just your opinion. Many people and large-scale projects have been happy with Go error handling. I personally like errgroup package and it solves a complicated problem cleanly. Be careful for what you wish for your enemy. ;)
Though note that it's his own post. Could argue that it's not editorialization since he's appending to his original thoughts, not introducing a third-party opinion or interpretation of them. :P
This was referring to the pain I went through changing the code mentioned in this passage:
As soon as I realized that Noms could panic when calculating the hash of its types I knew I was way off. In the end I think I spent closer to 6 weeks on this. It was time-consuming, and boring, but it couldn't be automated and every one of the changes was an opportunity to introduce bugs.
You are right though, I didn't spend too much of the post talking about how much I hated that 6 weeks and how I regretted my life choices.
> It would be great if mods edit the title. There's no mention of "Wouldnt wish this on my enemies" in the post, yet @bheni has added this to the title while posting to HN.
Sensationalization has started slowly creeping into HN unfortunately.
They said it was a hard fork. I don't understand how they would submit such a large re-write and re-design back, it completely re-thinks how Noms works at a low level.
It doesn't. We would have been interested in working with the Dolt folks. I would have probably helped convert the panics myself. They wanted to do their own thing.
That's allowed in open source and part of the beauty. It does kind of make me sad though.
I recently took someone else’s tool and migrated the panics to errors. How painful it is depends on the size and complexity of the codebase. Fortunately, this was a relatively small codebase written by—what looked to me—like someone who knew how to program but was unfamiliar with Go conventions.
So I spent a couple hours getting rid of panics, and afterwards I was able to use the tools.
The ergonomics of explicit error handling without monads and without pattern matching are so bad that the language designers gave up and allowed every pointer deref, bounds check, equality check, and send to panic. I may never understand why “if err != nil” is tolerated by engineers.
Any well-written framework will have a base-level exception handler. If not, it will simply exit the program with a trace.
Compare that with actually ending up with bad state in golang if errors are accidentally ignored or overwritten (I've seen it happen several times). golang errors are strictly inferior.
And in practice, with Java, C#, etc programs, there's a high level catch that prevents anything terrible from happening. Http requests return 500, UI threads show an error dialog, etc.
In almost all business processing, thanks to the magic of transactions, you never end up in a bad state.
The one thing that I don't think either has tackled is that things like OOM can leave synchronized blocks in a bad state. There are certain errors which should kill the whole process which is why they have the flag that lets you do that. But for more mundane errors, you have an escape hatch.
That's the same as Go panic's tho. (Go panics are extremely close to exceptions: the only difference is they can only be caught at function boundaries instead of scope boundaries.)
The main difference is cultural: you're encouraged to use return codes for expected error conditions and only panic for program bugs. But there's the temptation to not do that because panic(/exceptions) are more ergonomic.
I think exceptions tend to be OK in areas where you don't really care about handling errors: eg command-line tools, GUIs. But for long-lived proccesses, like servers, where it's important to handle errors in a precise way, they are painful, in my experience.
Not really. There's a root exception type, which is caught by any framework worth its salt to prevent the scenario described above from breaking program flow for other executing fibers.
Hmm, I think it's the same still. Go doesn't have type heirchies, but you can "catch" (recover in Go parlance) any panicked value. It's exactly equivalent, no?
To an extent, but you end up getting the worst of both worlds (and then some). You end up having to "catch" panics at the handler level anyway, and end up with verbose and very error-prone error handling in the code.
I much prefer the explicit error-handling style of Go, even if I also have to handle panics. Errors are explicit; panics (which are program bugs, and can't be handled) are implicit. But program bugs are always implicit.
To put it more concretely, my experience has been it is easier to write correct code using the error return style of Go than the exception style of Python or C#.
(And though I sadly haven't had an opportunity to use them for "real work," the explicit-error-return-with-ergonomic-syntax regimes of Rust/Zig/Haskell seem even nicer.)
Slightly pedantic point: In the case of C#, not observing an exception (i.e. catching or thrown by another thread could kill the whole program before .NET 4.5. But this was pretty easy to guard around in various, even easy (if sloppy) ways.
Elm (not mainstream, I concede) wraps list and array access in a Maybe (aka an Option if you're more familiar with ocaml) so there's no throwing or erroring when you try to get what's not there. Kinda pleasant.
intersting. how's that work out in practice? seems like it could be clumsy.
e.g. what does binary search look like in elm?
in general i always thought out-of-bounds controlled-crashing seemed the program was a reasonable thing to do, since it's arguably a bug in the program, not an error in input.
or what about integer oveflow? or divide by zero, it would seem very impracticle indeed to wrap all those operations in maybe...
In languages with pattern matching (which I assume Elm has, although I've never used it personally, it's very natural.
A portion of a binary tree walk in pseudo-code might look something like
match (s.left){ with
Some x -> recurse(s.left)
Nothing -> pass
}
Basically the difference is that it forces you to deal both branches (In a typical pattern match, you either need to explicitly handle all subtypes, or have some sort of catch-all/default case) and it makes sure you can't call the "do something" branch when you actually have nothing.
Returning a Maybe for an array access captures the context of possible failure inherent in that operation. Not handling it doesn't make it go away.
Luckily, languages with parameterized types (Maybe being one) let you write generic code to handle Maybes. You might be envisioning overly nested conditionals, but languages more or less avoid this
I get that, but I feel like the questions I'm curious about have still not been answered...
1. Array out of bounds is different than other types of errors, like e.g. file does not exist:
Because the size of the array is entirely controlled by the program, and a non-buggy program can completely avoid array out of bounds, it knows its access to the array will not return None:
Array access returning None is not an inherent condition that must be handled by any program.
So my question is, what does this look like then in conditions when you know you won't access out of bounds?
Specifically, that makes me wonder what a binary search implementation in Elm would look like. Binary search knows it will not access outside the bounds of the array. So how do you handle get returning Maybe? Do you just assert the result is not null?
OK, I got curious and searched, and here's one random implementation:
The answer is it checks for Nothing (which should never happen!) and just returns -1 if it does happen.
This does not seem good to me! If Array.get is returning Nothing, this indicates a bug in the binary search implementation.
Instead of getting a panic or crash or other indication that a bug has ocurred, the design of get has encouraged the author to silently swallow this condition and return -1.
2. Second question: There are many of other kinds of problems that can happen besides array out of bounds access; it seems unclear to me how you could handle that with Maybe: What if you divide by 0? What if you overflow the range of a type? What if you run write a recursive function that exhausts memory?
In summary, Maybe to handle places where the program is genuinely uncertain about something is great, but I'm unconvinced of its value in modeling situations that should never occur in a non-buggy program.
I've been playing with the Zig language recently, and I feel like it gets this exactly right.
- Optional types for cases where a value might or might not be there
- Sum-type with a set of errors and the value for cases where program input may result in an error
- Crash with a stacktrace for cases where a bug in the program is detected (e.g., array out of bounds access)
-1 seems ok here. The algo is returning the index at which the array contains the search value; returning -1 is a pretty normal way of indicating that there is no such index. She could of course change the search function to return a Maybe Int, then return Nothing in the case where the array does not contain it. Either way makes sense to me. The -1 value is actually warming on me more and more because if the returned index is later used to access the array, it will definitely return Nothing. But using Maybe.andThen to monadically deal with a Maybe wrapped return value that would also be cool.
Float division by zero works the way IEEE floats work: it becomes +/- Infinity. For Integers it actually becomes zero. This makes sense because to me you probably should have handled the case where the denominator was 0 before you got to the point of doing division. Like are you taking the mean of an empty list? What's going on?
The -1 will never actually happen though -- it's -1 if the binary search algorithm itself tries to do an out-of-bounds array access. If that happens, your binary search had a bug. So in this case possible bugs are being obscured, rather than surfaced. This is bad.
Integer division by 0 defining to 0 is interesting and certainly convenient; arguably you will accidentally end up obscuring bugs, when you didn't intend to divide by 0.
The broader point I'm making is it doesn't seem great to me to represent what are basically bugs with Maybe.
I think you're misreading it. It only returns a value _other_ than -1 when the value at mid is equal to the search value. It returns -1 when the number is not found in the array. See here: https://ellie-app.com/byLXrykwMV4a1
Right, as you noticed in your second comment, I'm talking about the inner -1, which should never be hit. The issue is broader than just the specific issue of binary search; it's a question of, which language design features lead you to writing more reliable software, more easily?
I'm unconvinced about array access returning Maybe being a good idea, because it's generally a bug, not actual uncertainty. The fact that the usage of the returned maybe value just serves to obscure possible bugs in the implemenatation of binary search is one example of that.
At the broadest level, running a program can be thought of as the combination of two things:
1. The program itself
2. The input to the program
Now there are two ways your program can encounter an "error". It can be because there is a bug in (1), or it can be because there is bad input in (2).
I think as a general rule, to handle (1) you just need to crash gracefully. The program itself has become confused; there is nothing you can do to recover. For (2), the program itself is not confused, and it should handle the data it has been given in the best possible way.
Thus, things like sum-types are appropriate for type 2 errors, but not for type 1 errors.
(That's my argument, anyway.)
(I recommend checking out the Zig language, which gets this exactly right IMO.)
> I'm unconvinced about array access returning Maybe being a good idea, because it's generally a bug, not actual uncertainty.
So that's the thing: in JS (where I spend most of my time) I never wrap an array access in a try/catch even though it could throw. Rather, I probably have an if statement that wraps up any array access I'm unsure about. That way I avoid throwing for the out-of-bounds case. So I'm with you there: if I wrote Elm the way I write JS it would be clumsy and redundant.
However I don't write Elm the way I write JS. That if-block for safety I mentioned earlier? I usually move that into the Nothing case when my Dict/List/Array access/query came up empty. In Elm you have to consider that the array access could go out of bounds, that you could end up trying to get the head of an empty list, that you could query a Dict with a key it doesn't have. Rather than move those to an if block surrounding same, we usually move those inside. In Haskell you can leave those cases undefined, natch. I like that in Elm there is no such thing as undefined behavior.
I've heard some very rad things about Zig so I'll definitely be checking it out soon.
> I like that in Elm there is no such thing as undefined behavior.
Thanks, that's an interesting perspective to consider. I do know most things I've read about Elm have been very positive, so apparently it does work pretty well in practice!
WAIT my turn to eat crow here: that "should never happen" -1 return statement actually does happen when the search value is greater than the greatest value in the array: https://ellie-app.com/byQ8vnnGTLba1
So the algorithm is correct, but I think sort of by accident.
If you change the initial check from "if lo > hi then" to "if lo >= hi then", the algorithm remains correct, and does not depend on accessing an out-of-bounds array index.
(The input range to the recursive search is half-open, so the check for an empty range should be greater-than-or-equals, not strict-greater-than.)
Yeah I immediately checked that after commenting since it's exactly the off-by-half thing I never get right without checking and yup, you can end up with a never used code path. What a rollercoaster. Ah well. For this particular thing you do in fact end up with one. In other cases whenever I end up with an unfollowed code path I can usually refactor it out. Non-empty lists are a good way of doing that, for instance.
That said: you are right that the inner -1 should never happen. But why are we binary searching in the first place? Maybe we should have stored the values as a set and then called Set.member; it's extremely rare to actually even use Arrays in FP languages as far as I know. I usually am only working with Maps, Sets, and Lists.
It seems like that's orthogonal to the pain of changing function type signatures everywhere if you screwed up? If you start out not using a monad that supports error propagation, don't you have to change a bunch of stuff?
I’m still not seeing how it’s different. Go is a compiled language. You can change a function’s type and let compile errors tell you the callers that need to be changed. Seems pretty similar?
Except for when it’s harder, as the blog post describes. I would guess it’s not all zoning out in a functional language either?
The problem is type systems---be it Go, Java, Haskell[1], C++, or Rust[2]---don't really mind if you ignore the values expressions turned into statements. All these `err` value business if having to remember to inspect and propagate.
The stuff Haskell and Rust do however make the type system far more aware of the issue. In Haskell, all the control flow is "reified" with Monads, which allows the type system (which is data-oriented) to be aware of everything and help much more. In particular, there is no way to "do sequential things" without also handling the errors or lots of extra work that is obvious readinng and wouldn't happen by accident writing.
The situation in Rust isn't quite as straightforward, because there are still genuine control flow operations being used. However, Sum types are still a huge help in that they properly model what is produced (either a success value or error value), and #[must_use] makes sure the Results aren't ignored completely. Again, the path of least resistance is doing the right thing, and anything else is very obvious reading.
-------------------------
This is a long response, but if I could say just one thing, it's wouldn't programming language theory but economics. It's important not just for Rob Pike or O'Rielly books to preach the right thing, but for the right thing to be
a) the easiest and shortest thing to write
b) the shortest and "unmarked" thing to read
Haskell and Rust succeed here, Go fails miserably.
-------------------------
[1] Thank goodness there is #[must_use], but to be fair that is linting post type checking.
[2] The moral equivalent of this problem in Haskell is the type of `seq`. But importantly the Monadic approach doesn't need to use `seq`.
Super sorry about the shitty code in the free project you built your business on ;-).
Anyway, this was a point of huge debate early on in the development of Noms. Some team members just could not accept the constant litter of `if err != nil` no matter how many times I repeated that Rob Pike says it's OK. It goes against deeply held beliefs that all programmers internalize early on, for good reason.
It’s probably why some parts of Go internally have also used panics for flow control (the JSON decoder, famously), and just convert them to errors at the boundaries. This was our plan, but we were not careful about protecting all the goroutine launches. As I said elsewhere in the thread I think we would have welcomed working together on this if the Dolt folks had wanted to.
I should say, Rob Pike didn't put in the language the features that would have helped alleviate the pain of writing 50,000 times the exact same statement littering Go code bases like a plague. Go errors are purely a convention that is no better than C style error code returns. Not good enough in whatever year Go was designed. There are solutions to that issue that are much more composable than that (options, sum types...).
People come to Go for performance and concurrency (although context is another one of these tacked on horrors) and they leave the language because of the crazy ceremonial boilerplate it incurs.
Go is painful if you don't want to write high-quality heavily exercised production code. If you do, then you should be considering each error condition. Most code doesn't need to worry about error handling as it will run under close supervision on at most one machine over its lifetime. Go is a nuisance for that.
> Go is painful if you don't want to write high-quality heavily exercised production code.
That's the excuse that the golang authors give. The fact of the matter, golang is also extremely annoying when writing "high quality production code". golang errors are error-prone, and on more than one occasion we've had instances where errors are mistakingly ignored. This is much worse than exceptions where they have to be explicitly swallowed, otherwise they'd automatically bubble up.
If you want to know that you have handled every error path, Go is awful because there is no language or compiler support for it at all. Completeness has to be ensured manually, by inspection.
Check out Rust's error handling approach sometime for an example of a language that truly values complete and correct error handling. It is a joy.
===
The reality is that Go doesn't have better error handling because the language designers value the simplicity of Go's implementation more than the simplicity of its interface - a trait that directly flows from its unix roots [1].
This is a fine tradeoff, and the right one for many problems. Go has been extremely successful in large part because of this philosophy. But you should not kid yourself that this is somehow the Right Way to handle errors, just because it's what Go does. Because even Go's designers are actively trying to fix it [2].
Give me a break. As the neighbor comment points out, this is necessary if you're writing production-quality code -- either your checking exceptions or checking errors, it makes little difference.
I've been writing Go almost daily for 5 years and have never thought writing if err != nil is remotely a plague. It's an opportunity to handle the situation gracefully, annotate the error or log with details and propagate or swallow. Each invocation becomes deliberate, routine, and meaningful.
If you're just trying to hack together a tire fire, then sure, maybe this is painful, but also maybe consider why you find this to be painful? What do you value, and if it's not what Go provides, why on earth are you using it?
I write production-quality code in Rust, and the vast majority of my error handling consists of the character `?`.
The mess of thousands of lines of `if err` and the lack of any language feature to actually deal with tuple returns as first class citizens was a mistake, full stop. It makes it significantly more difficult to actually read go code, since the actual logic is hidden amongst dozens of lines of visually-indistinguishable error handling logic.
With five years of Go experience at work (plus some casual Rust in my spare time), you learn to just skip over the `if err != nil` lines. It's not really a big deal.
It's like how people get overwhelmed when they read Rust code and they come across lifetime annotations for the first time. The unusual stuff sticks out and makes you trip up if you're not used to it. Once you are, it blurs into the background.
How can you skip over the `if err != nil` lines!? That's where the work is being done per canonical golang. Every line is
if res, err := someDifferentWork(x, y, z); err != nil {
return nil, err.fmt("look, ma! I'm a human exception handler")
}
Yes, Rust has lifetime annotations. Those are almost always only on function definitions, and consist of a small part of the overall definition. With golang, the above nonsense is repeated at least 2/3ds of every function body in practice. And the actual logic is embedded inside of it, over and over and over, with minor differences between each occurrence. `if err != nil {` is practically golang's newline separator.
Look, I get that as you spend more time with the language you get better at mentally ignoring this. But a) comparing noise that's virtually 50% of most golang code in the wild to noise that's maybe 2% of most Rust code in the wild is frankly ridiculous. And b) the fact that you eventually got used to it doesn't make it any less of a readability disaster.
Right but the skipping over is part of the problem. If you want to know your code works you want to treat the error handling as carefully as the happy path. You can’t just skip over it. I’ve found so many bugs in Go that come down to shadowed err variables or forgetting to check an err on one case of 7. Haven’t you? This comes directly from the mental habit of filtering out the error handling.
In well written software error handling is a majority of the paths through the code. But they are all very similar. So it’s a natural programmer inclination to both (a) want to handle them all explicitly, and (b) want to abstract away the similarities so it’s easier to see correctness.
Languages with exception optimize for b and ignore a. Go optimizes for a and ignores b.
But languages like Rust show that it’s possible to actually do both well. That’s what people are complaining about when they lament Go’s approach to errors.
CEO of DoltHub here, just want to go on the record and say discovering Noms made us realize Git semantics on top of a SQL database were closer at hand than we presumed. It changed the direction we were heading and we like the place we ended up.
Sure, the panics sucked but it's over. Happy to figure out a way to get those changes back into Noms eventually. When we changed it, it was unclear anyone would ever work on Noms formally again.
If they used unwind tables for panic, it would be cheaper than a deeply nested stack of mispredicted “if err != nil” branches. But according to https://dr-knz.net/go-calling-convention-x86-64.html they don’t, probably because you can randomly defer stuff anywhere and it isn’t statically scoped.
No, that's not possible. A panic must be recovered in the goroutine in which it occurs. An unrecovered panic in any goroutine kills the entire process, not just the offending goroutine. There is no way to top-down wrap an entire go process in a recover block to keep a panic from killing your process. You have to do it in each and every goroutine.
True, which is one of the reasons why the practice of launching goroutines inside of packages is discouraged.
Granted its sometimes unavoidable and some package maintainers just didn't get the memo, nevertheless when deciding on a package to use it's much easer to find dubious goroutines, than assessing if every potential panic has been handled correctly. Usually easer to fix too if warranted.
And this is why the equivalent of panics have well defined (and customizeable) blast radii in the erlang-vm languages.
This had been solved, fifteen years before go. I understand why java doesn't have this (it's legacy, from the era of erlang). I can't understand why Rob pike didn't steal that idea from erlang.
I agree that adding error checking at every possible error location and then passing up those errors is tedious, but it is a direct result of Golang essentially being a "modern C".
In the case you describe it sounds like it was particularly painful due to the multi-threaded nature of the software.
You didn't call it out directly but I feel like the inability of Golang to do polymorphism makes it more difficult.
If the result of something is simply a message, and that message can be either a normal data type or an error, then you don't need to always additionally return an optional error. This is why I prefer the idea of message passing languages over direct procedural languages.
It is still possible to do this somewhat in Golang using channels and/or queues, channels can tend to get clogged and freeze up in my experience.
I've had good experiences using Mangos ( Golang nanomsg implementation ). If you use inproc queues, you can do multi-threading in a language portable way. Then, if you decide to replace a processing component with something other than Golang, it is relatively easy to do so.
75 comments
[ 5.1 ms ] story [ 144 ms ] threadAside from that, I think that's just your opinion. Many people and large-scale projects have been happy with Go error handling. I personally like errgroup package and it solves a complicated problem cleanly. Be careful for what you wish for your enemy. ;)
As soon as I realized that Noms could panic when calculating the hash of its types I knew I was way off. In the end I think I spent closer to 6 weeks on this. It was time-consuming, and boring, but it couldn't be automated and every one of the changes was an opportunity to introduce bugs.
You are right though, I didn't spend too much of the post talking about how much I hated that 6 weeks and how I regretted my life choices.
Sensationalization has started slowly creeping into HN unfortunately.
handling all the cases sucks but it does make it really clear what choices your code is making
That's allowed in open source and part of the beauty. It does kind of make me sad though.
So I spent a couple hours getting rid of panics, and afterwards I was able to use the tools.
explicit error returns is a little clumsy, but still nicer than exceptions.
(zig error handling seems basically perfect to me.)
Compare that with actually ending up with bad state in golang if errors are accidentally ignored or overwritten (I've seen it happen several times). golang errors are strictly inferior.
In almost all business processing, thanks to the magic of transactions, you never end up in a bad state.
The main difference is cultural: you're encouraged to use return codes for expected error conditions and only panic for program bugs. But there's the temptation to not do that because panic(/exceptions) are more ergonomic.
I think exceptions tend to be OK in areas where you don't really care about handling errors: eg command-line tools, GUIs. But for long-lived proccesses, like servers, where it's important to handle errors in a precise way, they are painful, in my experience.
I much prefer the explicit error-handling style of Go, even if I also have to handle panics. Errors are explicit; panics (which are program bugs, and can't be handled) are implicit. But program bugs are always implicit.
To put it more concretely, my experience has been it is easier to write correct code using the error return style of Go than the exception style of Python or C#.
(And though I sadly haven't had an opportunity to use them for "real work," the explicit-error-return-with-ergonomic-syntax regimes of Rust/Zig/Haskell seem even nicer.)
e.g. what does binary search look like in elm?
in general i always thought out-of-bounds controlled-crashing seemed the program was a reasonable thing to do, since it's arguably a bug in the program, not an error in input.
or what about integer oveflow? or divide by zero, it would seem very impracticle indeed to wrap all those operations in maybe...
A portion of a binary tree walk in pseudo-code might look something like
Basically the difference is that it forces you to deal both branches (In a typical pattern match, you either need to explicitly handle all subtypes, or have some sort of catch-all/default case) and it makes sure you can't call the "do something" branch when you actually have nothing.Have array access return maybe would be much less convenient. What would binary search look like?
How about integer division? How about addition, which might overflow?
Modeling all possible failures as maybe seems like it would be clumsy...
Luckily, languages with parameterized types (Maybe being one) let you write generic code to handle Maybes. You might be envisioning overly nested conditionals, but languages more or less avoid this
1. Array out of bounds is different than other types of errors, like e.g. file does not exist:
Because the size of the array is entirely controlled by the program, and a non-buggy program can completely avoid array out of bounds, it knows its access to the array will not return None:
Array access returning None is not an inherent condition that must be handled by any program.
So my question is, what does this look like then in conditions when you know you won't access out of bounds?
Specifically, that makes me wonder what a binary search implementation in Elm would look like. Binary search knows it will not access outside the bounds of the array. So how do you handle get returning Maybe? Do you just assert the result is not null?
OK, I got curious and searched, and here's one random implementation:
https://github.com/terezka/elm-algorithms/blob/master/Binary...
The answer is it checks for Nothing (which should never happen!) and just returns -1 if it does happen.
This does not seem good to me! If Array.get is returning Nothing, this indicates a bug in the binary search implementation.
Instead of getting a panic or crash or other indication that a bug has ocurred, the design of get has encouraged the author to silently swallow this condition and return -1.
2. Second question: There are many of other kinds of problems that can happen besides array out of bounds access; it seems unclear to me how you could handle that with Maybe: What if you divide by 0? What if you overflow the range of a type? What if you run write a recursive function that exhausts memory?
In summary, Maybe to handle places where the program is genuinely uncertain about something is great, but I'm unconvinced of its value in modeling situations that should never occur in a non-buggy program.
I've been playing with the Zig language recently, and I feel like it gets this exactly right.
- Optional types for cases where a value might or might not be there
- Sum-type with a set of errors and the value for cases where program input may result in an error
- Crash with a stacktrace for cases where a bug in the program is detected (e.g., array out of bounds access)
Float division by zero works the way IEEE floats work: it becomes +/- Infinity. For Integers it actually becomes zero. This makes sense because to me you probably should have handled the case where the denominator was 0 before you got to the point of doing division. Like are you taking the mean of an empty list? What's going on?
Integer division by 0 defining to 0 is interesting and certainly convenient; arguably you will accidentally end up obscuring bugs, when you didn't intend to divide by 0.
The broader point I'm making is it doesn't seem great to me to represent what are basically bugs with Maybe.
Right, as you noticed in your second comment, I'm talking about the inner -1, which should never be hit. The issue is broader than just the specific issue of binary search; it's a question of, which language design features lead you to writing more reliable software, more easily?
I'm unconvinced about array access returning Maybe being a good idea, because it's generally a bug, not actual uncertainty. The fact that the usage of the returned maybe value just serves to obscure possible bugs in the implemenatation of binary search is one example of that.
At the broadest level, running a program can be thought of as the combination of two things:
1. The program itself
2. The input to the program
Now there are two ways your program can encounter an "error". It can be because there is a bug in (1), or it can be because there is bad input in (2).
I think as a general rule, to handle (1) you just need to crash gracefully. The program itself has become confused; there is nothing you can do to recover. For (2), the program itself is not confused, and it should handle the data it has been given in the best possible way.
Thus, things like sum-types are appropriate for type 2 errors, but not for type 1 errors.
(That's my argument, anyway.)
(I recommend checking out the Zig language, which gets this exactly right IMO.)
So that's the thing: in JS (where I spend most of my time) I never wrap an array access in a try/catch even though it could throw. Rather, I probably have an if statement that wraps up any array access I'm unsure about. That way I avoid throwing for the out-of-bounds case. So I'm with you there: if I wrote Elm the way I write JS it would be clumsy and redundant.
However I don't write Elm the way I write JS. That if-block for safety I mentioned earlier? I usually move that into the Nothing case when my Dict/List/Array access/query came up empty. In Elm you have to consider that the array access could go out of bounds, that you could end up trying to get the head of an empty list, that you could query a Dict with a key it doesn't have. Rather than move those to an if block surrounding same, we usually move those inside. In Haskell you can leave those cases undefined, natch. I like that in Elm there is no such thing as undefined behavior.
I've heard some very rad things about Zig so I'll definitely be checking it out soon.
Thanks, that's an interesting perspective to consider. I do know most things I've read about Elm have been very positive, so apparently it does work pretty well in practice!
So the algorithm is correct, but I think sort of by accident.
If you change the initial check from "if lo > hi then" to "if lo >= hi then", the algorithm remains correct, and does not depend on accessing an out-of-bounds array index.
(The input range to the recursive search is half-open, so the check for an empty range should be greater-than-or-equals, not strict-greater-than.)
I remember the first time I wrote some error-handling code in Elixir and nearly cried
For one, you can make few initial changes and then zone out fixing type errors, whereas this is entirely manual.
Except for when it’s harder, as the blog post describes. I would guess it’s not all zoning out in a functional language either?
The stuff Haskell and Rust do however make the type system far more aware of the issue. In Haskell, all the control flow is "reified" with Monads, which allows the type system (which is data-oriented) to be aware of everything and help much more. In particular, there is no way to "do sequential things" without also handling the errors or lots of extra work that is obvious readinng and wouldn't happen by accident writing.
The situation in Rust isn't quite as straightforward, because there are still genuine control flow operations being used. However, Sum types are still a huge help in that they properly model what is produced (either a success value or error value), and #[must_use] makes sure the Results aren't ignored completely. Again, the path of least resistance is doing the right thing, and anything else is very obvious reading.
-------------------------
This is a long response, but if I could say just one thing, it's wouldn't programming language theory but economics. It's important not just for Rob Pike or O'Rielly books to preach the right thing, but for the right thing to be
a) the easiest and shortest thing to write
b) the shortest and "unmarked" thing to read
Haskell and Rust succeed here, Go fails miserably.
-------------------------
[1] Thank goodness there is #[must_use], but to be fair that is linting post type checking.
[2] The moral equivalent of this problem in Haskell is the type of `seq`. But importantly the Monadic approach doesn't need to use `seq`.
Super sorry about the shitty code in the free project you built your business on ;-).
Anyway, this was a point of huge debate early on in the development of Noms. Some team members just could not accept the constant litter of `if err != nil` no matter how many times I repeated that Rob Pike says it's OK. It goes against deeply held beliefs that all programmers internalize early on, for good reason.
It’s probably why some parts of Go internally have also used panics for flow control (the JSON decoder, famously), and just convert them to errors at the boundaries. This was our plan, but we were not careful about protecting all the goroutine launches. As I said elsewhere in the thread I think we would have welcomed working together on this if the Dolt folks had wanted to.
I should say, Rob Pike didn't put in the language the features that would have helped alleviate the pain of writing 50,000 times the exact same statement littering Go code bases like a plague. Go errors are purely a convention that is no better than C style error code returns. Not good enough in whatever year Go was designed. There are solutions to that issue that are much more composable than that (options, sum types...).
People come to Go for performance and concurrency (although context is another one of these tacked on horrors) and they leave the language because of the crazy ceremonial boilerplate it incurs.
This is like an option type but manually built.
Go is painful if you don't want to write high-quality heavily exercised production code. If you do, then you should be considering each error condition. Most code doesn't need to worry about error handling as it will run under close supervision on at most one machine over its lifetime. Go is a nuisance for that.
That's the excuse that the golang authors give. The fact of the matter, golang is also extremely annoying when writing "high quality production code". golang errors are error-prone, and on more than one occasion we've had instances where errors are mistakingly ignored. This is much worse than exceptions where they have to be explicitly swallowed, otherwise they'd automatically bubble up.
If you want to know that you have handled every error path, Go is awful because there is no language or compiler support for it at all. Completeness has to be ensured manually, by inspection.
Check out Rust's error handling approach sometime for an example of a language that truly values complete and correct error handling. It is a joy.
===
The reality is that Go doesn't have better error handling because the language designers value the simplicity of Go's implementation more than the simplicity of its interface - a trait that directly flows from its unix roots [1].
This is a fine tradeoff, and the right one for many problems. Go has been extremely successful in large part because of this philosophy. But you should not kid yourself that this is somehow the Right Way to handle errors, just because it's what Go does. Because even Go's designers are actively trying to fix it [2].
[1] https://www.jwz.org/doc/worse-is-better.html
[2] https://github.com/golang/go/issues/32437
I've been writing Go almost daily for 5 years and have never thought writing if err != nil is remotely a plague. It's an opportunity to handle the situation gracefully, annotate the error or log with details and propagate or swallow. Each invocation becomes deliberate, routine, and meaningful.
If you're just trying to hack together a tire fire, then sure, maybe this is painful, but also maybe consider why you find this to be painful? What do you value, and if it's not what Go provides, why on earth are you using it?
The mess of thousands of lines of `if err` and the lack of any language feature to actually deal with tuple returns as first class citizens was a mistake, full stop. It makes it significantly more difficult to actually read go code, since the actual logic is hidden amongst dozens of lines of visually-indistinguishable error handling logic.
It's like how people get overwhelmed when they read Rust code and they come across lifetime annotations for the first time. The unusual stuff sticks out and makes you trip up if you're not used to it. Once you are, it blurs into the background.
Look, I get that as you spend more time with the language you get better at mentally ignoring this. But a) comparing noise that's virtually 50% of most golang code in the wild to noise that's maybe 2% of most Rust code in the wild is frankly ridiculous. And b) the fact that you eventually got used to it doesn't make it any less of a readability disaster.
In well written software error handling is a majority of the paths through the code. But they are all very similar. So it’s a natural programmer inclination to both (a) want to handle them all explicitly, and (b) want to abstract away the similarities so it’s easier to see correctness.
Languages with exception optimize for b and ignore a. Go optimizes for a and ignores b.
But languages like Rust show that it’s possible to actually do both well. That’s what people are complaining about when they lament Go’s approach to errors.
Sure, the panics sucked but it's over. Happy to figure out a way to get those changes back into Noms eventually. When we changed it, it was unclear anyone would ever work on Noms formally again.
https://github.com/dre1080/recovr
That's what we do. Well we always use errors, but we can't be sure the packages we use won't panic.
Granted its sometimes unavoidable and some package maintainers just didn't get the memo, nevertheless when deciding on a package to use it's much easer to find dubious goroutines, than assessing if every potential panic has been handled correctly. Usually easer to fix too if warranted.
This had been solved, fifteen years before go. I understand why java doesn't have this (it's legacy, from the era of erlang). I can't understand why Rob pike didn't steal that idea from erlang.
I was about to say UTF-8 is an exception but they themselves brought us that in 1992.
It's getting it in Project Loom as far as I understand (hierarchical/supervisor processes).
It goes to show how badly thought out golang is when it comes to language design.
In the case you describe it sounds like it was particularly painful due to the multi-threaded nature of the software.
You didn't call it out directly but I feel like the inability of Golang to do polymorphism makes it more difficult.
If the result of something is simply a message, and that message can be either a normal data type or an error, then you don't need to always additionally return an optional error. This is why I prefer the idea of message passing languages over direct procedural languages.
It is still possible to do this somewhat in Golang using channels and/or queues, channels can tend to get clogged and freeze up in my experience.
I've had good experiences using Mangos ( Golang nanomsg implementation ). If you use inproc queues, you can do multi-threading in a language portable way. Then, if you decide to replace a processing component with something other than Golang, it is relatively easy to do so.