Design com interfaces
Esta aula explora o design de interfaces em Go, focando em interfaces pequenas, o princípio 'aceite interfaces, retorne structs', e a composição de interfaces. Inclui exemplos práticos, referências e exercícios para fixar o conteúdo.
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
- Effective Go - Interfaces
- Go Blog: Constants (não diretamente sobre interfaces, mas útil)
- Go Language Specification - Interface Types
- Dave Cheney: SOLID Go Design
- Ardan Labs: Methods, Interfaces and Embedded Types
- Go Code Review Comments - Interfaces
Exercícios
- Exercício 1: Crie uma interface chamada
Shapecom um métodoArea() float64. Implemente a interface para os tiposCircle(com campo raio) eRectangle(com largura e altura). Escreva uma função que aceite umShapee imprima sua área.
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)
}
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.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.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()
}
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().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))
}