Hoje iremos compreender um pouco sobre Construtores no contexto do JavaScript Orientado a Objetos, um padrão de projeto para a criação de objetos que possui muita similaridade com a orientação a objetos com classes.
Para que você aproveite melhor o texto é recomendado que você tenha conhecimento dos conceitos básicos de orientação a objetos. Você pode ler uma pequena introdução no texto anterior dessa série. Também é recomendado que você conheça o comportamento e características dos objetos e funções em JavaScript. O texto hoje é um pouco longo porque achei importante unir os principais tópicos de cada padrão em uma só publicação.
Introdução 🙝
Na orientação a objetos, um construtor é uma rotina utilizada para inicializar um objeto cuja a memória para ele já foi alocada. Em linguagens com orientação a objetos baseada em classes, como é o caso do Java e do C++, o construtor é definido na própria classe e é invocado toda vez que uma instância dessa classe é criada.
O JavaScript não possui classes, mas o conceito de construtores é feito por meio de funções que inicializam objetos. Por exemplo, veja a função Person
abaixo, que recebe um objeto target
, além de um nome junto e um sobrenome em name
e surname
.
function Person(target, name, surname) {
target.name = name;
target.surname = surname;
target.getFullName = getFullName;
return target;
}
function getFullName() {
return this.name + ' ' + this.surname;
}
Veja que a função Person
recebe o objeto alvo e inicializa-o, atribuindo as propriedades e métodos que definem o objeto.
Veja que podemos utilizá-la como um construtor, uma vez que ela inicializa um objeto e permite a interação por meio de métodos e acesso às propriedades.
const a = Person({}, 'Pedremildo', 'Escavadeira');
const b = Person({}, 'Testerson', 'Trunk');
console.log(a.name); // → Pedremildo
console.log(b.surname); // → Trunk
console.log(a.getFullName()); // → 'Pedremildo Escavadeira'
console.log(a.getFullName === b.getFullName); // → true
Construtores 🙝
O exemplo anterior de um construtor está “bem comportado”, uma vez que estamos passando objetos “vazios” com o literal {…}
. Quando estamos falando de construtores, normalmente estamos falando de inicializar objetos recém-criados, mas nada garante que alguém passe qualquer objeto como o target
do nosso construtor.
Para isso, o JavaScript fornece o operador new
. O new
é um operador utilizado antes da chamada de funções e que muda alguns detalhes da sua execução. Ao executar new ⟨função⟩(⟨argumentos⟩)
, o JavaScript faz os seguintes passos:
- Cria um objeto vazio com protótipo igual a propriedade
prototype
da função; - “Amarra” o contexto
this
da função ao objeto criado no passo 1; - Retorna o objeto criado no passo 1 caso a função retorne
undefined
.
Assim, podemos reescrever a função Person
acima de maneira mais simples e intuitiva:
function Person(name, surname) {
this.name = name;
this.surname = surname;
}
Person.prototype.getFullName = function () {
return this.name + ' ' + this.surname;
};
Veja que não é mais necessário receber um objeto como parâmetro, pois o this
é implicitamente definido como o objeto recém criado pelo operador new
. Também não há necessidade de retornar um objeto, já que o new
implicitamente fará isso. Além disso, não precisamos mais atribuir os métodos diretamente no objeto como antes, uma vez que utilizamos a cadeia de protótipos. O operador new
irá criar um objeto com protótipo igual a Person.prototype
, onde nossos métodos são definidos.
O operador new
garante que um objeto “novo” é fornecido, então, precisamos instânciar nossos objetos utilizando ele. Veja que podemos fazer exatamente os mesmos testes que o exemplo anterior.
const a = new Person('Pedremildo', 'Escavadeira');
const b = new Person('Testerson', 'Trunk');
console.log(a.name); // → Pedremildo
console.log(b.surname); // → Trunk
console.log(a.getFullName()); // → 'Pedremildo Escavadeira'
console.log(a.getFullName === b.getFullName); // → true
Utilizar construtores com o operador new
e a cadeia de protótipos é considerado a técnica mais comum e recomendada de desenvolver código orientado a objetos em JavaScript, e é nela que iremos nos aprofundar nos próximos tópicos.
Herança 🙝
Podemos alcançar a herança ao utilizar uma chamada ao construtor qual se quer herdar as características com o auxílio do método apply
. O apply
é um método de funções (lembre-se que funções, no JavaScript, também são objetos), que recebe dois parâmetros: o primeiro, um objeto, indica contexto em que a função deve ser aplicada, ou seja, que this
se refere; e o segundo, um arranjo, que recebe os argumentos que devem ser passados a função a ser executada.
Vamos criar um novo construtor, Employee
, que estende Person
e adiciona uma propriedade e um método: salary
e getTax
, respectivamente. Veja como essa herança é implementada abaixo:
function Employee(name, surname, salary) {
Person.apply(this, [name, surname]);
this.salary = salary;
}
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.getTax = function () {
return this.salary * 0.08;
};
De forma geral, o que o método apply
está fazendo é executar a função Person
no objeto this
da função Employee
, algo muito similar à chamada super
em linguagens com orientação a objetos baseada em classes. Além disso, precisamos adequar o protótipo de Employee
para estender o protótipo de Person
e, assim, herdar os métodos por meio da cadeia de protótipos. Para fazer isso, utilizamos a função Object.create
, que cria um objeto com um protótipo definido.
Veja que a utilização é exatamente igual à Person
, no entanto, mais propriedades e métodos estão disponíveis.
const a = new Employee('Pedremildo', 'Escavadeira', 100);
const b = new Person('Testerson', 'Trunk');
console.log(a.name); // → Pedremildo
console.log(b.surname); // → Trunk
console.log(a.getFullName()); // → 'Pedremildo Escavadeira'
console.log(a.getFullName === b.getFullName); // → true
console.log(a.getTax()); // → 8
Encapsulamento 🙝
Como os objetos são estruturas de dados muito parecidos com uma tabela hash, todas as propriedades do objeto podem ser acessadas. O conceito de encapsulamento, com construtores, não é regido pela linguagem, mas sim por convenção.
Normalmente se queremos que uma propriedade do objeto seja “protegida” do meio externo, fazemos isso por meio de uma notação padrão, normalmente prefixando o nome da propriedade com _
. Vamos refatorar o exemplo acima para adicionar uma propriedade “privada” que armazena a taxa do imposto de um empregado.
function Employee(name, surname, salary) {
Person.apply(this, [name, surname]);
this._rate = 0.08;
this.salary = salary;
}
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.getTax = function () {
return this.salary * this._rate;
};
Veja que a propriedade _rate
é adicionada ao objeto como qualquer outra, no entanto, como prefixamos o nome com _
, quem utiliza esse objeto sabe que essa propriedade não deve ser manipulada, pois o comportamento do objeto pode se tornar imprevisível.
Relembrando: a propriedade _rate
é exibida e pode ser acessada como qualquer outra propriedade do objeto, mas, por convenção, indica-se que ela não deve ser manipulada fora do próprio objeto. Esse tipo de convenção não é único da linguagem JavaScript, outras linguagens dinâmicas, como o Python, adotam regras similares.
Polimorfismo 🙝
Em JavaScript o polimorfismo pode ser realizado sem muitos problemas, uma vez que funções são objetos, objetos são mutáveis e variáveis não possuem tipos definidos. Normalmente o polimorfismo se dá por meio do duck typing e que fazem o Teste do Pato.
Se algo parece com um pato, nada como um pato e grasna como um pato, então provavelmente é um pato
No contexto de programação, isso significa que não é o tipo nem a cadeia de herança de do objeto que irá definir sua semântica, mas sim suas propriedades e métodos. Se um conjunto de objetos contém um método com o nome foo
, você pode chamá-los sem problema.
Não há um contrato pré-definido entre as objetos — como é o caso das interfaces em Java ou classes abstratas em C++ — e por isso fica a cargo do codificador utilizar a mesmo protocolo nos objetos em que deve-se estabelecer um polimorfismo.
Propriedades e métodos estáticos 🙝
Propriedades estáticas são atributos/métodos que pertencem ao construtor e não a cada um dos objetos construídos por ele. Propriedades estáticas dos construtores podem ser criadas atribuindo propriedades aos próprios construtores.
Vamos exemplificar a utilização de propriedades estáticas no construtor Person
para armazenar a quantidade de objetos já criados por ele ou qualquer descendente.
function Person(name, surname) {
Person.count += 1;
this.name = name;
this.surname = surname;
}
Person.count = 0;
Person.prototype.getFullName = function () {
return this.name + ' ' + this.surname;
};
Assim, podemos agora verificar quantas instâncias já foram criadas, veja:
console.log(Person.count); // → 0
const a = new Person('Pedremildo', 'Escavadeira');
console.log(Person.count); // → 1
const b = new Person('Testerson', 'Trunk');
console.log(Person.count); // → 2
Garantias de instanciação 🙝
O JavaScript também permite verificar se um objeto é uma instância de um construtor por meio do operador instanceof
. Veja abaixo:
const a = new Employee('Pedremildo', 'Escavadeira', 100);
const b = new Person('Testerson', 'Trunk');
console.log(a instanceof Employee); // → true
console.log(a instanceof Person); // → true
console.log(a instanceof Object); // → true
console.log(b instanceof Employee); // → false
console.log(b instanceof Person); // → true
console.log(b instanceof Object); // → true
O operador instanceof
testa se a propriedade prototype
do construtor à direita está na cadeia de protótipos do objeto à esquerda, e, com isso, determina se um objeto foi construído por aquele.
O uso do instanceof
é bastante utilizado para garantir a segurança de um construtor. Uma vez que um construtor é uma função qualquer que utiliza o contexto this
, o que acontece se chamar a função sem o new
?
Nesse caso, não cria-se um objeto novo e o this
no construtor passa a ser o contexto atual de onde a função foi executada. Assim, o construtor, que espera que o this
seja um objeto recém-criado na verdade recebe outro e isso pode causar efeitos colaterais imprevisíveis e bugs de difícil localização. Para garantir que o construtor sempre receba no this
o objeto esperado, podemos adicionar o seguinte teste:
function Person(name, surname) {
if (!(this instanceof Person)) throw new TypeError();
this.name = name;
this.surname = surname;
}
Person.prototype.getFullName = function () {
return this.name + ' ' + this.surname;
};
Assim, se caso alguém chame Person
sem o new
, teremos um erro.
const a = Person('Pedremildo', 'Escavadeira'); // → erro!
const b = new Person('Testerson', 'Trunk'); // → sucesso
Conclusões 🙝
Utilizar construtores traz várias vantagens que incluem:
- Herança simplificada: basta chamar o construtor em outro e adequar a cadeia herdar as propriedades;
- Polimorfismo simplificado: não há contratos entre diferentes construtores.
- Uso eficiente da memória: cada método é somente alocado uma vez e compartilhado entre todos os objetos através do uso de protótipos;
Porém, alguns pontos devem ser considerados:
- Não permite um verdadeiro encapsulamento, todas as propriedades são acessíveis;
- É necessário sempre tomar cuidado ou tratar a chamada de construtores sem o operador
new
; - Justamente porque os métodos são compartilhados e dependem de um contexto específico, é necessário uma atenção especial quando os métodos são chamados para evitar problemas com o
this
.
Apesar dos pontos “negativos”, a utilização de construtores em JavaScript é tão comum que grande parte das APIs fornecidas pelos ambientes de execução utilizam esse padrão. No entanto, há uma outro padrão para programarmos JavaScript orientado a objetos: as Fábricas, que mitigam os pontos negativos vistos acima ao custo de alguns dos positivos. Mas, esse assunto fica para a próxima publicação.
Até a próxima!