I code solo, so I'm not sure how others do it.
The alternative is to have twenty methods?
I build a data structure, modify its state in a dozen ways and then return it to the caller.
I code solo, so I'm not sure how others do it.
The alternative is to have twenty methods?
I build a data structure, modify its state in a dozen ways and then return it to the caller.
Reasons:
- It's simple and easy to understand. There's no hidden complexity, no magic, no gotchas. Nothing is going to surprise me about it.
- It's a very readable cadence. Do the thing, check the error, do the thing, check the error, do the thing, check the error. Once you get used to the error checks it's extremely readable.
- Despite all the extra characters the actual mental load of the error checks is tiny. Yes it's more typing, but the hard bit about code is thinking not typing, and it doesn't make more thinking.
- If I have to do something special with the error, it's easy. There's no incentive to handle this error just like the rest, or ignore it and let the exception handler catch it. If this error needs (for example) extra logging then I can just put it in there for this error check, no hassle.
- It's pretty much standard across all Go code. If I have to deal with someone else's code, I can expect the same cadence, the same simplicity, the same readable pattern of error checks. One of the great things about Go is that it is opinionated about stuff like this.
The hard part is understanding the existing code. The more cluttered and verbose the code is, the harder that is. Go's boilerplate if err != nil return err, nil becomes something that your eyes just skim over - which is fine right up until you have some code that's doing something similar but not the same, and don't even notice.
I do have to spend a second reading the action if it's not just `return result, fmt.Errorf("failed to do the thing: %w", err)` but that's good, I think.
And all of this is way easier than trying to trace up through the stack to the nearest exception handler and work out what it will do with the error
edit: also, verbosity doesn't make code harder to understand, imho. If anything the other way around. Packing 5 statements into a single line is massively harder to read than separating those same 5 statements into 20 lines with error handlers.
You still have to do that part though? Like, this function returns err, so the caller returns err, so the caller of that returns err, ... - you've still got to walk up the stack to the point where the error is actually dealt with.
> edit: also, verbosity doesn't make code harder to understand, imho. If anything the other way around. Packing 5 statements into a single line is massively harder to read than separating those same 5 statements into 20 lines with error handlers.
Very much not my experience. There's a huge understandability hit when a function doesn't fit on a single screen and you have to scroll, so vertical space is really precious.
This is why we wrap errors. The error message gives a pretty good indication of what the stack was doing when it went wrong.
I had a junior dev work with me on some JS. I was writing it in functional style because it made sense at the time. He was really struggling, so I refactored it to old-school imperative and he understood it and was able to work with it. It might have been an issue with the way he was taught JS, but I think it's more that tightly-packed concise code is actually harder to parse. Not least because you have to understand the whole thing to work out wtf it's doing. Whereas with one-statement-per-line you can scan down to the lines you're interested in and focus on those.
OTOH, if you make sure you do something like
if err != nil {
return fmt.Errorf("what I was doing when the error happened: %v", err)
}
then you get very precise targeted errors appearing in logs that are much easier to track down and fix than either just returning the error or the usual generic catch block found in other languages.IME properly handled Go errors make for much more maintainable code.
IMO the real value in proper error handling (ie what makes fixing errors easy) is that context, not the precise line # of the error which, though useful, is supplementary data. Rust errors with anyhow::Contexts are far more workable than those without.
Edit: I actually wrote a simple Result handling package for Go that includes error context and stacktraces: https://github.com/kitd/chock
As I mention above, return instead the error with some identifying information and context, and you get highly targeted details needed to locate and fix the problem. And the way Go handles this makes it simpler than doing it via exception handling.
Many effectful actions e.g. reading from a file system can have a range of different errors each of which you want to handle differently e.g. out of disk space versus lack of permissions.
It's great that you're treating errors as values. But you need pattern matching and other techniques as they make your code more: (a) readable, (b) safer, (c) simpler and (d) less verbose.
The hilarious thing is that eventually Go is going to get these because there is nothing but upside. And then at that point you're going to wonder how you ever survived without it.
The go way to do this would be:
switch {
case errors.Is(err, outOfDiskSpaceError):
// handle out of disk space error
case errors.Is(err, lackOfPermissionsError):
// handle lack of permissions
...
default:
// do the equivalent of the `_` case in a scala match statement or `t` in a lisp cond
}
Which is roughly as readable as scala/rust's match statements. Moreso if someone tried to get cute in scala and bind both the object as a whole and parts of it at the same time ( something like `case x @ Type(_, _, y, _)` ) or when people get real cute with the unapply method.I mean, I like scala. It's fun. But I would never say it's more readable than go. I've been left to support things that happen when a team of average intelligence devs get a hold of it.
You're also really comparing an MIT and New Jersey style solution here, and judging them both on MIT merits. And I don't think that's exactly a fair argument.
PHP tried to correct the mistake of not giving some more thought to the switch statement at the beginning by including a new "match" expression in PHP 8 - fun times for everyone who used classes called "Match"...
Yeah, but when you need to bash someone over the head, the rocket suddenly isn't any use anymore.
IOW, sometimes the simpler thing is better.
In fact, in practice, the simpler thing is usually better... Like, rocks are usually more useful to the average person than rockets are.
I use a language that has both pattern matching and case constructs. I use both.
If you're doing complex pattern matching all over the place (especially matching the same sets of cases repeatedly in multiple locations in the code), maybe your design sucks. It's not making effective use of OOP or some other applicable organizational principle.
Much like other FP features, shoehorning pattern matching into a language doesn't give you nearly the same advantages as building a language around it, so I don't know that it would make Go significantly better.
struct BinaryTree {
leaf_value: int,
left_child: BinaryTree,
right_child: BinaryTree
}
function sum_leaves(tree: BinaryTree) -> int {
if tree.left_child != nil {
return sum_leaves(tree.left_child) + sum_leaves(tree.right_child);
} else {
return tree.leaf_value;
}
}
vs: enum BinaryTree {
Leaf(int),
Branch(BinaryTree, BinaryTree)
}
function sum_leaves(tree: BinaryTree) -> int {
match tree {
Leaf(leaf) => leaf,
Branch(left, right) => sum_leaves(left) + sum_leaves(right)
}
}
(If you're thinking "the first example should be using inheritance + polymorphism", imagine that "BinaryTree" is in a different library than "sum_leaves". If you're now thinking "visitor pattern", sure, go write your hundreds of lines of boilerplate code if you like.)The first example is less safe because it's filled with invariants: left_child is nil iff right_child is nil, and leaf_value should only be accessed when they're nil. The second example has zero invariants. (You might think there would be an invariant that the children aren't nil, but languages with pattern matching tend to use Optional instead of nil, so that invariant isn't necessary.) If you make mistakes about when you access various fields in the first example, you'll be accessing leaf_value when it's uninitialized, or get a null dereference from one of the pointers.
As for readability, that's in the eye of the beholder, but I find the second example a lot more readable for the same reason: it's clear in both the data definition and the use site which fields exist.
All sorts of details vary across languages, even with a small example like this, but that's the basic differences.
In terms of errors, though, it's generally "the result is either a value and no error, or no value and one of these errors". I get how sum types would help with this, and I'm not arguing against that; they would be useful. But the pattern matching basically still has to deal with that outcome, and have a pattern for each error type. It doesn't strike me as being inherently safer, more readable, etc.
Not so! In Rust:
enum FileError {
FileNotFound,
FileExplodedWhileOpening,
}
impl Display for FileError { ... } // say how to print the errors
// Result is defined in the standard library.
// It's used to store results that may be successful, or may be an error:
enum Result<Success, Error> {
Ok(Success),
Err(Error),
}
fn read_file(path: Path) -> Result<File, FileError> {
...
}
fn main() {
match read_file("kittens.png") {
Ok(file) => // show kittens on screen
Err(err) => println!("Could not show kittens :-( \n{}", err),
}
}I agree that sum types are lovely and that pattern matching makes them nice to work with but I don’t think you really make the case well here that it’d be superior rather than just personal preference.
I'm not sure what you mean by "appropriately represented". What's an appropriate representation of "not present" if a leaf is allowed to be any int?
> Or even representing them with a function call isLeaf.
Let's see how it looks with an isLeaf() function:
struct BinaryTree {
leaf_value: int,
left_child: BinaryTree,
right_child: BinaryTree
}
function sum_leaves(tree: BinaryTree) -> int {
if tree.is_leaf() {
return tree.leaf_value;
else {
return sum_leaves(tree.left_child) + sum_leaves(tree.right_child);
}
}
It still has an "else" clause, and it's still full of invariants. Not sure how this is supposed to be much better?> I don’t think you really make the case well here that it’d be superior rather than just personal preference.
Increased type safety is not personal preference! Here's the list of errors that are easy to make in the first example, and literally impossible in the second:
- accessing `leaf_value` when it's not set
- accessing `left_child` when it's nil
- accessing `right_child` when it's nil
- setting exactly one of `left_child`, `right_child` to nil
- setting `leaf_value` when `left_child` or `right_child` is nil
(You might imagine that the "setting" mistakes are possible in the second example too. If Go merely gained pattern matching, this would be the case. Most languages that were born with pattern matching, though, don't have default/uninitialized values for everything, and so don't let you make those mistakes. I.e., you cannot construct a BinaryTree in Rust without choosing a value for the leaf or for the two branches when you do.)
Sure it is! People literally make this choice all the time. If leaf can be any int then I’d suggest a value that determines whether the value is a branch or a leaf. Which is drum roll how tagged unions work anyway.
You still don’t need the else clause with the early return.
Just to be clear the sum type/pattern matching is nice! But it’s perfectly acceptable to live without it and the world won’t end.
Golang has alot of good things about it. This is not one of them and is a wart on the language that is tolerated because the genesis of the language is to be an entirely inverted approach to verbosity than Java. Its not something to be praised.
It's easy to look at a 50-line function with one statement every 5 lines and find the bit I'm interested in, because it's easier to screen out the bits I'm not interested in. Rather than unentangling a 5-line function that has 10 statements in it, because I have to work out what all of it does in order to understand it and I can't focus in on the bit I'm interested in.
DRY isn’t about reducing LoC
But not copying and pasting code by merging common logic is a matter of reducing the number of times you can fuck up, and reducing the number of “accidentally/unnecessarily special-cased” scenarios. You’re trying to reduce the amount of information needed to understand the codebase. The former is preserving the information, it’s just writing it more densely.
There absolutely are gotchas, exactly because they are not sum types. There are functions where both “slots” are used as return types, when an error occurred.
> It's a very readable cadence. Do the thing, check the error, do the thing
Arguably, you can’t reasonably handle most errors in-place, you just don’t have enough context for that. Also, you want to make your business logic right — all those verbose, often incorrect/naive error handles will just make it harder to read your own logic. Also, very easy to accidentally swallow an error - exceptions/sum types are much better in this regard, you can’t not care about them.
One thing you can do is go to the Go slack space at https://invite.slack.golangbridge.org/ and ask for a review in the #reviews channel; 20 error checks is a lot.