Estruturas de Dados: Objetos e Arrays

Em duas ocasiões me perguntaram: “Diga, Sr. Babbage, se você colocar números errados na máquina, sairão respostas corretas?” [...] Não sou capaz de compreender corretamente o tipo de confusão de ideias que poderia provocar tal pergunta.

Charles Babbage, Passages from the Life of a Philosopher (1864)
Ilustração de um esquilo ao lado de uma pilha de livros e um par de óculos. Uma lua e estrelas são visíveis ao fundo.

Números, valores booleanos e strings são os átomos a partir dos quais as estruturas de dados são construídas. No entanto, muitos tipos de informação exigem mais de um átomo. Objetos nos permitem agrupar valores—incluíndo outros objetos—para construir estruturas mais complexas.

Os programas que construímos até agora foram limitados pelo fato de operarem apenas sobre tipos de dados simples. Depois de aprender os fundamentos de estruturas de dados neste capítulo, você saberá o suficiente para começar a escrever programas úteis.

O capítulo percorre um exemplo de programação mais ou menos realista, introduzindo conceitos conforme se aplicam ao problema em questão. O código de exemplo frequentemente se baseará nos conceitos de funções e ligações apresentados anteriormente no livro.

O esquilo-homem

De vez em quando, geralmente entre oito e dez horas da noite, Jacques se transforma em um pequeno roedor peludo com um rabo espesso.

Por um lado, Jacques fica bastante feliz por não ter a licantropia clássica. Transformar-se em um esquilo causa menos problemas do que virar um lobo. Em vez de ter que se preocupar em comer acidentalmente o vizinho (isso seria constrangedor), ele se preocupa em ser comido pelo gato do vizinho. Após duas ocasiões em que acordou em um galho perigosamente fino no topo de um carvalho, nu e desorientado, ele passou a trancar portas e janelas do quarto à noite e colocar algumas nozes no chão para se manter ocupado.

Mas Jacques preferiria se livrar completamente de sua condição. As ocorrências irregulares da transformação fazem com que ele suspeite que possam ser desencadeadas por algo. Por um tempo, ele acreditou que isso acontecia apenas em dias em que ele havia estado perto de carvalhos. No entanto, evitar carvalhos não resolveu o problema.

Adotando uma abordagem mais científica, Jacques começou a manter um registro diário de tudo o que faz em um determinado dia e se mudou de forma ou não. Com esses dados, ele espera reduzir as condições que desencadeiam as transformações.

A primeira coisa de que ele precisa é uma estrutura de dados para armazenar essas informações.

Conjuntos de dados

Para trabalhar com um pedaço de dados digitais, primeiro precisamos encontrar uma maneira de representá-los na memória da máquina. Digamos, por exemplo, que queremos representar uma coleção dos números 2, 3, 5, 7 e 11.

Poderíamos ser criativos com strings—afinal, strings podem ter qualquer tamanho, então podemos colocar muitos dados nelas—e usar "2 3 5 7 11" como nossa representação. Mas isso é estranho. Teríamos que extrair os dígitos de alguma forma e convertê-los de volta para números para acessá-los.

Felizmente, o JavaScript fornece um tipo de dado específico para armazenar sequências de valores. Ele é chamado de array e é escrito como uma lista de valores entre colchetes, separados por vírgulas.

let listOfNumbers = [2, 3, 5, 7, 11];
console.log(listOfNumbers[2]);
// → 5
console.log(listOfNumbers[0]);
// → 2
console.log(listOfNumbers[2 - 1]);
// → 3

A notação para acessar os elementos dentro de um array também usa colchetes. Um par de colchetes imediatamente após uma expressão, com outra expressão dentro deles, irá buscar o elemento na expressão à esquerda que corresponde ao índice dado pela expressão entre os colchetes.

O primeiro índice de um array é zero, não um, então o primeiro elemento é recuperado com listOfNumbers[0]. A contagem baseada em zero tem uma longa tradição na tecnologia e, de certa forma, faz bastante sentido, mas leva um tempo para se acostumar. Pense no índice como o número de itens a pular, contando a partir do início do array.

Propriedades

Já vimos algumas expressões como myString.length (para obter o comprimento de uma string) e Math.max (a função de máximo) em capítulos anteriores. Essas expressões acessam uma propriedade de algum valor. No primeiro caso, acessamos a propriedade length do valor em myString. No segundo, acessamos a propriedade chamada max no objeto Math (que é uma coleção de constantes e funções relacionadas à matemática).

Quase todos os valores em JavaScript têm propriedades. As exceções são null e undefined. Se você tentar acessar uma propriedade em um desses não-valores, obterá um erro:

null.length;
// → TypeError: null has no properties

As duas principais maneiras de acessar propriedades em JavaScript são com um ponto e com colchetes. Tanto value.x quanto value[x] acessam uma propriedade em value—mas não necessariamente a mesma propriedade. A diferença está em como x é interpretado. Ao usar um ponto, a palavra após o ponto é o nome literal da propriedade. Ao usar colchetes, a expressão dentro deles é avaliada para obter o nome da propriedade. Enquanto value.x busca a propriedade de value chamada “x”, value[x] pega o valor da variável chamada x e usa isso, convertido para string, como nome da propriedade.

Se você sabe que a propriedade que lhe interessa se chama color, você escreve value.color. Se quiser extrair a propriedade nomeada pelo valor armazenado na variável i, você escreve value[i]. Nomes de propriedades são strings. Eles podem ser qualquer string, mas a notação com ponto só funciona com nomes que se parecem com nomes válidos de variáveis—começando com uma letra ou sublinhado e contendo apenas letras, números e sublinhados. Se quiser acessar uma propriedade chamada 2 ou John Doe, você deve usar colchetes: value[2] ou value["John Doe"].

Os elementos em um _array_ são armazenados como propriedades do array, usando números como nomes de propriedade. Como você não pode usar a notação com ponto com números e normalmente quer usar uma variável que contém o índice, é necessário usar a notação com colchetes para acessá-los.

Assim como strings, arrays têm uma propriedade length que nos diz quantos elementos o array possui.

Métodos

Tanto valores do tipo string quanto array contêm, além da propriedade length, várias propriedades que armazenam funções.

let doh = "Doh";
console.log(typeof doh.toUpperCase);
// → function
console.log(doh.toUpperCase());
// → DOH

Toda string possui uma propriedade toUpperCase. Quando chamada, ela retorna uma cópia da string em que todas as letras foram convertidas para maiúsculas. Também existe toLowerCase, que faz o contrário.

Curiosamente, mesmo que a chamada de toUpperCase não passe nenhum argumento, a função de alguma forma tem acesso à string "Doh", o valor cuja propriedade chamamos. Você descobrirá como isso funciona no Capítulo 6.

Propriedades que contêm funções geralmente são chamadas de métodos do valor ao qual pertencem, como em “toUpperCase é um método de uma string”.

Este exemplo demonstra dois métodos que você pode usar para manipular arrays.

let sequence = [1, 2, 3];
sequence.push(4);
sequence.push(5);
console.log(sequence);
// → [1, 2, 3, 4, 5]
console.log(sequence.pop());
// → 5
console.log(sequence);
// → [1, 2, 3, 4]

O método push adiciona valores ao final de um array. O método pop faz o oposto, removendo o último valor do array e retornando-o.

Esses nomes um tanto estranhos são os termos tradicionais para operações em uma pilha. Uma pilha, na programação, é uma estrutura de dados que permite inserir valores e removê-los na ordem inversa, de modo que o último elemento adicionado seja o primeiro a ser removido. Pilhas são comuns na programação—você talvez se lembre da pilha de chamadas do capítulo anterior, que é um exemplo da mesma ideia.

Objetos

De volta ao esquilo-homem. Um conjunto de registros diários pode ser representado como um array, mas as entradas não consistem apenas de um número ou uma string—cada entrada precisa armazenar uma lista de atividades e um valor booleano que indica se Jacques se transformou em esquilo ou não. Idealmente, gostaríamos de agrupar isso em um único valor e então colocar esses valores agrupados em um array de registros.

Valores do tipo objeto são coleções arbitrárias de propriedades. Uma maneira de criar um objeto é usando chaves como expressão.

let day1 = {
  squirrel: false,
  events: ["work", "touched tree", "pizza", "running"]
};
console.log(day1.squirrel);
// → false
console.log(day1.wolf);
// → undefined
day1.wolf = false;
console.log(day1.wolf);
// → false

Dentro das chaves, você escreve uma lista de propriedades separadas por vírgulas. Cada propriedade tem um nome seguido por dois pontos e um valor. Quando um objeto é escrito em várias linhas, a indentação como no exemplo ajuda na legibilidade. Propriedades cujos nomes não são nomes válidos de variáveis ou números válidos devem ser colocadas entre aspas:

let descriptions = {
  work: "Went to work",
  "touched tree": "Touched a tree"
};

Isso significa que chaves têm dois significados em JavaScript. No início de uma instrução, elas iniciam um bloco de instruções. Em qualquer outra posição, descrevem um objeto. Felizmente, raramente é útil iniciar uma instrução com um objeto entre chaves, então essa ambiguidade não é um grande problema. Um caso em que isso aparece é ao retornar um objeto em uma arrow function curta—você não pode escrever n => {prop: n}, pois as chaves serão interpretadas como corpo da função. Em vez disso, é preciso envolver o objeto em parênteses para deixar claro que é uma expressão.

Ler uma propriedade que não existe retorna o valor undefined.

É possível atribuir um valor a uma expressão de propriedade com o operador =. Isso substituirá o valor da propriedade se ela já existir ou criará uma nova propriedade no objeto se não existir.

Voltando brevemente ao nosso modelo de tentáculos para ligações—ligações de propriedades são semelhantes. Eles agarram valores, mas outras ligações e propriedades podem estar segurando esses mesmos valores. Você pode pensar em objetos como polvos com qualquer número de tentáculos, cada um com um nome escrito nele.

O operador delete corta um tentáculo desse polvo. É um operador unário que, quando aplicado a uma propriedade de objeto, remove a propriedade nomeada do objeto. Isso não é muito comum, mas é possível.

let anObject = {left: 1, right: 2};
console.log(anObject.left);
// → 1
delete anObject.left;
console.log(anObject.left);
// → undefined
console.log("left" in anObject);
// → false
console.log("right" in anObject);
// → true

O operador binário in, quando aplicado a uma string e um objeto, informa se esse objeto possui uma propriedade com esse nome. A diferença entre definir uma propriedade como undefined e realmente deletá-la é que, no primeiro caso, o objeto ainda tem a propriedade (apenas com um valor pouco interessante), enquanto no segundo caso a propriedade não está mais presente e in retornará false.

Para descobrir quais propriedades um objeto possui, você pode usar a função Object.keys. Dê a ela um objeto e ela retornará um array de strings—os nomes das propriedades do objeto:

console.log(Object.keys({x: 0, y: 0, z: 2}));
// → ["x", "y", "z"]

Existe uma função Object.assign que copia todas as propriedades de um objeto para outro:

let objectA = {a: 1, b: 2};
Object.assign(objectA, {b: 3, c: 4});
console.log(objectA);
// → {a: 1, b: 3, c: 4}

Arrays, então, são apenas um tipo de objeto especializado para armazenar sequências de coisas. Se você avaliar typeof [], o resultado será "object". Você pode visualizar arrays como polvos longos e achatados com todos os seus tentáculos alinhados em uma fileira, rotulados com números.

Jacques representará o diário que mantém como um array de objetos:

let journal = [
  {events: ["work", "touched tree", "pizza",
            "running", "television"],
   squirrel: false},
  {events: ["work", "ice cream", "cauliflower",
            "lasagna", "touched tree", "brushed teeth"],
   squirrel: false},
  {events: ["weekend", "cycling", "break", "peanuts",
            "beer"],
   squirrel: true},
  /* E assim por diante... */
];

Mutabilidade

Chegaremos à programação de fato em breve, mas antes há mais uma parte de teoria para entender.

Vimos que valores do tipo objeto podem ser modificados. Os tipos de valores discutidos em capítulos anteriores, como números, strings e booleanos, são todos imutáveis—é impossível alterar valores desses tipos. Você pode combiná-los e derivar novos valores a partir deles, mas quando pega um valor específico de string, ele sempre permanecerá o mesmo. O texto dentro dele não pode ser alterado. Se você tem uma string contendo "cat", não é possível que outro código altere um caractere para formar "rat".

Objetos funcionam de forma diferente. Você pode alterar suas propriedades, fazendo com que um mesmo valor de objeto tenha conteúdos diferentes em momentos diferentes.

Quando temos dois números, 120 e 120, podemos considerá-los exatamente o mesmo número, independentemente de se referirem aos mesmos bits físicos. Com objetos, há uma diferença entre ter duas referências ao mesmo objeto e ter dois objetos diferentes com as mesmas propriedades. Considere o seguinte código:

let object1 = {value: 10};
let object2 = object1;
let object3 = {value: 10};

console.log(object1 == object2);
// → true
console.log(object1 == object3);
// → false

object1.value = 15;
console.log(object2.value);
// → 15
console.log(object3.value);
// → 10

As ligações object1 e object2 apontam para o mesmo objeto, por isso alterar object1 também altera o valor visto por object2. Dizemos que eles têm a mesma identidade. A ligação object3 aponta para um objeto diferente, que inicialmente contém as mesmas propriedades que object1, mas vive uma vida separada.

Ligações também podem ser mutáveis ou constantes, mas isso é separado da forma como seus valores se comportam. Mesmo que valores numéricos não mudem, você pode usar uma ligação let para acompanhar um número que muda, alterando o valor ao qual a ligação aponta. Da mesma forma, embora uma ligação const para um objeto não possa ser alterado e continuará apontando para o mesmo objeto, o conteúdo desse objeto pode mudar.

const score = {visitors: 0, home: 0};
// Isso é permitido
score.visitors = 1;
// Isso não é permitido
score = {visitors: 1, home: 1};

Quando você compara objetos com o operador == do JavaScript, a comparação é feita por identidade: ele retorna true apenas se ambos os objetos forem exatamente o mesmo valor. Comparar objetos diferentes retorna false, mesmo que tenham propriedades idênticas. Não existe uma operação de comparação “profunda” embutida no JavaScript que compare objetos pelo conteúdo, mas é possível escrevê-la você mesmo (um dos exercícios ao final deste capítulo).

O diário do licantropo

Jacques abre seu interpretador JavaScript e prepara o ambiente de que precisa para manter seu diário:

let journal = [];

function addEntry(events, squirrel) {
  journal.push({events, squirrel});
}

Note que o objeto adicionado ao diário parece um pouco estranho. Em vez de declarar propriedades como events: events, ele apenas fornece o nome da propriedade: events. Isso é uma forma abreviada que significa a mesma coisa—se um nome de propriedade na notação com chaves não for seguido de um valor, seu valor é obtido da ligação com o mesmo nome.

Todas as noites às 22h—ou às vezes na manhã seguinte, depois de descer do topo da estante—Jacques registra o dia:

addEntry(["work", "touched tree", "pizza", "running",
          "television"], false);
addEntry(["work", "ice cream", "cauliflower", "lasagna",
          "touched tree", "brushed teeth"], false);
addEntry(["weekend", "cycling", "break", "peanuts",
          "beer"], true);

Quando tiver dados suficientes, ele pretende usar estatística para descobrir quais desses eventos podem estar relacionados às transformações em esquilo.

Correlação é uma medida de dependência entre variáveis estatísticas. Uma variável estatística não é exatamente a mesma coisa que uma variável de programação. Em estatística, você geralmente tem um conjunto de medições, e cada variável é medida para cada medição. A correlação entre variáveis costuma ser expressa como um valor que varia de -1 a 1. Correlação zero significa que as variáveis não estão relacionadas. Uma correlação de 1 indica que as duas estão perfeitamente relacionadas—se você conhece uma, também conhece a outra. -1 também significa que as variáveis estão perfeitamente relacionadas, mas são opostas—quando uma é verdadeira, a outra é falsa.

Para calcular a medida de correlação entre duas variáveis booleanas, podemos usar o coeficiente phi (ϕ). Essa é uma fórmula cuja entrada é uma tabela de frequência contendo o número de vezes que diferentes combinações das variáveis foram observadas. A saída da fórmula é um número entre -1 e 1 que descreve a correlação.

Podemos pegar o evento de comer pizza e colocá-lo em uma tabela de frequência como esta, onde cada número indica quantas vezes aquela combinação ocorreu em nossas medições.

Uma tabela 2x2 mostrando a variável pizza no eixo horizontal e a variável esquilo no eixo vertical. Cada célula mostra quantas vezes aquela combinação ocorreu. Em 76 casos, nenhum ocorreu. Em 9 casos, apenas pizza foi verdadeiro. Em 4 casos apenas esquilo foi verdadeiro. E em um caso ambos ocorreram.

Se chamarmos essa tabela de n, podemos calcular ϕ usando a seguinte fórmula:

ϕ =
n11n00n10n01
n1•n0•n•1n•0

(Se neste ponto você está fechando o livro por causa de um terrível flashback das aulas de matemática—calma! Não pretendo torturá-lo com páginas intermináveis de notação críptica—é só essa fórmula por enquanto. E mesmo assim, tudo o que fazemos é traduzi-la para JavaScript.)

A notação n01 indica o número de medições em que a primeira variável (estado de esquilo) é falsa (0) e a segunda variável (pizza) é verdadeira (1). Na tabela da pizza, n01 é 9.

O valor n1• refere-se à soma de todas as medições em que a primeira variável é verdadeira, que é 5 na tabela de exemplo. Da mesma forma, n•0 refere-se à soma das medições em que a segunda variável é falsa.

Então, para a tabela da pizza, a parte acima da linha de divisão (o numerador) seria 1×76−4×9 = 40, e a parte abaixo (o denominador) seria a raiz quadrada de 5×85×10×80, ou √340,000. Isso resulta em ϕ ≈ 0,069, que é muito pequeno. Comer pizza não parece ter influência nas transformações.

Calculando correlação

Podemos representar uma tabela 2x2 em JavaScript com um array de quatro elementos ([76, 9, 4, 1]). Também poderíamos usar outras representações, como um array contendo dois arrays de dois elementos ([[76, 9], [4, 1]]) ou um objeto com nomes de propriedades como "11" e "01", mas o array plano é simples e torna as expressões de acesso agradavelmente curtas. Interpretaremos os índices do array como números binários de dois bits, onde o dígito mais à esquerda (mais significativo) se refere à variável esquilo e o da direita (menos significativo) se refere ao evento. Por exemplo, o número binário 10 refere-se ao caso em que Jacques se transformou em esquilo, mas o evento (digamos, “pizza”) não ocorreu. Isso aconteceu quatro vezes. E como 10 em binário é 2 em decimal, armazenamos esse número no índice 2 do array.

Esta é a função que calcula o coeficiente ϕ a partir desse tipo de array:

function phi(table) {
  return (table[3] * table[0] - table[2] * table[1]) /
    Math.sqrt((table[2] + table[3]) *
              (table[0] + table[1]) *
              (table[1] + table[3]) *
              (table[0] + table[2]));
}

console.log(phi([76, 9, 4, 1]));
// → 0.068599434

Esta é uma tradução direta da fórmula de ϕ para JavaScript. Math.sqrt é a função de raiz quadrada, fornecida pelo objeto Math em um ambiente JavaScript padrão. Precisamos somar dois campos da tabela para obter valores como n1•, porque as somas de linhas ou colunas não são armazenadas diretamente em nossa estrutura de dados.

Jacques mantém seu diário por três meses. O conjunto de dados resultante está disponível no sandbox de código deste capítulo, onde está armazenado na ligação JOURNAL, e também em um arquivo para download.

Para extrair uma tabela 2x2 para um evento específico a partir do diário, precisamos percorrer todas as entradas e contar quantas vezes o evento ocorre em relação às transformações:

function tableFor(event, journal) {
  let table = [0, 0, 0, 0];
  for (let i = 0; i < journal.length; i++) {
    let entry = journal[i], index = 0;
    if (entry.events.includes(event)) index += 1;
    if (entry.squirrel) index += 2;
    table[index] += 1;
  }
  return table;
}

console.log(tableFor("pizza", JOURNAL));
// → [76, 9, 4, 1]

Arrays têm um método includes que verifica se um determinado valor existe no array. A função usa isso para determinar se o nome do evento de interesse faz parte da lista de eventos de um determinado dia.

O corpo do loop em tableFor determina em qual célula da tabela cada entrada do diário se encaixa, verificando se a entrada contém o evento específico e se esse evento ocorre junto com um incidente de esquilo. O loop então adiciona um à célula correta da tabela.

Agora temos as ferramentas necessárias para calcular correlaçãos individuais. O único passo restante é encontrar uma correlação para cada tipo de evento registrado e ver se algo se destaca.

Loops com array

Na função tableFor, há um loop assim:

for (let i = 0; i < JOURNAL.length; i++) {
  let entry = JOURNAL[i];
  // Fazer algo com entry
}

Esse tipo de loop é comum no JavaScript clássico—percorrer arrays elemento por elemento é algo frequente, e para isso você usa um contador ao longo do tamanho do array e pega cada elemento em sequência.

Há uma maneira mais simples de escrever esses loops no JavaScript moderno:

for (let entry of JOURNAL) {
  console.log(`${entry.events.length} events.`);
}

Quando um for usa a palavra of após a definição da variável, ele percorre os elementos do valor fornecido após of. Isso funciona não apenas para arrays, mas também para strings e algumas outras estruturas de dados. Discutiremos como isso funciona no Capítulo 6.

A análise final

Precisamos calcular uma correlação para cada tipo de evento que ocorre no conjunto de dados. Para isso, primeiro precisamos encontrar todos os tipos de evento.

function journalEvents(journal) {
  let events = [];
  for (let entry of journal) {
    for (let event of entry.events) {
      if (!events.includes(event)) {
        events.push(event);
      }
    }
  }
  return events;
}

console.log(journalEvents(JOURNAL));
// → ["carrot", "exercise", "weekend", "bread", …]

Ao adicionar ao array events quaisquer nomes de eventos que ainda não estejam nele, a função coleta todos os tipos de evento.

Usando essa função, podemos ver todas as correlações:

for (let event of journalEvents(JOURNAL)) {
  console.log(event + ":", phi(tableFor(event, JOURNAL)));
}
// → carrot:   0.0140970969
// → exercise: 0.0685994341
// → weekend:  0.1371988681
// → bread:   -0.0757554019
// → pudding: -0.0648203724
// E assim por diante...

A maioria das correlações parece próxima de zero. Comer cenoura, pão ou pudim aparentemente não desencadeia a licantropia esquiloide. As transformações parecem ocorrer com um pouco mais de frequência nos fins de semana. Vamos filtrar os resultados para mostrar apenas correlações maiores que 0,1 ou menores que -0,1:

for (let event of journalEvents(JOURNAL)) {
  let correlation = phi(tableFor(event, JOURNAL));
  if (correlation > 0.1 || correlation < -0.1) {
    console.log(event + ":", correlation);
  }
}
// → weekend:        0.1371988681
// → brushed teeth: -0.3805211953
// → candy:          0.1296407447
// → work:          -0.1371988681
// → spaghetti:      0.2425356250
// → reading:        0.1106828054
// → peanuts:        0.5902679812

Aha! Existem dois fatores com uma correlação claramente mais forte que os outros. Comer amendoim tem um forte efeito positivo na chance de se transformar em esquilo, enquanto escovar os dentes tem um efeito negativo significativo.

Interessante. Vamos tentar algo.

for (let entry of JOURNAL) {
  if (entry.events.includes("peanuts") &&
     !entry.events.includes("brushed teeth")) {
    entry.events.push("peanut teeth");
  }
}
console.log(phi(tableFor("peanut teeth", JOURNAL)));
// → 1

Esse é um resultado forte. O fenômeno ocorre exatamente quando Jacques come amendoim e não escova os dentes. Se ele não fosse tão relaxado com a higiene bucal, talvez nunca tivesse percebido sua condição.

Sabendo disso, Jacques para completamente de comer amendoim e descobre que suas transformações cessam.

Mas leva apenas alguns meses para ele perceber que algo está faltando nesse modo de vida totalmente humano. Sem suas aventuras selvagens, Jacques mal se sente vivo. Ele decide que prefere ser um animal selvagem em tempo integral. Depois de construir uma bela casinha na árvore na floresta e equipá-la com um dispensador de manteiga de amendoim e um suprimento para dez anos, ele se transforma uma última vez e vive a curta e energética vida de um esquilo.

Mais sobre array

Antes de terminar o capítulo, quero apresentar mais alguns conceitos relacionados a objetos. Começarei com alguns métodos de array úteis.

Já vimos push e pop, que adicionam e removem elementos no final de um array, anteriormente neste capítulo. Os métodos correspondentes para adicionar e remover elementos no início do array são chamados unshift e shift.

let todoList = [];
function remember(task) {
  todoList.push(task);
}
function getTask() {
  return todoList.shift();
}
function rememberUrgently(task) {
  todoList.unshift(task);
}

Este programa gerencia uma fila de tarefas. Você adiciona tarefas ao final da fila chamando remember("groceries"), e quando estiver pronto para fazer algo, chama getTask() para obter (e remover) o item da frente da fila. A função rememberUrgently também adiciona uma tarefa, mas a coloca no início da fila em vez do final.

Para procurar um valor específico, arrays fornecem o método indexOf. O método percorre o array do início ao fim e retorna o índice onde o valor foi encontrado—ou -1 se não foi encontrado. Para procurar a partir do final, existe um método semelhante chamado lastIndexOf:

console.log([1, 2, 3, 2, 1].indexOf(2));
// → 1
console.log([1, 2, 3, 2, 1].lastIndexOf(2));
// → 3

Ambos indexOf e lastIndexOf aceitam um segundo argumento opcional que indica onde começar a busca.

Outro método fundamental de array é slice, que recebe índices de início e fim e retorna um array contendo apenas os elementos entre eles. O índice inicial é inclusivo e o final é exclusivo.

console.log([0, 1, 2, 3, 4].slice(2, 4));
// → [2, 3]
console.log([0, 1, 2, 3, 4].slice(2));
// → [2, 3, 4]

Quando o índice final não é fornecido, slice pega todos os elementos após o índice inicial. Você também pode omitir o índice inicial para copiar o array inteiro.

O método concat pode ser usado para unir arrays e criar um novo array, semelhante ao que o operador + faz com strings.

O exemplo a seguir mostra concat e slice em ação. Ele recebe um array e um índice e retorna um novo array que é uma cópia do original sem o elemento na posição indicada:

function remove(array, index) {
  return array.slice(0, index)
    .concat(array.slice(index + 1));
}
console.log(remove(["a", "b", "c", "d", "e"], 2));
// → ["a", "b", "d", "e"]

Se você passar para concat um argumento que não é um array, esse valor será adicionado ao novo array como se fosse um array de um único elemento.

Strings e suas propriedades

Podemos ler propriedades como length e toUpperCase de valores string. Mas se tentarmos adicionar uma nova propriedade, ela não será mantida.

let kim = "Kim";
kim.age = 88;
console.log(kim.age);
// → undefined

Valores do tipo string, number e Boolean não são objetos e, embora a linguagem não reclame se você tentar definir novas propriedades neles, ela não armazena essas propriedades. Como mencionado anteriormente, esses valores são imutáveis e não podem ser alterados.

Mas esses tipos possuem propriedades internas. Toda string tem vários métodos. Alguns muito úteis são slice e indexOf, que se parecem com os métodos de array de mesmo nome:

console.log("coconuts".slice(4, 7));
// → nut
console.log("coconut".indexOf("u"));
// → 5

Uma diferença é que o indexOf de string pode buscar por uma string com mais de um caractere, enquanto o método equivalente de array procura apenas um elemento único:

console.log("one two three".indexOf("ee"));
// → 11

O método trim remove espaços em branco (espaços, quebras de linha, tabs e caracteres semelhantes) do início e do fim de uma string:

console.log("  okay \n ".trim());
// → okay

A função zeroPad do capítulo anterior também existe como método. Ela se chama padStart e recebe o tamanho desejado e o caractere de preenchimento como argumentos:

console.log(String(6).padStart(3, "0"));
// → 006

Você pode dividir uma string em cada ocorrência de outra string com split e juntá-la novamente com join:

let sentence = "Secretarybirds specialize in stomping";
let words = sentence.split(" ");
console.log(words);
// → ["Secretarybirds", "specialize", "in", "stomping"]
console.log(words.join(". "));
// → Secretarybirds. specialize. in. stomping

Uma string pode ser repetida com o método repeat, que cria uma nova string contendo várias cópias da original concatenadas:

console.log("LA".repeat(3));
// → LALALA

Já vimos a propriedade length de strings. Acessar caracteres individuais em uma string é parecido com acessar elementos de um array (com uma complicação que veremos no Capítulo 5).

let string = "abc";
console.log(string.length);
// → 3
console.log(string[1]);
// → b

Parâmetros rest

Pode ser útil que uma função aceite qualquer quantidade de argumentos. Por exemplo, Math.max calcula o maior entre todos os argumentos recebidos. Para escrever uma função assim, você coloca três pontos antes do último parâmetro da função, assim:

function max(...numbers) {
  let result = -Infinity;
  for (let number of numbers) {
    if (number > result) result = number;
  }
  return result;
}
console.log(max(4, 1, 9, -2));
// → 9

Quando essa função é chamada, o parâmetro rest é associado a um array contendo todos os argumentos restantes. Se houver outros parâmetros antes dele, seus valores não fazem parte desse array. Quando, como em max, ele é o único parâmetro, ele conterá todos os argumentos.

Você pode usar uma notação semelhante de três pontos para chamar uma função com um array de argumentos.

let numbers = [5, 1, 7];
console.log(max(...numbers));
// → 7

Isso “espalha” o array na chamada da função, passando seus elementos como argumentos separados. Também é possível incluir esse array junto com outros argumentos, como em max(9, ...numbers, 2).

A notação de array com colchetes também permite usar o operador de três pontos para espalhar outro a_array_rray dentro de um novo array:

let words = ["never", "fully"];
console.log(["will", ...words, "understand"]);
// → ["will", "never", "fully", "understand"]

Isso funciona até mesmo em objetos com chaves, onde adiciona todas as propriedades de outro objeto. Se uma propriedade for adicionada várias vezes, o último valor prevalece:

let coordinates = {x: 10, y: 0};
console.log({...coordinates, y: 5, z: 1});
// → {x: 10, y: 5, z: 1}

O objeto Math

Como vimos, Math é um conjunto de funções utilitárias relacionadas a números, como Math.max (máximo), Math.min (mínimo) e Math.sqrt (raiz quadrada).

O objeto Math é usado como um contêiner para agrupar várias funcionalidades relacionadas. Existe apenas um objeto Math, e raramente ele é útil como valor. Em vez disso, ele fornece um namespace para que todas essas funções e valores não precisem ser ligações globais.

Ter muitoas ligações globais “polui” o namespace. Quanto mais nomes já estiverem em uso, maior a chance de você sobrescrever acidentalmente o valor de alguma ligação existente. Por exemplo, não é improvável que você queira nomear algo como max em um de seus programas. Como a função max do JavaScript está protegida dentro do objeto Math, você não precisa se preocupar em sobrescrevê-la.

Muitas linguagens impedem você, ou pelo menos avisam, quando você define uma ligação com um nome já utilizado. JavaScript faz isso para ligações declaradas com let ou const, mas—de forma curiosa—não para ligações padrão nem para aquelas declaradas com var ou function.

Voltando ao objeto Math. Se você precisa fazer trigonometria, ele pode ajudar. Ele contém cos (cosseno), sin (seno) e tan (tangente), bem como suas funções inversas, acos, asin e atan. O número π (pi)—ou pelo menos a melhor aproximação que cabe em um número JavaScript—está disponível como Math.PI. Existe uma antiga tradição na programação de escrever nomes de valores constantes em letras maiúsculas.

function randomPointOnCircle(radius) {
  let angle = Math.random() * 2 * Math.PI;
  return {x: radius * Math.cos(angle),
          y: radius * Math.sin(angle)};
}
console.log(randomPointOnCircle(2));
// → {x: 0.3667, y: 1.966}

Se você não estiver familiarizado com seno e cosseno, não se preocupe. Vou explicá-los quando forem usados no Capítulo 14.

O exemplo anterior usou Math.random. Esta é uma função que retorna um novo número pseudoaleatório entre 0 (inclusivo) e 1 (exclusivo) toda vez que é chamada:

console.log(Math.random());
// → 0.36993729369714856
console.log(Math.random());
// → 0.727367032552138
console.log(Math.random());
// → 0.40180766698904335

Embora computadores sejam máquinas determinísticas—sempre reagem da mesma forma à mesma entrada—é possível fazê-los produzir números que parecem aleatórios. Para isso, a máquina mantém um valor oculto e, sempre que você solicita um novo número aleatório, ela realiza cálculos complexos sobre esse valor para gerar um novo. Ela armazena esse novo valor e retorna um número derivado dele. Assim, consegue produzir números sempre novos e difíceis de prever, de uma forma que parece aleatória.

Se quisermos um número inteiro aleatório em vez de um fracionário, podemos usar Math.floor (que arredonda para baixo) no resultado de Math.random:

console.log(Math.floor(Math.random() * 10));
// → 2

Multiplicar o número aleatório por 10 nos dá um número maior ou igual a 0 e menor que 10. Como Math.floor arredonda para baixo, essa expressão produzirá, com igual probabilidade, qualquer número de 0 a 9.

Existem também as funções Math.ceil (que arredonda para cima), Math.round (para o inteiro mais próximo) e Math.abs, que retorna o valor absoluto de um número, ou seja, transforma negativos em positivos e mantém positivos como estão.

Desestruturação

Vamos voltar à função phi por um momento.

function phi(table) {
  return (table[3] * table[0] - table[2] * table[1]) /
    Math.sqrt((table[2] + table[3]) *
              (table[0] + table[1]) *
              (table[1] + table[3]) *
              (table[0] + table[2]));
}

Um dos motivos pelos quais essa função é difícil de ler é que temos uma vinculação apontanda para nosso array, mas preferiríamos ter ligações para os elementos do array—isto é, let n00 = table[0] e assim por diante. Felizmente, há uma maneira concisa de fazer isso em JavaScript:

function phi([n00, n01, n10, n11]) {
  return (n11 * n00 - n10 * n01) /
    Math.sqrt((n10 + n11) * (n00 + n01) *
              (n01 + n11) * (n00 + n10));
}

Isso também funciona para ligações criadas com let, var ou const. Se você sabe que o valor que está sendo atribuído é um array, pode usar colchetes para “olhar dentro” dele e associar seu conteúdo.

Um truque semelhante funciona para objetos, usando chaves em vez de colchetes.

let {name} = {name: "Faraji", age: 23};
console.log(name);
// → Faraji

Note que, se você tentar fazer desestruturação com null ou undefined, obterá um erro, assim como aconteceria ao tentar acessar diretamente uma propriedade desses valores.

Acesso opcional a propriedades

Quando você não tem certeza se um determinado valor produz um objeto, mas ainda quer ler uma propriedade dele quando possível, pode usar uma variação da notação com ponto: object?.property.

function city(object) {
  return object.address?.city;
}
console.log(city({address: {city: "Toronto"}}));
// → Toronto
console.log(city({name: "Vera"}));
// → undefined

A expressão a?.b significa o mesmo que a.b quando a não é null nem undefined. Quando é, ela resulta em undefined. Isso pode ser útil quando, como no exemplo, você não tem certeza de que uma determinada propriedade existe ou quando uma variável pode conter um valor indefinido.

Uma notação semelhante pode ser usada com acesso por colchetes e até com chamadas de função, colocando ?. antes dos parênteses ou colchetes:

console.log("string".notAMethod?.());
// → undefined
console.log({}.arrayProp?.[0]);
// → undefined

JSON

Como propriedades apontam para seus valores em vez de contê-los, objetos e arrays são armazenados na memória do computador como sequências de bits que guardam os endereços—os locais na memória—de seus conteúdos. Um array que contém outro array consiste (no mínimo) em uma região de memória para o array interno e outra para o externo, contendo (entre outras coisas) um número que representa o endereço do array interno.

Se você quiser salvar dados em um arquivo para uso posterior ou enviá-los para outro computador pela rede, é preciso converter esses emaranhados de endereços de memória em uma descrição que possa ser armazenada ou transmitida. Você poderia enviar toda a memória do computador junto com o endereço do valor de interesse, mas isso não parece uma boa abordagem.

O que podemos fazer é serializar os dados. Isso significa convertê-los em uma descrição plana. Um formato popular de serialização é chamado JSON (pronunciado “Jason”), que significa JavaScript Object Notation. Ele é amplamente usado como formato de armazenamento e comunicação de dados na web, inclusive com linguagens diferentes de JavaScript.

JSON se parece com a forma de escrever arrays e objetos em JavaScript, com algumas restrições. Todos os nomes de propriedades devem estar entre aspas duplas, e apenas expressões de dados simples são permitidas—nenhuma chamada de função, ligações ou qualquer coisa que envolva computação. Comentários não são permitidos em JSON.

Uma entrada do diário pode se parecer com isto quando representada em JSON:

{
  "squirrel": false,
  "events": ["work", "touched tree", "pizza", "running"]
}

JavaScript nos fornece as funções JSON.stringify e JSON.parse para converter dados de e para esse formato. A primeira recebe um valor JavaScript e retorna uma string codificada em JSON. A segunda recebe essa string e a converte de volta para o valor que ela representa:

let string = JSON.stringify({squirrel: false,
                             events: ["weekend"]});
console.log(string);
// → {"squirrel":false,"events":["weekend"]}
console.log(JSON.parse(string).events);
// → ["weekend"]

Resumo

Objetos e arrays fornecem maneiras de agrupar vários valores em um único valor. Isso nos permite colocar várias coisas relacionadas em uma “bolsa” e carregar tudo junto, em vez de tentar segurar cada coisa separadamente.

A maioria dos valores em JavaScript tem propriedades, com exceção de null e undefined. Propriedades são acessadas usando value.prop ou value["prop"]. Objetos tendem a usar nomes para suas propriedades e armazenar um conjunto mais ou menos fixo delas. Arrays, por outro lado, geralmente contêm quantidades variáveis de valores conceitualmente idênticos e usam números (a partir de 0) como nomes de suas propriedades.

Existem algumas propriedades nomeadas em arrays, como length e vários métodos. Métodos são funções que vivem em propriedades e (geralmente) atuam sobre o valor ao qual pertencem.

Você pode iterar sobre arrays usando um tipo especial de loop for: for (let element of array).

Exercícios

A soma de um intervalo

A introdução deste livro mencionou o seguinte como uma boa forma de calcular a soma de um intervalo de números:

console.log(sum(range(1, 10)));

Escreva uma função range que receba dois argumentos, start e end, e retorne um array contendo todos os números de start até end, inclusive.

Em seguida, escreva uma função sum que receba um array de números e retorne a soma desses números. Execute o programa de exemplo e verifique se ele realmente retorna 55.

Como exercício bônus, modifique sua função range para receber um terceiro argumento opcional que indique o valor de “passo” usado na construção do array. Se nenhum passo for informado, os elementos devem crescer de um em um, como no comportamento original. A chamada range(1, 10, 2) deve retornar [1, 3, 5, 7, 9]. Certifique-se de que isso também funcione com valores negativos de passo, de modo que range(5, 2, -1) produza [5, 4, 3, 2].

// Seu código aqui.

console.log(range(1, 10));
// → [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
console.log(range(5, 2, -1));
// → [5, 4, 3, 2]
console.log(sum(range(1, 10)));
// → 55
Mostrar dicas...

Construir um array é mais fácil inicializando uma variável com [] (um array vazio) e chamando repetidamente o método push para adicionar valores. Não se esqueça de retornar o array ao final da função.

Como o limite final é inclusivo, você precisará usar o operador <= em vez de < para verificar o fim do loop.

O parâmetro de passo pode ser um parâmetro opcional com valor padrão (usando =) igual a 1.

Fazer com que range entenda valores negativos de passo provavelmente é melhor feito com dois loops separados — um para contar para cima e outro para contar para baixo — porque a comparação que verifica o fim do loop precisa ser >= em vez de <= quando se está contando para baixo.

Também pode ser interessante usar um valor padrão diferente para o passo, isto é, -1, quando o final do intervalo é menor que o início. Assim, range(5, 2) retorna algo útil em vez de entrar em um loop infinito. É possível referenciar parâmetros anteriores no valor padrão de um parâmetro.

Invertendo um array

Arrays possuem um método reverse que altera o array invertendo a ordem de seus elementos. Para este exercício, escreva duas funções, reverseArray e reverseArrayInPlace. A primeira deve receber um array como argumento e produzir um novo array com os mesmos elementos em ordem inversa. A segunda deve fazer o que o método reverse faz: modificar o array recebido invertendo seus elementos. Nenhuma das duas pode usar o método padrão reverse.

Pensando nas observações sobre efeitos colaterais e funções puras no capítulo anterior, qual variante você espera que seja mais útil na maioria dos casos? Qual delas é mais rápida?

// Seu código aqui.

let myArray = ["A", "B", "C"];
console.log(reverseArray(myArray));
// → ["C", "B", "A"];
console.log(myArray);
// → ["A", "B", "C"];
let arrayValue = [1, 2, 3, 4, 5];
reverseArrayInPlace(arrayValue);
console.log(arrayValue);
// → [5, 4, 3, 2, 1]
Mostrar dicas...

Existem duas maneiras óbvias de implementar reverseArray. A primeira é percorrer o array de entrada do início ao fim e usar o método unshift no novo array para inserir cada elemento no começo. A segunda é percorrer o array de trás para frente e usar o método push. Iterar um array ao contrário exige uma especificação de for um pouco mais estranha, como (let i = array.length - 1; i >= 0; i--).

Reverter o array no próprio lugar é mais difícil. Você precisa ter cuidado para não sobrescrever elementos que ainda serão usados. Usar reverseArray ou copiar todo o array (array.slice() é uma boa forma de copiar um array) funciona, mas é trapaça.

O truque é trocar o primeiro e o último elementos, depois o segundo e o penúltimo, e assim por diante. Você pode fazer isso percorrendo metade do tamanho do array (use Math.floor para arredondar para baixo — não é necessário mexer no elemento do meio em arrays com quantidade ímpar de elementos) e trocando o elemento na posição i com o da posição array.length - 1 - i. Você pode usar uma variável local para guardar temporariamente um dos elementos, sobrescrevê-lo com seu correspondente espelhado e depois colocar o valor armazenado no lugar do outro.

Uma lista

Como blocos genéricos de valores, objetos podem ser usados para construir diversos tipos de estruturas de dados. Uma estrutura comum é a lista (não confundir com arrays). Uma lista é um conjunto encadeado de objetos, onde o primeiro aponta para o segundo, o segundo para o terceiro, e assim por diante:

let list = {
  value: 1,
  rest: {
    value: 2,
    rest: {
      value: 3,
      rest: null
    }
  }
};

Os objetos resultantes formam uma cadeia, como mostrado no diagrama a seguir:

Um diagrama mostrando a estrutura de memória de uma lista encadeada. Existem 3 células, cada uma com um campo de valor contendo um número e um campo 'rest' com uma seta apontando para o restante da lista. A primeira célula aponta para a segunda, a segunda aponta para a última, e a última possui 'rest' igual a null.

Uma característica interessante das listas é que elas podem compartilhar partes de sua estrutura. Por exemplo, se eu criar dois novos valores {value: 0, rest: list} e {value: -1, rest: list} (com list referenciando a variável definida anteriormente), ambos são listas independentes, mas compartilham a estrutura que forma seus últimos três elementos. A lista original também continua sendo uma lista válida com três elementos.

Escreva uma função arrayToList que construa uma estrutura de lista como a mostrada ao receber [1, 2, 3] como argumento. Escreva também uma função listToArray que produza um array a partir de uma lista. Adicione as funções auxiliares prepend, que recebe um elemento e uma lista e cria uma nova lista adicionando o elemento no início, e nth, que recebe uma lista e um número e retorna o elemento na posição indicada (onde zero corresponde ao primeiro elemento) ou undefined quando não houver tal elemento.

Se ainda não tiver feito, escreva também uma versão recursiva de nth.

// Seu código aqui.

console.log(arrayToList([10, 20]));
// → {value: 10, rest: {value: 20, rest: null}}
console.log(listToArray(arrayToList([10, 20, 30])));
// → [10, 20, 30]
console.log(prepend(10, prepend(20, null)));
// → {value: 10, rest: {value: 20, rest: null}}
console.log(nth(arrayToList([10, 20, 30]), 1));
// → 20
Mostrar dicas...

Construir uma lista é mais fácil quando feito de trás para frente. Assim, arrayToList pode iterar o array ao contrário e, para cada elemento, adicionar um objeto à lista. Você pode usar uma variável local para armazenar a parte já construída da lista e fazer algo como list = {value: X, rest: list} para adicionar um elemento.

Para percorrer uma lista (em listToArray e nth), pode-se usar um loop for como este:

for (let node = list; node; node = node.rest) {}

Consegue entender como isso funciona? A cada iteração, node aponta para a sublista atual, e o corpo pode ler a propriedade value para obter o elemento atual. Ao final da iteração, node avança para a próxima sublista. Quando for null, chegamos ao fim da lista e o loop termina.

A versão recursiva de nth funciona de forma semelhante, olhando para uma parte cada vez menor da “cauda” da lista enquanto reduz o índice até chegar a zero, momento em que pode retornar o valor do nó atual. Para obter o elemento de índice zero, basta acessar o value do primeiro nó. Para obter o elemento N + 1, você pega o elemento N da lista contida na propriedade rest.

Comparação profunda

O operador == compara objetos por identidade, mas às vezes você prefere comparar os valores reais de suas propriedades.

Escreva uma função deepEqual que receba dois valores e retorne true apenas se eles forem iguais ou se forem objetos com as mesmas propriedades, cujos valores também sejam iguais quando comparados com chamadas recursivas a deepEqual.

Para descobrir se os valores devem ser comparados diretamente (usando ===) ou se suas propriedades devem ser comparadas, você pode usar o operador typeof. Se ele retornar "object" para ambos os valores, você deve fazer uma comparação profunda. Porém, há uma exceção estranha: por um acidente histórico, typeof null também retorna "object".

A função Object.keys será útil quando você precisar percorrer as propriedades dos objetos para compará-los.

// Seu código aqui.

let obj = {here: {is: "an"}, object: 2};
console.log(deepEqual(obj, obj));
// → true
console.log(deepEqual(obj, {here: 1, object: 2}));
// → false
console.log(deepEqual(obj, {here: {is: "an"}, object: 2}));
// → true
Mostrar dicas...

Seu teste para saber se está lidando com um objeto real será algo como typeof x == "object" && x != null. Tenha cuidado para comparar propriedades apenas quando ambos os argumentos forem objetos. Em todos os outros casos, você pode simplesmente retornar o resultado de ===.

Use Object.keys para percorrer as propriedades. Você precisa verificar se ambos os objetos possuem o mesmo conjunto de nomes de propriedades e se esses valores são idênticos. Uma forma de fazer isso é garantir que ambos tenham o mesmo número de propriedades (ou seja, o tamanho das listas é igual). Depois, ao percorrer as propriedades de um dos objetos, sempre verifique se o outro também possui aquela propriedade.

Retornar o valor correto é mais fácil retornando false imediatamente ao encontrar uma diferença e retornando true ao final da função.