Blog

Interfaces en Go: implícitas, pequeñas y la trampa del nil

Una interfaz de Go es una lista de métodos, y cualquier tipo que tenga esos métodos la satisface sin decirlo. Aprende conjuntos de métodos, Stringer, io.Reader, type switches y por qué un error que guarda un puntero nil no es nil.

Una interfaz en Go dice lo que un valor puede hacer, no lo que es. Cualquier tipo con los métodos correctos encaja, y nunca tiene que anunciarlo. Esa sola regla explica por qué el código Go suele tener interfaces pequeñas y pocas jerarquías de tipos.

Este post cubre cómo los tipos satisfacen interfaces, la regla de los receptores puntero que confunde a la gente, las interfaces pequeñas de la biblioteca estándar, any y los type switches, y la trampa del nil que ha mordido alguna vez a casi todo programador de Go. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.

Una interfaz es un conjunto de métodos

Un tipo interfaz lista firmas de métodos, y en él se puede guardar un valor de cualquier tipo que tenga todos esos métodos.

package main

import (
	"fmt"
	"math"
)

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

type Rect struct {
	W, H float64
}

func (r Rect) Area() float64      { return r.W * r.H }
func (r Rect) Perimeter() float64 { return 2 * (r.W + r.H) }

type Circle struct {
	R float64
}

func (c Circle) Area() float64      { return math.Pi * c.R * c.R }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.R }

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

func main() {
	describe(Rect{W: 3, H: 4})
	describe(Circle{R: 1})

	shapes := []Shape{Rect{W: 2, H: 2}, Circle{R: 2}}
	total := 0.0
	for _, s := range shapes {
		total += s.Area()
	}
	fmt.Printf("total area %.2f\n", total)
}

Imprime:

main.Rect: area 12.00, perimeter 14.00
main.Circle: area 3.14, perimeter 6.28
total area 16.57

Mira lo que falta. Rect y Circle nunca mencionan Shape. No hay ningún implements Shape por ninguna parte. Tienen un método Area y un método Perimeter con las firmas correctas, y con eso basta.

describe acepta cualquier Shape, y %T muestra el tipo concreto que realmente hay adentro. El slice []Shape guarda un Rect y un Circle uno al lado del otro, y el bucle llama a Area en cada uno sin saber cuál es cuál.

Como la satisfacción es implícita, puedes definir una interfaz después de que los tipos existan, incluso en otro paquete del que los autores de los tipos nunca oyeron hablar. Los tipos no necesitan cambiar.

El compilador verifica donde la usas

Implícita no significa sin verificar. En cuanto usas un valor como interfaz, el compilador verifica que su tipo tenga todos los métodos, y si falta uno, la compilación se detiene.

package main

import "fmt"

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

type Square struct {
	Side float64
}

func (s Square) Area() float64 { return s.Side * s.Side }

func describe(s Shape) {
	fmt.Println(s.Area(), s.Perimeter())
}

func main() {
	describe(Square{Side: 2})
}

La compilación falla con:

./main.go:21:11: cannot use Square{…} (value of struct type Square) as Shape value in argument to describe: Square does not implement Shape (missing method Perimeter)

El mensaje nombra el método que falta. Aquí no hay sorpresas en tiempo de ejecución: un tipo que no encaja es un error de compilación en la línea que intentó usarlo.

Verificar sin un lugar de uso

A veces nada en tu paquete pasa todavía el tipo como la interfaz. Un tipo de una biblioteca puede que solo lo usen así quienes la llaman. Para obtener la verificación de todos modos, los programadores de Go escriben una línea como esta:

package main

import "fmt"

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

type Square struct {
	Side float64
}

func (s *Square) Area() float64 { return s.Side * s.Side }

var _ Shape = (*Square)(nil)

func main() {
	fmt.Println("never gets here")
}

La compilación falla con:

./main.go:16:15: cannot use (*Square)(nil) (value of type *Square) as Shape value in variable declaration: *Square does not implement Shape (missing method Perimeter)

var _ Shape = (*Square)(nil) declara una variable que no puedes usar, porque su nombre es el identificador en blanco _. El valor es un *Square nil, que no cuesta nada. El único propósito de la línea es la asignación, que obliga al compilador a verificar que *Square satisface Shape. Agrega Perimeter y la línea compila a nada.

Receptores puntero y conjuntos de métodos

Si los métodos de un tipo tienen receptores puntero, solo el tipo puntero satisface la interfaz, y un valor común no.

package main

import "fmt"

type Counter interface {
	Inc()
	Value() int
}

type Clicks struct {
	n int
}

func (c *Clicks) Inc()       { c.n++ }
func (c *Clicks) Value() int { return c.n }

func main() {
	var c Counter = Clicks{}
	c.Inc()
	fmt.Println(c.Value())
}

La compilación falla con:

./main.go:18:18: cannot use Clicks{} (value of struct type Clicks) as Counter value in variable declaration: Clicks does not implement Counter (method Inc has pointer receiver)

Esto sorprende a la gente, porque con una variable común puedes llamar c.Inc() sobre un valor Clicks y Go toma su dirección por ti. Ese atajo no aplica a las interfaces. Guarda un puntero en su lugar:

package main

import "fmt"

type Counter interface {
	Inc()
	Value() int
}

type Clicks struct {
	n int
}

func (c *Clicks) Inc()       { c.n++ }
func (c *Clicks) Value() int { return c.n }

func main() {
	var c Counter = &Clicks{}
	c.Inc()
	c.Inc()
	fmt.Println(c.Value())
}

Imprime:

2

La regla tiene nombre: el conjunto de métodos (method set). El conjunto de métodos de T tiene los métodos con receptores valor. El conjunto de métodos de *T tiene esos más los que tienen receptores puntero. Un tipo satisface una interfaz cuando todos los métodos de la interfaz están en su conjunto de métodos.

La razón es lo que guarda una interfaz. Poner Clicks{} en una interfaz copia el struct dentro de ella. Si Go dejara que Inc se ejecutara sobre esa copia, c.n++ cambiaría una copia oculta, nada que tú pudieras ver, y la cuenta se quedaría en cero sin avisar. Negarse a compilar es la respuesta más amable. La parte sobre structs y métodos explica cuándo elegir un receptor puntero en primer lugar.

Interfaces pequeñas de la biblioteca estándar

Las interfaces más útiles de Go tienen un solo método, y la biblioteca estándar está construida sobre unas pocas de ellas.

fmt.Stringer

fmt.Stringer está declarada como interface { String() string }. El paquete fmt la busca, así que cualquier tipo con un método String controla cómo se imprime.

package main

import "fmt"

type Temp float64

func (t Temp) String() string {
	return fmt.Sprintf("%.1f°C", float64(t))
}

type Point struct {
	X, Y int
}

func main() {
	t := Temp(21.456)
	fmt.Println(t)
	fmt.Printf("%v and %s\n", t, t)
	fmt.Println(Point{X: 1, Y: 2})
	fmt.Println(float64(t))
}

Imprime:

21.5°C
21.5°C and 21.5°C
{1 2}
21.456

Temp tiene un método String, así que Println, %v y %s lo usan. Point no tiene ninguno, así que recibe el formato por defecto de los structs. La última línea merece una segunda mirada. Convertir t a float64 da un valor de otro tipo, y float64 no tiene método String, así que vuelve el número crudo.

Dentro de String llamamos a Sprintf con float64(t), no con t, por la misma razón. Pasar t con %v volvería a llamar a String, que volvería a llamar a Sprintf, para siempre.

io.Reader e io.Writer

io.Reader tiene un método, Read(p []byte) (n int, err error), e io.Writer tiene un método, Write(p []byte) (n int, err error). Los archivos, las conexiones de red, los cuerpos HTTP, los buffers y los strings satisfacen uno de los dos, o ambos.

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"strings"
)

type upperWriter struct {
	w io.Writer
}

func (u upperWriter) Write(p []byte) (int, error) {
	return u.w.Write(bytes.ToUpper(p))
}

func main() {
	r := strings.NewReader("hello from a string\n")
	n, err := io.Copy(os.Stdout, r)
	fmt.Println(n, err)

	loud := upperWriter{w: os.Stdout}
	io.Copy(loud, strings.NewReader("hello again\n"))
}

Imprime:

hello from a string
20 <nil>
HELLO AGAIN

io.Copy recibe un Writer y un Reader y mueve bytes del uno al otro hasta que el reader se agota. No sabe que está leyendo un string ni que está escribiendo en la terminal. Devolvió 20, el número de bytes copiados.

upperWriter es nuestro propio Writer. Su único método pasa los bytes a mayúsculas y se los entrega al writer que envuelve. Cuatro líneas crearon un tipo que io.Copy, y cualquier otra función que reciba un Writer, puede usar.

Acepta interfaces, devuelve structs

Una pauta común en Go es que las funciones acepten interfaces y devuelvan tipos concretos. Aceptar una interfaz deja que quien llama pase lo que tenga.

package main

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"strings"
)

func countLines(r io.Reader) (int, error) {
	sc := bufio.NewScanner(r)
	n := 0
	for sc.Scan() {
		n++
	}
	return n, sc.Err()
}

func main() {
	n, err := countLines(strings.NewReader("one\ntwo\nthree\n"))
	fmt.Println(n, err)

	var buf bytes.Buffer
	buf.WriteString("alpha\nbeta\n")
	n, err = countLines(&buf)
	fmt.Println(n, err)
}

Imprime:

3 <nil>
2 <nil>

countLines solo necesita Read, así que pide un io.Reader. La misma función cuenta líneas en un string, un buffer, un archivo abierto o el cuerpo de una petición, y los tests le pueden pasar un strings.Reader en lugar de un archivo real.

Devolver un struct, como *bytes.Buffer o *bufio.Scanner, va en la otra dirección. Quien llama recibe todos los métodos que tiene el tipo, y puede guardarlo en la interfaz pequeña que necesite. Si devuelves una interfaz, escondes esos métodos y decides por quien llama lo que puede hacer.

Es una pauta, no una ley. error es una interfaz, y las funciones la devuelven todo el tiempo.

any, aserciones de tipo y comma-ok

any es otro nombre para interface{}, la interfaz sin métodos, así que todo tipo la satisface. Ya la viste en fmt.Println(a ...any). Para sacar de vuelta un valor concreto, usas una aserción de tipo (type assertion).

package main

import "fmt"

func main() {
	var x any = "gopher"

	s, ok := x.(string)
	fmt.Printf("%q %v\n", s, ok)

	n, ok := x.(int)
	fmt.Println(n, ok)

	x = 42
	fmt.Println(x.(int) + 1)
	fmt.Println(x.(string))
}

Imprime tres líneas y se detiene:

"gopher" true
0 false
43
panic: interface conversion: interface {} is int, not string

x.(string) pregunta «¿el valor dentro de x es un string?». Con dos resultados, s, ok := x.(string), obtienes el valor y true cuando lo es. Cuando no lo es, como en x.(int), obtienes el valor cero y false, y nada se rompe. Es la misma forma comma-ok que usan los maps.

Con un solo resultado no hay dónde reportar el fallo, así que una suposición equivocada provoca un panic. El mensaje del panic sigue diciendo interface {}, la forma antigua de escribirlo, porque any es solo un alias. Usa la forma de un resultado solo cuando un tipo equivocado sería un bug en tu propio código.

Type switches, un nivel más a fondo

Un type switch es una cadena de aserciones de tipo, y sus casos pueden nombrar interfaces además de tipos concretos. La parte sobre control de flujo mostró la forma básica. Esto es lo demás que puede hacer.

package main

import (
	"errors"
	"fmt"
	"strconv"
)

type Celsius float64

func (c Celsius) String() string { return strconv.FormatFloat(float64(c), 'f', 1, 64) + "°C" }

func describe(x any) string {
	switch v := x.(type) {
	case nil:
		return "nil"
	case int, int64:
		return fmt.Sprintf("a whole number, %T %v", v, v)
	case error:
		return "an error: " + v.Error()
	case fmt.Stringer:
		return "a Stringer: " + v.String()
	default:
		return fmt.Sprintf("something else: %T", v)
	}
}

func main() {
	fmt.Println(describe(nil))
	fmt.Println(describe(7))
	fmt.Println(describe(int64(7)))
	fmt.Println(describe(Celsius(21.5)))
	fmt.Println(describe(errors.New("disk full")))
	fmt.Println(describe([]int{1, 2}))
}

Imprime:

nil
a whole number, int 7
a whole number, int64 7
a Stringer: 21.5°C
an error: disk full
something else: []int

Aquí pasan tres cosas:

  • Un caso con un solo tipo le da ese tipo a v. En case error, v es un error, así que v.Error() compila.
  • Un caso con varios tipos, como case int, int64, no puede elegir uno, así que v se queda como any. Puedes imprimirlo, pero no puedes hacer aritmética con él sin otra aserción.
  • Un caso con una interfaz coincide con cualquier valor cuyo tipo tenga esos métodos. Celsius coincidió con fmt.Stringer sin nombrarla nunca.

Los casos se prueban en orden, y gana la primera coincidencia. Un tipo con un método Error y un método String caería aquí en case error, porque va primero.

Recurre a un type switch cuando un valor de verdad puede ser uno de varios tipos sin relación, como un valor JSON decodificado. Cuando los tipos comparten comportamiento, pon ese comportamiento en un método de una interfaz y llámalo. Eso es lo que hizo Shape al principio.

La trampa del nil

Una interfaz que guarda un puntero nil no es nil ella misma, y eso hace que una función que devuelve error reporte un fallo que nunca ocurrió.

package main

import "fmt"

type MyError struct {
	Msg string
}

func (e *MyError) Error() string { return e.Msg }

func checkAge(age int) error {
	var e *MyError
	if age < 0 {
		e = &MyError{Msg: "age can't be negative"}
	}
	return e
}

func main() {
	err := checkAge(30)
	if err != nil {
		fmt.Println("failed:", err)
		return
	}
	fmt.Println("age is fine")
}

Imprime:

failed: <nil>

La edad estaba bien. e siguió siendo un puntero nil. Aun así, err != nil fue verdadero, y el programa tomó la rama de fallo.

El <nil> es una segunda sorpresa. fmt llamó a Error sobre un *MyError nil, y e.Msg desreferenció nil. fmt se recupera de ese panic en particular e imprime <nil> en su lugar. Llama tú mismo a err.Error() y el programa se cae con una desreferencia de puntero nil.

Explicado como si tuvieras diez años

Imagina una interfaz como una caja con una etiqueta en la tapa. La caja tiene dos lugares: la etiqueta, que dice qué clase de cosa hay adentro, y la cosa en sí.

Una caja vacía no tiene nada en la etiqueta ni nada adentro. Esa es la única caja que Go llama nil.

Ahora toma una caja, escribe «MyError» en la etiqueta y no pongas nada adentro. ¿Está vacía? Alguien que revisa «¿la etiqueta está en blanco y la caja vacía?» dice que no. La etiqueta está escrita. Así que err == nil es falso, aunque no haya nada adentro.

La versión precisa

Un valor interfaz son dos palabras. La primera dice qué tipo concreto está guardado, y la segunda es el valor de ese tipo (para un puntero, el puntero mismo). Una interfaz es nil solo cuando las dos palabras están vacías.

return e convierte el *MyError en un error. Esa conversión siempre registra el tipo, *MyError, en la primera palabra. La segunda palabra guarda el puntero nil. Una palabra está llena, así que la interfaz no es nil.

Dónde falla la analogía: una caja real sin nada adentro no sirve, pero una interfaz que guarda un puntero nil no siempre es un error. Puedes llamar métodos sobre ella, y un método escrito para manejar un receptor nil funciona bien. La trampa está solo en compararla con nil y esperar verdadero.

Mirar las dos palabras

Las dos palabras de un valor interfaz cambian en cada paso de la trampa, y la animación de abajo las sigue:

tipo valor err (error) nil nil p (*MyError) su tipo puntero *MyError nil *MyError nil err == nil es true p == nil es true err tiene tipo *MyError, valor nil err == nil es false err == nil otra vez true paso 1: var err error: casillas vacías paso 2: var p *MyError = nil no apunta a nada paso 3: err = p pone *MyError en el tipo; el valor es nil paso 4: el tipo está lleno, así que err no es nil solución: return nil; ambas casillas vacías

Un valor interfaz dibujado como dos celdas, tipo y valor. Un err vacío es igual a nil. Asignar un *MyError nil llena la celda de tipo con *MyError mientras la celda de valor sigue en nil, así que err ya no es igual a nil. Devolver un nil simple deja las dos celdas vacías.

Estos son los mismos pasos en palabras, por si la animación no se reproduce:

  1. var err error crea una interfaz con las dos casillas vacías. err == nil es verdadero.
  2. var p *MyError = nil crea un puntero que no apunta a nada. p == nil es verdadero.
  3. err = p guarda el tipo *MyError en la casilla de tipo y el puntero nil en la casilla de valor.
  4. err == nil ahora es falso, porque la casilla de tipo no está vacía.
  5. La solución: return nil en lugar del puntero, y las dos casillas siguen vacías.

Este programa recorre los primeros cuatro pasos:

package main

import "fmt"

type MyError struct {
	Msg string
}

func (e *MyError) Error() string { return e.Msg }

func main() {
	var err error
	fmt.Println("step 1: err == nil is", err == nil)

	var p *MyError = nil
	fmt.Println("step 2: p == nil is", p == nil)

	err = p
	fmt.Printf("step 3: err holds type %T\n", err)

	fmt.Println("step 4: err == nil is", err == nil)
}

Imprime:

step 1: err == nil is true
step 2: p == nil is true
step 3: err holds type *main.MyError
step 4: err == nil is false

p == nil compara un puntero con nil, y es verdadero. err == nil compara una interfaz con la interfaz vacía, y es falso. El mismo nil a la derecha significa dos cosas distintas, según el tipo que haya a la izquierda.

La solución: devolver un nil simple

No guardes un error en una variable del tipo puntero concreto para devolverla. Devuelve nil directamente cuando todo sale bien:

package main

import "fmt"

type MyError struct {
	Msg string
}

func (e *MyError) Error() string { return e.Msg }

func checkAge(age int) error {
	if age < 0 {
		return &MyError{Msg: "age can't be negative"}
	}
	return nil
}

func main() {
	for _, age := range []int{30, -1} {
		if err := checkAge(age); err != nil {
			fmt.Println(age, "failed:", err)
			continue
		}
		fmt.Println(age, "is fine")
	}
}

Imprime:

30 is fine
-1 failed: age can't be negative

return nil en una función cuyo tipo de resultado es error produce una interfaz con las dos palabras vacías. La regla que hay que guardar es simple: si una función devuelve error, sus variables para errores también deberían ser de tipo error, nunca *MyError. La parte sobre errores construye sobre esto con el wrapping, errors.Is y errors.As.

Qué recordar

  • Una interfaz es un conjunto de métodos. Un tipo la satisface teniendo esos métodos, sin ninguna palabra clave implements.
  • El compilador verifica en el punto de uso. var _ Shape = (*Square)(nil) fuerza la verificación cuando no hay un lugar de uso.
  • Los métodos con receptor puntero pertenecen solo al conjunto de métodos del tipo puntero, así que guarda &T{} en la interfaz, no T{}.
  • Las interfaces pequeñas hacen la mayor parte del trabajo: fmt.Stringer, io.Reader, io.Writer. Acéptalas como parámetros y devuelve tipos concretos.
  • Usa la forma comma-ok, v, ok := x.(T), salvo que un tipo equivocado sea de verdad un bug. Un caso de type switch con varios tipos deja v como any.
  • Una interfaz es nil solo cuando su tipo y su valor están vacíos. Un *MyError nil devuelto como error no es nil, así que devuelve un nil simple.

Una interfaz es nil solo cuando no guarda ni tipo ni valor.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.