Herança: O segundo pilar da Programação Orientada a Objetos
Fala Dev, tudo em riba?
Vamos dar continuidade à nossa série de artigos sobre Programação Orientada a Objetos (POO), porém antes vamos relembrar o que abordamos até o momento.
Entendemos o que é, para que serve, e quais os ganhos que temos ao utilizar o paradigma de POO. Abordamos também o primeiro pilar dos quatro principais da POO, o Encapsulamento que, de forma simplista, é proteger o estado interno do seu objeto.
Se você ainda não leu os artigos anteriores, recomendo que você pause a leitura desse artigo e leia os artigos anteriores, pois os conceitos neles abordados são extremamente necessários para um melhor entendimento do pilar que abordaremos nesse artigo.
- Programação Orientada a Objetos: Fundamentos que todo Dev precisa conhecer;
- Encapsulamento: O primeiro pilar da Programação Orientada a Objetos;
Neste artigo abordaremos o segundo pilar da POO, a Herança. Então pegue sua xícara de café e vamos ao artigo!
Um pouco de contexto
Antes de irmos para a definição do que é Herança, primeiramente vamos entrar no contexto onde esse pilar pode (na verdade deve) ser aplicado.
Vamos pensar em uma aplicação backend no qual temos os clientes (Customer) que podem utilizar os recursos disponíveis nessa aplicação e os administradores (Admin) que têm acesso a áreas de gestão dos recursos disponibilizados nessa referida aplicação.
Em um primeiro momento podemos pensar “olha, para cuidar dos clientes terei uma classe Customer e para cuidar dos administradores, terei uma classe Admin”. Então teríamos o seguinte código na aplicação.
class Customer {
constructor(
public id: string,
public name: string,
public email: string,
public shippingAddress: string
) {}
getDisplayName(): string {
return `${this.name} <${this.email}>`;
}
}
class Admin {
constructor(
public id: string,
public name: string,
public email: string,
public accessLevel: number
) {}
getDisplayName(): string {
return `${this.name} <${this.email}>`;
}
}
Conseguiu identificar o problema aqui? O código em si está perfeito, mas mesmo ele estando perfeito ainda sim tem um problema, conseguiu identificar? Vamos a ele!
Repare que tanto a classe Customer quanto a classe Admin compartilham características (atributos) e comportamentos (métodos) iguais. Ambas as classes têm os atributos id, name e email. Ambas tem também um mesmo método getDisplayName que faz a mesma coisa, tem o mesmo comportamento. A única diferença entre as classes é que a classe Customer tem um atributo shippingAddress e a classe Admin tem um atributo accessLevel. De resto, são idênticas.
Isso parece algo pequeno se sua aplicação for pequena, mas o ideal é que toda aplicação seja concebida de forma que seu crescimento seja facilitado tanto no crescimento em si quanto na manutenibilidade da aplicação à medida que ela cresce.
Ter uma infinidade de objetos idênticos pode ser uma dor de cabeça no futuro, pois a complexidade de manutenção e o volume de código pode impactar diretamente na qualidade da aplicação como um todo e dificultar adição de novas features.
Tá, mas como melhorar isso? Com o pilar da POO que vamos conhecer agora.
O que é Herança no contexto da POO?
A Herança, como o nome mesmo pode sugerir, é a possibilidade de uma classe herdar características (atributos) e comportamentos (métodos) de uma outra classe e desse ponto em diante adicionar características e comportamentos adicionais de acordo com necessidade.
Desse modo evitamos repetição de código e criamos uma relação clara do tipo “é um”. Sendo assim, trazendo para o cenário apresentado anteriormente, podemos dizer:
- Customer é um User;
- Admin é um User;
Ambos compartilham o que todo User tem, mas cada um tem suas particularidades. E ai aparecem as nomenclaturas classe pai e classe filha. Chamamos de classe pai a classe que vai oferecer sua estrutura, seus atributos e métodos para outras classes. E classes filhas são as classes que recebem atributos e métodos de outras classes e acrescentam seus próprios atributos e métodos.
Herança na prática
Ficou complicado de entender? Vamos refatorar o código anterior aplicando Herança para clarear as coisas.
// Classe PAI (superclasse / classe base)
class User {
constructor(
public readonly id: string,
public name: string,
public email: string
) {}
getDisplayName(): string {
return `${this.name} <${this.email}>`;
}
}
// Classe FILHA — herda tudo de User
class Customer extends User {
constructor(
id: string,
name: string,
email: string,
public shippingAddress: string // atributo específico de Customer
) {
super(id, name, email); // obrigatório: chama o construtor da classe pai
}
// Método específico de Customer
getShippingInfo(): string {
return `${this.name} — ${this.shippingAddress}`;
}
}
class Admin extends User {
constructor(
id: string,
name: string,
email: string,
public accessLevel: number // atributo específico de Admin
) {
super(id, name, email);
}
hasFullAccess(): boolean {
return this.accessLevel === 1;
}
}
Analisando o código acima:
- Criamos uma classe
User, onde centralizamos tudo que pode ser de comum uso por outras classes derivadas. Exemplo: customer (cliente), admin (administrador), employee (funcionário), manager (gerente), etc.; - A classe
Customerherda (extends) atributos e comportamento da classeUsere acrescenta um novo atributoshippingAddresse um novo métodogetShippingInfo, ou seja, incorporou a estrutura fornecida porUsere acrescentou sua própria característica e comportamento; - A classe
Adminherda (extends) atributos e comportamento da classeUsere acrescenta um novo atributoaccessLevele um novo métodohasFullAccess;
Olha que mudança! Reaproveitamos trechos de código que se repetiam e cada nova classe acrescentou suas características e comportamentos. E como utilizamos essas classes, como criamos os objetos? Segue o código:
const customer = new Customer("1", "Carlos", "carlos@email.com", "Rua A, 123");
const admin = new Admin("2", "Ana", "ana@email.com", 1);
// Métodos herdados de User
console.log(customer.getDisplayName()); // Carlos <carlos@email.com>
console.log(admin.getDisplayName()); // Ana <ana@email.com>
// Métodos específicos de cada classe
console.log(customer.getShippingInfo()); // Carlos — Rua A, 123
console.log(admin.hasFullAccess()); // true
Explicando:
- const customer: nessa linha criamos um objeto do tipo
Customerpassando todos os parâmetros necessários. Repare que informamos até os parâmetros existentes na classeUser, mas não mencionamos ela diretamente; - const admin: nessa linha criamos um objeto
Adminpassando todos os parâmetros necessários (inclusive os necessários na classeUser); console.log(customer.getDisplayName());econsole.log(admin.getDisplayName());: notou que nessas linhas cada objeto acionou o métodogetDisplayName? Os objetos criados não tem esse método dentro de suas respectivas classes, porém herdaram esses métodos da classe paiUser;console.log(customer.getShippingInfo());econsole.log(admin.hasFullAccess());: nessas linhas podemos ver que cada objeto aciona métodos que são particulares a eles;
Tá, e esse tal de super presente nos construtores?
Esse tal de super é a ponte entre a classe filha e a classe pai. Repara que no construtor da classe Customer, os atributos herdados da classe User são repassados através do super para a classe User. Isso ocorre pois a classe User tem um construtor que recebe esses atributos. Quando fazemos a herança, o construtor da classe User é executado e ele precisa dos valores de tais atributos. O mesmo ocorre na classe Admin.
O super pode ser utilizado em dois momentos:
- No construtor: obrigatório quando a classe filha tem um constructor. Você precisa chamar super(…) antes de qualquer uso do this:
constructor(id: string, name: string, email: string, public accessLevel: number) {
super(id, name, email); // primeiro chama o pai
// só depois pode usar this.
}
- Para chamar métodos da classe pai: quando você quer reaproveitar o comportamento da classe pai dentro de um método sobrescrito:
class Admin extends User {
getDisplayName(): string {
// Reutiliza o método do pai e adiciona algo a mais
return `[ADMIN] ${super.getDisplayName()}`;
}
}
const admin = new Admin("1", "Ana", "ana@email.com", 1);
console.log(admin.getDisplayName()); // [ADMIN] Ana <ana@email.com>
O modificador protected
Finalmente vamos entender de forma completa quando utilizamos o modificador protected (digo finalmente pois venho falando desse carinha nos últimos dois artigos). Esse modificador faz total sentido quando utilizamos herança.
O modificador private restringe o acesso de um atributo ou método ao mundo externo, ou seja, somente a própria classe tem acesso a ele. Já o modificador protected restringe o acesso de um atributo ou método ao mundo externo, porém é acessível pelas classes filhas. Vamos a um exemplo:
class User {
constructor(
public readonly id: string,
public name: string,
protected email: string // acessível em filhas, mas não fora da hierarquia
) {}
}
class Admin extends User {
getContact(): string {
return this.email;
}
}
const admin = new Admin("1", "Ana", "ana@email.com");
console.log(admin.email); // ❌ erro — email é protected, não public
console.log(admin.getContact()); // ✅ Admin pode acessar porque herda de User
No código acima, o atributo email recebeu o modificador protected na classe User. Ao instanciar um objeto do tipo Admin, o atributo email recebe um valor. Se tentar acessar esse valor através de admin.email, é retornado um erro, pois o atributo email não é acessível ao mundo externo. No entanto, é possível acessar o atributo via método getContact da classe Admin, pois a classe tem acesso a esse atributo.
Principais benefícios em se utilizar herança
- Reutilização de código: Essa é a vantagem mais conhecida. Com herança, você escreve a lógica uma vez na classe pai e todas as classes filhas herdam automaticamente esse comportamento;
- Organização e hierarquia lógica: A herança permite modelar o mundo real de forma natural, criando uma taxonomia que facilita o entendimento do sistema;
- Manutenção centralizada: Precisa corrigir um bug ou alterar um comportamento comum? Você muda em um único lugar — a classe pai — e todas as filhas são atualizadas;
- Base para o polimorfismo: A herança é o pré-requisito para o polimorfismo funcionar (pilar que veremos em um artigo futuro). Ao estabelecer uma relação pai-filho, você pode tratar objetos diferentes de forma uniforme;
- Extensibilidade: Você consegue estender comportamentos existentes sem modificar o código original, o que se alinha perfeitamente com o Princípio Aberto/Fechado (OCP) do SOLID;
Conclusão
A herança é um pilar com um papel muito interessante na POO, que contribui e muito para construção de uma aplicação organizada e robusta, preparada para receber novas features sem a necessidade de replicar ou repetir código e contribuindo com uma melhor manutenibilidade do code base.
Uma observação importante a ser feita é que, apesar de poderosa, a herança deve ser utilizada com critério, com moderação. Não devemos aplicar o conceito de herança para tudo. A regra de outro é a seguinte:
- Pergunte: É um?
- Exemplos: cliente é uma pessoa? Administrador é uma pessoa? Nesses casos utilizamos herança;
- Pergunte: Tem um?
- Exemplos: um carro tem motor? Uma moto tem motor? Nesses casos utilizamos a composição (calma, que esse conceito também veremos em um artigo futuro);
Espero que esse artigo tenha contribuído para seu aprendizado ou entendimento sobre o conceito de Herança, um dos 4 pilares da Programação Orientada a Objetos. No próximo artigo veremos o terceiro pilar, o Polimorfismo.
Forte abraço e até mais!