深入 C# 数值类型:表示、转换、精度与常见陷阱

写在前面

数值类型是所有程序的地基,却也是最容易被"想当然"的部分:金额算出 0.30000000000000004,long 存的 ID 一转 double 就悄悄变了,(int) 强转一个越界的 double 得到一个"看起来还能用"的值,byte 加 byte 还要强转回 byte……这些问题的根源不在 API 用法,而在于二进制如何表示数、语言如何定义转换和运算。

本文系统梳理 C# 数值类型:先建立类型全景与字面量规则,再深入 IEEE 754 浮点与 decimal 的内部表示,然后逐条过类型转换和运算符行为中的暗坑,最后给出选型决策、跨边界注意事项和一份工程检查清单。

示例基于 .NET 10 / C# 14,在 Windows x64 上实测;文中所有"输出"注释均为真实运行结果,数值输出环境的小数点为句点(如 zh-CN、Invariant)。版本相关行为(如 .NET 7 引入的 API)会单独标注。

一、数值类型全景:一张地图

1.1 三族类型

C# 的数值类型分三族:

  • 整数(integral types):sbyte、byte、short、ushort、int、uint、long、ulong,加上平台相关的 nint / nuint;
  • 二进制浮点(IEEE 754):float(binary32)与 double(binary64),另有 Half(binary16,见第七节);
  • 十进制浮点:decimal——128 位、基数为 10 的浮点类型,为十进制运算设计。

它们全部是值类型(struct),继承自 System.ValueType,普通赋值和按值传参采用复制语义;ref / out / in 参数则按引用传递。

类型大小范围后缀 / 说明
sbyte1 字节-128 ~ 127有符号 8 位
byte1 字节0 ~ 255无符号 8 位
short2 字节-32,768 ~ 32,767有符号 16 位
ushort2 字节0 ~ 65,535无符号 16 位
int4 字节-2,147,483,648 ~ 2,147,483,647默认整数类型
uint4 字节0 ~ 4,294,967,295后缀 u / U
long8 字节约 ±9.22 × 10^18后缀 l / L
ulong8 字节0 ~ 约 1.84 × 10^19后缀 ul / UL
nint / nuint平台相关32 位进程 4 字节,64 位进程 8 字节C# 9 起的关键字,IntPtr / UIntPtr 的别名
float4 字节约 ±3.4 × 10^38,精度约 6~9 位十进制后缀 f / F
double8 字节约 ±1.8 × 10^308,精度约 15~17 位十进制默认浮点类型,后缀 d / D
decimal16 字节约 ±1.0 × 10^-28 ~ ±7.9 × 10^28,28~29 位有效数字后缀 m / M

注意 float.MinValue 是最负的有限值(-3.4 × 10^38),不是"最小的数"——它能表示的最小正数是 float.Epsilon ≈ 1.4 × 10^-45(一个次正规数,见 2.4 节),与 MinValue 相差 40 多个数量级。

1.2 边界上的类型:char、bool 与 enum

三个类型和数值关系密切但不属于上表:

  • char:16 位 UTF-16 代码单元(code unit),是独立于 ushort 的类型;一个 Unicode 码点可能需要两个 char 表示。它可以参与算术并隐式转换为 ushort / int 等整数,'A' + 1 的结果是 int 66,想拿回字符得 (char)('A' + 1)。
  • bool:不与任何数值类型互通。没有 if (1) 这种写法,bool 与 int 之间不存在隐式或显式转换,只能 x != 0 这类显式逻辑表达。
  • 枚举:底层是整数,默认 int,可显式指定为八种固定宽度整数类型之一(enum Day : byte),不能用 nint / nuint。枚举与底层类型之间的转换是显式的,(byte)Day.Wed 得到底层值。

1.3 字面量与后缀

字面量的类型由形式 + 后缀共同决定,规则比想象中细:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int      dec   = 1_000_000;   // 十进制,_ 是 C# 7.0 起的数字分隔符
int      hex   = 0xFF;        // 十六进制
int      bin   = 0b1010_1010; // 二进制(C# 7.0 起)
uint     u     = 123u;        // u/U → uint(值超 uint 则 ulong)
long     l     = 123L;        // l/L → long(推荐大写 L,小写 l 与 1 难分辨)
ulong    ul    = 123UL;
double   d     = 1.5;         // 不带后缀的小数字面量默认 double
double   d2    = 1.5d;        // d/D 可省略
float    f     = 1.5f;        // float 必须显式后缀
decimal  m     = 1.5m;        // decimal 必须显式后缀

两个容易踩的点:

  1. 无后缀整数字面量的类型按 int → uint → long → ulong 顺序取第一个装得下的。给 var 赋值时类型跟着字面量走,var x = 0xFFFFFFFF; 是 uint 而不是 int,var y = 0x1_0000_0000; 是 long。
  2. float f = 0.1; 编译不过(CS0664:不能隐式把 double 转 float),必须写 0.1f 或显式强转——这不是编译器苛刻,是提醒你 0.1 的二进制近似值在两种宽度下并不相同(见 6.3 节)。

二、二进制浮点的真相:IEEE 754

float 和 double 是 IEEE 754 的 binary32 / binary64 实现。绝大多数"浮点玄学"在这一节都有直接答案。

2.1 位模式:符号、指数、尾数

一个浮点数被拆成三段:float 是 1 位符号 + 8 位指数 + 23 位尾数,double 是 1 + 11 + 52。规格化数的值是:

1
(-1)^sign × 1.xxx…x × 2^(e - 偏移)
  • 尾数隐含一个前导 1(隐藏位),所以 float 实际有 24 位二进制精度、double 有 53 位;
  • 指数采用偏移编码(127 / 1023),使非负有限值的位模式按无符号整数比较时与数值顺序一致;负数顺序相反,不能直接推广到所有浮点值;
  • 指数和尾数的特殊组合编码出 0、次正规数、无穷和 NaN。

用 BitConverter 可以直接看位模式:

1
2
Console.WriteLine($"{BitConverter.DoubleToUInt64Bits(0.1):X16}"); // 3FB999999999999A
Console.WriteLine($"{BitConverter.SingleToUInt32Bits(0.1f):X8}");  // 3DCCCCCD

拆开 0x3FB999999999999A:符号 0;指数段 0x3FB = 1019,减去偏移 1023 得 -4;尾数段 0x999999999999A / 2^52 ≈ 0.6(0.6 的二进制是无限循环的,这里取最接近的 52 位近似)。于是 double 里的"0.1"是一个有限长的二进制小数,精确值是 0.1000000000000000055511151231257827……——这是与精确的 0.1 有非零误差的固定近似值。

2.2 为什么 0.1 + 0.2 ≠ 0.3

二进制小数只能精确表示分母为 2 的幂的分数。0.1 和 0.2(1/10、1/5)在二进制里是无限循环小数,只能舍入到最近的 52 位尾数;相加之后再次舍入:

1
2
3
Console.WriteLine(0.1 + 0.2 == 0.3);          // 输出: False
Console.WriteLine(0.1 + 0.2);                 // 输出: 0.30000000000000004
Console.WriteLine(0.1.ToString("G17"));       // 输出: 0.10000000000000001

这不是 bug,而是二进制的数学必然。两个"反直觉但真实"的对照组:

1
2
Console.WriteLine(0.1f + 0.2f == 0.3f);   // 输出: True
Console.WriteLine(1.0 / 3 * 3 == 1.0);    // 输出: True

float 版本碰巧为 True,是因为 0.1f + 0.2f 舍入后的结果恰好等于 0.3f 的最近似值;1.0 / 3 * 3 则是两次舍入互相抵消。结论:== 精确比较存储的浮点值;若业务要比较近似计算结果,应按误差要求使用容差(见 6.2 节)。

2.3 特殊值:无穷、NaN 与 ±0.0

IEEE 754 用"最大指数 + 零尾数 / 非零尾数"编码出无穷和 NaN:

1
2
3
4
5
Console.WriteLine(1.0 / 0.0);    // 输出: ∞(double.PositiveInfinity)
Console.WriteLine(-1.0 / 0.0);   // 输出: -∞
Console.WriteLine(0.0 / 0.0);    // 输出: NaN
Console.WriteLine(double.MaxValue * 2);   // 输出: ∞(溢出不抛异常,按 IEEE 754 得到无穷)
Console.WriteLine(Math.Sqrt(-1));         // 输出: NaN

NaN 的语义需要特别记住三条:

1
2
3
Console.WriteLine(double.NaN == double.NaN);        // 输出: False
Console.WriteLine(double.NaN.Equals(double.NaN));   // 输出: True
Console.WriteLine(double.NaN + 1);                  // 输出: NaN(传染性)
  1. == 判断 NaN 永远是 false,包括 NaN 和自己比较。判 NaN 必须用 double.IsNaN(x);
  2. 但 Equals(以及基于它的哈希容器)认为 NaN 等于 NaN——所以 Dictionary<double, T> 里 NaN 是个合法且能查回的键,和 == 语义不一致;
  3. NaN 通常在加减乘除等算术中传播(但并非所有数学 API 都如此,例如 Math.Pow(double.NaN, 0) 返回 1),会静默污染整条聚合链(求和、求平均),直到输出才发现。

还有一对容易被忽略的值:±0.0。它们 == 相等,但位模式不同,除法能区分:

1
2
3
4
double nz = -0.0;
Console.WriteLine(nz == 0.0);   // 输出: True
Console.WriteLine(1 / nz);      // 输出: -∞(而 1 / 0.0 是 ∞)
Console.WriteLine(nz.ToString());   // 输出: -0

符号位为负的零常来自"负数下溢"或 (-x) * 0 这类运算,序列化成字符串 -0 后可能让下游(报表、对账)困惑。

2.4 double.Epsilon 的真面目与机器 ε

double.Epsilon 是最小的正可表示数(约 4.94 × 10^-324,次正规数),float.Epsilon 约 1.4 × 10^-45。它们经常被误用成"比较容差"——大错。正确的概念是机器 ε(1 与下一个可表示数的间距):

1
2
double next = BitConverter.Int64BitsToDouble(BitConverter.DoubleToInt64Bits(1.0) + 1);
Console.WriteLine(next - 1.0);   // 输出: 2.220446049250313E-16,即 2^-52

double 的机器 ε ≈ 2.22 × 10^-16,float ≈ 1.19 × 10^-7(2^-23)。数值的绝对间距随量级变化:在 1 附近是 2.22 × 10^-16,在 10^16 附近就是 2。所以任何固定常量都不适合当通用容差,选型见 6.2 节。

次正规数(subnormal)是介于 0 和最小规格化数之间的"渐进下溢"区域,指数段全 0、尾数非零。它们让极小量的差值不至于直接归零,但运算更慢(部分硬件需要陷阱或微码处理)。日常工程几乎不需要主动关心,只需要知道 Epsilon 表示的是这个区域的下界。

三、decimal:另一套十进制体系

decimal 是 128 位、基数为 10 的浮点类型。它不是 IEEE 754 的 decimal128,而是 .NET 自有格式,目标是:十进制小数(如货币)在它内部精确表示。

3.1 内部结构:96 位整数 × 10^-scale

decimal 由一个 96 位无符号整数、一个 0~28 的缩放指数(scale)和一个符号位组成,decimal.GetBits 返回的四个 int 正是这份布局:

1
2
3
foreach (var w in decimal.GetBits(1.2345m))
    Console.Write($"{w:X8} ");
// 输出: 00003039 00000000 00000000 00040000

解读:低三个 32 位字拼成 96 位整数 0x3039 = 12345;第四个字 0x00040000 的第 16~23 位是 scale = 4,第 31 位是符号。所以 1.2345m = 12345 × 10^-4。-1.2345m 的第四个字是 0x80040000(符号位为 1)。decimal.MaxValue = 79228162514264337593543950335,恰好是 2^96 - 1(scale 为 0 时能表示的最大整数)。

这解释了 decimal 的所有特征:0.1 精确(1 × 10^-1)、十进制加减法在结果可精确表示时精确、有效数字最多 28~29 位、除不尽时舍入到 28 位左右:

1
2
3
Console.WriteLine(0.1m + 0.2m == 0.3m);   // 输出: True
Console.WriteLine(1m / 3m);
// 输出: 0.3333333333333333333333333333(28 位)

decimal 还保留尾零——它记录了 scale,0.5m 与 0.500m 数值相等(== 为 True)但 ToString 的输出不同:

1
2
Console.WriteLine(0.500m);        // 输出: 0.500
Console.WriteLine(0.10m + 0.20m); // 输出: 0.30(本例 scale 取两者较大;一般情况下为适应系数上限可能降低 scale 并舍入)

对报表和金额展示,这个特性有时是福利(保留录入精度),有时需要对齐(decimal.Round 或格式化)。

3.2 行为差异:没有 Inf/NaN、溢出永远抛异常

decimal 的世界里没有无穷和 NaN:

1
2
decimal one = 1m, zero = 0m;
Console.WriteLine(one / zero);   // 抛 DivideByZeroException

除以零直接抛异常,不返回 Infinity。同样,算术溢出无条件抛 OverflowException,checked / unchecked 对它不生效:

1
2
decimal max = decimal.MaxValue;
Console.WriteLine(unchecked(max + 1m));   // 仍然抛 OverflowException

这与整数(默认静默回绕)和浮点(饱和到 ∞)都不同——decimal 的设计假设是你拿它算钱,算错就该立刻炸。

3.3 一个隐蔽坑:(decimal)double 只保留 15 位

把 double 显式转成 decimal 时,结果舍入到最多 15 位有效数字(Decimal(Double) 构造器定义的舍入规则),而不是你想象的"完整转换":

1
2
3
4
Console.WriteLine((decimal)Math.PI);        // 输出: 3.14159265358979(15 位)
Console.WriteLine((decimal)(0.1 + 0.2));    // 输出: 0.3(17 位的 0.30000000000000004 被舍成 15 位)
Console.WriteLine(decimal.Parse("3.14159265358979311599796346854"));
// 输出: 3.1415926535897931159979634685(结果有 28 位小数、29 位有效数字,已发生舍入)

经过 double 中转的十进制信息已经带着二进制误差,15 位十进制有效数字可在转为 double 再转回十进制时保留;反过来,使任意有限 double 经文本往返最多需要 17 位。要完整精度,让数据走字符串(decimal.Parse)而不是走 double。

四、类型转换:每条路都标了价

C# 的数值转换规则是"静态可查的契约":编译器保证不丢量级(order of magnitude),但精度损失可能发生在你意想不到的合法路径上。

4.1 隐式转换矩阵与"合法但有损"的暗门

隐式转换链:sbyte → short → int → long → float → double 这条经典的"从小到大",加上无符号分支(完整矩阵见语言参考)。允许哪些转换由语言明确列举,不能仅凭范围推导:float 的有限值范围包含 decimal 的范围,但 decimal → float 仍须显式转换。

但"装得下范围"不等于"装得下所有值"。int → float、long → float、long → double、ulong → double 这几条路是隐式却可能损失精度的——float 只有 24 位尾数,double 只有 53 位,而 int 是 31 位有效、long 是 63 位:

1
2
Console.WriteLine((float)16777217);            // 输出: 16777216(2^24+1 被 24 位尾数吞掉)
Console.WriteLine((double)9007199254740993L);  // 输出: 9007199254740992(2^53+1 被 53 位尾数吞掉)

编译器放行是因为它们"不损失量级"(不会变成 0 或无穷),但对 ID、时间戳、金额分单位这类每个整数都必须精确的场景,这一步就是静默的数据损坏。double → float、decimal → 任何 则都需要显式强转,编译器替你把住了明面上的门,完整的有损隐式转换还包括 uint / ulong / nint / nuint 到 float,以及平台相关整数到 double 的部分情况,详见转换矩阵。

4.2 浮点 → 整数:截断、越界与"未指定值"

显式的浮点 → 整数转换向零截断(不是向下取整):

1
2
Console.WriteLine((int)2.7);    // 输出: 2
Console.WriteLine((int)-2.7);   // 输出: -2(-2.7 向零截断,不是 -3)

越界时的行为由上下文决定,这是 C# 文档里需要区分实现行为与规范保证的地带:

  • checked 上下文:抛 OverflowException;
  • unchecked 上下文:结果是指定类型的"未指定值"(unspecified value),文档明确不承诺。

在 Windows x64 的 .NET 10 上实测,RyuJIT 对越界值做饱和处理、NaN 归零:

1
2
3
4
5
6
7
double big = 1e20, neg = -1e20, nan = double.NaN;
Console.WriteLine(unchecked((int)big));   // 输出: 2147483647(饱和到 MaxValue)
Console.WriteLine(unchecked((int)neg));   // 输出: -2147483648
Console.WriteLine(unchecked((int)nan));   // 输出: 0
Console.WriteLine(unchecked((uint)big));  // 输出: 4294967295
double huge = 1e300;
Console.WriteLine(unchecked((long)huge)); // 输出: 9223372036854775807

但这是运行期实现行为,常量折叠也可能给出不同结果(本机 unchecked((long)1e300) 常量表达式得到 0);不同 JIT、不同平台可能不同,不要依赖。需要确定的语义就用 .NET 7+ 的泛型数学转换 API(见 4.5 节)。

double → float 的显式转换同样有越界规则:超出 float 范围的正/负值饱和到 ±∞,过小的值(含 double.Epsilon)归零:

1
2
Console.WriteLine((float)1e300);   // 输出: ∞
Console.WriteLine((float)1e-300);  // 输出: 0

float/double → decimal 是个例外:无论 checked 还是 unchecked,越界与 NaN/∞ 一律抛 OverflowException,过小归零——它不给你静默错误的机会:

1
2
Console.WriteLine((decimal)1e-300);   // 输出: 0
// (decimal)1e300 与 (decimal)double.NaN 都抛 OverflowException

4.3 checked 与 unchecked:编译期和运行期是两个世界

checked / unchecked 控制内置整数算术、整数与 char 之间的转换,以及 float / double 转整数时的溢出行为;不改变二进制浮点算术或 decimal 算术的规则。默认规则容易记反:

  • 运行期,整数算术默认 unchecked,溢出静默回绕;
  • 编译期常量表达式,默认按 checked 求值,可显式用 unchecked 覆盖,常量溢出直接编译失败。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int imax = int.MaxValue;
Console.WriteLine(imax + 1);              // 输出: -2147483648(变量运算,静默回绕)
Console.WriteLine(unchecked(0u - 1u));    // 输出: 4294967295
Console.WriteLine(unchecked(int.MinValue / -1));   // 输出: -2147483648

// 以下均编译失败;溢出常量可在允许显式转换时用 unchecked;整数除零不能靠 unchecked 解决:
// int  a = int.MaxValue + 1;    // CS0220: 在 checked 模式下,运算在编译时溢出
// int  b = (int)1e20;           // CS0221: 常量值"1E+20"无法转换为"int"
// byte c = 300;                 // CS0031: 常量值"300"无法转换为"byte"
// int  q = 5 / 0;               // CS0020: 被常数零除

Console.WriteLine(unchecked((byte)300));   // 输出: 44(escape hatch:常量也能强转)

这里的 unchecked(int.MinValue / -1) 是常量折叠结果;在本机 .NET 10 x64 上,改为变量的运行期除法即使置于 unchecked 中也抛 OverflowException,不能据此示例推导运行期除法一定回绕。

注意两个细节:

  1. byte b = 13; 能编译——常量在目标范围内的隐式窄化是允许的,只有超范围才报 CS0031;
  2. 常量整数除以零是编译错 CS0020,但常量浮点除以零合法,直接折叠出无穷:double z = 5.0 / 0; 编译通过,值为 ∞。

运行期想让溢出炸出来,用 checked 语句或 checked 运算符:

1
2
checked { int r = imax + 1; }          // checked 语句
var r2 = checked(imax + 1);            // checked 运算符

编译选项 <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>(或 csc -checked+)可以把整个程序集默认改成 checked。checked 对 ++、--、复合赋值同样生效:

1
2
3
4
5
byte b = 255;
b++;                                   // unchecked:静默回绕到 0
b = 255;                               // 重置,确保下一次自增越界
try { checked { b++; } }               // checked:抛 OverflowException
catch (OverflowException) { /* ... */ }

回绕不算 bug 的场景(哈希混合、校验计算)保留 unchecked 并加注释;其余业务算术宁可让它抛——静默的 -2147483648 流入库存、金额字段,比异常难排查一个数量级。

4.4 拆箱必须精确匹配

装箱(boxing)会把值类型包成 object。拆箱的规则:运行时类型必须与目标类型完全一致,不存在"数值兼容":

1
2
3
4
object boxed = 1L;
var n = (int)boxed;        // InvalidCastException!必须是 (long)
object fb = 1.5f;
var d = (double)fb;        // InvalidCastException!float 装箱只能拆成 float

从 object / 接口取数再转 int 的正确姿势是两步:(int)(long)boxed,或用泛型避免装箱。这在处理反射、老旧集合(ArrayList)或动态数据(JSON JsonElement)时高频出现。

4.5 四种"转成 int":强转、Convert、Parse、CreateXxx

“把一个数变成 int"至少有四条路,语义各不相同,以 2.5/2.7 为例:

写法输入行为2.5 →2.7 →越界
(int)x此处为 double向零截断,unchecked 不抛22未指定值
Convert.ToInt32(x)数值/对象银行家舍入 + 范围检查23OverflowException
int.Parse(s)字符串解析,不允许小数点——OverflowException
int.CreateChecked(x)数值(.NET 7+)向零截断 + 检查22OverflowException

Convert.ToInt32 和 Math.Round 默认用银行家舍入(MidpointRounding.ToEven):中间值取偶数,目的是大量舍入的统计无偏:

1
2
3
4
5
Console.WriteLine(Convert.ToInt32(2.5));   // 输出: 2
Console.WriteLine(Convert.ToInt32(3.5));   // 输出: 4
Console.WriteLine(Convert.ToInt32(-2.5));  // 输出: -2
Console.WriteLine(Math.Round(2.5));        // 输出: 2
Console.WriteLine(Math.Round(2.5, MidpointRounding.AwayFromZero));   // 输出: 3

Convert.ToInt32(double.MaxValue) 会抛 OverflowException(有范围检查,不同于强转)。

.NET 7 的泛型数学带来了三个转换 API,补上了"强转语义未指定"的缺口,推荐在需要跨类型数值转换的代码里显式表达意图:

1
2
3
4
5
int.CreateChecked<double>(1e20);       // 抛 OverflowException(明确检查)
int.CreateSaturating<double>(1e20);    // 2147483647(明确饱和)
int.CreateSaturating<double>(-1e20);   // -2147483648
int.CreateSaturating<double>(double.NaN);   // 0
int.CreateTruncating<double>(2.7);     // 2(明确向零截断)

五、运算符行为:提升、除法与取模

5.1 整数提升:byte + byte 是 int

C# 规定:小于 int 的整数类型(byte、sbyte、short、ushort、char)参与二元算术时先提升为 int。结果有两个:溢出不可能发生在小类型上(中间结果至少是 int),但把结果赋回去必须强转:

1
2
3
byte a = 200, b = 100;
var sum = a + b;          // sum 是 int,值 300
byte c = (byte)(a + b);   // 想拿回 byte 必须显式强转(此时 300 → 44,又一个静默回绕点)

5.2 复合赋值的隐式转换陷阱

b = b + 1 编译不过(CS0266,int 不能隐式赋回 byte),但 b += 1 / b++ 可以——复合赋值和自增自减被语言特意开了后门(隐含强转)。隐含转换仍受 checked / unchecked 上下文控制;在默认的 unchecked 下:

1
2
3
byte b = 200;
b += 100;                 // 编译通过,静默回绕
Console.WriteLine(b);     // 输出: 44

这一行在 code review 里几乎不可疑,却是实打实的静默数据损坏。凡是"小整数类型 + 复合赋值"的组合都值得警惕,业务计数请直接用 int。

5.3 除法向零截断,取模符号随被除数

整数除法向零截断,取模满足 a == (a / b) * b + a % b,因此 % 的符号随被除数:

1
2
3
4
5
Console.WriteLine(5 / 2);     // 输出: 2
Console.WriteLine(-5 / 2);    // 输出: -2(向零截断,不是 -3)
Console.WriteLine(-7 % 3);    // 输出: -1(符号随被除数)
Console.WriteLine(7 % -3);    // 输出: 1
Console.WriteLine(-7.5 % 2);  // 输出: -1.5(浮点同样随被除数)

从 Python(-7 % 3 == 2,符号随除数)转过来的工程师最容易在这里出错。需要非负余数时,在 b > 0 时先算 r = a % b,再用 r < 0 ? r + b : r;这能避免双重取模公式中间加法溢出,Math.DivRem 的余数也需自行处理。

5.4 除以零:整型抛异常,浮点得无穷

  • 整数 / 常量零:编译错 CS0020(见 4.3 节);
  • 整数 / 零(运行期,变量):抛 DivideByZeroException;
  • 浮点 / 零:返回 ±∞ 或 NaN(0.0/0.0),不抛异常。
1
2
3
4
5
int five = 5, zero = 0;
Console.WriteLine(five / zero);    // 抛 DivideByZeroException
double dfive = 5.0, dzero = 0.0;
Console.WriteLine(dfive / dzero);  // 输出: ∞
Console.WriteLine(dzero / dzero);  // 输出: NaN

浮点除零会静默产生无穷,再往下传播(无穷参与比较、聚合),错误离现场越来越远。对可能为零的分母,要么先判 != 0.0,要么用 double.IsFinite 在出口处兜底检查。

5.5 移位计数按位宽取模

移位量不是"想移多少移多少”:左操作数 32 位时移位计数取 count & 31,64 位取 count & 63,负数移位不抛异常,同样按位与回绕:

1
2
Console.WriteLine(1 << 35);   // 输出: 8(35 & 31 = 3)
Console.WriteLine(1 << -1);   // 输出: -2147483648(-1 & 31 = 31,即 int.MinValue)

位运算没有溢出检查(移出去的位直接丢),checked 不覆盖它们。计算掩码、位偏移时记得边界。

5.6 混合运算的类型提升路径

二元运算的常规提升顺序:(byte, short, …) → int → long → float → double,而 decimal 独立成岛(这是简化示意,无符号类型混合等场景的完整规则见语言参考的"二元数值提升"):

1
2
3
4
Console.WriteLine((0.1f + 0.2f).GetType().Name);   // Single(float + float 仍是 float)
Console.WriteLine((0.1f + 0.1).GetType().Name);    // Double(碰到 double 提升)
Console.WriteLine((1m + 1).GetType().Name);        // Decimal(int 与 decimal 运算得 decimal)
// 1m + 0.1 是编译错误:decimal 与 double 之间没有任何隐式转换

decimal 与 float/double 混在一个表达式里必须显式选择一边——这不是不便,是强制你意识到两种表示的语义差异。

六、精度陷阱与正确处理

6.1 误差累积与 Kahan 补偿求和

单次 10^-17 量级的误差,累加千万次后就不是噪声了:

1
2
3
4
double acc = 0;
for (int i = 0; i < 10; i++) acc += 0.1;
Console.WriteLine(acc == 1.0);   // 输出: False
Console.WriteLine(acc);          // 输出: 0.9999999999999999

放大到 1000 万次(0.1 累加):

1
2
3
double naive = 0;
for (int i = 0; i < 10_000_000; i++) naive += 0.1;
Console.WriteLine(naive);   // 输出: 999999.9998389754

处理手段按优先级:

  1. 换类型:decimal 累加精确(0.1m 十次累加 == 1.0m 为 True);金额最常见的做法是干脆用 long 存"分",整数世界没有误差;
  2. 补偿求和(Kahan summation):用额外变量记录舍入补偿,通常能减小累加误差,但不保证任意输入都得到精确结果:
1
2
3
4
5
6
7
8
9
double sum = 0, c = 0;   // c 记录累计的舍入补偿
IEnumerable<double> source = Enumerable.Repeat(0.1, 10_000_000);
foreach (var x in source)
{
    double y = x - c;
    double t = sum + y;
    c = (t - sum) - y;   // 恢复本次加法丢失的低位
    sum = t;
}

同样的 1000 万次累加,Kahan 结果为 1000000(精确); 3. 数学恒等式:1 加到 n 用公式而不是循环,没有误差累积的机会。

6.2 浮点比较:容差怎么选

正确姿势是比较差值与容差,并区分量级:

1
2
3
4
5
6
7
8
static bool NearlyEqual(double a, double b, double absTol, double relTol)
{
    if (a == b) return true; // 包括同号无穷;NaN 不相等
    if (!double.IsFinite(a) || !double.IsFinite(b)) return false;
    double diff = Math.Abs(a - b);
    return diff <= absTol
        || diff <= relTol * Math.Max(Math.Abs(a), Math.Abs(b));
}
  • 差值接近零(理论值可为 0)的场景用绝对容差(如 1e-9);
  • 量级不确定的科学计算用相对容差 + 绝对容差兜底(上式);
  • 容差没有万能值,它取决于业务(纳米级坐标和光年级距离的容差显然不同)。

上例要求容差非负且有限,且差值与相对容差乘积不会溢出;极大数值需先缩放再比较。两个禁忌:double.Epsilon 不适合作为通用容差(它是 4.94 × 10^-324,diff 恰好为 Epsilon 时该判断也成立,但对通常量级的误差几乎没有容忍能力);Math.Abs(a - b) < 小常数 对大数无效(10^16 附近的间距是 2,1e-9 的容差永远不满足)。

6.3 混合精度比较:0.1f == 0.1 为什么是 false

float 与 double 比较时,float 先提升为 double,而提升是精确的——0.1f 的位模式展开成一个很长的 double:

1
2
3
4
float f = 0.1f;
Console.WriteLine(f == 0.1);   // 输出: False
Console.WriteLine(((double)f).ToString("G17"));
// 输出: 0.10000000149011612(这是 double 语境下的 0.1f)

所以"看起来都是 0.1"的两个字面量,在两种精度下是不同的近似值。跨精度的比较、以 float 为键查 double 字典、API 出参和入参精度不一致,都会在这里翻车。规则:同一份数据全程使用同一种浮点宽度。

6.4 大整数进浮点:2^24 与 2^53 两道坎

float 尾数 24 位、double 尾数 53 位,意味着:

  • 超过 2^24(16,777,216)的整数进 float 可能不精确——16777216f + 1f 仍是 16777216f;
  • 超过 2^53(9,007,199,254,740,992)的整数进 double 可能不精确——(double)9007199254740993L 得到 9007199254740992。

雪花 ID、纳秒时间戳、大额分单位金额都远超 2^53。它们要么留在 long / Int128,要么序列化为字符串,任何环节都不要转浮点(包括 JSON 消费端,见 8.1 节)。

6.5 舍入策略:银行家舍入与 2.345 的两个答案

Math.Round(value, digits) 默认 MidpointRounding.ToEven,而且"中间值"按实际存储的浮点值判定,不是按十进制字面量:

1
2
Console.WriteLine(Math.Round(2.345, 2));    // 输出: 2.35
Console.WriteLine(Math.Round(2.345m, 2));   // 输出: 2.34

同一个"2.345":double 里的 2.345 实际略大于中点,舍入向上到 2.35;decimal 里的 2.345 精确等于中点,银行家舍入取偶得 2.34。金额舍入必须按业务规则显式声明策略,不能统一假定使用 AwayFromZero。double 也能精确表示某些中点,例如 2.5、1.25;Math.Round(double, digits) 的缩放计算也可能发生舍入,不能仅凭原始值位于十进制中点哪一侧推导所有结果。

6.6 字符串与往返:最短表示、G17 与解析

.NET Core 3.0 重写了浮点格式化:ToString() / "G" / "R" 返回最短可往返字符串(shortest round-trippable)。在此之前,double.ToString() 默认只给 15 位,.NET 官方博客测过旧版本里只有约 8% 的 double 能满足 Parse(ToString()) 往返相等;新实现是 100%:

1
2
3
var s = (0.1 + 0.2).ToString();
Console.WriteLine(s);                              // 输出: 0.30000000000000004
Console.WriteLine(double.Parse(s) == 0.1 + 0.2);   // 输出: True(最短往返)

推论和注意点:

  • 调试/日志想看真实值用 "G17"(double)/ "G9"(float),默认 ToString 的最短表示为了短会隐藏误差细节(0.1.ToString() 就是 "0.1",看不出任何问题);
  • ToString("R") 与 "G17" 都保证往返;
  • 格式化会丢数据:(1e-7).ToString() 是 "1E-07",但 (1e-7).ToString("F") 是 "0.00"——小数值遇上固定小数位格式会直接归零,报表里"消失的 0.0000001"就是这么来的;
  • 解析越界不抛异常:double.Parse("1e400") 返回 ∞(与浮点除零一致),而 int.Parse("3000000000") 抛 OverflowException,字符串入口的防线只有 TryParse + 显式检查。

七、选型与性能

7.1 选型决策表

场景首选理由
计数、索引、循环int / long精确、最快、无精度问题
货币、计费、利率decimal(或分单位 long)十进制精确,舍入可控
科学计算、图形、传感器均值double动态范围大,硬件支持
大量浮点数组 / GPU / 机器学习数据float内存减半,SIMD 通道翻倍
大于 long 的 ID / 数值Int128 / string别过浮点(2^53 墙)
机器学习权重互操作Halfbinary16 原生对齐
任意精度数学BigInteger无上限,但慢且无小数

经验法则:默认 int,小数默认 double,钱一律 decimal。float 只在"量大 + 精度要求低"的明确理由下使用。

7.2 性能边界:定性结论与测量条件

在主流 x64/ARM64 的 .NET 实现中,decimal 算术由运行时软件例程实现,不能像 float / double 那样直接使用二进制浮点指令。在密集数值计算中通常更慢,但差距取决于运算、数据和 JIT 优化;评估热路径时应使用可复现的基准测试(附测试代码、预热和防止优化消除的措施),不要直接采信没有方法说明的倍数结论。

其余定性结论:

  • float 与 double 的加减乘除在现代 x64/ARM64 上的标量速度通常接近(依赖处理器与指令;x64 可用 SSE/AVX,ARM64 使用其自身浮点/SIMD 指令),float 的优势是内存与 SIMD 通道数而不是单条运算;
  • Math.Sqrt、Math.Abs 等常用成员对 float 有 MathF(.NET Core 2.0+)版本,避免隐式转 double 再转回来;
  • checked 整数运算有少量开销(JIT 生成溢出检查),绝大多数业务场景可忽略;需要极致性能且确认不会溢出的内核循环再手动 unchecked;
  • 小整数类型(byte 等)不省运算时间(都会提升为 int),只省内存——别为"性能"牺牲正确性。

7.3 Half、Int128 与 BigInteger

三个特殊数值类型定位明确:

  • Half(.NET 5+):IEEE 754 binary16,1+5+10 位,范围约 ±65,504,精度约 3 位十进制。它主要是互操作类型——机器学习(ONNX、TensorFlow)和 GPU 接口的权重格式;它的算术运算符在实现上经由 float 中转,.NET 内用它做算术并不比 float 快;
  • Int128 / UInt128(.NET 7+):128 位整数,补齐 long 到 BigInteger 之间的空档,支持全套运算符与泛型数学接口。注意它没有字面量后缀,170141183460469231731687303715884105727 这种字面量会报 CS1021(整数常量太大),要用 Int128.MaxValue 或 (Int128)longValue 构造;
  • BigInteger:无界整数,值类型但内部用数组存储,按需增长。没有溢出概念(内存允许就无限大),没有小数;频繁分配大数时性能显著低于原生类型。一个隐蔽点:它参与 ==/< 等比较是数值语义,与原生类型一致,但作为字典键时哈希计算代价高。

7.4 泛型数学:INumber 一套算法通吃

.NET 7 起,本文涉及的数值类型实现了统一的泛型数学接口(INumber<T>、IBinaryInteger<T>、IFloatingPoint<T>……,位于 System.Numerics 命名空间;个别类型只实现其中一部分,如 Complex 只到 INumberBase<T>),配合 C# 11 的静态抽象接口成员,一套代码覆盖多种数值类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
using System.Numerics;

static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T sum = T.Zero;
    foreach (var v in values)
        sum += v;          // 调用 T 的静态运算符
    return sum;
}

Sum(new[] { 1, 2, 3 });          // int: 6
Sum(new[] { 0.1m, 0.2m });       // decimal
Sum(new[] { 1f, 2f });           // float

INumber<T> 约束提供 T.Zero、T.One 和相应运算符;T.MaxValue 需要额外的 IMinMaxValue<T> 约束,无界的 BigInteger 没有最大值;4.5 节的 CreateChecked / CreateSaturating / CreateTruncating 也来自这套接口。解析器、向量库、单位换算这类"算法与类型无关"的代码,从此不必为每种数值复制一份。

八、跨边界的数值

数值类型在自己进程里守规则,一旦跨进程、跨语言、跨存储,精度与表示的决定权就不再完全在你手里。

8.1 JSON 与 JavaScript:2^53 的墙

System.Text.Json 序列化数值时保留原始精度——long 会以完整文本 9007199254740993 写出,decimal 也不会丢尾零,这是 .NET 侧的好消息:

1
2
3
4
using System.Text.Json;

var json = JsonSerializer.Serialize(new { Big = 9007199254740993L, Amount = 0.1m + 0.2m });
// 输出: {"Big":9007199254740993,"Amount":0.3}

坏消息在消费端:JavaScript 的 Number 是 binary64。JSON 文本 9007199254740993 在 JS 里读出来就是 9007199254740992——数据在对方内存里已经错了,和你无关但由你背锅。行业惯例:超出 2^53 的整数 ID 用字符串字段传输,JS 端也按字符串处理。前端展示 double 金额同样有格式化风险,精确金额请传字符串由前端 decimal 库处理或传整数分单位。

8.2 数据库:DECIMAL 的精度声明

decimal 通常映射到 SQL 的 DECIMAL / NUMERIC。应显式配置精度(precision,总位数)与小数位数(scale),避免提供程序默认值与业务范围不符。在 EF Core 中可用 HasPrecision(p, s);HasColumnType("decimal(18,4)") 则指定数据库列类型,须确认提供程序支持。超出 scale 的值可能被舍入,超出可表示范围可能报错;具体行为取决于数据库、提供程序及其配置,不能统一归因于“严格模式”。浮点类型的 SQL 映射名称也因数据库而异。近似计算结果可能与字面量 0.1 不同,因此金额相等判断应优先使用精确数值列;这并不意味着浮点列上的等值查询一律查不到。

8.3 二进制协议与字节序

跨设备/跨语言传输数值,序列化你自己负责。两点铁律:

  • 显式声明字节序:BitConverter 的输出是平台端序(x86/x64/主流 ARM 都是小端,但这是实现不是承诺);协议场景用 BinaryPrimitives.WriteInt32BigEndian / WriteInt32LittleEndian 这类显式 API,按协议规定写;
  • 宽度对齐对端:C# 的 int 恒为 32 位,但 C 的 int 只是"至少 16 位",主流平台 32 位——与 C ABI 对接时以对端头文件的真实宽度为准,并核对该平台的对齐与填充规则(详见《C# 原生互操作与设备 SDK(二):C# 类型与 C ABI》《设备通信与协议编程(三):二进制协议帧》)。

8.4 P/Invoke 与 blittable

decimal 不是 blittable 类型,但默认运行时封送会将它映射为 OLE Automation 的原生 DECIMAL,也可按契约用 UnmanagedType.Currency 映射为 CY,因此它可以用于 P/Invoke。前提是对端 API 明确采用兼容的布局、对齐和封送约定——普通 C/C++ 代码里的十进制类型并不自动兼容 .NET decimal。整数、float、double 是 blittable 的,同样需要核对宽度与布局。自定义跨语言协议最省心的做法仍是传"64 位整数 + scale"或字符串,由对端自行组装。

8.5 并发中的 double

多线程共享可变的 double 字段还有一道额外的坎:C# 规范保证 bool、char、固定宽度的 8/16/32 位整数、float 和引用等类型的读写原子性;不保证 double、long、ulong 与 decimal 的普通读写原子性,double / long 在 32 位平台上可能被撕裂(torn,高低 32 位来自两次写)。64 位平台上对齐的 8 字节读写事实上原子,但这依赖实现。Interlocked.Add / Increment 没有 double 版本,但 Interlocked.Exchange / CompareExchange 提供 ref double 重载(按位比较,NaN 参与时行为要留意),可用来实现原子的读改写循环;更复杂的场景用锁。内存模型与原子性的完整讨论见《线程安全的本质》。

九、工程检查清单

把本文的结论压缩成可执行的 review 条目:

  1. 金额用 decimal 或分单位整数,任何环节不过 float/double;舍入策略显式写 MidpointRounding;
  2. ID、时间戳、大整数不过浮点;跨 JSON 边界的 2^53 以上整数用字符串字段;
  3. 近似计算结果按业务容差比较,需要判断存储值精确相等时可用 ==;double.Epsilon 永远不当容差用;
  4. 同一份数据全程同一浮点宽度;float 字面量必须带 f 后缀;
  5. 业务整数运算考虑 checked(语句、运算符或程序集级开关),哈希/校验等有意回绕处加注释;
  6. 警惕复合赋值后门:byte/short 的 +=、++ 在 unchecked 下会静默回绕,计数直接用 int;
  7. 越界浮点 → 整数强转结果未指定,要么 checked,要么 CreateChecked/CreateSaturating 显式表达意图;
  8. 拆箱类型精确匹配,(int)(object)long 会炸,两步转换或上泛型;
  9. 解析用 TryParse + InvariantCulture(数据交换场景),注意 double.Parse("1e400") 不抛异常返回 ∞;
  10. 数据库 decimal 列显式声明精度与小数位,近似计算结果不依赖浮点精确相等来判断业务等价;
  11. 调试浮点用 "G17"(double)/ "G9"(float),别被默认最短表示骗了;
  12. 二进制协议显式端序 + 对端宽度核对;decimal 的 P/Invoke 封送必须匹配原生 DECIMAL/CY 等明确契约。

十、总结

C# 数值类型的三族结构各自回答了一个问题:整数回答"如何精确计数"(溢出规则是它的全部脾气);二进制浮点回答"如何用有限位逼近实数"(动态范围大,但十进制小数是近似值,比较、累积、舍入处处要防);decimal 回答"如何精确表示十进制"(没有无穷、没有 NaN、溢出必炸,密集算术通常比二进制浮点慢,需按负载测量)。

坑的分布比类型本身更值得记:类型转换里有"隐式但损失精度"的暗门(int → float、long → double)和"显式但结果未指定"的地雷(越界浮点强转);运算符里有提升(byte + byte 是 int)、复合赋值后门(b += 100 静默回绕)、两种除以零和符号随被除数的取模;字符串边界上有最短往返、银行家舍入和 culture 差异;跨进程边界上有 JavaScript 的 2^53 墙、数据库的 DECIMAL 精度和二进制端序。

这些行为全部写在语言规范和官方文档里,没有一条是未定义的"玄学"——出问题的从来不是编译器,而是我们没有读过契约。把这张地图装进脑子,数值类型的 bug 大多数在写下的那一刻就可以避免。

参考资料

  1. C# 语言参考:整型数值类型

  2. C# 语言参考:浮点数值类型

  3. C# 语言参考:内置数值转换

  4. C# 语言参考:checked 和 unchecked

  5. .NET API:Convert.ToInt32

  6. .NET API:Math.Round 与 MidpointRounding

  7. .NET API:Double.Epsilon / Single.Epsilon

  8. .NET Blog: Floating-Point Parsing and Formatting improvements in .NET Core 3.0

  9. .NET API:Half 结构

  10. .NET API:Int128 / BigInteger

  11. .NET API:Decimal(Double) 构造器

  12. .NET 泛型数学接口

  13. MDN:Number.isSafeInteger(JavaScript 的 2^53 边界)

  14. .NET:自定义结构封送(decimal 字段的 DECIMAL / CY 映射)

  15. IEEE 754 标准

  16. EF Core:属性精度与小数位

  17. C# 规范:变量读写的原子性

Licensed under CC BY-NC-SA 4.0