09 — Structs
Field Layout and Memory Alignment
go
// The Go compiler lays out struct fields in declaration order, adding
// PADDING for alignment. The field order affects memory usage.
// ┌─────────────────────────────────────────────────────────────────┐
// │ struct { a bool; b int64; c bool } │
// │ a (1 byte) + 7 padding + b (8 bytes) + c (1 byte) + 7 pad = 24 │
// │ │
// │ struct { b int64; a bool; c bool } │
// │ b (8 bytes) + a (1 byte) + c (1 byte) + 6 padding = 16 │
// │ │
// │ Reordering fields from largest to smallest eliminates padding. │
// │ The `fieldalignment` linter (go vet -vettool) detects this. │
// └─────────────────────────────────────────────────────────────────┘
// ❌ Poorly aligned — 24 bytes (8 bytes wasted as padding):
type Bad struct {
a bool // 1 byte + 7 padding
b int64 // 8 bytes
c bool // 1 byte + 7 padding
}
// ✅ Well aligned — 16 bytes (fields ordered by size):
type Good struct {
b int64 // 8 bytes
a bool // 1 byte
c bool // 1 byte
// 6 bytes padding (to align to 8-byte boundary)
}
// Check alignment in practice:
// go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
// fieldalignment -fix ./...
Struct Literals — Named vs Positional
go
type User struct {
ID int64
Name string
Email string
IsActive bool
}
// ✅ Named fields — order-independent, survives field additions:
u := User{
ID: 1,
Name: "Alice",
Email: "alice@example.com",
IsActive: true,
}
// ❌ Positional — fragile: adding a field breaks ALL literals:
// u := User{1, "Alice", "alice@example.com", true}
// ✅ Partial — omitted fields get their zero value:
u2 := User{Name: "Bob"} // ID=0, Email="", IsActive=false
// ✅ Zero value — all fields zero:
var u3 User // User{ID:0, Name:"", Email:"", IsActive:false}
Embedding — Composition, Not Inheritance
go
// Go has NO inheritance. Embedding is COMPOSITION with method promotion.
// The embedded type's fields and methods are "promoted" to the outer struct.
type Repository struct {
db *sql.DB
}
func (r *Repository) Save(ctx context.Context, entity any) error {
return r.db.QueryRowContext(ctx, "INSERT ...", entity).Err()
}
func (r *Repository) FindByID(ctx context.Context, id int64) (any, error) {
return nil, nil
}
// Embed Repository — UserService gets Save and FindByID promoted:
type UserService struct {
*Repository // embedded POINTER — must be initialized
cache redis.Cache
}
// UserService can override (shadow) promoted methods:
func (s *UserService) FindByID(ctx context.Context, id int64) (any, error) {
// Check cache first, fall back to repo:
if v, ok := s.cache.Get(ctx, fmt.Sprintf("user:%d", id)); ok {
return v, nil
}
return s.Repository.FindByID(ctx, id) // explicit access to the embedded method
}
// Usage:
func newUserService(db *sql.DB, cache redis.Cache) *UserService {
return &UserService{
Repository: &Repository{db: db}, // must initialize embedded pointer
cache: cache,
}
}
Embedding an interface — the decorator pattern
go
// Embedding an interface lets you wrap/decorate any implementation:
type Logger interface {
Log(msg string)
}
type LoggingService struct {
Logger // embedded interface — accepts any Logger
inner Service
}
func (l *LoggingService) DoWork(ctx context.Context) error {
l.Log("starting work")
err := l.inner.DoWork(ctx)
if err != nil {
l.Log(fmt.Sprintf("work failed: %v", err))
}
return err
}
// Usage — inject any Logger:
svc := &LoggingService{
Logger: log.New(os.Stderr, "", 0),
inner: realService{},
}
The embedding ambiguity trap
go
type A struct{ X int }
type B struct{ X int }
// ❌ Ambiguous — which X is promoted?
type C struct {
A
B
}
// c.X // compile error: ambiguous selector c.X
// c.A.X // ok — explicit disambiguation
// c.B.X // ok
// This is the "diamond" problem — Go handles it by requiring explicit
// disambiguation. There's no virtual inheritance to resolve it automatically.
Struct Tags — Serialization Metadata
go
// Tags are raw strings read by reflection (encoding/json, database/sql, etc.)
// Format: `key:"value" key2:"value2"` (space-separated, double-quoted)
type User struct {
// JSON tags:
ID int64 `json:"id"` // serialize as "id"
Name string `json:"name" validate:"required"` // multiple tags
Email string `json:"email,omitempty"` // omit if zero value
Password string `json:"-"` // never serialize
Age int `json:"age,string"` // serialize as string "25"
// DB tags:
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ─── JSON tag options ───
// json:"name" — field name in JSON
// json:"name,omitempty" — omit if zero value (0, "", false, nil)
// json:"-" — never serialize
// json:"-,omitempty" — field literally named "-" (edge case)
// json:"name,string" — serialize as a JSON string ("42" not 42)
// json:"name,omitempty,omitempty" — last wins (just omitempty)
// ─── Validation tags (go-playground/validator) ───
// validate:"required" — must be non-zero
// validate:"min=1,max=100" — numeric range
// validate:"email" — must be a valid email
// validate:"oneof=active inactive suspended"
// go vet checks json tag syntax:
// go vet ./...
// // "struct field tag X not compatible with reflect.StructTag.Get"
The Zero-Value-Is-Useful Idiom
go
// Go idiom: design structs so the zero value is immediately usable.
// This eliminates the need for constructors in the common case.
// ✅ Standard library examples:
// sync.Mutex{} — zero value is an unlocked mutex, ready to use
// bytes.Buffer{} — zero value is an empty buffer, ready to use
// http.Server{} — zero value is a server with sensible defaults
type Counter struct {
mu sync.Mutex
count int
}
// Zero value is a usable counter (unlocked, count=0):
var c Counter
c.Inc()
fmt.Println(c.Value()) // 1
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
// ❌ ANTI-PATTERN: struct where zero value panics:
type BadConfig struct {
dsn string // zero value is "" → sql.Open("", "") panics
}
// User writes `var cfg BadConfig; db, _ := sql.Open(cfg.dsn)` → runtime panic
// ✅ Fix: provide a constructor for required fields:
func NewConfig(dsn string) *Config {
return &Config{dsn: dsn, timeout: 30 * time.Second}
}
Empty Struct — The Zero-Byte Type
go
// struct{} occupies 0 bytes. Use as a set value or a signal channel.
// ─── Set implementation (map[string]struct{} is Go's set) ───
type Set[T comparable] struct {
m map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{m: make(map[T]struct{})}
}
func (s *Set[T]) Add(v T) {
s.m[v] = struct{}{} // struct{}{} is the zero-byte value
}
func (s *Set[T]) Contains(v T) bool {
_, ok := s.m[v]
return ok
}
func (s *Set[T]) Remove(v T) {
delete(s.m, v)
}
func (s *Set[T]) Len() int {
return len(s.m)
}
// ─── Signal channel (chan struct{} carries no data, zero-size) ───
done := make(chan struct{})
go func() {
// work
close(done) // signal completion — all receivers unblock
}()
<-done // wait for completion
// ┌───────────────────────────────────────────────┐
// │ Why struct{} is 0 bytes: │
// │ It has no fields, so no data to store. │
// │ The compiler knows it's zero-width, so │
// │ all struct{}{} values share the same │
// │ address (or no address at all). │
// │ This makes map[T]struct{} more memory- │
// │ efficient than map[T]bool (bool is 1 byte). │
// └───────────────────────────────────────────────┘
Struct Comparison and reflect.DeepEqual
go
// Structs are comparable with == ONLY if all fields are comparable.
// Slices, maps, and functions are NOT comparable — a struct containing
// them can't use ==.
type Point struct{ X, Y int }
p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true — all fields comparable
type WithSlice struct {
Name string
Tags []string // slice — not comparable
}
// w1 == w2 // compile error: struct containing []string cannot be compared
// ✅ Use reflect.DeepEqual for structs with non-comparable fields:
import "reflect"
w1 := WithSlice{Name: "a", Tags: []string{"x"}}
w2 := WithSlice{Name: "a", Tags: []string{"x"}}
fmt.Println(reflect.DeepEqual(w1, w2)) // true
// ⚠️ reflect.DeepEqual is slow (reflection + recursion). For hot paths,
// implement a custom Equals method:
func (w WithSlice) Equals(other WithSlice) bool {
if w.Name != other.Name { return false }
return slices.Equal(w.Tags, other.Tags)
}
// Comparable structs can be MAP KEYS:
m := map[Point]string{{0, 0}: "origin", {1, 1}: "diagonal"}
💡 Tips & Tricks
- Performance: order struct fields from largest to smallest (int64 before bool) to minimize padding. Use the
fieldalignmentlinter to detect wasted space. For hot-path structs (one per request), saving 8-16 bytes × millions of requests = significant memory. - Idiom: design zero values to be usable (
sync.Mutex{},bytes.Buffer{},Counter{}) — eliminates constructor boilerplate. Reserve constructors for required fields that have no sensible zero value (DSN, API keys). - Idiom: use named-field literals (
User{ID: 1, Name: "Alice"}) — they're robust to field additions and self-documenting. Positional literals break silently when a field is inserted in the middle. - Idiom: embed interfaces for decoration (
type LoggingService struct { Logger; inner Service }) — the embedded interface promotes its methods, letting the outer struct delegate or wrap. - Idiom: use
struct{}as the map value for sets —map[string]struct{}uses 0 bytes per entry (vs 1 byte forbool). For large sets, this saves meaningful memory. - Safety:
reflect.DeepEqualis slow and can be surprising (it compares unexported fields too). For production code, implement a customEqualsmethod usingslices.Equalfor slice fields.
⚠️ Edge Cases & Gotchas
- Field order affects struct size: padding between misaligned fields wastes memory.
struct{ a bool; b int64 }= 16 bytes;struct{ b int64; a bool }= 12 bytes (with 4 trailing padding to align to 8). Usefieldalignmentto optimize. - Embedding promotes fields AND methods:
d.Xandd.Speak()both work. If the outer struct defines a method with the same name, it shadows (not overrides) — no virtual dispatch. - Embedding a pointer:
type S struct { *T }leaves the field asnilunless initialized. Promoted method calls on a nil embedded pointer panic. Initialize in the literal or constructor. - Ambiguous embedding: embedding two types with the same field/method name →
c.Xis a compile error. Disambiguate withc.A.X/c.B.X. - Embedding is not inheritance: a
DogembeddingAnimalis not anAnimalfor interface purposes. Embedding is composition; interfaces provide polymorphism. - Positional literals break on field insertion:
Point{1, 2}still compiles if you addZ int(it gets 0), but the intent is lost. Named literals survive. - Struct with non-comparable fields can't use
==: a struct with a slice/map/func field can't be compared with==(compile error). Usereflect.DeepEqualor a custom method. struct{}is 0 bytes: allstruct{}{}values may share the same address. Don't take&struct{}{}and compare addresses — they may be equal even for "different" values.- Struct tags are raw strings: the
json:"x" db:"y"format is a convention.go vetchecksjsontag syntax but not custom tags. Malformed tags are silently ignored by the consuming package. omitemptychecks for zero value:json:"x,omitempty"omits the field if it's 0, "", false, or nil. For a field where 0 is a valid value,omitemptywill incorrectly omit it — use a*int(nil = omit, 0 = include).
🧠 Quick Quiz
go
type S struct {
A int64
B bool
C int64
D bool
}
type T struct {
A int64
C int64
B bool
D bool
}
fmt.Println(unsafe.Sizeof(S{}), unsafe.Sizeof(T{}))
What's printed (on a 64-bit system)?
Answer
32 24
S (poorly ordered): A(8) + B(1) + 7 padding + C(8) + D(1) + 7 padding = 32 bytes
T (well ordered): A(8) + C(8) + B(1) + D(1) + 6 padding = 24 bytes
The same fields, different order, 8 bytes saved (25% smaller). For a struct allocated millions of times (one per request, one per row), this is significant — 8 bytes × 1M = 8MB saved.
This is why the fieldalignment linter exists — it detects and can auto-fix (-fix) field ordering.
📚 What's Next
→ 10 — Pointers — escape analysis, value vs pointer receivers, nil pointer semantics, and when pointers help vs hurt performance.