io.Reader e io.Writer son las interfaces fundamentales que definen cómo fluyen los datos en Go. Una interfaz es, en esencia, un contrato: si un tipo implementa el método Read(p []byte) (n int, err error), puede ser usado en cualquier función que espere un lector; si implementa Write(p []byte) (int, error), puede ser un destino de datos.
Estas interfaces funcionan bajo el principio de abstracción máxima. En lugar de crear funciones específicas para leer archivos, otras para sockets TCP y otras para buffers de memoria, Go define estas dos interfaces de un solo método. Esto permite un desacoplamiento total: a una función que procesa datos no le importa si los bytes vienen de un archivo en un disco mecánico o de una conexión de red a mil kilómetros de distancia, siempre que el objeto cumpla el contrato.
Debes usarlas siempre que necesites mover, transformar o filtrar datos. Si estás escribiendo un componente que comprime datos, un cifrador, o un simple logger, no deberías recibir un *os.File o un *net.Conn; deberías recibir un io.Reader o un io.Writer. De este modo, tu código será infinitamente más flexible y testeable.
Si aplicas mal estas interfaces, especialmente al ignorar el valor de retorno n (la cantidad de bytes procesados) o al tratar a io.EOF como un error crítico de fallo en lugar de una señal de finalización, tu programa sufrirá pérdida de datos o entrará en bucles infinitos de lectura.
package main
import (
"fmt"
"io"
"strings"
)
// CounterWriter es un "decorator" que implementa io.Writer.
// Envuelve a otro io.Writer y lleva la cuenta de los bytes escritos.
type CounterWriter struct {
destination io.Writer
total int64
}
// Write implementa el contrato de io.Writer.
func (cw *CounterWriter) Write(p []byte) (int, error) {
// Delegamos la escritura al destino original
n, err := cw.destination.Write(p)
// Acumulamos cuántos bytes se han procesado realmente
cw.total += int64(n)
return n, err
}
func main() {
// 1. La fuente (io.Reader): Un buffer de memoria con datos.
data := "Go es potente y eficiente en su gestión de I/O."
reader := strings.NewReader(data)
// 2. El destino (io.Writer): Usamos io.Discard (un writer que no hace nada)
// para simular que estamos procesando datos sin voluminar la terminal.
// Lo envolvemos con nuestro CounterWriter para interceptar los bytes.
counter := &CounterWriter{destination: io.Discard}
// 3. El orquestador: io.Copy es la función que une ambos mundos.
// Internamente, io.Copy llama a reader.Read() en un bucle hasta que
// recibe io.EOF, y luego llama a counter.Write() con los bytes obtenidos.
written, err := io.Copy(counter, reader)
if err != nil {
fmt.Printf("Error crítico en la transferencia: %v\n", err)
return
}
fmt.Printf("Datos originales: %q\n", data)
fmt.Printf("Bytes transferidos por io.Copy: %d\n", written)
fmt.Printf("Bytes contabilizados por CounterWriter: %d\n", counter.total)
}
En el ejemplo anterior, io.Copy actúa como el motor de transferencia. Esta función es un ejemplo perfecto de la potencia de estas interfaces: no sabe que reader es un strings.Reader ni que counter es un tipo personalizado; solo sabe que puede llamar a Read y Write.
Fíjate en CounterWriter. Al implementar el método Write(p []byte) (int, error), automáticamente se convierte en un io.Writer. Esto nos permite aplicar el patrón decorator: envolvemos un io.Writer existente para añadirle funcionalidad (contar bytes) sin alterar su comportamiento original. Es la base de cómo funcionan en Go los compresores (como gzip) o los cifradores: envuelven un io.Writer para transformar los datos mientras pasan.
En cuanto a la semántica de los errores, observa que io.Copy devuelve el número de bytes escritos y un error. Si la transferencia es exitosa, el error será nil, aunque internamente el lector haya alcanzado su fin y devuelto io.EOF.
El error frecuente
Un error clásico para quienes vienen de otros lenguajes es no procesar los bytes devueltos cuando ocurre un error de tipo io.EOF. El contrato de io.Reader especifica que es perfectamente válido que Read devuelva n > 0 junto con err == io.EOF. Esto ocurre cuando el último bloque de datos llega exactamente al mismo tiempo que la señal de fin de archivo.
// FORMA INCORRECTA:
n, err := r.Read(buf)
if err != nil {
if err == io.EOF {
return // ¡ERROR! Si n > 0, esos bytes en 'buf' se pierden para siempre.
}
return err
}
process(buf[:n])
// FORMA CORRECTA:
n, err := r.Read(buf)
if n > 0 {
process(buf[:n]) // Primero procesas lo que se pudo leer
}
if err != nil {
if err == io.EOF {
return nil // El fin de stream es un estado esperado, no un error.
}
return err
}
Si ignoras el procesamiento de n antes de evaluar el error, estarás dejando datos en el buffer en la última lectura de cualquier flujo de datos.
N° 108