Composición y embedding de interfaces en Go

En Go, las interfaces no se implementan de forma explícita mediante palabras clave como implements. En su lugar, se satisfacen implícitamente. Esto permite un diseño basado en la composición, donde puedes construir interfaces complejas uniendo otras más pequeñas. Fíjate en io.ReadWriter [disponible desde Go 1.0]: no es una definición masiva de métodos, sino que simplemente combina io.Reader e io.Writer.

Cuando defines una interfaz que compone a otras, estás creando un contrato que exige que un tipo implemente la unión de todas las firmas de los métodos de las interfaces integradas. Esto es fundamental para mantener tus interfaces pequeñas y enfocadas, cumpliendo con el principio de responsabilidad única. En lugar de diseñar una interfaz gigante desde cero, diseñas piezas atómicas y las recompones según la necesidad.

Esta técnica es extremadamente útil cuando necesitas crear “wrappers” o decoradores. Al embed una interfaz dentro de un struct, ese struct adquiere automáticamente todos los métodos de la interfaz embebida. Sin embargo, hay una distinción crítica que debes entender para evitar errores en producción: cuando embebes una interfaz en un struct, lo que estás haciendo es incluir un campo que contiene un valor que satisface esa interfaz; no estás heredando una implementación. Si embebes un tipo concreto, estás promoviendo sus métodos directamente al struct; si embebes una interfaz, solo promueves sus firmas y dependes de que alguien asigne un valor al campo antes de usarlo.

Debes usar la composición de interfaces siempre que tu contrato requiera múltiples capacidades (leer y escribir, cerrar y leer, etc.). Es la forma de mantener tu código desacoplado. Si intentas definir interfaces demasiado grandes de forma manual, terminarás con un código rígido donde los mocks para tests serán una pesadilla, ya que tendrías que implementar decenas de métodos que no te interesan para una prueba unitaria. Si lo haces mal, especialmente al embeber interfaces en structs sin inicializarlas, el runtime lanzará un panic por desreferencia de un puntero nulo en cuanto intentes llamar a uno de esos métodos “heredados”.

package main

import (
	"fmt"
	"io"
	"os"
)

// Definimos interfaces pequeñas y atómicas.
type Reader interface {
	Read(p []byte) (n int, err error)
}

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

// ReadWriter es una interfaz compuesta. 
// Cualquier tipo que implemente Read y Write, implementará ReadWriter automáticamente.
type ReadWriter interface {
	Reader
	Writer
}

// InstrumentedWriter es un "decorator" que implementa Writer.
// Embebe io.Writer (una interfaz), lo que le permite "heredar" la firma de Write
// pero nos permite interceptar la llamada para añadir lógica de telemetría.
type InstrumentedWriter struct {
	io.Writer
}

// Write intercepta la llamada al método Write del objeto embebido.
func (iw *InstrumentedWriter) Write(p []byte) (n int, err error) {
	fmt.Printf("[LOG]: Intentando escribir %d bytes...\n", len(p))
	// Llamamos al método Write del objeto real que está dentro de la interfaz embebida.
	n, err = iw.Writer.Write(p)
	if err != nil {
		fmt.Printf("[LOG]: Error en la escritura: %v\n", err)
		return n, err
	}
	fmt.Printf("[LOG]: Escritura exitosa: %d bytes escritos.\n", n)
	return n, err
}

// CustomBuffer es un struct que implementa ReadWriter por composición de tipos.
// En este caso, embebe un tipo concreto (os.File) para satisfacer las interfaces.
type CustomBuffer struct {
	*os.File
}

func main() {
	// Usamos os.Stdout como nuestro destino de escritura real.
	// Envolvemos os.Stdout en nuestro decorador InstrumentedWriter.
	// Nota: Pasamos os.Stdout porque implementa io.Writer.
	wrappedStdout := &InstrumentedWriter{
		Writer: os.Stdout,
	}

	// Usamos una función que espera un io.Writer, pero le pasamos nuestro decorador.
	// Esto es posible gracias a que InstrumentedWriter satisface la interfaz Writer.
	printData(wrappedStdout, []byte("Hola, Go 1.23!\n"))

	// Ejemplo con un struct que implementa ReadWriter (simulado con un archivo)
	// En un escenario real, podrías tener un struct que implemente ReadWriter 
	// para procesar streams de datos de forma compleja.
	fmt.Println("\n--- Ejecución completada ---")
}

// printData no sabe que está escribiendo en un terminal o en un log; 
// solo sabe que lo que recibe tiene un método Write.
func printData(w io.Writer, data []byte) {
	_, _ = w.Write(data)
}

Desglose del código

En el ejemplo, la interfaz ReadWriter demuestra la composición de interfaces: no hay que reescribir Read ni Write; simplemente se incluyen las definiciones existentes.

El struct InstrumentedWriter es la pieza clave del patrón Decorator. Al declarar io.Writer dentro de él (sin nombre de campo, solo el tipo), estamos haciendo embedding de una interfaz. Esto provoca que InstrumentedWriter ahora tenga el método Write disponible. Sin embargo, fíjate en la implementación de func (iw *InstrumentedWriter) Write(...): no estamos sobrescribiendo la lógica desde cero para el objeto original, sino que estamos delegando la ejecución en iw.Writer.Write(p). Esto nos permite inyectar el fmt.Printf (la telemetría) antes y después de la llamada real.

En el main, cuando hacemos &InstrumentedWriter{Writer: os.Stdout}, estamos asignando un valor concreto (os.Stdout) al campo embebido. Esto es lo que permite que la llamada a w.Write en printData funcione. Si wrappedStdout fuera inicializado como &InstrumentedWriter{}, el campo Writer sería nil y el programa colapsaría al intentar ejecutar iw.Writer.Write(p).

Finalmente, printData es un ejemplo de programación orientada a interfaces: la función es totalmente agnóstica de si está escribiendo directamente en la consola (os.Stdout) o si está pasando por nuestra capa de instrumentación.

El error frecuente

Un error clásico ocurre cuando embebes una interfaz en un struct para “ahorrar trabajo” al implementar un mock o un wrapper, pero olvidas inicializarla.

type BadWrapper struct {
    io.Writer // Interfaz embebida
}

func main() {
    w := BadWrapper{}
    // ¡PANIC! w.Writer es nil, y estamos intentando llamar a su método Write.
    w.Write([]byte("esto fallará")) 
}

Para que el embedding de una interfaz en un struct funcione, el campo debe ser inicializado con un objeto que implemente dicha interfaz: w := BadWrapper{Writer: os.Stdout}.

77

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio