A fondo con go: structs, interfaces

Si es cierto, en el post anterior ya hablamos de structs, pero es que todavía nos queda mucho por aprender.
Ya sabemos que los structs son colecciones tipadas de campos y además son muy útiles para agrupar datos juntos.
Así que partiendo del ejemplo anterior de geometría, donde tenemos un struct Triangle con un método para calcular su área
y un struct Rectangle con un método para calcular su área vamos a darle una vuelta de tuerca para saber que son las interfaces y como sacarles partido.

9713407427_90f5f069a8_z
Estación de Chamberí – Antonio Tajuelo

Este es el fichero geometry.go de donde vamos a partir

package more_geometry

import "math"

type Triangle struct {
    a float64
    b float64
    c float64
}

func (triangle Triangle) Area() interface{} {
    semiperimeter := (triangle.a + triangle.b + triangle.c) / 2
    radicand := semiperimeter * (semiperimeter - triangle.a) * (semiperimeter - triangle.b) * (semiperimeter - triangle.c)
    return math.Sqrt(radicand)
}

type Rectangle struct {
    a float64
    b float64
}

func (rectangle Rectangle) Area() interface{} {
    return rectangle.a * rectangle.b
}

Este es el fichero de test

package more_geometry

import "testing"

func TestArea(t *testing.T) {

    t.Run("triangle", func(t *testing.T) {
        triangle := Triangle{4, 5, 3}
        got := triangle.Area()
        expected := 6.00

        if got != expected {
            t.Errorf("got %.2f expected %.2f", got, expected)
        }
    })

    t.Run("rectangle", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        got := rectangle.Area()
        expected := 72.0

        if got != expected {
            t.Errorf("got %.2f expected %.2f", got, expected)
        }
    })
}

Vemos que hay un poco de repetición en nuestros tests, es más si añadiremos una nueva figura por ejemplo un circulo veríamos más claro que el código de los test se repite.
Como tenemos los tests en verde es hora de hacer un poco de refactoring para eliminar esa duplicidad.
Lo que haremos será crear una colección de shapes (figuras) llamar al método área y verificar que todo funciona.

Interfaces

Para ello vamos a utilizar interfaces. La idea es hacer que todos los tipos que implementen dicha interfaz tengas que implementar esa función.
Es decir, es como un “contrato”. Todo struct que utilice ese contrato debe cumplir las condiciones. Con esto mantenemos la seguridad de los tipos.

Usando shape.Area() podemos crearnos la función assertArea con lo que eliminamos la duplicidad

func TestArea(t *testing.T) {

    assertArea := func(t *testing.T, shape Shape, want float64) {
        t.Helper()
        got := shape.Area()
        if got != want {
            t.Errorf("got %.2f want %.2f", got, want)
        }
    }

    t.Run("triangle", func(t *testing.T) {
        triangle := Triangle{4, 5, 3}
        assertArea(t, triangle, 6.00)
    })
    t.Run("rectangles", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        assertArea(t, rectangle, 72.00)
    })
}

Al ejecutar los test scon go test tenemos que el tipo Shape no está definido.

# github.com/jeslopcru/golang-examples/04-more-geometry
./geometry_test.go:7:41: undefined: Shape

Definiendo Shape para que todas las figuras tengan un método Area

type Shape interface {
    Area() float64
}

type Triangle struct {
    a float64
    b float64
    c float64
}

func (triangle Triangle) Area() float64 {
    semiperimeter := (triangle.a + triangle.b + triangle.c) / 2
    radicand := semiperimeter * (semiperimeter - triangle.a) * (semiperimeter - triangle.b) * (semiperimeter - triangle.c)
    return math.Sqrt(radicand)
}

type Rectangle struct {
    a float64
    b float64
}

func (rectangle Rectangle) Area() float64 {
    return rectangle.a * rectangle.b
}

Al definir Shape como interfaz, todos los métodos deben cumplirla, por lo que la firma es ligeramente distinta a la anterior.
Antes era algo así:

func (rectangle Rectangle) Area() interface{} {
    return rectangle.a * rectangle.b
}

Ahora al tener la interfaz tenemos que cumplirla, por lo que el valor a devolver será un float64. Con esto ya tenemos los tests de nuevo en verde.

func (rectangle Rectangle) Area() float64 {
    return rectangle.a * rectangle.b
}

Al ejecutar los tests, como hemos dicho, tenemos todo verde:

$ go test
=== RUN   TestArea
--- PASS: TestArea (0.00s)
=== RUN   TestArea/triangle
    --- PASS: TestArea/triangle (0.00s)
=== RUN   TestArea/rectangles
    --- PASS: TestArea/rectangles (0.00s)
PASS

Process finished with exit code 0

Vamos a explicarlo un poco mejor. Que visto así puede parecer lioso. Lo que hemos hecho ha sido:

  • Hemos un nuevo tipo, pero esta vez es una interfaz llamada Shape que tiene un método para calcular el Area()
  • Hemos creado un struct llamado Triangle y otro llamado Rectangle, resumiendo mucho son “objetos”
  • Ambos struct implementan la interfaz Shape por lo que tienen un método Area() cada uno.

Las interfaces en Go son un poco distintas que en el resto de lenguajes, aquí no hacen falta implements ni nada parecido. En Go la resolución de la interfaz es implícita.

Nosotros vamos a seguir avanzando con nuestro ejemplo con TDD. Como tenemos los tests en verde es hora de refactorizar.
En los tests tenemos duplicación y para eliminarla nos vamos a ayudar de la interfaz y de TableDrivenTest (algo así como un @dataprovider de phpunit).

Los test nos quedan así:


func TestArea(t *testing.T) {

    areaDataProvider := []struct {
        shape Shape
        want  float64
    }{
        {Rectangle{12, 6}, 72.0},
        {Triangle{4, 5, 3}, 6.0},
    }

    for _, sut := range areaDataProvider {
        got := sut.shape.Area()
        if got != sut.want {
            t.Errorf("got %.2f want %.2f", got, sut.want)
        }
    }
}

Lo que hemos hecho ha sido crear un array llamado areaDataProvider, dicho array tiene 2 elementos, el primero son objetos de tipos Shape y el segundo es el resultado esperado.
Después para testear los datos, tan solo hemos de crear un bucle for que recorra los datos y calcule el Area()

Si ejecutamos los tests después de esta refactorización, los test siguen en verde:

$ go test
=== RUN   TestArea
--- PASS: TestArea (0.00s)
=== RUN   TestArea/triangle
    --- PASS: TestArea/triangle (0.00s)
=== RUN   TestArea/rectangles
    --- PASS: TestArea/rectangles (0.00s)
PASS

En este test hemos aprendido a crear un “anonymous struct” llamado areaDataProvider. y hemos añadido al array los distintos tipos de figuras.
Ahora para añadir una figura nueva, tan solo tenemos que añadir un nuevo elemento al array.
Puede ver cómo sería muy fácil para un desarrollador introducir una nueva forma.

Añadiendo circulos

Ahora por ejemplo podríamos añadir una nueva figura, por ejemplo un círculo. Para ello tan sencillo como crear el nuevo struct e implementar el método Area() ¿te atreves?
Bueno, como estamos con TDD… lo primero antes de tocar el código de producción será crear el test, que como hemos dicho no es más que añadir una linea así como esta {Circle{10}, 314.1592653589793},

El test quedaría así:

func TestArea(t *testing.T) {

    areaDataProvider := []struct {
        shape Shape
        want  float64
    }{
        {Rectangle{12, 6}, 72.0},
        {Triangle{4, 5, 3}, 6.0},
        {Circle{10}, 6.0},
    }

    for _, sut := range areaDataProvider {
        got := sut.shape.Area()
        if got != sut.want {
            t.Errorf("got %.2f want %.2f", got, sut.want)
        }
    }
}

Desde ya Goland nos está indicando que el type Circle no está definido, es más si ejecutamos los tests obtendremos algo como esto:

$go test
# github.com/jeslopcru/golang-examples/04-more-geometry
./geometry_test.go:13:4: undefined: Circle

Una vez que creamos el struct Circle en el fichero geometry.go

type Circle struct {
    radius float64
}

volvemos a ejecutar los tests y estos siguen fallando:

# github.com/jeslopcru/golang-examples/04-more-geometry
   ./geometry_test.go:13:10: cannot use Circle literal (type Circle) as type Shape in field value:
    Circle does not implement Shape (missing Area method)

   Compilation finished with exit code 2

El error ya nos muestra el problema, necesitamos implementar el método Area() para que Circle cumpla con la interfaz de Shape
Añadimos la función dentro del fichero geometry.go

func (circle Circle) Area() float64  {
    return circle.radius * circle.radius * math.Pi
}

Y ahora al ejecutar los tests ya pasan. Tenemos un nuevo tipo añadido. Recapitulando tenemos:

  • La interfaz Shape con el método Area()
  • Los struct Rectangle, Triangle, y Circle que implementan dicha interfaz

Como los tests están en verde, es hora de refactorizar. ¿Qué pasa si nos falla solo uno de los tests? ¿tenemos que ejecutarlos todos cada vez?
Para mejorar esta parte podemos usar t.Run como utilizamos al principio de la serie. Vamos a usarlo aquí también:

func TestArea(t *testing.T) {

    areaDataProvider := []struct {
        name    string
        shape Shape
        want  float64
    }{
        {"Rectangle",Rectangle{12, 6}, 72.0},
        {"Triangle",Triangle{4, 5, 3}, 6.0},
        {"Circle",Circle{10}, 314.1592653589793},
    }

    for _, sut := range areaDataProvider {
        t.Run(sut.name, func(t *testing.T) {
            got := sut.shape.Area()
            if got != sut.want {
                t.Errorf("got %.2f want %.2f", got, sut.want)
            }
        })
    }
}

Lo primero es añadir al areaDataProvider un nuevo campo nombre, que servirá para dar nombre al test, en nuestro caso hemos optado por el mismo nombre de la figura.
Ahora hay que cambiar el bucle para que en cada paso ejecute t.Run para que al final nos quede algo así:

func TestArea(t *testing.T) {

    areaDataProvider := []struct {
        name    string
        shape Shape
        want  float64
    }{
        {"Rectangle",Rectangle{12, 6}, 72.0},
        {"Triangle",Triangle{4, 5, 3}, 6.0},
        {"Circle",Circle{10}, 314.1592653589793},
    }

    for _, sut := range areaDataProvider {
        t.Run(sut.name, func(t *testing.T) {
            got := sut.shape.Area()
            if got != sut.want {
                t.Errorf("got %.2f want %.2f", got, sut.want)
            }
        })
    }
}

Al ejecutar los tests tenemos que todo sigue funcionando, aunque la salida ha cambiado un poco:

$ go test
=== RUN   TestArea
--- PASS: TestArea (0.00s)
=== RUN   TestArea/Rectangle
    --- PASS: TestArea/Rectangle (0.00s)
=== RUN   TestArea/Triangle
    --- PASS: TestArea/Triangle (0.00s)
=== RUN   TestArea/Circle
    --- PASS: TestArea/Circle (0.00s)
PASS

Para ejecutar por ejemplo, solo el test de círculos tenemos que poner en el terminal algo así:

╰─$ go test -run TestArea/Circle
PASS
ok      github.com/jeslopcru/golang-examples/04-more-geometry   0.005s

Conclusiones

Esto solo ha sido un pequeño paso más para seguir haciendo TDD y aprendiendo Go. Hoy hemos aprendido:

  • A declarar interfaces con las que definir funciones que pueden ser utilizadas por diferentes tipos.
  • A crear métodos que implementan interfaces.
  • A crear test que utilizan dataproviders.

El post de hoy es importante, porque en lenguajes tipificados estáticamente como Go, ser capaces de diseñar nuestros propios tipos es esencial para crear software que sea fácil de entender y probar.

 

Anuncios

Comenta la entrada

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s