Polimorfismo: O terceiro pilar da Programação Orientada a Objetos
Fala Dev, tudo em riba?
Estamos nós aqui em mais um artigo da série sobre Programação Orientada a Objetos (POO). Recapitulando, passamos pelo conceito base da POO, abordamos o primeiro pilar que é o Encapsulamento – proteção do estado interno do objeto – e o segundo pilar que é a Herança – classe filha herda características e comportamentos da classe pai acrescentando seus próprios comportamentos e características – e vimos exemplos de cada um dos pilares. Se você não leu os três primeiros artigos da série, recomendo fortemente a leitura pois todos os conceitos neles abordados servem como base para o pilar que vamos abordar neste artigo, o Polimorfismo.
Dá uma pausa na leitura desse artigo aqui e dê uma lida nos artigos anteriores.
- Programação Orientada a Objetos: Fundamentos que todo Dev precisa conhecer
- Encapsulamento: O primeiro pilar da Programação Orientada a Objetos
- Herança: O segundo pilar da Programação Orientada a Objetos
Leu os artigos? Então bora entender o que é esse tal de Polimorfismo.
O que é o Polimorfismo?
Na POO, o polimorfismo é a capacidade de objetos de tipos diferentes responderem o mesmo método de formas distintas. Você chama o mesmo método, no mesmo “formato”, mas cada objeto executa seu próprio comportamento internamente. A palavra vem do grego poly (muitos) e morphos (formas).
Eu trabalho 99% do meu tempo com desenvolvimento backend e se você também trabalha ou tem contato com código de aplicações backend pode ter utilizado o polimorfismo de duas formas diferentes sem se dar conta – estou partindo do ponto que você querido leitor ou leitora esteja conhecendo o polimorfismo nesse exato instante. Mais à frente veremos quais são essas duas formas, mas antes precisamos saber por que deveríamos utilizar o polimorfismo.
O problema que o Polimorfismo resolve
Imagine que você esteja trabalhando em um e-commerce que disponibiliza como formas de pagamentos boleto bancário e cartão de crédito. Nesse caso o código associado ao checkout pode ser algo como:
class BilletTransaction {
processBillet(): void {
console.log("Processing billet...");
}
}
class CreditCardTransaction {
processCreditCard(): void {
console.log("Processing credit card...");
}
}
function processTransaction(transaction: BilletTransaction | CreditCardTransaction) {
if (transaction instanceof BilletTransaction) {
transaction.processBillet();
} else if (transaction instanceof CreditCardTransaction) {
transaction.processCreditCard();
}
}
No código acima temos uma função processTransaction que é responsável por processar as duas formas de pagamentos, boleto (billet) e o cartão de crédito (credit card). Repare que tem um if para cada forma de pagamento.
O processamento será realizado de acordo com o tipo de estrutura de dados que será fornecido via parâmetro transaction que pode ser BilletTransaction ou CreditCardTransaction.
Agora imagine que esse e-commerce vai passar a disponibilizar uma nova forma de pagamento, o Pix. Nesse caso esse código deverá ser atualizado em dois pontos:
- Na estrutura de dados esperada;
- Acrescentando mais um if para verificar a nova forma de pagamento;
Pode parecer algo pequeno, mas imagine em um sistema onde essas formas de pagamento escalam rapidamente. Vamos ficar adicionando if e mais if? O exemplo acima é sobre formas de pagamento mas pode ser usado em diferentes casos de uso onde poderemos ter uma infinidade de opções a serem tratadas.
O trecho de código acima representa uma solução frágil e o Polimorfismo resolve isso de forma limpa, simples e elegante.
Polimorfismo na prática
Diante do problema apresentado, podemos melhorar a solução aplicando o Polimorfismo. Vejamos o código abaixo:
enum TransactionStatus {
Pending = "pending",
Processed = "processed",
}
class Transaction {
constructor(
public readonly id: string,
public amount: number,
protected status: TransactionStatus = TransactionStatus.Pending
) {}
// Método que TODAS as filhas devem implementar do seu jeito
public process(): void {
throw new Error("process() must be implemented by subclass.");
}
public getReceipt(): string {
return `Transaction ${this.id} — Amount: $${this.amount} — Status: ${this.status}`;
}
}
class BilletTransaction extends Transaction {
constructor(id: string, amount: number, public name: string) {
super(id, amount);
}
public process(): void {
this.status = TransactionStatus.Processed;
console.log(`Billet generated to ${this.name}`);
}
}
class CreditCardTransaction extends Transaction {
constructor(id: string, amount: number, public cardLastDigits: string, public installments: number) {
super(id, amount);
}
public process(): void {
this.status = TransactionStatus.Processed;
console.log(`Charged card **** ${this.cardLastDigits} in ${this.installments}x`);
}
}
function processTransaction(transaction: Transaction) {
transaction.process();
console.log(transaction.getReceipt());
}
const billet = new BilletTransaction("1", 100, "John Doe");
const creditCard = new CreditCardTransaction("2", 200, "1234", 3);
processTransaction(billet);
processTransaction(creditCard);
// Output:
// Billet generated to John Doe
// Transaction 1 — Amount: $100 — Status: processed
// Charged card **** 1234 in 3x
// Transaction 2 — Amount: $200 — Status: processed
Explicando rapidamente, temos uma classe Transaction que é a classe pai onde temos seus atributos (características) e métodos (comportamentos). Repare que nessa classe temos um método process declarado e não implementado. Guarda ele ai que você já vai entender o motivo.
Em seguida temos a classe BilletTransaction que estende a classe pai Transaction, de onde ela herda seus atributos e comportamentos e adiciona seus atributos e comportamentos. Agora veja que nessa classe existe um método process implementado que sobrescreve o método process da classe pai.
O mesmo acontece com a classe CreditCardTransaction, estende a classe pai, adiciona seus atributos e métodos e tem um método process que sobrescreve o método existente na classe pai.
Agora que vem a solução do problema! Temos uma mesma função chamada processTransaction que recebe um único tipo Transaction e simplesmente executa um único método process. Devido à aplicação do conceito de Polimorfismo, não é mais necessário prever os tipos que podem ser recebidos como parâmetro da função e tão pouco ter um if para cada tipo.
Basta simplesmente instanciar a classe com a forma de pagamento que foi selecionada (exemplo const billet = new BilletTransaction("1", 100, "John Doe");) e chamar a função (processTransaction(billet);). O mesmo ocorre com transações com cartão de crédito.
Quando for necessário adicionar uma forma de pagamento Pix, por exemplo, basta criar a classe que faz o processamento dessa forma de pagamento estendendo a classe pai Transaction e usar. Top né?
Tá, mas você comentou que tem duas formas de utilizar o Polimorfismo, quais são? Veremos agora!
Polimorfismo com herança
A primeira forma de utilização do polimorfismo é através de herança – por isso que comentei no início do artigo que a leitura dos artigos anteriores da série seria muito importante para o entendimento do polimorfismo – onde o comportamento é alterado de acordo com a necessidade.
Foi exatamente o exemplo explicado acima. Tem uma classe filha que herda características da classe pai. No entanto a classe pai é centralizadora, ou seja, ela que orquestra tudo dizendo que todas as filhas devem ter atributos em comum e métodos em comum, que nesse caso é o método process. Repare que a função processTransaction recebe um tipo transaction, porém quando acionamos essa função passamos um tipo que é da classe filha que estende a classe pai.
O código acima pode e deve ser melhorado pelo seguinte aspecto: o ideal é que a classe filha seja obrigada a implementar os atributos e determinados métodos da classe pai – digo determinados pois vai depender do caso de uso, pode ser um ou vários métodos.
Repare que o método process da classe pai lança um erro caso a classe filha não implemente um método process. Isso é frágil, pois se esquecermos de lançar esse erro, o TypeScript não vai avisar sobre esse esquecimento.
O ideal é converter a classe pai em uma classe abstrata. Ficaria assim:
enum TransactionStatus {
Pending = "pending",
Processed = "processed",
}
abstract class Transaction {
constructor(
public readonly id: string,
public amount: number,
protected status: TransactionStatus = TransactionStatus.Pending
) {}
// Método que TODAS as filhas devem implementar do seu jeito
abstract process(): void;
public getReceipt(): string {
return `Transaction ${this.id} — Amount: $${this.amount} — Status: ${this.status}`;
}
}
class BilletTransaction extends Transaction {
constructor(id: string, amount: number, public name: string) {
super(id, amount);
}
public process(): void {
this.status = TransactionStatus.Processed;
console.log(`Billet generated to ${this.name}`);
}
}
class CreditCardTransaction extends Transaction {
constructor(id: string, amount: number, public cardLastDigits: string, public installments: number) {
super(id, amount);
}
public process(): void {
this.status = TransactionStatus.Processed;
console.log(`Charged card **** ${this.cardLastDigits} in ${this.installments}x`);
}
}
function processTransaction(transaction: Transaction) {
transaction.process();
console.log(transaction.getReceipt());
}
const billet = new BilletTransaction("1", 100, "John Doe");
const creditCard = new CreditCardTransaction("2", 200, "1234", 3);
processTransaction(billet);
processTransaction(creditCard);
// Output:
// Billet generated to John Doe
// Transaction 1 — Amount: $100 — Status: processed
// Charged card **** 1234 in 3x
// Transaction 2 — Amount: $200 — Status: processed
Repare que foram duas únicas modificações e todas na classe pai:
class Transactionparaabstract class Transaction;public process(): void {...paraabstract process(): void: note que o métodoprocessnão tem implementação alguma na classe pai, apenas nas classes filhas;
Com essas duas modificações se as classes filhas não implementarem o método process o TypeScript vai lançar um erro dizendo que é obrigatório implementar o método process. Ai ficou chique!
Polimorfismo com interface
Essa outra forma é muito usada, por exemplo, em aplicações construídas empregando conceitos de Clean Architecture, onde temos interfaces entre as camadas da aplicação.
As interfaces funcionam como contratos entre as camadas no qual são definidos comportamentos esperados entre as camadas. Vamos a um exemplo:
interface Processable {
process(): void;
getReceipt(): string;
}
class BilletTransaction implements Processable {
constructor(public id: string, public barCode: string) {}
process(): void {
console.log(`Billet ${this.barCode} registered.`);
}
getReceipt(): string {
return `Billet ${this.id} — Bar code: ${this.barCode}`;
}
}
class CreditCardTransaction implements Processable {
constructor(
public id: string,
public cardLastDigits: string,
public installments: number
) {}
process(): void {
console.log(`Charged card **** ${this.cardLastDigits} in ${this.installments}x`);
}
getReceipt(): string {
return `Credit card ${this.id} — Card: **** ${this.cardLastDigits} — ${this.installments}x`;
}
}
function processTransaction(transaction: Processable) {
transaction.process();
console.log(transaction.getReceipt());
}
const billet = new BilletTransaction("1", "12345.67890");
const creditCard = new CreditCardTransaction("2", "1234", 3);
processTransaction(billet);
processTransaction(creditCard);
// Output:
// Billet 12345.67890 registered.
// Billet 1 — Bar code: 12345.67890
// Charged card **** 1234 in 3x
// Credit card 2 — Card: **** 1234 — 3x
Note que as classes BilletTransaction e CreditCardTransaction implementam a interface Processable, diferentemente do Polimorfismo por herança onde a classe filha estende a classe pai. Dessa forma, todos os métodos (comportamentos) especificados na interface devem existir na classe que “assina”, “implementa” esse “contrato”. Caso a classe não contenha algum método especificado na interface, o TypeScript vai lançar um erro.
Herança ou interface, quando usar cada um?
Use a classe abstrata quando as classes compartilham atributos (características) e métodos (comportamentos) em comum — como id, amount e process() no nosso exemplo. Use a interface quando quer apenas definir um contrato de comportamento puro, sem nenhuma implementação compartilhada. Em aplicações com Clean Architecture, as interfaces são a forma mais comum de garantir o baixo acoplamento entre as camadas.
Vantagens de utilizar Polimorfismo
Vejamos algumas vantagens de utilizar o polimorfismo:
- Código aberto para extensão, fechado para modificação: Essa é a maior vantagem. Quando surge um novo requisito, você adiciona código, não mexe no que já funciona;
- Baixo acoplamento: O código que consome não depende de implementações concretas, depende apenas do contrato. Isso significa que você pode trocar a implementação sem tocar no consumidor;
- Facilidade para testar: Com baixo acoplamento, criar um mock para testes se torna trivial. Basta implementar o contrato com um comportamento controlado;
- Elimina condicionais desnecessários: if/else e switch espalhados pelo código são um sinal de que o polimorfismo poderia estar sendo usado. Cada if novo é uma nova razão para o código quebrar;
- Código mais legível e com responsabilidade bem definida: Cada classe tem uma razão para existir e um comportamento claro. O código fica autoexplicativo, fácil de navegar e de dar manutenção;
Conclusão
O Polimorfismo é um pilar fundamental da POO que, quando bem aplicado, resulta em um código mais flexível, extensível e de fácil manutenção. A capacidade de tratar objetos de tipos diferentes através de uma interface comum elimina condicionais desnecessários e abre o código para extensão sem precisar modificar o que já funciona.
Uma observação importante é que o Polimorfismo não existe de forma isolada. Ele caminha de mãos dadas com os pilares anteriores: o Encapsulamento protege o estado interno dos objetos, a Herança permite o reaproveitamento de comportamentos, e o Polimorfismo permite que esses comportamentos sejam substituídos de forma transparente. São conceitos complementares que juntos formam a base de uma boa arquitetura orientada a objetos.
Espero que esse artigo tenha contribuído para seu aprendizado ou entendimento sobre o conceito de Polimorfismo, um dos 4 pilares da Programação Orientada a Objetos. No próximo artigo veremos o quarto e último pilar, a Abstração.
Forte abraço e até mais!