Conversões Numéricas, Arredondamento e Overflow
Objective
Mover um número de um tipo para outro pode perder informação de quatro formas:
o valor não cabe, a fração é descartada, a precisão é arredondada ou o bit de
sinal muda de significado. O C# deixa você escolher o comportamento, mas o
padrão depende do tipo de conversão, e as três ferramentas que as pessoas usam
(o cast, System.Convert e Math.Round) discordam entre si sobre o 2.5.
Este conceito cobre as regras de conversão implícita e explícita, o que
checked e unchecked realmente mudam (e o que não mudam), os modos de
arredondamento por trás de Math.Round, Convert e ToString("F2"), e as
APIs que escolhem a política de overflow de forma explícita. Ele continua a
visão em nível de bits do conceito anterior
(numeric-types-and-binary-representation).
Use Cases
- Converter um
doublede um cálculo para um índiceintou um valor de pixelbytesem surpresas nas bordas. - Arredondar dinheiro para centavos do jeito que a regra de negócio manda, e não do jeito que o padrão acontece de fazer.
- Detectar overflow de inteiros em totais, contadores, tamanhos e cálculos de hash.
- Estreitar números genéricos (
TTo.CreateChecked) em código de biblioteca. - Ler código que mistura
(int)x,Convert.ToInt32(x)eMath.Round(x)e saber exatamente como cada um difere.
Deep Dive
A saída vem do .NET 10 (SDK 10.0.100) em arm64, com
CultureInfo.InvariantCulture. Onde um resultado depende da plataforma, o
texto diz.
Conversões implícitas: alargamento, nem sempre sem perda
O compilador permite uma conversão implícita quando todo valor do tipo de
origem cabe no destino. Exemplos: byte para short, int para long,
float para double, qualquer inteiro para decimal. A tabela está na página
do Microsoft Learn sobre conversões numéricas internas. Três regras importam na
prática:
- Qualquer tipo inteiro converte implicitamente para
float,doubleedecimal. A conversão mantém a ordem de grandeza, mas nem sempre todos os dígitos. - Não existe conversão implícita entre
decimalefloatoudoubleem nenhuma direção, então misturá-los numa expressão é erro de compilação. - Uma expressão constante
intconverte implicitamente para um tipo menor quando o valor cabe (byte b = 17;), e é o erro CS0031 quando não cabe (byte b = 300;).
As conversões implícitas com perda são int/uint/long/ulong para float
e long/ulong para double:
csharpint i = 123_456_789;
float f = i; // 123456790
(int)f // 123456792 (não é o valor original)
(double)i // 123456789 (o double tem espaço para um int)
long big = 9_007_199_254_740_993;
double bd = big; // 9007199254740992
(float)16777217 // 16777216A aritmética promove os dois operandos a um tipo comum: int + long é long,
int + float e long + float são float (então um long pode perder dígitos
numa soma com float), int + double é double, int + decimal é decimal.
Tipos menores que int são promovidos a int antes, por isso char + int é
int, enquanto (char)(ch + 1) é char e ch++ continua char ('A' vira
'B').
Conversões explícitas: casts e o que fazem
Um cast é um pedido ao compilador. O resultado depende do par de tipos.
Inteiro para inteiro mantém os bits baixos (unchecked) ou lança exceção (checked):
csharpunchecked((byte)300) // 44 300 = 1_0010_1100, 8 bits baixos = 0010_1100
unchecked((sbyte)200) // -56
unchecked((short)70000) // 4464
unchecked((uint)-1) // 4294967295
unchecked((int)3_000_000_000L) // -1294967296Ponto flutuante para inteiro trunca em direção a zero, não arredonda:
csharp(int)-3.7 // -3
(int)3.7 // 3
(int)2.5 // 2
(long)-0.9 // 0
(int)-3.7m // -3 decimal também truncaFora da faixa, NaN e infinito. A especificação da linguagem deixa sem
definição o resultado de um cast unchecked de double fora da faixa para
inteiro. Desde o .NET 9 o runtime faz as conversões de double/float para
int, uint, long e ulong saturarem em x86 e x64 (a página de breaking
change diz isso), e nesta máquina arm64 o .NET 10 deu o mesmo:
csharp(int)1e20 // 2147483647
(int)-1e20 // -2147483648
(int)double.NaN // 0
(int)double.PositiveInfinity // 2147483647
(uint)-1.0 // 0
(uint)5e9 // 4294967295
(ulong)-5.0 // 0
(long)1e30 // 9223372036854775807Para os tipos pequenos, o resultado no .NET 10 passou por int e depois deu a
volta: (byte)300.0 é 44, (byte)-5.0 é 251, (sbyte)1e10 é -1. As
páginas de compatibilidade do Microsoft Learn dizem que o .NET 11 faz as
conversões para sbyte, byte, short, ushort e char saturarem também,
então não dependa dos valores do .NET 10 para esses tipos. Trate casts de ponto
flutuante fora da faixa como "valide antes ou use checked".
double para float arredonda para o float mais próximo, e vira
Infinity ou 0 quando fora da faixa ((float)1e40 é Infinity,
(float)1e-50 é 0). (double)(float)0.1 é 0.10000000149011612: alargar de
volta não recupera os dígitos.
De e para decimal. (decimal) de um double arredonda para cerca de 15
dígitos significativos, e NaN, infinitos e valores acima de cerca de 7,9e28
lançam OverflowException; um valor pequeno demais vira 0
((decimal)1e-30 é 0). (int) de um decimal trunca e lança exceção quando
não cabe, independentemente do contexto checked. A documentação do .NET 11
Preview 7 lista uma mudança para que as conversões entre decimal e double
sejam arredondadas corretamente (um double como 1.23 converteria para o
valor exato 1.229999999999999982236431606). Isso não está no .NET 10, então o
comportamento de 15 dígitos acima é o que um programa .NET 10 vê.
checked e unchecked
A aritmética e as conversões de inteiros rodam em um contexto de verificação de overflow. O padrão é unchecked: o resultado é truncado nos bits baixos.
csharpint max = int.MaxValue;
max + 1 // -2147483648
max * 2 // -2
(short)max // -1
int acc = 1; for (int i = 1; i <= 13; i++) acc *= i; // 13! = 1932053504 (errado)Você troca o contexto para um bloco ou para uma expressão:
csharpchecked { v++; } // OverflowException
checked(a + b) // OverflowException
checked((byte)300) // OverflowException
checked((int)3e9) // OverflowException (double para int)
checked((int)double.NaN) // OverflowException
checked((int)-3.7) // -3 (cabe depois do truncamento, sem exceção)
checked((uint)-1) // OverflowExceptionAs operações afetadas são ++, --, - unário, + - * / binários em tipos
integrais e enums, e conversões explícitas entre tipos integrais ou de
float/double para um tipo integral. Não são afetados: deslocamentos e
operadores bitwise, decimal (ele sempre lança) e a aritmética de
float/double (dá Infinity; checked((float)1e40) continua Infinity).
Dois detalhes que causam bugs reais:
O escopo é textual. O contexto cobre as expressões escritas dentro do bloco ou dos parênteses, não os métodos que elas chamam:
csharpstatic int Mul(int a, int b) => a * b;
checked { return Mul(2, int.MaxValue); } // -2, sem exceção: Mul é unchecked
checked { return Mul(2, 2 * int.MaxValue); } // OverflowException: 2 * int.MaxValue está escrito dentro
checked(x * y) / z // a multiplicação é checked
(x * y) / z // unchecked: 100000 * 100000 / 1000 = 1410065
(long)x * y / z // 10000000: alargue antes de multiplicarUma lambda escrita dentro do bloco é checked, porque o corpo dela está textualmente ali.
Constantes são checked por padrão em tempo de compilação. O compilador
rejeita expressões constantes que estouram, então int a = int.MaxValue + 1; é
o erro CS0220, byte b = 300; é CS0031, (byte)300 é CS0221 ("use unchecked
syntax to override") e (decimal)double.NaN é CS0031. Você precisa de
unchecked(...) para obter a constante com wrap de propósito.
O padrão pode ser invertido para um projeto com
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> (um file-based
app aceita #:property CheckForOverflowUnderflow=true). Com isso ligado, todo
overflow de inteiro não constante no seu código lança exceção, inclusive hashes
escritos como h = h * 31 + c; esse laço lançou numa string de 43 caracteres,
então use unchecked(...) explicitamente ali, e ele retornou 1972221122. O
código do framework é compilado separadamente e não é afetado
(HashCode.Combine e string.GetHashCode rodaram normalmente). Deslocamentos
continuam dando a volta (int.MaxValue << 1 é -2).
A divisão tem um caso que nunca é unchecked: divisão inteira por zero lança
DivideByZeroException, e int.MinValue / -1 lança OverflowException (a
documentação diz que uma ArithmeticException pode ser lançada) mesmo dentro
de unchecked. No experimento int.MinValue % -1 também lançou.
O que o compilador emite é simples. Para a + b é add, para checked(a + b)
é add.ovf, para checked(a * b) é mul.ovf, e para um cast que estreita é
conv.u1 versus conv.ovf.u1 (bytes 58, D6, D8, D2, B4 nos corpos
de método que despejei). A forma checked custa um desvio e um teste de flag,
então é barata, mas não de graça em laços numéricos apertados.
Arredondamento: cast, Math.Round, Convert e formatação
Math.Round(double) e Math.Round(decimal) usam MidpointRounding.ToEven por
padrão (banker's rounding): um valor exatamente no meio vai para o inteiro par
mais próximo. Os outros modos são AwayFromZero e três modos direcionados,
ToZero, ToNegativeInfinity e ToPositiveInfinity, que arredondam todos os
valores e não só os pontos médios.
csharp// x (int) Round ToEven AwayFromZero ToZero ToNegInf ToPosInf Floor Ceiling Truncate Convert.ToInt32
// -2.5 -2 -2 -2 -3 -2 -3 -2 -3 -2 -2 -2
// -1.5 -1 -2 -2 -2 -1 -2 -1 -2 -1 -1 -2
// 0.5 0 0 0 1 0 0 1 0 1 0 0
// 1.5 1 2 2 2 1 1 2 1 2 1 2
// 2.5 2 2 2 3 2 2 3 2 3 2 2
// 3.5 3 4 4 4 3 3 4 3 4 3 4
// 2.6 2 3 3 3 2 2 3 2 3 2 3
// -2.6 -2 -3 -3 -3 -2 -3 -2 -3 -2 -2 -3Convert.ToInt32(double) arredonda com a mesma regra de Math.Round
(Convert.ToInt32(2.5) é 2, 3.5 é 4, 2.51 é 3), enquanto o cast
trunca. Math.Round(-0.4) é -0 (zero negativo), e imprime como -0.
As casas decimais são onde double e decimal diferem.
Math.Round(double, digits) no código-fonte do .NET 10 multiplica por uma
potência de dez, arredonda o produto e divide de volta, e aceita de 0 a 15
dígitos; decimal arredonda o valor decimal exato e aceita de 0 a 28. Uma
quantidade negativa de dígitos lança ArgumentOutOfRangeException nos dois.
csharpMath.Round(2.675, 2) // 2.68 (2.675 * 100 = 267.5 em double, depois para o par)
Math.Round(2.675m, 2) // 2.68
2.675.ToString("F2") // 2.67 (a formatação usa o valor exato guardado 2.67499999...)
Math.Round(1.005, 2) // 1 (1.005 * 100 = 100.49999999999999)
Math.Round(1.005m, 2) // 1.00
Math.Round(1.005m, 2, MidpointRounding.AwayFromZero) // 1.01
Math.Round(1.015m, 2) // 1.02 (101.5 vai para o par 102)
Math.Round(1.015, 2) // 1.01 (1.015 é guardado um pouco abaixo)
Math.Round(0.125, 2) // 0.12
Math.Round(0.125, 2, MidpointRounding.AwayFromZero) // 0.13Duas conclusões. Primeiro, a mesma chamada em double e em decimal pode
diferir porque o literal double não é o número que você digitou. Segundo, a
formatação é uma terceira implementação de arredondamento: nesta execução
0.5.ToString("F0") deu 0 e 2.5.ToString("F0") deu 2 para double, mas
1 e 3 para decimal, e 0.125.ToString("N2") deu 0.12 contra
0.125m.ToString("N2") dando 0.13. Se um valor é exibido e também guardado,
arredonde uma vez, explicitamente, com o modo que a regra nomeia, e formate o
valor arredondado.
Arredondar cada parte e arredondar o total dão resultados diferentes:
csharpMath.Round(1.005m, 2) + Math.Round(1.005m, 2) // 2.00
Math.Round(1.005m + 1.005m, 2) // 2.01
Math.Round(10m / 3, 2) * 3 // 9.99, falta um centavoRegras de dinheiro costumam dizer onde o arredondamento acontece (por linha ou
por nota) e para onde vai o centavo que sobra; codifique essa decisão, não uma
chamada a Math.Round.
System.Convert
Convert é uma família de métodos estáticos. Para números ele se comporta de
forma diferente de um cast nestes pontos:
csharpConvert.ToInt32(2.51) // 3 arredonda (para o par no ponto médio); (int)2.51 é 2
Convert.ToInt32(3e9) // OverflowException (sempre checked)
Convert.ToByte(300) // OverflowException
Convert.ToInt32(uint.MaxValue) // OverflowException
Convert.ToInt32(double.NaN) // OverflowException
Convert.ToInt32(true) // 1
Convert.ToInt32('7') // 55 o code point, não 7
Convert.ToInt32("42") // 42
Convert.ToInt32((string?)null) // 0 null vira zero
Convert.ToInt32("4.5") // FormatException
Convert.ToBoolean("yes") // FormatException
Convert.ToInt32(new object()) // InvalidCastException (não é IConvertible)
Convert.ToString(255, 2) // 11111111
Convert.ToInt32("ff", 16) // 255
Convert.ToInt32("102", 2) // FormatException
Convert.ChangeType(12.7, typeof(int)) // 13Duas armadilhas aparecem nessa lista. Convert com uma string null devolve 0
em vez de falhar, o que esconde um valor ausente; e Convert.ToInt32(string)
usa a cultura atual, então Convert.ToDouble("1,5") devolveu 15 com a
cultura invariante e Convert.ToDouble("1.5", new CultureInfo("pt-BR"))
também devolveu 15 (o . é separador de milhar ali). O parsing com cultura
explícita é tratado em parsing-tryparse-and-the-try-pattern.
Os overloads de base 2, 8, 10 e 16 leem e escrevem texto em complemento de
dois: Convert.ToString(-1, 16) é ffffffff e uma string de 32 uns com base 2
vira -1.
Escolhendo a política no código: checked, saturar, truncar, limitar
As interfaces de generic math colocam a política na própria chamada.
TTo.CreateChecked, CreateSaturating e CreateTruncating existem em todo
tipo numérico:
csharpbyte.CreateChecked(300) // OverflowException
byte.CreateSaturating(300) // 255
byte.CreateSaturating(-5) // 0
byte.CreateTruncating(300) // 44 bits baixos, como unchecked
int.CreateSaturating(3e9) // 2147483647
int.CreateChecked(double.NaN) // OverflowException
int.CreateSaturating(double.NaN) // 0
int.CreateTruncating(-3.7) // -3
int.CreateTruncating(uint.MaxValue) // -1
static TTo Narrow<TFrom, TTo>(TFrom v)
where TFrom : INumberBase<TFrom> where TTo : INumberBase<TTo>
=> TTo.CreateChecked(v);
Narrow<long, short>(70000L) // OverflowExceptionMath.Clamp(300, 0, 255) é a saturação legível para um valor que você já tem
em um tipo mais largo.
Algumas APIs do framework são checked independentemente do que o seu projeto
diz: Math.Abs(int.MinValue), Enumerable.Sum sobre int ou long
(new[] { int.MaxValue, 1 }.Sum() lança), new int[-1] e
Math.DivRem(int.MinValue, -1) lançaram OverflowException, enquanto
Aggregate(0, (a, b) => a + b) deu a volta para -2147483648 e
Sum(x => (long)x) deu 2147483648. Average() de dois int.MaxValue é
correto (2147483647) porque acumula em um long.
Operadores e conversões checked definidos pelo usuário
Desde o C# 11 um tipo pode fornecer uma variante checked dos operadores
aritméticos e das conversões explícitas. Um contexto checked chama ela, um
contexto unchecked chama a comum, e você também precisa definir o operador
comum:
csharpreadonly record struct Pct(byte Value)
{
public static Pct operator +(Pct a, Pct b) => new(unchecked((byte)(a.Value + b.Value)));
public static Pct operator checked +(Pct a, Pct b) => new(checked((byte)(a.Value + b.Value)));
public static explicit operator Pct(int v) => new(unchecked((byte)v));
public static explicit operator checked Pct(int v) => new(checked((byte)v));
}
new Pct(200) + new Pct(100) // 44%
checked(new Pct(200) + new Pct(100)) // OverflowException
(Pct)300 // 44%
checked((Pct)300) // OverflowExceptionSegundo a documentação, a palavra-chave checked no operador não muda o
contexto dentro do corpo dele; o corpo usa checked/unchecked
explicitamente, como acima.
Trade-offs
- Unchecked é o padrão porque é rápido e às vezes desejado. Hashes,
checksums e geradores aleatórios dependem do wrap-around. O custo é que um
contador ou uma soma pode ficar negativo em silêncio; coloque
checkedem volta de dinheiro, tamanhos e índices, euncheckedem volta de código de hash. CheckForOverflowUnderflowno projeto inteiro é uma rede de segurança com preço. Pega todo overflow silencioso no seu código, e também quebra funções de hash escritas à mão até você marcá-lasunchecked. Ligue em aplicações e testes, decida caso a caso em bibliotecas.- Um cast é a forma mais curta de dizer "aceito a perda", e não diz qual
perda.
(int)xtrunca,Math.Roundvai para o par, eConvert.ToInt32arredonda e lança exceção. Nomeie a intenção no código (Math.Round(x, MidpointRounding.AwayFromZero),int.CreateSaturating(x)). - Banker's rounding é estatisticamente justo e surpreende usuários. Evita
viés ao somar muitas metades arredondadas, mas uma lista de preços em que
2.5vira2parece errada. Escolha o modo pela regra de negócio, e prefiradecimalpara que o ponto médio seja de fato um ponto médio. Converté conveniente nas fronteiras e permissivo nos lugares errados. Umnullvira0e a cultura é implícita. Para entrada, faça parsing; para um estreitamento numérico sabidamente seguro, use um cast dentro decheckedouCreateChecked.- Saturar esconde o problema que checked revela. Limitar a leitura de um
sensor a um
byteé aceitável; limitar o valor de um pagamento é um bug à espera de uma auditoria. Use saturação onde o máximo é uma resposta legítima. - Depender de casts de ponto flutuante fora da faixa é depender da versão do
runtime. O resultado mudou entre o .NET 8 e o .NET 9, e os tipos inteiros
pequenos mudam de novo no .NET 11. Valide a faixa e o
NaNantes.
Documentation Links
- Built-in numeric conversions, Microsoft Learn
- The checked and unchecked statements, Microsoft Learn
- Arithmetic operators, overflow and user-defined checked operators, Microsoft Learn
- MidpointRounding enum, Microsoft Learn
- Math.Round method, Microsoft Learn
- Convert class, Microsoft Learn
- Floating point-to-integer conversions are saturating (.NET 9), Microsoft Learn
- CheckForOverflowUnderflow compiler option, Microsoft Learn