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
- Open source by default.
- Kinda odd, but nice. It makes modules (libraries) for pay more trouble than they're worth.
- Simplicity both in syntax and features.
- Comes at the expense of safety and/or conciseness.
- Takes some getting used to, but overall a positive I think.
- Concurrency is visible and easy.
- It's well done.
- Tooling is built-in.
- Example: testing, benchmarking, race detection, and formatting.
- Solves so many of the difficulties in collaborative development.
- Everything you really need in the standard library.
- There are 4 tiers of standard library, and Go opted for the complete option.
- none - There isn't one and everything except syntax must be implemented by a library. There aren't many real examples of this (Lisp? Lua?). Mostly pure scripting or toy languages.
- system - Provides (almost 1:1) wrappers for all system calls (filesystem, network, processes, etc.) you might otherwise have to do in natively compiled binary libraries. C has this in the
clib, which is almost always included in/with your compiler. When someone refers to a "system-level" language, this (and low level memory management) is what I think of. Interestingly, I think Rust also falls into this category. My quick test is usually, "does it have a built-in HTTP server implementation". You could generalize that to, "does it implement any protocols or algorithms". - reference - Provides minimal implementations for common operations (eg. running an HTTP server, rather than just listening on network sockets), but you'll need to add quite a bit for any real application. Node, Python, Java, C#, and other interpreted/JIT languages usually fall in to this category.
- complete - Provides fully featured high level solutions to most (or more than just the common) problems. You can hopefully implement your program without dependencies, and even if it's not perfect or is missing some bells and whistles, it will be "production ready". I'm actually not aware of many languages that have tried this. It's a bit of a subjective goal, and prone to endless debate.
- There are 4 tiers of standard library, and Go opted for the complete option.
- Distributed (not centralized) module distribution.
- Dependencies are pulled from git repos (or proxies).
- There's no central authority that knows about all published modules, though the https://proxy.golang.org/ is close.
- Makes perfect sense if you (the go maintainers) want to avoid having to run a central registry (like NPM).
- This makes it hard to be sure there's no shenanigans happening at the library source. Library publishers shouldn't (but can) change the code associated with a version. Basically, as an enterprise, the onus is on you to run (or use) a proxy you trust to protect you from supply chain attacks, and it's done many times all over the internet, instead of by a central authority. Again, it makes sense, a lot of us have just gotten lazy, trusting something like NPM (probably incorrectly) to do it for us.
- There is a way to "advertise" modules you publish, but it's basically just pulling your module through the default go proxy once (after it's tagged in your Git repo) so that the proxy knows about it as soon as possible. This also serves as a minimal guard against version mutations.
- Return errors rather than throwing (aka: panicking).
- This is one I have a bit of an issue with 🫤.
- There is a throw (
panic) mechanism that can be caught (recover-ed), and that's pretty much inevitable for any language, because there are always going to be exceptional cases. So, being told not to use it is a little disingenuous. The rule of thumb here is basically that you should not expect anything outside a module to handle panics from inside the module. Expected panics shouldn't be a library output/behavior/feature. If your module does panic and does not recover, it must be a truly exceptional case that may cause the program to crash (exit with an error). - This is supposed to make it harder to ignore errors. But, ironically, the standard tooling doesn't warn you if you call a function that returns an error, and don't handle the return value. So, it seems like it doesn't totally achieve this goal. But, at least it's a bit more visible, and I think its a simpler (if not better) solution than Java's
throwssyntax and checked vs unchecked errors.
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...
- Any other property where the type's zero value (uninitialized) would be a problem.
- Private properties that cannot be initialized by a consumer.
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.Newconstructor 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.
- Methods can't be generic, only structs, functions, and interfaces.
- You can use an interface as a generic type constraint. But there's really no point anyway. An interface typed argument already accepts anything that has the methods required by the interface. Also, you can't use an interface as part of a generic type constraint union, which is the only thing that would have given it a point.
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.
- Does the closure use a parent scope variable (that's not a loop variable)?
- Is the closure passed as a callback to be executed later, or is it a go-routine (async)?
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.