Princípios SOLID
Olá, seja bem vindo! Desta vez escrevo sobre um conjunto de princípios conhecidos como SOLID, que podem ser aplicados ao design de uma solução orientada a objetos para a escrita de um código-fonte de qualidade.
O nome SOLID na verdade tem cada letra como inicial de cada princípio. Por exemplo S.O.L.I.D. Veja abaixo cada sigla:
- Single Responsability Principle: Princípio de responsabilidade única;
- Open-Closed Principle: Princípio do aberto-fechado;
- Liskov Subistitution Principle: Princípio da substituição de liskov;
- Interface Segregation Principle: Princípio da segregação de interface;
- Dependence Inversion Principle: Princípio da inversão de dependência;
Princípio da Responsabilidade Única
O Single responsability Principle ou (Princípio da Responsabilidade Única) visa que cada classe tenha uma única responsabilidade, ao invés de várias. Veja o código abaixo:
class Conta {
private String titular;
private double saldo;
public void debita( double valor ) {
this.saldo -= valor;
System.out.println( "Operacao de debito!" );
System.out.println( "Valor debitado: "+valor );
System.out.println( "Saldo atual: "+this.saldo );
}
public void credita( double valor ) {
this.saldo += valor;
System.out.println( "Operacao de credito!" );
System.out.println( "Valor creditado: "+valor );
System.out.println( "Saldo atual: "+this.saldo );
}
public void transfere( Conta conta, double valor ) {
this.debita( valor );
conta.credita( valor );
System.out.println( "Operacao de transferencia!" );
System.out.println( "Valor transferido: "+valor );
System.out.println( "Saldo atual: "+this.saldo );
}
public double getSaldo() {
return this.saldo;
}
public String getTitular() {
return this.titular;
}
public void setTitular( String titularNome ) {
this.titular = titularNome;
}
}
public class Main {
public static void main( String[] args ) {
Conta conta1 = new Conta();
Conta conta2 = new Conta();
conta1.setTitular( "Maria" );
conta2.setTitular( "Joao" );
conta1.credita( 500 );
conta1.debita( 100 );
conta1.transfere( conta2, 200 );
}
}
Ao analisar o código acima, se pode, facilmente, perceber que a classe Conta tem a responsabilidade de realizar operações bancárias e de imprimir o resultado de cada operação. O ideal, seria separar as responsabilidades de impressão das mensagens e operações bancárias para classes diferentes. Outra razão para necessidade de separar as responsabilidades é que os métodos "debita" e "credita", são chamados no método de transferência. Logo, com a realização de uma operação de trabsferência, são impressas também as mensagens de resultado das operações de débito e de crédito. Abaixo é mostrada uma solução para separar as responsabilidades:
class Conta {
private String titular;
private double saldo;
public void debita( double valor ) {
this.saldo -= valor;
}
public void credita( double valor ) {
this.saldo += valor;
}
public void transfere( Conta conta, double valor ) {
this.debita( valor );
conta.credita( valor );
}
public double getSaldo() {
return this.saldo;
}
public String getTitular() {
return this.titular;
}
public void setTitular( String titularNome ) {
this.titular = titularNome;
}
}
class Impressora {
public void imprimeDebitoResult( double valor, double saldo ) {
System.out.println( "Operacao de debito!" );
System.out.println( "Valor debitado: "+valor );
System.out.println( "Saldo atual: "+saldo );
}
public void imprimeCreditoResult( double valor, double saldo ) {
System.out.println( "Operacao de credito!" );
System.out.println( "Valor creditado: "+valor );
System.out.println( "Saldo atual: "+saldo );
}
public void imprimeTransferenciaResult( double valor, double saldo ) {
System.out.println( "Operacao de transferencia!" );
System.out.println( "Valor transferido: "+valor );
System.out.println( "Saldo atual: "+saldo );
}
}
public class Main {
public static void main( String[] args ) {
Impressora impressora = new Impressora();
Conta conta1 = new Conta();
Conta conta2 = new Conta();
conta1.setTitular( "Maria" );
conta2.setTitular( "Joao" );
conta1.credita( 500 );
impressora.imprimeCreditoResult( 500, conta1.getSaldo() );
conta1.debita( 100 );
impressora.imprimeDebitoResult( 100, conta1.getSaldo() );
conta1.transfere( conta2, 200 );
impressora.imprimeTransferenciaResult( 200, conta1.getSaldo() );
}
}
Perceba no código acima que, agora, a classe Conta passa a ter apenas a responsabilidade de realizar operações bancárias, e a classe Impressora, fica com a responsabilidade de imprimir as mensagens. Logo, é resolvido o problema de imprimir as mensagens de débito e crédito na operação de transferência.
Claro, perceba também que a classe não verifica a exceção de o saldo ser menor que o valor sacado. Isso porque o objetivo aqui é apenas exemplificar a violação e o respeito ao princípio.
Principio do Aberto-Fechado
Agora vamos ao Open-Closed Principle ou (Princípio do aberto-fechado). Esse princípio trata de classes onde se deseje que mudanças no comportamento da classe não sejam feitas na classe, mas, através de uma estratégia envolvendo abstração.
O Aberto-Fechado pode se referir a classe estar fechada para implementação e aberta para extensão. Veja primeiramente a classe a baixo que viola o princípio:
class MatematicaPrinter {
public void imprimeResultado( double valor, String op ) {
double resultado = 0;
if ( op.equals( "seno" ) ) {
resultado = Math.sin( valor );
} else if ( op.equals( "cosseno" ) ) {
resultado = Math.cos( valor );
} else if ( op.equals( "quadrado" ) ) {
resultado = valor * valor;
}
System.out.println( "O resultado eh: "+resultado );
}
}
public class Principal {
public static void main( String[] args ) {
String operador = "quadrado";
Matematica matematica = new Matematica();
matematica.imprimeResultado( 4, operador ); // Isto imprime: O resultado eh: 16
}
}
Perceba que para adicionar o cálculo da função para cálculo da hipotenusa em função dos dois catetos de um triângulo retángulo, é necessário um novo if no método "calcula". Isso pode ser uma boa para sistemas com poucas alterações nesse método. Mas, viola o princípio do Aberto-Fechado, dado que necessita de alterações na classe MatematicaPrinter. Entretanto, existe um jeito de evitar que o suporte a novos cálculos altere a classe MatematicaPrinter. Então, vamos refatorar! Veja abaixo:
interface OperadorMatematico {
public double calcula();
}
class SenoOperador implements OperadorMatematico {
private double valor;
public SenoOperador( double valor ) {
this.valor = valor
}
public double calcula() {
return Math.sin( valor );
}
}
class CossenoOperador implements OperadorMatematico {
private double valor;
public CossenoOperador( double valor ) {
this.valor = valor
}
public double calcula() {
return Math.cos( valor );
}
}
class QuadradoOperador implements OperadorMatematico {
private double valor;
public QuadradoOperador( double valor ) {
this.valor = valor;
}
public double calcula() {
return valor * valor;
}
}
class HipotenusaOperador implements OperadorMatematico {
private double catetoOposto;
private double catetoAdjacente;
public HipotenusaOperador( double catetoOposto, double catetoAdjacente ) {
this.catetoOposto = catetoOposto;
this.catetoAdjacente = catetoAdjacente;
}
public double calcula() {
return Math.sqrt( Math.pow( catetoOposto, 2 ) + Math.pow( catetoAdjacente, 2 ) );
}
}
class MatematicaPrinter {
public void imprimeResultado( OperadorMatematico op ) {
double resultado = op.calcula();
System.out.println( "O resultado eh: "+resultado );
}
}
public class Principal {
public static void main( String[] args ) {
HipotenusaOperador operador = new HipotenusaOperador( 4, 3 );
Matematica matematica = new Matematica();
matematica.imprimeResultado( operador );
}
}
Perceba no código acima que agora, ao invés de precisar alterar a classe MatematicaPrinter para adicionar o suporte ao cálculo da hipotenusa, foi necessário apenas criar a nova classe e utilizá-la conforme está no método "main" da classe Principal.
Se quiser imprimir o resultado do quadrado de um número, basta fazer conforme abaixo:
public class Principal {
public static void main( String[] args ) {
QuadradoOperador operador = new QuadradoOperador( 4 );
Matematica matematica = new Matematica();
matematica.imprimeResultado( operador );
}
}
O mesmo vale para as classes SenoOperator e CossenoOperator.
Agora, para imprimir o resultado, basta chamar o método "imprimeResultado" da classe Matemática, passando como parâmetro a instância do OperadorMatematico que desejar!
Principio da Substituição de Liskov
O Liskov Substitution Principle (ou Princípio da Substituição de Liskov) trata de se tentar garantir que uma classe derivada deva ser substituível pela classe base sem alterar o fluxo principal do programa. Por exemplo, sem gerar erros ou exceções. Veja abaixo um exemplo que viola o princípio:
class Retangulo {
private double base;
private double altura;
public double calculaArea() {
return base * altura;
}
public void setBase( double base ) {
this.base = base;
}
public void setAltura( double altura ) {
this.altura = altura;
}
}
class Quadrado extends Retangulo {
public void setBase( double base ) {
super.setBase( base );
super.setAltura( base );
}
public void setAltura( double altura ) {
super.setBase( altura );
super.setAltura( altura );
}
}
class Testador {
public void teste( Retangulo retangulo ) {
retangulo.setBase( 10 );
retangulo.setAltura( 20 );
double area = retangulo.calculaArea();
if ( area != 200 )
throw new RuntimeException( "Erro no cálculo da área!" );
System.out.println( "Ok" );
}
}
public class Principal {
public static void main( String[] args ) {
Retangulo retangulo = new Retangulo();
Quadrado quadrado = new Quadrado();
Testador testador = new Testador();
testador.teste( retangulo ); // Ok
testador.teste( quadrado ); // lança exceção
}
}
Perceba no código acima que, em Quadrado, a chamada de qualquer um desses métodos seta, tanto a base quanto a altura, com o mesmo valor. Dado isto, analise o código da classe Testador. Perceba que o método "teste" dela recebe uma instância de Retangulo como parâmetro. No entanto, se uma instância de Quadrado for passada como parâmetro, uma exceção é lançada. Dado isto, podemos chegar a conclusão que não é uma boa ideia, nesse exemplo, fazer Quadrado extender Retangulo! Uma possível solução de refatoramento é mostrada logo abaixo:
interface Figura {
public double calculaArea();
}
class Retangulo implements Figura {
private double base;
private double altura;
@Override
public double calculaArea() {
return base * altura;
}
public void setBase( double base ) {
this.base = base;
}
public void setAltura( double altura ) {
this.altura = altura;
}
}
class Quadrado implements Figura {
private double lado;
@Override
public double calculaArea() {
return lado * lado;
}
public void setLado( double lado ) {
this.lado = lado;
}
}
class Testador {
public void teste( Figura figura, double valorEsperado ) {
double area = figura.calculaArea();
if ( area != valorEsperado )
throw new RuntimeException( "Erro no cálculo da área!" );
System.out.println( "Ok" );
}
}
public class Principal {
public static void main( String[] args ) {
Retangulo retangulo = new Retangulo();
retangulo.setBase( 10 );
retangulo.setAltura( 20 );
Quadrado quadrado = new Quadrado();
quadrado.setLado( 10 );
Testador testador = new Testador();
testador.teste( retangulo, 200 ); // Ok
testador.teste( quadrado, 100 ); // Ok
}
}
Perceba agora que a responsabilidade para alterar os atributos das classes Quadrado e Retangulo foi passada para o método "main" da classe Principal. Inclusive, o método "teste" da classe Testador também foi alterado para receber, agora, uma Figura, que pode ser uma instância de Quadrado ou Retangulo, e um valor esperado para testar. No método "main", agora é passado o valor esperado como parâmetro para o método "teste".
Princípio da segregação de interface
Agora, vamos ver o Interface Segregation Principle (ou Princípio da Segregação de Interface). Esse, princípio também é simples de entender. A ideia é evitar que interfaces sejam implementadas com métodos vazios na classe derivada. Veja o exemplo abaixo:
interface Animal {
public void caminha();
public void nada();
}
class Cachorro implements Animal {
public void caminha() {
System.out.println( "Cachorro caminhando... Caminhou!" );
}
public void nada() {
System.out.println( "Cachorro nadando... Nadou!" );
}
}
class Cavalo implements Animal {
public void caminha() {
System.out.println( "Cavalo caminhando... Caminhou!" );
}
public void nada() {
}
}
O cachorro é um animal, e ele nada e caminha. Por isso, os dois métodos da interface Animal são implementados pela classe Cachorro. No entanto, o cavalo é também um Animal, só que não nada! Por isso, o método "nada" da classe Cavalo está vazio. Então, há no código acima uma violação ao Princípio da Segregação de Interface. Onde deve-se atribuir a cada interface, apenas os métodos que serão herdados por todas as suas classes derivadas! Veja abaixo como ficaria então a alteração para não violar o princípio:
interface Animal {
public void caminha();
}
interface AnimalQueNada extends Animal {
public void nada();
}
class Cavalo implements Animal {
public void caminha() {
System.out.println( "Cavalo caminhando... Caminhou!" );
}
}
class Cachorro implements AnimalQueNada {
public void caminha() {
System.out.println( "Cachorro caminhando... Caminhou!" );
}
public void nada() {
System.out.println( "Cachorro nadando... Nadou!" );
}
}
Perceba agora que foi criada mais uma interface: A interface AnimalQueNada. Dado que esta herda de Animal, herdando também seu método "caminha". Logo, a classe Cachorro deve implementar a interface AnimalQueNada e a classe Cavalo deve implementar a interface Animal, evitando assim a sobrescrita com métodos vazios.
Princípio da Inversão de Dependência
Agora, vamos ao último princípio: O Dependency Inversion Principle (Princípio da Inversão de Dependência). Esse princípio pode visto como o seguinte:
- As entidades devem depender de abstrações, não de implementações;
- O módulo de alto nível não deve depender do módulo de baixo nível, mas deve depender de abstrações.
A inversão de dependência está na dependência da abstração, ao invés da depencência direta da classe com a implementação concreta. Agora vamos analisar o exemplo de código abaixo:
import java.awt.Graphics;
import javax.swing.JFrame;
import javax.swing.JPanel;
class Pintor {
public void desenhaQuadrado( Graphics g ) {
g.fillRect( 10, 10, 100, 100 );
}
public void desenhaCirculo( Graphics g ) {
g.fillArc( 10, 10, 100, 100, 0, 360 );
}
}
class PainelDesenho extends JPanel {
private Pintor pintor = null;
public void paintComponent( Graphics g ) {
super.paintComponent( g );
if ( pintor != null )
pintor.desenhaQuadrado( g );
}
public void setPintor( Pintor pintor ) {
this.pintor = pintor;
}
}
public class Principal {
public static void main( String[] args ) {
Pintor pintor = new Pintor();
PainelDesenho painelDesenho = new PainelDesenho();
painelDesenho.setPintor( pintor );
JFrame janela = new JFrame();
janela.setContentPane( painelDesenho );
janela.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
janela.setSize( 400, 400 );
janela.setLocationRelativeTo( janela );
janela.setVisible( true );
}
}
Perceba no código acima que o módulo de mais baixo nível é a classe Pintor e a classe de mais alto nível é a classe PainelDesenho. Perceba que a classe PainelDesenho depende da implementação, isto é, da classe concreta Pintor. Logo, se quisessemos mudar o desenho de "desenhaQuadrado" para "desenhaCirculo", seria necessário alterar em PainelDesenho A seguinte linha:
pintor.desenhaCirculo( g );
Isto é um problema porque o ideal é não necessitar mexer na classe PainelDesenho para alterar o desenho a ser mostrado. Dado o problema, vamos ao exemplo que resolve o problema, respeitando ao princípio da inversão de dependência por criar uma abstração dos "desenhos":
import java.awt.Graphics;
import javax.swing.JFrame;
import javax.swing.JPanel;
interface Desenho {
public void desenha( Graphics g );
}
class QuadradoDesenho implements Desenho {
public void desenha( Graphics g ) {
g.fillRect( 10, 10, 100, 100 );
}
}
class CirculoDesenho implements Desenho {
public void desenha( Graphics g ) {
g.fillArc( 10, 10, 100, 100, 0, 360 );
}
}
class PainelDesenho extends JPanel {
private Desenho desenho = null;
public void paintComponent( Graphics g ) {
super.paintComponent( g );
if ( desenho != null )
desenho.desenha( g );
}
public void setDesenho( Desenho desenho ) {
this.desenho = desenho;
}
}
public class Principal {
public static void main( String[] args ) {
Desenho desenho = new CirculoDesenho();
//Desenho desenho = new QuadradoDesenho();
PainelDesenho painelDesenho = new PainelDesenho();
painelDesenho.setDesenho( desenho );
JFrame janela = new JFrame();
janela.setContentPane( painelDesenho );
janela.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
janela.setSize( 400, 400 );
janela.setLocationRelativeTo( janela );
janela.setVisible( true );
}
}
Assim, se precisarmos alterar o desenho, basta fazer sem depender de alterações na classe PainelDesenho. Por exemplo, apenas passando para o método "setDesenho" da classe PainelDesenho o devido desenho que deve ser mostrado, conforme acontece no método "main" da classe Principal!
Finalizando...
Chegamos ao final de mais um artigo. Este foi sobre os princípios S.O.L.I.D. Até o próximo!
Referências
(Oloruntoba, S) - SOLID: os primeiros 5 princípios do design orientado a objeto. Acessado em: 27/10/2023. Disponível em: https://www.digitalocean.com/community/conceptual-articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design-pt
(Dirani, L) - Princípio da Substituição de Liskov (LSP). Acessado em: 27/10/2023. Disponível em: https://medium.com/fora-de-assunto/princ%C3%ADpio-da-substitui%C3%A7%C3%A3o-de-liskov-lsp-78628484e97d
(Paixão, J) - O que é SOLID: O guia completo para você entender os 5 princípios da POO. Acessado em: 27/10/2023. Disponível em: https://medium.com/desenvolvendo-com-paixao/o-que-%C3%A9-solid-o-guia-completo-para-voc%C3%AA-entender-os-5-princ%C3%ADpios-da-poo-2b937b3fc530