Italo Info


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