Structs, métodos e interfaces en Go

Lección 4 de 8. Aquí está la parte de Go que más desconcierta a quien viene de Java o de C#: no hay clases, no hay herencia y las interfaces no se declaran. Lo que hay es más simple, y una vez entendido cuesta echar de menos lo otro.

Structs: datos con nombre

Un struct es un conjunto de campos. Nada más: no tiene métodos dentro ni constructor.

type Producto struct {
	Nombre   string
	Precio   float64
	Unidades int
}

Se puede crear de varias formas, y una de ellas es no crearlo: como toda variable en Go, un struct declarado sin valor nace con cada campo en su valor cero.

var vacio Producto
fmt.Printf("%+v\n", vacio)

p := Producto{Nombre: "Camiseta", Precio: 19.90, Unidades: 3}
fmt.Printf("%+v\n", p)
salida{Nombre: Precio:0 Unidades:0}
{Nombre:Camiseta Precio:19.9 Unidades:3}

El verbo %+v de Printf imprime los campos con su nombre, y es la forma más rápida de ver qué contiene algo mientras depuras.

Nombrar los campos al crear. También vale Producto{"Camiseta", 19.90, 3}, por posición, pero no lo hagas: el día que alguien añada un campo en medio, todo el código que lo use así se romperá en silencio o dejará de compilar. Con los nombres puestos, da igual el orden.

Métodos: funciones con receptor

Un método es una función normal a la que se le pone delante quién la recibe. No va dentro del struct, va al lado.

func (p Producto) Total() float64 {
	return p.Precio * float64(p.Unidades)
}

func (p *Producto) Rebajar(porcentaje float64) {
	p.Precio = p.Precio * (1 - porcentaje/100)
}
salidatotal: 59.70
tras rebajar un 10%: 17.91

Fíjate en la diferencia entre los dos receptores: (p Producto) y (p *Producto). El primero recibe una copia; el segundo, el original.

Y por eso esto no funciona

type Contador struct{ N int }

func (c Contador) SumarMal()   { c.N++ }   // toca una copia
func (c *Contador) SumarBien() { c.N++ }   // toca el original

c := Contador{}
c.SumarMal()
fmt.Println("tras SumarMal: ", c.N)
c.SumarBien()
fmt.Println("tras SumarBien:", c.N)
salidatras SumarMal:  0
tras SumarBien: 1

Compila sin una queja y no hace nada. Es uno de los errores silenciosos más frecuentes al empezar.

La regla práctica, mientras no veamos punteros a fondo en la lección 6. Si el método modifica algo, receptor por puntero. Si solo lee, da un poco igual, pero la costumbre es que todos los métodos de un tipo usen el mismo estilo, para no tener que recordar cuál es cuál.

Composición en vez de herencia

Go no tiene herencia. Tiene incrustación: meter un tipo dentro de otro sin ponerle nombre de campo.

type Caja struct {
	Producto        // sin nombre: va incrustado
	Frágil bool
}

c := Caja{Producto: Producto{Nombre: "Camiseta", Precio: 19.9}, Frágil: true}

fmt.Println(c.Nombre)           // campo del Producto, como si fuera de Caja
fmt.Println(c.Etiqueta())       // y también sus métodos
fmt.Println(c.Producto.Nombre)  // el camino largo, por si hay ambigüedad
salidaCamiseta
Camiseta
Camiseta

Parece herencia y no lo es. Caja no «es un» Producto: lo tiene, y Go se limita a dejarte escribir c.Nombre en lugar de c.Producto.Nombre. La diferencia se nota en cuanto hay dos incrustados con el mismo campo: ahí el atajo desaparece y hay que decir cuál.

Interfaces: lo que de verdad cambia

Una interfaz en Go es una lista de métodos. Y lo importante no es eso, sino lo que no hay que hacer: ningún tipo declara que la cumple.

Las interfaces de Go se cumplen de forma implícitaEn Go nadie dice «implementa»: basta con tener el métodointerface DescribibleDescribir() stringtype Productofunc (p Producto)Describir() stringtype Usuariofunc (u Usuario)Describir() stringLos dos tipos no se tocan entre sí y ninguno nombra la interfaz: el compilador comprueba solo que encajanY por eso las interfaces de Go suelen tener uno o dos métodos: cuantos menos pide, más tipos encajan
type Describible interface {
	Describir() string
}

type Producto struct{ Nombre string; Precio float64 }
func (p Producto) Describir() string { return fmt.Sprintf("%s por %.2f €", p.Nombre, p.Precio) }

type Usuario struct{ Alias string }
func (u Usuario) Describir() string { return "@" + u.Alias }

Producto y Usuario no tienen nada que ver, ni mencionan la interfaz. Pero los dos encajan, así que los dos sirven donde se pida un Describible:

func listar(cosas []Describible) string { … }

fmt.Println(listar([]Describible{
	Producto{Nombre: "Camiseta", Precio: 19.90},
	Usuario{Alias: "ana"},
}))
salidaCamiseta por 19.90 € · @ana

Eso tiene una consecuencia que vale la pena entender bien: puedes cumplir una interfaz que escribió otro sin tocar tu código. Si una biblioteca pide algo con un método Read y tu tipo ya lo tiene, encaja. No hay que importar nada ni modificar la declaración.

La que más vas a usar sin darte cuenta

fmt.Stringer es una interfaz de la biblioteca estándar con un solo método, String() string. Si tu tipo lo tiene, fmt lo usa automáticamente al imprimirlo.

type Moneda float64

func (m Moneda) String() string { return fmt.Sprintf("%.2f €", float64(m)) }

precio := Moneda(12.5)
fmt.Println(precio)
fmt.Printf("%v y %s\n", precio, precio)
salida12.50 €
12.50 € y 12.50 €

Nadie le dijo a fmt que usara ese método. Simplemente comprueba si lo tienes.

Preguntar en tiempo de ejecución

Con una aserción de tipo se puede comprobar si algo cumple una interfaz:

var x any = Producto{Nombre: "Gorra", Precio: 9}

if d, ok := x.(Describible); ok {
	fmt.Println("sí sabe describirse:", d.Describir())
}
if _, ok := x.(fmt.Stringer); !ok {
	fmt.Println("pero no es un fmt.Stringer")
}
salidasí sabe describirse: Gorra por 9.00 €
pero no es un fmt.Stringer

Ese any es el nombre moderno de interface{}: la interfaz sin métodos, que por tanto cumple todo. Y reaparece el patrón valor, ok que ya vimos con los maps.

Interfaces pequeñas. Hay un dicho en la comunidad de Go: «cuanto mayor es la interfaz, más débil la abstracción». Las de la biblioteca estándar casi todas tienen uno o dos métodos, y por eso encajan en tantos sitios. Si te sale una con ocho, probablemente estés describiendo un tipo concreto en vez de un comportamiento.

Lo que hay que llevarse

En otros lenguajes En Go
Clase con datos y métodos dentro struct con los datos, y métodos al lado
Constructor Un literal, o una función NuevoX() por convención
Herencia Incrustación: lo tiene, no lo es
class X implements Y Nada. Si tiene los métodos, cumple
Interfaces grandes Interfaces de uno o dos métodos

Todo el código de esta lección se compiló y ejecutó con Go 1.25 antes de publicarla; las salidas están copiadas de esa ejecución, incluido el ejemplo del receptor por valor que no modifica nada.