S.O.L.I.D.: Princípio de Segregação de Interface

Artigos - 04/Mar/2020 - por André Benjamim

Introdução

Este é o quarto artigo de uma série na qual descreveremos cada um dos princípios do SOLID – conjunto de princípios que orientam o desenvolvimento de código compreensível e fácil de manter. No artigo anterior, discorremos sobre o Princípio de Substituição de Liskov. Aqui falaremos sobre o princípio de Segregação de Interface (Interface Segregation Principle).

Interface Segregation Principle (ISP)

O Princípio de Segregação de Interface diz que interfaces específicas são melhores do que uma única interface de propósito geral.

Em programação orientada a objetos, quando falamos de interface, estamos falando do conjunto de métodos que um objeto expõe, ou seja, das maneiras como nós podemos interagir com esse objeto. Toda mensagem (ou chamada de método) que um objeto recebe constitui uma interface.

A interface funciona como um contrato: nós definimos o comportamento da interface na forma de diferentes métodos que ela possui. Cada classe que deseja compartilhar o comportamento dessa interface precisa implementar os métodos dela. Quando a classe utiliza uma interface, ela assina esse contrato dizendo que irá implementar todos os métodos dessa interface.

As interfaces são comumente utilizadas por linguagens de programação estaticamente tipadas (como Java e C#) para adicionar novos comportamentos às classes, isso é feito utilizando a palavra reservada interface.

Linguagens como Ruby ou Python, definem sua interface de forma implícita, através dos métodos que a classe implementa. Comumente nos referimos a essa propriedade como Duck Typing. A ideia por trás do Duck Typing é que o objeto é representado pelos métodos que ele possui e pelo que esses métodos fazem.

Outra forma de implementar interfaces em Ruby é utilizando herança. Nós podemos fazer com que a classe base lance exceções para cada método, de forma que a classe herdeira é obrigada a implementar esses métodos. O mesmo pode ser obtido com mixins. Dessa maneira, ao herdar uma classe ou incluir um módulo, a classe herdeira precisa implementar o método.

O ISP é violado quando uma classe é obrigada a implementar métodos que não utiliza. Por esse motivo, o ISP diz que as interfaces devem ser específicas (ou pequenas) para que as classes possam implementar somente os comportamentos necessários. Caso contrário, erros serão lançados.

Exemplo

# Simple mailer
class Mailer; end

# Mailer that can send attachments
class AttachmentMailer; end

class MailerService
  def initialize(recipient)
    @recipient = recipient
  end

  def deliver_to(message, send_attachment)
    if send_attachment
      attachment_mailer.enqueue(recipient, message, message.attachments)
    else
      mailer.deliver(recipient, message)
    end
  end

  private

  attr_reader :recipient

  def mailer
    Mailer.new
  end

  def attachment_mailer
    AttachmentMailer.new
  end
end

Nesse exemplo temos uma classe Mailer, para e-mails simples, AttachmentMailer, para lidar com e-mails que possuem anexos e MailerService, que é responsável por enviar um e-mail simples ou com anexo.

MailerService recebe um destinatário (recipient) no construtor. Ela também possui um método deliver_to que recebe uma mensagem e um boolean (send_attachment), que indica se deve ou não enviar um e-mail com anexo.

A assinatura desse método nos força a especificar na chamada do método um boolean (send_attachment) que indica se queremos enviar um e-mail com anexo ou não. Além disso, os mailers utilizam assinatura de métodos (nome e quantidade de parâmetros) distintos para o mesmo fim (enqueue e deliver).

Vejamos agora como podemos melhorar esse código.

Refatoração

class BaseMailer
  def deliver(recipient, message)
    raise NotImplementedError
  end
end

class Mailer < BaseMailer
  def deliver(recipient, message)
    # code
  end
end

class AttachmentMailer < BaseMailer
  def deliver(recipient, message)
    attachments = message.attachments
    deliver_to(recipient, message, attachments)
  end

  private

  def deliver_to(recipient, message, attachments)
    # sends email
  end
end

class MailerService
  def initialize(recipient)
    @recipient = recipient
  end

  def deliver_to(message)
    mailer.deliver(recipient, message)
  end

  def deliver_with_attachment(message)
    attachment_mailer.deliver(recipient, email)
  end

  private

  attr_reader :recipient

  def mailer
    Mailer.new
  end

  def attachment_mailer
    AttachmentMailer.new
  end
end

Primeiramente, criamos um BaseMailer para garantir que Mailer e AttachmentMailer utilizem a mesma assinatura de método. Enquanto o código de Mailer permanece inalterado, o de AttachmentMailer sofreu pequenos ajustes, mas sem alterar o comportamento prévio.

Na classe MailerService, no lugar de passar um boolean, agora podemos dizer qual método queremos utilizar: o deliver_to para o Mailer e deliver_with_attachment para AttachmentMailer. Essa separação permite que tenhamos métodos bem definidos e sem a necessidade de parâmetros extras que aumentam a complexidade do método.

Para esse exemplo, usar herança para forçar a implementação de um método funcionou, mas a medida que um projeto evolui, talvez seja melhor rever se essa herança está aumentando o acoplamento das classes.

Essa refatoração deixou os métodos duplicados e dependentes das classes Mailer e AttachmentMailer. No próximo artigo veremos como o Princípio de Inversão de Dependência pode resolver esse problema.

Considerações finais

Em resumo o ISP propõe que, ao definirmos uma interface, ela tenha poucos métodos e que sejam específicos dos escopos que estamos trabalhando.

Em Ruby temos basicamente duas maneiras de implementar interfaces: por meio de herança ou de mixins. Uma alternativa seria simplesmente implementar um método e torcer para que as outras classes também o implementem.

Para forçar a implementação podemos definir os métodos de forma que lancem exceções nas classes bases (herança) ou nos módulos (mixins). Se não utilizarmos as opções anteriores, podemos adicionar testes compartilhados (shared_examples do rspec) e adicioná-los aos testes unitários das classes que queremos que implementem a interface. Dessa forma conseguimos garantir que todos os métodos serão implementados.

Ao separarmos em pequenas interfaces, conseguimos reaproveitar parte do código e utilizar só o que é realmente necessário em cada caso. Isso torna nossas classes mais coesas, pois temos comportamentos e responsabilidades bem definidas.

Se você estiver interessado em ler mais sobre os princípios de S.O.L.I.D., recomendamos os demais artigos dessa série:

Referências

Videos

Foto de perfil do autor
André Benjamim

Dev na Rebase

Desenvolvedor Ruby formado em Ciência da Computação. Gosta de falar de Design de Código, Arquitetura e reclamar do calor