写在前面
数值类型是所有程序的地基,却也是最容易被"想当然"的部分:金额算出 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 参数则按引用传递。
| 类型 | 大小 | 范围 | 后缀 / 说明 |
|---|---|---|---|
sbyte | 1 字节 | -128 ~ 127 | 有符号 8 位 |
byte | 1 字节 | 0 ~ 255 | 无符号 8 位 |
short | 2 字节 | -32,768 ~ 32,767 | 有符号 16 位 |
ushort | 2 字节 | 0 ~ 65,535 | 无符号 16 位 |
int | 4 字节 | -2,147,483,648 ~ 2,147,483,647 | 默认整数类型 |
uint | 4 字节 | 0 ~ 4,294,967,295 | 后缀 u / U |
long | 8 字节 | 约 ±9.22 × 10^18 | 后缀 l / L |
ulong | 8 字节 | 0 ~ 约 1.84 × 10^19 | 后缀 ul / UL |
nint / nuint | 平台相关 | 32 位进程 4 字节,64 位进程 8 字节 | C# 9 起的关键字,IntPtr / UIntPtr 的别名 |
float | 4 字节 | 约 ±3.4 × 10^38,精度约 6~9 位十进制 | 后缀 f / F |
double | 8 字节 | 约 ±1.8 × 10^308,精度约 15~17 位十进制 | 默认浮点类型,后缀 d / D |
decimal | 16 字节 | 约 ±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的结果是int66,想拿回字符得(char)('A' + 1)。bool:不与任何数值类型互通。没有if (1)这种写法,bool与int之间不存在隐式或显式转换,只能x != 0这类显式逻辑表达。- 枚举:底层是整数,默认
int,可显式指定为八种固定宽度整数类型之一(enum Day : byte),不能用nint/nuint。枚举与底层类型之间的转换是显式的,(byte)Day.Wed得到底层值。
1.3 字面量与后缀
字面量的类型由形式 + 后缀共同决定,规则比想象中细:
| |
两个容易踩的点:
- 无后缀整数字面量的类型按
int → uint → long → ulong顺序取第一个装得下的。给var赋值时类型跟着字面量走,var x = 0xFFFFFFFF;是uint而不是int,var y = 0x1_0000_0000;是long。 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(隐藏位),所以
float实际有 24 位二进制精度、double有 53 位; - 指数采用偏移编码(127 / 1023),使非负有限值的位模式按无符号整数比较时与数值顺序一致;负数顺序相反,不能直接推广到所有浮点值;
- 指数和尾数的特殊组合编码出 0、次正规数、无穷和 NaN。
用 BitConverter 可以直接看位模式:
| |
拆开 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 位尾数;相加之后再次舍入:
| |
这不是 bug,而是二进制的数学必然。两个"反直觉但真实"的对照组:
| |
float 版本碰巧为 True,是因为 0.1f + 0.2f 舍入后的结果恰好等于 0.3f 的最近似值;1.0 / 3 * 3 则是两次舍入互相抵消。结论:== 精确比较存储的浮点值;若业务要比较近似计算结果,应按误差要求使用容差(见 6.2 节)。
2.3 特殊值:无穷、NaN 与 ±0.0
IEEE 754 用"最大指数 + 零尾数 / 非零尾数"编码出无穷和 NaN:
| |
NaN 的语义需要特别记住三条:
| |
==判断 NaN 永远是 false,包括 NaN 和自己比较。判 NaN 必须用double.IsNaN(x);- 但
Equals(以及基于它的哈希容器)认为 NaN 等于 NaN——所以Dictionary<double, T>里 NaN 是个合法且能查回的键,和==语义不一致; - NaN 通常在加减乘除等算术中传播(但并非所有数学 API 都如此,例如
Math.Pow(double.NaN, 0)返回 1),会静默污染整条聚合链(求和、求平均),直到输出才发现。
还有一对容易被忽略的值:±0.0。它们 == 相等,但位模式不同,除法能区分:
| |
符号位为负的零常来自"负数下溢"或 (-x) * 0 这类运算,序列化成字符串 -0 后可能让下游(报表、对账)困惑。
2.4 double.Epsilon 的真面目与机器 ε
double.Epsilon 是最小的正可表示数(约 4.94 × 10^-324,次正规数),float.Epsilon 约 1.4 × 10^-45。它们经常被误用成"比较容差"——大错。正确的概念是机器 ε(1 与下一个可表示数的间距):
| |
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 正是这份布局:
| |
解读:低三个 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 位左右:
| |
decimal 还保留尾零——它记录了 scale,0.5m 与 0.500m 数值相等(== 为 True)但 ToString 的输出不同:
| |
对报表和金额展示,这个特性有时是福利(保留录入精度),有时需要对齐(decimal.Round 或格式化)。
3.2 行为差异:没有 Inf/NaN、溢出永远抛异常
decimal 的世界里没有无穷和 NaN:
| |
除以零直接抛异常,不返回 Infinity。同样,算术溢出无条件抛 OverflowException,checked / unchecked 对它不生效:
| |
这与整数(默认静默回绕)和浮点(饱和到 ∞)都不同——decimal 的设计假设是你拿它算钱,算错就该立刻炸。
3.3 一个隐蔽坑:(decimal)double 只保留 15 位
把 double 显式转成 decimal 时,结果舍入到最多 15 位有效数字(Decimal(Double) 构造器定义的舍入规则),而不是你想象的"完整转换":
| |
经过 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 位:
| |
编译器放行是因为它们"不损失量级"(不会变成 0 或无穷),但对 ID、时间戳、金额分单位这类每个整数都必须精确的场景,这一步就是静默的数据损坏。double → float、decimal → 任何 则都需要显式强转,编译器替你把住了明面上的门,完整的有损隐式转换还包括 uint / ulong / nint / nuint 到 float,以及平台相关整数到 double 的部分情况,详见转换矩阵。
4.2 浮点 → 整数:截断、越界与"未指定值"
显式的浮点 → 整数转换向零截断(不是向下取整):
| |
越界时的行为由上下文决定,这是 C# 文档里需要区分实现行为与规范保证的地带:
checked上下文:抛OverflowException;unchecked上下文:结果是指定类型的"未指定值"(unspecified value),文档明确不承诺。
在 Windows x64 的 .NET 10 上实测,RyuJIT 对越界值做饱和处理、NaN 归零:
| |
但这是运行期实现行为,常量折叠也可能给出不同结果(本机 unchecked((long)1e300) 常量表达式得到 0);不同 JIT、不同平台可能不同,不要依赖。需要确定的语义就用 .NET 7+ 的泛型数学转换 API(见 4.5 节)。
double → float 的显式转换同样有越界规则:超出 float 范围的正/负值饱和到 ±∞,过小的值(含 double.Epsilon)归零:
| |
float/double → decimal 是个例外:无论 checked 还是 unchecked,越界与 NaN/∞ 一律抛 OverflowException,过小归零——它不给你静默错误的机会:
| |
4.3 checked 与 unchecked:编译期和运行期是两个世界
checked / unchecked 控制内置整数算术、整数与 char 之间的转换,以及 float / double 转整数时的溢出行为;不改变二进制浮点算术或 decimal 算术的规则。默认规则容易记反:
- 运行期,整数算术默认
unchecked,溢出静默回绕; - 编译期常量表达式,默认按
checked求值,可显式用unchecked覆盖,常量溢出直接编译失败。
| |
这里的 unchecked(int.MinValue / -1) 是常量折叠结果;在本机 .NET 10 x64 上,改为变量的运行期除法即使置于 unchecked 中也抛 OverflowException,不能据此示例推导运行期除法一定回绕。
注意两个细节:
byte b = 13;能编译——常量在目标范围内的隐式窄化是允许的,只有超范围才报 CS0031;- 常量整数除以零是编译错 CS0020,但常量浮点除以零合法,直接折叠出无穷:
double z = 5.0 / 0;编译通过,值为 ∞。
运行期想让溢出炸出来,用 checked 语句或 checked 运算符:
| |
编译选项 <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>(或 csc -checked+)可以把整个程序集默认改成 checked。checked 对 ++、--、复合赋值同样生效:
| |
回绕不算 bug 的场景(哈希混合、校验计算)保留 unchecked 并加注释;其余业务算术宁可让它抛——静默的 -2147483648 流入库存、金额字段,比异常难排查一个数量级。
4.4 拆箱必须精确匹配
装箱(boxing)会把值类型包成 object。拆箱的规则:运行时类型必须与目标类型完全一致,不存在"数值兼容":
| |
从 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 不抛 | 2 | 2 | 未指定值 |
Convert.ToInt32(x) | 数值/对象 | 银行家舍入 + 范围检查 | 2 | 3 | OverflowException |
int.Parse(s) | 字符串 | 解析,不允许小数点 | — | — | OverflowException |
int.CreateChecked(x) | 数值(.NET 7+) | 向零截断 + 检查 | 2 | 2 | OverflowException |
Convert.ToInt32 和 Math.Round 默认用银行家舍入(MidpointRounding.ToEven):中间值取偶数,目的是大量舍入的统计无偏:
| |
Convert.ToInt32(double.MaxValue) 会抛 OverflowException(有范围检查,不同于强转)。
.NET 7 的泛型数学带来了三个转换 API,补上了"强转语义未指定"的缺口,推荐在需要跨类型数值转换的代码里显式表达意图:
| |
五、运算符行为:提升、除法与取模
5.1 整数提升:byte + byte 是 int
C# 规定:小于 int 的整数类型(byte、sbyte、short、ushort、char)参与二元算术时先提升为 int。结果有两个:溢出不可能发生在小类型上(中间结果至少是 int),但把结果赋回去必须强转:
| |
5.2 复合赋值的隐式转换陷阱
b = b + 1 编译不过(CS0266,int 不能隐式赋回 byte),但 b += 1 / b++ 可以——复合赋值和自增自减被语言特意开了后门(隐含强转)。隐含转换仍受 checked / unchecked 上下文控制;在默认的 unchecked 下:
| |
这一行在 code review 里几乎不可疑,却是实打实的静默数据损坏。凡是"小整数类型 + 复合赋值"的组合都值得警惕,业务计数请直接用 int。
5.3 除法向零截断,取模符号随被除数
整数除法向零截断,取模满足 a == (a / b) * b + a % b,因此 % 的符号随被除数:
| |
从 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),不抛异常。
| |
浮点除零会静默产生无穷,再往下传播(无穷参与比较、聚合),错误离现场越来越远。对可能为零的分母,要么先判 != 0.0,要么用 double.IsFinite 在出口处兜底检查。
5.5 移位计数按位宽取模
移位量不是"想移多少移多少”:左操作数 32 位时移位计数取 count & 31,64 位取 count & 63,负数移位不抛异常,同样按位与回绕:
| |
位运算没有溢出检查(移出去的位直接丢),checked 不覆盖它们。计算掩码、位偏移时记得边界。
5.6 混合运算的类型提升路径
二元运算的常规提升顺序:(byte, short, …) → int → long → float → double,而 decimal 独立成岛(这是简化示意,无符号类型混合等场景的完整规则见语言参考的"二元数值提升"):
| |
decimal 与 float/double 混在一个表达式里必须显式选择一边——这不是不便,是强制你意识到两种表示的语义差异。
六、精度陷阱与正确处理
6.1 误差累积与 Kahan 补偿求和
单次 10^-17 量级的误差,累加千万次后就不是噪声了:
| |
放大到 1000 万次(0.1 累加):
| |
处理手段按优先级:
- 换类型:
decimal累加精确(0.1m十次累加 ==1.0m为 True);金额最常见的做法是干脆用long存"分",整数世界没有误差; - 补偿求和(Kahan summation):用额外变量记录舍入补偿,通常能减小累加误差,但不保证任意输入都得到精确结果:
| |
同样的 1000 万次累加,Kahan 结果为 1000000(精确); 3. 数学恒等式:1 加到 n 用公式而不是循环,没有误差累积的机会。
6.2 浮点比较:容差怎么选
正确姿势是比较差值与容差,并区分量级:
| |
- 差值接近零(理论值可为 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:
| |
所以"看起来都是 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,而且"中间值"按实际存储的浮点值判定,不是按十进制字面量:
| |
同一个"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%:
| |
推论和注意点:
- 调试/日志想看真实值用
"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 墙) |
| 机器学习权重互操作 | Half | binary16 原生对齐 |
| 任意精度数学 | 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 的静态抽象接口成员,一套代码覆盖多种数值类型:
| |
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 侧的好消息:
| |
坏消息在消费端: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 条目:
- 金额用
decimal或分单位整数,任何环节不过float/double;舍入策略显式写MidpointRounding; - ID、时间戳、大整数不过浮点;跨 JSON 边界的 2^53 以上整数用字符串字段;
- 近似计算结果按业务容差比较,需要判断存储值精确相等时可用
==;double.Epsilon永远不当容差用; - 同一份数据全程同一浮点宽度;
float字面量必须带f后缀; - 业务整数运算考虑 checked(语句、运算符或程序集级开关),哈希/校验等有意回绕处加注释;
- 警惕复合赋值后门:
byte/short的+=、++在 unchecked 下会静默回绕,计数直接用int; - 越界浮点 → 整数强转结果未指定,要么
checked,要么CreateChecked/CreateSaturating显式表达意图; - 拆箱类型精确匹配,
(int)(object)long会炸,两步转换或上泛型; - 解析用
TryParse+InvariantCulture(数据交换场景),注意double.Parse("1e400")不抛异常返回 ∞; - 数据库 decimal 列显式声明精度与小数位,近似计算结果不依赖浮点精确相等来判断业务等价;
- 调试浮点用
"G17"(double)/"G9"(float),别被默认最短表示骗了; - 二进制协议显式端序 + 对端宽度核对;
decimal的 P/Invoke 封送必须匹配原生 DECIMAL/CY 等明确契约。
十、总结
C# 数值类型的三族结构各自回答了一个问题:整数回答"如何精确计数"(溢出规则是它的全部脾气);二进制浮点回答"如何用有限位逼近实数"(动态范围大,但十进制小数是近似值,比较、累积、舍入处处要防);decimal 回答"如何精确表示十进制"(没有无穷、没有 NaN、溢出必炸,密集算术通常比二进制浮点慢,需按负载测量)。
坑的分布比类型本身更值得记:类型转换里有"隐式但损失精度"的暗门(int → float、long → double)和"显式但结果未指定"的地雷(越界浮点强转);运算符里有提升(byte + byte 是 int)、复合赋值后门(b += 100 静默回绕)、两种除以零和符号随被除数的取模;字符串边界上有最短往返、银行家舍入和 culture 差异;跨进程边界上有 JavaScript 的 2^53 墙、数据库的 DECIMAL 精度和二进制端序。
这些行为全部写在语言规范和官方文档里,没有一条是未定义的"玄学"——出问题的从来不是编译器,而是我们没有读过契约。把这张地图装进脑子,数值类型的 bug 大多数在写下的那一刻就可以避免。