The underlying magic of Go interfaces

01 Deconstruction: The Underlying Structure of the Interface

The interface can flexibly adapt to different types, which stems from the “dual-column storage” logic of its underlying design. However, there are key differences in the structure between empty and non-empty interfaces, which is the basis for understanding the characteristics of the interface.

1.1 Empty Interface (eface): The underlying logic of the “universal container”

The empty interface ( interface{}) has no method declarations and can accept any type (such as intstring, slice, struct, etc.). Its underlying implementation relies on efacestruct to achieve “type + value” binding storage.

1.1.1 Core Principle: Dual-Column Storage Area

The underlying essence of an empty interface is a structure containing “type metadata” and “value pointers,” which can be compared to a “labeled storage box”—the label records the item type, and the box records the item’s storage location. The specific structure definition is as follows:

// The underlying structure of the empty interface 
type eface struct {
_type *_type // Pointer to the metadata of the dynamic type (such as the type description of int, []int)
data unsafe.Pointer // Pointer to the memory address of the dynamic value (the actual storage location of the data)
}

// _type: The "metadata template" of all types in Go, recording the core attributes of the type
type _type struct {
size uintptr // The memory size occupied by the type (such as int is 8 bytes in a 64-bit system)
ptrdata uintptr // The memory size of pointer type data (0 for non-pointer types)
hash uint32 // The type hash value (used for map key comparison and fast type matching)
tflag tflag // Type flags (such as whether it is a pointer or a structure)
align uint8 // Memory alignment requirements (such as int requires 8-byte alignment)
fieldalign uint8 // Alignment requirements for structure fields
kind uint8 // Type type (such as kindInt=2 for int, kindString=8 for string)
// Other metadata (such as structure fields, method sets, etc., are dynamically supplemented according to different types
)
1.1.2 Storage Logic: Data Binding Completed in Three Steps

Taking code var a interface{} = 100as an example, the stored procedure for an empty interface can be broken down into 3 steps:

  1. Initialize efacethe structure : Create an empty efaceinstance to store type and value information;
  2. Binding type metadata : _typeFields point to instances intof the type _type, and attributes such as record intsize (8 bytes) and type ( );kindInt
  3. Bind value pointer : Allocates storage space in memory 100and points datathe field to the address of that space.

aIn the final efacestructure, _typeit marks “this is an int type” and datapoints to “the memory address of 100”, thus realizing intthe storage of type values. Similarly, stringwhen storing and slicing, only _typethe metadata and datathe address to be pointed to need to be replaced, which is the core reason why the empty interface is “universal”.

1.2 Non-empty interface ( iface): “Container with gatekeeper”

Non-empty interfaces (such as type MyInterface interface{ Do() }) have explicit method declarations and only accept “types that implement all methods”. Their underlying dependency ifaceis a struct, and they have the additional capability of “method matching and verification” compared to empty interfaces.

1.2.1 Core Difference: Added “Method Matching Area”

The underlying structure of a non-empty interface efaceadds an itab(interface-type matching table) to the existing structure. This table is responsible for verifying whether the dynamic type implements all methods of the interface, acting like a “gatekeeper”—only types that pass the method verification can be stored. The specific structure definition is as follows:

// The underlying structure of a non-empty interface 
type iface struct {
tab *itab // Matching information between the interface and the dynamic type (the core of method verification)
data unsafe.Pointer // Memory address pointing to the dynamic value (consistent with the data function of eface)
}

// itab: The "matching contract" between the interface and the dynamic type, storing the method binding result
type itab struct {
inter *interfacetype // Meta-information pointing to the interface type (such as the method list of MyInterface)
_type *_type // Meta-information pointing to the dynamic type (such as the type description of *MyStruct)
link *itab // Hash table link pointer, used to cache matched interface-type pairs (improving performance)
bad bool // Marks whether the dynamic type has not implemented the interface (true = not implemented)
inhash bool // Marks whether the itab has been stored in the hash table (avoiding duplicate caching)
fun [1]uintptr // Array of method set pointers, storing the address of the interface method implemented by the dynamic type (such as the address of *MyStruct.Do())
}

// interfacetype: Meta-information of the interface type, recording the method list of the interface
type interfacetype struct {
typ _type // Type metadata of the interface itself (essentially a special type of _type)
pkgpath string // Package path of the interface (to avoid conflicts between interfaces with the same name in different packages)
methods []method // List of methods declared in the interface (e.g., the Do() method of MyInterface)
}
1.2.2 Key question: Why nilis the interface considered null when a pointer is assigned to a non-null interface false?

First, let’s look at the example code and its output:

package main 
import "fmt"

type MyInterface interface{ Do() } // Non-null interface
type MyStruct struct{} // Structure type
func (m *MyStruct) Do() {} // Pointer receiver implements Do()

func main() {
var ms *MyStruct = nil // ms is a nil *MyStruct pointer
var mi MyInterface = ms // Assign the nil pointer to the interface
fmt.Println("ms == nil?", ms == nil) // Output: ms == nil? true
fmt.Println("mi == nil?", mi == nil) // Output: mi == nil? false
}

Reason analysis : The condition for a non-empty interface to be empty is ” both tabitabdataare empty”, not datajust empty:

  1. When msnilof *MyStruct) is assigned a value miifacethe tabfield will first complete the method matching:
    • interMyInterfaceThe metadata it points to confirms Do()the method required by the interface;
    • _typeThe pointed *MyStruct-to metadata has been verified and therefore *MyStructimplemented ;Do()bad=false
    • funThe array stores *MyStruct.Do()the method addresses, completing the method binding.
  2. At this point ifacetabthe non-empty (method matching completed) and _typenon-empty (pointer *MyStructdataare only empty (because msit is nil), which does not satisfy the “double empty” condition, therefore mi != nil.

02 Practical Application: Core Usage and High-Frequency Scenarios

After mastering the underlying structure, you need to understand the core usage of interfaces in conjunction with practical scenarios—duck typing, type assertion, interface composition, and decoupling practices. These are the core manifestations of the flexibility of Go interfaces.

2.1 Duck Typology: The Design Philosophy of “Behavior is Typology”

Duck typing is the soul of Go interfaces. Its core principle is: “If a type walks like a duck and quacks like a duck, then it is a duck .” That is, to determine whether a type conforms to an interface, we don’t look at explicit declarations, but only at whether it implements all the methods of the interface.

2.1.1 Three Core Features
  1. Implicit implementation : Unlike Java implements, no declaration is required; the implementation is automatically applied based on the matching method set. Example:
// Define the interface (the duck's "behavioral standard") 
type Duck interface {
Quack() // Quack
Walk() // Walk
}

// Define the true duck type (Duck implementation not explicitly declared)
type Mallard struct{}

// Implement all methods of Duck (method names, parameters, and return values ​​are completely identical)
func (m Mallard) Quack() { fmt.Println("Quack") }
func (m Mallard) Walk() { fmt.Println("Waddle away") }

func main() {
var d Duck = Mallard{} // Valid: Mallard implicitly implements Duck
d.Quack() // Output: Quack
d.Walk() // Output: Waddle away
}

2. Focus on behavior, not type : Different types can be accepted by the same interface as long as their behaviors match. Example (toy ducks and real ducks share Duckthe same interface).

type ToyDuck struct{} // Toy duck type (no inheritance relationship with Mallard) 

// Implements all methods of Duck (behavior matching)
func (t ToyDuck) Quack() { fmt.Println("Toy duck: Squeak") }
func (t ToyDuck) Walk() { fmt.Println("Toy duck: Slide away") }

func main() {
var d1 Duck = Mallard{} // Real duck
var d2 Duck = ToyDuck{} // Toy duck (behavior matching, accepted)
d1.Quack() // Output: Quack
d2.Quack() // Output: Toy duck: Squeak
}

3. Compile-time safety checks : Although similar to the concept of dynamic typing, interface matching is completed at compile time, avoiding runtime errors. Example of an error (chicken not implemented Quack()).

type Chicken struct{}
func (c Chicken) Walk() { fmt.Println("Chick walking") } // Only implement Walk()
 
func main() {
    var d Duck = Chicken{} // Compilation error: Chicken does not have Quack() method
}
2.1.2 Value: Decoupling and Expansion

Duck typing frees code from “type dependencies.” For example, when designing a “feeding function,” there’s no need to write separate functions for `feed` and ` Mallardcatch` ToyDuck; you only need to receive Duckthe interface.

func Feed(d Duck) {
    fmt.Println("Feed the ducks")
    d.Quack() // Call the "Quack" method of ducks
}
 
func main() {
    Feed(Mallard{})  // Feed ducks
    Feed(ToyDuck{})  // Feed the toy duck
}

2.2 Type Assertion: “Retrieving” a Concrete Type from an Interface

Interfaces store “type + value”. Type assertions are used to convert interface variables into specific types, and there are two methods: “direct assertion” and “safe assertion”.

2.2.1 Direct assertion ( panicrisky)

grammar:Specific type variable=interface variable (Specific type)

  • If the dynamic type of the interface variable matches the target type, return the specific value;
  • If there is a mismatch, trigger directly panic(not recommended for production environments).

Example:

package main 
import "fmt"

func main() {
var a interface{} = 100 // An empty interface stores an int value
b := a.(int) // Directly asserts it as int (match successful)
fmt.Println(b + 100) // Output: 200

// c := a.(string) // Directly asserts it as string (no match, triggers panic)
}
2.2.2 Security Assertions (Recommended)

grammar:Specific type variable, matching result=interface variable (Specific type)

  • Returns two values: the first is a value of the specific type (zero for that type if the match fails), and the second is booltrue= if the match is successful, false= if the match fails).
  • It does not trigger when a match fails panicboolthe logic is based on value judgment, which is safer.

Example:

package main 
import "fmt"

func main() {
var a interface{} = 100
// Safe assertion for string
c, ok := a.(string)
fmt.Println("c=", c, "ok=", ok) // Output: c= ok= false (match failed, c is the zero value of string "")

// Combine with switch to implement "multi-type matching" (type selection)
switch t := a.(type) {
case int:
fmt.Println("int type, value squared:", t*t) // Output: int type, value squared: 10000
case string:
fmt.Println("string type, length:", len(t))
default:
fmt.Println("unknown type:", t)
}
}

2.3 Interface composition: Enabling the reuse and extension of method sets

Interface composition is the core way of “reusing method sets” in Go. It generates new interfaces by “embedding existing interfaces”, and the method set of the new interface is the union of the method sets of all the embedded interfaces.

2.3.1 Classic Example: io.ReadWriterInterface

ioIn Go’s standard library packages, ReadWriterinterfaces are generated through Readercomposition :Writer

// Existing interface: Reader (reading behavior) 
type Reader interface {
Read(p []byte) (n int, err error) // Reads data of length p, returns the number of bytes read and the error message
}

// Existing interface: Writer (writing behavior)
type Writer interface {
Write(p []byte) (n int, err error) // Writes data of length p, returns the number of bytes written and the error message
}

// Interface composition: ReadWriter = Reader + Writer
type ReadWriter interface {
Reader // Embedded Reader interface (inherits Read() method)
Writer // Embedded Writer interface (inherits Write() method)
}
2.3.2 Implementation Logic: Implicitly implements the composite interface

A type implicitly implements a composite interface if it implements all the methods of the embedded interfaces. Example:

// Define the File type and implement Read() and Write() 
type File struct{}

func (f File) Read(p []byte) (int, error) {
// Actual logic: Read data from the file into p
return len(p), nil // Simplified: Return the number of bytes read (the length of p)
}

func (f File) Write(p []byte) (int, error) {
// Actual logic: Write the data in p to the file
return len(p), nil // Simplified: Return the number of bytes written (the length of p)
}

func main() {
var rw ReadWriter = File{} // Valid: File implements Read() and Write(), implicitly implements ReadWriter
buf := make([]byte, 1024) // Create a 1024-byte buffer
rw.Read(buf) // Call the Read() method (from Reader)
rw.Write(buf) // Call the Write() method (from Writer)
}
2.3.3 Value: Flexible Expansion and Standardization

Interface composition avoids the redundancy of “repeatedly declaring methods,” for example:

  • To add a new “read/write interface with a closing function”, simply combine ReadWriterand Closertype Closer interface{ Close() error }) to generate ReadWriteCloserthe interface;
  • Different types (such as File, , Socket) can reuse the same set of interface standards as long as they implement the corresponding methods, thus reducing code coupling.

2.4 Interface-based Decoupling Practices

One of the core values ​​of an interface is “decoupling the caller from the implementer”—the caller only needs to rely on the method declarations of the interface and does not need to care about the specific implementation, thus achieving “replacing the implementation without modifying the calling logic”.

2.4.1 Practical Scenario: USB Device Management

Suppose a computer needs to support USB devices such as “mobile phones” and “cameras”. All you need to do is define USBan interface specification. The caller (computer) depends on the interface, and the implementers (mobile phones and cameras) implement the interface methods respectively.

Example code:

package main 
import "fmt"

// 1. Define the USB interface (standardize the behavior of USB devices)
type USB interface {
Start() // Device starts
Stop() // Device stops
}

// 2. Implementer 1: Mobile phone (implements the USB interface)
type Phone struct {
name string // Mobile phone brand
}

func (p Phone) Start() {
fmt.Printf("%s The phone is starting...\n", p.name)
}

func (p Phone) Stop() {
fmt.Printf("%s The phone is stopping...\n", p.name)
}

// 3. Implementer 2: Camera (implements the USB interface)
type Camera struct {
name string // Camera brand
}

func (c Camera) Start() {
fmt.Printf("%s The camera is starting...\n", c.name)
}

func (c Camera) Stop() {
fmt.Printf("%s The camera is stopping...\n", c.name)
}

// 4. Caller: Computer (depends on the USB interface, does not care about the specific device)
func ManageUSB(usb USB) {
usb.Start() // Start the device (call the interface method)
usb.Stop() // Stop the device (call the interface method)
}

func main() {
// Create different USB devices
vivo := Phone{name: "vivo"}
nikon := Camera{name: "Nikon"}

// Unified device management (callers do not need to distinguish between mobile phones and cameras)
ManageUSB(vivo) // Output: vivo phone starts working... + vivo phone stops working...
ManageUSB(nikon) // Output: Nikon camera starts working... + Nikon camera stops working...

// Extension: Support more devices (such as USB flash drives), only need to implement the USB interface, no need to modify ManageUSB
type UDisk struct{ name string }
func (u UDisk) Start() { fmt.Printf("%s USB flash drive starts working...\n", u.name) }
func (u UDisk) Stop() { fmt.Printf("%s USB flash drive stops working...\n", u.name) }
ManageUSB(UDisk{name: "Kingston"}) // Output: Kingston USB drive started working... + Kingston USB drive stopped working...
}
2.4.2 Decoupling Value
  • Adding a new implementation requires no modification to the calling logic : For example, to add a “USB flash drive” device, you only need to implement USBthe interface; ManageUSBthe function does not need any changes.
  • The replacement is flexible : if Start()the logic of the “phone” needs to be optimized (such as adding “network check”), only the changes need to be made Phone.Start(), without affecting ManageUSBother devices;
  • Test-friendlyMockUSB : Test ManageUSBlogic can be implemented via simulation (e.g. ) without relying on real hardware.

03 Avoiding Pitfalls: A List of Common Mistakes at the Source Code Level

When using interfaces, if the underlying principles are ignored, it is easy to fall into problems such as “confusion between value/pointer receivers” and “value passing misunderstandings”. The following is a source code-level guide to avoid these pitfalls.

3.1 Avoiding Pitfalls Guide 1: Implementation Differences Between Value Receivers and Pointer Receivers

The type of the method receiver (value/pointer) of an interface directly determines “which types can be assigned to the interface”, and the core is the inclusion relationship of the method set .

3.1.1 Practical Examples and Error Analysis

Example 1: Value receivers implement an interface (supports assignment of value types and pointer types)

type Printer interface{ Print() } 
type Dog struct{}

// Value receiver implements Print()
func (d Dog) Print() { fmt.Println("Value receiver") }

func main() {
var p Printer
p = Dog{} // Correct: The method set for value type Dog includes Print()
p.Print() // Output: Value receiver

p = &Dog{} // Correct: The method set for pointer type *Dog includes Print() (auto-dereference)
p.Print() // Output: Value receiver
}

Example 2: Pointer receiver implements an interface (pointer type assignment is supported only)

type Printer interface{ Print() } 
type Dog struct{}

// Pointer receiver implementation of Print()
func (d *Dog) Print() { fmt.Println("Pointer receiver") }

func main() {
var p Printer
// p = Dog{} // Compilation error: The method set for value type Dog does not contain Print()
p = &Dog{} // Correct: The method set for pointer type *Dog contains Print()
p.Print() // Output: Pointer receiver
}
3.1.2 Summary of Avoiding Pitfalls
  • If you want both “value types” and “pointer types” to be assigned to an interface, implement the method using a value receiver ;
  • If a method needs to modify the internal state of a type (such as modifying a struct field), it must use a pointer receiver (a value receiver operates on a copy and cannot modify the original variable).
  • The compilation error ” X does not implement Y (method Z has pointer receiver)” essentially means “the method set of the value type does not contain the methods required by the interface”, and the value type needs to be converted to a pointer type for assignment.

3.2 Avoidance Guide 2: Misconceptions about “Passing by Value” when Interfaces are Used as Parameters

In Go, all parameters are passed by value , including interface parameters—a copy of the struct is passed efaceifacenot the original interface variable itself. However, there are key differences in the modification behavior between “primitive types” and “reference types”.

3.2.1 Core Principle: Modification Boundaries of Replicas
  • The interface parameters are passed as a copy of eface/, ifaceand the fields in the copy point to the same memory address as the fields datain the original interface ;data
  • If the dynamic type is a basic type ( inte.g., string, , booletc.): modifying datathe value pointed to by the copy is essentially modifying the “memory copy pointed to by the copy” (because assignment of a basic type is a value copy), while the original variable remains unchanged;
  • If the dynamic type is a reference type (slice, mapchannel, pointer, etc.): modifying datathe value pointed to by the copy is essentially modifying the “data in the original memory” (because the reference type datapoints to the underlying data structure), and the original variable will change.
3.2.2 Practical Examples and Analysis

Example code:

package main 
import "fmt"

// Try modifying the value of the interface parameter
func modifyInterface(a interface{}) {
// Case 1: The dynamic type is a primitive type (int)
if num, ok := a.(int); ok {
num = 200 // Modifying the copy num (the memory copy pointed to by a's data), the original variable remains unchanged
fmt.Println("num inside modify:", num) // Output: num inside modify: 200
}

// Case 2: The dynamic type is a reference type ([]int)
if slice, ok := a.([]int); ok {
slice[0] = 100 // Modifying the underlying array pointed to by slice (the original memory data), the original variable will change
fmt.Println("slice inside modify:", slice) // Output: slice inside modify: [100 2 3]
}
}

func main() {
// Test the primitive type
var a int = 100
modifyInterface(a)
`fmt.Println("a inside main:", a)` // Output: `a inside main: 100` (Original variable unchanged)

// Test reference type:
`var b []int = []int{1, 2, 3}
modifyInterface(b)` `
fmt.Println("b inside main:", b)` // Output: `b inside main: [100 2 3]` (Original variable changed)
}
3.2.3 Avoiding Pitfalls: Limitations on “Deep Modification” of Reference Types

Even with reference types, not all modifications affect the original variable. If the modification changes the “structure of the reference type itself” (such as slice appendresizing or mapreassignment), the original variable will remain unchanged because only the copy is being modified data.

func modifySlice(a interface{}) { 
if slice, ok := a.([]int); ok {
// append to expand: generate a new underlying array, the data of the slice copy points to the new array
slice = append(slice, 4)
fmt.Println("slice inside modify:", slice) // Output: [100 2 3 4]
}
}

func main() {
var b []int = []int{1, 2, 3}
modifySlice(b)
fmt.Println("b inside main:", b) // Output: [1 2 3] (The original variable remains unchanged because the copy points to the new array)
}
3.2.4 Summary of Avoiding Pitfalls
  • Interface parameters are passed by value; modifying a copy of a “basic type” will not affect the original variable.
  • Modifying a “reference type” only affects the “underlying data” (such as slice elements and mapkey-value pairs), and cannot affect the “structure of the reference type itself” (such as slice length and mappointer reassignment).
  • If you need to modify the original variable (whether it is a primitive type or a reference type), you can change the interface parameter to an “interface pointer” (e.g. *interface{}), but you should use it with caution (it may increase code complexity).