Binários Negativos

11 min de leituraMédioPython

Um byte tem oito bits e nenhum lugar para pôr um sinal de menos. A solução que o mundo inteiro adotou, o complemento de dois, parece uma receita arbitrária ("inverta todos os bits e some 1") e é o contrário disso: é a única convenção que faz x + (-x) dar zero dentro de uma quantidade fixa de bits, e é por isso que o processador consegue subtrair sem ter um circuito de subtração.

O bit que deixa de valer magnitude

Em Números Binários, os oito bits de um byte valiam todos magnitude: 11111111 era 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255.

Para representar negativos é preciso guardar mais uma informação, o sinal, e não existe nono bit. A saída é sacrificar um dos oito, e o escolhido é sempre o mais significativo, o da esquerda. A convenção segue a do papel:

  • bit de sinal desligado quer dizer positivo (assim como escrever 5 sem sinal nenhum);
  • bit de sinal ligado quer dizer negativo (assim como escrever -5).

O preço é imediato: sobram sete bits de magnitude, e o alcance cai de 0 a 255 para algo em torno de -128 a 127. Não muda quantos números cabem (são 256 padrões de bits nos dois casos), muda quais.

O que ainda não foi decidido é o mais interessante: o que fazer com os outros sete bits quando o sinal está ligado. Existem três respostas conhecidas, e comparar as três é a melhor forma de entender por que a vencedora venceu.

Tentativa 1: sinal e magnitude

A ideia mais direta que qualquer pessoa teria: escreva o número positivo normalmente e ligue o bit da esquerda para dizer que ele é negativo.

 26  =  00011010
-26  =  10011010     <- só o bit da esquerda mudou

Funciona para ler, é fácil de explicar, e tem dois defeitos graves.

O primeiro é o zero duplo. Se 00000000 é zero, então 10000000 é "zero negativo". São dois padrões de bits para o mesmo número, o que obriga toda comparação de igualdade a tratar um caso especial, e desperdiça um dos 256 padrões disponíveis. Com essa convenção, oito bits escrevem só 255 números distintos.

O segundo é a aritmética. Somar 00011010 (26) com 10011010 (-26) dá 10110100, que não é zero nem por acidente. O processador teria que olhar os bits de sinal, decidir se aquilo é uma soma ou uma subtração, comparar magnitudes para saber o sinal do resultado, e só então operar. Muito circuito para uma conta que deveria ser uma só.

Tentativa 2: complemento de um

A segunda tentativa é mais esperta: para negar, inverta todos os bits. É a operação NOT bit a bit, escrita ~ na maioria das linguagens.

 26  =  00011010
-26  =  11100101     <- todos os bits invertidos

O bit da esquerda continua indicando o sinal (ele inverte junto), e a aritmética melhora bastante. Mas o defeito principal continua: 00000000 invertido é 11111111, então de novo existem dois zeros, e de novo sobram só 255 números distintos.

E a soma ainda não fecha: 00011010 + 11100101 = 11111111, que é o outro zero. Dá para consertar com uma regra extra (somar o vai-um de volta no fim, o chamado end-around carry), e "regra extra" em hardware significa mais circuito e mais atraso.

Complemento de dois: inverter e somar 1

A terceira convenção é a segunda com um passo a mais: inverta todos os bits e some 1.

 26  =  00011010
       ~00011010  =  11100101     1. inverte
       11100101 + 1 = 11100110    2. soma 1
-26  =  11100110
Visualizador · complemento de dois: inverter e somar 1
passo 1 de 13

O caso comum. Acompanhe os dois passos e depois a prova: somar 26 com o resultado tem que dar zero em oito bits, senão a representação não serviria para o processador fazer subtração somando.

início26 · em construção: 00011010 (26 lido com sinal)
O positivo de partida o bit da esquerda desligado quer dizer positivo
0271280266402532124161238022412120201
Em construção invertendo bit a bit
0sinal-1280266402532124161238022412120201

Ponto de partida: 26 em oito bits é 00011010. O objetivo é achar a representação de -26 sem inventar um símbolo de menos, usando só os mesmos oito bits.

complemento.py
1# complemento de dois, em 8 bits
2positivo = 0b00011010 # 26
3invertido = ~positivo & 0xFF # 1. inverte todos
4negativo = (invertido + 1) & 0xFF # 2. soma 1
5
6# a prova: somar os dois tem que dar zero
7(positivo + negativo) & 0xFF # o vai-um cai fora
Variáveis
positivo26
em construção (sem sinal)26
em construção (com sinal)26

Rode o preset do zero até o fim: inverter dá 11111111, somar 1 estoura para 00000000, e o oposto de zero é o próprio zero. É esse resultado, e só ele, que elimina a ambiguidade das outras duas convenções. Depois rode o 128 e repare que ele é o oposto de si mesmo: é por isso que a faixa com sinal vai de -128 a 127 e não é simétrica.

passo · espaço roda

Esse +1 desloca todo o lado negativo uma casa, e com isso os dois zeros se fundem num só. Vale seguir o preset do zero no visualizador acima, porque é ali que a mágica acontece: inverter 0000000011111111, somar 1 dá 100000000, o nono bit não cabe em oito e é descartado, e o que sobra é 00000000. O oposto de zero é o próprio zero, e existe uma representação só.

A prova de que a convenção está certa é a mesma da matemática: somar um número com o oposto dele tem que dar zero.

  00011010    (26)
+ 11100110    (-26)
-----------
 100000000    -> o nono bit estoura os 8 bits e cai fora
   00000000    -> zero
Visualizador · três formas de escrever um negativo, e três testes
1 de 3 passam nos três testes

Todas as três resolvem o problema de escrever um número negativo sem inventar um símbolo de menos: elas sacrificam o bit da esquerda, que deixa de valer magnitude e passa a valer sinal. A diferença está no que acontece com o zero e com a aritmética, e é isso que os três testes medem: quantos padrões de bits valem zero, se somar um número com o oposto dá zero, e quantos números distintos os 256 padrões conseguem escrever.

Sinal e magnitudereprova

Para negar, liga o bit da esquerda e deixa o resto como está.

10011010

Lido de volta: -26: bits diferentes, mesmo número.

  1. padrões de bits que valem zero2
  2. x + (-x) em 8 bits180
  3. números distintos nos 256 padrões255
  4. faixa que ela alcança-127 a 127
Complemento de umreprova

Para negar, inverte todos os bits.

11100101

Lido de volta: -26: bits diferentes, mesmo número.

  1. padrões de bits que valem zero2
  2. x + (-x) em 8 bits255
  3. números distintos nos 256 padrões255
  4. faixa que ela alcança-127 a 127
Complemento de doispassa nos três

Para negar, inverte todos os bits e soma 1.

11100110

Lido de volta: -26: bits diferentes, mesmo número.

  1. padrões de bits que valem zero1
  2. x + (-x) em 8 bits0
  3. números distintos nos 256 padrões256
  4. faixa que ela alcança-128 a 127

O primeiro teste é o do zero duplo. Em sinal e magnitude, 00000000 e 10000000 são os dois lidos como zero; em complemento de um, 00000000 e 11111111. Ter duas escritas para o mesmo número obriga toda comparação de igualdade a tratar um caso especial, e desperdiça um dos 256 padrões disponíveis. Só o complemento de dois tem um zero e um só.

O segundo é o da aritmética. Somar um número com o oposto dele tem que dar zero, e é isso que permite ao processador subtrair usando o mesmo circuito que soma. No complemento de dois isso vale sempre, com o vai-um final estourando os oito bits e sendo descartado. Nas outras duas não vale, e a máquina precisaria de correções extras.

Vale registrar o preço que as três pagam igual: com o bit da esquerda reservado para o sinal, sobram sete bits de magnitude, e o alcance sai de 0 a 255 para algo em torno de -128 a 127. E vale reparar numa consequência do zero duplo que passa despercebida: com 256 padrões de bits, sinal-magnitude e complemento de um escrevem só 255 números distintos, porque dois padrões dizem a mesma coisa. O complemento de dois escreve 256, e é por isso que a faixa dele é -128 a 127, assimétrica de propósito.

E aqui está a razão de tudo. Como x + (-x) = 0 vale sempre e sem correção nenhuma, subtrair vira somar: a - b é a + (-b), e o processador usa exatamente o mesmo somador. Não existe circuito de subtração num processador moderno; existe um somador e um inversor.

As três convenções existiram de verdade em máquinas comerciais. O complemento de um foi usado em computadores da CDC e da Univac, e o sinal e magnitude sobrevive até hoje num lugar que você usa todo dia: o padrão IEEE 754, dos números de ponto flutuante, guarda o sinal num bit separado. Ou seja, o -0.0 existe em ponto flutuante, é diferente de 0.0 na representação, e compara como igual por regra explícita da norma.

O truque de leitura: o peso da esquerda é negativo

Converter de volta invertendo e somando 1 funciona e é trabalhoso. Existe uma forma muito melhor de ler um complemento de dois, e ela não é uma regra nova: é a mesma soma de potências de sempre, com um sinal trocado.

No complemento de dois, o bit mais significativo vale -128 em vez de +128. Todo o resto continua idêntico:

11100110  =  -128 + 64 + 32 + 0 + 0 + 4 + 2 + 0  =  -26

Isso dispensa qualquer conversão. Também explica de imediato duas coisas que costumam parecer arbitrárias:

  • Por que 11111111 é -1. É -128 + 64 + 32 + 16 + 8 + 4 + 2 + 1, e a soma dos positivos dá 127. -128 + 127 = -1.
  • Por que acender bits à direita, com o sinal ligado, aumenta o número. Partindo de 10000000 (-128), cada bit ligado soma: 10000001 é -127, 10000010 é -126, e assim por diante até 11111111, que é -1.

A faixa assimétrica, e o padrão que não sabe o próprio sinal

Visualizador · o mesmo padrão, duas leituras
10000000 · sem sinal 128 · com sinal -128

O salto. Somar 1 a 127 acende o bit de sinal, e a leitura com sinal desaba de 127 para -128. Nenhum erro é reportado: para a máquina isso é só o padrão seguinte. Este é o estouro de inteiro que derruba sistema de verdade.

O padrão de bits o bit da esquerda vale -128, e é só isso que muda na leitura com sinal
1sinal-1280266402532024160238022402120201
Lido como sem sinalbyte, uint8

128 = 128

Todos os oito bits valem magnitude. A faixa vai de 0 a 255.

Lido como com sinalsbyte, int8

-128 = -128

O bit da esquerda vale -128 em vez de +128. Todo o resto da conta é idêntico, e é por isso que não existe uma "regra de ler negativo": existe um peso negativo.

Este é o padrão da virada. Vindo de 01111111 (127) e somando 1, o vai-um sobe até o bit de sinal e o número desaba para -128. Nenhuma exceção é lançada e nenhum aviso aparece: para a máquina isso é só o padrão de bits seguinte. É exatamente esse silêncio que torna o estouro de inteiro com sinal um dos erros mais difíceis de achar depois que acontece.

As faixas por tamanho com e sem sinal, o mesmo número de padrões
bitssem sinalcom sinal
8
0 a 255-128 a 127
16
0 a 65.535-32.768 a 32.767
32
0 a 4.294.967.295-2.147.483.648 a 2.147.483.647
64
0 a mais de 18 quintilhõescerca de -9,2 a 9,2 quintilhões

Repare que a faixa com sinal nunca é simétrica: com 8 bits vai de -128 a 127, e não de -128 a 128. O motivo não é arbitrário: são 256 padrões e o zero ocupa um deles, então sobram 255 para distribuir entre positivos e negativos, e o lado negativo fica com um a mais porque não precisa gastar padrão com o zero. Essa assimetria é a razão de `-Integer.MIN_VALUE` continuar negativo em Java e de `abs(-2147483648)` devolver um número negativo em C: não existe o positivo correspondente.

andar pelos padrões vizinhos

Duas consequências desta seção valem mais do que qualquer definição.

A primeira é que a faixa com sinal nunca é simétrica: com 8 bits ela vai de -128 a 127, e não de -128 a 128. São 256 padrões, o zero ocupa um deles, sobram 255 para distribuir, e o lado negativo fica com um a mais porque não precisa gastar padrão com o zero.

Isso tem efeito prático e surpreendente: o oposto do menor negativo não existe. Em Java, -Integer.MIN_VALUE devolve Integer.MIN_VALUE, ainda negativo. Em C, abs(-2147483648) devolve um número negativo. Não é bug da biblioteca: é que o positivo correspondente não cabe no tipo, e a operação dá a volta.

A segunda é que o padrão de bits não sabe o próprio sinal. 11111111 é 255 ou -1 conforme o tipo com que ele é lido, e não existe nada guardado na memória que decida entre os dois. Quem decide é a declaração da variável. Ler um valor com o tipo errado não dá erro, dá outro número, e esse é um dos bugs mais silenciosos que existem ao ler um formato binário ou uma resposta de rede.

BitsSem sinalCom sinal
80 a 255-128 a 127
160 a 65.535-32.768 a 32.767
320 a 4.294.967.295-2.147.483.648 a 2.147.483.647
640 a 18.446.744.073.709.551.615-9.223.372.036.854.775.808 a 9.223.372.036.854.775.807

Estouro: o erro que não avisa

Some 1 a 01111111 (127). O vai-um sobe até o bit de sinal, e o resultado é 10000000, que vale -128.

O número não ficou grande demais e travou; ele deu a volta. Nenhuma exceção é lançada, nenhum aviso aparece no log, nenhuma flag chega ao seu código por padrão. Para a máquina, 10000000 é apenas o padrão de bits seguinte ao 01111111, e a soma está aritmeticamente correta módulo 256.

É exatamente esse silêncio que torna o estouro de inteiro com sinal um dos erros mais caros do ofício. Três consequências que valem guardar:

  • Contadores que só crescem são candidatos. Um int de 32 bits com sinal para em 2.147.483.647, e um contador de eventos numa empresa grande chega lá.
  • A conta do meio da busca binária é o caso clássico. (lo + hi) / 2 estoura quando os dois índices são grandes, e o meio vira negativo. É o mesmo bug descrito em Busca Binária, e a correção (lo + (hi - lo) / 2) existe exatamente por isto.
  • Linguagens diferentes reagem diferente. Em C, o estouro de inteiro com sinal é comportamento indefinido, o que autoriza o compilador a assumir que ele nunca acontece e otimizar em cima disso. Em Java o resultado dá a volta e é definido. Em Python inteiro não tem tamanho fixo e o problema não existe, mas volta assim que você grava aquele número num formato binário ou numa coluna de banco.

Daqui, Operações Bitwise mostra o que dá para fazer manipulando esses bits diretamente, e Números Binários fica como a base para reler se a notação posicional ainda não estiver automática. Vale também revisitar Big O com esta lente: quando um artigo diz que uma operação é O(1), ele está assumindo que os números cabem numa palavra da máquina, e essa suposição tem exatamente o tamanho descrito na tabela acima.

Vídeo da aula

Direto do canal da comunidade Craft & Code Club · 26:13.

Problemas para praticar

Na ordem em que recomendamos resolver. Marque os que você já fez, fica salvo aqui.

FácilHamming DistanceLeetCode 461
FácilReverse BitsLeetCode 190
MédioReverse IntegerLeetCode 7
MédioSum of Two IntegersLeetCode 371
MédioDivide Two IntegersLeetCode 29

Referências

Artigos e materiais externos para se aprofundar.

Travou em algum passo? Traga sua questão para o Discord da comunidade ou para os encontros semanais.

Entrar
Concluiu este tópico?
Marque para acompanhar seu progresso.

Este tópico também tem página própria, fora deste roadmap: Binários Negativos.