Understanding Structs in Go
Why Structs & Methods Matters
The Problem: Loose data passed as scattered arguments is hard to evolve. Objects with hidden inheritance are hard to reason about.
The Solution: Go structs group related fields, methods attach behavior with explicit receivers (value or pointer), and composition (embedding) replaces inheritance — keeping the type system flat and predictable.
Real Impact: Done well, Go structs read like records with verbs attached — minimal magic, maximum clarity.
Real-World Analogy
Think of a struct as a labelled form:
- Struct = a paper form with named fields
- Method = an instruction on the form ('compute total' written next to the totals row)
- Pointer receiver = the original form — changes persist
- Value receiver = a photocopy — changes are local
- Embedding = stapling a sub-form to a master form — its fields show through
Memory Layout and Alignment
Structs in Go are composite types that group related data together. Understanding their memory layout is crucial for writing efficient code. Fields are laid out in memory in the order they're declared, with padding added for alignment based on the platform's word size.
Struct Memory Principles
- Alignment: Fields align to their natural boundaries (int64 to 8 bytes)
- Padding: Compiler adds padding between fields for alignment
- Size: Total size includes all fields plus padding
- Ordering: Reordering fields can reduce memory usage
// Memory layout demonstration
type Inefficient struct {
a bool // 1 byte + 7 bytes padding
b int64 // 8 bytes
c bool // 1 byte + 7 bytes padding
d int64 // 8 bytes
} // Total: 32 bytes
type Efficient struct {
b int64 // 8 bytes
d int64 // 8 bytes
a bool // 1 byte
c bool // 1 byte + 6 bytes padding
} // Total: 24 bytes
// Check struct size
fmt.Println(unsafe.Sizeof(Inefficient{})) // 32
fmt.Println(unsafe.Sizeof(Efficient{})) // 24
Struct Fundamentals
Declaration and Initialization
Go provides multiple ways to create and initialize structs. Understanding the differences between zero values, literals, and the new function helps you choose the right approach.
// Struct definition
type Person struct {
Name string
Age int
Email string
Active bool
}
// Various initialization methods
var p1 Person // Zero value
p2 := Person{} // Empty literal
p3 := Person{Name: "Alice", Age: 30} // Named fields
p4 := Person{"Bob", 25, "bob@ex.com", true} // Positional (avoid)
// Using new (returns pointer)
p5 := new(Person) // *Person, zero-valued
p6 := &Person{Name: "Carol"} // *Person, literal
// Anonymous structs
point := struct {
X, Y float64
}{10.5, 20.3}
// Struct tags
type User struct {
ID int `json:"id" db:"user_id"`
Username string `json:"username" validate:"required,min=3"`
Password string `json:"-" db:"password_hash"`
}
Struct Comparison and Assignment
Structs are comparable if all their fields are comparable. Assignment copies all fields, creating an independent copy (value semantics).
// Comparison
type Point struct { X, Y int }
p1 := Point{1, 2}
p2 := Point{1, 2}
p3 := Point{2, 1}
fmt.Println(p1 == p2) // true
fmt.Println(p1 == p3) // false
// Assignment creates a copy
p4 := p1
p4.X = 100
fmt.Println(p1.X) // Still 1
// Non-comparable structs (contains slice)
type Data struct {
Values []int
}
// d1 == d2 // Compile error!
Methods in Go
Defining Methods
Methods are functions with a special receiver argument. The receiver appears between the func keyword and the method name, binding the function to the type.
// Method with value receiver
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
// Method with pointer receiver
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
// Method on non-struct types
type Counter int
func (c *Counter) Increment() {
*c++
}
func (c Counter) String() string {
return fmt.Sprintf("Counter: %d", c)
}
Value vs Pointer Receivers
Choosing between value and pointer receivers is a critical design decision. Each has specific use cases and performance implications.
⚠️ Receiver Type Guidelines
- Use pointer receivers for methods that modify the receiver
- Use pointer receivers for large structs to avoid copying
- Be consistent - if one method has a pointer receiver, all should
- Value receivers are safe for concurrent use without synchronization
| Aspect | Value Receiver | Pointer Receiver |
|---|---|---|
| Modification | Cannot modify original | Can modify original |
| Memory | Copies entire struct | Copies pointer (8 bytes) |
| Nil Safety | Cannot be nil | Must check for nil |
| Interface | T and *T can use | Only *T can use |
| Concurrency | Safe without locks | Needs synchronization |
Struct Embedding and Composition
Embedding for Composition
Go doesn't have inheritance, but struct embedding provides a powerful composition mechanism. Embedded fields promote their methods to the outer struct, enabling code reuse.
// Base types
type Address struct {
Street string
City string
Country string
}
func (a Address) FullAddress() string {
return fmt.Sprintf("%s, %s, %s", a.Street, a.City, a.Country)
}
// Embedding
type Employee struct {
Name string
ID int
Address // Embedded field
}
// Usage
emp := Employee{
Name: "Alice",
ID: 123,
Address: Address{
Street: "123 Main St",
City: "Boston",
Country: "USA",
},
}
// Promoted fields and methods
fmt.Println(emp.City) // Direct access to embedded field
fmt.Println(emp.FullAddress()) // Promoted method
// Method overriding
func (e Employee) String() string {
return fmt.Sprintf("%s (#%d) at %s", e.Name, e.ID, e.FullAddress())
}
Multiple Embedding
Structs can embed multiple types, but field/method name conflicts must be resolved explicitly.
// Multiple embedding
type Reader struct {
Name string
}
func (r Reader) Read() string {
return "Reading..."
}
type Writer struct {
Name string
}
func (w Writer) Write() string {
return "Writing..."
}
type ReadWriter struct {
Reader
Writer
}
// Resolving conflicts
rw := ReadWriter{
Reader: Reader{Name: "ReaderName"},
Writer: Writer{Name: "WriterName"},
}
// Ambiguous: rw.Name (compile error)
fmt.Println(rw.Reader.Name) // Explicit access
fmt.Println(rw.Writer.Name)
fmt.Println(rw.Read()) // Promoted method
fmt.Println(rw.Write()) // Promoted method
Advanced Patterns
Builder Pattern
The builder pattern is useful for constructing complex structs with many optional fields. It provides a fluent interface for step-by-step construction.
// Builder pattern implementation
type Server struct {
Host string
Port int
Timeout time.Duration
MaxConns int
TLS bool
}
type ServerBuilder struct {
server Server
}
func NewServerBuilder() *ServerBuilder {
return &ServerBuilder{
server: Server{
Port: 8080, // Default values
Timeout: 30 * time.Second,
MaxConns: 100,
},
}
}
func (b *ServerBuilder) WithHost(host string) *ServerBuilder {
b.server.Host = host
return b
}
func (b *ServerBuilder) WithPort(port int) *ServerBuilder {
b.server.Port = port
return b
}
func (b *ServerBuilder) WithTimeout(timeout time.Duration) *ServerBuilder {
b.server.Timeout = timeout
return b
}
func (b *ServerBuilder) WithTLS() *ServerBuilder {
b.server.TLS = true
return b
}
func (b *ServerBuilder) Build() (*Server, error) {
if b.server.Host == "" {
return nil, fmt.Errorf("host is required")
}
return &b.server, nil
}
// Usage
server, err := NewServerBuilder().
WithHost("localhost").
WithPort(9000).
WithTimeout(60 * time.Second).
WithTLS().
Build()
Functional Options Pattern
Functional options provide a clean way to handle optional parameters and configuration. This pattern is widely used in Go libraries for API design.
// Functional options pattern
type Client struct {
baseURL string
timeout time.Duration
maxRetries int
apiKey string
}
type ClientOption func(*Client)
func WithTimeout(d time.Duration) ClientOption {
return func(c *Client) {
c.timeout = d
}
}
func WithMaxRetries(n int) ClientOption {
return func(c *Client) {
c.maxRetries = n
}
}
func WithAPIKey(key string) ClientOption {
return func(c *Client) {
c.apiKey = key
}
}
func NewClient(baseURL string, opts ...ClientOption) *Client {
c := &Client{
baseURL: baseURL,
timeout: 30 * time.Second, // Defaults
maxRetries: 3,
}
for _, opt := range opts {
opt(c)
}
return c
}
// Usage
client := NewClient("https://api.example.com",
WithTimeout(60*time.Second),
WithMaxRetries(5),
WithAPIKey("secret-key"),
)
Method Sets and Interfaces
Understanding Method Sets
The method set determines which methods are accessible and which interfaces a type implements. The rules differ for value and pointer types.
Method Set Rules
- Method set of T contains all methods with receiver T
- Method set of *T contains all methods with receiver T or *T
- Interface satisfaction depends on the method set
- Addressable values can use pointer receiver methods
// Method sets demonstration
type Shape interface {
Area() float64
Scale(float64)
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c *Circle) Scale(factor float64) {
c.Radius *= factor
}
// Interface satisfaction
var s Shape
c := Circle{Radius: 5}
// s = c // Compile error! Circle doesn't implement Shape
s = &c // OK: *Circle implements Shape
// But addressable values work
circles := []Circle{{1}, {2}, {3}}
circles[0].Scale(2) // OK: circles[0] is addressable
Performance Considerations
Stack vs Heap Allocation
Understanding when structs are allocated on the stack versus the heap is crucial for performance. Escape analysis determines the allocation location.
// Stack allocation (no escape)
func stackAlloc() {
p := Person{Name: "Alice", Age: 30}
fmt.Println(p.Name)
// p doesn't escape, allocated on stack
}
// Heap allocation (escapes)
func heapAlloc() *Person {
p := &Person{Name: "Bob", Age: 25}
return p // p escapes, allocated on heap
}
// Check escape analysis
// go build -gcflags="-m" main.go
// Benchmark comparison
func BenchmarkStackStruct(b *testing.B) {
for i := 0; i < b.N; i++ {
p := Person{Name: "Test", Age: 20}
_ = p
}
}
func BenchmarkHeapStruct(b *testing.B) {
for i := 0; i < b.N; i++ {
p := new(Person)
p.Name = "Test"
p.Age = 20
_ = p
}
}
| Aspect | Stack Allocation | Heap Allocation |
|---|---|---|
| Speed | Very fast | Slower (GC overhead) |
| Memory | Auto-freed on return | GC managed |
| Size Limits | Limited (typically 2-8MB) | Limited by available RAM |
| Sharing | Cannot share beyond scope | Can share via pointers |
| Use Case | Local, temporary data | Shared, long-lived data |
Best Practices
Struct and Method Guidelines
- Keep structs focused: Single responsibility principle
- Use embedding wisely: Prefer composition over inheritance
- Be consistent with receivers: All pointer or all value
- Document exported types: Clear godoc comments
- Consider zero values: Make them useful when possible
- Validate in constructors: Return errors for invalid states
- Use tags appropriately: JSON, validation, database mapping
Common Pitfalls
⚠️ Struct Gotchas
- Nil pointer receivers: Methods must handle nil gracefully
- Copying mutexes: Never copy structs containing sync.Mutex
- Large value receivers: Cause unnecessary copying
- Unexported fields: Break JSON marshaling across packages
- Embedded conflicts: Ambiguous field/method access
Practice Exercises
Exercise 1: Design a Cache
Create a generic cache struct with methods for Get, Set, Delete, and TTL support.
Exercise 2: Implement a Vector Type
Build a 3D vector struct with methods for math operations (add, subtract, dot product, cross product).
Exercise 3: Builder with Validation
Create a configuration builder that validates settings and returns detailed errors.
Exercise 4: Method Chaining
Design a query builder with fluent interface for building SQL-like queries.