Lesson 13/41 · Composite Data Types & Object-Oriented Design Basics m02-l04

Composition over Inheritance: Embedding, Interface Composition and Design Patterns

Module 2: Composite Data Types & Object-Oriented Design | Lesson: m02-l04 | Level: Beginner After this lesson you will be able to: reuse code through struct embedding, build minimal interfaces by composing smaller interfaces, and program against interfaces with the io.Reader/Writer pattern.

Learning Objectives

  • Understand Go's design philosophy of composition over inheritance, and master the embedding syntax for code reuse.
  • Master interface composition and the smallest interface principle, and be able to define interfaces that are clear and easy to implement.
  • Become familiar with the io.Reader and io.Writer ecosystem, and build reusable I/O components with the composition pattern.
  • Recognize and avoid common embedding pitfalls such as method shadowing and receiver-type confusion.

Core Concepts

2.1 Embedding: The Cornerstone of Code Reuse

Go has no extends keyword and does not support class inheritance as found in traditional OO languages. Instead it uses embedding. Embedding lets a struct directly contain another struct or interface type, thereby reusing its fields and methods.

What: When struct A embeds struct B, A automatically gains all of B's exported fields and methods. This is called method promotion. Note that embedding is not limited to structs; you can also embed interfaces.

Why this design: Embedding implements composition. Unlike inheritance, composition does not force a tight "is-a" coupling; it establishes a "has-a" relationship. This makes the code more flexible and easier to test and reuse.

How:

type Base struct {
    Name string
}

func (b Base) SayHello() {
    fmt.Println("Hello, I am", b.Name)
}

type Derived struct {
    Base // embed Base
    Age  int
}

In this example, a Derived instance can call SayHello() directly, because Base's method is promoted to Derived.

2.2 Interface Composition and the Smallest Interface Principle

Interface composition merges method sets by embedding other interface types inside an interface. Go has no + syntax; you simply list the embedded interface names.

The smallest interface principle states: interfaces should be as small as possible, containing only the methods the caller really needs. Smaller interfaces are easier to implement and easier to compose.

How:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// Interface composition: ReadWriter embeds Reader and Writer
type ReadWriter interface {
    Reader
    Writer
}

The method set of ReadWriter is the union of the method sets of Reader and Writer.

2.3 The io.Reader/Writer Composition Pattern

io.Reader and io.Writer are the most fundamental interfaces in the Go standard library. They define the input and output of data streams. By embedding these interfaces we can build richly featured components.

How: For example, io.Pipe returns a PipeReader and a PipeWriter, each implementing io.Reader or io.Writer. We can wrap any io.Reader into a logging Reader simply by embedding the original Reader and overriding the Read method.

Example Code

Example 1: Struct Embedding and Method Promotion

This example shows how to reuse code through embedding and how method promotion works.

package main

import (
    "fmt"
)

// Base defines basic behavior
type Base struct {
    Name string
}

// SayHello is a method of Base
func (b Base) SayHello() {
    fmt.Println("Hello, I am", b.Name)
}

// Derived embeds Base to reuse its fields and methods
type Derived struct {
    Base
    Age int
}

// OverrideSayHello is Derived's own method, coexisting with the promoted SayHello
func (d Derived) OverrideSayHello() {
    fmt.Println("Derived says Hello, I am", d.Name)
}

func main() {
    d := Derived{
        Base: Base{Name: "Alice"},
        Age:  18,
    }

    // call the promoted method
    d.SayHello()
    // call Derived's own method
    d.OverrideSayHello()
}

Expected output:

Hello, I am Alice
Derived says Hello, I am Alice

Example 2: Interface Composition and an io.Reader Wrapper

This example shows interface composition and how to use embedding to build a buffered io.Reader, demonstrating the advantage of programming against interfaces.

package main

import (
    "fmt"
    "io"
    "strings"
)

// BufferedReader embeds io.Reader and implements buffered reading logic
type BufferedReader struct {
    io.Reader
    buffer []byte
    pos    int
}

// NewBufferedReader creates a new BufferedReader
func NewBufferedReader(r io.Reader) *BufferedReader {
    return &BufferedReader{
        Reader: r,
        buffer: make([]byte, 1024),
    }
}

// Read overrides the Read method, showing how to delegate to the embedded Reader
func (br *BufferedReader) Read(p []byte) (n int, err error) {
    // Simplified logic here: delegate directly to the underlying Reader.
    // A real buffered reader is more complex and must handle leftover data in the buffer.
    n, err = br.Reader.Read(p)
    if err != nil && err != io.EOF {
        return n, err
    }
    return n, err
}

func main() {
    // create a string reader
    strReader := strings.NewReader("Hello, Go!")

    // wrap it in a BufferedReader
    br := NewBufferedReader(strReader)

    // read data
    buf := make([]byte, 10)
    n, err := br.Read(buf)
    if err != nil && err != io.EOF {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("Read %d bytes: %s\n", n, string(buf[:n]))
}

Expected output:

Read 10 bytes: Hello, Go!

Common Mistakes & Pitfalls

  • Wrong assumption: using + to compose interfaces
  • Bad code: type ReadWriter interface { Reader + Writer }
  • Why: Go does not support merging interfaces with a + operator.
  • Correct approach: embed the interface types directly. go type ReadWriter interface { Reader Writer }

  • Wrong assumption: thinking embedding an interface requires implementing its methods

  • Bad code: thinking that after embedding io.Reader in a struct, you must manually implement Read to satisfy the io.Reader interface.
  • Why: when you embed an interface, its methods are promoted to the struct. The struct satisfies the interface without implementing them itself (provided the embedded field is non-nil, or the calls would panic).
  • Correct approach: embed directly. If you need to delegate, call the embedded field's method from your method. go type MyReader struct { io.Reader } // MyReader automatically satisfies the io.Reader interface var _ io.Reader = MyReader{}

  • Wrong assumption: confusing value receivers with pointer receivers

  • Bad code: go type MyStruct struct{} func (m MyStruct) Read(p []byte) (int, error) { return 0, nil } var rw ReadWriter = &MyStruct{} // assuming ReadWriter requires a pointer receiver
  • Why: if an interface's method has a pointer receiver, only *MyStruct implements that interface; if it has a value receiver, both MyStruct and *MyStruct do. If B's method has a pointer receiver, then after A embeds B, only *A implements the interface.
  • Correct approach: choose the receiver type according to the interface definition. If the interface methods use value receivers, both value and pointer types implement it; if they use pointer receivers, only pointer types do.

Best Practices

  1. Prefer composition over inheritance: Go has no inheritance; reuse code through embedding and keep the structure loosely coupled.
  2. Follow the smallest interface principle: when defining an interface, include only the methods the caller really needs and avoid interface bloat.
  3. Decorate with io.Reader/io.Writer: by embedding these interfaces you can easily build decorators such as logging, buffering and compression.
  4. Watch out for method shadowing: if an outer struct defines a method with the same name as an embedded struct's method, the outer method shadows the inner one, which can cause an interface to stop being satisfied.
  5. Be explicit about receiver types: when embedding interfaces, be aware of how value and pointer types affect interface satisfaction, and use the correct one.

Exercises

  1. Concept question: explain what "composition over inheritance" means in Go, and describe how embedding achieves code reuse.
  2. Coding exercise: define a LoggingReader struct that embeds io.Reader and overrides the Read method to print a log message "Reading..." before each read.
  3. Advanced/debugging exercise: analyze the following code, explain why it does not produce both outputs, and fix it so that it correctly prints "Base Method" and "Derived Method". ```go package main

    import "fmt"

    type Base struct{}

    func (b Base) Method() { fmt.Println("Base Method") }

    type Derived struct { Base }

    func (d Derived) Method() { fmt.Println("Derived Method") }

    func main() { d := Derived{} // Modify this line so that both Derived's and Base's Method are called d.Method() } ```

Reference Answers

Reference Answers (click to expand / collapse)

Question 1

In Go, "composition over inheritance" means reusing code through composition (embedding) rather than extending classes through inheritance. Embedding lets one struct contain another, automatically gaining its fields and methods (method promotion). This approach is more flexible and avoids the tight coupling and bloated class hierarchies that inheritance brings.

Question 2

package main

import (
    "fmt"
    "io"
    "strings"
)

type LoggingReader struct {
    io.Reader
}

func (lr *LoggingReader) Read(p []byte) (n int, err error) {
    fmt.Println("Reading...")
    return lr.Reader.Read(p)
}

func main() {
    str := strings.NewReader("Hello")
    lr := &LoggingReader{Reader: str}
    buf := make([]byte, 10)
    lr.Read(buf)
}

Question 3

Problem analysis: 1. The exercise asks to print "Base Method" and "Derived Method", but the original code calls d.Method() only once, so it prints only "Derived Method". 2. To call Base's Method, you must go through the embedded field Base, i.e. d.Base.Method().

Fixed code:

package main

import "fmt"

type Base struct{}

func (b Base) Method() {
    fmt.Println("Base Method")
}

type Derived struct {
    Base
}

func (d Derived) Method() {
    fmt.Println("Derived Method")
}

func main() {
    d := Derived{}
    d.Method()       // calls Derived's Method
    d.Base.Method()  // calls Base's Method
}

Lesson Summary

  • Embedding is Go's core mechanism for code reuse; methods are shared through method promotion.
  • Interface composition merges method sets by embedding other interfaces and follows the smallest interface principle.
  • io.Reader and io.Writer are the foundation for building reusable I/O components and support the decorator pattern.
  • Watch how value/pointer receivers affect interface satisfaction, and watch out for method shadowing.

Next lesson preview: handling errors the Go way — the error interface, error wrapping with %w, errors.Is/As and sentinel errors.

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also:Browse existing issues · Email feedback (no GitHub account needed):278975598@qq.com