Skip to Content
Course content

20: Methods and Receivers

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a junior dev who was building a simple game engine. He had a Player struct with a Health field and a method called TakeDamage(amount int). On the surface, the code looked perfect. He was calling player.TakeDamage(20), but during playtests, the players were effectively immortal. Their health never dropped.

The culprit? He had defined the method with a value receiver instead of a pointer receiver. Inside the method, Go was creating a complete copy of the Player struct, subtracting the health from that copy, and then immediately throwing the copy away when the function returned. The original player remained untouched. It's a rite of passage in Go—we've all been there—but it highlights the most critical part of understanding methods: knowing exactly what you're operating on.

Attaching Behavior to Data

In many languages, you're used to classes where data and methods are bundled together by default. Go does things differently. We define our data in a struct and then "attach" functions to that struct. These functions are called methods, and the part that links the function to the struct is called the receiver.

Take a look at this example for a simple Rectangle. I don't want a standalone function like CalculateArea(r Rectangle); I want the rectangle to "know" how to calculate its own area.

type Rectangle struct {
    Width, Height float64
}

// This is a method. (r Rectangle) is the receiver.
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    fmt.Println(rect.Area()) // 50
}

The receiver (r Rectangle) acts much like this or self in other languages. It gives the method access to the fields of the struct instance it was called on. Notice how we call rect.Area() rather than passing rect as an argument. It's cleaner and creates a more intuitive API for whoever is using your code.

Choosing Between Value and Pointer Receivers

This is where the "immortal player" bug happens. You have two choices for your receiver: (r Rectangle) (a value receiver) or (r *Rectangle) (a pointer receiver). I usually follow two simple rules to decide which one to use.

First, if the method needs to modify the receiver, you must use a pointer receiver. If you use a value receiver, you're working on a copy, and any changes you make vanish the moment the method finishes. Second, if the struct is very large, using a pointer is more efficient because you're passing a memory address rather than copying the entire data structure across the stack.

Let's compare them using a BankAccount example:

type BankAccount struct {
    Owner   string
    Balance float64
}

// Value receiver: Good for read-only operations.
func (b BankAccount) DisplayBalance() {
    fmt.Printf("%s has %.2f\n", b.Owner, b.Balance)
}

// Pointer receiver: Necessary for modifying the balance.
func (b *BankAccount) Deposit(amount float64) {
    b.Balance += amount
}

func main() {
    acc := BankAccount{Owner: "Alice", Balance: 100.0}
    
    acc.Deposit(50.0)       // Balance is now 150.0
    acc.DisplayBalance()    // Prints 150.0
}

One detail that often trips people up: Go is smart. If you have a value acc but call a pointer method acc.Deposit(), Go automatically converts it to (&acc).Deposit() for you. You don't have to manually manage the pointers at the call site, which keeps the code looking sleek.

Consistency Over Optimization

You might be tempted to mix and match receivers in a single struct based on whether the method modifies data or not. I'd advise against that. If some of your methods require pointer receivers to mutate state, make all of your methods pointer receivers.

Why? Because it prevents confusion and ensures consistency. If you're constantly switching between (b BankAccount) and (b *BankAccount), you're more likely to make a mistake or create an inconsistent interface. When in doubt, just stick with pointers. It's the industry standard for most mutable state in Go.




📋 Practical Task

Implementing a Digital Wallet System

You need to build a basic wallet system to handle credits for a user. Your goal is to ensure that the wallet balance can be modified correctly while providing a way to view the wallet's status without altering it.

Requirements:

  • Create a Wallet struct with two fields: UserID (string) and Credits (int).
  • Implement a method AddCredits(amount int). This method must use a pointer receiver to ensure the actual wallet balance increases.
  • Implement a method SpendCredits(amount int). This method should check if there are enough credits; if so, subtract them. If not, it should print "Insufficient funds". This also requires a pointer receiver.
  • Implement a method Summary() string. This should return a formatted string like "User [ID] has [X] credits". This can use a value receiver.

Test your implementation: Create a wallet for "User_123" with 100 credits, add 50, spend 30, and then print the final summary. If your pointer receivers are set up correctly, the final balance should be 120.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.