A friend of mine had poked around with Go during a hack fest and blogged about his thoughts. This was just before I really started poking around. Interestingly the main issues that Aldo found frustrating with the errors for unused variables and unused imports, I have found not to be such a big deal. Passingly griping, sure, but not a big issue. Having the language enforce what is often a lint checker in other languages I see as an overall benefit. Also, even though I don't agree with the Go formatting rules, enforced by gofmt, it doesn't matter. It doesn't matter because all code is formatted by the tool prior to commit. As an emacs user, I found the go-mode to be extremely helpful, as I have it formatting all my code using gofmt before saving. I never have to think about it. One thing I couldn't handle though, was the eight character tabs. Luckily emacs can hide this from me.
;; Go bits.
(require 'go-mode-load)
(add-hook 'before-save-hook #'gofmt-before-save)
(add-hook 'go-mode-hook (lambda () (setq tab-width 4)))
There are some nice bits to Go. I very much approve of channels being first class objects, and the use of channels to communicate between concurrently executing code. Go routines are also nifty, although I've not used them too much myself yet. Our codebase does, but I've not poked into all the nooks and crannies yet.
However there are several things which irritate the crap out of me with Go.
Error handling
The first one I guess is a fundamental design decision which I don't really agree with. That is around error handling being in your face so you have to deal with it, as opposed to exceptions, which are all to often not thought about. Now if our codebase is in any way representative of Go code out there, this is just flat out wrong. The most repeated lines of code in the codebase would have to be:
if err != nil {
return nil
}
This isn't error handling. This is just passing it up to the chain, which is exactly what exception propagation does, only Go makes your codebase two to three times larger due to needing these three lines after every line of code that calls into another function. This is one thing I really dislike, but unlikely to change.
As a user of a language though, there are other things that could be added at the language level to make things slightly nicer. Syntactic sugar, as it is often known, makes the code easier to read.
If the language is wanting to keep the explicit handling of errors in the current way, how about some sugar with that.
Instead of
func magic() (*type, error) {
something, err := somefunc("blah")
if err == nil {
return nil, err
}
otherThing, err := otherfunc("blah")
if err == nil {
return nil, err
}
return foo(something, otherThing), nil
}
we had some magic sugar, say a built-in method like raise_error, which interrogated the function signature, and returned zeroed values for all non-error types, and the error, and returned only non-error values, we could have this
func magic() (*type, error) {
something := raise_error(somefunc("blah"))
otherThing := raise_error(otherfunc("blah"))
return foo(something, otherThing), nil
}
The range function
There are several different issues I have with the range function.- range returns one or two parameters, but the language doesn't allow any user defined functions to return one or two parameters, range is super special
- using range with a slice or array and getting a single value, doesn't give you the value, but instead the index - I never want this
- there is no way to define range behaviour for a user defined type
No generics
Initially I accepted this as a general part of the language. Shouldn't be a big deal right? C doesn't have generics. I guess I spent too long with C++ then.My first real annoyance was when I had two integers, and I wanted to find the maximum value of the two. I go to look in the standard library and find math.max. However that is just for the float64 type. The standard response from the team was "it is only a two line function". My response is "that's not the point".
Since there is no function overloading, nor generics, there is no way with the language at this stage to make a standard library function that determines the maximum value of two or more numeric types, and return that maximum in the same type as the parameters. Generics would help here.
A second case for generics is standard containers. The primary container in Go at this stage is the map. So many places in our codebase we have map[string]interface{}. The problem with this is that you have to cast all values retrieved from the map. There is also no set, multi_map, or multi_set. Since there is no way to provide simple iteration for user defined types, you can't easily define your own set type and have simple iteration using range.
Interfaces that aren't explicitly marked as being implemented help in some ways to provide features provided by generic types and functions, but it is a poor substitute.
11 comments:
There's another gotcha with range - when ranging over channels it doesn't return indexes as the single return value, it returns the channel values. This is stupidly inconsistent.
There's more stuff that annoys me too - it's far too easy to mask variables and it's the first time in a language I had to stop and think about how to create and assign to a new variable because of Go's var, := and =.
Lastly, this http://golang.org/doc/faq#nil_error is truly evil.
'range' is not a function, it's a keyword. Keywords are special by definition.
I think it's on purpose that you can't define range behaviour for a user defined type:
http://www.youtube.com/watch?v=sln-gJaURzk&t=19m
Even if range is a keyword, and that keywords can be special, it doesn't stop the fact that using range over a slice doesn't give you the value, nor that you can't use range for user defined types.
It is obvious that it is on purpose that you can't use range on a user defined type, however that doesn't mean I like it, or that I agree with it.
I certainly agree on most of what you've said.
As for "if err != nil {...", not only is it uglier in the code, if you actually do get an exception, you lose the whole traceback of where it actually occurred. You just get the report at the thing that finally checked it. So I know foo called bar(), but bar() could have called baz() or bing() which one gave the error?
Having map and slice be 'generic', but that can only be done with builtin (which then includes range and len, etc.)
They were fairly consistent that no built in type has a method, and functions that operate on builtin types can't take user defined types. It just sucks that it is true.
There are other small bits of inconsistency. Like it can infer type from the return value of a function.
foo := myfunc()
will give 'foo' a type matching myfunc().
Which means you rarely type out explicit types. That sometimes bytes you when you are trying to find out what 'myfunc()' returns. (This was worse in python, but you at least had pdb to let you step through and inspect the live type.)
Most of what you said makes a lot of sense to me.
On the error handling thing - I don't really think its too bad an approach. The only reason why I say that is that error handling is a hairy subject, and most of the solutions out there seem to be pretty mediocre at best. Like you mentioned, the problem with Go's mechanism is similar to the problem with how error handling is done with a lot of the POSIX interface, where you had error checks littered throughout your code.
Exceptions certainly allow for much more concise code, but things get ugly if you aren't considering exception safety at every part of the way. Its also the case that your rollback code which gets executed when an exception is thrown can be quite complex, especially when modifying some non-function-local state. It'd certainly be nice for C++ compilers to give you some kind of warning like "uh, that's going to end badly because you're calling a non-nothrow function and don't have any cleanup code". Although I gather that detecting this case is pretty difficult to do.
Go in this regard doesn't let you screw up. You have to deal with either an error or a result, so the user knows that they have to clean up and either return the error or deal with the error and continue.
I definitely agree though on the lack of generics, though I think we have yet to see an implementation of generics that doesn't suck. The template bloat you get in C++ is pretty ridiculous, especially with collections which hardly operate on the type's implicit interface other than its copy constructor, copy assignment operator and destructor. I think C#'s implementation comes pretty close to "not sucking", but I suspect that Go just wanted to avoid that can of worms entirely.
I'm suprised that you didn't talk about first-class functions, the parallelism constructs (goroutines, channels) and implicit interface satisfaction. The second provides for a fundamentally different way of thinking about programming in terms of communicating sequential processes.
Good to hear some interesting thoughts on the language though, especially in terms of the range () operator.
Cheers.
One way you can implement range-like behaviour for your own types is to have a method that returns a channel you can iterate over.
It isn't ideal though, since you'll need to spawn a goroutine to feed the channel, and be careful about early returns from the loop.
James, I did just try a quick Iter method on the set.Strings class that I wrote, but instead of a goroutine, I created a channel backed by the right number of items, so no blocking occurs. It works, but isn't really much better than creating a slice of the values. I'd guarantee that the efficiency of creating and using a channel is bigger than a slice, but iteration is nicer.
// Iter returns a channel that can be iteratated over
func (s *Strings) Iter() <-chan string {
result := make(chan string, len(s.values))
for key := range s.values {
result <- key
}
close(result)
return result
}
By closing the channel in the function, I'd hope that the object would get garbage collected when the reference to it is done even if it has values in it.
Closing the channel shouldn't make any difference to whether it can be garbage collected or not -- if there are no references to the channel, it is a GC candidate.
It is necessary to close the channel though if you want a "for x := range ch" loop terminate though.
If you are going to fill a channel with all the data, just return a slice. You're essentially creating a slice, and then adding the overhead of a channel on top of it.
goroutines are 'cheap', especially with GOMAXPROCS=1 it can do nice things that don't involve a lot of multi-cpu synchronization primatives. (And I think there are plans that the scheduler can see it can run the goroutine in the same CPU as the only receiver.)
But yeah, if you are going to buffer everything in a channel, just put it in a slice instead.
As for "if err != nil {...", not only is it uglier in the code, if you actually do get an exception, you lose the whole traceback of where it actually occurred. You just get the report at the thing that finally checked it. So I know foo called bar(), but bar() could have called baz() or bing() which one gave the error?
I agree that's an issue, which is why I don't tend to agree with the usual idiom of: if err != nil {return err}
I like to think of the returned error as the narrative behind what went wrong. That narrative should always make sense to the caller of the function IMO, which means that if the function encountered an error performing something, rather than simply returning the underlying error unadorned, it should annotate it with something that makes sense to its caller. If you do this, the if err {...} clauses are no longer just boilerplate, but are actually adding value to the program.
When returning the error unadorned, we only retain the story of the very first thing that went wrong, which probably makes no sense at all 5 levels up.
When done right, I think adorn-errors-by-default style can lead to error messages that are easier to comprehend than stack traces but similarly informative for finding out what was really going on at the time, albeit at the cost of somewhat more verbose code.
There's a problem with this approach though, and I think this is the reason why so few people use it, which is that many errors describe more than just what went wrong - they describe the function that was called too.
For example, if I call os.Remove("/tmp/xxxx"), I might get this error: "remove /tmp/xxxx: no such file or directory", even though the simpler "no such file or directory" makes total sense to the caller of os.Remove. It's very convenient though - the caller of os.Remove can usually just return the error unadorned... and thus the rot sets in, because the caller of *that* thinks it can do the same thing.
BTW stack-trace-based exceptions have deep problems in a concurrent world - the error might very well have passed through several goroutines on its way to the user.
Whoever writes their documentation keeps saying under the covers, which is a sexual euphemism. I believe the writer means under the hood.
http://golang.org/doc/faq#nil_error
Post a Comment