Resolvendo um quebra-cabeça divertido em JavaScript

Nossa história começa com um tweet de Tomas Lakoma, no qual ele o convida a imaginar que tal pergunta o encontrou em uma entrevista.







Parece-me que a reação a tal pergunta em uma entrevista depende do que exatamente é. Se a questão realmente for qual é o valor tree



, o código pode simplesmente ser inserido no console e obter o resultado.



No entanto, se a questão é como você resolveria esse problema, então tudo se torna bastante curioso e leva a um teste de conhecimento dos meandros do JavaScript e do compilador. Neste artigo, tentarei resolver toda essa confusão e obter conclusões interessantes.



Eu estava transmitindo o processo para resolver esse problema no Twitch . A transmissão é longa, mas permite que você dê uma outra olhada no processo passo a passo de solução desses problemas.



Raciocínio geral



Primeiro, vamos converter o código para o formato copiável:



let b = 3, d = b, u = b;
const tree = ++d * d*b * b++ +
 + --d+ + +b-- +
 + +d*b+ +
 u
      
      





Eu imediatamente percebi algumas peculiaridades e decidi que alguns truques do compilador poderiam ser usados ​​aqui. Veja, JavaScript geralmente adiciona ponto-e-vírgula no final de cada linha, a menos que haja uma expressão que não possa ser interrompida . Neste caso, +



ao final de cada linha ele informa ao compilador que não há necessidade de interromper a construção.



A primeira linha simplesmente cria três variáveis ​​e atribui um valor a elas 3



. 3



É um valor primitivo, então toda vez que uma cópia é criada, ela é criada por valor , então todas as novas variáveis ​​são criadas com um valor 3



... Se o JavaScript atribuísse valores a essas variáveis por referência , cada nova variável apontaria para a variável usada anteriormente, mas não criaria um valor para si mesma.



informação adicional



Precedência e associatividade do operador



Esses são os conceitos-chave para resolver essa tarefa assustadora. Resumindo, eles definem a ordem em que uma combinação de expressões JavaScript é avaliada.



Prioridade do operador



P: qual é a diferença entre essas duas expressões?



3 + 5 * 5
      
      





5 * 5 + 3
      
      





Do ponto de vista do resultado, não há diferença. Qualquer pessoa que se lembra das aulas de matemática da escola sabe que a multiplicação é feita antes da adição. Em inglês, lembramos a ordem como BODMAS (colchetes Off Divide Multiply Add Subtract - colchetes, grau, divisão, multiplicação, adição, subtração). JavaScript tem um conceito semelhante chamado precedência de operador: significa a ordem em que avaliamos as expressões. Se quiséssemos primeiro forçar a computação 3 + 5



, faríamos o seguinte:



(3+5) * 5
      
      





Os parênteses forçam esta parte da expressão a ser avaliada primeiro, porque o operador tem uma precedência ()



mais alta do que o operador *



.



Cada operador JavaScript tem precedência, portanto, com tantos operadores tree



nele, precisamos descobrir em que ordem eles serão avaliados. É especialmente importante o --



que mudará os valores b



e d



, portanto, precisamos saber quando essas expressões são avaliadas em relação ao resto tree



.



Importante: Tabela de prioridades do operador e informações adicionais



Associatividade



A associatividade é usada para determinar em que ordem as expressões são avaliadas em operadores com igual precedência. Por exemplo:



a + b + c
      
      





Não há precedência de operador nesta expressão porque há apenas um operador. Então, como deve ser calculado - como (a + b) + c



ou como a + (b + c)



?



Eu sei que o resultado será o mesmo, mas o compilador precisa saber disso para que possa selecionar uma operação primeiro e depois continuar o cálculo. Nesse caso, a resposta correta é (a + b) + c



porque o operador é +



associativo à esquerda, ou seja, avalia primeiro a expressão da esquerda.



“Por que não tornar todos os operadores associativos à esquerda?” Você pode perguntar.



Bem, vamos dar um exemplo como este:



a = b + c
      
      





Se usarmos a fórmula de associatividade à esquerda, obtemos



(a = b) + c
      
      





Mas espere, isso parece estranho, e não foi isso que eu quis dizer. Se quisermos que essa expressão funcione usando apenas associatividade à esquerda, teríamos que fazer algo assim:



a + b = c
      
      





Isso é convertido em (a + b) = c



, ou seja, primeiro a + b



e, em seguida, o valor desse resultado é atribuído à variável c



.



Se tivéssemos que pensar dessa maneira, JavaScript seria muito mais confuso, é por isso que usamos diferentes associatividades para diferentes operadores - isso torna o código mais legível. Quando lemos a = b + c



, a ordem de cálculo parece natural para nós, apesar do fato de que tudo é mais habilmente organizado internamente e usa operandos associativos à direita e à esquerda.



Você provavelmente notou o problema de associatividade em a = b + c



... Se os dois operadores têm associatividade diferente, como você sabe qual expressão avaliar primeiro? Resposta: aquele com a precedência de operador mais alta , como na seção anterior! Nesse caso, ele +



tem uma prioridade mais alta, portanto, é calculado primeiro.



Eu adicionei uma explicação mais detalhada no final do artigo, ou você pode ler mais informações .



Compreender como a nossa expressão de árvore é avaliada



Tendo compreendido esses princípios, podemos começar a analisar nosso problema. Ele usa muitos operadores e a ausência de parênteses torna-o difícil de entender. Portanto, vamos apenas adicionar parênteses, listando todos os operadores usados ​​junto com sua precedência e associatividade.



(operador com variável x): uma prioridade associatividade
x ++: 18 não
x--: 18 não
++ x: 17 certo
--x: 17 certo
+ x: 17 certo
*: quinze esquerda
x + y: 14 esquerda
=: 3 certo


Parênteses



Vale a pena mencionar aqui que adicionar parênteses corretamente é uma tarefa complicada. Verifiquei se a resposta é calculada corretamente em cada etapa, mas isso não garante que meus parênteses estejam sempre colocados corretamente! Se você conhece uma ferramenta para colocação automática de cinta, envie-me um e-mail.



Vamos descobrir a ordem em que as expressões são avaliadas e adicionar parênteses para mostrá-lo. Vou mostrar passo a passo como cheguei ao resultado final, apenas passando dos operadores de maior prioridade para baixo.



Postfix ++ e postfix -



const tree = ++d * d*b * (b++) +
 + --d+ + +(b--) +
 + +d*b+ +
 u
      
      





Unário +, prefixo ++ e prefixo -



Temos um pequeno problema aqui, mas começarei avaliando o operador unário +



e, em seguida, chegaremos ao ponto do problema.



const tree = ++d * d*b * (b++) +
 + --d+ (+(+(b--))) +
 (+(+(d*b+ (+
 u))))
      
      





E é aqui que surgem as dificuldades.



+ --d+
      
      





--



e +()



têm a mesma prioridade. Como sabemos em que ordem devemos calculá-los? Vamos formular o problema de uma maneira mais simples:



let d = 10
const answer = + --d
      
      





Lembre-se de que +



isso não é adição, mas adição unária ou positividade. Você pode percebê-lo como -1



, apenas aqui está +1



.



A solução é que avaliamos da direita para a esquerda, porque os operadores dessa precedência são associativos à direita .



Portanto, nossa expressão é convertida em + (--d)



.



Para entender isso, tente imaginar que todos os operadores são iguais. Nesse caso, + +1



será equivalente de (+ (+1))



acordo com a lógica, que 1 — 1 — 1



equivale a ((1 — 1) — 1)



... Observe que o resultado dos operadores associativos à direita na notação com parênteses é o oposto do caso com os operadores à esquerda?



Se aplicarmos a mesma lógica ao ponto do problema, obteremos o seguinte:



const tree = ++d * d*b * (b++) +
 (+ (--d)) + (+(+(b--))) +
 (+(+(d*b+ (+
 u))))
      
      





E, finalmente, inserindo os parênteses para o último ++



, obtemos:



const tree = (++d) * d*b * (b++) +
 (+ (--d)) + (+(+(b--))) +
 (+(+(d*b+ (+
 u))))
      
      





Multiplicação (*)



Novamente, temos que lidar com a associatividade, mas desta vez com o mesmo operador, que é associativo à esquerda. Em comparação com a etapa anterior, isso deve ser fácil!



const tree = ((((++d) * d) * b) * (b++)) +
 (+ (--d)) + (+(+(b--))) +
 (+(+((d*b) + (+u))))
      
      





Chegamos a um estágio em que já é possível iniciar os cálculos. Seria possível adicionar parênteses para o operador de atribuição, mas acho que será mais confuso do que fácil de ler, portanto, não faremos isso. Observe que a expressão acima é um pouco mais complicada x = a + b + c



.



Podemos encurtar alguns dos operadores unários, mas irei salvá-los caso sejam importantes.



Ao dividir a expressão em várias partes, podemos compreender os estágios individuais dos cálculos e desenvolvê-los.



let b = 3, d = b, u = b;
 
const treeA = ((((++d) * d) * b) * (b++))
const treeB = (+ (--d)) + (+(+(b--)))
const treeC = (+(+((d*b) + (+u))))
const tree = treeA + treeB + treeC
      
      





Feito isso, podemos começar a explorar o cálculo de diferentes valores. Vamos começar com treeA.



TreeA



let b = 3, d = b, u = b;
const treeA = (((++d) * d) * b) * (b++)
      
      





A primeira coisa que será avaliada aqui é uma expressão ++d



que retornará 4



e incrementará d



.



// b = 3
// d = 4
((4 * d) * b) * (b++)
      
      





Então é executado 4*d



: sabemos que neste estágio d é 4, portanto 4*4



é 16.



// b = 3
// d = 4
(16 * b) * (b++)
      
      





O interessante sobre essa etapa é que vamos multiplicar por b antes de incrementar b, então o cálculo é feito da esquerda para a direita. 16 * 3 = 48



...



// b = 3
// d = 4
48 * (b++)
      
      





Acima, falamos sobre o que ++



tem uma prioridade maior do que *



, então isso pode ser escrito como 48 * b++



, mas há outros truques aqui - o valor de retorno b++



é o valor antes do incremento, não depois. Portanto, mesmo que b eventualmente se torne 4, o valor multiplicado será 3.



// b = 3
// d = 4
48 * 3
// b = 4
// d = 4
      
      





48 * 3



é igual 144



, então após calcular a primeira parte b e d são iguais a 4, e o resultado da expressão é 144







let b = 4, d = 4, u = 3;
 
const treeA = 144
const treeB = (+ (--d)) + (+(+(b--)))
const treeC = (+(+((d*b) + (+u))))
const tree = treeA + treeB + treeC
      
      





TreeB



const treeB = (+ (--d)) + (+(+(b--)))
      
      





Nesse ponto, podemos ver que os operadores unários não fazem nada. Se os encurtarmos, simplificaremos muito a expressão.



// b = 4
// d = 4
const treeB = (--d) + (b--)
      
      





Já vimos esse truque acima. --d



retorna 3



, mas b--



retorna 4



, mas no momento em que a expressão é avaliada, ambos receberão o valor 3.



const treeB = 3 + 4
// b = 3
// d = 3
      
      





Portanto, agora nossa tarefa se parece com isto:



let b = 3, d= 3, u = 3;
 
const treeA = 144
const treeB = 7
const treeC = (+(+((d*b) + (+u))))
const tree = treeA + treeB + treeC
      
      





TreeC



E estamos quase terminando!



// b = 3
// d = 3
// u = 3
const treeC = (+(+((d*b) + (+u))))
      
      





Vamos nos livrar desses irritantes operadores unários primeiro.



// b = 3
// d = 3
// u = 3
const treeC = (+(+((d*b) + u)))
      
      





Nós nos livramos dele, mas aqui você precisa ter cuidado com os colchetes, etc.



// b = 3
// d = 3
// u = 3
const treeC = (d*b) + u
      
      





É muito simples agora. 3 * 3



igual 9



, 9 + 3



igual 12



e, finalmente, temos ...



Responda!



let b = 3, d= 3, u = 3;
 
const treeA = 144
const treeB = 7
const treeC = 12
const tree = treeA + treeB + treeC
      
      





144 + 7 + 12



igual 163



. A resposta para o problema: 163



.



Conclusão



JavaScript pode confundir você de várias maneiras estranhas e deliciosas. Mas, ao compreender como a linguagem funciona, você pode chegar à razão mais fundamental para isso.



De modo geral, o caminho para uma solução pode ser mais informativo do que a resposta, e as mini-soluções encontradas ao longo do caminho podem nos ensinar algo por si mesmas.



Vale a pena dizer que verifiquei meu trabalho usando o console do navegador e foi mais interessante para mim fazer a engenharia reversa da solução do que resolver o problema com base nos princípios básicos.



Mesmo que você saiba como resolver um problema, existem muitas ambigüidades sintáticas que precisam ser tratadas ao longo do caminho. E tenho certeza que muitos de vocês notaram quando olharam para a expressão de nossa árvore. Listei alguns deles abaixo, mas cada um vale um artigo separado!



Também gostaria de agradecer https://twitter.com/AnthonyPAlicea, sem o curso do qual eu nunca teria sido capaz de descobrir tudo, e https://twitter.com/tlakomy por esta pergunta.



Notas e esquisitices



Eu destaquei os mini-enigmas que encontrei ao longo do caminho em uma seção separada para que o processo de encontrar uma solução permaneça transparente.



Como mudar a ordem das variáveis ​​afeta



Veja este video



let x = 10
console.log(x++ + x)
      
      





Várias perguntas podem ser feitas aqui. O que será impresso no console e qual é o valor x



na segunda linha?



Se você acha que esse é o mesmo número, desculpe-me, eu enganei você. O truque é o que é x++ + x



calculado como (x++) + x



, e quando o mecanismo JavaScript calcula o lado esquerdo (x++)



, ele faz o incremento x



, portanto, quando se trata de + x



, o valor de x é igual 11



, não 10



.



Outra pergunta complicada - que valor x++



ele retorna ?



Eu dei uma pista bastante óbvia sobre qual é a resposta realmente 10



.



Esta é a diferença entre x++



e ++x



. Se olharmos para as funções subjacentes dos operadores, eles se parecem com isto:



function ++x(x) {
  const oldValue = x;
  x = x + 1;
  return oldValue;
}
function x++(x) {
  x = x + 1;
  return x
}
      
      





Olhando para eles desta forma, podemos entender que



let x = 10
console.log(x++ + x)
      
      





significará o que x++



retorna 10



e, no momento da avaliação, + x



seu valor é 11



. Portanto, ele será impresso no console 21



e o valor x será igual a 11



.



Essa tarefa relativamente simples aponta para um antipadrão comum usado em todo o código - expressões confusas e efeitos colaterais . Mais detalhes.



Poderia haver dois operadores com a mesma precedência, mas associatividades diferentes?



Vamos mover em ordem e esquecer a palavra "associatividade" por enquanto.



Vamos pegar os operadores +



e =



, e resumir a situação.



Foi mostrado acima o que é a + b + c



calculado como (a + b) + c



, porque é +



associativo à esquerda.



a = b = c



calculado como a = (b = c)



porque é =



associativo correto. Observe que ele =



retorna o valor atribuído à variável, portanto, a



será igual ao que é b



após avaliar a expressão.



Vamos substituir os operandos por suas prioridades:



a left b left c = (a left b) left c
a right b right c = a right (b right c)

  

a left b right c = ?
a right b left c = ?
      
      





Vê que os segundos exemplos são logicamente impossíveis? a + b = c



só é possível porque +



tem precedência sobre =



, portanto, o analisador sabe o que fazer. Se dois operadores têm a mesma precedência, mas associatividade diferente, o analisador de sintaxe não será capaz de determinar em que ordem realizar as ações!



Então, para resumir: não, operadores com a mesma precedência não podem ter associatividade diferente!



É curioso que no F # você possa alterar a associatividade das funções na hora, por isso consegui falar sobre associatividade sem enlouquecer! Mais detalhes.



Operadores unários



Um ponto interessante descoberto ao analisar a ordem de cálculo +n



e ++n



.



Não pode ser executado -- -i



porque -



retorna um número e os números não podem ser aumentados ou diminuídos e não pode ser executado ---i



porque o significado é ---



ambíguo (isto -- -



ou - --



? Veja os comentários abaixo.), Mas você pode fazer isto:



let i = 10
console.log(-+-+-+-+-+--i)
      
      





Positividade confusa



Um dos problemas mais problemáticos era a ambigüidade +



em JavaScript. O mesmo símbolo, conforme visto abaixo, é usado em quatro funções diferentes:



let i = 10
console.log(i++ + + ++i)
      
      





Cada operando tem seu próprio significado, prioridade e associatividade. Isso me lembra o famoso quebra-cabeça de palavras:



Buffalo buffalo Buffalo buffalo buffalo Buffalo buffalo .



Operadores unários ou atribuição?



+



pode significar um operador unário ou uma atribuição. O que é no caso do u



problema desde o início do artigo?



... +
u
      
      





Em última análise, a resposta depende de ... o que é. Se escrevêssemos tudo em uma linha



... + u
      
      





então a resposta seria diferente para x + u



e x - + u



. No primeiro caso, o símbolo significa adição, e no segundo - unário +



. A única maneira de descobrir o que isso significa é analisar o resto da expressão até que haja apenas um operador para representar!






Propaganda



VDS para programadores com o hardware mais recente, proteção contra ataques e uma grande seleção de sistemas operacionais. A configuração máxima é de 128 núcleos de CPU, 512 GB de RAM, 4000 GB NVMe.






All Articles