A Vida Secreta dos Objetos
Um tipo de dado abstrato é realizado escrevendo um tipo especial de programa […] que define o tipo em termos das operações que podem ser realizadas sobre ele.

Capítulo 4 apresentou os objetos do JavaScript como contêineres que armazenam outros dados. Na cultura de programação, programação orientada a objetos é um conjunto de técnicas que usam objetos como o princípio central de organização de programas. Embora ninguém concorde exatamente com sua definição precisa, a programação orientada a objetos moldou o design de muitas linguagens de programação, incluindo JavaScript. Este capítulo descreve como essas ideias podem ser aplicadas em JavaScript.
Tipos de Dados Abstratos
A ideia principal na programação orientada a objetos é usar objetos, ou melhor, tipos de objetos, como a unidade de organização do programa. Organizar um programa como um conjunto de tipos de objetos estritamente separados fornece uma forma de pensar sobre sua estrutura e, assim, impor algum tipo de disciplina, evitando que tudo se torne um emaranhado.
A maneira de fazer isso é pensar nos objetos mais ou menos como você pensaria em uma batedeira elétrica ou outro eletrodoméstico. As pessoas que projetam e montam uma batedeira precisam fazer um trabalho especializado que envolve ciência dos materiais e compreensão da eletricidade. Elas escondem tudo isso sob uma carcaça plástica lisa para que as pessoas que só querem misturar massa de panqueca não precisem se preocupar com esses detalhes — elas precisam entender apenas os poucos botões com os quais a batedeira pode ser operada.
Da mesma forma, um tipo de dado abstrato, ou classe de objeto, é um subprograma que pode conter código arbitrariamente complexo, mas expõe um conjunto limitado de métodos e propriedades que as pessoas que trabalham com ele devem usar. Isso permite que programas grandes sejam construídos a partir de vários tipos de “eletrodomésticos”, limitando o grau de emaranhamento entre essas partes ao exigir que interajam apenas de maneiras específicas.
Se um problema for encontrado em uma dessas classes de objetos, muitas vezes ele pode ser corrigido ou até completamente reescrito sem impactar o restante do programa. Melhor ainda, pode ser possível usar classes de objetos em vários programas diferentes, evitando a necessidade de recriar sua funcionalidade do zero. Você pode pensar nas estruturas de dados internas do JavaScript, como arrays e strings, como esses tipos de dados abstratos reutilizáveis.
Cada tipo de dado abstrato tem uma interface, o conjunto de operações que código externo pode realizar sobre ele. Quaisquer detalhes além dessa interface são encapsulados, tratados como internos ao tipo e sem importância para o restante do programa.
Até coisas básicas como números podem ser vistas como um tipo de dado abstrato cuja interface nos permite somá-los, multiplicá-los, compará-los e assim por diante. Na verdade, a fixação em objetos individuais como principal unidade de organização na programação orientada a objetos clássica é um pouco infeliz, já que partes úteis de funcionalidade frequentemente envolvem um grupo de diferentes classes de objetos trabalhando juntas de forma próxima.
Métodos
Em JavaScript, métodos nada mais são do que propriedades que armazenam valores de função. Este é um método simples:
function speak(line) { console.log(`The ${this.type} rabbit says '${line}'`); } let whiteRabbit = {type: "white", speak}; let hungryRabbit = {type: "hungry", speak}; whiteRabbit.speak("Oh my fur and whiskers"); // → The white rabbit says 'Oh my fur and whiskers' hungryRabbit.speak("Got any carrots?"); // → The hungry rabbit says 'Got any carrots?'
Normalmente, um método precisa fazer algo com o objeto sobre o qual foi chamado. Quando uma função é chamada como método — buscada como propriedade e imediatamente chamada, como em object.method() — a ligação chamada this em seu corpo aponta automaticamente para o objeto sobre o qual foi chamada.
Você pode pensar em this como um parâmetro extra que é passado para a função de uma maneira diferente dos parâmetros normais. Se quiser fornecê-lo explicitamente, você pode usar o método call de uma função, que recebe o valor de this como seu primeiro argumento e trata os argumentos seguintes como parâmetros normais.
speak.call(whiteRabbit, "Hurry"); // → The white rabbit says 'Hurry'
Como cada função tem sua própria ligação this, cujo valor depende da forma como é chamada, você não pode se referir ao this do escopo externo em uma função comum definida com a palavra-chave function.
Funções arrow são diferentes — elas não vinculam seu próprio this, mas conseguem enxergar o this do escopo ao redor. Assim, você pode fazer algo como o código a seguir, que referencia this de dentro de uma função local:
let finder = { find(array) { return array.some(v => v == this.value); }, value: 5 }; console.log(finder.find([4, 5])); // → true
Uma propriedade como find(array) em uma expressão de objeto é uma forma abreviada de definir um método. Ela cria uma propriedade chamada find e atribui a ela uma função como valor.
Se eu tivesse escrito o argumento de some usando a palavra-chave function, esse código não funcionaria.
Protótipos
Uma forma de criar um tipo de objeto coelho com um método speak seria criar uma função auxiliar que recebe um tipo de coelho como parâmetro e retorna um objeto contendo esse tipo na propriedade type e nossa função speak na propriedade speak.
Todos os coelhos compartilham esse mesmo método. Especialmente para tipos com muitos métodos, seria bom se houvesse uma forma de manter os métodos de um tipo em um único lugar, em vez de adicioná-los individualmente a cada objeto.
Em JavaScript, protótipos são a forma de fazer isso. Objetos podem ser ligados a outros objetos para, “magicamente”, obter todas as propriedades que o outro objeto possui. Objetos simples criados com a notação {} são ligados a um objeto chamado Object.prototype.
let empty = {}; console.log(empty.toString); // → function toString(){…} console.log(empty.toString()); // → [object Object]
Parece que acabamos de acessar uma propriedade de um objeto vazio. Mas, na verdade, toString é um método armazenado em Object.prototype, o que significa que ele está disponível na maioria dos objetos.
Quando um objeto recebe uma requisição por uma propriedade que ele não possui, seu protótipo é consultado em busca dessa propriedade. Se não estiver lá, o protótipo do protótipo é consultado, e assim por diante, até que um objeto sem protótipo seja alcançado (Object.prototype é um desses).
console.log(Object.getPrototypeOf({}) == Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null
Como você pode imaginar, Object. retorna o protótipo de um objeto.
Muitos objetos não têm diretamente Object.prototype como seu protótipo, mas sim outro objeto que fornece um conjunto diferente de propriedades padrão. Funções derivam de Function. e arrays derivam de Array.prototype.
console.log(Object.getPrototypeOf(Math.max) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) == Array.prototype);
// → true
Esse objeto protótipo, por sua vez, terá um protótipo, frequentemente Object.prototype, de modo que ainda forneça indiretamente métodos como toString.
Você pode usar Object.create para criar um objeto com um protótipo específico.
let protoRabbit = { speak(line) { console.log(`The ${this.type} rabbit says '${line}'`); } }; let blackRabbit = Object.create(protoRabbit); blackRabbit.type = "black"; blackRabbit.speak("I am fear and darkness"); // → The black rabbit says 'I am fear and darkness'
O coelho “proto” atua como um contêiner para as propriedades compartilhadas por todos os coelhos. Um objeto coelho individual, como o coelho preto, contém propriedades que se aplicam apenas a ele — neste caso, seu tipo — e obtém as propriedades compartilhadas de seu protótipo.
Classes
O sistema de protótipos do JavaScript pode ser interpretado como uma abordagem um tanto flexível de tipos de dados abstratos ou classes. Uma classe define a forma de um tipo de objeto — quais métodos e propriedades ele possui. Esse objeto é chamado de uma instância da classe.
Protótipos são úteis para definir propriedades cujo valor é compartilhado por todas as instâncias de uma classe. Propriedades que diferem por instância, como a propriedade type dos nossos coelhos, precisam ser armazenadas diretamente nos próprios objetos.
Para criar uma instância de uma determinada classe, você precisa criar um objeto que derive do protótipo correto, mas também precisa garantir que ele tenha as propriedades que as instâncias dessa classe devem ter. É isso que uma função construtora faz.
function makeRabbit(type) { let rabbit = Object.create(protoRabbit); rabbit.type = type; return rabbit; }
A notação de classe do JavaScript facilita a definição desse tipo de função, junto com um objeto de protótipo.
class Rabbit { constructor(type) { this.type = type; } speak(line) { console.log(`The ${this.type} rabbit says '${line}'`); } }
A palavra-chave class inicia uma declaração de classe, que nos permite definir um construtor e um conjunto de métodos juntos. Qualquer número de métodos pode ser escrito dentro das chaves da declaração. Esse código tem o efeito de definir uma ligação chamada Rabbit, que contém uma função que executa o código em constructor e possui uma propriedade prototype que contém o método speak.
Essa função não pode ser chamada como uma função normal. Construtores, em JavaScript, são chamados colocando a palavra-chave new antes deles. Fazer isso cria um novo objeto de instância cujo protótipo é o objeto da propriedade prototype da função, depois executa a função com this vinculado ao novo objeto e, por fim, retorna o objeto.
let killerRabbit = new Rabbit("killer");
Na verdade, class foi introduzido apenas na edição de 2015 do JavaScript. Qualquer função pode ser usada como construtor e, antes de 2015, a forma de definir uma classe era escrever uma função comum e depois manipular sua propriedade prototype.
function ArchaicRabbit(type) { this.type = type; } ArchaicRabbit.prototype.speak = function(line) { console.log(`The ${this.type} rabbit says '${line}'`); }; let oldSchoolRabbit = new ArchaicRabbit("old school");
Por esse motivo, todas as funções que não são arrow começam com uma propriedade prototype contendo um objeto vazio.
Por convenção, os nomes de construtores começam com letra maiúscula para que possam ser facilmente distinguidos de outras funções.
É importante entender a distinção entre a forma como um protótipo é associado a um construtor (por meio de sua propriedade prototype) e a forma como objetos têm um protótipo (que pode ser obtido com Object.). O protótipo real de um construtor é Function., já que construtores são funções. A propriedade prototype da função construtora contém o protótipo usado para instâncias criadas por ela.
console.log(Object.getPrototypeOf(Rabbit) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf(killerRabbit) ==
Rabbit.prototype);
// → true
Construtores normalmente adicionam algumas propriedades específicas de instância a this. Também é possível declarar propriedades diretamente na declaração de classe. Diferentemente dos métodos, essas propriedades são adicionadas aos objetos de instância e não ao protótipo.
class Particle { speed = 0; constructor(position) { this.position = position; } }
Assim como function, class pode ser usado tanto em instruções quanto em expressões. Quando usado como expressão, ele não define uma ligação, mas apenas produz o construtor como valor. Você pode omitir o nome da classe em uma expressão de classe.
let object = new class { getWord() { return "hello"; } }; console.log(object.getWord()); // → hello
Propriedades Privadas
É comum que classes definam algumas propriedades e métodos para uso interno que não fazem parte de sua interface. Essas são chamadas de propriedades privadas, em oposição às públicas, que fazem parte da interface externa do objeto.
Para declarar um método privado, coloque um sinal # antes de seu nome. Esses métodos só podem ser chamados de dentro da class que os define.
class SecretiveObject { #getSecret() { return "I ate all the plums"; } interrogate() { let shallISayIt = this.#getSecret(); return "never"; } }
Quando uma classe não declara um construtor, ela recebe automaticamente um construtor vazio.
Se você tentar chamar #getSecret de fora da classe, receberá um erro. Sua existência fica completamente oculta dentro da declaração da classe.
Para usar propriedades privadas de instância, você deve declará-las. Propriedades normais podem ser criadas apenas atribuindo valores a elas, mas propriedades privadas devem ser declaradas na declaração da classe para existirem.
Esta classe implementa um “eletrodoméstico” para obter um número inteiro aleatório abaixo de um número máximo dado. Ela tem apenas uma propriedade pública: getNumber.
class RandomSource { #max; constructor(max) { this.#max = max; } getNumber() { return Math.floor(Math.random() * this.#max); } }
Sobrescrevendo propriedades derivadas
Quando você adiciona uma propriedade a um objeto, esteja ela presente no protótipo ou não, a propriedade é adicionada ao próprio objeto. Se já existia uma propriedade com o mesmo nome no protótipo, ela deixa de afetar o objeto, pois agora está “escondida” pela propriedade do próprio objeto.
Rabbit.prototype.teeth = "small"; console.log(killerRabbit.teeth); // → small killerRabbit.teeth = "long, sharp, and bloody"; console.log(killerRabbit.teeth); // → long, sharp, and bloody console.log((new Rabbit("basic")).teeth); // → small console.log(Rabbit.prototype.teeth); // → small
O diagrama a seguir ilustra a situação após a execução desse código. Os protótipos Rabbit e Object ficam atrás de killerRabbit como uma espécie de pano de fundo, onde propriedades que não são encontradas no próprio objeto podem ser buscadas.
Sobrescrever propriedades que existem em um protótipo pode ser algo útil. Como o exemplo dos dentes do coelho mostra, isso pode ser usado para expressar propriedades excepcionais em instâncias de uma classe mais genérica, enquanto os objetos não excepcionais usam um valor padrão do protótipo.
A sobrescrita também é usada para dar aos protótipos padrão de funções e arrays um método toString diferente do protótipo básico de objeto.
console.log(Array.prototype.toString ==
Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2
Chamar toString em um array produz um resultado semelhante a chamar . — ele coloca vírgulas entre os valores do array. Chamar diretamente Object. com um array produz uma string diferente. Essa função não conhece arrays, então simplesmente coloca a palavra object e o nome do tipo entre colchetes.
console.log(Object.prototype.toString.call([1, 2])); // → [object Array]
Maps
Vimos a palavra map usada no capítulo anterior para uma operação que transforma uma estrutura de dados aplicando uma função aos seus elementos. Por mais confuso que seja, na programação a mesma palavra também é usada para algo relacionado, mas diferente.
Um map é uma estrutura de dados que associa valores (as chaves) a outros valores. Por exemplo, você pode querer mapear nomes para idades. É possível usar objetos para isso.
let ages = { Boris: 39, Liang: 22, Júlia: 62 }; console.log(`Júlia is ${ages["Júlia"]}`); // → Júlia is 62 console.log("Is Jack's age known?", "Jack" in ages); // → Is Jack's age known? false console.log("Is toString's age known?", "toString" in ages); // → Is toString's age known? true
Aqui, os nomes das propriedades do objeto são os nomes das pessoas, e os valores das propriedades são suas idades. Mas certamente não listamos ninguém chamado toString no nosso map. Ainda assim, como objetos simples derivam de Object.prototype, parece que essa propriedade está lá.
Por esse motivo, usar objetos simples como maps é perigoso. Existem várias maneiras de evitar esse problema. Primeiro, você pode criar objetos sem protótipo. Se você passar null para Object.create, o objeto resultante não derivará de Object.prototype e poderá ser usado com segurança como map.
console.log("toString" in Object.create(null)); // → false
Nomes de propriedades de objetos devem ser strings. Se você precisar de um map cujas chaves não possam ser facilmente convertidas em strings — como objetos — não poderá usar um objeto como map.
Felizmente, JavaScript possui uma classe chamada Map feita exatamente para esse propósito. Ela armazena associações e permite qualquer tipo de chave.
let ages = new Map(); ages.set("Boris", 39); ages.set("Liang", 22); ages.set("Júlia", 62); console.log(`Júlia is ${ages.get("Júlia")}`); // → Júlia is 62 console.log("Is Jack's age known?", ages.has("Jack")); // → Is Jack's age known? false console.log(ages.has("toString")); // → false
Os métodos set, get e has fazem parte da interface do objeto Map. Escrever uma estrutura de dados que possa atualizar e pesquisar rapidamente um grande conjunto de valores não é fácil, mas não precisamos nos preocupar com isso. Alguém já fez esse trabalho, e podemos usar essa interface simples para aproveitá-lo.
Se você tiver um objeto simples que precisa tratar como map por algum motivo, é útil saber que Object.keys retorna apenas as chaves próprias do objeto, não aquelas do protótipo. Como alternativa ao operador in, você pode usar a função Object.hasOwn, que ignora o protótipo do objeto.
console.log(Object.hasOwn({x: 1}, "x"));
// → true
console.log(Object.hasOwn({x: 1}, "toString"));
// → false
Polimorfismo
Quando você chama a função String (que converte um valor para string) em um objeto, ela chamará o método toString desse objeto para tentar criar uma representação significativa. Mencionei que alguns protótipos padrão definem sua própria versão de toString para criar uma string mais útil do que "[object Object]". Você também pode fazer isso.
Rabbit.prototype.toString = function() { return `a ${this.type} rabbit`; }; console.log(String(killerRabbit)); // → a killer rabbit
Este é um exemplo simples de uma ideia poderosa. Quando um trecho de código é escrito para funcionar com objetos que têm uma determinada interface — neste caso, um método toString — qualquer tipo de objeto que suporte essa interface pode ser usado nesse código e funcionará corretamente.
Essa técnica é chamada de polimorfismo. Código polimórfico pode trabalhar com valores de diferentes formas, desde que eles suportem a interface esperada.
Um exemplo de interface amplamente usada é a de objetos semelhantes a array que têm uma propriedade length contendo um número e propriedades numeradas para cada elemento. Tanto arrays quanto strings suportam essa interface, assim como vários outros objetos, alguns dos quais veremos mais adiante nos capítulos sobre o navegador. Nossa implementação de forEach do Capítulo 5 funciona com qualquer coisa que forneça essa interface. Na verdade, Array. também funciona.
Array.prototype.forEach.call({
length: 2,
0: "A",
1: "B"
}, elt => console.log(elt));
// → A
// → B
Getters, setters e statics
Interfaces frequentemente contêm propriedades simples, não apenas métodos. Por exemplo, objetos Map têm uma propriedade size que informa quantas chaves estão armazenadas neles.
Não é necessário que o objeto calcule e armazene essa propriedade diretamente na instância. Mesmo propriedades acessadas diretamente podem ocultar uma chamada de método. Esses métodos são chamados de getters e são definidos escrevendo get antes do nome do método em uma expressão de objeto ou declaração de classe.
let varyingSize = { get size() { return Math.floor(Math.random() * 100); } }; console.log(varyingSize.size); // → 73 console.log(varyingSize.size); // → 49
Sempre que alguém lê a propriedade size desse objeto, o método associado é chamado. Você pode fazer algo semelhante quando uma propriedade recebe um valor, usando um setter.
class Temperature { constructor(celsius) { this.celsius = celsius; } get fahrenheit() { return this.celsius * 1.8 + 32; } set fahrenheit(value) { this.celsius = (value - 32) / 1.8; } static fromFahrenheit(value) { return new Temperature((value - 32) / 1.8); } } let temp = new Temperature(22); console.log(temp.fahrenheit); // → 71.6 temp.fahrenheit = 86; console.log(temp.celsius); // → 30
A classe Temperature permite ler e escrever a temperatura tanto em graus Celsius quanto em graus Fahrenheit, mas internamente armazena apenas Celsius e converte automaticamente de e para Celsius no getter e setter fahrenheit.
Às vezes você quer anexar algumas propriedades diretamente à função construtora em vez de ao protótipo. Esses métodos não terão acesso a uma instância da classe, mas podem, por exemplo, fornecer maneiras adicionais de criar instâncias.
Dentro de uma declaração de classe, métodos ou propriedades com static antes do nome são armazenados no construtor. Por exemplo, a classe Temperature permite escrever Temperature. para criar uma temperatura usando graus Fahrenheit.
let boil = Temperature.fromFahrenheit(212); console.log(boil.celsius); // → 100
Symbols
Mencionei no Capítulo 4 que um loop for/of pode percorrer vários tipos de estruturas de dados. Este é outro caso de polimorfismo — esses loops esperam que a estrutura de dados exponha uma interface específica, o que arrays e strings fazem. E também podemos adicionar essa interface aos nossos próprios objetos! Mas, antes disso, precisamos dar uma olhada rápida no tipo symbol.
É possível que várias interfaces usem o mesmo nome de propriedade para coisas diferentes. Por exemplo, em objetos semelhantes a array, length se refere ao número de elementos na coleção. Mas uma interface que descreve uma rota de caminhada poderia usar length para fornecer o comprimento da rota em metros. Não seria possível que um objeto atendesse a ambas as interfaces.
Um objeto tentando ser uma rota e também semelhante a array (talvez para enumerar seus pontos) é um pouco improvável, e esse tipo de problema não é tão comum na prática. Para coisas como o protocolo de iteração, porém, os projetistas da linguagem precisavam de um tipo de propriedade que realmente não entrasse em conflito com nenhuma outra. Assim, em 2015, os symbols foram adicionados à linguagem.
A maioria das propriedades, incluindo todas as que vimos até agora, é nomeada com strings. Mas também é possível usar symbols como nomes de propriedades. Symbols são valores criados com a função Symbol. Diferentemente de strings, symbols recém-criados são únicos — você não pode criar o mesmo symbol duas vezes.
let sym = Symbol("name"); console.log(sym == Symbol("name")); // → false Rabbit.prototype[sym] = 55; console.log(killerRabbit[sym]); // → 55
A string passada para Symbol é incluída quando ele é convertido para string e pode facilitar a identificação do symbol, por exemplo, no console. Mas não tem significado além disso — vários symbols podem ter o mesmo nome.
Por serem únicos e utilizáveis como nomes de propriedades, symbols são adequados para definir interfaces que possam coexistir com outras propriedades, independentemente de seus nomes.
const length = Symbol("length"); Array.prototype[length] = 0; console.log([1, 2].length); // → 2 console.log([1, 2][length]); // → 0
É possível incluir propriedades com symbol em expressões de objeto e classes usando colchetes ao redor do nome da propriedade. Isso faz com que a expressão entre colchetes seja avaliada para produzir o nome da propriedade, de forma análoga à notação de acesso por colchetes.
let myTrip = { length: 2, 0: "Lankwitz", 1: "Babelsberg", [length]: 21500 }; console.log(myTrip[length], myTrip.length); // → 21500 2
A interface de iteração
O objeto fornecido a um loop for/of deve ser iterável. Isso significa que ele possui um método nomeado com o symbol Symbol.iterator (um valor symbol definido pela linguagem e armazenado como propriedade da função Symbol).
Quando chamado, esse método deve retornar um objeto que fornece uma segunda interface, iterator. Este é o objeto que realmente faz a iteração. Ele tem um método next que retorna o próximo resultado. Esse resultado deve ser um objeto com uma propriedade value que fornece o próximo valor, se houver, e uma propriedade done, que deve ser true quando não houver mais resultados e false caso contrário.
Observe que os nomes das propriedades next, value e done são strings comuns, não symbols. Apenas Symbol.iterator, que provavelmente será adicionado a muitos objetos diferentes, é um symbol de fato.
Podemos usar essa interface diretamente.
let okIterator = "OK"[Symbol.iterator](); console.log(okIterator.next()); // → {value: "O", done: false} console.log(okIterator.next()); // → {value: "K", done: false} console.log(okIterator.next()); // → {value: undefined, done: true}
Vamos implementar uma estrutura de dados iterável semelhante à lista encadeada do exercício no Capítulo 4. Desta vez, escreveremos a lista como uma classe.
class List { constructor(value, rest) { this.value = value; this.rest = rest; } get length() { return 1 + (this.rest ? this.rest.length : 0); } static fromArray(array) { let result = null; for (let i = array.length - 1; i >= 0; i--) { result = new this(array[i], result); } return result; } }
Observe que this, em um método static, aponta para o construtor da classe, não para uma instância — não há instância quando um método static é chamado.
Iterar sobre uma lista deve retornar todos os elementos do início ao fim. Vamos escrever uma classe separada para o iterador.
class ListIterator { constructor(list) { this.list = list; } next() { if (this.list == null) { return {done: true}; } let value = this.list.value; this.list = this.list.rest; return {value, done: false}; } }
A classe acompanha o progresso da iteração atualizando sua propriedade list para avançar ao próximo objeto da lista sempre que um valor é retornado e indica que terminou quando essa lista está vazia (null).
Vamos configurar a classe List para ser iterável. Ao longo deste livro, ocasionalmente usarei manipulação de protótipo após a definição para adicionar métodos a classes, para que os trechos de código permaneçam pequenos e autocontidos. Em um programa real, você provavelmente declararia esses métodos diretamente na classe.
List.prototype[Symbol.iterator] = function() { return new ListIterator(this); };
Agora podemos percorrer uma lista com for/of.
let list = List.fromArray([1, 2, 3]); for (let element of list) { console.log(element); } // → 1 // → 2 // → 3
A sintaxe ... em arrays e chamadas de função também funciona com qualquer objeto iterável. Por exemplo, você pode usar [...value] para criar um array contendo os elementos de um objeto iterável qualquer.
console.log([..."PCI"]); // → ["P", "C", "I"]
Herança
Imagine que precisamos de um tipo de lista muito parecido com a classe List que vimos antes, mas como vamos consultar seu comprimento o tempo todo, não queremos percorrer rest a cada vez. Em vez disso, queremos armazenar o comprimento em cada instância para acesso eficiente.
O sistema de protótipos do JavaScript torna possível criar uma nova classe, semelhante à antiga, mas com novas definições para algumas de suas propriedades. O protótipo da nova classe deriva do protótipo antigo, mas adiciona uma nova definição para, por exemplo, o getter length.
Em termos de programação orientada a objetos, isso é chamado de herança. A nova classe herda propriedades e comportamento da classe antiga.
class LengthList extends List { #length; constructor(value, rest) { super(value, rest); this.#length = super.length; } get length() { return this.#length; } } console.log(LengthList.fromArray([1, 2, 3]).length); // → 3
O uso da palavra extends indica que essa classe não deve se basear diretamente no protótipo padrão Object, mas em outra classe. Isso é chamado de superclasse. A classe derivada é a subclasse.
Para inicializar uma instância de LengthList, o construtor chama o construtor de sua superclasse por meio da palavra-chave super. Isso é necessário porque, se esse novo objeto deve se comportar (aproximadamente) como uma List, ele precisará das propriedades de instância que listas possuem.
O construtor então armazena o comprimento da lista em uma propriedade privada. Se tivéssemos escrito this.length, o próprio getter da classe seria chamado, o que ainda não funciona porque #length ainda não foi definido. Podemos usar super.algo para chamar métodos e getters no protótipo da superclasse, o que frequentemente é útil.
Herança permite construir tipos de dados ligeiramente diferentes a partir de tipos existentes com relativamente pouco esforço. Ela é uma parte fundamental da tradição orientada a objetos, junto com encapsulamento e polimorfismo. Mas, enquanto os dois últimos são geralmente considerados ótimas ideias, herança é mais controversa.
Enquanto encapsulamento e polimorfismo podem ser usados para separar partes do código, reduzindo o emaranhado geral do programa, a herança fundamentalmente liga classes entre si, criando mais acoplamento. Ao herdar de uma classe, você geralmente precisa saber mais sobre como ela funciona do que ao simplesmente usá-la. Herança pode ser uma ferramenta útil para tornar alguns tipos de programas mais concisos, mas não deve ser a primeira opção, e você provavelmente não deveria sair procurando oportunidades para construir hierarquias de classes.
O operador instanceof
Às vezes é útil saber se um objeto foi derivado de uma classe específica. Para isso, o JavaScript fornece um operador binário chamado instanceof.
console.log( new LengthList(1, null) instanceof LengthList); // → true console.log(new LengthList(2, null) instanceof List); // → true console.log(new List(3, null) instanceof LengthList); // → false console.log([1] instanceof Array); // → true
O operador considera tipos herdados, então um LengthList é uma instância de List. O operador também pode ser aplicado a construtores padrão como Array. Quase todo objeto é uma instância de Object.
Resumo
Objetos fazem mais do que apenas armazenar suas próprias propriedades. Eles têm protótipos, que são outros objetos. Eles se comportam como se tivessem propriedades que não possuem, desde que seu protótipo tenha essas propriedades. Objetos simples têm Object.prototype como seu protótipo.
Construtores, que são funções cujos nomes geralmente começam com letra maiúscula, podem ser usados com o operador new para criar novos objetos. O protótipo do novo objeto será o objeto encontrado na propriedade prototype do construtor. Você pode aproveitar isso colocando no protótipo as propriedades compartilhadas por todos os valores de um tipo. Existe uma notação class que fornece uma maneira clara de definir um construtor e seu protótipo.
Você pode definir getters e setters para chamar métodos “secretamente” sempre que uma propriedade de um objeto é acessada. Métodos static são métodos armazenados no construtor da classe, e não em seu protótipo.
O operador instanceof pode, dado um objeto e um construtor, dizer se aquele objeto é uma instância desse construtor.
Uma coisa útil a se fazer com objetos é especificar uma interface para eles e dizer a todos que devem interagir com seu objeto apenas por meio dessa interface. O restante dos detalhes que compõem seu objeto fica encapsulado, escondido atrás da interface. Você pode usar propriedades privadas para ocultar partes do objeto do mundo externo.
Mais de um tipo pode implementar a mesma interface. Código escrito para usar uma interface automaticamente sabe trabalhar com qualquer número de objetos diferentes que a forneçam. Isso é chamado de polimorfismo.
Ao implementar várias classes que diferem apenas em alguns detalhes, pode ser útil escrever as novas classes como subclasses de uma classe existente, herdando parte de seu comportamento.
Exercícios
Um tipo de Vetor
Escreva uma classe Vec que represente um vetor em um espaço bidimensional. Ela recebe parâmetros x e y (números), que são armazenados em propriedades com os mesmos nomes.
Dê ao protótipo de Vec dois métodos, plus e minus, que recebem outro vetor como parâmetro e retornam um novo vetor com a soma ou diferença dos valores de x e y dos dois vetores (this e o parâmetro).
Adicione uma propriedade getter length ao protótipo que calcula o comprimento do vetor — isto é, a distância do ponto (x, y) até a origem (0, 0).
// Seu código aqui. console.log(new Vec(1, 2).plus(new Vec(2, 3))); // → Vec{x: 3, y: 5} console.log(new Vec(1, 2).minus(new Vec(2, 3))); // → Vec{x: -1, y: -1} console.log(new Vec(3, 4).length); // → 5
Mostrar dicas...
Veja novamente o exemplo da classe Rabbit se não tiver certeza de como são as declarações de class.
Adicionar uma propriedade getter ao construtor pode ser feito colocando a palavra get antes do nome do método. Para calcular a distância de (0, 0) até (x, y), você pode usar o teorema de Pitágoras, que diz que o quadrado da distância é igual ao quadrado da coordenada x mais o quadrado da coordenada y. Assim, √(x2 + y2) é o número desejado. Math.sqrt é a forma de calcular raiz quadrada em JavaScript e x ** 2 pode ser usado para elevar um número ao quadrado.
Grupos
O ambiente padrão do JavaScript fornece outra estrutura de dados chamada Set. Assim como uma instância de Map, um conjunto armazena uma coleção de valores. Diferentemente de Map, ele não associa outros valores a esses — apenas controla quais valores fazem parte do conjunto. Um valor pode estar no conjunto apenas uma vez — adicioná-lo novamente não tem efeito.
Escreva uma classe chamada Group (já que Set já existe). Como Set, ela tem métodos add, delete e has. Seu construtor cria um grupo vazio, add adiciona um valor ao grupo (apenas se ainda não estiver presente), delete remove seu argumento do grupo (se estiver presente), e has retorna um valor booleano indicando se o argumento pertence ao grupo.
Use o operador ===, ou algo equivalente como indexOf, para determinar se dois valores são iguais.
Dê à classe um método static from que recebe um objeto iterável como argumento e cria um grupo contendo todos os valores produzidos ao iterar sobre ele.
class Group { // Seu código aqui. } let group = Group.from([10, 20]); console.log(group.has(10)); // → true console.log(group.has(30)); // → false group.add(10); group.delete(10); console.log(group.has(10)); // → false
Mostrar dicas...
A maneira mais simples de fazer isso é armazenar um array de membros do grupo em uma propriedade da instância. Os métodos includes ou indexOf podem ser usados para verificar se um determinado valor está no array.
O construtor da sua classe pode inicializar a coleção de membros como um array vazio. Quando add for chamado, ele deve verificar se o valor já está no array ou adicioná-lo, possivelmente usando push.
Remover um elemento de um array, em delete, é menos direto, mas você pode usar filter para criar um novo array sem o valor. Não se esqueça de substituir a propriedade que contém os membros pela nova versão filtrada do array.
O método from pode usar um loop for/of para obter os valores do objeto iterável e chamar add para inseri-los em um novo grupo.
Grupos iteráveis
Torne a classe Group do exercício anterior iterável. Consulte a seção sobre a interface de iteração anteriormente neste capítulo se não se lembrar da forma exata da interface.
Se você usou um array para representar os membros do grupo, não simplesmente retorne o iterador criado ao chamar o método Symbol.iterator do array. Isso funcionaria, mas anula o propósito deste exercício.
Tudo bem se seu iterador se comportar de forma estranha quando o grupo for modificado durante a iteração.
// Seu código aqui (e o código do exercício anterior) for (let value of Group.from(["a", "b", "c"])) { console.log(value); } // → a // → b // → c
Mostrar dicas...
Provavelmente vale a pena definir uma nova classe GroupIterator. Instâncias de iteradores devem ter uma propriedade que acompanhe a posição atual no grupo. Cada vez que next é chamado, ele verifica se terminou e, caso contrário, avança para o próximo valor e o retorna.
A própria classe Group recebe um método nomeado por Symbol.iterator que, quando chamado, retorna uma nova instância da classe iteradora para aquele grupo.