Lately, I’ve been seeing a lot of teams switching over to writing Go. Most of the people I cross paths with are coming from object-oriented languages like “Java” or “C#”. Since I love talking about Go, every time I encounter someone who is considering making the switch (or already has), I find myself in long chats.
In those chats, I realized some problems are very common among various teams, and I want to talk about those in this post. We Go enthusiasts call ourselves “Gophers” and I’m going to talk about how I think while writing Go code (hence the title “Thinking like a Gopher”).
Go is a special language that sits between “high-level” and “low-level” languages. I think this is the main reason for the confusion I see so often. Some people like to say “Go is the C of the future.” While I strongly disagree with that, you should kinda think like you’re writing C. Let me explain what I mean.
Go compiles into a single runnable binary, which means you don’t need a VM or interpreter to run it like higher-level languages do. But on the other hand, the Go compiler also injects the Go runtime into that binary while compiling. The Go runtime handles scheduling, garbage collection and more for you. But this doesn’t mean you shouldn’t worry about concepts like memory management.
Pointers
Most people see pointers for the first time in their lives when they start Go. This usually leads to two results: overusing pointers for everything, or never using pointers and fighting with the language when it copies data where it shouldn’t. So, I will explain pointers briefly.
Let’s say you have two variables and a function that uses them;
func main() {
a, b := 10, 20
fmt.Println(add(a, b))
}
func add(a, b int) (sum int) {
sum = a + b
return
}
What happens here is the add function copies the values of a and b into its own local a and b variables. So what happens when you use pointers?
func main() {
a, b := 10, 20
fmt.Println(add(&a, &b))
}
func add(a, b *int) (sum int) {
sum = *a + *b
return
}
Here, I am passing pointers to a and b instead of the variables themselves. The add function doesn’t copy the values. Instead, it is looking up the values of the variables belonging to the main function. Using pointers here is kinda pointless because you are only reading the values, not changing them. Sure, it makes a bit of sense if you want to keep your stack small by not copying massive data structures (like a huge file buffer), but for simple integers, the impact is zero. It’s not worth the extra complexity.
Let’s say the main function needs to handle the sum variable for some reason;
func main() {
a, b, sum := 10, 20, 0
add(a, b, sum)
fmt.Println(sum)
}
func add(a, b, sum int) {
sum = a + b
return
}
Here is an example of where you should use a pointer. In this snippet, the add function copies all the values and changes its local sum variable. But it changes the value of the copy, not the original. So the value of sum in main never changes.
The Gopher way is passing copies of a and band passing a pointer to sum.
func main() {
a, b, sum := 10, 20, 0
add(a, b, &sum)
fmt.Println(sum)
}
func add(a, b int, sum *int) {
*sum = a + b
return
}
Here the add function copies a and b, but receives a pointer to sum. It updates the value where the pointer is pointing.
This is exactly what pointers are. Nothing more, nothing less. You only need to use pointers if your data is huge (expensive to copy) or if you need to change a value from another function. Other than that, just pass the variable. Pointers add unnecessary complications if you overuse them.
Methods
Go is not object-oriented, but I see a lot of object-oriented patterns forced into Go code. Go is all about data and functions, not objects.
Yes, Go has “structs” which look like classes, but they aren’t. They are… well, like the name suggests, structured data. Go also has methods, but (you guessed it) they are not class methods. Methods in Go just exist to attach some behavior to a specific piece of data.
Let’s say we have a User struct and we need to construct a full name from a name and surname;
type User struct {
Name string
Surname string
}
func (u User) FullName() (fullname string) {
fullname = u.Name + u.Surname
return
}
func FullName(u User) (fullname string) {
fullname = u.Name + u.Surname
return
}
func main() {
u := User{
Name: "John",
Surname: "Doe",
}
fmt.Println(u.FullName())
fmt.Println(FullName(u))
}
Under the hood, these two functions are the exact same thing. The only difference is the method is associated with the type visually when you use it. This is the main purpose of methods in Go.
I see a lot of people creating structs and interfaces and attaching methods to them just to group functionality together like a Java “Service” class. That is not the Gopher way. If you just need to group functions together, use Go “packages.” That’s what they’re there for!
Memory Management and GC
Yes, the Go runtime has a garbage collector. But this doesn’t mean you shouldn’t care about what happens to your data unlike (yes, I’ll say it) Java. Go uses both stacks and a heap for memory management.
Well, what are stacks and the heap? They are how Go organizes data in memory. Every goroutine has its own stack. When a function returns, its portion of the stack gets invalidated immediately. That space is instantly open for the goroutine to use for its next function call.
The heap, on the other hand, is owned by the runtime. Think of it as a massive shared pool of memory. Data in the heap doesn’t get instantly invalidated like it does on the stack. Instead, the garbage collector constantly scans the heap for data that is no longer being used and deletes it. This means when the heap gets bloated with unnecessary data, the garbage collector is forced to do more background work, which steals performance from the core logic.
How can we control the stack and heap? The short answer is you can’t. Go doesn’t allow you to manually manage where you keep your data like C does. Instead, the compiler decides automatically. But you can ask the compiler what it’s doing. By adding a flag to the go build command, the compiler will tell you which variables “escape” to the heap.
Let me show you two example functions;
func main() {
a, b := 10, 20
fmt.Println(add(a, b))
fmt.Println(*sub(a, b))
}
func add(a, b int) int {
return a + b
}
func sub(a, b int) *int {
result := b - a
return &result
}
When we run go build -gcflags=-m, the Go compiler shows us its heap allocation decisions:
$ go build -gcflags=-m
./main.go:13:6: can inline add
./main.go:17:6: can inline sub
./main.go:8:17: inlining call to add
./main.go:8:13: inlining call to fmt.Println
./main.go:9:18: inlining call to sub
./main.go:9:13: inlining call to fmt.Println
./main.go:8:13: ... argument does not escape
./main.go:8:17: ~r0 escapes to heap
./main.go:9:13: ... argument does not escape
./main.go:9:14: *(~r0) escapes to heap
./main.go:18:2: moved to heap: result
Notice moved to heap: result. Why did it do that? Because the sub function returns a pointer to the local result variable. To be safe, the Go compiler realized the variable will be used after the function returns, so it moved result to the heap where it can survive until main is done with it. You need to always keep in mind that how the compiler manages memory under the hood.
Long story short, I see a lot of people just write their old code using Go syntax and call it a day. They think their code will magically run faster and more efficiently. I understand that it is hard to change habits, but treating Go like a different language entirely is doing more harm than good. Think like a Gopher!