En Go, la potencia no reside en lo que una interfaz puede hacer, sino en lo poco que necesita saber. Mientras que en otros lenguajes es común encontrar interfaces “pesadas” con decenas de métodos, en Go la norma es la segregación extrema. La idea es que las interfaces deben ser tan pequeñas que sea casi imposible no cumplirlas por accidente.
Esta filosofía se basa en que las interfaces en Go son satisfechas de forma implícita. No necesitas declarar explícitamente que un tipo implementa una interfaz; si tu tipo tiene el método Read(p []byte) (n int, err error), entonces ya es un io.Reader. Esto permite que el ecosistema sea increíblemente composable: puedes escribir una función que acepte un io.Reader y esa función funcionará automáticamente con un archivo, un socket de red, un buffer en memoria o un objeto de un tercero que tú no controlas.
Si diseñas interfaces grandes, estás creando un acoplamiento fuerte. Si una función pide una interfaz con cinco métodos y tú solo necesitas uno, obligas al llamador a implementar los otros cuatro métodos (aunque sean con panic o simplemente no hagan nada) solo para poder usar la función. Esto hace que el código sea difícil de testear (necesitas mocks gigantescos) y muy rígido. En cambio, si diseñas interfaces de un solo método, permites que cualquier tipo “se una a la fiesta” implementando solo lo que realmente necesita.
package main
import (
"errors"
"fmt"
"io"
)
// DataStream es una estructura que simula un flujo de datos en memoria.
// Nota cómo implementa múltiples interfaces estándar sin conocerlas explícitamente.
type DataStream struct {
content []byte
pos int
}
// Write implementa io.Writer. Permite añadir datos al stream.
func (d *DataStream) Write(p []byte) (n int, err error) {
d.content = append(d.content, p...)
return len(p), nil
}
// Read implementa io.Reader. Permite leer datos desde la posición actual.
func (d *DataStream) Read(p []byte) (n int, err error) {
if d.pos >= len(d.content) {
return 0, io.EOF
}
n = copy(p, d.content[d.pos:])
d.pos += n
return n, nil
}
// String implementa fmt.Stringer. Permite una representación textual.
func (d *DataStream) String() string {
return fmt.Sprintf("Stream(len=%d, pos=%d)", len(d.content), d.pos)
}
// Close implementa io.Closer. Limpia recursos al terminar.
func (d *DataStream) Close() error {
d.content = nil
return nil
}
// persistencia es una función que solo le importa la capacidad de escribir.
// Al aceptar io.Writer, no le importa si es un archivo, un buffer o un socket.
func persistencia(w io.Writer, data string) error {
_, err := fmt.Fprint(w, data)
return err
}
// mostrarEstado solo necesita saber cómo convertir algo a string.
func mostrarEstado(s fmt.Stringer) {
fmt.Printf("Estado actual: %s\n", s)
}
func main() {
stream := &DataStream{}
// Usamos stream como io.Writer gracias a su método Write
err := persistencia(stream, "Hola, Go!")
if err != nil {
panic(err)
}
// Usamos stream como fmt.Stringer gracias a su método String
mostrarEstado(stream)
// Leemos del stream (como io.Reader) para verificar el contenido
buf := make([]byte, 10)
n, err := stream.Read(buf)
if err != nil && err != io.EOF {
panic(err)
}
fmt.Printf("Leído: %s (bytes: %d)\n", string(buf[:n]), n)
// Cerramos el stream como io.Closer
if err := stream.Close(); err != nil {
panic(err)
}
fmt.Println("Stream cerrado correctamente.")
}
Análisis del diseño
En el ejemplo anterior, la estructura DataStream es el centro de la composición. Fíjate en la función persistencia: su firma es func(io.Writer, string). No pide un *DataStream. Esto es crucial. Al aceptar la interfaz io.Writer, la función es genérica por diseño. Podrías pasarle un os.File o un bytes.Buffer y funcionaría exactamente igual. El acoplamiento se mantiene en el mínimo nivel necesario para cumplir la tarea.
Cuando llamamos a mostrarEstado(stream), estamos aprovechando que DataStream implementa fmt.Stringer. La función no necesita saber qué es un DataStream, solo necesita saber que puede llamar al método String(). Esto es lo que hace que el sistema sea extensible: si mañana creas un tipo DatabaseLog, solo tienes que implementar String() para que mostrarEstado lo acepte.
La gestión de recursos se maneja mediante io.Closer. En aplicaciones reales, esto es vital para evitar fugas de memoria o de descriptores de archivos. Al separar Read, Write, String y Close en interfaces distintas, permites que una función que solo lee datos (io.Reader) no tenga la responsabilidad (ni la posibilidad accidental) de cerrar el recurso, manteniendo la responsabilidad única en cada nivel de la abstracción.
El error frecuente
Un error común al venir de otros lenguajes es intentar “predecir el futuro” definiendo interfaces excesivamente completas.
// ERROR: Interface "Fat" (pesada)
type DataProcessor interface {
Read() []byte
Write([]byte) error
Close() error
Flush() error
Reset() error
GetStatus() string
}
Si defines DataProcessor de esta manera, cualquier tipo que quieras usar en una función que requiera un DataProcessor estará obligado a implementar todos esos métodos. Si solo necesitas leer datos, te verás obligado a escribir implementaciones vacías para Flush, Reset, etc., solo para que el compilador te deje pasar. Esto ensucia el código, complica los tests unitarios (tienes que mockear 6 métodos en lugar de 1) y rompe la flexibilidad que hace que Go sea tan potente.
Si tu función necesita leer, acepta io.Reader. Si necesita escribir, acepta io.Writer. Si necesita cerrar, acepta io.Closer. La segregación es tu mejor aliada para un código mantenible.
N° 75