Structs
Creating structs, value vs pointer receivers, what counts as a copy, and the range trap.
Creating a struct
A struct type is a blueprint. Nothing exists in memory until you create a value from it.
type Account struct {
Owner string
Balance float64
}
a1 := &Account{Owner: "Suprim", Balance: 1000} // named fields (preferred)
a2 := &Account{"Ram", 500} // positional: must match field order
var a3 Account // zero value: {"" 0}
a3.Owner = "Sita" // then set fields directlyfmt.Println(a1) // &{Suprim 1000} & means it's a pointer
fmt.Printf("%+v\n", a1) // &{Owner:Suprim Balance:1000} %+v adds field names
fmt.Println(a3) // {Sita 0} no &, it's a valueUse named fields
Positional literals break silently if a field is added or the order changes. Use named fields unless the struct is tiny and local.
Value vs pointer receivers
Whether a method changes the original depends only on the method's receiver. It doesn't matter whether your variable is a value or a pointer, because Go converts between them for you.
| Your variable | Method receiver | What Go actually does | Original changes? |
|---|---|---|---|
a3 (value) | Deposit (*Account) | (&a3).Deposit(...) | Yes |
a1 (pointer) | BrokenDeposit (Account) | (*a1).BrokenDeposit(...) | No, works on a copy |
func (a Account) BrokenDeposit(amt float64) { a.Balance += amt } // a is a copy: change is lost
func (a *Account) Deposit(amt float64) { a.Balance += amt } // a points to the real struct- Setting fields directly (
a3.Owner = "Sita") always changes the variable itself. Receivers only matter inside a method. - A method that only reads shows current values with either receiver.
When to use which
Use a pointer receiver when:
- the method changes the struct,
- the struct is big (a value receiver copies the whole thing on every call),
- it holds something that must not be copied, like a
sync.Mutex, - the type's other methods already use pointers. Don't mix receiver types on one type.
Use a value receiver for small, read-only methods on types where no method needs a pointer.
What counts as a copy
var a3 Account is a new, standalone value. It isn't a copy of anything. A copy only happens when an existing value is duplicated:
b := a3 // b is a copy of a3
accounts := []Account{*a1, *a2, a3} // each element is a copy
for _, acc := range accounts { } // acc is a copy of each element
func (a Account) BrokenDeposit(...) // a is a copy of what it was called onCopying a pointer doesn't copy the struct
A copied address still points to the same struct. This is why a slice of pointers works in the trap below.
The range trap
accounts := []Account{*a1, *a2, a3} // copies of the accounts
for _, acc := range accounts {
acc.Deposit(100) // changes acc, a copy of a copy. Nothing outside the loop changes.
}Two ways to fix it, and they do different things:
// range still copies, but it copies the POINTER, which points to the same struct.
// Changes a1, a2 and a3.
accounts := []*Account{a1, a2, &a3}
for _, acc := range accounts {
acc.Deposit(100)
}In the real world
A tiny HTTP API that takes deposits, like a handler you'd write with Gin. Everything from this page shows up in it:
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
)
// What the client sends. Struct tags map JSON keys to fields.
type DepositRequest struct {
Owner string `json:"owner"`
Amount int64 `json:"amount"` // cents
}
// One Store is shared by every request.
type Store struct {
mu sync.Mutex
balances map[string]int64
}
// The zero value isn't usable: its map is nil, and writing to a nil map panics.
func NewStore() *Store {
return &Store{balances: make(map[string]int64)}
}
// Pointer receiver: every request must lock the SAME mutex.
func (s *Store) Deposit(owner string, amount int64) int64 {
s.mu.Lock()
defer s.mu.Unlock()
s.balances[owner] += amount
return s.balances[owner]
}
func (s *Store) handleDeposit(w http.ResponseWriter, r *http.Request) {
var req DepositRequest
// &req: Decode has to write into our variable, not into a copy of it.
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Amount <= 0 {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
balance := s.Deposit(req.Owner, req.Amount)
json.NewEncoder(w).Encode(map[string]int64{"balance": balance})
}
func main() {
store := NewStore()
http.HandleFunc("POST /deposit", store.handleDeposit)
log.Fatal(http.ListenAndServe(":8089", nil))
}go run main.go
curl -X POST localhost:8089/deposit -d '{"owner":"suprim","amount":50000}'
# {"balance":50000}Where the struct rules matter:
Decode(&req)takes a pointer.Decodehas to write into your variable. Passingreqwould hand it a copy, and yourreqwould stay empty. This is the same rule asTransfer(a1, &a3, 200).Storeneeds pointer receivers. Every request shares oneStore, so every request must lock the same mutex.- The zero value isn't always usable.
var s Storehas a nil map, and writing to a nil map panics. That's whyNewStore()exists: a constructor is the usual Go answer when the zero value isn't ready to use. - Struct tags (
`json:"owner"`) tellencoding/jsonwhich JSON key goes in which field.
The bug you'd hit
Change Deposit to a value receiver, func (s Store) Deposit(...). Each call now locks its own copy of the mutex, so nothing is locked at all. The map isn't copied, though: both copies point to the same map. Under load the server crashed with fatal error: concurrent map writes. go vet catches it before you run anything: Deposit passes lock by value: Store contains sync.Mutex.
My doubts
"I declared var config ServerConfig as a value. Why did setting its fields and calling Display() show my values?"
var creates a variable, not a receiver. Writing to its fields changes it. Display only reads, so it shows the current values with either receiver type.
"Is a3 a copy of the original struct?"
There is no original struct. The type is a blueprint, and a3 is its own value.
"Why did acc.Summary() skip a2?"
It didn't skip anything. It printed nothing at all. Summary() returns a string, and the lines I saw came from earlier Println calls.
Mistakes I made
| What I thought | What's actually true | Fix |
|---|---|---|
a3.Deposit(2000) works on a copy because a3 is a value | Go calls (&a3).Deposit, so a3 changes | Look at the receiver, not the variable |
Passing &a3 to Transfer changes "a copy" | &a3 is the address of a3 itself | |
| The bonus loop changed a1 and a2 | Nothing changed: the slice literal and range both copy | []*Account, or loop by index |
acc.Summary() prints | It returns a string, which was thrown away | fmt.Println(acc.Summary()) |
Withdraw with a.Balance <= amount | Refuses to withdraw the exact balance | Use < |
Println shows &{"Suprim" 1000} | %v prints strings without quotes: &{Suprim 1000} |
Practice problem: bank account
- Define
AccountwithOwner stringandBalance float64. - Create three accounts: named fields, positional fields, and a zero value. Print each with
Printlnand%+v. - Write
Summary() string,Deposit(amount)andWithdraw(amount) bool, choosing a receiver for each. - Write
BrokenDepositwith the other receiver type. Predict the output, then run it. - Write
Transfer(from, to *Account, amount float64) bool. Send 200 from Suprim to Sita. - Bonus: put the accounts in a slice,
rangeover it and deposit 100 into each. Did the balances change? Why?
My solution vs best practice
// Best-practice version of ../main.go (bank account problem).
// Run: go run ./revision/structs/improved (from ~/projects/go)
//
// What changed vs my solution, and why:
// 1. Money is int64 cents, not float64. Floats can't store most decimals exactly
// (0.1 + 0.2 != 0.3), so balances drift. Integer cents are always exact.
// 2. All methods use pointer receivers. Once one method needs a pointer, Go style
// is to use pointers for all of them so the type behaves one consistent way.
// 3. Errors instead of bool. Withdraw/Transfer return an error that says WHY it
// failed (insufficient funds vs invalid amount). The caller can check which with
// errors.Is.
// 4. Amounts are validated: depositing -500 would otherwise be a hidden withdrawal.
// 5. No `if a == nil` checks. Calling a method on a nil *Account is a programmer bug;
// silently returning hides it. Let it panic so you find the bug.
// 6. Transfer reuses Withdraw and Deposit instead of repeating the balance logic.
// 7. Summary returns a string with no trailing "\n"; the caller decides on newlines.
// 8. Always use named fields in literals. Positional ones break silently if a field
// is added or the order changes.
package main
import (
"errors"
"fmt"
)
var (
ErrInvalidAmount = errors.New("amount must be positive")
ErrInsufficientFunds = errors.New("insufficient funds")
)
type Account struct {
Owner string
Balance int64 // in cents: 1000_00 = $1000.00
}
func (a *Account) Summary() string {
return fmt.Sprintf("%s has $%d.%02d", a.Owner, a.Balance/100, a.Balance%100)
}
func (a *Account) Deposit(amount int64) error {
if amount <= 0 {
return ErrInvalidAmount
}
a.Balance += amount
return nil
}
func (a *Account) Withdraw(amount int64) error {
if amount <= 0 {
return ErrInvalidAmount
}
if a.Balance < amount {
return ErrInsufficientFunds
}
a.Balance -= amount
return nil
}
// Transfer is a plain function: it belongs to neither account more than the other.
func Transfer(from, to *Account, amount int64) error {
if err := from.Withdraw(amount); err != nil {
return fmt.Errorf("transfer from %s: %w", from.Owner, err)
}
// Can't fail: Withdraw already rejected amount <= 0.
_ = to.Deposit(amount)
return nil
}
func main() {
suprim := &Account{Owner: "Suprim", Balance: 1000_00}
ram := &Account{Owner: "Ram", Balance: 500_00}
var sita Account // zero value: {"" 0}
sita.Owner = "Sita"
fmt.Printf("%+v\n", *suprim) // {Owner:Suprim Balance:100000}
if err := Transfer(suprim, &sita, 200_00); err != nil {
fmt.Println("error:", err)
}
fmt.Println(suprim.Summary()) // Suprim has $800.00
fmt.Println(sita.Summary()) // Sita has $200.00
// The error says why it failed, and errors.Is can check the reason
// even though Transfer wrapped it with %w.
err := Transfer(ram, &sita, 9999_00)
fmt.Println(err) // transfer from Ram: insufficient funds
fmt.Println(errors.Is(err, ErrInsufficientFunds)) // true
// Slice of pointers: range copies each pointer, but the copy points to the same
// account, so the originals change.
accounts := []*Account{suprim, ram, &sita}
for _, acc := range accounts {
_ = acc.Deposit(100_00) // safe to ignore: 100_00 is a valid amount
}
for _, acc := range accounts {
fmt.Println(acc.Summary()) // $900.00, $600.00, $300.00
}
}What changed and why
- Money is
int64cents, notfloat64. Floats can't store most decimals exactly (0.1 + 0.2 != 0.3), so balances drift. Integer cents are always exact. - All methods use pointer receivers. Once one method needs a pointer, use pointers for all of them.
- Errors instead of
bool.WithdrawandTransferreturn an error that says why they failed. The caller can check the reason witherrors.Is, even after wrapping with%w. - Amounts are validated. Without it, depositing
-500is a hidden withdrawal. - No
if a == nilchecks. Calling a method on a nil pointer is a bug. Returning quietly hides it, and a panic shows you where it is. TransferreusesWithdrawandDepositinstead of repeating the balance logic.Summaryhas no trailing\n. The caller decides about newlines.- Named fields everywhere.