Adições e subtrações inteiras: condições de overflow

Já reparou que linguagens de alto nível não te fornecem acesso direto aos flags? O que acontece se você somar dois inteiros e o resultado ultrapassar o limite superior? A resposta é simples: você obterá um valor negativo! Da mesma forma, se subtrair dois valores e o resultado ultrapassar o limite inferior, obterá um valor positivo.

A afirmação acima é válida, é claro, para tipos sinalizados, mas overflows também ocorrem com tipos sem sinal. Neste caso a aritmética é modular, ou seja, na realidade, a+b é a+b\mod\,2^N, onde N é o tamanho do tipo em bits.

Na ausência do flag OF, como verificar se sua adição ou subtração “passaram da faixa”? É interessante notar que na adição existem apenas dois casos onde overflows podem ocorrer e ambos os casos ocorrem quando o sinal dos dois operandos são iguais. Tomemos o tipo char como exemplo (porque tem 8 bits e estou com preguiça de escrever)… Falarei sobre overflows com esses tipos sinalizados primeiro porque é o caso mais complicado (mas, nem tanto!): A faixa de valores de um char é entre -128 até +127. Consideremos o caso onde os sinais dos operandos sejam diferentes.. Se somarmos -128 a valores positivos obteremos valores entre -128 e 1, todos dentro da faixa dos chars e, por isso, não há overflows. Agora, se somarmos qualquer valor negativo a -128, o resultado não cabe num char, o mesmo ocorre se somarmos um valor positivo diferente de zero a +127.

Na subtração é o contrário! Apenas operandos com sinais diferentes podem causar overflow: -128 – (+1) e +127 – (-1) são exemplos.

A primeira técnica para verificação de overflows, quando a aritmética usa a técnica do complemento 2 (a maioria dos processadores faz isso), é uma comparação simples. Na adição:

s = a + b;
overflow = b >= 0 ? s < a : s > a;

Não há como s ser menor que a se b for maior que zero e vice versa… Se isso ocorrer, então temos um overflow. O teste entre s e a, no sentido contrário segue o mesmo princípio, se b for negativo. A coisa inverte, se estivermos falando de subtrações:

d = a - b;
overflow = b >= 0 ? d > a : d < a;

É simples assim. Mas temos que considerar uma coisa: Isso só funciona com aritmética de complemento 2! Por esse motivo a especificação ISO 9989 nos diz que isso é um “undefined behavior“. Existe outro meio, mais complicado, para verificarmos a condição de overflow usando apenas operações lógicas.

Estamos interessados apenas nos sinais dos operandos e do resultado. Daí, se assumirmos que o msb contém essa informação, podemos fazer:

s = a + b;
overflow = ( ~(a ^ b) & (s ^ a) ) >> ((8 * sizeof s) - 1);

A lógica aqui é a seguinte: Se o bit msb de a e b forem diferentes, a ^ b setará esse bit (um valor true), invertendo essa expressão significa que termos o bit setado apenas se os bits forem iguais, ou seja, se tiverem o mesmo sinal… A expressão s ^ a, então, compara os sinais da adição e do primeiro operando e será true apenas se eles forem diferentes. Como precisamos que ambas as condições sejam satisfeitas, as sub-expressões são concatenadas com um & (AND)… Por fim, isolamos apenas o msb, realizando o shift para a direita. Tanto faz se esse shift seja lógico ou aritmético.

Por analogia, a lógica para a subtração é parecida, bastando deixar de inverter o XOR da primeira sub-expressão:

d = a - b;
overflow = ( (a ^ b) & (d ^ a) ) >> ((8 * sizeof s) - 1);

Ou seja, se os sinais de a e b forem diferentes e os sinais de d e a forem diferentes, então temos um overflow. Neste caso, o teste da subtração contra a é importante. No caso da soma, poderia ser contra b

Se a arquitetura não armazena inteiros de forma que o msb seja o indicativo de sinal, podemos sempre escrever as expressões de overflow acima como:

inline int sign(int x) { return x < 0; }

s = a + b;
overflow = ~(sign(a) ^ sign(b)) & (sign(s) ^ sign(a));

d = a - b;
overflow = (sign(a) ^ sign(b)) & (sign(d) ^ sign(a));

Comparando os métodos:

Parece que o método usual, usando comparações, é mais eficiente, não é? Infelizmente não! Note que temos um if escondido ali no uso do operador ternário ‘?’. Na aproximação lógica não temos saltos. Eis um código de testes para adição (subtração é parecida):

int ofadd( int s, int a, int b )
{
  return b >= 0 ? s < a : s > a;
}

int ofadd2( int s, int a, int b )
{
  return ( ~(a ^ b) & (s ^ a) ) >> (8 * sizeof a - 1);
}

Dando uma olhada no código gerado para x86-64:

ofadd:
  test edx,edx
  js  .cond2
  xor  eax,eax
  cmp  edi,esi
  setl al
  ret
.cond2:
  xor  eax,eax
  cmp  edi,esi
  setg al
  ret

ofadd2:
  mov  eax,edx
  xor  eax,esi
  xor  esi,edi
  not  eax
  and  eax,esi
  sar  eax,31
  ret

Potencialmente a função offadd() pode ser mais rápida se b >= 0, caso contrário ela será mais lenta por dois motivos: O algoritmo de branch prediction estático nos diz que saltos para frente são mais lentos e, no caso de b < 0 o salto será feito (o que quebra a sequência de execução)… A rotina offadd2() não realiza salto algum e só tem uma operação lógica/aritmética a mair que offadd().

Claro, existe a vantagem de offadd2() ser menor e isso é vantajoso se a fizermos inline.

E os inteiros unsigned?

Felizmente as expressões abaixo não são “undefined behavior“:

s = a + b;
overflow = s < a;

d = a - b;
overflow = d > a;