Nesta quinta parte de minhas reflexões, seguimos com a letra "I", obedecendo a ordem proposta pelo acrônimo SOLID. Trabalharei com TypeScript nos exemplos.
Neste artigo
Interface Segregation Principle
Exemplo abstrato
Exemplo técnico (Front-End)
Exemplo técnico (Back-End)
Exemplo pessoal
Exemplo funcional
As aplicabilidades
Reflexões finais
Interface Segregation Principle
Um ponto de partida muito importante é o fato de que este princípio é o único princípio focado em interfaces, e não classes. Espera-se que o leitor entenda essa diferença, mas um resumo muito prático é: interfaces compõe aquilo que a classe deve se encarregar em implementar.
O Interface Segregation Principle (ou Princípio da Segregação de Interface) propõe que uma classe não deve depender de métodos dos quais não precisa, e que deve-se optar por múltiplas interfaces ao invés de uma única interface com múltiplas responsabilidades.
O mais interessante é o quanto este princípio se encaixa no tema do primeiro princípio, o de Single Responsibility. Ambos trazem essa visão de segregação de responsabilidades, e direcionam o desenvolvimento do sistema aos propósitos de garantir a escalabilidade das classes. Uma classe ou interface com muitas responsabilidades é, naturalmente, mais complicada de lidar, pois uma alteração indevida pode gerar muitos efeitos colaterais indesejados.
Vamos entender, com exemplos, como podemos identificar esse princípio e aplicá-lo.
Exemplo abstrato
Continuemos com nosso exemplo de biblioteca. Dessa vez, imagine que a biblioteca possui não somente livros, mas DVDs e Blu-Rays de filmes e séries também. Bom, nesse cenário:
- Cada item da biblioteca deve ser mapeado;
- Gostaríamos de saber o nome de cada item através de um método;
- Para livros, gostaríamos de saber a quantidade de páginas;
- Para filmes e séries, gostaríamos de saber a duração;
🔴 Implementação Incorreta
// Imaginemos uma interface que solicita a implementação de três métodos.
interface LibraryItem {
getName(): string; // Tanto para livros quanto para séries e filmes, queremos saber o nome do item.
getPages(): number; // Somente para livros, quantas páginas possuem.
getDuration(): number; // Somente para séries e filmes, qual a duração em minutos.
}
// Agora, vamos criar nossa classe Book, implementando a interface LibraryItem.
// Isso irá nos obrigar a obedecer o que a LibraryItem contém. Vamos acompanhando.
class Book implements LibraryItem {
constructor(private title: string, private pages: number) {}
// Sem problemas nesse método.
getName() {
return this.title;
}
// Sem problemas nesse método.
getPages(): number {
return this.pages;
}
getDuration(): number {
// VIOLAÇÃO DO PRINCÍPIO: O método getDuration, apesar de pertencer a itens de biblioteca, não faz sentido
// no contexto de livros, e não será implementado. Portanto, os livros estão sendo obrigados a depender e
// implementar um método do qual não se utilizam.
throw new Error("Livros não possuem duração em minutos");
}
}
class DVD implements LibraryItem {
constructor(private title: string, private duration: number) {}
// Sem problemas nesse método.
getName(): string {
return this.title;
}
// VIOLAÇÃO DO PRINCÍPIO: Mesma obsevação do item acima, só que agora da visão de filmes e séries, que não
// possuem quantidade de páginas, mas são obrigados a implementar.
getPages(): number {
throw new Error("DVDs não possuem quantidade de páginas");
}
// Sem problemas nese método.
getDuration(): number {
return this.duration;
}
}
🟢 Implementação Correta
// É preferível que segreguemos as interfaces. É melhor ter múltiplas, cada qual com sua responsabilidade,
// do que uma única que força as classes a implementarem métodos dos quais não precisam.
interface LibraryItem {
getName(): string; // Método em comum para todos.
}
interface BookItem {
getPages(): number; // Método específico para livro.
}
interface DVDItem {
getDuration(): number; // Método específico para DVDs.
}
// Agora, cada classe implementa somente o que utiliza.
class Book implements LibraryItem, BookItem {
constructor(private title: string, private pages: number) {}
getName() {
return this.title;
}
getPages(): number {
return this.pages;
}
}
class DVD implements LibraryItem, DVDItem {
constructor(private title: string, private duration: number) {}
getName(): string {
return this.title;
}
getDuration(): number {
return this.duration;
}
}
Exemplo técnico (Front-End)
Suponhamos que temos 3 tipos de botões na nossa aplicação: PrimaryButton, IconButton e ToggleButton. Se todas dependerem de uma única interface mestra, começamos a encontrar problemas.
🔴 Implementação Incorreta
// Interface genérica demais para os diversos tipos de botões existentes.
interface Button {
render(): void; // Método para renderizar o botão.
setLabel(label: string): void; // Método para definir a label do botão.
setIcon(icon: string): void; // Método para associar o ícone ao botão.
toggle(): void; // Método para botões toggle, de ligar/desligar.
}
class PrimaryButton implements Button {
constructor(private label: string) {}
render(): void {
console.log("Renderizando o botão...", this.label);
}
setLabel(label: string): void {
this.label = label;
}
// VIOLAÇÃO DO PRINCÍPIO: PrimaryButton não tem suporte para ícones, mas é obrigado a implementar o método.
setIcon(icon: string): void {
throw new Error("Este botão não possui suporte para ícones");
}
// VIOLAÇÃO DO PRINCÍPIO: Mesma observação do exemplo acima.
toggle(): void {
throw new Error("Este botão não possui suporte para toggle");
}
}
// VIOLAÇÃO DO PRINCÍPIO: Abaixo, temos mais duas classes, IconButton e ToggleButton, que exemplificam o contrário do PrimaryButton.
// Cada uma implementa seu método respectivo, setIcon e toggle, mas também são obrigadas a implementar métodos que
// não utilizam.
class IconButton implements Button {
constructor(private label: string, private icon: string) {}
render(): void {
console.log("Renderizando o botão...", this.label, this.icon);
}
setLabel(label: string): void {
this.label = label;
}
setIcon(icon: string): void {
this.icon = icon;
}
toggle(): void {
throw new Error("Este botão não possui suporte para toggle");
}
}
class ToggleButton implements Button {
constructor(private label: string, private state: boolean) {}
render(): void {
console.log("Renderizando o botão...", this.label, this.state);
}
setLabel(label: string): void {
this.label = label;
}
setIcon(icon: string): void {
throw new Error("Este botão não possui suporte para ícones");
}
toggle(): void {
this.state = !this.state;
}
}
🟢 Implementação Correta
// A simplicidade e elegância da solução é segregar em múltiplas interfaces, unificando na inteface
// Button aquilo que for realmente genérico.
interface Button {
render(): void;
setLabel(label: string): void;
}
interface WithIcon {
setIcon(icon: string): void;
}
interface WithToggle {
toggle(): void;
}
// Classes agora implementam somente o que precisam.
class PrimaryButton implements Button {
constructor(private label: string) {}
render(): void {
console.log("Renderizando o botão...", this.label);
}
setLabel(label: string): void {
this.label = label;
}
}
class IconButton implements Button, WithIcon {
constructor(private label: string, private icon: string) {}
render(): void {
console.log("Renderizando o botão...", this.label, this.icon);
}
setLabel(label: string): void {
this.label = label;
}
setIcon(icon: string): void {
this.icon = icon;
}
}
class ToggleButton implements Button, WithToggle {
constructor(private label: string, private state: boolean) {}
render(): void {
console.log("Renderizando o botão...", this.label, this.state);
}
setLabel(label: string): void {
this.label = label;
}
toggle(): void {
this.state = !this.state;
}
}
Exemplo técnico (Back-End)
Vamos partir de um princípio de que é possível executar transactions em bancos de dados relacionais, mas não em bancos de dados não-relacionais. Sabemos que isso não é uma verdade absoluta (depende muito do vendor e do modelo de armazenamento), mas para sermos didáticos, vamos partir deste princípio.
🔴 Implementação Incorreta
// Interface genérica para bancos de dados, implementando conexões, queries e transactions.
interface Database {
connect(): void;
disconnect(): void;
runQuery(query: string): unknown;
startTransaction(): void;
commitTransaction(): void;
rollbackTransaction(): void;
}
// Para bancos de dados relacionais, todas as implementações funcionam.
class RelationalDatabase implements Database {
connect(): void {
console.log("Conectado com sucesso");
}
disconnect(): void {
console.log("Desconectado com sucesso");
}
runQuery(query: string): unknown {
console.log(`Executando query: ${query}`);
return { ... };
}
startTransaction(): void {
console.log("Transaction - Iniciada");
}
commitTransaction(): void {
console.log("Transaction - Encerrada com Commit");
}
rollbackTransaction(): void {
console.log("Transaction - Encerrada com Rollback");
}
}
class NonRelationalDatabase implements Database {
connect(): void {
console.log("Conectado com sucesso");
}
disconnect(): void {
console.log("Desconectado com sucesso");
}
runQuery(query: string): unknown {
console.log(`Executando query: ${query}`);
return { ... };
}
// VIOLAÇÃO DO PRINCÍPIO: Se transações não funcionam para todo tipo de banco de dados, por que
// faz parte da interface genérica, forçando as classes a implementá-las?
startTransaction(): void {
throw new Error("Bancos de dados não-relacionais não suportam Transactions")
}
commitTransaction(): void {
throw new Error("Bancos de dados não-relacionais não suportam Transactions")
}
rollbackTransaction(): void {
throw new Error("Bancos de dados não-relacionais não suportam Transactions")
}
}
🟢 Implementação Correta
// Ainda podemos ter nossa interface genérica, mas segregamos o que é específico.
interface Database {
connect(): void;
disconnect(): void;
}
interface DatabaseQueries {
runQuery(query: string): unknown;
}
interface DatabaseTransactions {
startTransaction(): void;
commitTransaction(): void;
rollbackTransaction(): void;
}
class RelationalDatabase implements Database, DatabaseQueries, DatabaseTransactions {
connect(): void {
console.log("Conectado com sucesso");
}
disconnect(): void {
console.log("Desconectado com sucesso");
}
runQuery(query: string): unknown {
console.log(`Executando query: ${query}`);
return { ... };
}
startTransaction(): void {
console.log("Transaction - Iniciada");
}
commitTransaction(): void {
console.log("Transaction - Encerrada com Commit");
}
rollbackTransaction(): void {
console.log("Transaction - Encerrada com Rollback");
}
}
// Agora, nosso banco de dados não-relacional implementa somente o que faz sentido.
class NonRelationalDatabase implements Database, DatabaseQueries {
connect(): void {
console.log("Conectado com sucesso");
}
disconnect(): void {
console.log("Desconectado com sucesso");
}
runQuery(query: string): unknown {
console.log(`Executando query: ${query}`);
return { ... };
}
}
Exemplo pessoal
Em Super Mario Kart, existem alguns itens que são específicos de alguns personagens - comportamento que pode ser questionável, mas este não é o foco do artigo. Neste cenário, como podemos implementar as interfaces desses itens e aderir ao princípio?
🔴 Implementação Incorreta
interface Items {
throwShell(): void; // Item que qualquer personagem pode ter.
throwFire(): void; // Item exclusivo do Bowser, sendo uma bola de fogo.
throwMushroom(): void; // Item exclusivo da Peach (na época Princess Toadstool) e do Toad.
}
// VIOLAÇÃO DO PRINCÍPIO: Encontraremos diversos erros em cada um dos cenários específicos.
class Mario implements Items {
throwShell(): void {
console.log('Lançando o item "Casco"');
}
throwFire(): void {
throw new Error("Mario não possui acesso a esse item.");
}
throwMushroom(): void {
throw new Error("Mario não possui acesso a esse item.");
}
}
class Bowser implements Items {
throwShell(): void {
console.log('Lançando o item "Casco"');
}
throwFire(): void {
console.log('Lançando o item "Fogo"');
}
throwMushroom(): void {
throw new Error("Bowser não possui acesso a esse item.");
}
}
class Princess implements Items {
throwShell(): void {
console.log('Lançando o item "Casco"');
}
throwFire(): void {
throw new Error("Princess não possui acesso a esse item.");
}
throwMushroom(): void {
console.log('Lançando o item "Cogumelo"');
}
}
🟢 Implementação Correta
// Podemos quebrar nossa interface em várias, separando o genérico do específico.
interface CommonItems {
throwShell(): void;
}
interface FireSpecialItems {
throwFire(): void;
}
interface MushroomSpecialItems {
throwMushroom(): void;
}
Exemplo funcional
Imaginemos a interface de um processador de dados. Gostaríamos de interpretar tanto arquivos JSON quanto arquivos CSV, mas cada um com sua especificidade. Se implementarmos as opções de cada um deles de forma unificada, podemos ferir o princípio.
🔴 Implementação Incorreta
// Nessa interface, definimos uma função que processa dados.
type DataProcessor = (
data: string, // Os dados que serão processados.
jsonToObject: boolean, // Somente para JSON, indicando se deseja converter para objeto.
csvSeparator: string // Somente para CSV, indicando qual o separador de colunas.
) => string[];
// VIOLAÇÃO DO PRINCÍPIO: Toda função definida com base nessa interface ficará dependente de
// parâmetros dos quais, talvez, não precise. Um processador só de JSON, ou só de CSV, precisará
// obrigatoriamente implementar aqueles parâmetros.
const jsonProcessor: DataProcessor = (data, jsonToObject, csvSeparator) => {
let result = validateJSON(data);
if (jsonToObject) {
result = transformJSON(result);
}
return result;
};
const csvProcessor: DataProcessor = (data, jsonToObject, csvSeparator) => {
let result = validateJSON(data);
result = transformCSV(result, csvSeparator);
return result;
};
// Veja que as chamadas de função são obrigadas a chamar parâmetros desnecessários.
const json = jsonProcessor(jsonData, true, false);
const csv = csvProcessor(csvData, false, ",");
🟢 Implementação Correta
// Com programação funcional, existem diversas formas de se abordar essa solução.
// Aqui, optei por segregar as interfaces em objetos de opção.
type DataProcessorJSONOptions = {
toObject: boolean;
};
type DataProcessorCSVOptions = {
separator: string;
};
type DataProcessor = (
data: string,
// Agora, o segundo parâmetro possui opções para cada tipo, sendo opcional.
options: {
json?: DataProcessorJSONOptions;
csv?: DataProcessorCSVOptions;
}
) => string[];
const jsonProcessor: DataProcessor = (data, { json }) => {
let result = validateJSON(data);
if (json?.toObject) {
result = transformJSON(result);
}
return result;
};
const csvProcessor: DataProcessor = (data, { csv }) => {
let result = validateJSON(data);
result = transformCSV(result, csv?.separator);
return result;
};
// Como comentei, existem outras formas de solucionar este problema.
// Esta seria uma visão básica, utilizando parâmetros opcionais.
// Outra forma seria usar Union Types ou algo do gênero.
const json = jsonProcessor(jsonData, { json: { toObject: true } });
const csv = csvProcessor(csvData, { csv: { separator: "," } });
As aplicabilidades
Por ser o único princípio aplicado em interfaces, pessoalmente, vejo um potencial muito interessante. É possível encontrarmos situações adversas a esse princípio em classes muito complexas, ou até mesmo como resultado da falta de aplicação dos demais princípios do SOLID - como tenho me referido, principalmente o primeiro.
O exercício de se definir as interfaces das classes antes de suas efetivas implementações pode ajudar na identificação desses problemas. É importante se questionar sobre o quão genérica a implementação seria, e o quão reaproveitável os métodos podem ser entre diferentes cenários. Hoje em dia, também podemos trabalhar muito com métodos opcionais, o que pode ser uma salvaguarda para cenários assim, mas pode acabar gerando a necessidade de "ginástica de tipagem" para checar se o método foi implementado ou não.
Reflexões finais
Existe uma grande discussão sobre a generalização em diversos aspectos da Engenharia de Software, e isso é definitivamente essencial - o problema é o excesso, e também quando não fica claro onde demarcar a linha de limite. Se tratarmos tudo como genérico, então o que é específico acabará afetando todos os outros cenários; do contrário, se tudo for específico, teremos um amontoado gigantesco de classes para dar manutenção, e perdemos a noção de sua correta existência.
O ideal é utilizarmos este princípio como ponto de base para construção de novas classes com diversos propósitos: Quais métodos terei? Em quais cenários eles serão aplicados? Será que estou forçando classes a implementarem algo que não lhes será útil? Partindo de tais pressupostos, fica um pouco mais fácil de identificar a necessidade do princípio, que simplesmente propõe que devemos cuidar para não criarmos código apenas por obrigação, mas sim para um propósito implementado.
Top comments (0)