O termo “escovar bits” na programação de computadores refere-se a um nível extremamente detalhado e minucioso de otimização ou manipulação de código, geralmente lidando diretamente com bits e estruturas de dados de baixo nível. Esse conceito surgiu na cultura hacker e se popularizou em comunidades de programadores que trabalham com sistemas embarcados, compiladores, jogos, gráficos e otimização extrema de desempenho.
Na programação com linguagens de alto nível, escovar bits ainda tem um papel importante, apesar das abstrações oferecidas pelas linguagens modernas. Em linguagens como TypeScript, Java, Kotlin e Python, o controle direto de bits não é tão comum quanto em C ou Assembly, mas ainda há cenários em que manipulação bitwise pode trazer benefícios. Aqui, o conceito de escovar bits foi estendido ao longo do tempo! A expressão evoluiu para descrever qualquer trabalho de otimização extrema, detalhismo excessivo ou microgerenciamento técnico, mesmo em linguagens de alto nível.
Dentro desse contexto, um termo comum é “overengineering” (engenharia excessiva). Quando um desenvolvedor implementa uma solução muito mais complexa do que o necessário, adicionando camadas desnecessárias de abstração, interfaces e padrões sofisticados para um problema simples, ou até problemas complexos cuja solução já seja bem conhecida e aceita no mercado.
Escovar bits deve ser algo cujo benefício supera a complexidade adicionada no software. Este é um dilema da engenharia de software. De um lado, existe a busca por eficiência máxima, otimizações extremas e controle detalhado sobre o desempenho do código. Do outro, a necessidade de legibilidade, simplicidade e manutenabilidade, permitindo que a equipe consiga compreender, modificar e evoluir o sistema sem dificuldades.
Vamos analisar um exemplo que usa a lista de unidades federativas – UF – do Brasil, também chamadas de estados. Essa lista é bem definida e pouco suscetível a mudança. Geralmente, uma informação muito importante é conhecer o código IBGE de um estado. O Maranhão, por exemplo, tem o código 21. Esse número pode ser armazenado em um banco de dados e a lógica no software ser construída para associar o valor ao estado, executando alguma rotina quando for pertinente. Um sistema de e-commerce pode calcular o valor do frete para o Maranhão baseando-se no código 21.
No entanto, o valor 21 pode indicar muitas outras coisas. Pode ser uma idade, meu saldo bancário ou a quantidade de produtos em em um estoque. O 21, enquanto número, sozinho não diz muita coisa. Em uma linguagem de alto nível e orientada a objetos, como o Java, considerado a ideia que a lista de estados brasileiros é bem definida, uma boa prática é criar o que chamamos de enumeração. Um bom nome para ela seria UF. Assim, em vez de usar o valor 21 no software, podemos usar MA e associar 21 a este valor. Quando batermos o olho em algo como UF.MA não teremos dúvida, estamos lidando com uma unidade federativa e, no caso, o Maranhão. Bem melhor!
Mas enquanto o software usa a constante MA, pode existir um bom motivo para manter o valor 21 no banco de dados. Ser for o caso, o software deve fazer as traduções de 21 para MA, e MA para 21. O exemplo abaixo mostra que converter de MA para 21 é muito simples e direto. O código IBGE da UF vai estar disponível em algo como UF.MA.getIbgeCode().
public enum UF {
RO(11), AC(12), AM(13), RR(14), PA(15), AP(16),
TO(17), MA(21), PI(22), CE(23), RN(24), PB(25),
PE(26), AL(27), SE(28), BA(29), MG(31), ES(32),
RJ(33), SP(35), PR(41), SC(42), RS(43), MS(50),
MT(51), GO(52), DF(53);
private final int ibgeCode;
UF(int ibgeCode) {
this.ibgeCode = ibgeCode;
}
public int getIbgeCode() {
return ibgeCode;
}
}
Por outro lado, obter o MA a partir do 21 requer um pouco mais de estratégia. Uma maneira bem trivial é percorrer a lista de UF até encontrar aquela que possua o valor 21.
public static UF fromIbgeCode_using_for(int ibgeCode) {
for (UF uf : UF.values()) {
if (uf.getIbgeCode() == ibgeCode) {
return uf;
}
}
throw new IllegalArgumentException("Illegal UF code: " + ibgeCode);
}
Outra forma de aplicar a mesma estratégia é usar streams do Java e percorrer o array de constantes da enumeração.
public static UF fromIbgeCode_using_stream(int ibgeCode) {
return Arrays.stream(values())
.filter(uf -> uf.ibgeCode == ibgeCode)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Illegal UF code: " + ibgeCode));
}
Outra maneira é inicializar uma espécie de cache com mapa, onde cada valor vai estar associado à constante correspondente. Esta abordagem precisa considerar que cada acesso ao mapa tem um custo computacional envolvido. A implementação do mapa provavelmente vai criar um hash, verificar colisões e assegurar que tudo execute dentro do esperado.
private static Map<Integer, UF> ufCacheMap = Arrays
.stream(values())
.collect(Collectors.toMap(UF::getIbgeCode, Function.identity()));
public static UF fromIbgeCode_using_cacheMap(int ibgeCode) {
return Optional.ofNullable(ufCacheMap.get(ibgeCode))
.orElseThrow(() -> new IllegalArgumentException("Illegal UF code: " + ibgeCode));
}
Também é possível criar o cache usando um array. Rondônia tem o código 11 e o Distrito Federal tem o código 53. Eles não são contínuos. Por exemplo, não existe um estado com código 18. Podemos criar um array com 54 posições. Cada estado fica armazenado no índice correspondente ao seu código. Assim, quando consultarmos o índice 21, teríamos o MA.
private static UF[] ufCacheArray = new UF[54];
static {
for (UF uf : values()) {
ufCacheArray[uf.ibgeCode] = uf;
}
}
public static UF fromIbgeCode_using_cacheArray(int ibgeCode) {
UF foundUf = ibgeCode >= 0 && ibgeCode < ufCacheArray.length && ufCacheArray[ibgeCode] != null
? ufCacheArray[ibgeCode]
: null;
if(foundUf == null) {
throw new IllegalArgumentException("Illegal UF code: " + ibgeCode);
}
return foundUf;
}
Mas qual dessas implementações é a melhor do ponto de vista da performance, qual é a mais complicada de implementar e entender, escrever testes unitários é uma tarefa simples? Construir um software do zero é algo bastante complexo dependendo do nível de problema que ele vai resolver. Por mais complexo que o desenvolvimento inicial possa parecer, esse custo não se compara em manter esse software durante anos e décadas. Empresas que fazem uso massivo de tecnologia empenham um bom dinheiro apenas para engenheiros de software evoluírem o software com novas funcionalidades e assegurar que eles estarão disponíveis em ambiente de produção. E analisando por este ponto de vista da manutenção do software, é muito importante que o código seja o mais claro possível.
O código abaixo faz uma espécie de teste em cada uma das implementações.
class Main {
public static void main(String[] args) {
UfGetter[] getters = new UfGetter[] {
ibgeCode -> UF.fromIbgeCode_using_for(ibgeCode),
ibgeCode -> UF.fromIbgeCode_using_stream(ibgeCode),
ibgeCode -> UF.fromIbgeCode_using_cacheMap(ibgeCode),
ibgeCode -> UF.fromIbgeCode_using_cacheArray(ibgeCode),
};
long[] timeCounters = new long[]{ 0, 0, 0, 0 };
Random random = new Random();
final int iteration = 4000000;
for (int i = 0; i < iteration; i++) {
int getterIndex = random.nextInt(getters.length);
UfGetter getter = getters[getterIndex];
int ibgeCode = UF.values()[random.nextInt(UF.values().length)].getIbgeCode();
long t1 = System.nanoTime();
getter.fromIbgeCode(ibgeCode);
long t2 = System.nanoTime();
timeCounters[getterIndex] += (t2 - t1);
}
String[] technics = new String[] { "for loop", "stream", "cacheMap", "cacheArray" };
for (int i = 0; i < technics.length; i++) {
System.out.printf("%12s: %.3f ms\n", technics[i], timeCounters[i] / 1000000.0);
}
}
}
O teste repete 4 milhões de vezes uma busca de forma aleatória. O resultado é apresentado abaixo.
for loop: 129,430 ms
stream: 404,244 ms
cacheMap: 89,951 ms
cacheArray: 62,091 ms
A solução com stream foi, de longe, a mais fraca, embora me pareça a mais simples e menos verbosa de todas. As outras soluções ficaram bem equivalentes. Usar um cache codificado em um array pré inicializado não é uma solução tão complexa e mostrou o melhor resultado.
Neste ponto, dentro do contexto do Java, é importante mencionar que a JVM aplica diversas otimizações no código. A JVM pode aplicar o JIT (Just In Time), onde ela converte o byte code em código nativo do sistema onde a JVM está executando. Os resultados abaixo foram executados com a flag -Xint que indicam para a JVM não aplicar qualquer tipo de otimização.
for loop: 1413,448 ms
stream: 13349,168 ms
cacheMap: 1832,194 ms
cacheArray: 406,256 ms
Veja que a performance caiu drasticamente. A melhor implementação, que usa um cache implementado em array, ficou seis vezes pior. A solução com stream ficou 33 vezes mais lenta. Uma vez que a JVM aplica otimizações no código, ele pode ser feito de qualquer forma? Claro que não! Mas é importante conhecer as ferramentas utilizadas para construir o softawre. Seja Python, PHP, nodejs, dentre muitas outras.
Os teste foram executados 4 milhões de vezes, de maneira aleatória. Cosiderando que a distribuição é bem estável, cada implementação foi executada cerca de 1 milhão de vezes. A pior delas, a que usa streams, e sem qualquer otimização da JVM, levou apenas cerca de 0,013 milissegundo para encontrar a UF a partir do código IBGE. Se este código for o backend de uma API, quanto esse tempo vai impactar na resposta final ao usuário somando com todas as outras rotinas que devem ser executadas? Qual o custo financeiro em gasto de CPU se essa rotina estiver rodando em um ambiente de cloud? Se o ganho em performance não for percepitível, me parece que vale mais a pena escolher um código mais claro e fácil de testar, manter e evoluir.
O exemplo da UF é uma ilustração do que podemos considerar quando estamos escovando bits em um software construído com uma linguagem de alto nível e frameworks. Não acredito que nenhuma das implementações apresentadas seja extremamente complexa. O problema de converter código IBGE em UF e vice-versa é bem trivial. Em um software de mercado temos que lidar com situações bem mais desafiadoras. Optar por uma solução extremamente performática pode ser uma troca cuja compensação não valha a pena se esse código precisa ser mantido durante vários anos.
Criar um software do zero é caro, mas o custo de mantê-lo por décadas é ainda maior. A longo prazo, empresas que não investem em boas práticas (código limpo, documentação, testes automatizados) pagam um preço ainda maior devido ao acúmulo de débito técnico. Muitas vezes, a solução para esses problemas é algo bem conhecido entre engenheiros de software com alguma experiência: fazer tudo de novo. E pior: fazer tudo de novo enquanto o software antigo continua evoluindo.
Escovar bits não se trata apenas de buscar o máximo desempenho, mas de equilibrar otimização com clareza, manutenção e evolução do software. O exemplo da conversão de códigos IBGE para unidades federativas, a escolha da abordagem pode impactar diretamente na performance e na complexidade do código. No entanto, otimizações excessivas podem levar a um custo de manutenção elevado, tornando-se um fardo ao longo do tempo. Assim, o verdadeiro desafio da engenharia de software não é apenas escrever código eficiente, mas garantir que ele continue compreensível, extensível e sustentável ao longo dos anos.