Uso de interfaces en Go

Completado

Las interfaces en Go son un tipo de datos que se usa para representar el comportamiento de otros tipos. Una interfaz es como un plano técnico o un contrato que un objeto debe cumplir. Cuando se usan interfaces, el código base se vuelve más flexible y adaptable, porque se escribe código que no está vinculado a una implementación concreta. Por tanto, se puede extender la funcionalidad de un programa rápidamente. Entienda por qué en este módulo.

A diferencia de las interfaces de otros lenguajes de programación, las de Go se satisfacen de forma implícita. Go no ofrece palabras clave para implementar una interfaz, Por lo tanto, si está familiarizado con las interfaces en otros lenguajes de programación, pero no está familiarizado con Go, esta idea podría resultar confusa.

En este módulo, trabajamos con varios ejemplos para explorar interfaces en Go y demostrar cómo sacar el máximo partido a ellas.

Declaración de una interfaz

Una interfaz en Go es como un plano técnico. Un tipo abstracto que solo incluye los métodos que un tipo concreto debe poseer o implementar.

Imagine que quiere crear una interfaz en el paquete de geometría que indica qué métodos debe implementar una forma. Podría definir una interfaz como esta:

type Shape interface {
    Perimeter() float64
    Area() float64
}

La interfaz Shape significa que cualquier tipo que quiera considerar Shape debe tener los métodos Perimeter() y Area(). Por ejemplo, al crear una estructura Square, tiene que implementar los dos métodos, no solo uno. Además, tenga en cuenta que una interfaz no contiene detalles de implementación para esos métodos (por ejemplo, para calcular el perímetro y el área de una forma). Son simplemente un contrato. Las formas como triángulos, círculos y cuadrados tienen otras maneras de calcular el área y el perímetro.

Implementar una interfaz

Como se ha explicado antes, en Go no hay una palabra clave para implementar una interfaz. Un tipo cumple de forma implícita una interfaz en Go cuando tiene todos los métodos que necesita una interfaz.

Ahora se creará una estructura Square que tenga los dos métodos de la interfaz Shape, como se muestra en el código de ejemplo siguiente:

type Square struct {
    size float64
}

func (s Square) Area() float64 {
    return s.size * s.size
}

func (s Square) Perimeter() float64 {
    return s.size * 4
}

Observe cómo la firma del método de la estructura Square coincide con la firma de la interfaz Shape. Pero es posible que otra interfaz tenga otro nombre pero los mismos métodos. ¿Cómo o cuándo sabe Go qué interfaz implementa un tipo concreto? Go sabe cuándo se usa, en tiempo de ejecución.

Para demostrar cómo se usan las interfaces, puede escribir el siguiente código:

func main() {
    var s Shape = Square{3}
    fmt.Printf("%T\n", s)
    fmt.Println("Area: ", s.Area())
    fmt.Println("Perimeter:", s.Perimeter())
}

Al ejecutar el programa anterior, se obtiene la salida siguiente:

main.Square
Area:  9
Perimeter: 12

En este momento, es irrelevante si se usa una interfaz o no. Ahora se creará otro tipo, como Circle, y luego se explorará por qué las interfaces son útiles. Este es el código de la estructura Circle:

type Circle struct {
    radius float64
}

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

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.radius
}

Ahora vamos a refactorizar la función main() y crear una función que pueda imprimir el tipo del objeto que recibe, junto con su área y perímetro, de la siguiente manera:

func printInformation(s Shape) {
    fmt.Printf("%T\n", s)
    fmt.Println("Area: ", s.Area())
    fmt.Println("Perimeter:", s.Perimeter())
    fmt.Println()
}

Observe cómo la función printInformation tiene Shape como parámetro. Puede enviar un Square o un objeto Circle a esta función y funciona, aunque la salida es diferente. Ahora la función main() tiene este aspecto:

func main() {
    var s Shape = Square{3}
    printInformation(s)

    c := Circle{6}
    printInformation(c)
}

Observe que para el objeto c, no se especifica que sea un objeto Shape. Pero la función printInformation espera un objeto que implemente los métodos que se definen en la interfaz Shape.

Al ejecutar el programa, debería obtener la salida siguiente:

main.Square
Area:  9
Perimeter: 12

main.Circle
Area:  113.09733552923255
Perimeter: 37.69911184307752

Observe cómo no recibe un error y la salida varía en función del tipo de objeto que recibe. También puede ver que el tipo de objeto de la salida no indica nada sobre la interfaz Shape.

La ventaja de usar interfaces es que, para cada nuevo tipo o implementación de Shape, la función printInformation no tiene que cambiar. Tal y como hemos mencionado antes, su código se vuelve más flexible y fácil de extender cuando usa interfaces.

Implementación de una interfaz Stringer

Un ejemplo sencillo de ampliación de la funcionalidad existente consiste en usar una instancia de Stringer, que es una interfaz que tiene un método String(), similar al siguiente:

type Stringer interface {
    String() string
}

La fmt.Printf función usa esta interfaz para imprimir valores, lo que significa que puede escribir su método String() personalizado para imprimir una cadena personalizada, como esta:

package main

import "fmt"

type Person struct {
    Name, Country string
}

func (p Person) String() string {
    return fmt.Sprintf("%v is from %v", p.Name, p.Country)
}
func main() {
    rs := Person{"John Doe", "USA"}
    ab := Person{"Mark Collins", "United Kingdom"}
    fmt.Printf("%s\n%s\n", rs, ab)
}

Al ejecutar el programa anterior, se obtiene la salida siguiente:

John Doe is from USA
Mark Collins is from United Kingdom

Como puede ver, usó un tipo personalizado (una estructura) para escribir una versión personalizada del método String(). Esta técnica es una forma común de implementar una interfaz en Go y encontrará ejemplos de ella en muchos programas, ya que estamos a punto de explorar.

Ampliación de una implementación existente

Imagine que tiene el código siguiente y le gustaría extender su funcionalidad escribiendo una implementación personalizada de un método Writer que se encarga de manipular algunos datos.

Con el código siguiente, puede crear un programa que use la API de GitHub para obtener tres repositorios de Microsoft:

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func main() {
    resp, err := http.Get("https://api.github.com/users/microsoft/repos?page=15&per_page=3")
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }

    io.Copy(os.Stdout, resp.Body)
}

Al ejecutar el código anterior, se obtiene algo parecido a la salida siguiente (abreviada para mejorar la legibilidad):

[{"id":276496384,"node_id":"MDEwOlJlcG9zaXRvcnkyNzY0OTYzODQ=","name":"-Users-deepakdahiya-Desktop-juhibubash-test21zzzzzzzzzzz","full_name":"microsoft/-Users-deepakdahiya-Desktop-juhibubash-test21zzzzzzzzzzz","private":false,"owner":{"login":"microsoft","id":6154722,"node_id":"MDEyOk9yZ2FuaXphdGlvbjYxNTQ3MjI=","avatar_url":"https://avatars2.githubusercontent.com/u/6154722?v=4","gravatar_id":"","url":"https://api.github.com/users/microsoft","html_url":"https://github.com/micro
....

Observe que la llamada io.Copy(os.Stdout, resp.Body) es la que imprime en el terminal el contenido que obtuvo de la llamada a la API de GitHub. Imagine que quiere escribir una implementación propia para acortar el contenido que se ve en el terminal. Al examinar el origen de la io.Copyfunción, ve lo siguiente:

func Copy(dst Writer, src Reader) (written int64, err error)

Si profundiza en los detalles del primer parámetro, dst Writer, observa que Writer es una interfaz:

type Writer interface {
    Write(p []byte) (n int, err error)
}

Puede seguir explorando el código fuente del paquete de io hasta que encuentre dónde Copy llama al Writemétodo, pero dejemos esta exploración solo por ahora.

Como Writer es una interfaz y es un objeto que la función Copy espera, podría escribir la implementación personalizada del método Write. Por tanto, puede personalizar el contenido que se imprime en el terminal.

Lo primero que necesita para implementar una interfaz es crear un tipo personalizado. En este caso, puede crear una estructura vacía, porque simplemente tiene que escribir el método Write personalizado, de esta manera:

type customWriter struct{}

Ahora está preparado para escribir la función Write personalizada. También debe escribir una estructura para analizar la respuesta de la API en formato JSON en un objeto Golang. Podría usar el sitio de JSON-to-Go para crear una estructura a partir de una carga JSON. Por tanto, el método Write tendría este aspecto:

type GitHubResponse []struct {
    FullName string `json:"full_name"`
}

func (w customWriter) Write(p []byte) (n int, err error) {
    var resp GitHubResponse
    json.Unmarshal(p, &resp)
    for _, r := range resp {
        fmt.Println(r.FullName)
    }
    return len(p), nil
}

Por último, tiene que modificar la función main() para usar el objeto personalizado, de esta manera:

func main() {
    resp, err := http.Get("https://api.github.com/users/microsoft/repos?page=15&per_page=5")
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }

    writer := customWriter{}
    io.Copy(writer, resp.Body)
}

Al ejecutar el programa, debería obtener la salida siguiente:

microsoft/aed-blockchain-learn-content
microsoft/aed-content-nasa-su20
microsoft/aed-external-learn-template
microsoft/aed-go-learn-content
microsoft/aed-learn-template

Ahora la salida es mejor, gracias al método Write personalizado que ha escrito. Esta es la versión final del programa:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
)

type GitHubResponse []struct {
    FullName string `json:"full_name"`
}

type customWriter struct{}

func (w customWriter) Write(p []byte) (n int, err error) {
    var resp GitHubResponse
    json.Unmarshal(p, &resp)
    for _, r := range resp {
        fmt.Println(r.FullName)
    }
    return len(p), nil
}

func main() {
    resp, err := http.Get("https://api.github.com/users/microsoft/repos?page=15&per_page=5")
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }

    writer := customWriter{}
    io.Copy(writer, resp.Body)
}

Escritura de una API de servidor personalizada

Por último, se examinará otro caso de uso para las interfaces que puede resultarle útil si va a crear una API de servidor. La forma habitual de escribir un servidor web consiste en usar la interfaz http.Handler del paquete net/http, que tiene un aspecto similar al siguiente (no es necesario escribir este código):

package http

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

func ListenAndServe(address string, h Handler) error

Observe cómo la función ListenAndServe espera una dirección de servidor, como http://localhost:8000, y una instancia del Handler que envía la respuesta de la llamada a la dirección del servidor.

Ahora se creará y examinará el siguiente programa:

package main

import (
    "fmt"
    "log"
    "net/http"
)

type dollars float32

func (d dollars) String() string {
    return fmt.Sprintf("$%.2f", d)
}

type database map[string]dollars

func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    for item, price := range db {
        fmt.Fprintf(w, "%s: %s\n", item, price)
    }
}

func main() {
    db := database{"Go T-Shirt": 25, "Go Jacket": 55}
    log.Fatal(http.ListenAndServe("localhost:8000", db))
}

Antes de explorar el código anterior, se ejecutará de esta forma:

go run main.go

Si no obtiene ninguna salida, es buena señal. Ahora abra http://localhost:8000 en una nueva ventana del explorador o, en el terminal, ejecute el comando siguiente:

curl http://localhost:8000

Debería obtener esta salida:

Go T-Shirt: $25.00
Go Jacket: $55.00

Ahora se revisará detenidamente el código anterior para comprender lo que hace y observar la eficacia de las interfaces de Go. En primer lugar, empiece por crear un tipo personalizado para un tipo float32, con la idea de escribir una implementación personalizada del método String(), que se usará más adelante.

type dollars float32

func (d dollars) String() string {
    return fmt.Sprintf("$%.2f", d)
}

Después, se escribe la implementación del método ServeHTTP que http.Handler puede usar. Observe cómo se ha vuelto a crear un tipo personalizado, pero esta vez es un mapa, no una estructura. Después, se escribe el método ServeHTTP mediante el tipo database como receptor. La implementación de este método usa los datos del receptor, los recorre en bucle e imprime cada elemento.

type database map[string]dollars

func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    for item, price := range db {
        fmt.Fprintf(w, "%s: %s\n", item, price)
    }
}

Por último, en la función main(), se ha creado una instancia de un tipo database y se ha inicializado con algunos valores. Se ha iniciado el servidor HTTP mediante la función http.ListenAndServe, donde se ha definido la dirección del servidor, incluido el puerto que se va a usar y el objeto db que implementa una versión personalizada del método ServeHTTP. Al ejecutar el programa, Go usa la implementación de ese método y así se usa e implementa una interfaz en una API de servidor.

func main() {
    db := database{"Go T-Shirt": 25, "Go Jacket": 55}
    log.Fatal(http.ListenAndServe("localhost:8000", db))
}

Puede encontrar otro caso de uso para las interfaces en una API de servidor al usar la función http.Handle. Para obtener más información, vea la publicación Writing web applications (Escritura de aplicaciones web) en el sitio de Go.