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.

✔️
Recuerda una regla importante: no definas interfaces antes de usarlas. Sin un ejemplo real, es difícil entender si realmente son necesarias, sin mencionar los métodos que deberían contener.
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.

✔️
Recuerda una regla importante: cada vez que uses la palabra clave go en tu programa para iniciar una goroutine, debes saber cómo y cuándo terminará. Si no conoces la respuesta a estas dos preguntas, puede provocar fugas de memoria.

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!

Categorizado en:

Go,