Uma interface não deve obrigar quem a implementa a carregar método que não faz sentido no contexto dela.
O problema
Um contrato de integração raramente nasce grande. Ele engorda: cada coisa nova que o sistema do outro lado passa a expor vira mais um método ali dentro, e ninguém percebe o momento em que ele passou do ponto.
O custo não aparece quando o contrato é escrito. Aparece quando alguém precisa substituí-lo, porque, para ocupar o lugar do que já existe, a implementação nova tem que dar conta de tudo, inclusive das partes que não têm nada a ver com o que se queria trocar.
O caso que deu origem ao princípio
Robert C. Martin viveu exatamente isso consultando para a Xerox, num sistema de impressora novo. Existia uma classe Job que fazia tudo: imprimir, grampear, enviar fax. Cada função nova virava mais um método ali, e como todo mundo dependia de Job, qualquer mexida obrigava a recompilar e reimplantar o sistema inteiro: uma mudança de uma linha custava um ciclo de uma hora.
A solução foi criar interfaces por cliente (PrintJob, StapleJob), com Job implementando todas. Quem imprimia passou a depender só de PrintJob.
O enunciado que ele publicou pouco depois, no The C++ Report:
Clientes não devem ser forçados a depender de interfaces que não usam.
E ele tem dois lados, que quase sempre são confundidos:
- Protege quem implementa: ninguém é obrigado a escrever método que não sabe cumprir.
- Protege quem consome: ninguém carrega dependência de método que nunca chama.
O segundo é o enunciado literal. O primeiro é o que mais dói no dia a dia.
A ideia
O ISP não é sobre a quantidade de métodos. Interface pequena não é um objetivo em si.
O que eu uso como régua é separar os métodos pelo contexto deles, e pelo ganho que a separação traz. Se não tem ganho, não separa.
Quando separar não ganha nada
Pega um adapter de token, que cria e valida token. Não faz sentido virar duas classes.
Os dois métodos são do mesmo contexto (tokenização) e, mais importante que isso: qualquer implementação vai ter os dois de qualquer jeito. Quem cria token valida token. Separar aqui não livra ninguém de implementar nada, só espalha em dois arquivos o que sempre anda junto.
Esse é o teste que eu acho mais honesto: se toda implementação vai acabar implementando as duas partes, a separação não te deu nada.
O critério
É um trade-off, e o que funciona pra mim é isto:
- Mapear o propósito da interface antes de implementar. Saber para que ela existe e quem vai consumir, em vez de deixar ela nascer do que a classe por acaso sabe fazer.
- Revalidar quando ela crescer. Contrato grande raramente nasce grande, ele engorda. Quando isso acontecer, olhar de novo e avaliar se vale quebrar em implementações menores.
Não dá para acertar o corte de primeira, e tudo bem. O erro não é começar com uma interface que depois precisa ser dividida, é nunca voltar para dividir.
Interface também é conjunto
O post sobre LSP começa dizendo que tipo é conjunto. Isso continua valendo aqui, e explica direitinho por que um contrato inchado fica difícil de substituir.
Uma interface é o conjunto dos objetos que a satisfazem. E a gangorra reaparece, lida do outro lado:
Quanto mais o contrato exige, menos objetos cabem nele.
Um contrato com três métodos aceita muita coisa. Um contrato com trinta aceita quase nada. É essa a razão de fundo pela qual trocar uma integração inchada dói: o contrato engordou tanto que o conjunto de implementações possíveis encolheu até caber uma só, a que já estava lá.
E tem uma peça que a herança não oferece: um objeto pode pertencer a vários conjuntos ao mesmo tempo. Herança simples obriga a escolher uma linhagem; contratos separados deixam o mesmo objeto ser Animal e Nadante de uma vez. Implementar várias interfaces é interseção de conjuntos, e é isso que torna o ISP possível onde a hierarquia trava.
Como fica
O exemplo é o mesmo Animal do post sobre LSP, resolvido pelo outro lado.
Antes: um contrato só, com tudo que algum animal faz
interface Animal {
nome: string;
andar(): string;
nadar(): string;
voar(): string;
}
class Peixe implements Animal {
nome: string;
// Duas das três capacidades são impossíveis. O contrato exige assim mesmo.
andar(): string {
throw new Error("peixe não anda");
}
nadar(): string {
return "nadando";
}
voar(): string {
throw new Error("peixe não voa");
}
}
// Quem organiza a corrida só precisa de andar(), mas recebe o contrato inteiro,
// e não tem como saber quem realmente anda antes de chamar e quebrar.
function corrida(participantes: Animal[]): string[] {
return participantes.map((animal) => `${animal.nome} ${animal.andar()}`);
}Depois: cada capacidade no seu contrato
interface Animal {
nome: string;
}
interface Andante {
andar(): string;
}
interface Nadante {
nadar(): string;
}
interface Voador {
voar(): string;
}
class Cachorro implements Animal, Andante, Nadante { /* anda e nada */ }
class Peixe implements Animal, Nadante { /* só nada */ }
class Passaro implements Animal, Andante, Voador { /* anda e voa */ }
// `&` é interseção: precisa ser as duas coisas ao mesmo tempo.
// Passar um Peixe aqui não compila: o erro saiu do runtime e virou erro de tipo.
function corrida(participantes: (Animal & Andante)[]): string[] {
return participantes.map((animal) => `${animal.nome} ${animal.andar()}`);
}▸ Exemplo completo e executável
Em outras linguagens: o mesmo exemplo em Python e Java, com saída idêntica. Em Python os contratos são
Protocol, satisfeitos estruturalmente: a classe não declara nada, basta ter os métodos. Em Java a interseção vira um bound genérico:<T extends Animal & Andante>.
Fatiar ou generalizar?
Vale comparar com o que o post sobre LSP fez com esse mesmo Animal. Lá, a correção foi generalizar: andar() virou mover(), que é verdade para todo o conjunto. Aqui a correção é fatiar: cada capacidade vira um contrato à parte.
As duas são legítimas, e o que decide é a forma do problema:
- Generalizar serve quando todos fazem a mesma coisa de jeitos diferentes. Todo animal se move, o que muda é o como. Você ganha o polimorfismo: trata todo mundo pela mesma porta.
- Fatiar serve quando as capacidades se cruzam. Cachorro anda e nada, pássaro anda e voa, peixe só nada. Não existe hierarquia que dê conta disso, porque não é uma árvore, é um grid. Você ganha precisão: o tipo passa a dizer exatamente o que aquele objeto faz.
O preço de fatiar é mais tipos para manter, e a perda da chamada uniforme sobre todos.
Os sinais de que o contrato engordou
Como perceber a violação sem teoria nenhuma:
UnsupportedOperationException,NotImplementedError, método vazio, método que devolvenullsó para cumprir tabela. Alguém foi obrigado a assinar o que não cumpre.- O mock do teste. Se para testar uma chamada você precisa stubar doze métodos, o contrato não é de ninguém. É o detector mais honesto que existe, porque o teste é o cliente mais exigente que você tem.
- Ripple de build. Mexeu na interface e recompilou módulo que não tem nada a ver, a dor original da Xerox.
- A hora de trocar. Se substituir a implementação assusta, o contrato está exigindo demais.
Quando segregar demais
O erro na direção contrária é mais comum hoje do que o contrato gordo, e o critério lá de cima já resolve os três casos: se não tem ganho, não separa.
- Uma interface por método, aplicada mecanicamente. Vira indireção sem benefício.
IFoo/FooImplcom uma implementação só, que nunca terá outra. Se a interface tem exatamente os métodos públicos da única classe que a implementa, ela não segregou nada, é cerimônia.- Segregar antes do segundo cliente. O corte certo é descoberto, não previsto. Com um consumidor só, você não tem informação para saber onde cortar, e vai acertar por sorte.
Princípios relacionados
- Single Responsibility (SRP): o SRP olha para o que a classe faz e separa responsabilidades; o ISP olha para o que a classe promete e separa o contrato pelo ponto de vista de quem consome. Uma classe pode ter uma responsabilidade só e ainda expor um contrato gordo demais para um cliente.
- Liskov Substitution (LSP): os dois atacam a mesma promessa falsa por lados opostos. O LSP conserta generalizando, até a promessa ser verdade para o conjunto inteiro; o ISP conserta fatiando, até cada promessa sobrar só para quem a cumpre.
Referências
- Robert C. Martin. The Interface Segregation Principle (PDF), The C++ Report, 1996. O artigo que formula o princípio, na coluna "Engineering Notebook". Vale saber antes de abrir: os exemplos dele são
Door/TimedDoore um caixa eletrônico, o caso da Xerox não está aqui. - Robert C. Martin. Agile Software Development, Principles, Patterns, and Practices, Prentice Hall, 2002. É onde ele conta o caso da Xerox e da classe
Jobque deu origem ao princípio.