Close

Thoughts on GoLang

Go is a return to C

Go can best be understood as an improved implementation of C. From Wikipedia:

Go is a statically typed, compiled programming language … with memory safety, garbage collection, structural typing,[6] and CSP-style concurrency.

  • Compiled obviously means that before you run it, there’s a compiler that takes a look and makes sure everything looks ok, and then translates it into a form the computer can run.
  • Statically typed means everything is declared up front, and if you mismatch types, the compiler will yell at you. Go provides some elegant shortcuts to reduce needless typing, including in-line initialization and type inference (that is, the ability to give a variable the type of the thing it receives, meaning you don’t need to declare it).
  • Structural typing means that rather than checking the names you’ve provided for eg. interfaces, the compiler examines the implementation signature and barfs if the signatures don’t match.
  • Memory safe and garbage collected means that you don’t have to worry about buffer overflows and/or having the stack bleed into your heap.
  • CSP-Style concurrency means that it has built-in primitives that make threading easy.

Go provides massively improved memory management primitives: buffers are allocated and operated on as immutable, non-resizeable, fully initialized arrays, with a concept of a “slice” used to interact to portions of the data within the buffer. It has some elegant language primitives for managing some forms of error and cleanup. Philosophically it appears to favor being slightly verbose and repetitive in service of putting related code together in one place – but it is really focused on putting related code together in one place.

Go was invented by, among others, the man who invented “B” (which is the language that preceded C).

Here’s my analysis of what succeeds and what doesn’t. Note that a blog post I wrote in 2008, “On Explicit Typing,” has some ideas that will inform this discussion. That one’s shorter and more controversial, you should probably start there.

The Good Stuff

Some things about Go are absolutely great.

Opinionated Style (where I share their tastes)

I’m pretty happy about some of the stylistic decisions the designers made:

  • Go bans cyclic dependencies, implicit type conversions, unused variables, and unused imports. In my mind the only one of these that is even debatable is unused imports, as it can get in the way during development; on the other hand, a decent IDE will handle this for us and so I like it.
  • Tools and libraries distributed with Go suggest standard approaches to things like API documentation (godoc), testing (go test), building (go build), package management (go get), and so on.
  • Go deliberately omits certain features common in other languages, including pointer arithmetic and unions. I won’t miss either of these, as both are primarily useful for in conjunction with direct and optimized memory management. I’ll take garbage collection and memory safety over this without any hesitation.
  • Go functions support multi-value function return, which makes some types of programming much cleaner.

Defer

This is perhaps the killer feature of Go, one of the nicest language features I’ve encountered in a long time. Go observes that functions/methods fundamentally define blocks of related code, and so instead of multiple, nested “try { } finally { }” blocks within a function, they instead provide a LIFO queue of cleanup functions that are guaranteed to execute when the function exits. If you need more nesting, just define another function and call it. Better still, this allows the “finally” block to be defined immediately adjacent to the act that demanded them. Consider the simplicity of:

f := os.Open("main.go") 
defer f.close()

This is simple and elegant; it provides all the best of “try/finally,” without the clutter. (Note: I have intentionally ignored errors here, see “The Dislikey Stuff” section below.) It is an instantly recognizable pattern, putting the clean-up logic adjacent to the creation code. Awesome.

Panic (& Recover)

This is simple: Panic is to Go what RuntimeException is to Java.

When a function calls Panic, Go terminates the function, runs all the deferred functions, and then tells the calling function (if any) to panic. The existence of defer makes RuntimeExceptions trivial to reason about! While the Go documentation very carefully avoids stating this, this is a truly graceful “throw/catch” mechanism: in a defer function, a caller can invoke the recover() function to effectively catch the runtime exception (“regain control of a panicking goroutine”).

The Go documentation is often quite dismissive of the idea of exceptions: “The language’s design and conventions encourage you to explicitly check for errors where they occur (as distinct from the convention in other languages of throwing exceptions and sometimes catching them).” Riiiight. Because catching a checked exceptions is optional in a way that handling a returned error is not? Sure.

But the point is that Go provided RuntimeException (while emphasizing that they don’t support exceptions), and did it in a graceful, clear way. That’s a great language feature.

Concurrency

Another place Go really shines is the simplicity of useful, graceful concurrency. Launching and running a thread takes literally two characters: “go” (ok, ok, two characters and a space). Threading is lightweight and fast. Further, to facilitate interprocess communication the language provides an elegant, re-entrant in-memory FIFO queue called a channel, which will block either/both the sender and/or the receiver if they aren’t ready to process the data. Go also provides graceful mutexes if the threading needs it. Great stuff.

Everything or Nothing at all

The Go compiler produces statically linked, native binaries. That is, you can create a binary executable for Windows on a Mac (and vice versa). Furthermore, the executable will include everything you need to run; everything you depend on will be built right into the binary. This may swell the binary a little, but that’s a tiny price to pay for not needing to worry about a classpath ever again.

In a related decision, libraries in Go are included by source (not binary). This means that you’ll never be surprised by the implementation of a library on which you depend, and that you don’t need a linker. It also means that the entire executable is recompiled every time, but I’m told that the compiler is blazingly fast and again, that’s a fair price to pay for the simplicity.

The Intriguing Stuff

Some things about Go seem unfamiliar to me, but I don’t think I can really judge them until I’ve had a chance to use them in practice.

Opinionated Style (where I’m not sure yet I share their tastes)

Go deliberately omits class inheritance. To be honest, I don’t think I’m going to miss it; I’ve been favoring composition for quite some time now, so I doubt I’ll really miss it. (They also provide a flavor of inheritence in the form of struct composition/embedding.) But it’s surprising and will take some getting used to.

Interface is a strongly worded letter

The “interface” keyword only means “really should implement these methods” – so any object which provides those methods conforms to the interface. That is, if your implementation has the method signatures specified by the interface, you’ve implemented it, whether or not you meant to.

While this smells like the “duck typing,” as the language designers vigorously remind us, it really isn’t: the check to assure that the object implements the interface method is enforced at compile time, an approach known as structural typing. On the one hand, this doesn’t have all the fussy “you forgot to implement that method you don’t need” tripwires imposed by nominative typing (like what Java does), which is great. On the other hand, it does mean that it can be hard to tell whether or not a particular object is intended to implement a particular interface, or if it does. (See “No implements” below.)

However, one amazingly useful feature of this approach is that you can essentially inject an interface into an implementation, as long as the publicly available functions provide the features you need. I think I like this, but I’m going to need to use it for a while to convince myself of it. (However, again, see “No implements” below.)

Type Inference

Go allows undeclared variables to assume the type of the value being assigned to them, which boils away a lot of useless code. Consider the good, simple case:

var i int; i = 10

vs.

i := 10

Clearly the latter is superior. If you’ve read my post on explicit typing, you’ll know that I dislike forcing everything to be typed; this is a graceful way to avoid needlessly and repetitive declarations.

However, consider the following:

tick := time.Tick(100 * time.Millisecond)

If you haven’t encountered it before, you might not immediately realize that tick is, in fact, a channel! Here’s where “type as documentation” comes in. There’s no reason we couldn’t explicitly type it for documentation purposes… but OTOH the typing of the channel returned by Tick is pretty ugly.

var tick <-chan time.Time;
tick = time.Tick(100 * time.Millisecond)

So I guess I’d rather have type inference and look up the function signature when things get wonky, and if it’s weird I can always add the explicit typing.

The Dislikey Stuff

I’ll be blunt: there’s a few things about Go I really don’t care for. Like, really really don’t care for.

Opinionated Style (and I don’t share their tastes)

“On matters of style, swim like a fish. On matters of principal, stand like a rock.” – Benjamin Franklin

You know, it’s great for language designers to be opinionated on things that matter, but… whitespace? Really? You’re taking a position on tabs vs. spaces? You’re going to require unnecessary carriage returns whether I want them or not? That’s just plain overstepping, imo. Why not demand I use a particular editor?

  • Indentation, spacing, and other surface-level details of code are automatically standardized by the gofmt tool. It uses tabs for indentation and blanks for alignment. Alignment assumes that an editor is using a fixed-width font. golint does additional style checks automatically.

Oh, wait, you know what? This language was designed to be usable in vim! Oh. My. God. The arrogance! How do I know? Because…

Lexicographic access modifiers

They use of capitalization to distinguish internal vs. public information in a package.

CAPITALIZATION.

Oh my god.

So there’s two important consequences, one stylistic and one substantial. Stylistically, this means that you can’t create idioms that allow fields and functions to be disambiguated; you have to learn to look for the parentheses. That’s awkward, IMO, but I’ll probably get used to it. More substantially, if you change your mind about the scope of an identifier, you have to change the name. Again, that’s not a huge deal, because it’ll always be changing a private identifier to a public one, so it’ll be a search/replace within the current file to perform the change. So, you know, I’ll probably get used to it. I just find it distasteful.

Why would you do this? Well, I’ll have to go see what they say, but my derisive suspicion is because that means you don’t need an IDE to colorize the variables to understand their scope. Which, if so, argh.

No “Implements”

This one is much more troubling to me. Per above, if your implementation has the method signatures specified by the interface, you’ve implemented it. However, this applies whether or not you meant to. I’m not sure how often that would end up being a problem – it’ll only happen if the methods have identical function signatures – but it’s inevitably going to happen, and when it does, I’m not sure how bad the consequences would be.

More substantially, as I mentioned above, this approach makes it hard to figure out whether people have actually implemented the interface. Consider (from the Go blog):

type NegativeSqrtError float64
func (f NegativeSqrtError) Error() string {
    return fmt.Sprintf("math: square root of negative number %g", float64(f))
}

This pattern is the Go equivalent of “implements Error” (where Error is the interface defining an error return). For well understood and recognizable interfaces, that’s probably sufficient; but for less recognizable or complex interfaces, this rather obscures the intent. It is surprising and disappointing that the language designers did not provide an optional equivalent to “implements”, so we could state our intentions:

type NegativeSqrtError float64 isa error

Per my aforementioned post on explicit typing, typing is (a) testing and (b) documentation. Providing this empowers the compiler to tell me when I’ve forgotten to implement a method of an interface (testing), and tells everybody who comes behind that i’ve intended to (documentation). Making it optional means I only apply it when that level of rigor is appropriate.

Disappointing.

No Exceptions

This is the big one, because it has a lot of knock-on ramifications.

Go expects errors to be handled as return values. There are no built-in facility to handle repetitive error handling; there are no facilities to move error handling away from the main logic of the code. Remember that elegant example of using defer to ensure that an opened file is closed? Well, the actual code looks like this:

if f,err := os.Open("main.go"); err != nil {
    return err
} 
defer f.close()

This idiom is going to happen again, and again, and again; even if all you want to do is return the error to the root of the call stack (say to trigger a retry), you have to explicitly ignore it. And if you have to actually handle the error, all of that logic is going to sit in the middle of your business logic.

The need for separate channels for output and error is not exactly a new concept; it was built into Unix in the mid-60s (via stdout and stderr). I’m astonished that they simply shrugged on this one.

What’s particularly surprising about this is that the designers were so close to actually addressing this in a really useful way. The code that handles errors is significantly idiomatic; the error result is always the last in the chain, the caller checks the error return and either eats it, handles it, or propogates the error up the chain (per above). Recall that the elegance of the “defer” statement is founded on the idea that a function creates a “try” scope. Why not allow me to “throw” an error, and “catch” it the same way? And bubble it up the call stack in the same way? And, at the outermost scope, panic on uncaught errors?

I’ve seen notes that they considered and rejected the introduction of a “try” primitive to the language recently. That’s just the wrong answer to this problem; as far as I can see, they’ve already solved it. Obviously I’ll have to do a bunch more reading to understand their reasoning, but from the surface it seems just sad.

Note that one feature of a deferred function in go is that it “may read and assign to the returning function’s named return values. … This is convenient for modifying the error return value of a function.” This is subtle, but in my opinion it’s pretty gross. It’s not quite relying on side-effects, as the definition of the deferred function is described in the body of the method, but it’s not quite not relying on side-effects either, as it would not be hard to really confuse a developer when they issue a “return 42” statement and the method ends up returning something else.

No possibility of chaining

There’s no facility to chain methods together (passing the return value(s) of one as the input to the next). This is a deeply surprising omission in a modern language.

I suspect this is largely due to the conflation of result and error in the return values; without exceptions, you are forced to handle errors between function calls. This prevents the addition of any functional elements, which is a real shame in a language which otherwise would be amazingly well suited to it.

I suspect many would suggest that using channels as the connective tissue rather than declarative transformations is sufficient. I strongly disagree. If the dataset fits in memory, the difference is immaterial; if you want to scale your processing to massive datasets (eg. Spark, Hadoop) the ability to specify declarative transformations scales in ways imperative simply can’t.

Conclusion

There are some really elegant and important language features in Go that would be very difficult to retrofit into a language; the shortcomings are somewhat cosmetic, if real. Ultimately I suspect that once I get used to Go, if you asked me to choose between Java and Go, I’d choose Go.

But I’ll still lament the gaps.

About dondo

Leave a Reply

Your email address will not be published. Required fields are marked *

Are you a spambot? *