Como tengo que aprender el lenguaje Go voy a comenzar una serie hablando sobre el lenguaje y el camino del aprendizaje. Esto no va a ser una guía o un tutorial sino una pequeña guía de consulta para mi mismo en el futuro.

Introducción

Go es uno de los lenguajes modernos que forman parte de los lenguajes de moda del momento. Entre las características que tiene son:

  • Multiparadigma.
  • Estáticamente tipado (aunque posee inferencia de tipos).
  • Lenguaje compilado y diseñado para ser compilado para diferentes plataformas de forma sencilla.
  • Posee recolector de basura por lo que la gestión de la memoria es automática.

La filosofía del diseño es tener un lenguaje sencillo, compilación rápida, primitivas de concurrencia en el propio lenguaje, posee un gestor de paquetes propio y la propia toolchain ofrece las herramientas necesarias para las tareas comunes en el desarrollo de software como testing, formato de código, gestión de dependencias, compilador, depurador etc.

Cada nuevo lenguaje que aprendes es una oportunidad para aprender diferentes filosofías de desarrollo y aprender nuevas técnicas. El lenguaje en sí tiene sus pros y sus contras (¿Qué tecnología no las tiene?) entre los pros considero un acierto tener las herramientas básicas como testing, formateo de código y gestión de dependencias incorporado al lenguaje, la integración de todo esto es más cómodo de trabajar. No tengo suficiente experiencia todavía en el mundo real para destacar los contras, no obstante lo que me pesa al trabajar es no tener ciertas cosas que encuentras en otros lenguajes orientados a objetos clásicos, como por ejemplo poder contar con establecer variables públicas y privadas en un objeto (En Go esto no se puede hacer directamente, tienes que jugar con la visibilidad de los paquetes), el uso de mayúsculas para exportar variables, objetos y funciones al consumidor (No es algo dramático pero al principio te parece incómodo).

En definitiva es un lenguaje usado en el mundo real, con demanda y se han realizado proyectos de envergadura con el, como por ejemplo Docker y Kubernetes entre otros. Es un lenguaje productivo con multitud de librerías y herramientas que te permiten desarrollar rápido y con calidad.

Instalación

La instalación depende de la plataforma en la que vayas a desarrollar. Aquí está el link con las instrucciones: https://go.dev/doc/install

Clásico Hola Mundo

El clásico hola mundo en Go:

package main
import "fmt"

func main() {
    fmt.Printf("Hello world\n")
}
# Para ejecutar directamente
go run hello.go
# Para generar un ejecutable
go build hello.go
# Y genera un ejecutable en el directorio de trabajo llamado hello

Cada programa en Go está hecho de packages en este caso hemos llamado a nuestro paquete main. El punto de entada del programa es la función main.

Tipos primitivos

Los tipos primitivos son:

  • bool
  • string
  • int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64
  • byte (Alias de uint8)
  • float32, float64
  • complex64, complex128
  • rune (Alias de int32)
  • arrays
  • maps
  • slices

Go posee punteros y se representan con los mismos símbolos y poseen el mismo significado que en lenguajes como C/C++.

number := 32
p := &number //Puntero a number

*p = 50 //Modifica el valor de number a través del puntero

Variables

Inicialización

La sintaxis de la declaración de variables en Go es diferente a lenguajes como C y C++. El tipo de la variable siempre va al final.

No es necesario declarar el tipo de las variables, en Go existen inferencias de tipos, es decir el tipo de la variable se infiere en función del tipo de la variable/constante del lado derecho.

Por defecto las variables se declaran en el stack, para declarar variables en el heap es necesario new. Pero lo veremos más adelante

package main

func main() {
    var counter int //Declaración de variable
    var index int = 0 //Declaración e inicialización
    //Inferencia de tipos
    j := index //j es de tipo int porque la variable index es de tipo int
    message := "Hello world" //message es de tipo string
}

Valor inicial

Cuando se omite el valor de inicialización las variables se inicializan a cero para el caso de enteros, false para booleanos y cadenas vacías “” para strings.

Conversión de tipos

La conversión de tipos explícita está prohibida en Go. Al hacer eso el programa no compila. La conversión de tipos debe ser explícita:

package main
import "fmt"

func main() {
    pi := 3.1416
    pi_int := int(pi) //conversión explicita obtiene el valor entero

    fmt.Printf("pi_int=%d\n", pi_int)
}

Constantes

Las constantes se declaran con la palabra clave const. Las constantes no se puede declarar con la sintaxis :=

Los tipos que pueden constantes son: string, bool y tipos numéricos.

Otra sintaxis de declarar constantes es:

package main
import "fmt"

const (
    PI = 3.1416
    E = 2.71828
)

func main() {
    const MY_CONSTANT =  7//sintaxis clásica

    fmt.Printf("pi=%f\n", PI)
}

Reserva de memoria con new

Podemos declarar variables en memoria dinámica usando la función new. Al reservar memoria con new los bloques de memoria se inicializan al valor cero. Esto implica que cuando usemos objetos hay que tener en cuenta la inicialización de los miembros. No obstante no es necesario hacerlo manualmente al estilo de C (memset + inicialización de cada miembro al valor deseado) el lenguaje provee de sintaxis para reservar memoria dinámica y darle un valor por defecto como si de un constructor se tratara.

Recordar que Go tiene un recolector de basura y no es necesario eliminar la memoria de forma manual.

Funciones

Las funciones son otro tipo primitivo en Go. Las funciones pueden devolver múltiples valores, devolver otras funciones, etc.

No existe la sobrecarga en las funciones, cada función debe tener un nombre único

package main
import "fmt"

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

func swap(x, y int) (int, int) {
    return y, x
}

func main() {
    fmt.Printf("add(1, 1)=%d\n", add(1, 1))

    x, y := swap(3, 5)
    fmt.Printf("swap(3, 5)=%d,%d\n", x, y)
}

Named return values

Go retorna valores de las funciones por el nombre. Un return sin argumentos usa el nombre los argumentos de salida para retornar el valor. Mejor un ejemplo:

package main
import "fmt"

func swap(a, b int) (x, y int) {
    x = b
    y = a
    return
}

func main() {
    a := 5
    b := 3
    x, y := swap(a, b)
    fmt.Printf("swap(%d, %d)=%d,%d\n", a, b, x, y)
}

Punteros y funciones

Las funciones pueden recibir punteros a tipos de datos. El comportamiento se similar a C/C++ cuando se pasa una variable por valor la función recibe una copia de la variable y cuando se pasa un puntero no se realiza una copia. Modificar una variable con un puntero modifica la variable global.

Destacar que a nivel de funciones Go es sensible a si una variable se pasa por valor o por referencia, es decir si una función espera una referencia (variable pasada por puntero) y se le pasa por valor da un error de compilación.

package main
import "fmt"

func foo(p *int) {
    *p = *p * 10
}

func main() {
    n := 1
    foo(&n) //OK
    fmt.Printf("n=%d\n", n)

    j := 2
    foo(j) //ERROR de compilacion
}

Recordar que esta sensibilidad entre pasar por valor o referencia solo ocurre en funciones

Funciones variádicas

Las funciones variádicas son funciones que aceptan cualquier número de parámetros. La sintaxis es la siguiente:

package main
import "fmt"

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

func main() {
    total := sum(1, 2)
    fmt.Printf("total=%d\n", total)
}

Closures

Go soporta el uso de funciones anónimas lo que pueden formar closures

package main
import "fmt"

func incrementer(step int) func() int {
    counter := 0
    return func() int {
        counter += step
        return counter
    }
}

func main() {
    inc := incrementer(1)
    inc()
    
    fmt.Printf("%d\n", inc()) //should print 2
}

Control de flujo

if/else, for, switch

La sintaxis de los ifs y for son similares a C/C++ pero sin paréntesis. No existen los whiles en Go, en su lugar se usan for sin condición de parada.

Go posee sentencias switch a diferencia de C/C++ los switch en Go solo ejecutan el caso seleccionado e ignora los que vienen a continuación. El orden de evaluación es desde arriba hacia abajo (from top to bottom)

package main
import "fmt"

func main() {
    for i := 0; i <= 5; i++ {
        fmt.Printf("%d ", i)
    }
    fmt.Printf("\n")

    max := 10
    if max >= 10 {
        fmt.Printf("max >= 10\n")
    }

    platform := "linux"
    switch platform {
    case "linux":
        fmt.Printf("Linux platform\n")
    case "lindows":
        fmt.Printf("Window platform\n")
    case "MacOs":
        fmt.Printf("MacOs platform\n")
    default:
        fmt.Printf("Unknow platform\n")
    }
}

Defer

La sentencia defer retrasa la ejecución de una función hasta que la función actual haya salido del scope. Los defer se pueden apilar, es decir, puedes llamar múltiples veces un defer y se ejecutaran en orden LIFO (Last In, First out).

Los defers son bastantes útiles para cerrar recursos y quedan bastante legibles en el código.

package main

import "fmt"

func main() {
    fmt.Println("counting")
    for i := 0; i < 10; i++ {
        defer fmt.Println(i)
    }
    fmt.Println("done")
}

Arrays

Los arrays son una secuencia de datos.

Destacar una vez más que los arrays son de tamaño fijo y una vez declarados si tamaño no varía. Los arrays se declaran en memoria estática no se reserva memoria para ellos.

package main
import "fmt"

func main() {
    number_list := []int{1, 2, 3, 4}
    fmt.Printf("number_list len=%d\n", len(number_list))
    for _, value := range number_list {
        fmt.Printf("%d\n", value)
    }
}

Destacar la palabra clave range que nos permite poder iterar con secuencia de datos. range devuelve siempre dos argumentos: índice y valor. Para ignorar cualesquiera de los valores devueltos hay que usar _ para que el compilador de Go entienda que no vamos a usar esa variable.

Slices

Los arrays en realidad son el bloque de construcción de los slices que es lo que se usará en la práctica. Los slices usan memoria dinámicas y pueden variar su tamaño. Además tenemos los slices mantienen información de su tamaño que se puede consultar con la función len.

Para crear una slice de cualquier tipo se usa la función make. Esta función reserva memoria y ademas inicialia los valores de la estructura interna de los slices.

Para obtener subelementos de un slice la sintaxis es igual que la que se usa en python.

package main
import "fmt"

func print_elements(s []int) {
    fmt.Printf("number_list len=%d\n", len(s))
    for _, v := range s {
        fmt.Printf("%d\n", v)
    }
}

func main() {
    number_list := make([]int, 10)
    print_elements(number_list)

    //print subelements
    print_elements(number_list[0: 5])
}

make vs new

Existen dos funciones para reservar memoria: make y new. La realidad es que ambas funciones tiene propósitos diferentes, *new reserva e inicializa la memoria a cero mientras que make está pensada para reservar memoria para slices, maps y channels. La función make además de reservar memoria inicializa las variables internas de dichas estructuras de datos a los valores correspondientes.

La regla de oro es:

Usar make para slices, maps y channels y new para valors numéricos, structs etc.

Maps

Los mapas son estructuras de datos clave/valor. Los mapas se construyen con make.

package main
import "fmt"

func main() {
    city_capitals := make(map[string]string)
    
    //Insert element
    city_capitals["Spain"] = "Madrid"
    city_capitals["Portugal"] = "Lisboa"
    city_capitals["France"] = "Paris"
    city_capitals["Israel"] = "Tel Aviv"

    //Delete element
    delete(city_capitals, "Israel")
    city_capitals["Israel"] = "Palestina"

    //Test if element is present
    palestina_capital, ok := city_capitals["Palestina"]
    if ok {
        fmt.Printf("Palestina capital: %s", palestina_capital)
    }

    //Retrieve element
    spain_capital := city_capitals["Spain"]
    if spain_capital != "Madrid" {
        fmt.Printf("What happended to Spain?\n")
    }

    //Iterating with maps
    //range returns key,value pair
    for country, capital := range city_capitals {
        fmt.Printf("%s -> %s\n", country, capital)
    }
}

Structs

Las structs son agrupaciones de datos clásicas como podrían ser en lenguajes como C/C++.

Los campos de una struct se acceden con un punto, independientemente de que sea una variable declarada en el stack o un puntero a una struct. El lenguaje nos permite tener una sintaxis única.

package main
import "fmt"

type Point struct {
    x, y int
}

func print_point(p *Point) {
    fmt.Printf("Point{x=%d,y=%d}\n", p.x, p.y)
}

func main() {
    //Declaration syntax
    var point Point //all members set to zero
    origin := Point{0, 0}
    dest := Point{x: 1, y: 0}

    point_in_heap := new(Point)
    point_in_heap.x = 2
    point_in_heap.y = 3

    print_point(&point)
    print_point(&origin)
    print_point(&dest)
    print_point(point_in_heap)
}

Struct embedding

Go soporta incrustar structs e interfaces que facilita la composición de tipos. La sintaxis es:

type User struct {
    name string
}

type Account struct {
    User //Embedded struct, just include the type
    email string
    password string
}

Como se observa, en la estructura Account el campo User se incluye solo escribiendo el tipo y no se le asigna un nombre de variable. Esto permite acceder al campo name directamente desde Account sin tener que pasar por la variable intermedia. Veamos el ejemplo:

//Initalize Account
account := Account{
    User{"Andres"},
    "andres@mail.com",
    "mypassword"
}
//Access to members
fmt.Printf("name=%s\n", account.name) //Notice the direct access
fmt.Printf("email=%s\n", account.email)
fmt.Printf("password=%s\n", account.password)

https://gobyexample.com/struct-embedding

Métodos

Go no posee clases pero podemos definir métodos para los tipos de datos. Esto en combinación con structs (que no dejan de ser un tipo de datos en el fondo) permiten poder aplicar los conceptos de programación orientada objetos. Al no haber clases tampoco hay herencia, pero se puede resolver usando la composición en lugar de la herencia, con lo cual podemos extender la funcionalidad sin demasiados problemas.

Según el vocubulario de Go, un método es una función con un argumento especial llamado *reciever argument**. Veamos el ejemplo anterior pero usando un método para imprimir los campos de Point:

package main
import "fmt"

type Point struct {
    x, y int
}

func (p *Point) print() {
    fmt.Printf("Point{x=%d,y=%d}\n", p.x, p.y)
}

func (p Point) print_no_pointer() {
    fmt.Printf("Point{x=%d,y=%d}\n", p.x, p.y)
}

func main() {
    origin := Point{0, 0}
    origin.print() //method call
    origin.print_no_pointer()
}

Aplican las mismas normas de paso por valor o por referencia. Si se pasa por valor, el método recibirá una copia de la variable mientras que en un puntero no, y tiene la posibilida de modificar el receiver.

Destacar que en el caso de los métodos si que pueden existir dos métodos de diferente tipo con el mismo nombre.

Interfaces

Go provee de la capacidad de crear interfaces. Es decir, la capacidad de crear contratos entre structs para que implementen una conjunto de funciones comunes que satisfagan la interfaz (el contrato).

Siempre que una struct implemente todos los métodos definidos por una interfaz, se considera que el contrato se cumple. Si no lo hace y se trata de usar una struct que no implemente todas las funciones, da un error de compilación.

Esta parte es la definición normal de una interfaz, no obstante no existe una relación explícita en el código de que una struct implemente tal interfaz. Me parece algo peculiar.

package main
import "fmt"

//Cat
type Cat struct {}

func (c *Cat) Eat() {
    fmt.Printf("Cat::Eat\n")
}

//Dog
type Dog struct {}
func (d *Dog) Eat() {
    fmt.Printf("Dog::Eat\n")
}

//Tree, doesn't follow Animal interface
type Tree struct {}

//Interface
type Animal interface {
    Eat()
}

func AnimalEat(animal Animal) {
    animal.Eat()
}

func main() {
    cat := Cat{}
    dog := Dog{}
    tree := Tree{}

    AnimalEat(&cat)
    AnimalEat(&dog)
    AnimalEat(&tree) //Tree doesn't follow animal interface, so go throws compiler error
}

Errors

En Go un error es una interfaz que cumple este contrato:

type error interface {
    Error() string
}

Todos las strucst que cumplan este contrato se pueden devolver como el tipo error.