Um decimal em C# armazena um número como um inteiro de 96 bits mais uma escala que indica onde fica o ponto decimal. É por isso que ele conta em décimos e centésimos como nós, e é por isso que 0.1m + 0.2m é exatamente 0.3m, enquanto a mesma soma em double não é.

Para baixar o código-fonte deste artigo, você pode acessar nosso repositório no GitHub.

A exatidão tem um preço: o intervalo de valores. Um decimal vai até 79.228.162.514.264.337.593.543.950.335 e não passa disso, o que dá 28 ou 29 dígitos significativos, dependendo do número. Todo o restante do artigo trata de gastar esses dígitos de forma deliberada, seja no que exibimos, seja no que armazenamos.

O que é o tipo decimal em C#?

O tipo decimal em C# é um número de 128 bits formado por um bit de sinal, um inteiro de 96 bits e uma escala que registra quantas casas o ponto decimal deve ser deslocado para a esquerda. Um literal decimal leva o sufixo m ou M, porque um literal sem sufixo, como 1.5, é um double.

Como a escala conta potências de dez, e não de dois, o decimal representa os valores que as pessoas anotam. 0.1m é exatamente um décimo. Em double, 0.1 é a fração binária mais próxima de um décimo, e é por isso que 0.1 + 0.2 produz 0.30000000000000004 e 0.1m + 0.2m produz 0.3.

É essa exatidão que faz dele o tipo certo para dinheiro e para qualquer coisa que uma pessoa vá auditar. O custo está no intervalo e no armazenamento: um decimal chega no máximo a 79.228.162.514.264.337.593.543.950.335 e ocupa 16 bytes de memória, enquanto um double ocupa apenas 8.

Primeiro, decimal é um valor de ponto flutuante de 128 bits na base 10 (embora apenas 102 bits sejam de fato usados). Ele é composto por 96 bits para um número inteiro, 5 bits para a escala e um bit de sinal. A principal vantagem do decimal em cálculos financeiros ou em certos cálculos científicos é o fato de ser um valor de ponto flutuante na base 10, e não binário. Isso ajuda a evitar muitos dos erros de arredondamento que vemos em tipos como float e double. Para se aprofundar nos problemas de arredondamento, confira o nosso artigo sobre igualdade de números de ponto flutuante.

Vamos ilustrar isso com um exemplo numérico simples:

decimal highPrecisionValue = 123456789.1234567890123456789012345M;
double regularDouble = 123456789.1234567890123456789012345;
float regularFloat = 123456789.1234567890123456789012345f;

Console.WriteLine($"Decimal: {highPrecisionValue}");
Console.WriteLine($"Double: {regularDouble}");
Console.WriteLine($"Float: {regularFloat}");

Aqui, definimos três valores “idênticos”, mas os atribuímos a três tipos de ponto flutuante diferentes do C#: decimal, double e float, respectivamente. Observe também como usamos M para definir uma constante decimal e f para indicar uma constante float. Isso porque, por padrão, em C# uma constante de ponto flutuante é considerada um double.

Agora, vamos analisar a saída:

Decimal: 123456789.12345678901234567890
Double: 123456789.12345679
Float: 123456790

Aqui, vemos claramente a diferença de precisão disponível entre decimal, double e float. Esse exemplo mostra por que o decimal é o tipo preferido para cálculos financeiros e outros cálculos que exigem muita precisão. Para ter o quadro completo de como decimal se compara a double e float, incluindo quando cada um é a escolha certa, temos um artigo dedicado.

Com isso em mente, vamos ver como controlar a precisão desses valores.

Por que um decimal em C# tem 28 ou 29 dígitos significativos?

O inteiro de 96 bits dentro de um decimal pode conter qualquer valor até 79.228.162.514.264.337.593.543.950.335. Esse número tem 29 dígitos, então 29 dígitos é o teto. Mas nem todo número de 29 dígitos cabe: qualquer valor acima desse limite precisa de um 97º bit e, por isso, é arredondado ou causa estouro. Todo número de 28 dígitos cabe, sem exceção.

Isso é tudo o que há por trás de “28 ou 29, dependendo do valor”. Vinte e oito dígitos estão disponíveis a partir de 0,1, e o vigésimo nono só a partir de 1, enquanto o número fica abaixo do limite.

A escala é um orçamento à parte. Ela vai de 0 a 28 e decide quantos desses dígitos ficam depois do ponto. 1.10m e 1.1m são iguais na comparação e representam a mesma quantidade, mas têm escalas diferentes e são impressos de forma diferente, porque um decimal guarda o zero à direita que recebeu.

Atribua um literal mais longo, e o compilador simplesmente o arredonda para caber.

A divisão mostra o limite diretamente: 907m / 31m dá 29.258064516129032258064516129, exatamente 29 dígitos significativos, porque o quociente nunca termina e o tipo preenche todos os dígitos que tem.

PropriedadeValor
Tipo .NETSystem.Decimal
Tamanho128 bits, dos quais 102 contêm informação
Composiçãobit de sinal + inteiro de 96 bits + escala (0 a 28)
Dígitos significativos28 ou 29, dependendo do valor
decimal.MaxValue79228162514264337593543950335
decimal.MinValue-79228162514264337593543950335
Menor valor diferente de zero0.0000000000000000000000000001 (1 x 10^-28)
Sufixo de literalm ou M, como em 123.45m
Valor padrão0m
Arredondamento padrãoMidpointRounding.ToEven (arredondamento bancário); as strings de formato arredondam os empates para longe do zero

Como controlar a precisão exibida de um decimal?

Exibir um decimal com menos dígitos não altera o valor. ToString() gera uma nova string; o decimal na variável permanece intacto, e o próximo cálculo continua usando todos os dígitos que ele tinha.

Há duas formas de dizer quantos dígitos queremos. Uma string de formato personalizada descreve o formato diretamente: myDecimal.ToString("0.00") dá duas casas, "0.0000" dá quatro. Uma string de formato padrão nomeia um estilo, e "F" obtém a quantidade de dígitos do próprio especificador de precisão ou de um NumberFormatInfo: tanto "F3" quanto ToString("F", format) com NumberDecimalDigits definido como 3 dão três casas.

A string personalizada é a escolha certa por padrão. Ela não exige nenhum objeto extra e o resultado fica visível na própria string. O caminho com NumberFormatInfo compensa quando a quantidade de dígitos é uma variável, e não uma constante, ou quando as mesmas configurações são reutilizadas em muitos valores.

As duas arredondam, em vez de truncar.

Para se aprofundar nas strings de formato disponíveis, confira o nosso artigo ‘Strings de formato numérico padrão e personalizadas em C#‘.

Controle da precisão do decimal com strings de formato personalizadas

Primeiro, vamos ver como usar strings de formato personalizadas para controlar a precisão de um decimal. Vamos testar isso restringindo a parte fracionária do valor a duas casas decimais com a string de formato personalizada "0.00":

const decimal myDecimal = 123.456789M;

Console.WriteLine($"Value (\"0.00\"): {myDecimal.ToString("0.00")}");
Console.WriteLine($"Value (default format): {myDecimal}");

Aqui, definimos um valor decimal myDecimal com mais de 2 casas decimais. Em seguida, chamamos ToString() com a string de formato personalizada "0.00" e imprimimos o valor resultante no console. Depois, imprimimos o valor original sem nenhuma formatação personalizada.

Ao examinar a saída, vemos que o valor interno permaneceu inalterado (como mostra a segunda chamada a WriteLine()), mas, quando o imprimimos com a string de formato personalizada, a quantidade de casas decimais exibidas fica restrita:

Value ("0.00"): 123.46
Value (default format): 123.456789

Para exibir mais dígitos, basta ajustar a string de formato. Por exemplo, para exibir 4 casas decimais:

The Web API Production Checklist, e-book gratuito em inglês

E-book gratuito

Sua Web API está pronta para produção?

33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.

O e-book está em inglês.

Baixe o checklist gratuito

PDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.

Console.WriteLine($"Value (\"0.0000\"): {myDecimal.ToString("0.0000")}");

O resultado é:

Value ("0.0000"): 123.4568

Uso do NumberFormatInfo

Outra opção para formatar a saída de valores decimal é usar a classe NumberFormatInfo. Embora essa classe tenha uma infinidade de opções para controlar a saída do valor, aqui vamos nos concentrar apenas em controlar a quantidade de dígitos exibidos. Fazemos isso definindo a propriedade NumberDecimalDigits em um método de extensão. Daqui em diante, todos os métodos de extensão ficam dentro de uma classe estática, que declaramos em um novo arquivo, DecimalExtensions.cs:

using System.Globalization;

public static string ToStringXDecimalPlaces(this decimal val, int decimalPlaces)
{
    var format = new NumberFormatInfo
    {
        NumberDecimalDigits = decimalPlaces
    };

    return val.ToString("F", format);
}

Primeiro, inicializamos uma nova instância de NumberFormatInfo e definimos NumberDecimalDigits de acordo com a precisão especificada. Em seguida, retornamos o decimal como uma string formatada com o formato numérico padrão de ponto fixo e o objeto NumberFormatInfo.

A propriedade NumberDecimalDigits só se aplica quando usamos as strings de formato numérico padrão “N” (número) ou “F” (ponto fixo). Para mais informações sobre essas e outras strings de formato, podemos consultar a documentação do .NET sobre strings de formato numérico padrão.

Vamos ver o método de extensão em ação:

Console.WriteLine($"Value (NumberFormatInfo 3 digits): {myDecimal.ToStringXDecimalPlaces(3)}");

O resultado é:

Value (NumberFormatInfo 3 digits): 123.457

Com strings de formato, podemos imprimir números decimais sem alterar o valor numérico deles. No entanto, também podemos usar o arredondamento para controlar a precisão interna.

Como controlar a precisão armazenada de um decimal?

Alterar o valor armazenado significa chamar um de quatro métodos, e cada um retorna um novo decimal em vez de alterar o que passamos.

decimal.Round() é o que recebe uma quantidade de dígitos: decimal.Round(value, 2) mantém duas casas. Por padrão, ele resolve os empates arredondando para o dígito par mais próximo, então 2.5m é arredondado para 2 e 3.5m é arredondado para 4. Passar MidpointRounding.AwayFromZero dá o comportamento que a maioria das pessoas aprendeu na escola, em que 2.5m vira 3.

Math.Round() produz o mesmo resultado para um argumento decimal, então a escolha entre os dois é questão de estilo, e não de comportamento.

Os outros três não recebem quantidade de dígitos e sempre chegam a um número inteiro. Truncate() descarta a parte fracionária e vai em direção a zero. Floor() vai em direção ao infinito negativo. Ceiling() vai em direção ao infinito positivo.

Em números positivos, Truncate() e Floor() parecem idênticos. Em -15.6789m, eles divergem, e Truncate() passa a coincidir com Ceiling(): Truncate() dá -15, Floor() dá -16 e Ceiling() dá -15. Essa divergência é a única coisa que vale memorizar sobre eles.

Controle da precisão do decimal com arredondamento

As funções de arredondamento são essenciais para controlar a precisão de números decimal, pois nos permitem escolher quantos dígitos de precisão o valor mantém.

O arredondamento continua necessário mesmo com um tipo exato na base 10. A referência da Microsoft para a estrutura System.Decimal diz isso com todas as letras: “O tipo Decimal não elimina a necessidade de arredondamento.”

Em C#, a função Math.Round(), um dos métodos de arredondamento da classe Math, arredonda um valor para o inteiro mais próximo ou para um número específico de casas decimais. Também podemos usar a função equivalente decimal.Round(), um método estático que o System.Decimal oferece há muito tempo e que tem o mesmo comportamento. Por padrão, o método Round() usa a estratégia MidpointRounding.ToEven:

public static decimal Round(this decimal value, int decimalPlaces)
    => decimal.Round(value, decimalPlaces);

Aqui, criamos um método de extensão simples que chama decimal.Round() para arredondar o valor para o número de casas especificado.

The Web API Production Checklist, e-book gratuito em inglês

E-book gratuito

Sua Web API está pronta para produção?

33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.

O e-book está em inglês.

Baixe o checklist gratuito

PDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.

Agora, vamos vê-lo em ação:

Console.WriteLine($"Value (default format): {myDecimal}");
Console.WriteLine($"Value (round 2 places): {myDecimal.Round(2)}");

Observe que estamos imprimindo os dois valores com o formato padrão do decimal:

Value (default format): 123.456789
Value (round 2 places): 123.46

O método Truncate

Também podemos usar o método Truncate() para controlar a precisão do valor. Truncate() remove a parte fracionária e deixa apenas a parte inteira do valor. Vamos criar um método de extensão para testá-lo:

public static decimal Truncate(this decimal value) => decimal.Truncate(value);

Ao chamá-lo com o nosso valor decimal (123.456789):

Console.WriteLine($"Value (truncate): {myDecimal.Truncate()}");

Obtemos:

Value (truncate): 123

Os métodos Ceiling e Floor

Ceiling() e Floor() são parecidos com Truncate(), pois os dois retornam um valor sem a parte fracionária. A diferença é que, enquanto Truncate() simplesmente descarta a parte fracionária, Ceiling() e Floor() se comportam mais como Round(). Ceiling() retorna o menor inteiro maior ou igual ao valor, e Floor() retorna o maior inteiro menor ou igual a ele. Quando o objetivo é um número inteiro, e não uma quantidade fixa de casas, o nosso artigo sobre como arredondar um número para baixo até o inteiro mais próximo trata desse caso separadamente.

Vamos criar mais alguns métodos de extensão para testar essas operações:

public static decimal Ceiling(this decimal value) => decimal.Ceiling(value);

public static decimal Floor(this decimal value) => decimal.Floor(value);

Esses métodos não passam de um pouco de açúcar sintático que nos permite chamá-los diretamente no valor decimal do exemplo:

Console.WriteLine($"Value (ceiling): {myDecimal.Ceiling()}");
Console.WriteLine($"Value (floor): {myDecimal.Floor()}");

O resultado é:

Value (ceiling): 124
Value (floor): 123
Método12.34567.0-15.6789O que faz
decimal.Round(v, 2)12.357.0-15.68Arredonda para 2 casas decimais, com desempate para o número par
decimal.Truncate(v)127-15Descarta a parte fracionária, em direção a zero
decimal.Floor(v)127-16Maior inteiro menor ou igual ao valor
decimal.Ceiling(v)137-15Menor inteiro maior ou igual ao valor

As colunas 12.3456 e -15.6789 trazem os valores que os testes do projeto de exemplo verificam, então a tabela e o repositório coincidem.

O que acontece ao converter float ou double em decimal?

Uma conversão para decimal não recupera a precisão que nunca existiu, nem preserva toda a que existia. Ela arredonda, e o quanto mantém depende do tipo de origem.

Converter a partir de float mantém 7 dígitos significativos. A partir de double, mantém 15. É por isso que (decimal)(float)2222.998 dá 2222.998, embora o float contenha, na verdade, 2222.998046875: sete dígitos dão 2222.998, e o resto é descartado. Passe o mesmo valor por double, com (decimal)(double)(float)2222.998, e 2222.998046875 sobrevive por inteiro.

A lição não é sobre conversão. É que um valor que passa por um float já perdeu exatidão, e convertê-lo em decimal depois só decide quanto do estrago fica visível.

Por isso, um valor que precisa ser exato é declarado como decimal no ponto em que entra no programa, a partir de um literal ou de uma string convertida, e nunca passa por float ou double no caminho.

Quando essa string chega em tempo de execução, decimal.TryParse() e os métodos de conversão são a forma de transformá-la em um decimal sem desviar por um tipo binário.

Conclusão

Neste artigo, vimos várias técnicas para controlar a precisão de valores decimal. Primeiro, examinamos como controlar o formato de saída sem modificar o armazenamento interno dos valores. Depois, examinamos técnicas para controlar a precisão interna deles. No fim das contas, a opção que escolhemos depende do caso de uso. Se quisermos manter um alto nível de precisão, provavelmente devemos nos concentrar apenas em ajustar a formatação de exibição dos dados. Por outro lado, quando temos dados menos precisos, talvez queiramos usar uma das técnicas de arredondamento para reduzir a precisão interna dos valores. Para mais informações sobre o tipo decimal, confira o excelente artigo de Jon Skeet sobre o assunto.

Testado com .NET 10.