Un decimal de C# almacena un número como un entero de 96 bits más una escala que indica dónde va el separador decimal. Por eso cuenta en décimas y centésimas igual que nosotros, y por eso 0.1m + 0.2m es exactamente 0.3m, mientras que la misma suma con double no lo es.

Para descargar el código fuente de este artículo, puedes visitar nuestro repositorio de GitHub.

Se sacrifica rango a cambio de exactitud. Un decimal llega hasta 79 228 162 514 264 337 593 543 950 335 y no más allá, lo que equivale a 28 o 29 dígitos significativos según el número. Todo lo que sigue trata de cómo gastar esos dígitos de forma deliberada, ya sea en lo que mostramos o en lo que almacenamos.

¿Qué es el tipo decimal en C#?

El tipo decimal de C# es un número de 128 bits formado por un bit de signo, un entero de 96 bits y una escala que registra cuántas posiciones hay que desplazar el separador decimal hacia la izquierda. Un decimal se escribe con el sufijo m o M, porque un literal sin sufijo, como 1.5, es un double.

Como la escala cuenta potencias de diez y no de dos, decimal representa los valores tal como la gente los escribe. 0.1m es exactamente una décima. En double, 0.1 es la fracción binaria más cercana a una décima, y por eso 0.1 + 0.2 da 0.30000000000000004 y 0.1m + 0.2m da 0.3.

Esa exactitud es lo que lo convierte en el tipo para el dinero y para cualquier cosa que vaya a auditar una persona. El costo es el rango y el almacenamiento: un decimal se detiene en 79 228 162 514 264 337 593 543 950 335 y ocupa 16 bytes de memoria, mientras que un double ocupa solo 8.

Para empezar, decimal es un valor de punto flotante en base 10 de 128 bits (aunque en realidad solo se usan 102). Consta de 96 bits para un número entero, 5 bits para la escala y un bit de signo. La principal ventaja de decimal en cálculos financieros o en ciertos cálculos científicos es que se trata de un valor de punto flotante en base 10 y no binario. Esto ayuda a evitar muchos de los errores de redondeo que aparecen con tipos como float y double. Si quieres profundizar en los problemas de redondeo, no dejes de leer nuestro artículo sobre la igualdad en punto flotante.

Vamos a ilustrarlo con un ejemplo numérico sencillo:

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

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

Aquí definimos tres valores “idénticos”, pero los asignamos a tres tipos de punto flotante de C# distintos: decimal, double y float, respectivamente. Fíjate también en que usamos M para definir una constante decimal y f para indicar una constante float. Esto se debe a que, en C#, una constante de punto flotante se considera double de forma predeterminada.

Ahora veamos la salida:

Decimal: 123456789.12345678901234567890
Double: 123456789.12345679
Float: 123456790

Aquí se ve claramente la diferencia de precisión disponible entre decimal, double y float. Este ejemplo muestra por qué decimal es el tipo preferido para cálculos financieros y otros cálculos en los que necesitamos mucha precisión. Para ver en detalle cómo se compara decimal con double y float, incluido cuándo conviene cada uno, tenemos un artículo dedicado.

Con esto en mente, veamos cómo controlar la precisión de estos valores.

¿Por qué un decimal de C# tiene 28-29 dígitos significativos?

El entero de 96 bits que hay dentro de un decimal puede contener cualquier valor hasta 79 228 162 514 264 337 593 543 950 335. Ese número tiene 29 dígitos, así que el techo son 29 dígitos. Pero no todos los números de 29 dígitos caben: cualquiera por encima de ese límite necesita un bit número 97 y por eso se redondea o se desborda. Todos los números de 28 dígitos caben, sin excepción.

Eso es todo lo que significa “28 o 29, según el valor”. Hay veintiocho dígitos disponibles a partir de 0.1, y el vigésimo noveno solo a partir de 1, mientras el número se mantenga por debajo del límite.

La escala es un presupuesto aparte. Va de 0 a 28 y decide cuántos de esos dígitos quedan después del separador. 1.10m y 1.1m son iguales al compararlos y representan la misma cantidad, pero tienen escalas distintas y no se imprimen igual, porque un decimal recuerda el cero final que se le dio.

Si asignas un literal más largo, el compilador simplemente lo redondea para que quepa.

La división muestra el límite directamente: 907m / 31m da 29.258064516129032258064516129, exactamente 29 dígitos significativos, porque el cociente nunca termina y el tipo llena todos los dígitos que tiene.

CaracterísticaValor
Tipo de .NETSystem.Decimal
Tamaño128 bits, de los cuales 102 contienen información
Composiciónbit de signo + entero de 96 bits + escala (de 0 a 28)
Dígitos significativos28 o 29, según el valor
decimal.MaxValue79228162514264337593543950335
decimal.MinValue-79228162514264337593543950335
Menor valor distinto de cero0.0000000000000000000000000001 (1 x 10^-28)
Sufijo de literalm o M, como en 123.45m
Valor predeterminado0m
Redondeo predeterminadoMidpointRounding.ToEven (redondeo bancario); las cadenas de formato redondean los empates alejándose de cero

¿Cómo controlar la precisión con la que se muestra un decimal?

Mostrar un decimal con menos dígitos no cambia el valor. ToString() genera una cadena nueva; el decimal de la variable queda intacto, y el siguiente cálculo sigue usando todos los dígitos que tenía.

Hay dos maneras de indicar cuántos dígitos queremos. Una cadena de formato personalizado describe la forma directamente: myDecimal.ToString("0.00") da dos decimales y "0.0000", cuatro. Una cadena de formato estándar nombra un estilo, y "F" toma el número de dígitos de su especificador de precisión o de un NumberFormatInfo: tanto "F3" como ToString("F", format) con NumberDecimalDigits establecido en 3 dan tres decimales.

La cadena personalizada es la opción adecuada por defecto. No necesita ningún objeto adicional y lo que produce se ve en la propia cadena. La vía de NumberFormatInfo compensa cuando el número de dígitos es una variable y no una constante, o cuando se reutiliza la misma configuración en muchos valores.

Las dos redondean, no truncan.

Si quieres profundizar en las propias cadenas de formato disponibles, no dejes de leer nuestro artículo ‘Cadenas de formato numérico estándar y personalizado en C#‘.

Controlar la precisión de un decimal con cadenas de formato personalizado

Primero, veamos cómo usar cadenas de formato personalizado para controlar la precisión de un decimal. Vamos a probarlo limitando la parte fraccionaria del valor a dos decimales con la cadena de formato personalizado "0.00":

const decimal myDecimal = 123.456789M;

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

Aquí definimos un valor decimal myDecimal con más de 2 decimales. Después llamamos a ToString() con la cadena de formato personalizado "0.00" e imprimimos el valor resultante en la consola. A continuación, imprimimos el valor original sin ningún formato personalizado.

Al examinar la salida, vemos que el valor interno no ha cambiado (como se observa en la segunda llamada a WriteLine()), pero, al imprimirlo con la cadena de formato personalizado, el número de decimales que se muestran queda limitado:

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

Para mostrar más dígitos, basta con ajustar la cadena de formato. Por ejemplo, para mostrar 4 decimales:

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

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

Lo que produce:

Value ("0.0000"): 123.4568

Usar NumberFormatInfo

Otra opción para dar formato a la salida de los valores decimal es usar la clase NumberFormatInfo. Aunque esta clase tiene un sinfín de opciones para controlar la salida del valor, aquí nos centraremos únicamente en controlar el número de dígitos que se muestran. Para ello, establecemos la propiedad NumberDecimalDigits en un método de extensión. A partir de aquí, todos los métodos de extensión van dentro de una clase estática, que declaramos en un archivo nuevo, 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);
}

Primero, creamos una nueva instancia de NumberFormatInfo y establecemos su NumberDecimalDigits según la precisión indicada. Después devolvemos el decimal como una cadena con el formato numérico estándar de punto fijo y el objeto NumberFormatInfo.

La propiedad NumberDecimalDigits solo se aplica cuando se usan las cadenas de formato numérico estándar “N” (número) o “F” (punto fijo). Para más información sobre estas y otras cadenas de formato, podemos consultar la documentación de .NET sobre las cadenas de formato numérico estándar.

Veamos nuestro método de extensión en acción:

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

Lo que produce:

Value (NumberFormatInfo 3 digits): 123.457

Con las cadenas de formato podemos imprimir números decimales sin alterar su valor numérico. Sin embargo, también podemos usar el redondeo para controlar su precisión interna.

¿Cómo controlar la precisión almacenada de un decimal?

Para cambiar el valor almacenado hay que llamar a uno de cuatro métodos, y cada uno devuelve un decimal nuevo en lugar de modificar el que le pasamos.

decimal.Round() es el que acepta un número de dígitos: decimal.Round(value, 2) conserva dos decimales. De forma predeterminada, resuelve los empates hacia el dígito par más cercano, así que 2.5m se redondea a 2 y 3.5m, a 4. Si pasamos MidpointRounding.AwayFromZero, obtenemos el comportamiento que la mayoría aprendió en la escuela, en el que 2.5m pasa a ser 3.

Math.Round() produce el mismo resultado con un argumento decimal, así que elegir entre los dos es cuestión de estilo, no de comportamiento.

Los otros tres no aceptan un número de dígitos y siempre dan un número entero. Truncate() descarta la parte fraccionaria y redondea hacia cero. Floor() redondea hacia menos infinito. Ceiling() redondea hacia más infinito.

Con números positivos, Truncate() y Floor() parecen idénticos. Con -15.6789m se separan y Truncate() coincide, en cambio, con Ceiling(): Truncate() da -15, Floor() da -16 y Ceiling() da -15. Esa diferencia es lo único que vale la pena memorizar de ellos.

Controlar la precisión de un decimal mediante el redondeo

Las funciones de redondeo son esenciales para controlar la precisión de los números decimal, ya que nos permiten elegir cuántos dígitos de precisión tiene el valor.

El redondeo sigue siendo necesario incluso con un tipo exacto en base 10. La referencia de Microsoft sobre la estructura System.Decimal lo dice claramente: “El tipo Decimal no elimina la necesidad de redondear”.

En C#, la función Math.Round(), que forma parte de la clase Math y sus métodos de redondeo, redondea un valor al entero más cercano o a un número concreto de decimales. También podemos usar la función equivalente decimal.Round(), un método estático de System.Decimal que existe desde hace mucho y que ofrece el mismo comportamiento. De forma predeterminada, el método Round() usa la estrategia MidpointRounding.ToEven:

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

Aquí creamos un método de extensión sencillo que invoca decimal.Round() para redondear el valor al número de decimales indicado.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

Ahora veámoslo en acción:

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

Fíjate en que aquí imprimimos ambos valores con el formato decimal predeterminado:

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

El método Truncate

También podemos usar el método Truncate() para controlar la precisión del valor. Truncate() elimina la parte fraccionaria y nos deja solo la parte entera del valor. Vamos a crear un método de extensión para probarlo:

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

Si lo llamamos con nuestro valor decimal (123.456789):

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

Obtenemos:

Value (truncate): 123

Los métodos Ceiling y Floor

Ceiling() y Floor() se parecen a Truncate() en que ambos devuelven un valor sin la parte fraccionaria. La diferencia es que, mientras que Truncate() simplemente elimina la parte fraccionaria, Ceiling() y Floor() se comportan más bien como Round(). Ceiling() devuelve el menor entero que no es inferior al valor, y Floor() devuelve el mayor entero que no es superior a él. Cuando el objetivo es un número entero y no una cantidad fija de decimales, nuestro artículo sobre cómo redondear un número hacia abajo al entero más cercano trata ese caso por separado.

Vamos a crear un par de métodos de extensión más para probar estas operaciones:

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

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

Estos métodos no son más que un poco de azúcar sintáctico que nos permite llamarlos directamente sobre el valor decimal de ejemplo:

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

Lo que da:

Value (ceiling): 124
Value (floor): 123
Método12.34567.0-15.6789Qué hace
decimal.Round(v, 2)12.357.0-15.68Redondea a 2 decimales; los empates, hacia el par
decimal.Truncate(v)127-15Descarta la parte fraccionaria, hacia cero
decimal.Floor(v)127-16Mayor entero que no es superior al valor
decimal.Ceiling(v)137-15Menor entero que no es inferior al valor

Las columnas 12.3456 y -15.6789 contienen los valores que comprueban las pruebas del proyecto de ejemplo, así que la tabla y el repositorio coinciden.

¿Qué pasa al convertir un float o un double en decimal?

Una conversión a decimal no recupera la precisión que nunca existió, ni conserva toda la que había. Redondea, y cuánto conserva depende del tipo de origen.

Al convertir desde float se conservan 7 dígitos significativos. Desde double, 15. Por eso (decimal)(float)2222.998 da 2222.998, aunque el float en realidad contiene 2222.998046875: siete dígitos son 2222.998, y el resto se descarta. Si en cambio pasas el mismo valor por double, con (decimal)(double)(float)2222.998, se conserva todo 2222.998046875.

La lección no tiene que ver con la conversión, sino con que un valor que pasa por un float ya ha perdido exactitud, y convertirlo después en decimal solo decide qué parte del daño se ve.

Así que un valor que debe ser exacto se declara como decimal en el punto en que entra en el programa, a partir de un literal o del análisis de una cadena, y en ningún momento pasa por float ni por double.

Cuando esa cadena llega en tiempo de ejecución, decimal.TryParse() y los métodos de análisis son la forma de convertirla en un decimal sin dar un rodeo por un tipo binario.

Conclusión

En este artículo hemos visto varias técnicas para controlar la precisión de los valores decimal. Primero hemos examinado cómo controlar el formato de salida sin modificar el almacenamiento interno de los valores. Después hemos pasado a las técnicas para controlar su precisión interna. En definitiva, la opción que elijamos depende del caso de uso. Si queremos mantener un alto grado de precisión, probablemente deberíamos limitarnos a ajustar el formato con el que mostramos los datos. En cambio, cuando los datos son menos precisos, puede que nos convenga usar alguna de las técnicas de redondeo para reducir la precisión interna de los valores. Para más información sobre el tipo decimal, no dejes de leer el excelente artículo de Jon Skeet sobre el tema.

Probado con .NET 10.