brainbaking/content/post/2021/04/exploring-go.md

18 KiB

title subtitle date tags categories
Exploring the Go programming language From the perspective of a Java/C#/JS/C++ dev 2021-04-15
java
C++
go
javascript
programming

When I wrote Teaching students about coding trends, I formed a plan to treat myself by exploring new and perhaps exciting programming languages. As a polyglot, I love learning new things like that. But instead of simply writing a hello world application and calling it a day after compiling it, I wanted to get my hands dirty. Really, really dirty. I had my eye on Go for a few years now, and this was the perfect opportunity to pretend to attend a virtual GopherCon to watch experts talk about the language. They convinced me to buy 2 books and gave me permission to dive deep (see, now it looks like it's their fault, not mine!).

Anyway, the serve-my-jams Node Webmention microservice I talked about seemed like the perfect candidate for a little port project. Go excels at handling web requests robustly, it's partly conceived at Google because of that. After a week of fiddling, tinkering and testing, I came up with go-jamming, a native compiled alternative to serve-my-jams, written in Go. I knew nothing about the language, and in the process, I learned about unit testing, channels and parallel programming, http serving, struct embedding, slices, and so forth. It was well worth the journey. I'd like to take this opportunity to glance back and clarify why I think Go is both amazing and boring.

Go fixed Java/C#

Most of my early programming days - sadly enough - are spend writing Java (1.7? 1.8?) code. After six years, I switched to an even darker side: C# and the .NET platform. Both languages have a lot in common, and both languages love stealing features from each other. C#'s got cool LINQ queries? Fine, now Java too has a streaming API. C# 3's got type inference? Fine, now Java 10 has it too ("local variable type inference")! Java has an awesome open source community with an endless pool of decent packages? Fine, now C# has... wait, that's not right. I hated Nuget. Log4net started as Log4j. NHibernate started as Hibernate. If I had to pick either one, I'd always go for the JVM over the CLR, because of the toolset support (Gradle > project properties files), not because of the language itself.

So, how does Go fit in? Well, it does not. At all. Go compiles natively, there's no virtual machine. Go lacks about 65% of the language features that Java/C# have, such as:

  • enums
  • classes
  • "proper" inheritance, constructors, destructors, ...
  • readonly/final
  • overloading
  • a while loop? There's only for! Ha!
  • many fancy collections
  • access modifiers
  • streams, a (too) complex threading API
  • intricate garbage collection settings
  • ...

Guess what. I don't miss most features. They all add to the bloat - and learning curve - of the language and its ecosystem, and Go is designed with simplicity in mind. In fact, they're really picky about it: many language addition proposals are rejected after years of discussing, because it does not make things simpler. The reference Go programming language book is about 1/4th in thickness compared to my reference Java or C# book. Granted, it all looks a little bit weird, at first. Let's take a look at an example. Here's a typical set of classes:

public abstract class Animal {
    public int eat() {
        return 3;
    }
    private void fart() { 
        System.out.println("whoops, sorry");
    }
}
public class Elephant extends Animal {
    private final int amount;
    public Elephant() {
        amount = 5;
    }
    @Override
    public int eat() {
        return amount;
    }
    public String toot() {
        return "whoo-hoo";
    }
}

Want a new instance? Elephant tooty = new Elephant();. Want to use it? tooty.toot();. Great. Now how do we do that in Go?

type Elephant struct {
    amount int
}
func (e *Elephant) Eat() int {
    return e.amount
}
func (e *Elephant) Toot() string {
    return "whoo-hoo"
}
func NewElephant() *Elephant {
    return &Elephant{ amount = 5 }
}

It's missing a lot of features. Want to control what is visible (gets "exported" from a package)? Use a capital in types/funcs. Lowercases are "private" - except they're still visible in the same package. Want to use the constructor to initialize an elephant? Uh-oh. Create your own function that imitates a "constructor". I've seen many Go experts even refer to them as constructors, but in fact, NewElephant is an ordinary function. A few more caveats:

  • amount is mutable. But it's unexported.
  • What's the difference between the Eat/Toot and NewElephant functions? The first two are methods, the third is a simple function. A method is a function that gets a receiver: the elephant instance. This is called message passing and reminiscent of Smalltalk.
  • What's up with the pointers? Oh, right. Go uses pass-by-value. Whaaat? If you're coming from the Java/C# world, this will be a major hurdle. But actually, it's not. Go makes things explicit. In Java/C#, all objects passed are references, so in Go, you do so too, using pointers. Go is all about avoiding confusion.

Speaking about making things explicit: there's no exception system. So, this:

public void toot() {
    try {
        this.friend.toot();
        System.out.println("Tootie!!");
    } catch(NullPointerException ex) {
        System.out.println("Where's my friend?");
    }
}

Does not work. Instead, you're obliged to explicitly think about possible errors after calling (system) functions. What do you want to do when a HTTP GET fails, a file is no longer available, or a friend's toot fails? Do you intend to handle it yourself, and continue with perhaps reduced functionality? Or do you give up and let the caller handle it? These things have to be written out in the calling function, not somewhere high up the chain where things are fixed implicitly:

func (e *Elephant) toot() error {
    err := e.friend.toot()
    if err != nil {
        // decide what to do here. 
        fmt.Printf("where's my friend?")
        return err // maybe aggregate it?
    }
    fmt.Printf("Tootie!!")
    return nil
}

This is good and bad. It's good, because it forces the programmer to think carefully about what can go wrong, and implementing graceful degradation is very easy in Go. It's also bad, because Go code is usually littered with constructs such as the above one. You can return the error up the chain, or wrap it with fmt.Errorf("whoops: %w", err). Take a look at any Go source code and you'll find hundreds of examples. This makes code longer, but more clear about what should be done when something goes wrong. Most Go code is structured to lean to the left of the gutter as much as possible: first check what can go wrong, exit early, and then go on to do your business. In Java/C#, most code is filled with intricate nested { } stuff. Extracting stuff in methods helps, but does not solve the core problem.

I love the simplicity philosophy of Go, although I must admit that some things could be viewed as weird decisions. For example, I've been lying, there's a way to recover from "panics" (crashes such as nil references), but it involves using defer and recover(), which is decisively NOT simple. Furthermore, the absence of something simple like a set implementation is baffling. I had to roll my own, as recommended in many books, but if it's that common, why not include it in the standard library? Many utility one-liner functions such as errors.New() are there, why not a stupid set based on a map?

One more thing. Go's toolkit chain is extraordinary. Installing go comes with a simple go command and a set of subcommands such as go get (package manager), go test (unit testing is built-in), and go build (build and link chain). There is no need for an external build automation tool, for a build.gradle file, for a Makefile, for any kind of configuration. In fact, the designers of Go said that Go would have failed if that would be needed. Since Go 1.16, go modules are enabled by default, and adding dependencies modifies the go.mod file:

module brainbaking.com/go-jamming

go 1.16

require (
    github.com/MagnusFrater/helmet v1.0.0
    github.com/gorilla/mux v1.8.0
    github.com/hashicorp/go-cleanhttp v0.5.1
    github.com/hashicorp/go-retryablehttp v0.6.8
)

No more fighting with Gradle transitive dependencies, or .NET DLL hells.

Go fixed C

The last few years, I find myself writing a fair bit of C++ code. It's safe to say that I detest every minute of the "++" part... Go is heavily influenced by C, but luckily comes with a garbage collector. Furthermore, the designers of Go were smart enough to only steal the best features of C - and not to look at C++ at all. For example, there is no pointer arithmetic. At least not without resorting to the unsafe package and uintptr. Another blessing and ease-of-use is the implicit conversion of values to pointers and pointers to values. Consider the following example in C:

struct elephant {
    int age;
};
typedef struct elephant elephant;

void stuff(elephant *e) {
    printf("%d\n", (*e).age);
}
int main(void) {
    elephant* tooty = malloc(sizeof(elephant));
    tooty->age = 5;
    stuff(tooty);
    return 0;
}

Without dereferencing e and using the dot notation to access age (-> is a shorthand for that), you'd get a compile error. The point is that reference conversion mistakes are commonplace. Even worse, making the same mistake with an int* causes a runtime segfault. Go converts these automatically for you:

type elephant struct {
    age int
}

func stuff(e *elephant) {
    fmt.Printf("%d\n", e.age);
}
func main() {
    tooty := &elephant{ age: 5 }
    stuff(tooty)
}

There's no need for (*e).age - it's exactly the same. This makes sprinkling * around in your code a joy, not a drag. What else do you notice about these code snippets?

Go is designed to be easily readable and understandable1. Go code reads from left to right. Names come first! The stuff function arguments read: "e is a pointer of elephant", not "elephant is a pointer to e"?? The same hold true for type definitions: "here is a type called elephant, it's a struct", not "here's a struct, it's an elephant". You'd like to define a slice? elephants := []elephant. Elephants is a slice of type elephant. [] comes first!

You can even name your return parameters if you're up to it:

func hi(msg string) (number int, whatWentWrong error) {
    number = 5
    whatWentWrong = nil
    return
}

Now that I know a bit of Go, I'd never voluntarily write in C/C++ again. Go interopts with C flawlessly, it also compiles natively, and is also designed to be fast. So what about cross-compiling, that's got to hurt, right? No, it does not. Go compiles to pseudo-assembly, which is simply mapped to an architecture of choice. The go toolkit comes with a huge list of OS and architectures supported built-in! I compiled my microservice on my M1 Air ARM64 for a Win32 machine using GOOS=windows GOARCH=386 go build. Done. No need for cumbersome cross-compile toolchains, fiddling with the correct versions of these things, ... What a relief!

Go did not fix JS

I have a special place in my heart for the mess that is called JavaScript. And since I've ported a Node application to Go, it is inevitable that I ended up comparing portions of the JS code to the Go counterpart. First, the good parts.

Functions are first-class values in Go.

Now, the bad parts. Go is not a functional language, but easily lends itself to that style. That said, there's no built-in support for something like map() or filter(), and chaining is a pain in the ass. It is called idiomatic to simply rely on a for {} loop instead. Sure, that works, and is probably both easier to maintain and faster. However, it's boring - and not in a good way. Writing something like this in Go:

  return entries
    .map(enrichWithDateProperties)
    .filter(sincePublicationDate)
    .map(item => {
        return {
            link: item.link,
            hrefs: collectHrefsFromDescription(item.description)
        }
    })

Is impossible without calling in external packages with unclear APIs and incomplete documentation. As Fumitoshi Ukai said in his When in Go, do as Gophers do talk:

Don't write Go code as you write code in C++/Java/Python. Write Go code as Gophers write code.

So I complied and simply wrote a for loop. Next, I wanted to chain two string replace methods together, which is simply "bla".replace(...).replace(...) in any language - except Python perhaps. And Go. In Go, many utility functions reside in packages instead of as methods on the string type, resulting in something awkward as:

str := "yeah"
strings.Replace(strings.Replace(str, ...), ...)

I solved this by creating my own type - as you simply can't extend things that do not belong in your own package:

type xml string
func (theXml xml) replace(key string, value string) xml {
    return xml(strings.ReplaceAll(theXml.String(), key, value))
}
func (theXml xml) fill(mention mf.Mention) string {
    return theXml.
        replace("{$source}", mention.Source).
        replace("{$target}", mention.Target).
        String()
}

This means I've hit the limits of a static language. In JavaScript, it's very easy (yes, and dangerous) to extend and replace stuff. In Go, I banged my head into walls fairly often while trying to unit test something that calls external packages. I had to resort to the classic "extract interface" method, which is simply not fun compared to writing tests in Jest. Jest is indeed, as it states on the site, a delightful JS testing framework. Mocking is very easy, and even with the help of Testify, mocking in Go was even more painful than using Mockito in Java: mock(SomeInterface.class) does not work, so you either have to resort to generators that spew out a mocked struct or do it yourself.

Of course, my preference for dynamic languages is not doing me any good here, and I don't think anyone resorts to writing short scripts in Go instead of Ruby/Python/JS when performance does not matter and you're not interested in running it after today. But my JS microservice was designed to last, and ran using PM2 on the server. Admittedly, the Go alternative consumes 0.3% of the server RAM, and I don't want to know that Node did, plus, installing Node/npm/yarn and fetching packages is much more cumbersome (and error prone) than copy-pasting a single binary.

Another point for go for introducing channels and making concurrent programming easy. Well, easier than in C/C++/Java/C#. Consider the following example in JS, which fetches all files in parallel (parts omitted for brevity)2:

const fsp = require('fs').promises

async function load(domain) {
    const fileEntries = await fsp.readdir(`data/${domain}`)

    const files = await Promise.all(fileEntries.map(async (file) => {
        const contents = await fsp.readFile(`data/${domain}/${file.name}`)
        return JSON.parse(contents)
    }))

    return files
}

This scatter/gather pattern is much more explicit in Go, but it's possible:

func FromDisk(domain string, dataPath string) mf.IndiewebDataResult {
    loadPath := path.Join(dataPath, domain)
    info, _ := ioutil.ReadDir(loadPath)
    amountOfFiles := len(info)
    results := make(chan *mf.IndiewebData, amountOfFiles)

    for _, file := range info {
        fileName := file.Name()
        go func() {
            data, _ := ioutil.ReadFile(path.Join(loadPath, fileName))
            indiewebData := &mf.IndiewebData{}
            json.Unmarshal(data, indiewebData)
            results <- indiewebData
        }()
    }

    indiewebResults := gather(amountOfFiles, results)
    return mf.WrapResult(indiewebResults)
}

func gather(amount int, results chan *mf.IndiewebData) []*mf.IndiewebData {
    var indiewebResults []*mf.IndiewebData
    for i := 0; i < amount; i++ {
        result := <-results
        if result.Url != "" {
            indiewebResults = append(indiewebResults, result)
        }
    }
    return indiewebResults
}

Why is this so long?

  1. In JS, JSON is... well... JS. In Go, it's either a string, or you have to create a struct that matches its contents, as most static language serialization works. Ugly. Points for JS. More explicit in Go. Points for Go.
  2. String interpolation does not exist in Go. I hate that!
  3. async/await patterns usually involve channels, meaning a second loop is needed to unwrap the results.

Still, for a static language, it's more than elegant enough. It's not exactly fair to compare a dynamic language with a static one. I'd have to write the equivalent in C++, which would probably involve me getting nauseous more than a couple of times.

To conclude

I love Go's simplicity philosophy and its powerful toolset. Compiling is extremely fast, cross-compiling is ridiculously easy, and the language is small but powerful. It's 4x faster than PyPy for serving HTTP requests, which is 4 to 50x faster than CPython. Ouch! I haven't even touched the subject of new interfaces that match existing structs. As an architect, I would choose Go as my go-to (ha!) backend programming language in a heartbeat. It's easy to understand and maintain, and those two reasons are very powerful arguments to convince managers. So yes, I'm also a Gopher now, and the hype is justified.

Still, I also want to have fun when writing code. Go is more fun than C, C++, C#, and perhaps Java, although the latter depends on what other ecosystems you have running on the JVM. But I don't think I can say I enjoy Go more than JS, even though many people will probably shoot me for saying that. I am well aware of the trainwreck called ECMA, and its increased complexity, which I do not support at all. Who wants classes in their JS anyway?

Maybe next time, I'll dive deep into Ruby. I've always wanted to do that.


  1. It also eases parsing and compiling your own Go code! Just interpret tokens from left to right. ↩︎

  2. Yes, I do not use ; in JS. Yes, that gives problems in anonymous function wrappers. ↩︎