Blog

Funciones en Go: múltiples retornos, closures y defer

Las funciones de Go pueden devolver varios valores, llevar variables consigo como closures y programar la limpieza con defer. Mira cómo funciona cada cosa, incluido el orden de las llamadas diferidas y cuándo se fijan sus argumentos.

Las funciones en Go se parecen a las de cualquier otro lenguaje, hasta que notas tres cosas. Pueden devolver más de un valor. Son valores en sí mismas, así que puedes guardarlas y pasarlas de un lado a otro. Y defer le permite a una función programar trabajo para el momento en que termina.

Este post cubre las tres, y también los closures que aparecen cuando tratas las funciones como valores. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.

Declarar una función

Una función de Go escribe el tipo de cada parámetro después de su nombre, y el tipo del resultado después de la lista de parámetros. Cuando dos parámetros seguidos tienen el mismo tipo, puedes escribir el tipo una sola vez.

package main

import (
	"fmt"
	"strconv"
)

func add(a, b int) int {
	return a + b
}

func minMax(nums []int) (int, int) {
	lo, hi := nums[0], nums[0]
	for _, n := range nums[1:] {
		lo = min(lo, n)
		hi = max(hi, n)
	}
	return lo, hi
}

func main() {
	fmt.Println(add(2, 3))

	lo, hi := minMax([]int{7, 2, 9, 4})
	fmt.Println(lo, hi)

	n, err := strconv.Atoi("42")
	fmt.Println(n, err)

	n, err = strconv.Atoi("forty-two")
	fmt.Println(n, err)
}

Imprime:

5
2 9
42 <nil>
0 strconv.Atoi: parsing "forty-two": invalid syntax

add(a, b int) es la forma corta de add(a int, b int). minMax devuelve dos valores, así que sus tipos de resultado van entre paréntesis, y quien la llama los recibe con lo, hi :=. (min y max son funciones incorporadas desde Go 1.21).

strconv.Atoi muestra la forma más común en Go: un valor y un error. Cuando la conversión funciona, err es nil. Cuando falla, recibes un valor cero y un error que dice qué salió mal. Go no usa excepciones para esto. El error es simplemente el último valor de retorno, y lo revisas con if err != nil. La parte sobre errores lo trata a fondo.

No puedes ignorar un segundo resultado por accidente

Una función que devuelve dos valores no cabe en una sola variable:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n := strconv.Atoi("42")
	fmt.Println(n)
}

El compilador dice:

./main.go:9:7: assignment mismatch: 1 variable but strconv.Atoi returns 2 values

Si de verdad no quieres el error, tienes que decirlo con el identificador vacío: n, _ := strconv.Atoi("42"). Ese _ se ve en la revisión de código, y esa es la idea. Descartar un error es una decisión que alguien puede ver.

Resultados con nombre

Go te deja ponerles nombre a los valores de resultado, igual que a los parámetros. Los resultados con nombre se declaran al principio de la función, empiezan con su valor cero, y un return solo devuelve lo que tengan en ese momento.

package main

import (
	"fmt"
	"strings"
)

func splitPair(s string) (key, value string, ok bool) {
	i := strings.Index(s, "=")
	if i < 0 {
		return
	}
	key = s[:i]
	value = s[i+1:]
	ok = true
	return
}

func main() {
	k, v, ok := splitPair("host=example.com")
	fmt.Printf("%q %q %v\n", k, v, ok)

	k, v, ok = splitPair("no equals sign")
	fmt.Printf("%q %q %v\n", k, v, ok)
}

Imprime:

"host" "example.com" true
"" "" false

El return temprano devuelve "", "" y false, porque todavía no se había asignado nada y esos son los valores cero. (La biblioteca estándar ya tiene esta función, como strings.Cut. Escribirla a mano muestra cómo funciona).

Los nombres ayudan sobre todo como documentación. (key, value string, ok bool) le dice al lector qué se devuelve. (string, string, bool) lo obliga a adivinar qué cadena es cuál.

Un return solo como ese se llama naked return. Está bien en una función así de corta. En una larga, evítalo. El lector tiene que subir para ver qué se está devolviendo, y una variable declarada en un bloque interno puede ocultar un resultado sin que te des cuenta. Go detecta la peor versión de eso:

package main

import (
	"fmt"
	"strconv"
)

func parsePort(s string) (port int, err error) {
	if s != "" {
		port, err := strconv.Atoi(s)
		if err != nil {
			return
		}
		fmt.Println("parsed", port)
	}
	return
}

func main() {
	fmt.Println(parsePort("8080"))
}

El compilador dice:

./main.go:12:4: result parameter port not in scope at return
./main.go:12:4: result parameter err not in scope at return

Dentro del if, := declaró un port y un err completamente nuevos que ocultan los resultados con nombre. Un naked return ahí devolvería los de afuera, que nunca se asignaron, así que Go se niega a compilarlo. Escribir return port, err de forma explícita hace que el código diga lo que quiere decir.

Funciones variádicas

Una función variádica acepta cualquier cantidad de argumentos de un mismo tipo. Marcas el último parámetro con ..., y dentro de la función es un slice.

package main

import "fmt"

func sum(nums ...int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

func main() {
	fmt.Println(sum())
	fmt.Println(sum(1, 2, 3))

	scores := []int{10, 20, 30}
	fmt.Println(sum(scores...))
}

Imprime:

0
6
60

sum() recibe un nums vacío, así que el total es 0. sum(1, 2, 3) recibe un slice de tres.

Cuando ya tienes un slice, no puedes pasarlo como sum(scores), porque un []int no es un int. Escribir scores... lo expande en la llamada. Go no copia los elementos cuando haces eso. nums dentro de la función es el mismo slice que scores, y comparte el mismo array. La parte sobre slices muestra por qué eso importa.

Llevas todo este tiempo llamando a una función variádica. fmt.Println está declarada como func Println(a ...any) (n int, err error).

Las funciones son valores

Una función de Go es un valor con un tipo, como un int o una cadena. Puedes asignarla a una variable, pasarla a otra función y devolverla desde una.

package main

import (
	"fmt"
	"strings"
)

func apply(words []string, f func(string) string) []string {
	out := make([]string, 0, len(words))
	for _, w := range words {
		out = append(out, f(w))
	}
	return out
}

func exclaim(s string) string {
	return s + "!"
}

func main() {
	words := []string{"go", "is", "fun"}

	shout := strings.ToUpper
	fmt.Println(shout("hello"))

	fmt.Println(apply(words, exclaim))
	fmt.Println(apply(words, strings.ToUpper))
	fmt.Println(apply(words, func(s string) string {
		return "<" + s + ">"
	}))

	fmt.Printf("%T\n", exclaim)
}

Imprime:

HELLO
[go! is! fun!]
[GO IS FUN]
[<go> <is> <fun>]
func(string) string

shout := strings.ToUpper no lleva paréntesis después de ToUpper, así que no llama a la función. La guarda. apply recibe cualquier función de tipo func(string) string y la llama una vez por palabra. Tu propia exclaim, la strings.ToUpper de la biblioteca y un literal de función escrito ahí mismo en la llamada encajan, porque todas tienen ese tipo.

La última línea imprime el tipo en sí. El tipo de una función son los tipos de sus parámetros y de sus resultados. El nombre no forma parte de él.

Closures

Un literal de función puede usar variables de la función que lo rodea. Cuando lo hace, se llama closure, y mantiene vivas esas variables mientras el closure exista.

package main

import "fmt"

func newCounter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

func main() {
	a := newCounter()
	b := newCounter()

	fmt.Println(a(), a(), a())
	fmt.Println(b())
	fmt.Println(a())
}

Imprime:

1 2 3
1
4

newCounter retorna, y normalmente su count local desaparecería. Pero la función que devolvió todavía usa count, así que count se queda. Cada llamada a a le suma uno.

b es otra llamada a newCounter, así que recibió su propio count, que empieza en 0. Por eso b() imprime 1, y por eso a sigue hasta 4 después. Los dos contadores no comparten nada.

Explicado como si tuvieras diez años

Piensa en un closure como una función que lleva una mochila.

Cuando newCounter crea la pequeña función que cuenta, mete count en la mochila de esa función antes de mandarla afuera. Adonde vaya la función, va la mochila. Cada vez que la llamas, abre la mochila, le suma uno al número que hay dentro y te dice el número nuevo.

Llama a newCounter otra vez y obtienes una segunda función con una segunda mochila. Lo que hay en una mochila no tiene nada que ver con la otra.

La versión precisa

Un literal de función que se refiere a una variable de una función que lo encierra captura la variable misma, no una copia de su valor. El closure y la función que lo encierra comparten esa única variable. Si cualquiera de los dos la cambia, el otro ve el cambio.

Como el closure puede vivir más que la llamada que declaró la variable, el compilador normalmente pone esa variable en el heap, donde vive mientras algo se refiera a ella. La parte sobre memoria muestra cómo lo decide el compilador.

Dónde falla la analogía: una mochila suena a una copia privada, empacada en el momento en que se creó la función. No es una copia. Si la función que lo encierra cambia count después de crear el closure, el closure ve el valor nuevo, porque comparten una sola variable.

Closures en un bucle

Ese compartir causaba un bug famoso con las variables de bucle. Desde Go 1.22, cada iteración de un bucle for recibe una variable nueva, así que un closure creado en el bucle captura el valor de esa iteración:

package main

import "fmt"

func main() {
	var printers []func()
	for i := range 3 {
		printers = append(printers, func() {
			fmt.Println("i is", i)
		})
	}
	for _, p := range printers {
		p()
	}
}

Imprime:

i is 0
i is 1
i is 2

Antes de Go 1.22, todo el bucle compartía un solo i, y esto imprimía i is 3 tres veces. Todavía verás código viejo que lo esquiva con una línea como i := i dentro del bucle. En Go 1.22 y posteriores esa línea no hace nada, y puedes borrarla.

defer: ejecuta esto cuando la función termine

Una sentencia defer programa una llamada a función para que se ejecute cuando la función que la rodea retorna, y no antes. Cuando hay varias, se ejecutan en orden inverso.

package main

import "fmt"

func main() {
	fmt.Println("start")
	defer fmt.Println("deferred 1")
	defer fmt.Println("deferred 2")
	defer fmt.Println("deferred 3")
	fmt.Println("end of body")
}

Imprime:

start
end of body
deferred 3
deferred 2
deferred 1

El cuerpo se ejecuta de arriba abajo e imprime start y end of body. Las tres llamadas diferidas esperan. Cuando main retorna, se ejecutan, y la última que se difirió va primero. Míralo paso a paso:

código func main() { fmt.Println("start") defer fmt.Println("deferred 1") defer fmt.Println("deferred 2") defer fmt.Println("deferred 3") fmt.Println("end of body") } pila defer deferred 1 deferred 2 deferred 3 (vacía) salida start end of body deferred 3 deferred 2 deferred 1 el cuerpo va de arriba abajo; la primera línea imprime "start" cada defer pone su llamada en la pila, y aún no se imprime nada la última línea del cuerpo imprime "end of body" main retorna: primero se ejecuta la llamada de arriba, deferred 3 sigue la de abajo: deferred 2 el primer defer va al final, la pila queda vacía y main termina

La pila de defer del programa de arriba. El cuerpo imprime start, y luego cada defer pone una llamada en la pila sin ejecutarla. El cuerpo imprime end of body y main retorna. Después, las llamadas diferidas salen de lo alto de la pila una por una, así que imprimen deferred 3, deferred 2 y deferred 1.

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

  1. El cuerpo empieza arriba e imprime start.
  2. Cada línea defer pone su llamada en una pila. Primero entra deferred 1, luego deferred 2, y luego deferred 3 encima. No se imprime nada.
  3. La última línea del cuerpo imprime end of body.
  4. main retorna. La llamada de arriba de la pila, deferred 3, se ejecuta primero.
  5. Luego se ejecuta deferred 2.
  6. Luego deferred 1, la primera que se difirió, se ejecuta al final. La pila queda vacía y main termina.

Explicado como si tuvieras diez años

Imagina una pila de platos junto al fregadero. Cada vez que dices defer, escribes una tarea en un plato y lo pones encima de la pila. Todavía no haces la tarea. Solo sigues con lo que estabas haciendo.

Cuando terminas y estás por salir de la cocina, haces las tareas de los platos. Solo puedes tomar el plato de arriba, así que la última tarea que anotaste es la primera que haces.

La versión precisa

Cada vez que se ejecuta una sentencia defer, Go evalúa en ese momento el valor de la función y sus argumentos, y guarda la llamada. Cuando la función retorna, ya sea con una sentencia return, al llegar a su llave de cierre o por un panic, las llamadas guardadas se ejecutan en orden LIFO: la última en entrar es la primera en salir. Se ejecutan después de que se asignan los valores de resultado y antes de que la función le devuelva el control a quien la llamó.

Un defer dentro de un bucle guarda una llamada por iteración, y ninguna se ejecuta hasta que retorna la función completa. Vale la pena saberlo antes de usar defer dentro de un bucle que da un millón de vueltas.

Dónde falla la analogía: tú escribirías la tarea en el plato con palabras y resolverías los detalles después. Go resuelve los argumentos en el momento en que dices defer, como muestra la siguiente sección.

Los argumentos se evalúan al hacer defer, no cuando se ejecuta la llamada

Una llamada diferida espera a que la función termine, pero sus argumentos no esperan. Se calculan en la línea del defer, y eso sorprende a casi todos la primera vez.

package main

import "fmt"

func main() {
	x := 1
	defer fmt.Println("deferred sees x =", x)
	defer func() {
		fmt.Println("closure sees x =", x)
	}()
	x = 2
	fmt.Println("main sets x =", x)
}

Imprime:

main sets x = 2
closure sees x = 2
deferred sees x = 1

El primer defer evaluó x de inmediato, cuando todavía valía 1, y guardó ese 1 como argumento. Cambiar x después no lo afectó.

El segundo defer guardó un closure que no recibe argumentos. No había nada que evaluar antes. Cuando por fin se ejecutó, leyó x, y para entonces x valía 2. Comparte la variable, exactamente como lo hacía el contador.

Así que, si quieres que una llamada diferida vea el valor más reciente, difiere un closure. Si quieres congelar el valor, pásalo como argumento.

El uso de todos los días: la limpieza

La mayoría de las sentencias defer en código Go real liberan algo apenas se obtiene: cerrar un archivo, cerrar el cuerpo de una respuesta, desbloquear un mutex. Escribir la limpieza en la línea siguiente significa que no puedes olvidarla en algún camino de retorno que agregues más adelante.

package main

import (
	"errors"
	"fmt"
)

type resource struct {
	name string
}

func open(name string) *resource {
	fmt.Println("open", name)
	return &resource{name: name}
}

func (r *resource) Close() error {
	fmt.Println("close", r.name)
	return nil
}

func process(name string) error {
	r := open(name)
	defer r.Close()

	if name == "broken.txt" {
		fmt.Println("bad data, returning early")
		return errors.New("bad data")
	}
	fmt.Println("processing", name)
	return nil
}

func main() {
	fmt.Println(process("notes.txt"))
	fmt.Println(process("broken.txt"))
}

Imprime:

open notes.txt
processing notes.txt
close notes.txt
<nil>
open broken.txt
bad data, returning early
close broken.txt
bad data

Aquí resource hace de archivo, para que el programa no toque el disco. Su método Close solo imprime. (Los métodos tienen su propia parte. Por ahora, lee r.Close() como «llama a Close sobre r»).

Las dos llamadas cerraron el recurso, y las dos lo cerraron antes de que process le devolviera su error a main. El retorno temprano no necesitó su propio Close, porque el defer cubre todas las salidas de la función.

El mismo patrón protege un lock. Vas a escribir esto todo el tiempo cuando llegues a la concurrencia:

mu.Lock()
defer mu.Unlock()

Hay algo que ese programa se salta sin decirlo: Close devuelve un error, y defer r.Close() lo descarta. Para un archivo que solo lees, es normal. Para un archivo en el que escribiste, un cierre fallido puede significar datos perdidos. La siguiente sección muestra una forma de atraparlo.

Cambiar el resultado con un closure diferido

Un closure diferido se ejecuta después de que se asignan los valores de retorno, y si esos resultados tienen nombre, puede leerlos y cambiarlos. Ese es el único lugar donde los resultados con nombre son más que documentación.

package main

import (
	"errors"
	"fmt"
)

func save(name string) (err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("save %q: %w", name, err)
		}
	}()

	if name == "" {
		return errors.New("empty name")
	}
	return nil
}

func main() {
	fmt.Println(save("notes"))
	fmt.Println(save(""))
}

Imprime:

<nil>
save "": empty name

return errors.New("empty name") asigna err. Luego se ejecuta el closure diferido, ve un err que no es nil y lo reemplaza con un error envuelto que agrega el nombre del archivo. Quien llama recibe el valor reemplazado.

Con un resultado sin nombre, el closure no tendría un nombre por el cual llegar al valor de retorno, y no podría hacer esto. El mismo truco le permite a un Close diferido reportar su error: if cerr := f.Close(); cerr != nil && err == nil { err = cerr }.

defer también se ejecuta cuando una función entra en panic, y eso es lo que hace posible recover. Los dos se tratan a fondo en la parte sobre errores.

Qué recordar

  • Los parámetros del mismo tipo pueden compartir un solo nombre de tipo, y una función puede devolver varios valores. El último suele ser un error, que se revisa con if err != nil.
  • Los resultados con nombre empiezan en su valor cero y documentan lo que se devuelve. Deja los naked returns para funciones cortas.
  • Un parámetro variádico ...T es un slice dentro de la función. Expande un slice que ya tienes en él con s....
  • Las funciones son valores con un tipo, como func(string) string. Puedes guardarlas, pasarlas y devolverlas.
  • Un closure comparte las variables que usa de la función que lo rodea, y las mantiene vivas. Desde Go 1.22, cada iteración de un bucle tiene su propia variable.
  • defer ejecuta llamadas cuando la función termina, la última en entrar primero. Sus argumentos se evalúan en la línea del defer. Un closure diferido lee las variables cuando se ejecuta, y puede cambiar los resultados con nombre.

Pon la limpieza en la línea justo después de lo que necesita limpiarse.

¿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.