[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, anderror - 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
- Interface basics: method sets
- Implicit interface satisfaction (duck typing)
- Standard-library interfaces
- Empty interface and type assertions
- Practical patterns: implicit implementation,
any, assertions, standard I/O, errors - Interface design principles
- 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
implementskeyword - 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 thenanyis an alias forinterface{}. Style guides usually preferany. - 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
| Situation | Recommendation |
|---|---|
| Only one or two concrete types | v.(ConcreteType) or s, ok := v.(string) |
| Many types in one function | switch v := x.(type) { ... } |
nil interface values | Assertions 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.Readerall look the same. - io.MultiWriter: one
Writecan fan out to severalWriters (e.g. file + hash). - error: a single method
Error() string, so any custom type can be an error. Carry domain fields and useerrors.Asat 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++ | Go | Notes |
|---|---|---|
virtual functions | interface methods | explicit vs implicit |
| Explicit inheritance | implicit satisfaction | duck typing |
| Base-class pointer | interface value | more flexible |
RTTI (dynamic_cast) | type assertion | more concise |
| Multiple inheritance | interface composition | safer |
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
| Previous | Index | Next |
|---|---|---|
| ← #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.
Related reading
These posts connect to this topic.
- [Go in 2 Weeks #03] Days 5–6: OOP without classes — favor composition over inheritance
- Two-week Go curriculum for C++ developers
- C++ virtual functions guide
Keywords (related searches)
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++.
Related posts
- Understanding Go through a C++ developer’s lens [#47-2]
- Two-week Go curriculum for C++ developers
- C++ vs Go — performance, concurrency, and how to choose [#47-1]
- [Go in 2 Weeks #01] Days 1–2: philosophy and syntax
- [Go in 2 Weeks #02] Days 3–4: memory and data structures