Seahax Musings

Go Learnings

Getting back into go after trying it briefly five years ago (pre-generics). Here's what I've learned so far...

Keep in mind, I'm (mostly) coming from a super flexible language like Typescript. I imagine coming from Python might be a little similar.

FYI: Go developers are called Gophers 😆.

Go Versions

Go as a language is still pretty young (~15 years, still version 1.x), and it's still undergoing active development. They have introduced breaking changes without bumping the major version. This is important when you search the web (or AI) for answers to Go questions. Constrain your search to the last year or versions of go at or near your version. Otherwise, you're likely to get wrong answers (especially from AI).

The good news is that Go isn't a runtime. So, binaries compiled from old Go versions won't suddenly stop working with a future version of go. Because binaries aren't using the Go version you have installed. They literally have a Go version built into them.

Current version (at time of writing): 1.25

Go Philosophy

Modules vs Packages

A single "module" is a directory with a go.mod file in it. A "package" is a directory and its contents (non-recursive) within a module.

A module can be published by pushing it to a public Git repo, and adding a version tag. If your go.mod file declares the module path to be your Git repo (the URL minus the protocol), then that's really all there is to it. If you want to use a "vanity" module path, then you'll have to host an HTML page under that module path (which still has to be a domain/URI), with an HTML page and a <meta> tag that points to the repo.

Unfortunately, when you're looking for 3rd party modules (dependencies), a lot of people will refer to them as packages. C'est la vie.

Standard Library (Core Modules)

When it was designed, one of the goals was to NOT have the JS/NPM ecosystem with a million different 3rd party modules that all do the same thing in different ways. To do that, they tried their best to build everything you would need into the standard library that come with Go.

Now, many years later, you can still do a lot with the standard library, but the community aversion to pulling in dependencies has relaxed a bit. Still a good idea to use the standard library as much as possible. It's actually really good.

Modules

Go really likes to have you run go commands in the directory that contains your go.mod file. This doesn't seem like a big deal. But try putting your go project in a subdirectory of your repo, and then figuring out a way to run go run from the root of the repo.

Go strongly emphasizes open source. When you depend on a third party module, you're basically compiling that module's code into your project. The dependency isn't a binary compiled from code, it's the code.

Public and Private

Things (functions, structs, interfaces, types) that are capitalized are public. You can only access things that are (package-)private within a single package (directory).

Inheritance

Go doesn't really do inheritance. What it does instead is "embedding", which is a form of composition with syntactic sugar.

type A struct {
  PropertyOfA string
}

func (a *A) MethodOfA() {}

type B struct {
  A // A is "embedded"
}

An instance of B "acts" like it inherits from A. But A is actually a property of B. The property is both type A and also named A. It's just that Go syntactically "promotes" the properties (and methods) of A so they can be used as if they were owned by B.

b := B{};

b.A.PropertyOfB // Works
b.PropertyOfA // Also works (shorthand for above)

b.A.MethodOfA() // Works
b.MethodOfA() // Also works (shorthand for above)

It's not inheritance, because type B is not assignable to type A.

a := A{}
b := B{}

a = b // Nope
a = b.a // Works

func useA(a A) {}

useA(b) // Nope
useA(b.a) // Works

The above example also applies to struct pointer types.

Pointers

Go has real pointers (memory addresses), just like C. Go does not allow pointer arithmetic, but that's a deliberate restriction that the compiler just chooses not to allow by default. It actually is possible with unsafe core module. Also, you never have to dealloc or worry about circular reference memory leaking. Go's garbage collector handles those for you.

Unlike C, you can use a pointer just like a value of the same type. No need for an arrow operator (->). The dot (.) will always do.

Careful using pointers too much. Basically, avoid them until you have a reason to use them. Just saving memory isn't really a good reason unless you have a case that takes a LOT of memory.

Interfaces

Interfaces only define methods (not properties). Interface instances are ALWAYS pointers to structs. If an argument or variable is an interface type, the value is a pointer to a struct. You don't need to make pointers to interface typed values (double pointer).

Interfaces are only for polymorphism, and should only be used as function argument types.

It might be tempting to go all in then on functions only accepting interface arguments. But, remember that interfaces always refer to struct pointers. So, they are not suitable if you want to pass a complex type by value. Also, defining interfaces for everything leads to unnecessary overhead.

Best to use struct inputs until you specifically want input polymorphism.

Struct Methods

Methods are functions with a receiver of the struct they are a method for.

type Foo struct {}

func (f *Foo) MethodOfFoo() {}

Note the (f *Foo) after the func keyword. That's the receiver. It means the method has to be called using the dot (.) notation with a struct instance.

f := &Foo{}
f.MethodOfFoo()

The receiver type should almost always be a pointer to the struct. The method will still work even if you use it with a struct value and not a pointer. Without a pointer, every method call would copy the struct, and the method would not be able to modify any receiver properties.

f := Foo{}
f.MethodOfFoo() // Works (even though f is a value, not a pointer)

Constructors

Go doesn't have constructors. But, there is a convention for constructor-like functions with are prefixed with New*. If you have a struct that requires some initialization to work correctly, then this is the solution. Though you can also just make them for clarity and convenience.

An example might be a struct that has a pointer property which might accidentally be left nil, and if it's nil, then methods or consumers will break.

type myThing struct {
  OtherThing *OtherThingStruct
}

func NewMyThing(otherThing *OtherThingStruct) *myThing {
  thing := &myThing{
    // Possible problem (nil pointer) if someone forgot to initialize this.
    OtherThing: otherThing
  }
}

Other examples might be structs with...

You don't have to make the whole struct private if you add a constructor function. But, you can if you want to force consumers outside of the package to use the constructor, because it's doing something necessary, not just helpful.

I've found it better to try and avoid required constructors. They're still useful has optional helpers even when the struct is public. But, when you make the struct private, you start to get into strange design territory.

Avoid Returning Interfaces

If you use the constructor-like pattern or any other function/method that returns a non-primitive, then Go doctrine says you should still return (probably a pointer to) the concrete type from your constructor-like function, not an interface. In fact, Go lore is very much against using interfaces as return types.

NOTE: Go's very own standard library error.New constructor violates this doctrine. Just saying.

I'm not entirely convinced this is an important or even reasonable convention. I really want to return an interface. In any other language, you would define the interface first, then implement it. But, in Go, you very much do not implement interfaces.

Go's reasoning on this is that interfaces shouldn't be owned by producers at all. Instead, consumers should define and own interfaces that represent what they need to consume. That actually makes sense in a purely theoretical way. For instance, this is a lot more inline with the "I" (interface segregation) principal in SOLID. But, it seems... cumbersome... and a bit backwards. Because, in order for that to work, you would have to at least design structs to be consumable through their methods alone (no public properties/fields). Which is doable, but also entirely overkill, unless there's an interface you're implementing!

This is all deeeep into the weeds. Mostly, just return concrete types. Don't worry about the implications. And go ahead and return an interface if it makes things easier.

Slices Are Fancy Array Pointers

Slices and arrays look a lot alike. A slice looks like an array without a size (which it kind of is). But to be a "size-less" array, it has to actually be a pointer to an array so that the underlying array can be reallocated (and the hidden pointer updated) when the slice needs to expand. Generally, you don't need to use pointers-to-slices, which would be a pointer to a pointer to an array.

Careful with slice inputs (arguments). Setting slice argument values inside a function will affect the slice values outside the function. But, anything that reallocates the slice (appending to it for instance) will suddenly copy the underlying array and element updates will stop affecting the slice outside of the function. Best to just treat slices as readonly and return new slices from functions rather than modifying the one passed in. This is an example of Go's simplicity sometimes leading to a little less safety.

No Constants or Readonly Values

This one really blows my mind. Everything is mutable. Every variable and struct property can be set/reassigned. I don't think I've encountered another language that had no concept of immutability.

The counterpoint, is that everything is pass by value, so it's fairly easy to copy things if you want to avoid modifying the original. In fact, copying is pretty common. Unlike interpreted languages, copying anything is just copying the memory (but watch out for nested pointers).

So, copy things defensively when necessary, and avoid mutating function arguments/inputs unless that's really the contract of the function.

Another approach is to use interfaces with only getter methods. You still have to make sure the getters don't return something mutable. Generally, you're trying too hard if you go this route.

No Function Overloading

Honestly, good riddance.

No Top-level Function Calls

You can't call functions outside of a function body.

func A() {}

A() // Nope

func B() {
  A() // Works
}

Top-level Variables

You can declare and initialize variables at the top-level of a file (outside a function body). But, using var is required. The shorthand (:=) won't work.

a := 1 // Nope
var b = 1 // Works
var c int // Works (zero value)

Type Definition vs Type Alias

This is an "oof" on the order of forgetting semicolons 😝.

Type Definition

The following is defining a new type, that has exactly the same definition as another type. Type cloning? The new type cannot be used in place of the original type.

type B A

While an instance of B is not assignable to a variable of type A (also not vise-versa), you can explicitly convert an instance of B to A (or A to B), because A is the "underlying" type (a Go term) of B.

func giveMeA(a A) {}
func giveMeB(b B) {}

func test() {
  var a A
  var b B
  
  giveMeA(a) // Works
  giveMeA(b) // Nope
  giveMeA(A(b)) // Works (explicit conversion)
  
  giveMeB(b) // Works
  giveMeB(a) // Nope
  giveMeB(B(a)) // Works (explicit conversion)
}

The only reason you would want to do this is to define methods that do not exist on the underlying type. So, this is interesting, but of dubious usefulness.

type B A

func (b B) MethodOfB() {} // Works

Type Alias

The following is a type alias. A type alias can be used interchangeably with the type it's aliasing.

type B = A

func GiveMeA(a A) {}

func test() {
  var a A
  var b B
  
  GiveMeA(a) // Works
  GiveMeA(b) // Still works
}

You cannot define method on an alias.

func (b B) MethodOfB() {} // Nope

Generics Are Limited

Go didn't originally have generics (deliberate choice). But, over time it became clear that there are good reasons to implement an pattern once, and then be able to apply it to more than one thing. So generics were eventually added.

Still, there are some very tight restrictions.

You probably won't need generics unless you're implementing a data structure. For example a tree where the node value isn't known ahead of time.

Interestingly, some of the Go standard library collections aren't generic, because they predate the addition of generics. For example, collections/list which is a doubly linked list, just uses any for it's element Value type 🤷.

Loops, Closures, and Variable Scoping

This outlines it better than I can: https://go.dev/blog/loopvar-preview

Basically, before Go 1.22, it was unsafe to use loop variables in closures. From 1.22 on, loop variables are safe to use in closures. This is an example of Go still undergoing active development and introducing changes that might be considered breaking.

You should ALWAYS be careful with closures (anonymous functions) and the variables they're using from parent scopes, which might change before the closure is called.

Map Keys Are Lexicographically Ordered

Unlike JS, the order that keys are added to a map doesn't matter. If you iterate over map keys, they'll be in alphabetical order.

Panic vs Return Error

Go has panic which is like throw or raise in other languages. But it's not the preferred way to handle exceptions.

The recommended approach to errors is to return them from the function instead of panicing. If you also need to return a value, you can return a tuple (eg. return (value, error)). This can get... unwieldy.

So, the community has basically decided that as long as you recover (catch) the panic within your module (especially if your module is a reusable library), it's fine to use panic. But, you still should avoid it when possible.

Embedded Assets

Go lets you embed assets (eg. images, text files, etc.) in your binary. But they have to be in the same directory (or a child directory) of the module that embeds the asset.

This is a fairly new feature, and probably not that useful unless you want to do something like make build a web app into a single binary file.