Neste capítulo ver-se-á em profundidade a herança prototipada e suas diferenças com a herança clássica; e também os principais padrões usados para implementar: overriding, member protection e extension prevention. Tudo isso, além da herança múltipla e dos mixins, será discutido e implementado em ES6.
Abordar-se-á neste capítulo:
- Prototypal inheritance
- Overriding methods and properties
- Implementing protected members
- Controlling object extension
- Multiple inheritance and mixins
A herança é um dos princípios fundamentais da Orientação a Objetos. Definem-na como sendo um relacionamento do tipo "is-a" [é-um(a)] entre objetos(ou classes, se a linguagem permitir). Consideremos, por exemplo, uma pessoa e um estudante genéricos. Pode-se dizer que o estudante é uma pessoa que herda todas as "características" (features) de uma pessoa genérica. Contudo, o estudante possui um "perfil especializado" (specialized profile | qualidade particular) - ele estuda. Vale também para cantor, professor, advogado, etc.
O "is-a" é uma relação, uma herança. Pode-se dizer, de certo modo, que se um objeto A herda de um objeto B, então A é B, ou seja, A é uma versão especializada de B. Às vezes, diz-se que A é derivado de B, enquanto B é o objeto base ou pai.
Em que é útil a herança na programação orientada a objetos? Como todo objeto derivado possui todos os recursos do objeto base, a herança ajuda a reduzir código redundante entre objetos semelhantes, os que compartilham recursos entre si. O objeto base contém os "recursos(características) base" (common features), que são compartilhados com os objetos derivados. Ademais, torna o código sustentável, já que se pode alterar um recurso apenas no objeto base e compartilhá-lo com todos os objetos derivados.
Nós já vimos duas maneiras de criar objetos. A primeira e mais simples usa a notação literal:
let obra = {
data: new Date(),
autor: "Luís de Camões"
};
A segunda e mais flexível é a que usa uma função construtora:
function Obra(data, autor) {
this.data = data;
this.autor = autor.
}
let obra = new Obra( new Date(), "Luis de Camões" )
Não há diferença quanto aos resultados e, talvez, por isso tendemos a acreditar que criamos dois objetos do zero. Não é verdade. Nos dois casos criamos um objeto derivado de uma instância do construtor Object(). É um construtor que nos permite criar o objeto base de todos os objetos Javascript: o objeto vazio {}
. Todo objeto criado literalmente ou via construtor herda todas as propriedades e métodos de uma instância do objeto vazio.
O método toString()
nos ajuda a verificar:
let obra = {
data: new Date(),
autor: "Luís de Camões"
};
obra.toString(); // resultado: "[object Object]"
Não definimos o método toString()
, contudo, a qualquer objeto criado via notação literal ou função construtora é anexado esse e mais alguns métodos herdados do objeto vazio. Além disso, haverá um construtor assossiado ao "objeto literal". Verifiquemos isso acessando a propriedade herdada pelo construtor:
obra.constructor.autor; // resultado: "Object"
Também podemos verificar se nosso objeto é uma instância do "Object constructor" usando o operador instanceof
:
console.log( obra instanceof Object ); // resultado: true
Até agora não sabemos como criar objetos do zero, apenas os que derivam de uma instância vazia do "Object() constructor".
Em JavaScript diz-se que o objeto vazio é o "prototype" dos seus demais objetos.
O "prototype" é um objeto que serve de modelo para outro objeto. Não há classes em JavaScript, logo, os recursos compartilhados entre objetos são tomados dos objetos referência. Em outras palavras, se precisarmos de um objeto que seja semelhante a um objeto existente A, criamos um novo objeto B dizendo que seu prototype
é o objeto existente A. Esse é o mecanismo básico de herança da JavaScript (linguagem)'.
Todos os objetos JavaScript pussuem prototype
, incluindo funções. O prototype de um objeto literal é o objeto vazio {}
. Quando criamos um objeto via função construtora, o prototype do novo objeto é o prototype da função construtora.
Há duas maneiras de acessar o prototype de um objeto.
A primeira é usar a propriedade prototype do construtor. Por exemplo, podemos criar um objeto com o construtor de Obra()
e colocar o prototype do objeto em uma variável da seguinte forma:
let protoOfObra = obra.constructor.prototype;
Vale também para o objeto literal, visto ter um método construtor, o Object()
.
A segunda é usar o método Object.getPrototypeOf()
:
let protoOfObra = Object.getPrototypeOf( obra );
Em ambos os casos obtemos o objeto modelo do objeto Obra.
Podemos verificar se um objeto é prototype de outro usando o método isPrototypeOf()
:
let l = Object.getPrototypeOf( obra );
console.log( l.isPrototypeOf( obra ) ); // resultado: true
console.log( obra.isPrototypeOf( l ) ); // resultado: false
Com esse exemplo podemos notar que a relação prototype entre objetos não é simétrica, ou seja, se o objeto A é prototype de B, então B não é prototype de A.
Ao discutirmos objetos e prototypes entendemos que criar objetos está intimamente ligado à herança. Duas são as maneiras de criar objetos: notação literal e método construtor, e, ambas criam-nos com um "prototype built-in" (default). Pergunto:
- 1º. Podemos criar objetos sem prototype?
- 2º. Podemos selecionar um prototype específico?
Sim, podemos. O Object.create()
tem esse poder. Ele permite que se crie objetos de modo funcional, e ser funcional é ser flexível.
Podemos criar um objeto sem prototype da seguinte maneira:
let obra = Object.create( null )
Aí está. A variável obra
contém um objeto sem prototype. Ele não herda os recursos do objeto vazio, ou seja, não possui o método toString()
e os demais que vimos anteriormente. Obteremos o seguinte ao verificar o prototype:
console.log( Object.getPrototypeOf( obra ) ); //resultado: null
O prototype do objeto é null
. É o valor que demos ao método Object.create()
. Para criarmos um objeto cujo prototype seja outro objeto, especificamo-lo como argumento do método. Vejamos:
let obra = {
data: new Date(),
autor: "Luís de Camões"
}
let lusiadas = Object.create( livro )
Agora, lusiadas
tem obra
como prototype - Os Lusíadas é uma obra.
O método Object.create()
permite-nos especificar novas propriedades para o novo objeto. Passamo-las como segundo argumento (opcional) do método. Vejamos:
let obra = {
data: new Date(),
autor: ""
};
let livro = Object.create(obra, {
nome: { writable: true, configurable: true }
})
Adicionamos a propriedade nome
ao objeto livro
via descritor de propriedades[1]. Com isso, o objeto livro
herda as propriedades do prototype e a nova propiedade definida no momento da criação.
Podemos associar um prototype a um objeto na criação - via Object.create()
, e também após criarmos o objeto. E o método que se usa para tal é o Object.setPrototypeOf()
. Vejamos:
let obra = {
data: new Date(),
autor: "Luís de Camões"
}
let lusiadas = {
nome: "Os Lusíadas"
}
Object.setPrototypeOf( lusiadas, obra )
Agora, o objeto obra
é prototype do objeto lusiadas
, logo, lusiadas
herda os membros data
e autor
de obra
:
console.log( lusiadas.data ) // resultado: "2019-07-05T16:08:19.392Z"
console.log( lusiadas.autor ) // resultado: "Luís de Camões"
Acabamos de combinar objetos após a criação dos mesmos. Na verdade, o método Object.setPrototypeOf()
permite-nos alterar o prototype de um objeto, modificar os seus recursos (comportamento em OO), em tempo de execução (run-time). Em nosso caso, o prototype inicial de ambos os objetos, obra
e lusiadas
, era o do objeto vazio {}
. Mas, ao executarmos o método Object.setPrototypeOf()
o prototype de lusiadas
passa a ser outro.
Apesar de o método Object.setPrototypeOf()
ser interessante, deve-se evitá-lo, pois o seu modo dinâmico e em tempo de execução de trabalhar com o prototype produz impactos negativos no desempenho da aplicação. O Object.create()
, ao contrário, permite que o mecanismo JavaScript analize e otimize o seu código de modo estático.
Ao criarmos um objeto podemos especificar o seu prototype graças ao método Object.create()
e, como bem sabemos, o objeto herdará os recursos do prototype. O prototype de um objeto criado da maneira "tradicional", por sua vez, possui seu próprio prototype. Observemos o código a seguir:
let obra = {
data: new Date(),
autor: "Luís de Camões"
}
let lusiadas = Object.create(obra, {
nome: { writable: true, configurable: true }
});
Ao usar o objeto lusiadas
podemos acessar a propriedade nome
, adicionada no momento em que criamos o objeto, bem como data
e autor
, herdadas do seu prototype, o objeto obra
. Contudo, o objeto obra
possui o seu próprio prototype, pois quando se cria um objeto usando a notação literal, repito, o seu prototype é o objeto vazio {}
. Sendo assim, o objeto obra
herda os membros - por exemplo, o método toString()
- do objeto "default". Logo, o objeto lusiadas
possui tanto o prototype de obra
quanto o do objeto vazio {}
. Vejamos:
console.log( lusiadas.toString() ); // "[object Object]"
Conclui-se que a herança é um relacionamento entre vários objetos e não apenas entre dois. Um objeto herda todos os membros de seu prototype e os do prototype do prototype, e por aí vai. Esse encadeamento de prototypes é comumente chamado de prototype chain
.
Que realmente significa ser membro (herdeiro|herdado) do prototype?
O mecanismo de herança é extremamente simples. Ao se tentar acessar um membro qualquer, o sistema, a princípio, procura-o no objeto mesmo [em si]. Se não o encontra, [o sistema] procura-o no prototype do objeto. E se novamente não o encontra, percorre-se a cadeia de prototypes até que, ou o membro ou o valor null
seja encontrado. No primeiro caso: retorna-se o membro; no segundo: retorna-se undefined
. Veja a representação gráfica do percurso que o sistema faz na cadeia de prototypes em busca do método toString()
:
+----------------------------------------------------------------------------+
| +----+ |
| +--------| {} |--------+ |
| | +----+ | |
| prototype | |
| | . toString() | |
| | | |
| +----------------------+ |
| ↑ | | | |
| | +------+ |
| | +--------| obra |--------+ |
| | | +------+ | |
| prototype . data | |
| | . autor | |
| | | |
| +------------------------+ |
| ↑ |
| | +----------+ |
| | +--------| lusiadas |--------+ |
| | | +----------+ | |
| prototype | |
| | . nome | |
| | | |
| +----------------------------+ |
+----------------------------------------------------------------------------+
O mecanismo de prototypes é flexível e consome pouca memória. Contudo, se se tem uma cadeia de prototypes muito longa, corre-se o risco de se ter uma aplicação pouco performática. Logo, devemos limitar a cadeia de prototypes para reduzir os problemas de performance em nossos aplicativos.
Pode-se gerenciar a herança mediante "object constructors" (construtores de objeto). Em linguagem simples: pode-se criar objetos mediante uma função construtora que herdam "características / recursos" de outros objetos. Ou seja, uma relação de tipo "is-a" entre construtores.
Vejamos o construtor de objetos do tipo Pessoa
:
function Pessoa(nome, sobreNome) {
this.nome = nome;
this.sobreNome = sobreNome;
}
Vejamos o construtor de objetos do tipo desenvolvedor
que herdam recursos de objetos criados pelo construtor Pessoa()
:
function Desenvolvedor(nome, sobreNome, linguagem_favorita) {
Pessoa.apply(this, arguments);
this.linguagem_favorita = linguagem_favorita;
}
Observemos que no "standard constructor" se usa o método apply
do construtor Pessoa
. O método apply
executa o método (ou função) Pessoa
no contexto e com os argumentos do Desenvolvedor
. Ou seja, cria-se as propriedades da função Pessoa
na instância do Desenvolvedor
.
Ao se criar uma instância do Desenvolvedor
, obtém-se as propriedades: nome
, sobreNome
e linguagem_favorita
:
let desenvolvedor_01 = new Desenvolvedor("Lucas", "Lopes", "Haskell");
console.log( desenvolvedor_01.nome ) // Lucas
console.log( desenvolvedor_01.sobreNome ) // Lopes
console.log( desenvolvedor_01.linguagem_favorita ) // Haskell
Essa abordagem permite que se herde membros de Pessoa
em objetos Desenvolvedor
. Contudo, observemos como ela quebra a consistência do operador instanceof
na cadeia de prototypes:
let desenvolvedor_01 = new Desenvolvedor("Lucas", "Lopes", "Haskell");
desenvolvedor_01 instanceof Desenvolvedor; // true
desenvolvedor_01 instanceof Pessoa; // false
desenvolvedor_01 instanceof Object; // true
Nota-se que uma instância do Desenvolvedor
não é considerada também como uma instância de Pessoa
. Deve-se corrigir esse problema atribuindo explícitamente um prototype ao construtor Desenvolvedor
:
Desenvolvedor.prototype = Object.create(Pessoa.prototype);
Desenvolvedor.prototype.constructor = Desenvolvedor;
// OU
function extendss(derivaded, base) {
derivaded.prototype = Object.create(base.prototype);
derivaded.prototype.constructor = derivaded;
}
extendss(Desenvolvedor, Pessoa);
A "palavra-chave" (construção) class
exposta pela especificação ECMAScript6 permite-nos trabalhar com herança de forma simples. Com sintaxe semelhante à de linguagens OO clássicas, pode-se fazer com uma classe herde de outra. Reescravamos em sintaxe ES6 o exemplo anterior:
class Pessoa {
constructor(nome, sobreNome) {
this.nome = nome;
this.sobreNome = sobreNome;
}
}
class Desenvolvedor extends Pessoa { // Atenção
constructor(nome, sobreNome, linguagem_favorita) {
super(nome, sobreNome); // Atenção
this.linguagem_favorita = linguagem_favorita;
}
}
As linhas importantes desse exemplo contêm o comentário Atenção
. Nota-se que a classe Desenvolvedor
herda da classe Pessoa
mediante a palavra-chave extends
. Ademais, a classe Developer
, em seu construtor, chama a sua classe pai (ou base) mediante a palavra-chave super
. O exemplo acima é apenas uma maneira mais compacta e legível de se obter o mesmo resultado do código:
function Pessoa(nome, sobreNome) {
this.nome = nome;
this.sobreNome = sobreNome;
}
function Desenvolvedor(nome, sobreNome, linguagem_favorita) {
Pessoa.apply(this, arguments);
this.linguagem_favorita = linguagem_favorita;
}
function extendss(derivaded, base) {
derivaded.prototype = Object.create(base.prototype);
derivaded.prototype.constructor = derivaded;
}
extendss(Desenvolvedor, Pessoa);
Pode-se usar extends
de duas maneiras:
- Num método construtor de classe (class constructor method) para chamar o construtor base.
- Dentro do método de uma classe para usar os métodos da classe base
No primeiro caso podemos usá-lo como se usa uma função passando quaisquer parâmetros, como vimos ao definir a classe Desenvolvedor
. No segundo podemos usá-lo como um "objeto que expõe métodos", assim:
class Pessoa {
constructor(nome, sobreNome) {
this.nome = nome;
this.sobreNome = sobreNome;
}
obterNomeCompleto() {
return `${this.nome} ${this.sobreNome}`;
}
}
class Desenvolvedor extends Pessoa {
constructor(nome, sobreNome, linguagem_favorita) {
super(nome, sobreNome);
this.linguagem_favorita = linguagem_favorita;
}
exibirCompetencia() {
console.log( `${super.obterNomeCompleto()} é competente em: ${this.linguagem_favorita}` ) // Atenção
}
}
Classes e funções construtoras podem coexistir numa aplicação. Pode-se definir uma classe que herda de uma função construtora:
function Pessoa(nome, sobreNome) {
this.nome = nome;
this.sobreNome = sobreNome;
}
class Desenvolvedor extends Pessoa {
constructor(nome, sobreNome, linguagem_favorita) {
super(nome, sobreNome);
this.linguagem_favorita = linguagem_favorita;
}
}
Contudo, não se pode definir uma função construtora que herde de uma classe.