Introducción
Este post es una traduccion del post Write Go like a senior engineer, cuando lo leí me resultó interesante para compartirlo a partir del titulo, ya que toca varios puntos importantes cuando programamos en Go que son interesantes de profundizar. A esta lectura y comentarios como siempre, le agrego algunas cositas en base a mi experiencia.
Los parámetros en Go son pasados por valor
Por defecto, golang acepta pasajes por valor en sus funciones, lo que significa que lo que se recibe en la función es la copia del valor que pasamos. Esto impide que el valor de la estructura que estamos pasando mute dentro de la función a la que estamos llamando.
Veamos un ejemplo:
package main
import "fmt"
type Operation struct {
Value float64
}
// Recibimos la operación y le "agregamos los impuestos"
func addTaxes(operation Operation) {
operation.Value += operation.Value * 0.21
}
func main() {
operation := Operation{Value: 100}
// Llamamos a la función que agrega los impuestos
addTaxes(operation)
fmt.Printf("The total amount is: %f \n", operation.Value)
}
Al terminar esta función, detectamos que el valor no muto, con esto queda demostrado que el pasaje es por valor. En cambio, si quisiéramos modificar el valor de la operación, deberíamos recibir el puntero (la referencia al struct operation
) como lo vemos a continuación:
package main
import "fmt"
type Operation struct {
Value float64
}
// Ahora la función recibe la referencia a una `Operation`
func addTaxes(operation *Operation) {
operation.Value += operation.Value * 0.21
}
func main() {
operation := Operation{Value: 100}
// Ahora se envia la direccion de memoria de `operation`
addTaxes(&operation)
fmt.Printf("The total amount is: %f \n", operation.Value)
}
En este caso si vemos que el valor muta ya que estamos cambiando el valor de la referencia, y no de una copia.
Usar punteros (pero no abusar)
Operador *
y operador &
Como vimos en el ejemplo anterior, a la hora de manejarnos con punteros, podemos utilizar los símbolos *
y &
, pero que significa cada uno?
El operador *
es la declaración de un puntero, por ejemplo podriamos tener la siguiente declaracion:
var operation *Operation
Lo cual significa que la variable operation, es del tipo puntero de Operation.
En cambio, el símbolo &
significa, pasar el valor del puntero de una variable, o la inicialización del puntero de una estructura:
// Aca creamos un puntero a una nueva estructura del tipo Operation
operation = &Operation{Value: 100}
// Aca pasamos la referencia, o locación de memoria de la variable operation
addTaxes(&operation)
Aunque en el post original, diga
If you’re ever wondering “should I use a pointer here?” the answer is probably “Yes.” When in doubt, use a pointer.
Yo prefiero evitar punteros, esto lo voy a explicar en un post posterior porque hay tela que cortar.
Valor nil
El valor nil
es el valor cero de los punteros en Go, una de las causa de los panic en go, debido a que a veces se trabaja con punteros nulos no chequeados. Esta es una de las causas por las cuales intento evitar punteros, porque se necesita ese check previo a la ejecución:
// `operation` es `nil`
var operation *Operation
// Al no estar chequeado, en el método nos arroja el panic
addTaxes(operation)
Para evitar esto, tenemos que chequear y asegurarnos que el puntero no será nulo de la siguiente forma:
// `operation` es `nil`
var operation *Operation
// Estamos chequeando para evitar `operation == nil`
if operation != nil {
addTaxes(operation)
fmt.Printf("The total amount is: %f \n", operation.Value)
}
Declarar interfaces donde las vayas a usar
Las interfaces en Golang son explícitas, por lo que no es necesario declarar de donde se extienden, esto significa, que para poder extender de una interfaz, solo es necesario implementar sus métodos, veamos un ejemplo:
// UserRepository es el contrato que debemos cumplir
type UserRepository interface {
FindByID(id string) User
Save(user User)
}
// Esta será nuestra estructura que herede de la interfaz,
// notemos que no es necesario aclarar que extiende
// de UserRepository
type SQLUserRepository struct {
}
// FindByID y Save son los métodos que implementamos para poder extender
func (S SQLUserRepository) FindByID(id string) User {
panic("implement me")
}
func (S SQLUserRepository) Save(user User) {
panic("implement me")
}
Esa es la forma de crear interfaces, cómo podemos asegurarnos de que nuestra clase cumple el contrato, tenemos 2 opciones:
// La primera que suelo hacer, es crear un constructor
func NewSQL() UserRepository { // ← this is the line
return SQLUserRepository{}
}
// La segunda, es una recomendación escrita en el post
var _ UserRepository = (*SQLUserRepository)(nil)
Preferir tests en tablas, pero no hacer de más
En mi experiencia desarrollando Golang me he encontrado y he tenido que luchar con estos tests, no porque estuvieran mal hecho, sino que es muy difícil mantenerlos en el tiempo si la lógica de los métodos es compleja. Si, para probar entradas y salidas de tests funcionan perfectamente:
func Multiply(n1, n2 int) int {
return n1 * n2
}
// Test de Multiply
func TestMultiply(t *testing.T) {
cases := []struct {
name string
n1 int
n2 int
expectedResult int
}{
{
name: "Multiply 2 numbers",
n1: 2,
n2: 4,
expectedResult: 8,
},
{
name: "Multiply by 0",
n1: 0,
n2: 10,
expectedResult: 0,
},
}
for _, it := range cases {
t.Run(it.name, func(t *testing.T) {
result := Multiply(it.n1, it.n2)
if result != it.expectedResult {
t.Error("Result different, it'll fail")
t.Fail()
}
})
}
}
En este caso, se puede ver un test simple, al cual a medida que agregamos casos, no se modificará tanto el código. Por lo cual para estos casos, creo que funcionan perfectos.
Cuándo evitar la tabla de tests?
Es muy fácil encontrar momentos en los que las tablas de tests se pueden evitar, mayormente cuando la complejidad del método es grande. En mi experiencia me he topado con tablas de tests en las cuales se empiezan a mockear elementos de los distintos casos de uso, y en donde cada caso apunta a algo diferente. Ya no alcanza con una entrada o salida, ahora tenemos que configurar algo más! Les propongo un ejemplo que me he encontrado:
func TestUserSaver_Execute(t *testing.T) {
cases := []struct {
name string
user User
saver UserSaver
expectedErr string
}{
//...
}
for _, it := range cases {
t.Run(it.name, func(t *testing.T) {
err := it.saver.Execute(it.user)
if len(it.expectedErr) > 0 {
if err != nil {
if err.Error() != it.expectedErr {
t.Errorf("We expected the error: %s", it.expectedErr)
t.Fail()
}
} else {
t.Errorf("An error was expected %s", it.expectedErr)
t.Fail()
}
}
})
}
}
En este caso la lógica es realmente compleja, para un método que realmente es simple. Por otro lado, si el caso de uso sigue incrementando su dependencia con otros componentes, los test se irán modificando. En un momento, si llegásemos a encontrar un bug, por experiencia propia, es muy complejo poder entender los tests y poder corregir ese problema.
Fuentes para seguir mejorando
Así como en el post original, quiero recomendarles una de las mejores guías para escribir go:
Conclusión
El objetivo de este post es aprender, y seguir aprendiendo, me parece una buena fuente para compartir con quien quiera seguir mejorando en su día a día en el lenguaje, toca puntos con los que me he cruzado y he luchado, por lo que dejó parte de mi experiencia por si a alguien le ayuda. Si conocen de otros puntos a sumar, bienvenidos sean, agradezco sus reacciones para poder motivarme a seguir compartiendo este conocimiento con todos los gophers de la comunidad hispana.
Top comments (0)