Golang Pointer vs Value Receiver

Β· 1979 words Β· 10 minute read

Overview πŸ”—

In Go, methods can have either value receivers or pointer receivers. A receiver is what appears between the func keyword and the method name. It defines the type on which the method is allowed to be called. For example, if we have a MyStruct type:

type MyStruct struct {
    Field int
}

We can attach a method to it in two ways. Either:

func (m MyStruct) DoSomething() {
    // This uses a value receiver.
}

Or:

func (m *MyStruct) DoSomething() {
    // This uses a pointer receiver.
}

Both choices affect how the compiler treats method calls. They also affect how data moves around in memory. These choices can have implications for concurrency, method sets, and performance. They can also influence how easy (or hard) it is to read your code.


Value Receivers πŸ”—

Definition πŸ”—

A value receiver means that when you call a method, Go copies the value. Inside the method, you see only that copy. If you modify fields within the method, it does not affect the caller’s copy.

Basic Example πŸ”—

type Circle struct {
    Radius float64
}

// Area uses a value receiver
func (c Circle) Area() float64 {
    c.Radius = 0 // This change does not affect the original Circle
    return 3.14 * c.Radius * c.Radius
}

When we do:

circle := Circle{Radius: 5}
fmt.Println(circle.Area())
fmt.Println(circle.Radius) // Still 5, not 0

We get the correct area for the radius of 5, and the original Circle stays the same.

When to Use Value Receivers πŸ”—

  • Immutability: If you do not want your method to alter the original value, use a value receiver.
  • Small Structs: If your struct is small, the cost of copying is low. There is no harm in passing by value, and it may be clearer to show the method does not change the struct.
  • Cleaner Concurrency: If you pass small values by copy, you reduce the risk of data races. Using pointer receivers can cause shared state in code. This can lead to tricky race conditions if you modify fields.

Pros πŸ”—

  • Simpler to read. It is obvious that the method does not mutate the struct.
  • Easier concurrency. You do not have to worry as much about shared state.
  • Predictable. The method sees only a copy and cannot break anything outside it.

Cons πŸ”—

  • Might be expensive for large structs. A copy of a big struct can trigger more memory movement.
  • You cannot modify the original struct’s fields directly from the method.

Pointer Receivers πŸ”—

Definition πŸ”—

A pointer receiver uses a pointer to a struct rather than a direct copy. Inside the method, you see the original struct. You can change fields in place.

Basic Example πŸ”—

type MyStruct struct {
    Field int
}

// UpdateField uses a pointer receiver
func (m *MyStruct) UpdateField(newVal int) {
    m.Field = newVal
}

When we do:

s := MyStruct{Field: 10}
fmt.Println(s.Field) // 10
s.UpdateField(20)
fmt.Println(s.Field) // 20

The value changes from 10 to 20 because we used a pointer.

When to Use Pointer Receivers πŸ”—

  • Need to Mutate: If you want your method to modify the struct’s fields, a pointer receiver is the direct choice.

  • Large Structs: If your struct is big, you might avoid the cost of copying by using a pointer. This can help performance when you call methods many times in tight loops.

  • Method Chaining: Some developers like to return the pointer from a method so they can chain calls. For example:

    func (m *MyStruct) SetField(val int) *MyStruct {
        m.Field = val
        return m
    }
    
    // Then they call it like
    my := &MyStruct{}
    my.SetField(10).SetField(20)
    

Pros πŸ”—

  • You can mutate the original data in methods.
  • You avoid extra copies for large structs.
  • You can do method chaining in your code.

Cons πŸ”—

  • Leads to shared mutable state. This can cause confusion when multiple parts of the program share the same pointer.
  • Complicates concurrency if the same pointer is accessed in multiple goroutines without synchronization.
  • Might not always yield the best performance. Pointer usage can trigger escape analysis, and the compiler might move data to the heap in ways you do not expect.

Method Sets and Receivers πŸ”—

In Go, the receiver type affects which methods belong to which type. This can have subtle effects. For instance:

  • If you define func (m MyStruct) SomeMethod(), you can call SomeMethod() on MyStruct values and on *MyStruct pointers. In the language of method sets, SomeMethod belongs to both MyStruct and *MyStruct.

  • If you define func (m *MyStruct) SomeMethod(), then SomeMethod is only in the method set of *MyStruct. That means that if you want MyStruct (the value type) itself to implement an interface requiring SomeMethod, you’re out of luck. However, in actual code you can still call a pointer-receiver method on an addressable MyStruct value. Go automatically takes the address of the value for you. For example, myStructVar.SomeMethod() is effectively (&myStructVar).SomeMethod() if myStructVar is addressable.

This fact leads some developers to prefer value receivers by default so that the methods are available to both MyStruct and *MyStruct method sets. That might make code simpler in some cases.


Performance Considerations πŸ”—

Performance is a big factor when choosing pointer vs value receivers. But it is not as simple as “pointer = faster.” Let’s go deeper:

  1. Copying Overhead: Value receivers copy the whole struct. For small structs (say up to a few words in size), this cost is small. For large structs with many fields, copying can cost more.

  2. Compiler Optimizations: Go’s compiler can optimize some operations. If the struct is not large or does not escape to the heap, passing by value might still be efficient.

  3. Escape Analysis: If the struct or fields are passed around or used in certain ways, the compiler might move them to the heap. This can offset any gains you get from using pointers if you do not know how the compiler handles the memory.

  4. Garbage Collection: More pointers can mean more references for the garbage collector to track. Value copies do not create references in the same way. If you have a data structure with many pointers, it might be more taxing on the GC.

  5. Caching Behavior: Copying can sometimes be cheaper than pointer chasing in modern CPUs. The data you need may sit in the CPU cache. By copying it, you may get cache-friendly behavior. By using pointers that point to data in different parts of memory, you might cause more cache misses.

It is best to measure rather than guess. Write benchmarks if performance is key. Often, the simplest approach (value receivers by default) works best until you see real performance issues.


Concurrency Concerns πŸ”—

Pointer receivers can create mutable shared state. If multiple goroutines have access to the same pointer, they can all modify the same data. This can cause data races or obscure bugs if you do not use locks or channels.

Value receivers reduce the risk of shared state because they copy the data. Each goroutine sees its own copy. Mutations to one copy do not affect others. But note that if the struct has pointer fields inside it, you are not always safe. The inner pointers can still lead to shared data.

If you need concurrency, aim for a design with minimal shared data. Pass copies around if possible. If you must share data, use sync.Mutex, sync.RWMutex, or channels. Or consider using the concurrency patterns that keep data local to each goroutine.


Example: Large Struct Scenario πŸ”—

Here is a more advanced example to illustrate a large struct. Suppose we have a type that holds a big array and we want to call a method many times.

type BigData struct {
    Items [1000]int
}

// Process modifies each item in the array.
func (b *BigData) Process() {
    for i := 0; i < len(b.Items); i++ {
        b.Items[i] = b.Items[i] * 2
    }
}

We use a pointer receiver to avoid copying the entire array when we call Process(). If we used a value receiver:

func (b BigData) Process() {
    for i := 0; i < len(b.Items); i++ {
        b.Items[i] = b.Items[i] * 2
    }
}

Every call to Process() would copy 1000 integers, which can be expensive if we call it in a tight loop. That might slow down the program and create more garbage for the runtime to handle.

But if you do not need to mutate the original array, a value receiver might reduce complexity because each method call does not affect the original data. It depends on your use case.


Example: Immutability by Choice πŸ”—

Here is another example. Suppose we have a type that we want to treat as immutable. We can define methods on it with value receivers:

type Vector2D struct {
    X, Y float64
}

func (v Vector2D) Add(other Vector2D) Vector2D {
    return Vector2D{
        X: v.X + other.X,
        Y: v.Y + other.Y,
    }
}

func (v Vector2D) Scale(factor float64) Vector2D {
    return Vector2D{
        X: v.X * factor,
        Y: v.Y * factor,
    }
}

We return new copies instead of updating the original in place. This approach helps us avoid confusion about which part of the code changed a Vector2D. We also avoid side effects. This pattern often works well with concurrency. You do not need locks if your data does not change.


My Opinionated Stance πŸ”—

I prefer to start with value receivers. Then I switch to pointer receivers if I need one of these benefits:

  1. Mutation: If it is natural for the method to mutate the receiver’s fields, I use a pointer. That is the only way to do it.
  2. Large Data: If the struct is large enough to cause performance issues with copying, I measure. If the overhead is real, I switch to pointers.

I do not blindly choose pointers because they might break concurrency safety or create hidden side effects. I value easy-to-read code. Code that uses pointer receivers for no reason might cause confusion. People ask, “Why is this code mutating a pointer?” when the code does not need to mutate anything.

But I also want to warn that measuring performance is tricky. Microbenchmarks can mislead. Real-world performance depends on many variables. The overhead of copying might be small in practice, or the runtime might optimize away the overhead. If your type has references or slices inside, it might not be as big a copy as you think.


Common Gotchas πŸ”—

Nil Receivers πŸ”—

A pointer receiver can be nil. Your code should check for nil if you expect that to happen. Value receivers cannot be nil. This can lead to panic if you call a pointer receiver on a nil pointer.

Binding Methods πŸ”—

Methods defined on a pointer receiver belong only to pointer types, while methods defined on a value receiver belong to both pointer and value types. That can be surprising if you try to call myStruct.SomePointerMethod() on a non-pointer variable.

Unintentional Escapes πŸ”—

If you pass the pointer around, the data might escape to the heap. This can affect garbage collection. Sometimes passing by value is more efficient if you keep the data on the stack.

Shadowing πŸ”—

If you do a method with a value receiver and then do m.Field = 10 inside it, it can look like you changed the field. But you changed only the copy. The original data remains unchanged. This is a common source of bugs for new Go developers.


Final Advice πŸ”—

  • Use value receivers unless you have a clear reason to use pointers.
  • Switch to pointers if you need to mutate data or if profiling shows that copying large structs is too slow.
  • Be consistent. Use the same style across your codebase for the same type of struct.
  • Write benchmarks to check performance claims. Guessing about pointer performance can lead to poor design.

Go is simple, but it gives you these choices. The best approach is to keep your code easy to read and easy to test. Think about concurrency from the start. Avoid side effects if you can. Then, only optimize if you find real performance bottlenecks.