Em Go, interfaces são ferramentas poderosas para design de código, permitindo abstração e polimorfismo. No entanto, o uso eficaz de interfaces exige mais do que apenas conhecer a sintaxe; envolve princípios de design que promovem código limpo, flexível e de fácil manutenção. Nesta aula, vamos explorar três princípios fundamentais: manter interfaces pequenas, seguir a máxima 'aceite interfaces, retorne structs', e compor interfaces para construir abstrações maiores. Esses princípios são amplamente adotados na comunidade Go e ajudam a criar APIs claras e robustas.

Vamos começar entendendo por que interfaces pequenas são preferíveis e como elas se relacionam com a simplicidade. Em seguida, discutiremos a orientação prática de aceitar interfaces em parâmetros e retornar tipos concretos, e finalizaremos com a composição de interfaces, que permite reutilizar comportamentos de forma elegante. Ao final, você terá uma compreensão sólida de como aplicar esses conceitos em seus próprios projetos.

Interfaces pequenas

Em Go, a filosofia de design de interfaces é radicalmente diferente de linguagens como Java ou C#. Em vez de definir grandes contratos com muitos métodos, a recomendação é criar interfaces pequenas, geralmente com um ou poucos métodos. Isso promove a simplicidade e a reutilização, pois interfaces pequenas são mais fáceis de implementar e de combinar.

Um exemplo clássico é a interface io.Reader, que contém apenas um método Read(p []byte) (n int, err error). Essa interface é implementada por inúmeros tipos em Go, desde arquivos até buffers, e é a base para a composição de streams. Se a interface tivesse muitos métodos, seria difícil implementá-la para novos tipos, limitando sua utilidade.

Interfaces pequenas também facilitam o teste e a criação de mocks. Quando uma interface tem um único método, é trivial criar uma implementação fake para testes. Considere o seguinte exemplo:

package main

import "fmt"

// Greeter é uma interface pequena com apenas um método.
type Greeter interface {
    Greet() string
}

// Person implementa Greeter.
type Person struct {
    Name string
}

func (p Person) Greet() string {
    return "Hello, " + p.Name
}

// Robot também implementa Greeter.
type Robot struct {
    Model string
}

func (r Robot) Greet() string {
    return "Beep boop, I am " + r.Model
}

func printGreeting(g Greeter) {
    fmt.Println(g.Greet())
}

func main() {
    person := Person{Name: "Alice"}
    robot := Robot{Model: "R2D2"}
    printGreeting(person)
    printGreeting(robot)
}

A interface Greeter tem apenas um método, e ambos os tipos a implementam implicitamente. Essa simplicidade permite que a função printGreeting aceite qualquer tipo que implemente Greeter, tornando o código flexível e fácil de estender.

Aceite interfaces, retorne structs

Este princípio, popularizado por Jack Lindamood, sugere que ao projetar funções e métodos, você deve aceitar parâmetros como interfaces (para permitir flexibilidade) e retornar tipos concretos (para facilitar o uso e evitar acoplamento desnecessário). A ideia é que quem chama sua função pode passar qualquer tipo que implemente a interface, enquanto quem recebe o retorno tem a garantia de um tipo específico com todos os seus campos e métodos.

Vamos analisar um exemplo. Suponha que você esteja escrevendo uma função que processa dados de diferentes fontes. Em vez de aceitar um tipo concreto como *os.File, você pode aceitar um io.Reader, permitindo que qualquer fonte que implemente Read seja usada. Por outro lado, ao retornar um objeto, é melhor retornar um struct concreto para que o chamador possa acessar campos adicionais sem precisar de type assertions.

package main

import (
    "fmt"
    "io"
    "os"
    "strings"
)

// processData aceita qualquer io.Reader e retorna um struct concreto.
type DataResult struct {
    Size int
    Content string
}

func processData(r io.Reader) DataResult {
    data, _ := io.ReadAll(r)
    return DataResult{
        Size: len(data),
        Content: string(data),
    }
}

func main() {
    // Usando um arquivo
    file, _ := os.Open("input.txt")
    result := processData(file)
    fmt.Printf("Arquivo: %d bytes, conteúdo: %s\n", result.Size, result.Content)
    file.Close()

    // Usando uma string
    result2 := processData(strings.NewReader("Hello, Go!"))
    fmt.Printf("String: %d bytes, conteúdo: %s\n", result2.Size, result2.Content)
}

Neste exemplo, processData aceita um io.Reader, então pode ser usado com arquivos, strings, buffers, etc. O retorno é um struct concreto DataResult, que contém campos úteis. Isso oferece flexibilidade na entrada e segurança no retorno.

É importante notar que esse princípio não é uma regra absoluta. Às vezes, faz sentido retornar uma interface, especialmente se você deseja esconder a implementação concreta ou se o tipo pode variar. No entanto, na maioria dos casos, retornar um struct é mais simples e direto.

Composição

Go não tem herança de classes, mas oferece composição através de structs aninhados e, para interfaces, a composição de interfaces. Uma interface pode incorporar outras interfaces, criando uma nova interface que exige todos os métodos das interfaces incorporadas. Isso permite construir abstrações maiores a partir de partes menores e reutilizáveis.

Por exemplo, a interface io.ReadWriter é composta por io.Reader e io.Writer. Qualquer tipo que implemente tanto Read quanto Write automaticamente implementa io.ReadWriter. Isso é poderoso porque você pode definir funções que exigem múltiplas capacidades sem duplicar código.

package main

import "fmt"

// Definição de interfaces pequenas
type Animal interface {
    Speak() string
}

type Mover interface {
    Move() string
}

// Composição: interface que requer ambos
type AnimalMover interface {
    Animal
    Mover
}

// Implementação concreta
type Dog struct{}

func (d Dog) Speak() string { return "Woof!" }
func (d Dog) Move() string  { return "Run" }

type Cat struct{}

func (c Cat) Speak() string { return "Meow!" }
func (c Cat) Move() string  { return "Walk" }

func describe(am AnimalMover) {
    fmt.Println(am.Speak(), "and", am.Move())
}

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

A interface AnimalMover combina Animal e Mover. Qualquer tipo que implemente os métodos Speak e Move pode ser usado onde AnimalMover é esperado. Isso promove a reutilização de interfaces pequenas e a construção de abstrações modulares.

Composição de interfaces também pode ser usada para adicionar comportamentos opcionais. Por exemplo, você pode ter uma interface Reader e uma interface Writer, e depois uma interface ReadWriter que as combina. Isso é comum na biblioteca padrão, como io.ReadWriteCloser.

Exemplos

Vamos consolidar os conceitos com um exemplo mais completo que combina interfaces pequenas, aceite interfaces/retorne structs e composição. Imagine que estamos construindo um sistema de notificação que pode enviar mensagens por diferentes canais (email, SMS, push).

package main

import (
    "fmt"
)

// Interfaces pequenas
type Notifier interface {
    Notify(message string) error
}

type Logger interface {
    Log(message string)
}

// Composição: NotifierLogger requer ambos
type NotifierLogger interface {
    Notifier
    Logger
}

// Implementações concretas
type EmailNotifier struct {
    Address string
}

func (e EmailNotifier) Notify(message string) error {
    fmt.Printf("Email para %s: %s\n", e.Address, message)
    return nil
}

func (e EmailNotifier) Log(message string) {
    fmt.Printf("[LOG] Email: %s\n", message)
}

type SMSNotifier struct {
    Phone string
}

func (s SMSNotifier) Notify(message string) error {
    fmt.Printf("SMS para %s: %s\n", s.Phone, message)
    return nil
}

func (s SMSNotifier) Log(message string) {
    fmt.Printf("[LOG] SMS: %s\n", message)
}

// Função que aceita uma interface (Notifier) e retorna um struct concreto
type NotificationResult struct {
    Channel string
    Message string
}

func sendNotification(n Notifier, message string) NotificationResult {
    err := n.Notify(message)
    if err != nil {
        return NotificationResult{Channel: "unknown", Message: "erro: " + err.Error()}
    }
    return NotificationResult{Channel: fmt.Sprintf("%T", n), Message: message}
}

func main() {
    email := EmailNotifier{Address: "alice@example.com"}
    sms := SMSNotifier{Phone: "+123456789"}

    result1 := sendNotification(email, "Bem-vinda!")
    fmt.Printf("Resultado: %+v\n", result1)

    result2 := sendNotification(sms, "Código: 1234")
    fmt.Printf("Resultado: %+v\n", result2)

    // Usando a interface composta
    var nl NotifierLogger = email
    nl.Log("Mensagem enviada")
}

Neste exemplo, temos interfaces pequenas Notifier e Logger, e uma interface composta NotifierLogger. A função sendNotification aceita um Notifier e retorna um struct concreto NotificationResult. Os tipos EmailNotifier e SMSNotifier implementam as interfaces. Isso demonstra flexibilidade, reutilização e clareza.

Boas práticas

  • Mantenha interfaces pequenas: prefira interfaces com um ou dois métodos. Isso facilita a implementação e a composição.
  • Aceite interfaces, retorne structs: use interfaces para parâmetros quando precisar de flexibilidade, mas retorne tipos concretos para que o chamador não precise fazer type assertions.
  • Componha interfaces: combine interfaces menores para criar contratos maiores, mas evite interfaces inchadas com muitos métodos.
  • Nomeie interfaces com sufixo er quando representam uma ação (ex.: Reader, Writer), mas não é obrigatório.
  • Evite interfaces que não são necessárias: não crie interfaces apenas para satisfazer um padrão; adicione-as quando houver múltiplas implementações ou necessidade de abstração.

Referências

Exercícios

  1. Exercício 1: Crie uma interface chamada Shape com um método Area() float64. Implemente a interface para os tipos Circle (com campo raio) e Rectangle (com largura e altura). Escreva uma função que aceite um Shape e imprima sua área.

✓ Resposta:
package main

import "fmt"

type Shape interface {
    Area() float64
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return 3.14159 * c.Radius * c.Radius
}

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func printArea(s Shape) {
    fmt.Printf("Área: %.2f\n", s.Area())
}

func main() {
    circle := Circle{Radius: 5}
    rect := Rectangle{Width: 3, Height: 4}
    printArea(circle)
    printArea(rect)
}
  • Exercício 2: Explique por que é preferível aceitar interfaces em parâmetros de função em vez de tipos concretos, e dê um exemplo de situação em que isso é benéfico.
  • ✓ Resposta: Aceitar interfaces em parâmetros permite que a função aceite qualquer tipo que implemente a interface, aumentando a flexibilidade e a reutilização. Por exemplo, uma função que aceita io.Reader pode ser usada com arquivos, buffers, strings, etc., sem precisar alterar a função. Isso desacopla a função da implementação específica, facilitando testes e extensibilidade.
  • Exercício 3: Defina uma interface composta ReadWriteCloser que combine io.Reader, io.Writer e io.Closer. Implemente um tipo File que tenha métodos Read, Write e Close (simulados) e demonstre que ele implementa a interface composta.
  • ✓ Resposta:
    package main
    
    import (
        "fmt"
        "io"
    )
    
    type ReadWriteCloser interface {
        io.Reader
        io.Writer
        io.Closer
    }
    
    type File struct {
        content string
    }
    
    func (f *File) Read(p []byte) (n int, err error) {
        if len(f.content) == 0 {
            return 0, io.EOF
        }
        n = copy(p, f.content)
        f.content = f.content[n:]
        return n, nil
    }
    
    func (f *File) Write(p []byte) (n int, err error) {
        f.content += string(p)
        return len(p), nil
    }
    
    func (f *File) Close() error {
        fmt.Println("Arquivo fechado")
        return nil
    }
    
    func main() {
        var rwc ReadWriteCloser = &File{}
        rwc.Write([]byte("Olá, ")
        rwc.Write([]byte("mundo!"))
        buf := make([]byte, 10)
        n, _ := rwc.Read(buf)
        fmt.Printf("Lido: %s\n", string(buf[:n]))
        rwc.Close()
    }
    
  • Exercício 4: Crie uma função que aceite uma interface Stringer (da biblioteca padrão) e retorne uma string formatada. Use essa função com diferentes tipos, como int e um struct personalizado que implementa String().
  • ✓ Resposta:
    package main
    
    import (
        "fmt"
        "strconv"
    )
    
    type Person struct {
        Name string
        Age  int
    }
    
    func (p Person) String() string {
        return fmt.Sprintf("%s (%d anos)", p.Name, p.Age)
    }
    
    func formatString(s fmt.Stringer) string {
        return "Valor: " + s.String()
    }
    
    func main() {
        num := 42
        p := Person{Name: "Bob", Age: 30}
        fmt.Println(formatString(num)) // int implementa Stringer? Não, mas podemos adaptar
        // Para int, usamos strconv.Itoa
        fmt.Println(formatString(p))
    }
    
  • Exercício 5: Discuta os trade-offs de retornar interfaces em vez de structs. Em que situações você consideraria retornar uma interface?
  • ✓ Resposta: Retornar interfaces pode ser útil quando você deseja esconder a implementação concreta (padrão Factory), permitir que o chamador dependa apenas da abstração, ou quando o tipo retornado pode variar de acordo com condições. No entanto, isso pode dificultar o uso do valor retornado, pois o chamador não tem acesso a campos ou métodos específicos sem type assertion. Portanto, a menos que haja um bom motivo, prefira retornar structs.