Nos primórdios da computação, cada comando, cada memória utilizada por nossos programas era extremamente necessária uma otimização bit a bit para extrair o máximo de desempenho possível do Hardware uma vez que eles não eram tão poderosos como nos dias atuais. Com o avanço do hardware e das tecnologias de programação alguns conceitos começaram e ficar um pouco esquecidos ou sem tanta importância nos cursos já que temos hardware com tanta abundância no mercado. Um destes conceitos é a manipulação de bits para realizar operações booleanas.
O que é ?
Imagine um cenário onde você precisa ter várias variáveis para tomar decisões e cada uma delas é um Boolean. Agora imagine que cada um destas variáveis na verdade precisam ser salvas no banco de dados para que no futuro, sejam carregadas para que o programa possa decidir sobre o rumo do código com elas. Parece um cenário comum, certo? Uma tabela com dezenas de colunas booleanas guardando cada uma das configurações… Muitas empresas provavelmente tem algo parecido no seu banco de dados e…. isso é errado? Claro que não. Na programação tudo é possível e há um motivo para cada decisão de implementação. Mas e se eu disser que você pode ter apenas uma única coluna para guardar todas configurações utilizando um tipo numérico, economizar consideravelmente espaço em memória, melhorar a performance do sistema e legibilidade do código… você acha isso possível? A resposta é… SIM!
Para explicar o básico da ideia, vou criar aqui um cenário hipotético bem simples… Um sistema onde temos várias “Roles” (8 no total) para controlar acesso em nossas features. As roles disponiveis serão:
- Admin
- CEO
- ProductOwner
- TechLead
- SeniorDev
- MidDev
- JuniorDev
- TraineeDev
E vamos criar uma funcão que recebe um usuário e verifica se ele pode acessar o recurso ou não. Irei utilizar a linguagem Go para os exemplos, mas tudo que será aprendido aqui pode ser replicado em qualquer linguagem.
Exemplo de como poderia ser a implementação da maneira mais convencional:
Go Lang: https://go.dev
Considerando que o PostgreSQL utiliza 1 Byte para armazenar cada boolean, isso significa que cada usuário no nosso sistema terá 8 bytes de consumo de memória pois cada um deles terá um boolean para cada Role, 8 no total. Ta, mas isso é praticamente nada certo?
Ok, vamos imaginar que temos 69 milhões de clientes utilizando o sistema (Número atual do Nubank por exemplo), então agora o banco de dados tem 552Mb total para os clientes. Pode não parecer muito, mas lembrem-se que essa é a única tabela com apenas estes atributos, se o seu sistema chegar a um número tão considerável de usuários no futuro, com a quantidade de outras tabelas que muitas vezes tem até a mesma ideia com vários booleanos, seu banco de dados começará a ficar extremamente preenchido com vários dados que poderiam ser economizados, sem contar as querys gigantes tentando capturar situações condicionais específicas.
Ainda não convenci? Então veja agora quanto de armazenamento você gastaria utilizando a abordagem com bits… Tudo que precisaremos é uma coluna com o tipo SmallInt (2 Bytes), este é o menor tipo numérico disponível e eu poderia guardar nessa coluna 16 configurações diferentes (o dobro do exemplo). Com esta abordagem o banco de dados estaria sendo ocupado agora com 138Mb (OBS: Lembre-se que poderíamos ter até 16 configurações! A mesma quantidade em campos booleanos ocuparia 1Gb e 104Mb… 1 GIGA… 8X mais consumo de memória).
Referência dos tipos de dados PostgreSQL
https://www.tutorialspoint.com/postgresql/postgresql_data_types.htm
Ainda não entendi… 2 Bytes… 16 Configurações?!
Hora de explicar como isso funciona 🙂
Um boolean é representado por 1 ou 0, ligado ou desligado. Lembra do que aprendemos quando estamos iniciando na programação? Justamente. Um boolean não precisa de mais do que 1 bit para ser representado.
Então porque o tipo boolean usa 1 Byte?
Processadores não conseguem endereçar um valor menor que 1 byte. Processadores tem componentes chamados de registradores. Um processador de arquitetura x64 possui 16 registradores de 64-bits (4 bytes), 16 de 32-bits (3 bytes), 16 de 16-bits (2 bytes) e 16 de 8-bits (1 byte).
Todos os registradores de uma CPU e seus nomes (Pode variar dependendo da arquitetura) http://www.egr.unlv.edu/~ed/assembly64.pdf
Portanto quando o processador precisa processar um boolean, em teoria o mínimo que ele pode fazer é alocar o valor booleano (00000001) em um dos registradores de 8 Bits (de “al” até “r15b” por exemplo). Perceba que temos 7 bits ”não utilizados” durante o endereçamento. A abordagem utilizando os bits faz com que você aproveite os bits “não utilizados” e envie várias configurações booleanas em uma única instrução ao processador. Isso significa que além de economizar espaço durante o processamento e o armazenamento no banco de dados, as operações são muito mais eficientes para o processador porque várias instruções booleanas serão processadas em lote através do uso de todos os bits. Talvez você não perceba a diferença de desempenho em um programa simples, mas quando existe processamento pesado com dezenas de condicionais sendo avaliadas e várias querys enormes, a quantidade processada em uma única instrução no processador pode realmente ser um diferencial de otimização.
Agora voltando ao código
Abaixo, como ficaria o código utilizando a abordagem de Bits em Go:
O que é “<<” e “iota”?
Todas linguagens de programação possuem os operadores basicos “Bitwise” que são:
- & Aplica uma comparação AND nos bits. Ex: 0011 & 0101 == 0001
- | Aplica uma comparação OR nos bits. Ex: 0011 & 0101 == 0111
- ^ Aplica uma comparação XOR nos bits. Ex: 0011 & 0101 == 0110
- << (Left Shift) Empurra todos os bits para esquerda. Ex: 0101 << 1 == 1010
- >> (Right Shift) Empurra todos os bits para direita. Ex: 0101 >> 1 == 0010
Atenção: && e || são operadores booleanos que você geralmente usa no dia a dia, & e | são operadores Bitwise
O iota é um recurso do Go utilizado para atribuir números automaticamente para cada constante, por exemplo:
Em cada uma das constantes, iota retornará o valor anterior + 1, começando por zero. É apenas um recurso da linguagem que pode ser combinado e gerar valores diferentes… mas este não é o foco. O ponto é que eu utilizei o recurso para que você veja o operador << em ação! Como ele sempre retorna o número anterior mais um, o código ficou:
O operador << sempre empurra para esquerda a quantidade de bits informado, desta forma cada uma das Roles esta representada em um dos 8 bits!
Mais detalhes em: https://go.dev/ref/spec#Iota
Agora só falta eu explicar como a condicional funciona:
Estou aplicando o operador & entre o atributo Role do usuário e a constante Ceo, veja como fica quando o user é um Ceo e quando é um JuniorDev:
Lembre-se, o operador & só retorna 1 se em ambos operandos também existe o valor 1, como ele avalia bit por bit, o número 1 só é gerado nas posições que possuem o valor 1 como no exemplo do Ceo.
Agora eu posso inclusive criar grupos de validação! Vou incluir um pouco mais de código no exemplo:
Agora, uma role que agrupa todos os Devs está disponível! Vamos testar?
Perceba que mesmo que você não tenha entendido 100% de como funciona as operações bit a bit, o código continua expressivo e você consegue entender o que esta acontecendo e que uma validação apenas para devs esta sendo executada. Você consegue interpretar também exatamente quem são os devs. A regra de negócio sobre quem é developer está definida e em todos os lugares que necessitar desta validação não é necessário criar um novo “if” gigante ou correr o risco de esquecer de incluir um dos devs ou até mesmo ter dúvida de quem é considerado Dev ou não.
Esta abordagem requer apenas um pouco de estudo e acostumar a enxergar este tipo de abordagem quando fizer sentido.
Vamos voltar ao banco de dados… imagine que existe uma situação no sistema em que é possível um usuário ser Admin, TechLead e SeniorDev simultaneamente e você precisa buscar todos que possuem exatamente estas roles no banco de dados, a query ficaria algo como:
Isso melhorara e muito a performance uma vez que é possível utilizar o campo role de forma indexavel, que em ambientes com milhões de linhas faz toda a diferença na hora de realizar a busca dos dados.
Curiosidades…
Você sabia que muitos jogos de tabuleiro utilizam uma estrutura de dados chamada Bitboard? Ela é basicamente um array de bits onde cada bit indica a posição da uma das peças ou até mesmo os locais para onde a peça pode se movimentar, e as operações são em base tudo o que você aprendeu neste artigo 😊! Mais detalhes em: https://en.wikipedia.org/wiki/Bitboard
Em muitas linguagens, operações de leitura de arquivos como READ ou WRITE são gerenciadas com operadores bitwise, ex: open(’awesome_book.txt’, mode: READ | WRITE);
Resumo
Espero que este artigo possa te ajudar um dia! Como demonstrado, utilizar bits para guardar valores booleanos (Junte apenas os valores que fazem sentido estarem juntos para manter a coesão nos dados) além de serem visualmente mais fáceis de ler em muitas situações, podem ser o diferencial para obter um sistema rápido e performático, tanto do ponto de vista de processamento quanto de armazenamento.
Requer prática e estudo para se acostumar com cenários de processamento em lote e a manipular os bits nos momentos em que faz sentido utilizar, e o exemplo que eu trouxe foi o mais simples e didático que eu consegui pensar e uma vez que você aprende esta tecnica não quer dizer que você nunca mais vai escrever um boolean na vida. É mais um recurso disponível no seu repertório e você utilizará quando fizer sentido para a situação. Existem N formas de manipular os bits e situações onde ele pode ser útil, bora estudar? 😉
Até a próxima!