Go Functions, Structs & Pointers: Complete Tutorial

Go Functions, Structs & Pointers: Complete Guide
In our previous modules, we established the basic building blocks of data and logic. Now, we're ready to organize our code into reusable components. Go's approach to functions and data structures is both powerful and deceptively simple.
There are no classes or inheritance hierarchies. Instead, Go uses Structs for data and Functions for behavior. This "composition over inheritance" philosophy is what makes Go code so robust and easy to maintain at scale.
1. The Stack Mirror: Function Frame Allocation
Every time you call a function in Go, the runtime creates a Stack Frame. This is a private, ultra-fast scratchpad for that specific function.
The Stack Physics
- Fast Allocation: Unlike the "Heap" (which requires a search), the "Stack" is just a pointer increment. It is effectively "Free" in terms of CPU cycles.
- Escape Analysis: The Go compiler performs a "Mirror Check." If it detects that a variable created inside a function is being shared outside (e.g., returning a pointer to a local variable), it move that variable to the Heap.
- The Result: By designing functions that keep variables on the stack, you eliminate Garbage Collection pressure and ensure near-instant memory reuse.
2. Reusable Logic: Functions
Functions in Go are first-class citizens. They can return multiple values, take other functions as arguments, and can even be assigned to variables.
package main
import "fmt"
// Basic function declaration
func add(a int, b int) int {
return a + b
}
// Function with multiple return values (very common for error handling)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Result:", result)
}3. The Struct Mirror: Field Alignment and Padding
A struct is a contiguous block of memory. However, the order in which you define your fields can drastically change its size due to Alignment Physics.
The Padding Mirror
- Machine Word Alignment: 64-bit CPUs prefer to read data in 8-byte "Words."
- The Physics: If you put a
bool(1 byte) between twoint64(8 bytes), Go will add 7 bytes of "Padding" to ensure the second integer starts on a word boundary. - The Optimization: By ordering your struct fields from largest to smallest, you shrink the struct's footprint, fitting more objects into the CPU cache and increasing performance by 10-20%.
4. Creating Custom Types: Structs
// Defining a struct
type User struct {
ID int
Username string
Email string
IsActive bool
}
func main() {
// Initializing a struct
u := User{
ID: 1,
Username: "topictrick",
Email: "hello@topictrick.com",
IsActive: true,
}
fmt.Println(u.Username)
}Performance & References: Pointers
By default, when you pass a variable to a function, Go creates a copy of that data. If you want a function to modify the original data, or if the data is very large, you should pass a Pointer.
Working with Pointers
Functional Comparison
| Task / Feature | Pass by Value (Copy) | Pass by Pointer (Reference) |
|---|---|---|
| No comparison data available | ||
First-Class Functions and Closures
In Go, functions are first-class values — you can assign them to variables, pass them as arguments, and return them from other functions. This enables powerful patterns like middleware, callbacks, and functional programming idioms.
// Function type declaration
type MathFunc func(int, int) int
// Higher-order function: takes a function as an argument
func apply(a, b int, fn MathFunc) int {
return fn(a, b)
}
func main() {
add := func(a, b int) int { return a + b }
multiply := func(a, b int) int { return a * b }
fmt.Println(apply(3, 4, add)) // 7
fmt.Println(apply(3, 4, multiply)) // 12
}Closures
A closure is a function that "closes over" variables from its surrounding scope:
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
fmt.Println(counter()) // 3 — count persists between calls
}Closures are used extensively in Go for middleware, deferred cleanup functions, and goroutine payloads.
Named Return Values
Go allows you to name return values in the function signature. This has two effects: the named return value is automatically declared as a local variable, and a bare return statement (without arguments) returns the current values of all named returns.
func divide(a, b float64) (result float64, err error) {
if b == 0 {
err = errors.New("cannot divide by zero")
return // Bare return: returns result=0, err=<the error>
}
result = a / b
return // Bare return: returns result=<value>, err=nil
}Named returns are useful for short functions where they add clarity. For longer functions, explicit returns are preferred to avoid confusion.
Struct Embedding: Composition in Practice
Go achieves inheritance-like behaviour through struct embedding. An embedded type's methods and fields are promoted to the outer struct:
type Animal struct {
Name string
}
func (a Animal) Speak() string {
return a.Name + " makes a sound"
}
type Dog struct {
Animal // Embedded — Dog "inherits" Animal's fields and methods
Breed string
}
func (d Dog) Speak() string {
return d.Name + " barks" // Override the embedded method
}
func main() {
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
fmt.Println(d.Speak()) // "Rex barks" (Dog's method)
fmt.Println(d.Name) // "Rex" — promoted from Animal
}This is how Go implements code reuse without classical inheritance — and it is far more flexible because you can embed multiple types.
When to Use Pointers vs. Values
The rule of thumb:
- Use a value receiver for small, immutable structs (like a
Point{X, Y}). Copying is cheap and safe. - Use a pointer receiver if the method needs to modify the struct, or if the struct is large (slices, maps, or structs with many fields).
- Be consistent within a type: if any method uses a pointer receiver, all methods should use pointer receivers.
type Config struct {
Host string
Port int
Timeout time.Duration
MaxConns int
// Many fields — use pointer receiver
}
func (c *Config) SetTimeout(d time.Duration) {
c.Timeout = d // Modifies the original
}Further Reading
For the next step after functions and structs, see Go methods and interfaces which builds on these concepts to implement polymorphism. For applying structs to concurrent programs, see Go goroutines and the scheduler. To understand the broader Go module system that organises your functions and packages, see Go modules and packages.
Phase 6: Core Structure Mastery Checklist
- Verify Field Alignment: Use a tool like
fieldalignmentto audit your structs and minimize memory padding. - Audit Escape Analysis: Run
go build -gcflags="-m"to see which variables are "escaping to heap" and refactor to keep them on the stack. - Implement Pointer Receivers: Ensure that any struct exceeding 64 bytes is passed by pointer to avoid expensive "Pass-by-Value" copies.
- Test Interface Saturation: Confirm that your structs implicitly satisfy the intended interfaces without needing explicit "implements" tags.
- Use Constructors: Implement
NewStructName(...)patterns to ensure structs are always initialized in a valid architectural state.
Read next: Go Methods and Interfaces: The Polymorphism Mirror →
Next Steps
Now that you can create functions, custom data types, and manage how they're passed through memory using pointers, we're ready for the "killer feature" of Go. In the next tutorial, we will explore Concurrency through Goroutines and Channels—the secret to Go's incredible performance in cloud-native applications.
Common Mistakes with Go Functions, Pointers, and Structs
1. Passing large structs by value
Go copies the entire struct when you pass it by value. For structs with many fields, pass a pointer (*MyStruct) instead. The rule of thumb: if the struct is larger than a few words, use a pointer.
2. Nil pointer dereference
Declaring var p *Person gives you a nil pointer. Accessing p.Name without checking if p != nil first causes a runtime panic. Always initialise pointers before use: p := &Person{Name: "Alice"}.
3. Forgetting pointer receivers for mutation
A method with a value receiver (func (p Person) SetName(...)) receives a copy — changes do not affect the original. Use a pointer receiver (func (p *Person) SetName(...)) when the method needs to modify the struct's fields.
4. Not exporting fields for JSON marshalling
json.Marshal and json.Unmarshal only process exported (capitalised) struct fields. A field like name string will be silently ignored. Use Name string with a JSON tag: Name string \json:"name"``.
5. Using struct literals without field names
Person{"Alice", 30} works today but breaks silently if someone adds a field to the struct. Always use named fields: Person{Name: "Alice", Age: 30}. See the Go spec on composite literals.
Frequently Asked Questions
When should I use a pointer vs a value receiver? Use pointer receivers when the method modifies the receiver, or when the struct is large. Use value receivers for small, immutable structs and when the method does not modify state. Consistency within a type matters more than the individual choice — if any method on a type uses a pointer receiver, all methods should.
What is the difference between new(T) and &T{}?
Both allocate a zeroed T and return a pointer. &T{} is idiomatic and lets you initialise fields inline. new(T) is rarely used in modern Go code but is equivalent for zero-value allocation.
Can Go structs implement interfaces?
Yes. In Go, interface implementation is implicit — a struct satisfies an interface by simply implementing all of the interface's methods. There is no implements keyword. This duck-typing approach is documented in the Go tour on interfaces.
