The Secret Life of Go: Interfaces

 

The Secret Life of Go: Interfaces





Chapter 7: The Power of Implicit Contracts

Tuesday morning brought fog. Ethan descended to the archive carrying coffee and a small box of biscotti.

Eleanor looked up. "Italian today?"

"The baker said biscotti are designed to fit perfectly into coffee cups—form following function."

She smiled. "Perfect. Today we're talking about interfaces—Go's way of defining what something can do, regardless of what it is."

Ethan set down the coffees. "That sounds abstract."

"It is. Let me show you something." Eleanor opened a new file:

package main

import "fmt"

type Dog struct {
    Name string
}

func (d Dog) Speak() string {
    return "Woof!"
}

type Cat struct {
    Name string
}

func (c Cat) Speak() string {
    return "Meow!"
}

func main() {
    dog := Dog{Name: "Buddy"}
    cat := Cat{Name: "Whiskers"}

    fmt.Println(dog.Name, "says:", dog.Speak())
    fmt.Println(cat.Name, "says:", cat.Speak())
}

She ran it:

Buddy says: Woof!
Whiskers says: Meow!

"Two different types—Dog and Cat. Both have a Speak() method that returns a string. But they're unrelated types. Now watch this:"

package main

import "fmt"

type Speaker interface {
    Speak() string
}

type Dog struct {
    Name string
}

func (d Dog) Speak() string {
    return "Woof!"
}

type Cat struct {
    Name string
}

func (c Cat) Speak() string {
    return "Meow!"
}

func MakeItSpeak(s Speaker) {
    fmt.Println(s.Speak())
}

func main() {
    dog := Dog{Name: "Buddy"}
    cat := Cat{Name: "Whiskers"}

    MakeItSpeak(dog)
    MakeItSpeak(cat)
}

Output:

Woof!
Meow!

"An interface. type Speaker interface defines a contract—any type that has a Speak() string method satisfies the Speaker interface. The function MakeItSpeak accepts any Speaker, and both Dog and Cat qualify."

Ethan studied the code. "But we never said Dog implements Speaker."

Eleanor's eyes gleamed. "Exactly. That's the magic. In Java or C#, you'd write class Dog implements Speaker. In Go, if a type has the methods an interface requires, it automatically satisfies that interface. It's implicit."

"Why implicit?"

"Flexibility. You can define an interface that existing types already satisfy, even if those types were written before your interface existed. You're not locked into declaring relationships upfront."

She pulled out her checking paper. "Let's look at a more realistic example—writing data to different destinations:"

package main

import (
    "fmt"
    "os"
)

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

type ConsoleWriter struct{}

func (cw ConsoleWriter) Write(data []byte) (int, error) {
    n, err := fmt.Println(string(data))
    return n, err
}

func WriteMessage(w Writer, message string) {
    w.Write([]byte(message))
}

func main() {
    console := ConsoleWriter{}
    WriteMessage(console, "Hello, Console!")

    file, _ := os.Create("output.txt")
    defer file.Close()
    WriteMessage(file, "Hello, File!")
}

"The Writer interface has one method: Write([]byte) (int, error). Our ConsoleWriter implements it. But look—os.File from the standard library also has a Write method with the same signature. So os.File satisfies Writer too, even though it was written by the Go team years ago and has no knowledge of our interface."

"So interfaces let you work with different types the same way?"

"Yes. Polymorphism—many forms, one interface. You write WriteMessage once, and it works with anything that can write bytes. Console, file, network connection, in-memory buffer—if it has Write, it works."

Eleanor typed a new example:

package main

import "fmt"

type Shape interface {
    Area() float64
    Perimeter() float64
}

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return 3.14159 * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * 3.14159 * c.Radius
}

func PrintShapeInfo(s Shape) {
    fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}

func main() {
    rect := Rectangle{Width: 5, Height: 3}
    circle := Circle{Radius: 4}

    PrintShapeInfo(rect)
    PrintShapeInfo(circle)
}

She ran it:

Area: 15.00, Perimeter: 16.00
Area: 50.27, Perimeter: 25.13

"The Shape interface requires two methods: Area() and Perimeter(). Both Rectangle and Circle have these methods, so both satisfy Shape. The function PrintShapeInfo accepts any Shape and calls its methods. The concrete type doesn't matter—only the behavior."

Ethan watched the output. "This is like duck typing in Python?"

"Similar, but with a crucial difference. Python's duck typing is checked at runtime—if you call a method that doesn't exist, you get a runtime error. Go's interfaces are checked at compile time. If you try to pass a type that doesn't satisfy the interface, the compiler stops you."

"So it's type-safe duck typing?"

"Exactly. You get the flexibility of 'if it walks like a duck and quacks like a duck, it's a duck,' but the compiler verifies it before your code ever runs."

Eleanor opened a new file. "Now, here's something that confuses everyone at first—the empty interface:"

package main

import "fmt"

func PrintAnything(v any) {
    fmt.Println(v)
}

func main() {
    PrintAnything(42)
    PrintAnything("hello")
    PrintAnything(true)
    PrintAnything([]int{1, 2, 3})
}

Output:

42
hello
true
[1 2 3]

"The type any is Go's way of saying 'any type.' It's actually an alias for the empty interface—interface{}—which has no methods. Since every type has at least zero methods, every type satisfies the empty interface. In Go 1.18 and later, any is the preferred way to write this—it's clearer than interface{}."

"That seems dangerous."

"It is. When you use any, you lose type safety. You can pass anything, but you can't do anything with it without checking what it actually is."

She typed:

package main

import "fmt"

func Describe(v any) {
    // Type assertion
    if str, ok := v.(string); ok {
        fmt.Printf("String: %s (length %d)\n", str, len(str))
        return
    }

    if num, ok := v.(int); ok {
        fmt.Printf("Integer: %d (doubled: %d)\n", num, num*2)
        return
    }

    fmt.Printf("Unknown type: %T\n", v)
}

func main() {
    Describe("hello")
    Describe(42)
    Describe(true)
}

Output:

String: hello (length 5)
Integer: 42 (doubled: 84)
Unknown type: bool

"Type assertions. v.(string) asks 'is v a string?' If yes, we get the string and ok is true. If no, we get the zero value and ok is false. It's the comma-ok idiom again—Go's pattern for operations that might fail."

Eleanor paused. "Important: if you skip the ok check and write str := v.(string), the assertion will panic if v isn't a string. Always use the comma-ok form unless you're absolutely certain of the type."

Ethan nodded slowly. "So any is like any, but you have to check the actual type?"

"Exactly. And there's a cleaner way when you have multiple types to check—a type switch:"

package main

import "fmt"

func Describe(v any) {
    switch val := v.(type) {
    case string:
        fmt.Printf("String: %s (length %d)\n", val, len(val))
    case int:
        fmt.Printf("Integer: %d (doubled: %d)\n", val, val*2)
    case bool:
        fmt.Printf("Boolean: %t\n", val)
    default:
        fmt.Printf("Unknown type: %T\n", val)
    }
}

func main() {
    Describe("hello")
    Describe(42)
    Describe(true)
    Describe(3.14)
}

Output:

String: hello (length 5)
Integer: 42 (doubled: 84)
Boolean: true
Unknown type: float64

"The type switch—switch val := v.(type)—checks v's actual type and branches accordingly. In each case, val has the correct type, so you can use it directly."

Eleanor drew in her notebook:

Interface Hierarchy (from specific to general):

Specific Interface:
    type Reader interface {
        Read([]byte) (int, error)
    }
    - Only types with Read() method

Empty Interface (any):
    any  (alias for interface{})
    - Every type satisfies it
    - No type safety
    - Requires type assertions

"Interfaces in Go are implicit contracts. The more methods an interface has, the more specific it is. The empty interface—written as any—is the least specific. It accepts everything but tells you nothing about what you can do with it."

She typed another example:

package main

import "fmt"

type Animal interface {
    Speak() string
}

type Mammal interface {
    Animal
    WarmBlooded() bool
}

type Dog struct {
    Name string
}

func (d Dog) Speak() string {
    return "Woof!"
}

func (d Dog) WarmBlooded() bool {
    return true
}

func main() {
    dog := Dog{Name: "Buddy"}

    // Dog satisfies both Animal and Mammal
    var a Animal = dog
    fmt.Println(a.Speak())

    var m Mammal = dog
    fmt.Println(m.Speak(), "- Warm blooded:", m.WarmBlooded())
}

Output:

Woof!
Woof! - Warm blooded: true

"Interfaces can embed other interfaces. Mammal embeds Animal, so any type satisfying Mammal must have both Speak() and WarmBlooded() methods. Dog has both, so it satisfies both interfaces. A variable of type Mammal can hold any type that implements both methods—it's the union of requirements from Animal and the additional WarmBlooded() method."

"This is composition again?"

"Yes. Just like structs can embed structs, interfaces can embed interfaces. You build complex contracts from simpler ones."

Eleanor closed her laptop. "That's the essence of interfaces. Named collections of method signatures. Types satisfy interfaces implicitly by having the required methods. The type any accepts any type but requires type assertions. Type switches handle multiple possibilities cleanly."

She took a biscotti and dipped it in her coffee. "Next time: goroutines and channels. How Go handles concurrency."

Ethan gathered the cups. "Eleanor?"

"Yes?"

"Why make interface satisfaction implicit? Why not require 'implements' like other languages?"

Eleanor opened her laptop again. "Let me show you why this matters. Imagine you're using a database library:"

// This type comes from a third-party database library
type DatabaseConnection struct {
    host string
}

func (db DatabaseConnection) Write(data []byte) (int, error) {
    // Database library code we don't control
    fmt.Println("Writing to database:", string(data))
    return len(data), nil
}

// In YOUR code, you define an interface for logging:
type Logger interface {
    Write([]byte) (int, error)
}

func LogMessage(logger Logger, msg string) {
    logger.Write([]byte(msg))
}

// DatabaseConnection satisfies Logger automatically,
// even though the database library never heard of our Logger interface

"See? The database library didn't know about your Logger interface when it was written. But because DatabaseConnection has a Write method with the right signature, it satisfies Logger automatically. That's decoupling."

Eleanor smiled. "Because it decouples interfaces from implementations. In Java, when you write a class, you declare all the interfaces it implements. Your class is locked to those interfaces forever. In Go, anyone can define an interface that your type happens to satisfy. Libraries can work with your types without your types knowing those libraries exist."

"That sounds powerful."

"It is. The standard library defines small, focused interfaces like io.Reader and io.Writer. Hundreds of types satisfy these interfaces—types from the standard library, third-party packages, and your own code. They all work together seamlessly because they share behavior, not because they declared a relationship."

She paused. "Rob Pike said Go's interfaces are about what types can do, not what they are. A dog is not a speaker—but a dog can speak. That distinction matters."

Ethan climbed the stairs, thinking about implicit contracts and duck typing with safety nets. In Python, you just called methods and hoped they existed. In Java, you declared relationships everywhere. Go found the middle ground—flexible like Python, safe like Java.

Maybe that was the pattern: Go trusted you to define small, focused interfaces, and trusted types to satisfy them naturally. No bureaucracy. No inheritance hierarchies. Just behavior, clearly specified and automatically satisfied.


Key Concepts from Chapter 7

Interfaces: Named collections of method signatures. Define what a type can do, not what it is.

Interface definition: type Name interface { Method1(); Method2() returnType }.

Implicit satisfaction: Types satisfy interfaces automatically by implementing the required methods. No implements keyword needed.

Polymorphism: Write functions that accept interfaces, work with any type that satisfies the interface.

Empty interface (any): any (alias for interface{}) has no methods, so every type satisfies it. Used for "any type" but requires type assertions to use.

Type assertions: value, ok := interfaceVar.(ConcreteType) checks if an interface holds a specific type. Uses comma-ok idiom.

Type assertion safety: Always use the comma-ok idiom (value, ok := i.(Type)) to safely check types. Omitting ok will panic if the assertion fails.

Type switches: switch v := interfaceVar.(type) branches based on the actual type held by an interface variable.

Interface embedding: Interfaces can embed other interfaces. A type must satisfy all embedded interfaces' methods to satisfy the embedding interface.

Small interfaces: Go convention favors small, focused interfaces (often just one or two methods) over large ones.

Standard library interfaces: Common interfaces like io.Readerio.Writerfmt.Stringer are widely satisfied by many types.

Compile-time checking: Interface satisfaction is verified at compile time, providing type safety with flexibility.

Decoupling: Interfaces decouple behavior from implementation. Code can work with interfaces it didn't know would exist.


Next chapter: Goroutines and Channels—where Ethan learns about Go's approach to concurrency, and Eleanor explains why "Don't communicate by sharing memory; share memory by communicating" changes everything.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Comments

Popular posts from this blog

The New ChatGPT Reason Feature: What It Is and Why You Should Use It

Insight: The Great Minimal OS Showdown—DietPi vs Raspberry Pi OS Lite

Raspberry Pi Connect vs. RealVNC: A Comprehensive Comparison