Go first impressions

Marek Scholle
12 min readJan 26, 2021

This is an article about my first impressions from using Go language. I didn’t find much useful information on internet when I wanted to “get into” Go, so I decided to write few sentences myself. To understand my view better, my background is Scala and C++ for message-based systems. I enjoy pragmatic, ergonomic, type safe programming. I did not want to make any comments about Go until I delivered a production, non-trivial code, and so my complaints are real-world, not academic ones.

It’s easy to find both enthusiasm for and criticism of Go on the internet. I don’t like neither of them, usually they are oversimplified (“error handling finally done right” [apparently written by a person who just switched from Python], “useless for lack of OOP / inheritance” [from Java]). Be warned that some claims below are not precise for the sake of brevity.

Error handling. Perhaps the most prominent feature shown many times, in almost every function call. Go doesn’t have exceptions, instead it uses returning them in second value position, e.g. func f() (int, error). This makes code quite messy as you have to write x, err := f(); if err != nil { return err } very often if you want early exit on error (and you want so — that’s what exceptions do, and Rust’s Result also — with help of nice syntax sugar). Ugly, but is this really a problem (does the programming depend on few more chars)? You may try to abstract the repeating pattern with ideas like this, but I don’t think it’s much win — if you want early exit, you can’t abstract that and have to either write if err != nil { return err } all the time or resort to Go exceptions panic (toxic way).

To be honest, I more like exceptions with combination of some catching system (like Akka actors that crash on exception) or no exception catching at all (let the exception crash the process!), but it’s much better than most of code full of try {...} catch {...} which I have seen. Maybe that using exceptions comes with better understanding of deeper programming principles.

The Go way of closing resources is repetitive (and you may forget to do it), but works well in practice:

f, err := os.Open("test.txt")
if err != nil { return err }
defer f.Close() // called when `f` leaves scope

A memory is GC-managed in Go, you need to close only things like files, network sockets etc. The C++ version of above:

std::fstream fs;
fs.open("test.txt")
// the destructor f.~() is called when `f` leaves scope

is IMO more pleasing, but honestly I saw much C++ code with various Close() calls instead of leaving the clean up to destructors.

For more complex stuff about resources you need to understand move semantics in C++ or Rust and most people don’t (that’s just the fact, the move semantics is not in many mainstream languages). The resource management is a hard topic in general (mostly in the presence of asynchronicity).

The Go way is good for Java programmers and IMO just works. Usually programmers know what they need to close and Go makes this explicit. And defer is definitely much better than trying to close manually at every exit point. It is IMO better than use-with-resources blocks known from Java, C# or Python. It is worse than RAII, but doesn’t require any deeper knowledge.

When you have error as value on last return position, you can’t compose two functions func f() (T, error) and func g(t T) (U, error) and have to name the intermediate variable: t, err := f(); if err != nil {...}; u, err := g(t); .... This is quite annoying. On the other hand it’s your responsibility to handle a potential error (or ignore it).

Although this is an imminent experience, it does not look overall much important; it is not a judgement point on Go.

Lack of generics. One may wonder that a language without generics may have any success. The important remark here is that you still have map[K]V type and []T slice type which provide the most needed type safe collections (think of Python dict and list). It’s very sad that you can’t have even generic maximum function comparing two numbers of the same type.

For some types of applications the absence of generic algorithms would be definitely no-Go. But for types of applications which I worked on last years (lot of messages and asynchronicity) this would be painful, but acceptable. Much more important was to accept a message, deserialize, make some transformations, persist something, send something.

The message is that if you need any generic programming, don’t write it Go (for me, type safety is above all and I will never resort to unsafe casting). Think twice how often you really need generic programming. Or maybe you can split a service into two services where the heavy part is written in something more solid (or rigid), and the system wrangling is written in Go.

A special note here is that Go is a language where you can’t implement your own generic collections. How different is this from C++ or Rust where all collections and algorithm are generic (i.e. type safe), written as “user space” code without any magic involved (just backed by superb language design). Go is definitely an application language which forces you to use its magical maps and slices, but you should ask if you really need custom generic collections in your code. If you are an application programmer, the bets are you don’t need them. Any custom code means some burden on code reviews, potential source of abuse and errors, need to write own tests, comments etc (and it’s expensive).

It may sound surprising to some, but Go is much weaker than Python or Javascript in generic programming. In Javascript, you can write arr.filter(x => x > 0).map(x => 2*x); in Go, you need to write loops manually. Generic programming and type safety are different things.

No overloading. Maybe problem to some, not for me. Much bigger problem is the lack of generic algorithms, but for most application code, you can live without it.

Lack of OOP. What precisely is OOP in the first place? I think that Go is quite a usual OOP language for most people, just their interfaces are implicit and not explicit (you will not find extends or implements keywords). IMO this is not good in big picture as you can’t easily find who implements your interface in the project. But you can live with it. It works well in practice — if you don’t implement a required method on your struct, the compiler will not allow you to use a struct instance as an interface (some simplification involved). Go doesn’t have class inheritance (by which I specifically mean inheriting from classes), but that’s a bad idea anyway; a composition proved to be much better way to write things.

The idea is that when you use a struct instance and its “methods” (functions using it) as interface, the compiler will bake a pair (pointer, virtual method table) for you at the point of function call. It works fine. It’s what C programmers may write manually in C.

Statements over expression. I believe that “programming in expressions” is much better than imperative mutable programming. Examples:

// Good// ternary operator
y = (x == 0) ? 1 : 2
// find max of xs
y = xs.max
// filter & map
y = xs.filter(_ > 0).map(_ * 2)

Vs Go:

// no ternary operator
var y int
if x == 0 {
y = 1
} else {
y = 2
}
// find max of xs (simplified)
y := -1
for _, x := range xs {
if y < x {
y = x
}
}
// filter & map (perhaps most striking example)
y := []int{}
for _, x := range xs {
if x > 0 {
y = append(y, 2 * x)
}
}

The “pattern” that you allocate some piece of memory (in empty state) and then you write some imperative code that assigns a desired value to it seems to be common in Go.

After some coding in Go, I feel this is the main reason why I want to stay away from it. It wasn’t obvious from the beginning, but the more I code, the more I miss expressions approach I’m used to (generic functions, match expressions; Go does not have even ternary operator).

Zero-initiated values. I am very surprised this is not mentioned every time you see article about Go. In Go, every struct has default value with zero values (empty value for strings). A practical consequence is that you can’t have invariants guaranteed by constructors as in C++.

A simple example:

type Point struct {
x int
y int
}
p := Point{}

You then decide to add z to Point. Your code compiles, z gets zero value everywhere. Compare with what you would write in Scala:

case class Point(x: Int, y: Int)
val p = Point(1, 2)

If you add z to Point and you would get compile time error. In its very nature, Go is imperative, mutable, C-like language. You can’t use compile time constructs to enforce invariants. How very bad. Go is not for people who use compiler as their friend who watches their project against bugs.

You can prevent trivial bugs with discipline and writing some kind of “init” functions for your structs:

type S struct {
a int
b int
}
func New(a int, b int) S { ... }

I didn’t see any recommended patterns for Go in this way, so I must assume most of Go code works with the help of endless testing.

In this point (zero initiation), I feel much similarity with Google Protocol buffers (v3). You have no guarantees about content of structs. If you need to use them, write defensive code each time you use a struct. Whenever you read newly added field from struct, bets are that it is not initialized (as compiler will happily accept struct literal that does not assign value to this new field).

In C++, you can write a code in such way that you have an instance of something, you know to have a valid state in it. Rust forces you to set the initial state of data (I very like this approach which does the right thing with little ceremony). In Go, you are in the wild.

Lack of visibility keywords. Again, I’m surprised it’s not widely mentioned. I think that Go way that everything is at least package visible is simply stupid (or I’m used to different way). If I have two files in the same directory and the first there is type A struct { x int}, I can reference x from the second one (which is easy if you use IDE autocompletion). If I don’t want to resort to some naming convention (_x), I need to virtually have one file per directory. Or I can “hide” your data by using interfaces, but for small projects, this may be to heavy solution (I don’t like the idea of using something to achieve something very different).

IDE experience. I was able to setup Go toolchain with brew in one minute, VS Code has a nice extension from Go team. Go enforces strict formatting, the result is very readable. The IDE experience (autocomplete, navigation) is fine. It’s all very lightweight, overall I was happy.

Packaging system. I am not expert on build systems, but Go tooling is fine. Everything works without problems and feels natural. You can compile a library from github into your binary easily.

Go plus points. It’s statically compiled, binaries are small, the compilation is instant. It doesn’t look like much win, but if you build a small service that runs on AWS and you can deploy and try your build in few seconds, it makes iterations much faster. With the help of Docker multistage builds, you just have Alpine base container with your small binary (few MBs) that has minimal memory footprint. Compare this to JVM services (which need 100MB for Hello, world application).

The standard library contains most needed things which will get compiled into binary. You will get working HTTP server and client, JSON marshalling, TCP server etc. Pick what you want and compile to executable of few MBs.

You can write reasonably working concurrent application without help of any framework (thing of Scala’s Akka actors or IO systems like Cats Effect or ZIO); you usually need to inject some framework (almost a runtime) in other languages.

False selling points. Goroutines look great, but you need to be careful about protecting state in concurrent environment (race conditions, visibility problems). Example:

type server struct{...}func (s *server) mutate() {
// You probably need lock here.
...
}
func (s *server) Handle(...) {
s.mutate()
}
http.Handle("/", s.Handle)

You need to protect server state with a mutex. You can implement server (and similar stuff) as actor (= for loop processing messages from input channels) and request-response as a variant of ask pattern (below), but you have little language support; I’m not sure how often the slogan Do not communicate by sharing memory; instead, share memory by communicating (mutexes vs channels) is used in practice.

Ask pattern in Go (as I imagine it):

type Request struct { 
...
replyTo chan Response
}
func handle(requests chan Request) {
for req := range requests {
req.replyTo <- Response{...}
}
}

Conclusion. If you need a service that does a lot of asynchronous stuff and does not have specific algorithmic requirements, Go may be a choice. I personally wrote a service that spawns worker EC2 instances and routes some network traffic to them and was OK with goroutines, with networking, IDE, build and deployment. I didn’t need custom generic collections, generic algorithms, the problem was the networking in AWS EC2 environment (more DevOps than programming). Few maps and channels were all I needed. (It definitely wasn’t very satisfying experience.) I don’t wonder that Docker and Consul (to name some) are written in Go.

I see Go as Python killer for some kind of production code as it has compiled, lightweight runtime. But even it sells itself as a type safe language, it isn’t much better than Python in my eyes. (Only IDE experience is better.) It has no generic programming, effectively killing everything you could call functional style. Mutability everywhere and default zero-initiation of structs makes your code depend on unit tests. (And I am used to write bug-free code with almost no unit tests, instead I heavily rely on compiler constructs). Lack of basic functional support (map , filter) means you need to write for loops to make even the simplest data transformations.

If you want to write an application that watches other processes, does lot of networking etc., consider Go. A compiler will not help you much in this domain, you need to understand how operating systems work in the first place. Go looks like a fine language for “sidecar” applications which purpose is to move some stuff from the “main” ones.

Go is a not-very-good language with a superb runtime, and I don’t think it is true that it is the language that allows for that runtime. All about Go but the language itself looks well-thought (build tools, formatting, documentation). As such, I see Go as a huge missed opportunity. It could be Rust for GC languages, but instead we have GC-ed C, admittedly with few nice things (goroutines, defer) and standard library for most needed stuff. The runtime model perfect for cloud environments means that this mediocre language will be widely used. Managers will push Go forward as everyone want to have Google valuation and they may think that using Google tool will help to achieve this. I would love to be mistaken about Go, but I fear I am not.

One more note. I saw multiple times a statement that Go chose to have few, simple, orthogonal concepts. I don’t think it’s true. Go simply doesn’t have any concepts, I wasn’t able to develop any mental model about Go (which doesn’t mean I wasn’t able to make things right). They gradually cooked the language from ideas how things should look, without formulating any theoretical concepts behind. Few examples: you have multiple return values, but no tuple type. You have for-range syntax for arrays, slices and maps, but no concept of iterator. There are few global functions like slice len and magic factory make, but no notion of user-space global functions. In many modern languages many things are just syntactic sugar for something you can reason about; Go bakes its “generic” slices and maps and things like for-range into compiler. (Example: to have for-range in C++ for you collection, you need to implement iterator over it, basically begin() and end() iterators. C++ standard collections are written as C++ code you could write yourselves.)

Go designers “solved” problems by doing bad things by default and moving the responsibility to programmer. On the other hand, without expressive language, a bad programmer can’t do much harm.

The key point here is our programmers are Googlers, they’re not researchers. They’re typically, fairly young, fresh out of school, probably learned Java, maybe learned C or C++, probably learned Python. They’re not capable of understanding a brilliant language but we want to use them to build good software. So, the language that we give them has to be easy for them to understand and easy to adopt.

(Rob Pike, the loudest of Go authors)

True indeed. By providing junior programmers a simple tool like Go, you may use them to produce an acceptable software. The problem is the missed opportunity if you have an excellent programmer in your team. I understand that Go may be a good language for bigger companies, having simple language means less space for personal over-cleverness, but overall I’m not happy with it.

--

--