Representação binária, modificadores e tipos de dados

Há um tempo atrás, alguém postou uma dúvida sobre o propósito dos modificadores signed e unsigned em algum grupo de C no Facebook. Dei uma longa resposta (pra os parâmetros do Facebook) tentando ser o mais claro possível, e nesse post vou reciclar a resposta que dei, acrescentando mais algumas coisas sobre bits, bytes e codificação, para mostrar como o conhecimento dessa parte mais técnica ajuda a nos fazer entender a razão de ser de certas coisas que, num primeiro momento, parecem sem sentido e arbitrárias. Então vamos lá.

O que é um bit

Você já deve ter ouvido falar que processadores só entendem zeros e uns. Bom, na verdade, eles só entendem sinais elétricos que se alternam entre dois estados distintos. Esses dois estados distintos sim são representados por zeros e uns, que recebem o nome de bit (de binary digit).

É através de sequências de bits que o computador armazena todo tipo de informação: textos, filmes, músicas, programas, etc. Geralmente, as informações contidas nesses arquivos de dimensões maiores são organizadas como sequências de valores menores, como os caracteres desse texto na tela do seu computador, por exemplo. Cada letra é, na verdade, armazenada no seu computador como um agrupamento de bits de tamanho fixo (isso não é completamente verdadeiro, mas finjamos que sim para fins didáticos).

A sacada é que quanto mais bits você tiver pra armazenar um valor, mais valores você pode representar. Com 1 bit você armazena ou 0 ou 1, com 2 bits, você armazena 00, 01, 10, 11, com 3 bits, você armazena 000, 001, 010, 011, 100, 101, 110, 111.

Matematicamente falando, com um dígito que pode assumir dois valores diferentes, seu total de combinações possíveis é 2. Com 2 dígitos, seu total de combinações possíveis é 2 x 2, que corresponde a 22. Com 3 dígitos, seu total é 2 x 2 x 2, que é igual 23. Ou seja, o total de combinações possíveis com uma quantidade n de bits é igual 2n.

Com 8 bits, por exemplo, você pode armazenar 28 (256) valores diferentes. Um agrupamento de 8 bits corresponde a 1 byte. Se utilizarmos 1 byte para armazenar números, podemos armazenar todos os números inteiros de 0 a 255. Sequências de 1 byte são utilizadas em todo tipo de aplicação. Principalmente porque ela é a unidade mínima de armazenamento nas arquiteturas de computadores modernas. A memória do computador é organizada em blocos de 1 byte, cada um com um endereço específico.

O código RGB, por exemplo, utilizado para codificar cores, utiliza 3 bytes, cada um pra definir as diferentes intensidades de vermelho (red), verde (green) e azul (blue) necessárias para produzir as outras cores. O endereço IP v.4 utiliza 4 bytes, que são representados como 4 valores de 0 a 255 separados por um ponto, como 127.0.0.1 (o IP convencionalmente utilizado como localhost).

Capacidade de representação

Eu falei anteriormente sobre certa quantidade de bits “ser capaz de representar” tantas coisas diferentes. Funciona mais ou menos assim: se eu preciso armazenar informações diversas no computador, mas ele só entende sequências de zeros e uns, então eu preciso criar uma correspondência entre esses códigos binários e a informação que eu quero armazenar.

Se eu quero armazenar na memória os valores VACA, CACHORRO, GALINHA e PAPAGAIO, eu posso criar uma relação de correspondência em que cada conjunto específico de bits representa um desses animais. Como já vimos antes, para armazenar 4 valores, eu preciso de 2 bits. Então poderíamos criar a seguinte convenção:

00 VACA

01 CACHORRO

10 GALINHA

11 PAPAGAIO

Se eu sentir a necessidade de armazenar valores referentes a outros animais na memória, digamos, ORNITORRINCO, PACA, TATU e COIOTE, então eu terei que aumentar o número de bits utilizados para armazenar esses valores. Com 3 bits, eu poderei armazenar um total de 8 animais, com 4 bits, 16 animais. Como já expliquei anteriormente, a cada bit, o número de combinações possíveis de 0 e 1 dobra, logo, a cada bit a mais, o poder de representação de um dado sistema de codificação dobra. Sendo assim, pra saber quantas coisas você pode representar com determinada quantidade de 0 e 1, basta calcular 2n, sendo n a quantidade de dígitos de que você dispõe.

Os tipos de dados

Perceba mais uma coisa: o computador apenas armazena esses códigos binários, ele não sabe que está armazenando vacas e cachorros, ele vê apenas zeros e uns. Quem tem que saber o que esses zeros e uns significam é o programa ou a pessoa que faz uso desses dados. Ou seja, uma determinada sequência de bits precisa ser interpretada para que faça sentido.

É por isso que existem os tipos de dados, como int, float, string, etc. Porque como existem sistemas diferentes para codificar os diferentes tipos de dados no computador, o computador precisa saber com que tipos de dados está lidando para interpretá-los da forma correta.

Representação binária de inteiros positivos

Para analisar essa questão um pouco melhor, vamos estudar como funciona a representação dos números inteiros. Para armazenar apenas números inteiros positivos, basta atribuir um número decimal pra cada combinação possível de zeros e uns. O número de bytes que utilizaremos definirá o maior algarismo que podemos armazenar. Com 1 byte, por exemplo, podemos armazenar do 0 ao 255, com 2 bytes, do 0 ao 65535, e por aí vai.

Embora você possa inventar seu próprio sistema, a escolha de qual sequência binária corresponderá a cada número decimal geralmente não é feita de forma completamente arbitrária. Na verdade, o sistema binário pode ser entendido como um sistema de contagem que segue a mesma lógica posicional que o sistema decimal.

Não vou entrar em muitos detalhes aqui, mas resumidamente, imagine que cada dígito em um número binário (cada posição na sequência de zeros e uns) corresponde a uma potência de 2. Tomemos como exemplo o número 1010 1101.

1

0

1

0

1

1

0

1

27

26

25

24

23

22

21

20

Da direita para esquerda, começamos com 20 e seguimos acrescendo 1 na potência de 2 até o último digito. Para chegarmos ao número que uma certa sequência representa, somamos todas as potências nas posições em que o bit é igual a 1 e ignoramos os bits 0. Vamos marcar os bits que devemos somar em nosso exemplo para ficar mais fácil:

1

0

1

0

1

1

0

1

27

26

25

24

23

22

21

20

Então, a sequência 10101101 representa o valor decimal 27+25+23+22+20, que corresponde a 128 + 32 + 8 + 4 + 1 = 173 (lembrando que todo número elevado a 0 é igual a 1, e todo número elevado a 1 é igual a ele mesmo).

Para converter um número decimal para sua representação binária, você pode utilizar um algoritmo que eu expliquei aqui.

Representação de inteiros negativos com complemento de dois

O negócio começa a complicar quando a gente resolver armazenar números negativos. Não tem como representar -0010. A solução é usar 1 bit pra servir como o sinal positivo ou negativo. Então eu posso usar o bit mais a esquerda do meu número binário pra dizer: se esse digito for 1, meu número é negativo, se for 0, é positivo.

Usando essa lógica, eu poderia representar o número 1 com 8 bits como como 0000 0001 e o -1 como 1000 0001. Simples, né? Infelizmente não é assim que funciona, na prática. Para facilitar operações aritméticas com números negativos, foi criado um outro sistema para representar números negativos, chamado de complemento de dois.

Funciona assim: para representar um número negativo, pegamos o número positivo correspondente e invertemos todos os bits. Os bits de valor 1 viram 0 e os bits de valor 0 viram 1. Depois, somamos 1 bit ao resultado. Somar bits é muito simples. É só seguir regras análogas aos da soma de números decimais:

0+0 = 0

0+1 = 1

1+0 = 1

1+1 = 0 e vai 1.

Exemplificando:

vai1

Nesse exemplo, fazemos a soma de 58 com 67. Quando o bit de cima e o de baixo são 1, o resultado é 0, e adicionamos um bit 1 à esquerda. Perceba que fazemos a mesma coisa com números do sistema decimal. Quando o resultado da soma do número de cima com o de baixo é maior que 9, colocamos a primeira parte no resultado, e o 1 é somado ao número da esquerda.

Então, se utilizamos 1 byte para armazenar nossos números e queremos armazenar o valor -5, pegamos o código referente ao 5: 00000101, e invertemos todos os bits para obter 11111010. E depois somamos 1.

complemento-de-dois

Essa é a representação binária do -5.

Nesse sistema, o 1 mais à esquerda ainda serve para sinalizar que o número é negativo. O que significa que na verdade, o maior número positivo que eu posso armazenar é o 01111111 (o número 127) e o menor número negativo seria o complemento de dois desse número, o 10000001 (-127).

Interpretação dos valores binários

O problema é que 10000001 também poderia ser a representação binária do número 129 (você pode checar isso em algum conversor online, como este, se não quiser calcular na mão), se eu estivesse usando o 1 mais à esquerda como parte do número, e não como sinal. Como saber se 10000001 se refere ao -127 ou ao 129?

Pra isso que serve os modificadores signed e unsigned em linguagens como C. Ao utilizar esses modificadores eu estou deixando explícito se o 1 mais à esquerda do meu número binário deve sinalizar que se trata de um número negativo, ou se ele está sendo usado na representação de um número positivo.

Como já sugeri, utilizando um bit pra marcar o sinal, eu tenho um bit a menos pra representar os números. Como cada bit a mais dobra a capacidade de representação, um bit a menos pra representar os números reduz a capacidade de representação pela metade.

Um int normal, por padrão, é do tipo signed. Ou seja, se eu uso 4 bytes (32 bits) pra armazenar meu int, um bit vai ser pro sinal, e os outros 31 bits vão ser pra representar o número. Então eu posso representar 231 números positivos, e 231 números negativos. Mas se eu marcar o int como unsigned, eu estou dizendo que não existe bit de sinal e todos os 32 bits serão usados pra representar números. Então agora eu posso representar 232 números positivos, ou seja, meu poder de representação em relação à magnitude dos números dobra.

Conclusão

Então, a questão de usar signed ou unsigned int é uma questão de estabelecer se para sua aplicação, é mais importante armazenar inteiros positivos de magnitude maior, ou armazenar inteiros que podem ser positivos ou negativos, mas com uma magnitude menor. E essa decisão geralmente é definida de forma explícita para que o computador saiba como deve interpretar os dados. Em C, especificamente, um int sem modificador é sempre considerado signed, mas o char sem modificador pode ser signed ou unsigned a depender da implementação.

Strings e floats, por sua vez, utilizam sistemas completamente diferentes de representação (posso falar sobre isso no futuro, se houver interesse!). Por exemplo, em C, strings são cadeias de valores do tipo char, que por sua vez é um inteiro de 1 byte que tem correspondência direta com os sinais gráficos e de controle do padrão ASCII.

Definindo os tipos, o computador saberá não apenas como interpretar determinada sequência de bits, mas também o tamanho da sequência que precisa interpretar, já que na memória, instruções e dados são representados como uma sequência linear de blocos de 8 bits sem fim nem começo. Mas essa já é outra história!

Related Posts

2 thoughts on “Representação binária, modificadores e tipos de dados

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *