Herança e polimorfismo são dois pilares da programação orientada a objetos que permitem criar código mais organizado, reutilizável e flexível. Em JavaScript, esses conceitos são implementados através de protótipos e, a partir do ES6, com a sintaxe de classes. Com a herança, uma classe pode herdar propriedades e métodos de outra, evitando duplicação e promovendo a reutilização. O polimorfismo, por sua vez, permite que objetos de diferentes classes respondam ao mesmo método de maneiras distintas, facilitando a criação de código que trabalha com abstrações.

Nesta aula, vamos mergulhar nos detalhes de como implementar herança em JavaScript moderno, explorando as palavras-chave extends e super, a sobrescrita de métodos, e o operador instanceof. Vamos entender não apenas a sintaxe, mas também o comportamento subjacente, para que você possa aplicar esses conceitos de forma eficaz em seus projetos.

extends

A palavra-chave extends é usada para criar uma classe que herda de outra classe (chamada de classe base ou superclasse). A nova classe, chamada de classe derivada ou subclasse, ganha acesso a todos os métodos e propriedades da classe base, podendo também adicionar novos membros ou modificar os existentes.

Em JavaScript, a herança é baseada em protótipos, mas a sintaxe extends torna esse processo mais claro e similar a outras linguagens orientadas a objetos. Quando uma classe estende outra, ela estabelece uma cadeia de protótipos: a subclasse tem como protótipo a superclasse, permitindo que os métodos sejam encontrados ao longo dessa cadeia.

Vejamos um exemplo básico:

class Animal {
  constructor(nome) {
    this.nome = nome;
  }

  falar() {
    console.log(`${this.nome} faz um som.`);
  }
}

class Cachorro extends Animal {
  // A classe Cachorro herda o construtor e o método falar de Animal
}

const rex = new Cachorro('Rex');
rex.falar(); // "Rex faz um som."

No exemplo acima, Cachorro estende Animal sem adicionar nada novo. Portanto, instâncias de Cachorro têm acesso ao construtor e ao método falar da classe Animal. Isso demonstra a reutilização de código: não precisamos reescrever o método falar na classe Cachorro.

É importante notar que extends também funciona com classes que são expressões ou com funções construtoras tradicionais. No entanto, o uso mais comum é com a sintaxe de classes.

super

A palavra-chave super é usada para acessar e chamar funções do objeto pai (a superclasse). Existem dois usos principais: dentro do construtor da subclasse, para chamar o construtor da classe base; e em métodos, para chamar métodos da superclasse.

Quando você define um construtor em uma subclasse, você deve chamar super() antes de usar this. Isso é necessário para inicializar o objeto herdado corretamente. Se você não definir um construtor, o construtor padrão da superclasse é usado implicitamente.

Vamos ver um exemplo com construtor personalizado:

class Animal {
  constructor(nome) {
    this.nome = nome;
  }

  falar() {
    console.log(`${this.nome} faz um som.`);
  }
}

class Cachorro extends Animal {
  constructor(nome, raca) {
    super(nome); // chama o construtor da classe Animal
    this.raca = raca;
  }

  // Podemos adicionar novos métodos
  buscar() {
    console.log(`${this.nome} está buscando a bola!`);
  }
}

const rex = new Cachorro('Rex', 'Labrador');
console.log(rex.nome); // "Rex"
console.log(rex.raca); // "Labrador"
rex.falar(); // "Rex faz um som."
rex.buscar(); // "Rex está buscando a bola!"

No exemplo, super(nome) chama o construtor de Animal, passando o parâmetro nome. Isso define a propriedade this.nome na instância. Depois, adicionamos a propriedade raca específica da subclasse.

Além do construtor, super também pode ser usado em métodos para invocar métodos da superclasse. Isso é útil quando queremos estender o comportamento de um método existente:

class Animal {
  constructor(nome) {
    this.nome = nome;
  }

  falar() {
    console.log(`${this.nome} faz um som.`);
  }
}

class Cachorro extends Animal {
  falar() {
    super.falar(); // chama o método falar da classe Animal
    console.log(`${this.nome} late.`);
  }
}

const rex = new Cachorro('Rex');
rex.falar();
// "Rex faz um som."
// "Rex late."

Aqui, a classe Cachorro sobrescreve o método falar e usa super.falar() para reutilizar a lógica da superclasse antes de adicionar seu próprio comportamento. Isso é uma forma de polimorfismo, pois a chamada ao método falar em uma instância de Cachorro executa a versão sobrescrita.

Sobrescrita de métodos

Sobrescrever um método significa definir um novo comportamento para um método que já existe na superclasse. Isso é fundamental para o polimorfismo, pois permite que uma subclasse forneça uma implementação específica de um método que é comum a várias classes.

Para sobrescrever um método, basta declarar um método com o mesmo nome na subclasse. A versão da subclasse será usada em vez da versão da superclasse quando o método for chamado em uma instância da subclasse. Você pode, se desejar, chamar a versão da superclasse usando super.metodo().

Vamos considerar um exemplo mais elaborado:

class Forma {
  constructor() {}

  area() {
    return 0;
  }

  descricao() {
    return 'Esta é uma forma genérica.';
  }
}

class Circulo extends Forma {
  constructor(raio) {
    super();
    this.raio = raio;
  }

  area() {
    return Math.PI * this.raio ** 2;
  }

  descricao() {
    return `Este é um círculo com raio ${this.raio}.`;
  }
}

class Retangulo extends Forma {
  constructor(largura, altura) {
    super();
    this.largura = largura;
    this.altura = altura;
  }

  area() {
    return this.largura * this.altura;
  }

  descricao() {
    return `Este é um retângulo com largura ${this.largura} e altura ${this.altura}.`;
  }
}

const formas = [new Circulo(5), new Retangulo(4, 6)];

formas.forEach(forma => {
  console.log(forma.descricao());
  console.log(`Área: ${forma.area()}`);
});

Neste exemplo, temos uma classe base Forma com métodos area() e descricao(). As subclasses Circulo e Retangulo sobrescrevem ambos os métodos para fornecer implementações específicas. Quando iteramos sobre o array de formas e chamamos descricao() e area(), o JavaScript resolve dinamicamente qual método executar com base no tipo real do objeto. Isso é polimorfismo em ação: o mesmo código funciona para diferentes tipos de objetos.

É possível também sobrescrever métodos estáticos, mas isso é menos comum. A sobrescrita de métodos é uma técnica poderosa para criar hierarquias de classes flexíveis e expressivas.

instanceof

O operador instanceof verifica se um objeto é uma instância de uma determinada classe (ou de uma classe que herda dela). A sintaxe é: objeto instanceof Classe. Ele retorna true se o objeto estiver na cadeia de protótipos da classe.

Esse operador é útil para verificar o tipo de um objeto em tempo de execução, especialmente quando você trabalha com hierarquias de classes e deseja tomar decisões baseadas no tipo.

Vejamos um exemplo:

class Animal {}
class Cachorro extends Animal {}
class Gato extends Animal {}

const rex = new Cachorro();
const mingau = new Gato();

console.log(rex instanceof Cachorro); // true
console.log(rex instanceof Animal);   // true (pois Cachorro herda de Animal)
console.log(rex instanceof Gato);     // false
console.log(mingau instanceof Animal); // true

No exemplo, rex é uma instância de Cachorro e também de Animal porque Cachorro estende Animal. Já mingau é uma instância de Gato e de Animal, mas não de Cachorro.

O instanceof também funciona com classes que não são declaradas com class, como funções construtoras tradicionais, e até mesmo com objetos que têm um Symbol.hasInstance personalizado. No entanto, na maioria dos casos, você usará com classes.

Uma observação importante: instanceof verifica a cadeia de protótipos. Se você criar um objeto com Object.create(AlgumaClasse.prototype), ele também será considerado uma instância de AlgumaClasse (desde que a cadeia seja mantida). Mas isso é um detalhe avançado.

Boas práticas e observações finais

Ao trabalhar com herança, é importante lembrar que a composição muitas vezes é preferível à herança (o princípio de 'prefira composição em vez de herança'). A herança pode levar a hierarquias rígidas e problemas de acoplamento. Avalie se a relação é realmente 'é um' antes de usar herança.

Além disso, evite heranças profundas (mais de alguns níveis), pois isso dificulta a manutenção e a compreensão do código. Prefira métodos pequenos e bem definidos, e use sobrescrita com cuidado para não quebrar o contrato da superclasse.

Por fim, lembre-se de que super() deve ser chamado antes de acessar this no construtor de uma subclasse. Se você esquecer, um erro será lançado.

Exercícios

  1. Crie uma classe Veiculo com propriedades marca e ano, e um método info() que retorna uma string como "Marca: [marca], Ano: [ano]". Em seguida, crie uma classe Carro que estenda Veiculo, adicione a propriedade portas e sobrescreva o método info() para incluir o número de portas. Teste criando uma instância de Carro e chamando info().

    ✓ Resposta:
    class Veiculo {
      constructor(marca, ano) {
        this.marca = marca;
        this.ano = ano;
      }
    
      info() {
        return `Marca: ${this.marca}, Ano: ${this.ano}`;
      }
    }
    
    class Carro extends Veiculo {
      constructor(marca, ano, portas) {
        super(marca, ano);
        this.portas = portas;
      }
    
      info() {
        return `${super.info()}, Portas: ${this.portas}`;
      }
    }
    
    const meuCarro = new Carro('Toyota', 2020, 4);
    console.log(meuCarro.info()); // "Marca: Toyota, Ano: 2020, Portas: 4"
    
  2. Explique o que acontece se você tentar acessar this em um construtor de uma subclasse antes de chamar super(). Forneça um exemplo que cause erro.

    ✓ Resposta:
    class Base {
      constructor() {}
    }
    
    class Derivada extends Base {
      constructor() {
        // this.nome = 'teste'; // Isso causa erro: Must call super constructor in derived class before accessing 'this'
        super();
        this.nome = 'teste';
      }
    }
    
    const d = new Derivada();
    
  3. Dado o seguinte código:
    class A {
      metodo() { console.log('A'); }
    }
    class B extends A {
      metodo() { console.log('B'); super.metodo(); }
    }
    const b = new B();
    b.metodo();
    
    Qual é a saída? Explique o papel de super.

    ✓ Resposta:
    // Saída:
    // B
    // A
    
    // O método metodo() em B sobrescreve o de A. Dentro dele, primeiro imprime 'B', depois chama super.metodo() que executa o método da classe A, imprimindo 'A'.
    
  4. Crie uma hierarquia de classes para formas geométricas: Forma (classe base), Circulo e Quadrado. Cada uma deve ter um método calcularArea() que retorna a área. Use polimorfismo para criar uma função que recebe um array de formas e imprime a área de cada uma.

    ✓ Resposta:
    class Forma {
      calcularArea() {
        return 0;
      }
    }
    
    class Circulo extends Forma {
      constructor(raio) {
        super();
        this.raio = raio;
      }
    
      calcularArea() {
        return Math.PI * this.raio ** 2;
      }
    }
    
    class Quadrado extends Forma {
      constructor(lado) {
        super();
        this.lado = lado;
      }
    
      calcularArea() {
        return this.lado * this.lado;
      }
    }
    
    function imprimirAreas(formas) {
      formas.forEach(forma => {
        console.log(forma.calcularArea());
      });
    }
    
    const formas = [new Circulo(2), new Quadrado(3), new Circulo(1)];
    imprimirAreas(formas); // 12.566..., 9, 3.141...
    
  5. Escreva um código que demonstre o uso de instanceof para verificar se um objeto é uma instância de uma classe específica, mesmo quando ele é criado a partir de uma subclasse. Use as classes Animal, Cachorro e Gato.

    ✓ Resposta:
    class Animal {}
    class Cachorro extends Animal {}
    class Gato extends Animal {}
    
    const rex = new Cachorro();
    const mingau = new Gato();
    
    console.log(rex instanceof Animal);  // true
    console.log(rex instanceof Cachorro); // true
    console.log(rex instanceof Gato);    // false
    console.log(mingau instanceof Animal); // true
    

Referências