Resolviendo un divertido rompecabezas en JavaScript

Nuestra historia comienza con un tweet de Tomas Lakoma, en el que te invita a imaginar que tal pregunta te conocí en una entrevista.







Me parece que la reacción a tal pregunta en una entrevista depende de qué es exactamente. Si la pregunta realmente es cuál es el valor tree



, entonces el código puede simplemente insertarse en la consola y obtener el resultado.



Sin embargo, si la pregunta es cómo resolvería este problema, entonces todo se vuelve bastante curioso y conduce a una prueba de conocimiento de las complejidades de JavaScript y el compilador. En este artículo intentaré solucionar toda esta confusión y sacar conclusiones interesantes.



Estaba transmitiendo el proceso para resolver este problema en Twitch . La transmisión es larga, pero le permite echar otro vistazo al proceso paso a paso para resolver tales problemas.



Razonamiento general



Primero, convierta el código a formato copiable:



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





Inmediatamente noté algunas peculiaridades y decidí que algunos trucos del compilador podrían usarse aquí. Verá, JavaScript generalmente agrega punto y coma al final de cada línea, a menos que haya una expresión que no se pueda interrumpir . En este caso, +



al final de cada línea le dice al compilador que no es necesario interrumpir esta construcción.



La primera línea simplemente crea tres variables y les asigna un valor 3



. 3



Es un valor primitivo, por lo que cada vez que se crea una copia, se crea por valor , por lo que todas las variables nuevas se crean con un valor 3



... Si JavaScript asignara valores a estas variables por referencia , entonces cada nueva variable apuntaría a la variable utilizada anteriormente, pero no crearía un valor por sí misma.



Información Adicional



Precedencia y asociatividad del operador



Estos son los conceptos clave para resolver esta abrumadora tarea. En resumen, definen el orden en el que se evalúa una combinación de expresiones JavaScript.



Prioridad del operador



P: ¿cuál es la diferencia entre estas dos expresiones?



3 + 5 * 5
      
      





5 * 5 + 3
      
      





Desde el punto de vista del resultado, no hay diferencia. Cualquiera que recuerde las lecciones de matemáticas de la escuela sabe que la multiplicación se realiza antes que la suma. En inglés, recordamos el orden como BODMAS (corchetes fuera de dividir multiplicar sumar restar - corchetes, grado, división, multiplicación, suma, resta). JavaScript tiene un concepto similar llamado Operador Precedencia: significa el orden en el que evaluamos expresiones. Si quisiéramos forzar el cálculo primero 3 + 5



, haríamos lo siguiente:



(3+5) * 5
      
      





Los paréntesis obligan a que esta parte de la expresión se evalúe primero, porque el operador tiene una precedencia ()



mayor que el operador *



.



Cada operador de JavaScript tiene prioridad, por lo que con tantos operadores en tree



él, debemos averiguar en qué orden se evaluarán. Es especialmente importante lo --



que cambiará los valores b



y d



, por lo tanto, necesitamos saber cuándo se evalúan estas expresiones en relación con el resto tree



.



Importante: tabla de prioridades del operador e información adicional



Asociatividad



La asociatividad se utiliza para determinar en qué orden se evalúan las expresiones en operadores con igual precedencia. Por ejemplo:



a + b + c
      
      





No hay precedencia de operadores en esta expresión porque solo hay un operador. Entonces, ¿cómo se calcula, cómo (a + b) + c



o cómo a + (b + c)



?



Sé que el resultado será el mismo, pero el compilador necesita saber esto para poder seleccionar una operación primero y luego continuar con el cálculo. En este caso, la respuesta correcta es (a + b) + c



porque el operador es +



asociativo a la izquierda, es decir, evalúa primero la expresión de la izquierda.



“¿Por qué no hacer que todos los operadores sean asociativos a la izquierda?”, Podría preguntar.



Bueno, tomemos un ejemplo como este:



a = b + c
      
      





Si usamos la fórmula de asociatividad izquierda, obtenemos



(a = b) + c
      
      





Pero espera, esto se ve raro, y eso no es lo que quise decir. Si quisiéramos que esta expresión funcionara usando solo la asociatividad izquierda, tendríamos que hacer algo como esto:



a + b = c
      
      





Esto se convierte a (a + b) = c



, es decir, primero a + b



, y luego el valor de este resultado se asigna a la variable c



.



Si tuviéramos que pensar de esta manera, JavaScript sería mucho más confuso, razón por la cual usamos diferentes asociatividades para diferentes operadores: hace que el código sea más legible. Cuando leemos a = b + c



, el orden de cálculo nos parece natural, a pesar de que todo está ordenado de forma más inteligente en el interior y utiliza operandos asociativos de derecha e izquierda.



Probablemente hayas notado el problema de asociatividad en a = b + c



... Si ambos operadores tienen una asociatividad diferente, ¿cómo sabe qué expresión evaluar primero? Respuesta: ¡el que tiene mayor precedencia de operadores , como en la sección anterior! En este caso, +



tiene una prioridad más alta, por lo que se calcula primero.



He agregado una explicación más detallada al final del artículo, o puede leer más información .



Comprender cómo se evalúa la expresión de nuestro árbol



Habiendo entendido estos principios, podemos comenzar a analizar nuestro problema. Utiliza muchos operadores y la ausencia de paréntesis dificulta su comprensión. Así que agreguemos paréntesis, enumerando todos los operadores utilizados junto con su precedencia y asociatividad.



(operador con variable x): una prioridad asociatividad
x ++: 18 no
X--: 18 no
++ x: 17 derecho
--X: 17 derecho
+ x: 17 derecho
*: quince izquierda
x + y: 14 izquierda
=: 3 derecho


Paréntesis



Vale la pena mencionar aquí que agregar paréntesis correctamente es una tarea complicada. Verifiqué que la respuesta se calcula correctamente en cada etapa, ¡pero esto no garantiza que mis paréntesis siempre estén colocados correctamente! Si conoce una herramienta para la colocación automática de aparatos ortopédicos, envíeme un correo electrónico.



Averigüemos el orden en el que se evalúan las expresiones y agreguemos paréntesis para mostrarlo. Le mostraré paso a paso cómo llegué al resultado final, pasando de los operadores de mayor prioridad hacia abajo.



Postfix ++ y postfix -



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





Unario +, prefijo ++ y prefijo -



Tenemos un pequeño problema aquí, pero empezaré por evaluar el operador unario +



y luego llegaremos al punto del problema.



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





Y aquí es donde surgen las dificultades.



+ --d+
      
      





--



y +()



tienen la misma prioridad. ¿Cómo sabemos en qué orden calcularlos? Formulemos el problema de una manera más sencilla:



let d = 10
const answer = + --d
      
      





Recuerde, +



esto no es una adición, sino un plus unitario o positividad. Puedes percibirlo como -1



, solo aquí está +1



.



La solución es que evaluamos de derecha a izquierda, porque los operadores de esta precedencia son asociativos por la derecha .



Entonces nuestra expresión se convierte en + (--d)



.



Para entender esto, intente imaginar que todos los operadores son iguales. En este caso, + +1



será equivalente (+ (+1))



según la lógica, que es 1 — 1 — 1



equivalente a ((1 — 1) — 1)



... ¿Observa que el resultado de los operadores asociativos de la derecha en la notación con paréntesis es el opuesto del caso con los operadores de la mano izquierda?



Si aplicamos la misma lógica al punto del problema, obtenemos lo siguiente:



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





Y finalmente, insertando el paréntesis para este último ++



, obtenemos:



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





Multiplicación (*)



De nuevo tenemos que lidiar con la asociatividad, pero esta vez con el mismo operador, que queda asociativo. En comparación con el paso anterior, ¡esto debería ser fácil!



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





Hemos llegado a la etapa en la que ya es posible comenzar los cálculos. Sería posible agregar paréntesis para el operador de asignación, pero creo que será más confuso que fácil de leer, así que no lo haremos. Tenga en cuenta que la expresión anterior es un poco más complicada x = a + b + c



.



Podemos acortar algunos de los operadores unarios, pero los guardaré en caso de que sean importantes.



Al dividir la expresión en varias partes, podemos comprender las etapas individuales de los cálculos y desarrollarlas.



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
      
      





Una vez hecho esto, podemos comenzar a explorar el cálculo de diferentes valores. Comencemos con treeA.



ÁrbolA



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





Lo primero que se evaluará aquí es una expresión ++d



que regresará 4



e incrementará d



.



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





Luego se ejecuta 4*d



: sabemos que en esta etapa d es 4, por lo tanto 4*4



es 16.



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





Lo interesante de este paso es que vamos a multiplicar por b antes de incrementar b, por lo que el cálculo se realiza de izquierda a derecha. 16 * 3 = 48



...



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





Anteriormente, hablamos sobre lo que ++



tiene una prioridad más alta que *



, por lo que esto se puede escribir como 48 * b++



, pero hay otros trucos aquí: el valor de retorno b++



es el valor antes del incremento, no después. Entonces, aunque b eventualmente se convierta en 4, el valor multiplicado será 3.



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





48 * 3



es igual 144



, por lo que después de calcular la primera parte byd son iguales a 4, y el resultado de la expresión es 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
      
      





ÁrbolB



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





En este punto, podemos ver que los operadores unarios en realidad no hacen nada. Si los acortamos, simplificaremos mucho la expresión.



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





Ya hemos visto este truco arriba. --d



regresa 3



, pero b--



regresa 4



, pero cuando se evalúa la expresión, a ambos se les asignará el valor 3.



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





Así que ahora nuestra tarea se parece a esto:



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





TreeC



¡Y ya casi terminamos!



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





Deshagámonos primero de esos molestos operadores unarios.



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





Nos deshicimos de él, pero aquí hay que tener cuidado con los corchetes, etc.



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





Ahora es bastante simple. 3 * 3



igual 9



, 9 + 3



igual 12



, y finalmente, tenemos ...



¡Responder!



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



. La respuesta al problema: 163



.



Conclusión



JavaScript puede confundirte de muchas formas extrañas y agradables. Pero al comprender cómo funciona el lenguaje, puede llegar a la razón más fundamental de esto.



En términos generales, el camino hacia una solución puede ser más informativo que la respuesta, y las mini-soluciones encontradas en el camino pueden enseñarnos algo por sí mismas.



Vale la pena decir que verifiqué mi trabajo usando la consola del navegador y fue más interesante para mí hacer ingeniería inversa de la solución que resolver el problema en base a los principios básicos.



Incluso si sabe cómo resolver un problema, hay muchas ambigüedades sintácticas que deben resolverse en el camino. Y estoy seguro de que muchos de ustedes se dieron cuenta cuando miraron nuestra expresión de árbol. He enumerado algunos de ellos a continuación, ¡pero cada uno vale un artículo separado!



También me gustaría agradecer a https://twitter.com/AnthonyPAlicea, sin el curso del cual nunca hubiera podido resolverlo todo, y https://twitter.com/tlakomy por esta pregunta.



Notas y rarezas



He resaltado los mini-acertijos que encontré en el camino en una sección separada para que el proceso de encontrar una solución sea transparente.



Cómo afecta el cambio de orden de las variables



Mira este video



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





Aquí se pueden hacer varias preguntas. ¿Qué se imprimirá en la consola y cuál es el valor x



en la segunda línea?



Si crees que es el mismo número, discúlpame, te burlé. El truco es lo que se x++ + x



calcula como (x++) + x



, y cuando el motor de JavaScript calcula el lado izquierdo (x++)



, hace el incremento x



, por lo que cuando se trata de + x



, el valor de x es igual 11



, no 10



.



Otra pregunta delicada: ¿qué valor devuelve x++



?



He dado una pista bastante obvia sobre cuál es realmente la respuesta 10



.



Ésta es la diferencia entre x++



y ++x



. Si miramos las funciones subyacentes de los operadores, se ven así:



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





Mirándolos de esta manera, podemos entender que



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





significará lo que x++



devuelve 10



, y en el momento de la evaluación, + x



su valor es 11



. Por lo tanto, se imprimirá en la consola 21



y el valor x será igual a 11



.



Esta tarea relativamente simple apunta a un anti-patrón común que se usa en todo el código: expresiones desordenadas y efectos secundarios . Más detalles.



¿Podría haber dos operadores con la misma precedencia pero diferentes asociatividades?



Movámonos en orden y olvidemos la palabra "asociatividad" por ahora.



Tomemos los operadores +



y =



, y resumamos la situación.



Se mostró por encima de lo que se a + b + c



calcula como (a + b) + c



, porque es +



asociativo a la izquierda.



a = b = c



calculado a = (b = c)



porque es =



asociativo a la derecha. Tenga en cuenta que =



devuelve el valor asignado a la variable, por lo a



que será igual a lo que es b



después de evaluar la expresión.



Reemplacemos los operandos con su prioridad:



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 = ?
      
      





¿Ves que los segundos ejemplos son lógicamente imposibles? a + b = c



solo es posible porque +



tiene prioridad sobre =



, por lo que el analizador sabe qué hacer. Si dos operadores tienen la misma precedencia, pero diferente asociatividad, entonces el analizador de sintaxis no podrá determinar en qué orden realizar las acciones.



Entonces, para resumir: no, los operadores con la misma precedencia no pueden tener una asociatividad diferente.



Es curioso que en F # se pueda cambiar la asociatividad de funciones sobre la marcha, por eso pude hablar de asociatividad sin volverme loco. Más detalles.



Operadores unarios



Un punto interesante descubierto al analizar el orden de cálculo +n



y ++n



.



No se puede ejecutar -- -i



porque -



devuelve un número, y los números no se pueden incrementar o disminuir, y no se puede hacer ---i



porque el significado es ---



ambiguo (¿esto -- -



o - --



? Vea los comentarios a continuación), pero puede hacer esto:



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





Positividad confusa



Uno de los problemas más problemáticos fue la ambigüedad +



en JavaScript. El mismo símbolo, como se ve a continuación, se usa en cuatro funciones diferentes:



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





Cada operando tiene su propio significado, prioridad y asociatividad. Me recuerda el famoso rompecabezas de palabras:



búfalo búfalo búfalo búfalo búfalo búfalo búfalo .



¿Operadores unarios o asignación?



+



puede significar un operador unario o una asignación. ¿Qué ocurre con el u



problema del principio del artículo?



... +
u
      
      





En última instancia, la respuesta depende de ... qué es. Si escribiéramos todo en una línea



... + u
      
      





entonces la respuesta sería diferente para x + u



y x - + u



. En el primer caso, el símbolo significa adición, y en el segundo, unario +



. ¡La única forma de averiguar qué significa es analizar el resto de la expresión hasta que solo quede un operador para representar!






Publicidad



VDS para programadores con el último hardware, protección contra ataques y una gran selección de sistemas operativos. La configuración máxima es de 128 núcleos de CPU, 512 GB de RAM, 4000 GB de NVMe.






All Articles