O Curioso Dia em que Dez foi Menor do que Quatro em JavaScript

se você caiu aqui googlando uma solução para este problema bizarro no electron-packager: calma que já está resolvido; atualize a versão do node-packager e vai ficar tudo bem.

Hoje fui cutucar um projeto antigo em Electron – uma framework para desenvolvimento de aplicativos desktop em JavaScript – e me deparei com este maravilhoso erro ao tentar criar uma build.

…pois é. Aparentemente, 10 é menor que 4.

Vamos entender o que está acontecendo.

O culpado

O código responsável é uma comparação de versão no pacote electron-packager. A comparação visa abortar a execução com uma mensagem de erro caso a versão em uso do Node seja menor que 4.0.

var nodeVersionInfo = process.versions.node.split('.').map(
	function (n) { return Number(n) }
)
if (nodeVersionInfo < [4, 0, 0]) {
	console.error('Electron Packager requires Node 4.0.0 or above')
	process.exit()
}

logo de cara temos um erro aqui; se a versão desejada é 4.0.0 ou superior – ou seja, incluindo 4.0.0 – a comparação deveria ser <=, não <.
mas o problema que nos interessa é outro.

A linha de código (deveras horrível) acima pega a versão atual do Node e a processa, resultando em um array no formato [majorNumber, minorNumber, patchNumber]. Versão 4.0 corresponde a um array [4, 0, 0]; versão 10.5.1 seria [10, 5, 1], etc. Este array é então comparado com o operador < à [4, 0, 0].

Nossa, que bacana! Quer dizer que JavaScript suporta comparação assim entre arrays? Então, mais ou menos.

[0,0,0] < [4, 0, 0] // true; como esperado
[9.1.5] < [4, 0, 0] // true; ok
[10, 0, 0] < [4, 0, 0] // false; ...o quê?
[10, 5, 0] < [4, 0, 0] // false; hmm...
[20, 0, 0] < [4, 0, 0] // false; hmmmmmmm...
[50, 0, 0] < [4, 0, 0] // true; ...como assim?

10 JavaScript Points se souber o que está acontecendo aqui.

Como JavasScript compara arrays?

Ora, transformando cada elemento do array em uma string, concatenando tudo separado por vírgula, e comparando as strings resultantes, claro! Assim que é para um array de números funcionar, não? …não?

[0, 0, 0] < [4, 0, 0]
// javascript vai tratar como...
[0, 0, 0].join() < [4, 0, 0].join()
// que resulta na comparação:
"0,0,0" < "4,0,0"

Então não, na verdade JavaScript não suporta comparações entre arrays, mas ele alegremente irá converter os arrays em strings, daí fazer a comparação. Converter variáveis para um tipo mais simples no qual a operação/comparação desejada pode ser realizada é algo muito comum em linguagens com tipagem dinâmica.

E como comparações entre strings funcionam mesmo?

// comparações '<' e '>' entre strings comparam cada caracter da string, sequencialmente; retornando true ou false assim que um deles for maior ou menor que o outro.
"0,0,0" < "4,0,0" // true; pois 0 é menor que 4
"5,0,0" < "4,0,0" // false; pois 5 é maior que 4
"4,1,0" < "4,0,0" // false; sendo os primeiros caracteres iguais, a comparaço prossegue para os segundos caracteres, e 1 é maior que 0.

Agora a natureza do problema fica clara:

"9,0,0" < "4,0,0" // true
"10,0,0" < "4,0,0" // false; 1 - o primeiro caracter - é menor que 4
"50,0,0" < "4,0,0" // true novamente; 5 é maior que 4

Esta comparação boba era uma bomba-relógio, se tornando um bug que impede completamente o uso do electron-packager assim que a versão do Node alcançasse 10 ou superior.

Consertar este bug é fácil; basta trocar o código em questão por um algoritmo que compara os números de versão numericamente e sequencialmente. Ou ainda melhor, usar uma biblioteca que faça isso para você:

var semver = require('semver')
if (semver.lt(process.versions.node, '4.0.0')) {
	console.error('Electron Packager requires Node 4.0.0 or above')
    process.exit()
}

Semver (semantic versioning) é um padrão estabelecido para estrutura de versões de software. A maioria das linguagens/plataformas possuem uma biblioteca especializada em interpretar e comparar versões neste formato; no caso do Node/NPM, o módulo semver se encarrega disso.

Esta foi exatamente a solução adotada pelos mantedores do electron-packager, e este bug está consertado na versão 12.0.2 em diante.

Moral da história

Se não consegue imaginar a implementação de uma operação, não a faça

Dá para comparar arrays em C? Não, poxa. Afinal, um array é só um ponteiro para uma região de memória onde um amontoado de valores está armazenado em série. Como todo tipo de dado complexo, não é possível realizar operações com um array diretamente em uma linguagem de baixo nível.

já tuitava Boromir

Então como programador você SABE que existe alguma mágica aqui; que uma sintaxe que permite “comparar arrays” é tão artificial quanto uma que permite a criação de classes e objetos: por definição a linguagem está fazendo malabarismos por debaixo dos panos para lhe proporcionar uma sintaxe conveniente.

E como programador responsável você não tem motivos para acreditar que o malabarismo que o JavaScript está fazendo para implementar é exatamente o algoritmo em C que você gostaria para a situação – como não foi neste caso.

Então se você sabe que algo não é uma operação “natural” (geralmente qualquer coisa que requeira ponteiros ou tipos não-básicos se enquadra nisso), não confie cegamente na sua linguagem ou framework. Sente para ver a documentação e faça alguns experimentos para conferir se o comportamento realmente é do jeito que você espera.

Cuidado com operações e comparações em linguagens dinâmicas

Um efeito colateral de não precisar manualmente especificar o tipo de cada variável é que não há garantia alguma sobre o que cada variável é. Certamente você já está acostumado com precisar fazer algumas verificações em valores para se certificar que eles são do tipo que você precisa que sejam.

no começo você reclama de C/Java e fica todo feliz com linguagens dinâmicas; daí dez anos passam e sua vida agora é desesperadamente simular tipos estáticos em sua linguagem dinâmica.

Ao realizar comparações entre valores, estas linguagens irão os converter para um “tipo mínimo comum” que seja capaz de os representar e que suporte a operação de comparação desejada. Neste caso, nossos arrays de números foram convertidos para strings quando decididamente não desejávamos fazer uma mera comparação entre strings.

Esteja sempre alerta para este tipo de typecasting implícito em linguagens dinâmicas; frequentemente é mais confiável e previsível você próprio explicitamente converter as variáveis envolvidas em uma operação para os tipos desejados.

…mas a verdadeira lição é a seguinte:

Use bibliotecas terceiras para solucionar problemas de domínio específico

Pra quê escrever código quando existem milhares de escrav- er, desenvolvedores open source que já o fizeram por você?

Deixar que seu programa seja um amontoado de código dos outros não é profissional ou responsável, mas a palavra chave aqui é “seu programa“. Reconheça onde o domínio de competência de seu programa começa e termina.

Neste caso, o electron-packager é uma biblioteca responsável por empacotar projetos Electron e suas dependências em executáveis prontos para execução independente. Comparar versões de software é um problema fora do escopo do projeto e deve ser “terceirizado”, se possível.

E apesar do benefício em economia de trabalho ser óbvio, o importante aqui é o ganho em confiabilidade: a realidade é que a mais trivial das situações sempre vai resultar em um problema complexo e extenso quando convertido para algoritmo e lidando com todos os casos possíveis. Afinal, a biblioteca semver usada nesta solução possui 1300 linhas de código (sem contar o dobro disso em testes automatizados) – certamente a tarefa de comparar versões não é de forma alguma trivial.

acho que a transição entre “usar código open source por preguiça ou ignorância” para “usar código open source pois sei que nada sei e tenho que focar no meu problema” é o que separa Programadores de meros “garotos que programam”.

Mas o seu ganho não é em estar deixando de escrever 1300 linhas de código: é em estar aproveitando da extensiva proficiência, estudo e dedicação investida nesta solução, por desenvolvedores que estavam ativamente focados em resolverem este problema da forma mais completa possível. O nível de qualidade de uma solução especializada assim sempre será maior do que você apressadamente resolvendo o problema com meia dúzia de linhas de código improvisadas enquanto está preocupado só em voltar a escrever a parte do seu software que efetivamente trás benefício ao seu cliente.

Afinal, se o semver tivesse sido usado ao invés destas linhazinhas ingênuas de código, este bug não teria ocorrido.

Escrever software não é fácil. Abordar problemas fora do seu escopo de forma casual é arrogante e irresponsável. Você já vai ter dor de cabeça suficiente abordando o domínio de conhecimento do seu código – se achar quaisquer outros problemas, terceirize-os para gente que pensou muito mais sobre eles do que você.

 

— Matheus E. Muller

, , ,