Go in 2 Weeks #03: Structs, Methods, Pointer vs Value Receivers and Embedding Instead of Inheritance
Key takeaways
No class keyword in Go: structs, methods, pointer vs value receivers, embedding, and NewXxx constructors compared to C++. Part of the 2-week Go series for C++ devs.
Series overview
Go in 2 Weeks #03 | Full series index
This post covers Days 5–6 of the two-week Go curriculum for C++ developers.
Previous: #02 Memory & data structures ← | → Next: #04 Interfaces
Introduction: OOP without classes
In C++, class bundles data and behavior; inheritance reuse is common. Go has no classes and no inheritance. Instead, structs and composition keep models simple and flexible. “Favor composition over inheritance” is not just a slogan—it is how Go is meant to be written.
The shift is bigger than a syntax change. A C++ class is one unit that owns its data, its behavior, its access control and its place in a hierarchy. Go splits those apart: a struct holds data, methods are ordinary functions attached to a type, visibility is decided per package by capitalization, and polymorphism is handled separately by interfaces (next post). There are no constructors, destructors, overloading, protected, or virtual functions. What looks like a loss at first is mostly a removal of choices: there is one way to define a type, and reuse happens by putting one type inside another.
Notes for C++ developers
What changes in practice
The adjustments that take the longest for C++ programmers are not the syntax but the habits. There is no destructor, so cleanup of files, locks and connections is explicit (defer f.Close()), and memory is reclaimed by the garbage collector. There is no const method, so the only signal that a method does not mutate is a value receiver. And since there are no hierarchies, the question “what is this type a kind of?” is replaced by “what does this type need to do?”, answered by small interfaces defined where they are used. The small language surface pays off in code review and onboarding: most Go codebases look alike.
Structs: classes reimagined
C++ vs Go
class Person {
private:
std::string name;
int age;
public:
Person(const std::string& n, int a) : name(n), age(a) {}
void SetName(const std::string& n) { name = n; }
std::string GetName() const { return name; }
void SetAge(int a) { age = a; }
int GetAge() const { return age; }
void Introduce() const {
std::cout << "I'm " << name << ", " << age << " years old\n";
}
};
Person p("Alice", 30);
p.Introduce();
package main
import "fmt"
type Person struct {
Name string // exported (capitalized)
age int // package-private
}
func NewPerson(name string, age int) *Person {
return &Person{Name: name, age: age}
}
func (p *Person) Age() int { return p.age }
func (p *Person) SetAge(age int) { p.age = age }
func (p *Person) Introduce() {
fmt.Printf("I'm %s, %d years old\n", p.Name, p.age)
}
func main() {
p := NewPerson("Alice", 30)
p.Introduce()
}
Differences:
- Visibility: capitalized = exported; lowercase = package-private
- Constructors:
NewXxxis a convention, not syntax - Methods: defined outside the type with a receiver
Visibility is per package, not per type. Code anywhere in the same package can read and write p.age directly; “private” in Go protects a package’s internals from other packages, not a type from its neighbors. That is also why Go code has fewer getters and setters than C++: exported fields like Name are accessed directly, and a method like Age() exists only where access needs control (validation, or keeping the field read-only outside the package). By convention a getter is named Age(), not GetAge(). The Go Person has no SetName, because assigning p.Name is the idiomatic way to change an exported field.
NewPerson returns a pointer to a local variable, which would be a dangling pointer in C++. In Go it is safe: the compiler’s escape analysis sees that the value outlives the function and allocates it on the heap. You do not choose stack or heap in Go; the compiler does, and go build -gcflags=-m shows its decisions.
Struct initialization
package main
type Point struct {
X, Y int
}
func main() {
p1 := Point{10, 20}
p2 := Point{X: 10, Y: 20}
p3 := Point{X: 10}
p4 := Point{}
p5 := &Point{X: 10, Y: 20}
p6 := new(Point)
_ = p1; _ = p2; _ = p3; _ = p4; _ = p5; _ = p6
}
Every field not mentioned gets its zero value (0, "", false, nil), so p3 is {10 0} and p4 is {0 0}; there is no uninitialized memory in Go. Good Go types are designed so that the zero value is usable, as sync.Mutex and bytes.Buffer are, which often removes the need for a constructor at all. Prefer the keyed form (p2): the positional form p1 breaks at compile time when someone adds a field, and silently assigns values to the wrong fields when two fields of the same type are reordered, which is why go vet warns about unkeyed literals of types from other packages. &Point{...} and new(Point) both produce a *Point; the literal form is more common because it can set fields at the same time.
Methods and receivers
Go methods are functions with a receiver—explicit, unlike hidden this.
C++ vs Go
class Counter {
int count = 0;
public:
void Increment() { count++; }
int GetCount() const { return count; }
};
package main
type Counter struct{ count int }
func NewCounter() *Counter { return &Counter{count: 0} }
func (c *Counter) Increment() { c.count++ }
func (c *Counter) GetCount() int { return c.count }
Syntax: func (receiver Type) MethodName() { }
The receiver is just the first parameter written in a special position; c.Increment() is shorthand for (*Counter).Increment(c), and that method expression form actually compiles. The receiver is conventionally a short name derived from the type (c, p, srv), never this or self. Methods can be declared only on types defined in the same package, so you cannot add a method to int or to a type from another package; define your own type (type Celsius float64) instead. There is no overloading either: Counter cannot have two methods named Add with different parameters, which is why Go APIs use distinct names like AddInt and AddString or variadic parameters.
Pointer vs value receivers
Value receiver
package main
import (
"fmt"
"math"
)
type Point struct{ X, Y int }
func (p Point) Distance() float64 {
return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}
func (p Point) Move(dx, dy int) {
p.X += dx // does NOT mutate original
p.Y += dy
}
func main() {
p := Point{3, 4}
fmt.Println(p.Distance())
p.Move(10, 10)
fmt.Println(p) // {3 4}
}
Pointer receiver
package main
import "fmt"
type Point struct{ X, Y int }
func (p *Point) Move(dx, dy int) {
p.X += dx
p.Y += dy
}
func (p *Point) Reset() {
p.X, p.Y = 0, 0
}
func main() {
p := Point{3, 4}
p.Move(10, 10) // Go passes &p automatically
fmt.Println(p)
p2 := &Point{1, 2}
p2.Move(5, 5)
fmt.Println(*p2)
}
A value receiver gets a copy, exactly like passing a struct by value in C++, so the first Move changes only its local copy and the caller’s p is still {3 4}. Nothing warns you about this at compile time, which makes it one of the most common bugs when coming from languages with reference semantics for objects. The pointer receiver version modifies the caller’s value. Go inserts the & automatically when you call a pointer method on an addressable variable (p.Move becomes (&p).Move), and dereferences automatically in the other direction, so call sites look the same either way.
The automatic & has limits that produce confusing errors. Map elements are not addressable, so with m := map[string]Point{...}, m["a"].Move(1, 1) fails with cannot call pointer method Move on Point; store pointers in the map (map[string]*Point) or copy, modify and write back. Likewise a value returned from a function cannot have pointer methods called on it directly.
Choosing receivers
flowchart TD
A[Define method] --> B{Mutate fields?}
B -->|Yes| C["Pointer receiver *T"]
B -->|No| D{Large struct?}
D -->|Yes ~64B+| C
D -->|Small| E{Consistency}
E -->|Other methods use *T| C
E -->|All value receivers| F["Value receiver T"]
Rules of thumb:
- Pointer: mutating methods, large structs, or consistency with other methods on the type
- Value: small immutable reads, or when copying is cheap and clarity benefits
Two rules make the choice more than a style question. First, method sets: a pointer-receiver method belongs to *T only, so a T value does not satisfy an interface that requires it. var s fmt.Stringer = p fails with Point does not implement fmt.Stringer (method String has pointer receiver) until you write &p. Second, types containing a sync.Mutex or other state that must not be copied need pointer receivers everywhere, because a value receiver copies the mutex along with the struct; go vet reports this as passes lock by value. The 64-byte figure in the diagram is only a rough guide; the copy cost rarely matters as much as these semantic rules, and mixing receiver kinds on one type is the thing to avoid.
Struct embedding: reuse without inheritance
C++ inheritance vs Go embedding
class Animal {
protected:
std::string name;
public:
Animal(const std::string& n) : name(n) {}
virtual void Speak() { std::cout << "Some sound\n"; }
void Eat() { std::cout << name << " is eating\n"; }
};
class Dog : public Animal {
public:
Dog(const std::string& n) : Animal(n) {}
void Speak() override { std::cout << "Woof!\n"; }
void Fetch() { std::cout << name << " is fetching\n"; }
};
Dog d("Buddy");
d.Speak();
d.Eat();
d.Fetch();
package main
import "fmt"
type Animal struct{ Name string }
func (a Animal) Speak() { fmt.Println("Some sound") }
func (a Animal) Eat() { fmt.Printf("%s is eating\n", a.Name) }
type Dog struct {
Animal
Breed string
}
func (d Dog) Fetch() {
fmt.Printf("%s is fetching\n", d.Name)
}
func (d Dog) Speak() {
fmt.Println("Woof!")
}
func main() {
d := Dog{
Animal: Animal{Name: "Buddy"},
Breed: "Golden Retriever",
}
d.Speak()
d.Eat()
d.Fetch()
}
Differences:
- No inheritance in Go
- Embedding promotes fields and methods of the inner type
- Defining
SpeakonDogshadowsAnimal.SpeakforDogvalues
Shadowing is not overriding, and the difference is where C++ intuition fails. Suppose Animal had a method Greet() that calls a.Speak(). Calling d.Greet() runs Animal.Greet with the embedded Animal as its receiver, and that receiver’s Speak is Animal.Speak, so it prints “Some sound”, not “Woof!”. There is no virtual dispatch through embedding: the inner type knows nothing about the outer one. Also, a Dog is not an Animal; you cannot pass d to a function taking Animal (you would pass d.Animal, the embedded value). When you need “any animal that can speak”, that is an interface, type Speaker interface { Speak() }, which both Animal and Dog satisfy.
How embedding works
package main
import "fmt"
type Base struct{ Value int }
func (b Base) Print() { fmt.Println("Base:", b.Value) }
type Derived struct {
Base
Extra string
}
func main() {
d := Derived{Base: Base{Value: 42}, Extra: "extra"}
d.Print()
fmt.Println(d.Value)
d.Base.Print()
}
An embedded field is an ordinary field whose name is the type name (d.Base). What embedding adds is promotion: the compiler lets you write d.Print() and d.Value and rewrites them to d.Base.Print() and d.Base.Value. So d.Print() and d.Base.Print() are the same call, and both print Base: 42. Promoted methods also count toward the outer type’s method set, which is how embedding a type lets the outer type satisfy the same interfaces.
Multiple embeds
package main
import "fmt"
type Logger struct{}
func (Logger) Log(msg string) { fmt.Println("[LOG]", msg) }
type Validator struct{}
func (Validator) Validate(data string) bool { return data != "" }
type Service struct {
Logger
Validator
name string
}
func (s *Service) Process(data string) {
s.Log("Processing started")
if !s.Validate(data) {
s.Log("Invalid data")
return
}
s.Log("Processing completed")
}
Embedding several types works until two of them provide the same name at the same depth. If both Logger and Validator had a Name() method, s.Name() would fail with ambiguous selector s.Name, but only where it is used; the struct itself compiles. You resolve it by calling s.Logger.Name() explicitly or by defining Name() on Service. A more important design caution: embedding exports the embedded type’s whole API as part of the outer type. Embedding a sync.Mutex in an exported struct lets every caller call Lock() on it, and embedding a concrete logger ties Service to that type. Often a named field (log *Logger) is clearer, and embedding is best when the outer type genuinely is an extended version of the inner one, as with wrapping an http.ResponseWriter to record the status code.
Constructor patterns
There is no constructor keyword— NewXxx returning *T is idiomatic.
A constructor function is needed when the zero value is not valid (a map field must be allocated, a default port must be set) or when an invariant must be established. When construction can fail, the convention is to return an error as well, func NewServer(addr string) (*Server, error). Because nothing forces callers to use the function, types with unexported fields and a NewXxx function are the Go way to make “always construct properly” hard to bypass from outside the package.
Functional options (advanced)
package main
import "time"
type Server struct {
host string
port int
timeout time.Duration
maxConn int
}
type Option func(*Server)
func WithHost(host string) Option {
return func(s *Server) { s.host = host }
}
func WithPort(port int) Option {
return func(s *Server) { s.port = port }
}
func WithTimeout(timeout time.Duration) Option {
return func(s *Server) { s.timeout = timeout }
}
func NewServer(opts ...Option) *Server {
s := &Server{
host: "localhost",
port: 8080,
timeout: 30 * time.Second,
maxConn: 100,
}
for _, opt := range opts {
opt(s)
}
return s
}
Functional options solve the problem C++ handles with default arguments and overloaded constructors, neither of which Go has. NewServer() gives all defaults, NewServer(WithPort(9090), WithTimeout(5*time.Second)) overrides two, and adding a new option later does not break any existing caller. The defaults live in one place, and each option is self-documenting at the call site. The cost is some boilerplate per option and a bit of indirection. For a type with two or three settings, a plain config struct (NewServer(Config{Port: 9090}), relying on zero values for “use the default”) is simpler and is also widely used; options shine in libraries whose configuration grows over time. If an option can be invalid (a negative port), have Option return an error and check it in the loop.
Exercises
Exercise 1: Rectangle
package main
import "fmt"
type Rectangle struct{ Width, Height float64 }
func NewRectangle(w, h float64) *Rectangle {
return &Rectangle{Width: w, Height: h}
}
func (r *Rectangle) Area() float64 { return r.Width * r.Height }
func (r *Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
func main() {
rect := NewRectangle(10, 5)
fmt.Printf("Area: %.2f\n", rect.Area())
rect.Scale(2)
fmt.Printf("Area: %.2f\n", rect.Area())
}
Exercise 2: Stack
package main
import (
"errors"
"fmt"
)
type Stack struct{ items []int }
func NewStack() *Stack {
return &Stack{items: make([]int, 0)}
}
func (s *Stack) Push(item int) { s.items = append(s.items, item) }
func (s *Stack) Pop() (int, error) {
if len(s.items) == 0 {
return 0, errors.New("stack is empty")
}
i := len(s.items) - 1
v := s.items[i]
s.items = s.items[:i]
return v, nil
}
func (s *Stack) Peek() (int, error) {
if len(s.items) == 0 {
return 0, errors.New("stack is empty")
}
return s.items[len(s.items)-1], nil
}
Exercise 3: Embedding
package main
import (
"fmt"
"time"
)
type Timer struct{ start time.Time }
func (t *Timer) Start() { t.start = time.Now() }
func (t *Timer) Elapsed() time.Duration { return time.Since(t.start) }
type Logger struct{ prefix string }
func (l Logger) Log(msg string) {
fmt.Printf("[%s] %s\n", l.prefix, msg)
}
type LoggedTimer struct {
Timer
Logger
}
func NewLoggedTimer(prefix string) *LoggedTimer {
return &LoggedTimer{Logger: Logger{prefix: prefix}}
}
func (lt *LoggedTimer) Start() {
lt.Timer.Start()
lt.Log("Timer started")
}
func (lt *LoggedTimer) Stop() time.Duration {
e := lt.Elapsed()
lt.Log(fmt.Sprintf("Timer stopped: %v", e))
return e
}
The exercises use pointer receivers for all methods of Rectangle and Stack, including read-only ones like Area and Peek, following the consistency rule: Scale and Push must mutate, so the whole type uses *T. In Stack.Pop, returning (value, error) instead of panicking on an empty stack is the idiomatic Go shape; s.items[:i] shrinks the slice without freeing the backing array, which is fine for int but, for a stack of pointers, you would set s.items[i] = nil first so the garbage collector can reclaim the popped object.
In LoggedTimer, Start is defined on the outer type, so it shadows the promoted Timer.Start, and the explicit lt.Timer.Start() call reaches the inner one; this is the Go equivalent of calling a base-class method from an override. Elapsed and Log are promoted unchanged.
Wrap-up: Days 5–6 checklist
- Define types with
struct - Package-level visibility via naming
- Methods and receiver syntax
- Pointer vs value receivers
-
NewXxxconstructors - Embedding for reuse
- Three exercises done
C++ — Go
| C++ | Go |
|---|---|
class | struct + methods |
| ctor/dtor | NewXxx / defer |
this | explicit receiver |
| inheritance | embedding + interfaces |
Next
Interfaces: polymorphism without virtual.
Series navigation
| Previous | Index | Next |
|---|---|---|
| #02 Data Structures | Index | #04 Interfaces |
Go in 2 weeks: Curriculum · #01 · #02 · #03 · #04 · #05 · #06 · #07 · #08 · #09
TL;DR: No classes—use structs, methods, and composition; embedding gives reuse without inheritance.