Este artículo te mostrará recomendaciones para escribir código limpio en Go. Analizaremos ejemplos de las características del lenguaje y aplicaremos en la práctica las principales construcciones sintácticas.
Trabajo con Datos
Diferencia entre make
y new
make
y new
son mecanismos integrados para asignar memoria. Se usan en diferentes situaciones y tienen sus propias características.
new
inicializa el valor nulo para un tipo dado y devuelve un puntero a ese tipo.make
se usa exclusivamente para crear e inicializar rebanadas, mapas y canales; devuelve una instancia no nula del tipo especificado.
La principal diferencia entre ellos es que make
devuelve un tipo inicializado, listo para usarse después de su creación, mientras que new
devuelve un puntero al tipo con su valor nulo.
a := new(chan int) // a tiene el tipo *chan int
b := make(chan int) // b tiene el tipo chan int
Datos Ocultos en Rebanadas (slices)
Un slice es un arreglo de longitud variable que puede almacenar elementos de un mismo tipo. Internamente, representa una referencia al arreglo base.
Al trabajar con rebanadas, a menudo surge la tarea de «rebanarlas» en otras más pequeñas. Como resultado, la rebanada resultante referenciará al arreglo original. No hay que olvidar esto, de lo contrario, el programa podría tener un consumo de memoria impredecible.
Consideremos esta característica en ejemplos concretos:
// Mala práctica - consumo de memoria impredecible
func cutSlice() []byte {
slice := make([]byte, 256)
fmt.Println(len(slice), cap(slice), &slice[0]) // 256 256 <0x...>
return slice[:10]
}
func main() {
res := cutSlice()
fmt.Println(len(res), cap(res), &res[0]) // 10 256 <0x...>
}
Para prevenir el error, asegúrate de que la copia se realiza desde una rebanada temporal:
// Buena práctica - los datos se copian de una rebanada temporal
func cutSlice() []byte {
slice := make([]byte, 256)
fmt.Println(len(slice), cap(slice), &slice[0]) // 256 256 <0x...>
copyOfSlice := make([]byte, 10)
copy(copyOfSlice, slice[:10])
return copyOfSlice
}
func main() {
res := cutSlice()
fmt.Println(len(res), cap(res), &res[0]) // 10 10 <0x...>
}
Funciones
Funciones con Retorno Múltiple
Las funciones en Go pueden devolver varios valores. Esto se conoce como «retorno múltiple». Esta característica del lenguaje permite devolver no solo el resultado, sino también valores adicionales, como errores u otros datos necesarios.
Ejemplo de declaración de una función con retorno múltiple en Go:
package main
import "fmt"
func swap(a, b int) (int, int) {
return b, a
}
func main() {
x, y := swap(1, 2)
fmt.Println(x, y) // 2 1
a, _ := swap(3, 4)
fmt.Println(a) // 4
}
En el ejemplo, la función swap
toma dos argumentos de tipo int
y devuelve dos valores del mismo tipo, intercambiando las variables originales.
También puedes ignorar uno o más valores devueltos usando el identificador vacío (_
).
Las funciones con retorno múltiple son especialmente útiles cuando se necesita devolver varios resultados, por ejemplo, al trabajar con errores o con procesamiento paralelo de datos.
La función openFile
que se muestra a continuación devuelve dos valores, uno de los cuales es un error o nil
en caso de no haber error.
func openFile(name string) (*File, error) {
file, err := os.Open(name)
if err != nil {
return nil, err
}
return file, nil
}
Interfaces
En Go, las interfaces representan un conjunto de métodos que definen el comportamiento de un objeto. Permiten abstraerse de la implementación concreta y trabajar con diferentes tipos de datos. Es decir, las interfaces solo definen cierta funcionalidad, pero no la implementan.
package worker // worker.go
type Worker interface { Work() bool }
func Foo(w Worker) string { ... }
package worker // worker_test.go
type secondWorker struct{ ... }
func (w secondWorker) Work() bool { ... }
...
if Foo(secondWorker{ ... }) == "value" { ... }
Aquí tienes un ejemplo de un enfoque incorrecto al trabajar con interfaces:
// Mala práctica
package employer
type Worker interface { Worker() bool }
type defaultWorker struct{ ... }
func (t defaultWorker) Work() bool { ... }
func NewWorker() Worker { return defaultWorker{ ... } }
La solución correcta desde el punto de vista de Go es devolver un tipo concreto y permitir que Worker
imite la implementación de employer
:
// Buena práctica
package employer
type Worker struct { ... }
func (w Worker) Work() bool { ... }
func NewWorker() Worker {
return Worker{
...
}
}
Concurrencia y Paralelismo
Monitoreo de Goroutines
Las goroutines son baratas de iniciar y usar, pero tienen un costo finito en términos de memoria ocupada: no puedes crear un número infinito de ellas. A diferencia de las variables, el entorno de ejecución de Go no puede detectar que una goroutine ya no se usará más.
Las goroutines se explican detalladamente en el artículo «Goroutines: ¿qué son y cómo funcionan?«. Leerlo te ayudará a comprender mejor el tema.
Veamos un ejemplo para ilustrar este error:
func leakGoroutine() {
ch := make(chan int)
go func() {
received := <- ch
fmt.Println("Valor recibido:", received)
}
}
Aquí, la función leakGoroutine
inicia una goroutine que bloquea la lectura del canal ch
. Como resultado, nada se enviará a él y nunca se cerrará. La goroutine se bloqueará para siempre, la llamada a la función fmt.Println
nunca ocurrirá.
Detección de Fugas
Los ingenieros de Uber, que participan activamente en el desarrollo de Go, crearon un detector de fugas de goroutines: el paquete goleak
, diseñado para integrarse con las pruebas modulares. Consideremos un ejemplo del funcionamiento de esta herramienta en la práctica.
Supongamos que hay una función leakGoroutine
con una fuga de goroutine:
func leakGoroutine() {
go func() {
time.Sleep(time.Minute)
}()
return nil
}
Y una prueba de esta función:
func TestLeakGoroutine(t *Testing.T) {
defer goleak.VerifyNone(t)
if err := leak(); err != nil {
t.Fatal("Mensaje fatal")
}
}
Al ejecutar las pruebas, aparece un mensaje de error found unexpected goroutines
, donde se indica la cima de la pila con la goroutine problemática, su estado y su identificador.
Esta herramienta puede ser útil al crear programas, ya que permite reducir el tiempo dedicado a encontrar y solucionar fugas de memoria.
Manejo de Errores y Recuperación
Los errores en Go se representan mediante la interfaz error
, que define el método Error() string
. Cualquier tipo que implemente este método puede usarse como error.
type error interface {
Error() string
}
Para obtener más información sobre los errores en Go, se recomienda leer el artículo «¿Las excepciones en Go son fáciles?«. Aprenderás cómo solucionar eficazmente situaciones problemáticas en los programas.
Manejar Errores Correctamente
Ignorar errores puede provocar un comportamiento indefinido y dificultar la depuración del código. Consideremos la forma correcta de manejar errores en el ejemplo del trabajo con un archivo:
// incorrecto
file, err := os.Open("filename.txt")
if err == nil {
// operaciones con el archivo
}
// correcto
file, err := os.Open("filename.txt")
if err != nil {
log.Fatal(err) // manejo del error
}
defer f.Close() // llamada diferida a la función para cerrar el archivo
Sin Pánico, Pero con Recuperación
La forma clásica de informar sobre un error es devolver el tipo error
. ¿Pero qué hacer en los casos en que no se puede recuperar rápidamente? Entonces, la función incorporada panic
(a menudo llamada simplemente «pánico») viene al rescate. Esta función termina el programa y muestra un mensaje de error configurable.
Aquí tienes un ejemplo de una función simple con pánico:
package main
import "fmt"
func examplePanic() {
panic("Pánico - programa terminado")
fmt.Println("La función examplePanic terminó correctamente")
}
func main() {
examplePanic()
fmt.Println("La función main terminó correctamente")
}
Cuando ocurre un pánico, la función termina y se ejecutan las funciones diferidas restantes con defer
, y también se deshace la pila de goroutines. En las situaciones reales de desarrollo, se deben evitar estas situaciones, ya que comprometen el funcionamiento ininterrumpido del programa. Afortunadamente, los autores de Go anticiparon esta deficiencia y crearon un mecanismo de recuperación después del pánico: recover
. Permite detener la descomposición de la pila y devolver el control del programa al desarrollador.
Para demostrar el funcionamiento de este mecanismo, veamos un ejemplo:
package main
import "fmt"
func Recovery() {
if recoveryResult := recover(); recoveryResult != nil {
fmt.Println(recoveryResult)
}
fmt.Println("Recuperando...")
}
func Panic() {
defer Recovery()
panic("Pánico")
fmt.Println("La función Panic terminó correctamente")
}
func main() {
Panic()
fmt.Println("La función main terminó correctamente")
}
Como resultado de la ejecución del código, obtendremos la siguiente salida:
Pánico
Recuperando...
La función main terminó correctamente
Observa que la función Panic
no termina después del pánico. Esto sucede porque mediante defer
se llama a la función diferida Recovery
, que recupera el funcionamiento del programa. Luego, la ejecución se pasa a main
, donde se completa correctamente todo el código.
Conclusión
Es importante recordar que la calidad y limpieza del código dependen no solo del lenguaje de programación, sino también de las habilidades del desarrollador. El uso de los ejemplos considerados y el seguimiento de los principios generales ayudarán a mejorar la calidad del software creado.
Esperamos que este artículo te inspire a aplicar las prácticas descritas en el desarrollo con Go y a crear programas que incluso un principiante pueda entender fácilmente. ¡Recuerda que el código limpio es el camino hacia un proyecto exitoso!