Você conhece os pilares da programação orientada a objetos?

A Orientação a Objetos (OOP) revolucionou o desenvolvimento de software ao permitir modelar sistemas como conjuntos de entidades interconectadas, espelhando o mundo real. Seus quatro pilares – Encapsulamento, Abstração, Herança e Polimorfismo – são a base para criar código modular, reutilizável e resiliente a mudanças. Neste artigo, iremos explorar cada pilar com exemplos práticos abordando cenários reais de software,utilizando a linguagem Java, mas que podem ser aplicados a outras linguagens que tenham suporte ao paradigma em questão.

Abstração

Vamos começar pelo encapsulamento. Essa é a etapa onde controlamos o acesso aos nossos dados. A ideia é agrupar dados (atributos) e comportamentos (métodos) em uma unidade coesa (classe), restringindo o acesso direto ao estado interno desta.

Para deixar mais claro, temos uma simulação de sistema bancário simples, onde temos uma conta, e o saldo desta conta não pode ser alterado livremente, precisamos então proteger esse dado de alguma forma. Vamos ver no exemplo abaixo:

public class ContaBancaria {
    private String titular;
    private double saldo;  // Atributo privado: acesso restrito

    public ContaBancaria(String titular, double saldoInicial) {
        this.titular = titular;
        this.saldo = saldoInicial;
    }

    // Método público para depositar (com validação)
    public void depositar(double valor) {
        if (valor > 0) {
            saldo += valor;
            System.out.println("Depósito realizado. Novo saldo: " + saldo);
        } else {
            System.out.println("Valor inválido!");
        }
    }

    // Método público para sacar (com validação)
    public void sacar(double valor) {
        if (valor > 0 && valor <= saldo) {
            saldo -= valor;
        } else {
            System.out.println("Saldo insuficiente ou valor inválido!");
        }
    }

    // Getter: acesso controlado ao saldo
    public double getSaldo() {
        return saldo;
    }
}

O saldo da conta só pode ser modificado com a utilização do método depositar() ou sacar(). E se por algum motivo a lógica mudar, como adicioanr uma taxa para cada operação, basta ajustarmos os métodos. Dessa forma, temos um código seguro e fácil de manter.

Encapsulamento

Dito isso, passamos para o encapsulamento, onde a ideia é focar apenas no essencial que um objeto faz, ocultando detalhes de implementação.

Podemos pensar em um sistema de pagamentos, que precisa processar diferentes métodos (cartão, pix, boleto, etc…) sem se preocupar com os detalhes internos de cada um. Vamos ao código:

// Interface define o contrato "processável"
public interface MetodoPagamento {
    void processarPagamento(double valor);
}

// Implementação para Cartão
public class CartaoCredito implements MetodoPagamento {
    @Override
    public void processarPagamento(double valor) {
        conectarGateway(); // Detalhe interno oculto
        validarLimite(valor);
        System.out.println("Pagamento com cartão: R$" + valor);
    }

    private void conectarGateway() { /* ... */ }
}

// Implementação para Pix
public class Pix implements MetodoPagamento {
    @Override
    public void processarPagamento(double valor) {
        gerarQRCode(valor); // Detalhe interno oculto
        System.out.println("Pagamento via Pix: R$" + valor);
    }

    private void gerarQRCode(double valor) { /* ... */ }
}
public class Checkout {
    public void finalizarCompra(MetodoPagamento metodo, double valor) {
        metodo.processarPagamento(valor); // Chama a implementação concreta
    }
}

Checkout checkout = new Checkout();
checkout.finalizarCompra(new CartaoCredito(), 150.0); 
checkout.finalizarCompra(new Pix(), 200.0);

Dessa forma conseguimos deixar o nosso código desacoplado e extensível, o Checkout não sabe como o pagamento é processado, apenas que ele pode ser processado. E uma vez que precisarmos adicionar um novo método de pagamento, não precisamos realizar alterações no Checkout.

Herança

O terceiro pilar é a Herança. Esse conceito nos permite criar classes baseadas em classes existentes. Isso promove a reutilização, organização e manutenção do nosso código.

Imagine que você está organizando uma garagem virtual e precisa catalogar diversos tipos de veículos. Em vez de criar classes completamente separadas para cada um, a herança permite modelar essa relação de forma mais eficiente.

Pense em uma classe base (que chamamos de superclasse) chamada Veiculo. Todo veículo, independentemente do tipo, compartilha características comuns, certo? Ele tem uma marca, um modelo, um ano de fabricação. Ele também realiza ações básicas, como ligar e desligar.

Agora, vamos aos veículos mais específicos. Temos carros, motos e caminhões. Todos eles são veículos, mas também possuem características e comportamentos próprios.

  • Um Carro é um Veiculo, mas também tem um número de portas.
  • Uma Moto é um Veiculo, mas tem um tipo de guidão.
  • Um Caminhão é um Veiculo, mas tem uma capacidade de carga.

Vamos transformar isso em código:

// Superclasse: Veiculo
class Veiculo {
    String marca;
    String modelo;
    int anoFabricacao;

    public Veiculo(String marca, String modelo, int anoFabricacao) {
        this.marca = marca;
        this.modelo = modelo;
        this.anoFabricacao = anoFabricacao;
    }

    public void ligar() {
        System.out.println(modelo + " ligado.");
    }

    public void desligar() {
        System.out.println(modelo + " desligado.");
    }

    public void exibirDetalhes() {
        System.out.println("Marca: " + marca + ", Modelo: " + modelo + ", Ano: " + anoFabricacao);
    }
}

// Subclasse: Carro herda de Veiculo
class Carro extends Veiculo {
    int numeroPortas;

    public Carro(String marca, String modelo, int anoFabricacao, int numeroPortas) {
        super(marca, modelo, anoFabricacao); // Chama o construtor da superclasse
        this.numeroPortas = numeroPortas;
    }

    public void abrirPortaMalas() {
        System.out.println("Porta-malas do " + modelo + " aberto.");
    }

    @Override // Sobrescrita do método exibirDetalhes
    public void exibirDetalhes() {
        super.exibirDetalhes(); // Chama o método da superclasse
        System.out.println("Número de Portas: " + numeroPortas);
    }
}

// Subclasse: Moto herda de Veiculo
class Moto extends Veiculo {
    String tipoGuidon;

    public Moto(String marca, String modelo, int anoFabricacao, String tipoGuidon) {
        super(marca, modelo, anoFabricacao);
        this.tipoGuidon = tipoGuidon;
    }

    public void empinar() {
        System.out.println(modelo + " empinando.");
    }
}

// Exemplo de uso
public class GaragemVirtual {
    public static void main(String[] args) {
        Veiculo meuVeiculo = new Veiculo("Generica", "Modelo Base", 2020);
        meuVeiculo.exibirDetalhes();
        meuVeiculo.ligar();
        System.out.println("---");

        Carro meuCarro = new Carro("Ford", "Focus", 2023, 4);
        meuCarro.exibirDetalhes(); // Exibe detalhes do Veiculo e do Carro
        meuCarro.ligar();
        meuCarro.abrirPortaMalas();
        System.out.println("---");

        Moto minhaMoto = new Moto("Honda", "CBR 600RR", 2024, "Esportivo");
        minhaMoto.exibirDetalhes();
        minhaMoto.ligar();
        minhaMoto.empinar();
    }
}

Vamos explorar melhor o que esse código faz.

Veiculo é a “família” principal, ela define as características e comportamentos que todos os membros da família de veículos possuem.

Carro, Moto (e Caminhao, se criado) são os “filhos” que herdam, eles estendem (extends) as características de Veiculo e adicionam suas próprias particularidades.

super() é como dizer “mãe/pai, faça o que você faz de melhor”, no construtor das subclasses, super(marca, modelo, anoFabricacao) chama o construtor da superclasse, garantindo que as propriedades de Veiculo sejam inicializadas corretamente.

Em relação a reutilização, observe que não precisamos reescrever marca, modelo, anoFabricacao, ligar() ou desligar() em Carro ou Moto. Eles simplesmente herdam essas funcionalidades de Veiculo.

Polimorfismo

Por último, mas não menos importante, temos o polimorfismo (muitas formas). Esse conceito permite que objetos de diferentes classes sejam tratados como objetos de uma classe comum, desde que essas classes compartilhem uma superclasse ou implementem uma interface. Isso proporciona grande flexibilidade e extensibilidade ao seu código.

Imagine que você é o maestro de uma orquestra. Em sua orquestra, você tem diversos tipos de instrumentos: violino, flauta, bateria, etc. Cada um desses instrumentos, quando tocado, produz um som. No entanto, a maneira como cada um produz o som é diferente. Um violino é tocado com um arco, uma flauta é soprada e a bateria é batida.

Como maestro, você não precisa saber os detalhes específicos de como cada instrumento produz o som. Você simplesmente dá a instrução genérica “Tocar!”. E cada instrumento, em sua “própria forma”, executa essa instrução, produzindo seu som característico.

Tudo bem, a teoria é bem interessante, mas como isso funciona na prática? Vamos ver!

// Classe base: InstrumentoMusical
class InstrumentoMusical {
    String nome;

    public InstrumentoMusical(String nome) {
        this.nome = nome;
    }

    // Método polimórfico: tocar
    public void tocar() {
        System.out.println(nome + " está produzindo um som.");
    }
}

// Subclasse: Violino herda de InstrumentoMusical
class Violino extends InstrumentoMusical {
    public Violino() {
        super("Violino");
    }

    @Override // Sobrescrita do método tocar()
    public void tocar() {
        System.out.println("O violino está vibrando as cordas.");
    }
}

// Subclasse: Flauta herda de InstrumentoMusical
class Flauta extends InstrumentoMusical {
    public Flauta() {
        super("Flauta");
    }

    @Override // Sobrescrita do método tocar()
    public void tocar() {
        System.out.println("A flauta está soprando o ar.");
    }
}

// Subclasse: Bateria herda de InstrumentoMusical
class Bateria extends InstrumentoMusical {
    public Bateria() {
        super("Bateria");
    }

    @Override // Sobrescrita do método tocar()
    public void System.out.println("A bateria está batendo nas peles.");
    }
}

// Exemplo de uso: A Orquestra
public class Orquestra {
    public static void main(String[] args) {
        // Criando instâncias de diferentes instrumentos
        InstrumentoMusical violino = new Violino();
        InstrumentoMusical flauta = new Flauta();
        InstrumentoMusical bateria = new Bateria();

        // Um array que pode conter qualquer InstrumentoMusical
        InstrumentoMusical[] minhaOrquestra = {violino, flauta, bateria};

        System.out.println("Maestro diz: 'Tocar!'");
        // O polimorfismo em ação: chamando tocar() em cada instrumento
        for (InstrumentoMusical instrumento : minhaOrquestra) {
            instrumento.tocar(); // Cada instrumento executa seu próprio método tocar()
        }
    }
}

Podemos entender o seguinte. InstrumentoMusical é a “classe base” genérica, ela define a ação comum que todos os instrumentos podem fazer: tocar().

Violino, Flauta e Bateria são as “formas específicas”, eles estendem InstrumentoMusical e, crucialmente, sobrescrevem (@override) o método tocar() para implementar sua forma particular de produzir som.

A lista minhaOrquestra é o “maestro”, ela pode conter objetos de diferentes tipos (Violino, Flauta, Bateria), mas os trata todos como InstrumentoMusical.

O for loop é a “instrução do maestro”, quando instrumento.tocar() é chamado dentro do loop, o Java automaticamente determina qual versão do método tocar() deve ser executada em tempo de execução, com base no tipo real do objeto (se é um Violino, uma Flauta ou uma Bateria).

Bem, era isso que eu queria trazer. Considere que os pilares da OO não são teorias acadêmicas, mas ferramentas práticas que nos ajudam a reduzir a complexidade, evitar duplicação e facilitar extensões do nosso código. Dominar esses recursos nos permite criar sistemas menos propensos a bugs, facilmente extensíveis e alinhados com o domínio do problema em questão.

Mas não leve isso ao extremo, se esforce para ter a percepção de quando vale a pena aplicar cada um desses recursos. A chave é ter equilíbrio. “Prefira composição em vez de herança” e “Nem tudo precisa ser uma classe” são máximas que evitam armadilhas comuns. Comece com problemas simples e evolua para cenários de maior complexidade, quando menos perceber, estará fazendo isso de forma automática.

Muito obrigado por ter lido até aqui, e por favor, caso eu tenha deixado passar algum detalhe importante, ou você tenha uma analogia mais clara para cada uma das construídas aqui, compartilhe abaixo nos comentários. Ficarei feliz em ler cada uma com a devida atenção. E caso você queira se aprofundar mais no assunto, seguem duas ótimas referências:

Design Patterns: Elements of Reusable Object-Oriented Software (Gamma et al.).
Clean Code: A Handbook of Agile Software Craftsmanship (Robert C. Martin).

Leave a Reply