본문으로 건너뛰기 [2026] Go Interfaces Complete Guide — Polymorphism Without virtual, Duck Typing, and io.Reader/io.Writer

[2026] Go Interfaces Complete Guide — Polymorphism Without virtual, Duck Typing, and io.Reader/io.Writer

[2026] Go Interfaces Complete Guide — Polymorphism Without virtual, Duck Typing, and io.Reader/io.Writer

이 글의 핵심

How to implement polymorphism with Go interfaces instead of C++ virtual functions and inheritance: implicit interfaces (no implements), duck typing, and small interfaces like io.Reader and io.Writer.

Series overview

📚 Go in 2 Weeks #04 | Full series index

This article is Day 7 of the two-week Go curriculum for C++ developers.

Previous: #03 OOP & composition ← | → Next: #05 Error handling


Introduction: from explicit inheritance to implicit satisfaction

In C++, polymorphism usually means inheriting a base class and declaring virtual functions with virtual. Go has no explicit inheritance declaration. If you implement the methods, you satisfy the interface automatically. That is the duck-typing mindset: “If it walks like a duck and quacks like a duck, it is a duck.” You will learn:

  • What interfaces are and how they are satisfied implicitly
  • Core interfaces from the standard library
  • The empty interface and type assertions · type switches
  • Practical patterns combining io.Reader, io.Writer, and error
  • Interface design patterns

From a C++ developer’s perspective: This post emphasizes differences and pitfalls when moving from a C++ background to Go. It compares pointers, concurrency, and memory management where it helps.

How it feels in practice

When teams that mainly used C++ for servers adopt Go, a common first impression is that syntax and the toolchain look simpler. In production, that simplicity often translates into faster builds and deploys and more readable concurrency code. Commonly cited strengths:

  • Development speed: Varies by team and domain, but network and CLI code is often quick to ship.
  • Safety: A GC reduces manual deallocation burden.
  • Deployment: A single binary is easy to move around.

Table of contents

  1. Interface basics: method sets
  2. Implicit interface satisfaction (duck typing)
  3. Standard-library interfaces
  4. Empty interface and type assertions
  5. Practical patterns: implicit implementation, any, assertions, standard I/O, errors
  6. Interface design principles
  7. Hands-on exercises

1. Interface basics: method sets

C++ vs Go: implementing polymorphism

// C++: polymorphism with virtual functions
class Shape {
public:
    virtual double Area() const = 0;
    virtual double Perimeter() const = 0;
    virtual ~Shape() = default;
};
class Circle : public Shape {
    double radius;
    
public:
    Circle(double r) : radius(r) {}
    
    double Area() const override {
        return 3.14159 * radius * radius;
    }
    
    double Perimeter() const override {
        return 2 * 3.14159 * radius;
    }
};
class Rectangle : public Shape {
    double width, height;
    
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    
    double Area() const override {
        return width * height;
    }
    
    double Perimeter() const override {
        return 2 * (width + height);
    }
};
// Using polymorphism
void printShapeInfo(const Shape& s) {
    std::cout << "Area: " << s.Area() << "\n";
    std::cout << "Perimeter: " << s.Perimeter() << "\n";
}
int main() {
    Circle c(5.0);
    Rectangle r(4.0, 6.0);
    
    printShapeInfo(c);
    printShapeInfo(r);
}
// Go: polymorphism with interfaces (no explicit inheritance)
package main
import (
    "fmt"
    "math"
)
// Interface definition
type Shape interface {
    Area() float64
    Perimeter() float64
}
// Circle type
type Circle struct {
    Radius float64
}
// Circle implements Area() and Perimeter(), so it satisfies Shape automatically
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}
// Rectangle type
type Rectangle struct {
    Width, Height float64
}
// Rectangle also implements Area() and Perimeter(), so it satisfies Shape
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}
// Using polymorphism
func printShapeInfo(s Shape) {
    fmt.Printf("Area: %.2f\n", s.Area())
    fmt.Printf("Perimeter: %.2f\n", s.Perimeter())
}
func main() {
    c := Circle{Radius: 5.0}
    r := Rectangle{Width: 4.0, Height: 6.0}
    
    printShapeInfo(c)
    printShapeInfo(r)
}

Key differences:

  • Go has no implements keyword
  • Implement the methods and you automatically satisfy the interface
  • Looser coupling between concrete types and interfaces

2. Implicit interface satisfaction (duck typing)

Why duck typing helps

// Go: you can introduce an interface later
package main
import "fmt"
// Existing types (they do not know about any interface yet)
type Dog struct {
    Name string
}
func (d Dog) Speak() string {
    return "Woof!"
}
type Cat struct {
    Name string
}
func (c Cat) Speak() string {
    return "Meow!"
}
// Define the interface later (no change to existing types required)
type Speaker interface {
    Speak() string
}
func makeSpeak(s Speaker) {
    fmt.Println(s.Speak())
}
func main() {
    dog := Dog{Name: "Buddy"}
    cat := Cat{Name: "Whiskers"}
    
    // Dog and Cat satisfy Speaker (no explicit declaration)
    makeSpeak(dog)
    makeSpeak(cat)
}

Compared to C++:

  • C++: you typically design base classes up front and inherit from them
  • Go: you can add an interface later without editing existing types

3. Standard-library interfaces

The Go standard library is full of small, powerful interfaces.

io.Reader and io.Writer

// Go: io.Reader interface
type Reader interface {
    Read(p []byte) (n int, err error)
}
// io.Writer interface
type Writer interface {
    Write(p []byte) (n int, err error)
}

Example usage:

// Go: using io.Reader
package main
import (
    "bytes"
    "fmt"
    "io"
    "os"
    "strings"
)
func processData(r io.Reader) error {
    data, err := io.ReadAll(r)
    if err != nil {
        return err
    }
    fmt.Println(string(data))
    return nil
}
func main() {
    // 1. Read from a file
    f, _ := os.Open("file.txt")
    defer f.Close()
    processData(f) // *os.File implements io.Reader
    
    // 2. Read from a string
    sr := strings.NewReader("Hello from string")
    processData(sr) // *strings.Reader implements io.Reader
    
    // 3. Read from a byte buffer
    buf := bytes.NewBufferString("Hello from buffer")
    processData(buf) // *bytes.Buffer implements io.Reader
}

fmt.Stringer

// C++: operator<< overloading
class Person {
    std::string name;
    int age;
    
public:
    Person(const std::string& n, int a) : name(n), age(a) {}
    
    friend std::ostream& operator<<(std::ostream& os, const Person& p) {
        os << p.name << " (" << p.age << ")";
        return os;
    }
};
// Usage
Person p("Alice", 30);
std::cout << p << "\n";
// Go: fmt.Stringer interface
package main
import "fmt"
type Person struct {
    Name string
    Age  int
}
// Satisfies fmt.Stringer
func (p Person) String() string {
    return fmt.Sprintf("%s (%d)", p.Name, p.Age)
}
func main() {
    p := Person{Name: "Alice", Age: 30}
    fmt.Println(p) // "Alice (30)" — String() is called automatically
}

error interface

// Go: the built-in error interface is: type error interface { Error() string }
package main

import (
    "fmt"
    "strings"
)

// Custom error type
type ValidationError struct {
    Field string
    Value string
}
func (e ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s = %s", e.Field, e.Value)
}
// Usage
func validate(email string) error {
    if !strings.Contains(email, "@") {
        return ValidationError{
            Field: "email",
            Value: email,
        }
    }
    return nil
}
func main() {
    if err := validate("invalid"); err != nil {
        fmt.Println(err) // "validation failed: email = invalid"
    }
}

4. Empty interface and type assertions

interface{} (any)

// C++: void* (not type-safe)
void* ptr = new int(42);
int* p = static_cast<int*>(ptr);  // manual cast
// Go: interface{} or any (type-safe when narrowed)
package main
import "fmt"
func printAny(v interface{}) {
    fmt.Printf("Value: %v, Type: %T\n", v, v)
}
func main() {
    printAny(42)
    printAny("hello")
    printAny(3.14)
    printAny([]int{1, 2, 3})
}

Type assertions

// Go: type assertions
package main
import "fmt"
func process(v interface{}) {
    // Safe assertion (ok idiom)
    if s, ok := v.(string); ok {
        fmt.Println("String:", s)
        return
    }
    
    if i, ok := v.(int); ok {
        fmt.Println("Int:", i)
        return
    }
    
    fmt.Println("Unknown type")
}
func main() {
    process("hello")  // "String: hello"
    process(42)       // "Int: 42"
    process(3.14)     // "Unknown type"
}

Type switches

// Go: type switch (handle many types)
package main
import "fmt"
func describe(v interface{}) {
    switch t := v.(type) {
    case string:
        fmt.Printf("String: %s (length %d)\n", t, len(t))
    case int:
        fmt.Printf("Int: %d\n", t)
    case bool:
        fmt.Printf("Bool: %t\n", t)
    case []int:
        fmt.Printf("Int slice: %v\n", t)
    default:
        fmt.Printf("Unknown type: %T\n", t)
    }
}
func main() {
    describe("hello")
    describe(42)
    describe(true)
    describe([]int{1, 2, 3})
    describe(3.14)
}

5. Practical patterns: implicit implementation, any, assertions, standard I/O, errors

Using implicit implementation in real code

  • Interfaces are usually defined by the consumer. Say “this function only needs an io.Reader” and only the required methods matter—any type that has them can be passed in.
  • Concrete types do not need to know the interface name. That makes it easy to extend with test doubles or adapters without editing existing types.
  • A common rule of thumb: accept interfaces, return structs—take narrow interfaces at public boundaries; constructors return concrete pointers.
// HTTP handler example: ResponseWriter and Request combine interfaces and structs
func Handler(w http.ResponseWriter, r *http.Request) {
    io.WriteString(w, "ok") // w satisfies io.Writer
}

interface{} and any

  • Before Go 1.18, the empty interface was written interface{}; since then any is an alias for interface{}. Style guides usually prefer any.
  • Every type is assignable to any, but you lose compile-time checking, so you narrow with assertions or type switches afterward.
  • Typical for data whose shape is only known at runtime, e.g. JSON decoding (see Exercise 4).

Type assertions and type switches — checklist

SituationRecommendation
Only one or two concrete typesv.(ConcreteType) or s, ok := v.(string)
Many types in one functionswitch v := x.(type) { ... }
nil interface valuesAssertions can fail or surprise—see the “nil interface” trap in #05
// Use the ok form to avoid panic on failed assertion
if n, ok := v.(int); ok {
    _ = n
}

Composing io.Reader, io.Writer, and error

The standard library builds large behavior from very small interfaces.

  • io.Copy(dst Writer, src Reader): connects read and write streams—files, buffers, network, bytes.Reader all look the same.
  • io.MultiWriter: one Write can fan out to several Writers (e.g. file + hash).
  • error: a single method Error() string, so any custom type can be an error. Carry domain fields and use errors.As at the boundary (#05).
package main
import (
    "bytes"
    "fmt"
    "io"
    "strings"
)
func main() {
    r := strings.NewReader("hello")
    var buf bytes.Buffer
    if _, err := io.Copy(&buf, r); err != nil {
        fmt.Println(err)
        return
    }
    // buf implements io.Writer — same pattern for files, hashes, etc.
    fmt.Println(buf.String())
}

One-line summary: model I/O with io.Reader/io.Writer, failures with error, and use any plus narrowing only when types truly vary at runtime.


6. Interface design principles

Small interfaces

Go’s philosophy: the smaller the interface, the better

// Good: small interfaces
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Writer interface {
    Write(p []byte) (n int, err error)
}
type Closer interface {
    Close() error
}
// Compose when needed
type ReadWriter interface {
    Reader
    Writer
}
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}
// Bad: a large “do everything” interface
type Database interface {
    Connect() error
    Disconnect() error
    Query(sql string) ([]Row, error)
    Insert(table string, data map[string]interface{}) error
    Update(table string, id int, data map[string]interface{}) error
    Delete(table string, id int) error
    BeginTransaction() error
    CommitTransaction() error
    RollbackTransaction() error
}
// Too many methods — hard to implement and test

Interface segregation

// Good: segregated interfaces
type Querier interface {
    Query(sql string) ([]Row, error)
}
type Inserter interface {
    Insert(table string, data map[string]interface{}) error
}
type Transactional interface {
    BeginTransaction() error
    CommitTransaction() error
    RollbackTransaction() error
}
// Require only what you need
func fetchData(q Querier) ([]Row, error) {
    return q.Query("SELECT * FROM users")
}

7. Hands-on exercises

Exercise 1: Shape interface

// Go: multiple shapes
package main
import (
    "fmt"
    "math"
)
type Shape interface {
    Area() float64
    Perimeter() float64
}
type Circle struct {
    Radius float64
}
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}
type Rectangle struct {
    Width, Height float64
}
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}
type Triangle struct {
    A, B, C float64 // side lengths
}
func (t Triangle) Area() float64 {
    // Heron's formula
    s := (t.A + t.B + t.C) / 2
    return math.Sqrt(s * (s - t.A) * (s - t.B) * (s - t.C))
}
func (t Triangle) Perimeter() float64 {
    return t.A + t.B + t.C
}
func printShapeInfo(s Shape) {
    fmt.Printf("Type: %T\n", s)
    fmt.Printf("Area: %.2f\n", s.Area())
    fmt.Printf("Perimeter: %.2f\n", s.Perimeter())
    fmt.Println()
}
func main() {
    shapes := []Shape{
        Circle{Radius: 5},
        Rectangle{Width: 4, Height: 6},
        Triangle{A: 3, B: 4, C: 5},
    }
    
    for _, shape := range shapes {
        printShapeInfo(shape)
    }
}

Exercise 2: Custom Writer

// Go: custom Writer
package main
import (
    "bytes"
    "fmt"
    "io"
    "os"
)
// Writer that uppercases output
type UpperWriter struct {
    w io.Writer
}
func NewUpperWriter(w io.Writer) *UpperWriter {
    return &UpperWriter{w: w}
}
// Satisfies io.Writer
func (uw *UpperWriter) Write(p []byte) (n int, err error) {
    upper := bytes.ToUpper(p)
    return uw.w.Write(upper)
}
func main() {
    // Uppercase stdout
    uw := NewUpperWriter(os.Stdout)
    
    fmt.Fprintln(uw, "hello world")  // "HELLO WORLD"
    
    // Composable with io.Copy and friends
    io.WriteString(uw, "go is awesome\n")  // "GO IS AWESOME"
}

Exercise 3: Combining interfaces

// Go: combining several interfaces
package main
import (
    "fmt"
    "io"
    "os"
)
// Type that satisfies multiple interfaces via embedding
type File struct {
    *os.File
}
// File satisfies io.Reader, io.Writer, io.Closer
// (os.File already does; embedding forwards the methods)
func processReadWriteCloser(rwc io.ReadWriteCloser) {
    // Read, Write, Close available
    defer rwc.Close()
    
    data := []byte("test data")
    rwc.Write(data)
}
func main() {
    f, err := os.Create("temp.txt")
    if err != nil {
        panic(err)
    }
    
    // *os.File satisfies io.ReadWriteCloser
    processReadWriteCloser(f)
}

Exercise 4: Type assertions with JSON

// Go: JSON parsing with type assertions
package main
import (
    "encoding/json"
    "fmt"
)
func parseJSON(jsonStr string) {
    var data interface{}
    
    if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
        fmt.Println("Parse error:", err)
        return
    }
    
    // Type switch
    switch v := data.(type) {
    case map[string]interface{}:
        fmt.Println("Object:")
        for key, value := range v {
            fmt.Printf("  %s: %v\n", key, value)
        }
    case []interface{}:
        fmt.Println("Array:")
        for i, item := range v {
            fmt.Printf("  [%d]: %v\n", i, item)
        }
    default:
        fmt.Printf("Other type: %T\n", v)
    }
}
func main() {
    parseJSON(`{"name":"Alice","age":30}`)
    parseJSON(`[1, 2, 3, 4, 5]`)
}

Wrap-up: Day 7 checklist

Completion checklist

  • Interfaces are sets of methods
  • No explicit implements — automatic satisfaction (duck typing)
  • Use standard interfaces such as io.Reader, io.Writer, fmt.Stringer
  • Empty interface (interface{}, any) and type assertions
  • Type switches for multiple types
  • Design small interfaces
  • Finish all four exercises

C++ to Go transition

C++GoNotes
virtual functionsinterface methodsexplicit vs implicit
Explicit inheritanceimplicit satisfactionduck typing
Base-class pointerinterface valuemore flexible
RTTI (dynamic_cast)type assertionmore concise
Multiple inheritanceinterface compositionsafer

End of week one

Congratulations—you have finished week one. So far you have covered:

  • ✅ Go syntax and philosophy
  • ✅ Pointers and data structures (slice, map)
  • ✅ Structs and methods
  • ✅ Interfaces and polymorphism In week two you will learn concurrent programming, one of Go’s main strengths.

📚 Series navigation

PreviousIndexNext
← #03 OOP📑 Full index#05 Errors →

Go in 2 weeks: Curriculum#01 Basics#02 Data structures#03 OOP#04 Interfaces#05 Errors#06 Goroutines & channels#07 Testing#08 REST API#09 Context & graceful shutdown


TL;DR: Go interfaces are satisfied automatically when you implement the methods—no explicit inheritance. Prefer small interfaces for reuse.

These posts connect to this topic.


Go interface, duck typing, io.Reader, type assertion, any, Go polymorphism, C++ virtual functions compared, Golang interfaces, Go in 2 weeks, and similar queries should surface this article.

Practical tips

Tips you can apply immediately.

Debugging

  • Check compiler warnings first
  • Reproduce with a small test case

Performance

  • Do not optimize without profiling
  • Define measurable goals first

Code review

  • Check common review feedback early
  • Follow team conventions

Field checklist

Use this when applying these ideas in production.

Before you code

  • Is this the best fit for the problem?
  • Can teammates understand and maintain it?
  • Does it meet performance requirements?

While coding

  • Are all compiler warnings addressed?
  • Did you consider edge cases?
  • Is error handling appropriate?

At review

  • Is intent clear?
  • Are tests sufficient?
  • Is documentation in place?

Use this checklist to reduce mistakes and improve quality.


FAQ

Q. Where do I use this in production?

A. Anywhere you would use virtual functions and inheritance in C++—pluggable I/O, test doubles, library boundaries—with Go’s implicit interfaces and small interface design. Apply the examples and decision guides from the main text.

Q. What should I read first?

A. Follow Previous / Next links at the bottom of each post, or see the C++ series index for the full picture.

Q. How do I go deeper?

A. Use go.dev and official library docs. cppreference helps when comparing with C++.