WAT JS - Mergulhando nos Comportamentos do JavaScript
No post Conversão de tipos em JavaScript, que fiz há algum tempo, expliquei um pouco de como o motor do JS trata algumas conversões internamente, e recebi um desafio para explicar algumas bizarrices da linguagem, que você pode ver no vídeo da talk WAT, por Gary Bernhardt - CodeMash 2012.
Ah, se você ainda não leu o post que falei, clique aqui pra ler, ajudará a entender algumas partes deste post. ;)
Esclarecimento
Gostaria de fazer uma observação aqui. Em um dos últimos parágrafos do post, eu disse que a operação ==
entre objetos compara se eles (os objetos) são iguais. Na verdade, esta operação avalia se o objeto é o mesmo. Como assim? Devemos saber que quando atribuímos um objeto à uma variável, estamos criando uma nova instância daquele objeto. Veja bem:
// Nesse caso os objetos realmente são iguais.
var foo = { lang: 'js' };
var bar = { lang: 'js' };
// Mas a operação de comparação retorna `false`
// Porque são duas instâncias diferentes
foo == bar; // false
// Entretanto, se fizermos o seguinte:
foo = bar = { lang: 'js' };
// Podemos comparar os objetos e teremos `true`
// Já que é a mesma instância.
foo == bar; // true
foo == bar
retorna true
porque é a mesma instância do objeto, como se fosse um ponteiro. Nesse caso, podemos fazer até a atribuição de outras propriedades ou métodos, assim:
foo.baz = 94;
foo.fn = function () { return this.lang; }
// E ambos os objetos terão o mesmo conteúdo
foo; // { lang: 'js', baz: 94, fn: [Function] }
bar; // { lang: 'js', baz: 94, fn: [Function] }
Pronto, tudo certo. Podemos prosseguir.
Operadores Aditivos
Para explicar os WAT JS, precisamos entender um pouco do funcionamento dos operadores aditivos, já que eles que causam o *"problema". Então, vou tentar explicar um pouco do que eles (*+
** e -
) fazem aqui.
O Operador de Adição (+)
Segundo a seção 12.7.3 da especificação do ES6:
O operador de adição executa concatenação de strings, ou adição numérica.
Mas como ele sabe qual ação deve realizar? Simples: antes de trabalhar com os dados, o algoritmo da adição pega os valores primitivos dos termos e trabalha em cima desses valores. Considere a operação a + b
para o algoritmo abaixo (não é uma linguagem de programação específica):
lval: recebe o valor de ToPrimitive(a)
rval: recebe o valor de ToPrimitive(b)
se (lval = String) OR (rval = String):
lstr: recebe o valor de ToString(lval)
rstr: recebe o valor de ToString(rval)
Retorna a String concatenada de lstr e rstr
se não:
lnum: recebe o valor de ToNumber(lval)
rnum: recebe o valor de ToNumber(rval)
Retorna Operacao( lnum + rnum )
Esse é o algoritmo da adição de forma simplificada. Aqui podemos ver que se o primeiro OU o segundo termo for uma String
, a operação realizada será concatenação e todos os valores serão forçados para o tipo String
; caso contrário, será soma, e todos os valores serão forçados para o tipo Number
, como nos exemplos a seguir:
'Java' + 'Script'; // "JavaScript"
'JavaScript ' + 6; // "JavaScript 6"
6 + ' JavaScript'; // "6 JavaScript"
2015 + 6 ; // 2021
Beleza, estamos indo bem, no entanto, as operações do vídeo são feitas com objetos mais complexos: Array
e Object
. Vamos fazer o seguinte, assim que eu terminar de explicar a questão dos operadores, eu volto nesse assunto, OK?
O Operador de Subtração (-)
Segundo a seção 12.7.4 da especificação do ES6, o operador de subtração (-
) não tem muitos poderes como o de adição (+
), ele apenas subtrai os valores ToNumber()
dos termos.
Considere a - b
:
lnum: recebe o valor de ToNumber(a)
rnum: recebe o valor de ToNumber(b)
Retorna Operacao( lnum - rnum )
Regras da Operação
A função Operacao()
, invocada nos retornos das operações aditivas, seguirá algumas regras e as mais peculiares são:
se (um dos termos = NaN):
Retorna NaN
se (ambos os termos = Infinity, mas de sinais opostos):
Retorna NaN
Quando um termo é NaN
, significa que o resutado de ToNumber()
de um dos termos não teve uma conversão ToPrimitive()
normal, então, era um elemento do tipo Object
que não tinha um tipo primitivo definido.
As demais condições seguem a matemática que aprendemos mesmo:
+Infinity + (+Infinity) # Infinity
-Infinity + (-Infinity) # -Infinity
4 + 0 # 4
0 - 4 # -4
(-0) + 0 # 0
6 + 4 # 10
5 - 6 # -1
Pronto. Agora vamos mudar um pouco de assunto.
Arrays e Objects ToPrimitive()
Como vimos, a operação de adição verifica o tipo primitivo do valor para decidir o que fazer. Nossa grande questão é:
Qual o tipo primitivo de um
Array
ou de umObject
???
Antes de mais nada, um Array
é um objeto, certo? Certo!
Os objetos de normais (Boolean
, Number
e String
), que vimos no outro post, apenas pegam o valor primitivo do objeto. Já os objetos complexos, quando devem ter uma conversão ToPrimitive()
, buscam um método default para a conversão toString()
.
No caso do Array
, temos o que podemos chamar de Array.prototype.toString
, que basicamente retorna a string do join()
. Observe:
// O `join()` de um Array vazio
// sempre será uma string vazia
var foo = new Array(); // []
foo.join(); // ""
foo.toString(); // ""
// Se o Array tiver elementos,
// teremos uma string bacana.
var bar = [ 1, 2, 3, 4, 5 ];
bar.join(); // "1,2,3,4,5"
bar.toString(); // "1,2,3,4,5"
Poderíamos modificar o join()
ou o toString()
do Array
caso necessário.
Já um objeto do tipo Object
, tem um Object.prototype.toString
um pouco diferente. Nesse caso, o algoritmo verifica o tipo do objeto e retorna o resultado da concatenação do seguinte: '[object ' + objectType + ']'
, gerando o resultado '[object Object]'
na maioria dos casos. Observe o exemplo:
var foo = new Object(); // {}
foo.toString(); // '[object Object]'
// Até mesmo para objetos
// que não estejam vazios
var bar = { lang: 'js', y: 94 };
foo.toString(); // '[object Object]'
Enfim, agora que deu pra entender como as coisas funcionam, podemos começar a desmistificar a matemática maluca do WAT JS.
WAT JS
[] + []
e[] + {}
Ao avaliar a primeira operação, lembre-se que ao converter um Array
para seu tipo primitivo, teremos uma string do join()
do seu conteúdo. Já que ambos estão vazios, teremos duas strings vazias, e como os termos da "soma" são strings, o operados +
irá concatenar as duas strings vazias. Daí nosso retorno ""
.
Já na segunda, a única diferença é que uma das strings que são concatenadas, vem do toString()
de um Object
, o que nos leva ao resultado "[object Object]"
{} + []
e{} + {}
Antes de prosseguir, aconselho a leitura deste capítulo, para entender um pouco mais sobre escopo.
Vamos analisar a primeira operação. Bom, primeiro entenda que o motor fará uma análise léxica da esquerda para a direita (isso para todas essas operações, na verdade), e ao examinar o {}
, ele não o reconhece como um objeto vazio, mas sim como um bloco vazio. Ao tratar o Array
, como não há outro termo na operação (devido ao bloco vazio ser desconsiderado), o operador unário +
converterá a string vazia (valor primitivo do Array
vazio) em um tipo Number
, e Number("")
retorna 0
.
Agora a segunda. No caso desse vídeo, temos a mesma explicação de "bloco vazio" e "operador unário" da operação anterior, mas na parte do operador unário temos +{}
. Aqui há conversão ToPrimitive()
de {}
, que resulta em "[object Object]"
. Por fim, Number("[object Object]")
retorna NaN
, que é o resultado obtido no vídeo.
O resultado do vídeo é o resultado que tenho rodando no console do Firefox 49. Já o Chrome/Chromium +50 e o Node.js 4.5 (V8), que foram meus ambientes de teste, retornam a string "[object Object][object Object]"
, mas eu ainda estou tendando descobrir o motivo e eu acho que posso explicar o motivo nos testes abaixo.
No Node.js 4.5 ou no Chrome/Chromium 53 (os ambientes que eu testei), tenho os seguintes resultados:
{} + {} // "[object Object][object Object]"
({} + {}) // "[object Object][object Object]"
eval('{} + {}') // NaN
eval('({} + {})') // "[object Object][object Object]"
Já no Firefox 49 tenho o seguinte:
{} + {} // NaN
({} + {}) // "[object Object][object Object]"
eval('{} + {}') // NaN
eval('({} + {})') // "[object Object][object Object]"
Como vimos, o resultado de {} + {}
é NaN
pelo fato do primeiro {}
ser considerado um bloco vazio ao invés de um objeto, o que resulta em +{}
ser NaN
. No outro caso, como temos a operação entre parênteses, ambos os {}
são considerados objetos, o que justifica
"[object Object][object Object]"
.
O que não faz sentido, é {} + {}
retornar a string no Chrome/Chromium e no Node.js, quando está fora do eval()
. Se você tentar rodar { foo: 1, bar: 2 }
nesses ambientes, eles retornam um objeto mesmo, já no Firefox, ele tenta executar esse código como um bloco, mas dá erro de sintaxe.
A conclusão que cheguei foi que o V8 deve permitir esses objetos no contexto do console (ou seja, não deve ser resultado de algum comportamento peculiar da linguagem), uma vez que um arquivo block.js com o conteúdo { foo: 1, bar: 2 }
, dá o erro esperado quando executado com node block.js
E o Batman?
1) Bom, segundo o que já vimos aqui, o que acontece quando se tem um fator do tipo String
numa subtração JS? NaN
! O operador -
não é tão poderoso, faz apenas subtrações, então não pode fazer nada com algo que não é um número. OK.
2) new Array(16)
nos retorna um Array
com 16 posições do tipo undefined
. Então se executarmos um join()
, teremos 16 vírgulas.
3) new Array(16).join("wat" - 1)
nos retorna 16 NaN
s em sequência, agora é só concatenar um " Batman"
nesse retorno e bater palmas haha.
Conclusão
JavaScript não é uma linguagem bizarra, apenas incompreendida.
Espero ter sido claro, mas se não fui, ou se restou alguma dúvida, deixe nos comentários, será um prazer falar sobre JavaScript.
Isso é tudo pessoal, até a próxima.
(: