数据类型

理解 C# 值类型与引用类型的复制语义、可空性、数值转换和装箱规则,并避开生成代码中常见的类型错误。

难度 进阶 时长 标准深度约 11分钟
版本 C# 14 / .NET 10
what

C# 类型主要分为值类型和引用类型;赋值前者会复制值,赋值后者会复制指向对象的引用。

trap

值类型不等于“在栈上”,T? 对值类型和引用类型也不是同一种机制;忽略这些区别会制造复制、空值和转换错误。

fix

根据所需语义选择类型,在边界处显式处理空值、解析失败和缩小转换,并用泛型避免不必要的装箱。

是什么,为什么存在

C# 的每个值都有编译时类型,运行时对象也有实际类型。类型决定一个值能参与哪些操作、如何赋值和传参,以及哪些转换需要显式确认。编译器据此在代码运行前拒绝大量无效组合。

C# 类型主要分为两类: 值类型(value type) 变量直接包含一个值,引用类型变量则包含指向对象的引用。intboolenumstruct 是值类型;classinterface、数组、委托、objectstring 是引用类型。

这个分类最直接的影响是复制语义。把值类型赋给另一个变量,会复制其中的数据;把引用类型赋给另一个变量,只会复制引用,因此两个变量可以指向同一个可变对象。这个规则也适用于普通的按值参数传递。

C# 的类型系统是统一的:任何值都可以作为 object 处理。引用类型只需复制引用;值类型则通常需要 装箱(boxing) ,也就是创建一个包含该值的对象。统一模型让非泛型 API 可以接收任意值,但复制和分配成本不会因此消失。

工作原理

类型类别决定变量保存什么

类别变量保存的内容赋值结果可以直接为 null
非可空值类型完整的值复制值
可空值类型 T?T 的值或无值状态复制整个可空值
引用类型指向对象的引用复制引用运行时可以
可空引用类型 T?同一种运行时引用,加上可空意图复制引用

不要把“直接包含值”简化成“值类型总在栈上”。值类型字段可以内联在类对象或数组中,局部值也可能因闭包、异步状态机或装箱而进入托管堆。类型类别规定的是语义,不是固定的内存区域。

引用类型变量自身也有值,它的值是一条引用。复制引用不会复制对象;重新给其中一个变量赋值也不会改变另一个变量。只有通过任一引用修改共享对象时,另一个变量才会观察到变化。

可空值与可空引用

对于非可空值类型 TT?Nullable<T> 的简写。它同时记录是否有值以及存在时的 T 值。int? 因而可以保存整数或 null,而 Value 在没有值时会抛出 InvalidOperationException

对于引用类型, 可空引用类型(nullable reference type) 注解表达 API 的空值意图。string 表示调用方不应提供 nullstring? 表示 null 是预期状态。两者在运行时仍是相同的引用类型;编译器依靠流分析发出警告,并不会自动插入运行时检查。

?. 在接收者为 null 时停止成员访问,?? 提供回退值,模式 is not null 则能缩小后续代码中的空状态。空包容运算符 ! 只抑制编译器警告,它既不检查引用,也不会把 null 变成对象。

转换、强制转换与解析

隐式转换只适用于语言认为无需调用方确认的转换,例如 intlong,或者派生类引用到基类引用。隐式数值转换并不总是保留精度:某些整数转浮点数的转换仍可能舍入。不要把“隐式”理解成“数值完全不变”。

缩小转换(narrowing conversion) 需要显式强制转换,例如 longint。整数缩小时,unchecked 上下文会丢弃超出目标宽度的高位;checked 上下文会抛出 OverflowException。如果溢出必须成为错误,应把转换和相关算术都放进 checked 上下文。

引用强制转换检查对象的运行时类型,失败时抛出 InvalidCastExceptionis 模式适合同时检查和取得目标值,as 则在兼容的引用类型或可空值类型转换失败时返回 null。文本到数值不是强制转换,应使用带有明确区域性和格式规则的 TryParse

装箱保留准确的值类型

把值类型转换到 object 或它实现的接口通常会装箱。箱中的运行时值仍有准确的原始值类型,因此装箱的 int 必须先拆成 int。即使 int 可以隐式转换成 long,直接把同一个箱拆成 long 仍会抛出 InvalidCastException

泛型集合如 List<int> 能直接为 int 实例化存储与操作路径,通常不需要把每个元素转换成 object。非泛型容器、接受 object 的 API,以及把结构体转换为接口引用的代码都值得检查。装箱是否发生由实际转换决定,不能只靠变量名或源代码表面猜测。

示例

复制值与复制引用

第一个例子故意使用可变结构体,以便看到复制结果。生产代码通常更适合使用不可变结构体,但复制规则相同。

CopySemantics.cs
using System;
using System.Collections.Generic;

var original = new Reading(21);
var copy = original;
copy.Celsius = 25;

var firstCart = new Cart(new List<string> { "book" });
var secondCart = firstCart;
secondCart.Items.Add("pen");

Console.WriteLine($"readings: {original.Celsius}, {copy.Celsius}");
Console.WriteLine($"cart items: {string.Join(", ", firstCart.Items)}");

public struct Reading
{
    public Reading(int celsius) => Celsius = celsius;
    public int Celsius { get; set; }
}

public sealed class Cart
{
    public Cart(List<string> items) => Items = items;
    public List<string> Items { get; }
}
readings: 21, 25
cart items: book, pen

copy 得到完整的 Reading 副本,所以修改它不会触及 originalsecondCart 得到的却是同一个 Cart 对象的引用;通过它增加商品后,firstCart.Items 也能看到新元素。

结构体字段仍可包含引用。如果 Reading 内部保存一个可变列表,复制结构体会复制列表引用,不会递归复制列表。所谓值复制只覆盖该值实际包含的字段。

把缺失状态留在类型中

解析外部文本时,失败是正常输入状态,不应依靠异常来控制常规分支。这里以 decimal? 表示“有效价格或没有价格”,并固定使用不受机器区域设置影响的格式。

NullableParsing.cs
#nullable enable
using System;
using System.Globalization;

string?[] inputs = ["19.95", null, "free"];

foreach (string? input in inputs)
{
    decimal? price = ParsePrice(input);
    Console.WriteLine(price is decimal value
        ? $"price: {value:F2}"
        : "price: invalid");
}

static decimal? ParsePrice(string? text)
{
    if (decimal.TryParse(
        text,
        NumberStyles.Number,
        CultureInfo.InvariantCulture,
        out decimal value) && value >= 0)
    {
        return value;
    }

    return null;
}
price: 19.95
price: invalid
price: invalid

text? 告诉调用方和编译器这个参数可能为空。TryParse 同时处理 null 和格式错误,范围条件再拒绝负数。返回处的 decimal? 与引用注解不同,它确实是 Nullable<decimal> 值。

真实边界还需要写清小数位数和最大金额策略。decimal 可以准确表示示例中的十进制值,但它仍有有限范围和 28 到 29 位有效精度,并不会替业务自动决定舍入规则。

在缩小和拆箱处失败得清楚

下面把两个容易混淆的动作放在一起:数值缩小会改变表示范围,拆箱则要求箱内类型完全匹配。它们都使用强制转换语法,但运行规则不同。

ConversionsAndBoxing.cs
using System;
using System.Collections.Generic;

long shipmentCount = int.MaxValue;

try
{
    int narrowed = checked((int)(shipmentCount + 1));
    Console.WriteLine(narrowed);
}
catch (OverflowException)
{
    Console.WriteLine("narrowing overflow detected");
}

object boxed = 42;
Console.WriteLine($"boxed runtime type: {boxed.GetType().Name}");

try
{
    long wrong = (long)boxed;
    Console.WriteLine(wrong);
}
catch (InvalidCastException)
{
    Console.WriteLine("unbox must match Int32");
}

long converted = (int)boxed;
var values = new List<int> { (int)converted };
Console.WriteLine($"converted: {values[0]}");
narrowing overflow detected
boxed runtime type: Int32
unbox must match Int32
converted: 42

checked 让超出 int 范围的缩小转换明确失败。箱内是 System.Int32,所以 (long)boxed 不是普通的 intlong 数值转换。正确顺序是先以 (int) 拆箱,再让结果隐式扩大为 long

最后的 List<int> 保存 int,不要求元素先变成 object。这里再次转成 int 只是为了匹配集合的元素类型;数值 42 仍在 long 的有效范围内。

陷阱

用内存位置解释类型类别

修复方法: 先按复制与共享语义推理。只有在分析实际分配时,才结合具体代码、运行时版本和分析工具讨论栈、托管堆或寄存器;不要从 structclass 关键字直接推导位置。

! 掩盖未知空状态

修复方法: 在数据进入系统时验证必填值,对合法的缺失状态使用 ?,并用模式匹配收窄。只有当编译器无法理解但程序已经建立了不变量时才使用 !,同时用测试证明该不变量。

默认区域性悄悄改变解析结果

修复方法: 在协议、配置和持久化边界指定 CultureInfo.InvariantCulture 或明确的业务区域性,并优先使用 TryParse。测试小数分隔符、分组符、符号、空值、超范围值和多余字符。

只检查最终赋值是否溢出

修复方法: 在运算前扩大至少一个操作数,例如 checked((long)quantity * unitCents),并让 checked 覆盖整个表达式。边界测试应包含每个输入的最大值以及它们组合后的最大值。

把数值转换当成拆箱

修复方法: 如果对象契约保证箱内为 int,先用模式 boxed is int value 取得值,再转换成 long。如果输入可能有多种数值类型,应明确列出支持集合;不要依靠 Convert.ChangeType 猜测领域规则。

把可变结构体当成共享对象

修复方法: 小型值对象优先设计成不可变的 readonly structreadonly record struct。需要共享可变身份时使用类;必须原地更新结构体时,则让 ref 语义在 API 和调用点都清楚可见。

深入 存储位置由上下文决定

存储位置由上下文决定

类型声明决定值的布局和复制语义,实际存储则取决于它出现在哪里。类对象中的结构体字段通常直接成为该对象数据的一部分;值类型数组也连续保存元素值,而不是为每个元素单独创建对象。反过来,装箱会为值类型创建对象。

局部变量也不能简单等同于栈槽。JIT 可以把值保存在寄存器中,捕获的局部状态会成为编译器生成对象的字段,async 方法中的值可能进入状态机。规范允许实现选择物理表示,只要可观察行为符合语言规则。

这一区分会改变代码审查的顺序。先问赋值后是否独立、多个变量是否可能指向同一个对象、方法是否通过 ref 取得别名。只有确认语义正确后,才值得用分配跟踪或基准测量定位真实成本。

参数传递仍然传值

普通参数默认按值传递。值类型参数收到值的副本;引用类型参数收到引用的副本。被调用方法能通过引用修改对象,却不能仅靠给参数重新赋值来替换调用方的变量。

refoutin 参数改为传递变量存储位置的别名。ref 允许读写,out 要求被调用方赋值,in 提供只读访问。它们修改的是参数传递方式,不会把结构体永久变成引用类型。

可空值类型的装箱边界

Nullable<T> 的装箱有专门规则。没有值的 T? 装箱后得到空引用;有值的 T? 则把底层 T 装箱,而不是创建一个运行时类型为 Nullable<T> 的箱。因此,不能通过 boxed.GetType() 观察到 Nullable<int>

这会让反射和通用对象管道显得反直觉。int? number = 42; object boxed = number; 之后,boxed.GetType() 返回 System.Int32。而 number = null 后再赋给 object?,结果就是 null,调用 GetType() 会因为没有对象而失败。

模式匹配通常比手写拆箱分支更清楚。boxed is int number 同时验证准确的运行时类型并取得底层值。处理 object? 时先覆盖 null,再列出允许的类型,能让输入契约保持可见。

泛型何时避免装箱

泛型让运行时能针对值类型实参使用具体表示。例如,List<int> 的元素是 int,读取时不需要从 object 拆箱。接受 T 的泛型方法也常能在受约束调用中直接操作结构体。

这不是“泛型永不装箱”的保证。如果泛型代码显式把值转换成 object,或转换成需要装箱的接口引用,装箱仍会发生。判断时应寻找转换边界,性能结论则要用目标运行时上的分配分析验证。

数值类型不是一条精度阶梯

C# 的内置数值类型各自定义范围、表示方式和运算规则。更宽的类型可能容纳更大的范围,却不一定精确表示源类型的每个值。尤其是 longdouble 属于隐式转换,但大整数可能在转换时舍入。

C# 名称.NET 类型表示重点常见用途
byteSystem.Byte8 位无符号整数二进制数据
intSystem.Int3232 位有符号整数计数与索引
longSystem.Int6464 位有符号整数更大范围的整数
floatSystem.Single32 位二进制浮点数可接受较低精度的数据
doubleSystem.Double64 位二进制浮点数通用浮点计算
decimalSystem.Decimal128 位十进制表示需要十进制舍入规则的金额

这些关键字是 .NET 类型的别名,不是另外一套类型。intSystem.Int32 完全相同,选择哪种写法通常只是风格问题。API 文档常用 .NET 名称,C# 源代码则通常用关键字。

二进制浮点数不能精确表示许多十进制小数,所以 0.1 + 0.2double 结果不必等于字面量 0.3。比较测量值时,容差必须来自领域误差模型,而不是复制一个任意常数。金额通常适合 decimal,但仍需明确小数位、舍入模式和允许范围。

checked 主要改变整数算术与整数转换的溢出行为。floatdouble 运算遵循浮点规则,溢出可产生无穷值;decimal 运算超出范围时会抛出 OverflowException。审查代码时必须先确认参与运算的具体类型,再讨论溢出策略。

字面量与 var 不会取消静态类型

整数字面量会根据数值和后缀取得可容纳它的内置类型,LU 等后缀能明确意图。实数字面量默认是 doubleF 选择 floatM 选择 decimal。错误的后缀可能在表达式开始时就把运算带入另一套精度与转换规则。

var 让编译器从初始化表达式推断局部变量的静态类型。推断完成后,该变量仍然只有一个确定类型,不能随后保存不兼容的值。它与 dynamic 不同,后者把许多成员绑定与转换检查推迟到运行时。

目标类型也会影响某些表达式的解释,例如集合表达式、default 和条件表达式。遇到重载选择或转换结果意外时,应同时查看子表达式的类型和接收位置要求的类型。只看最终变量声明,可能漏掉已经发生的舍入或溢出。

相等性取决于类型契约

值类型和引用类型的分类不会独自决定 == 的含义。类可以重载相等运算符,string== 比较文本内容,记录类型也会生成值相等逻辑。没有重载时,普通类的 == 比较引用标识。

结构体从 ValueType 获得默认的字段相等行为,也可以覆盖 Equals 并定义运算符。结构体包含引用字段时,字段自身的相等规则仍会参与比较。需要把值用作字典键时,相等与哈希码必须保持一致。

装箱会再次影响所调用的契约。两个独立装箱的 int 是不同对象,所以把它们作为 object 使用 == 会比较引用并得到 false;调用 Equals 则按装箱值的类型比较。intlong 即使数值看似相同,装箱后的 Equals 也返回 false,因为值类型不同。

泛型算法通常应使用 EqualityComparer<T>.Default。它选择 T 的默认相等实现,并能在许多值类型路径上避免仅为比较而装箱。领域需要别的相等规则时,应通过明确的 IEqualityComparer<T> 传入,而不是在算法内部偷偷转换类型。

编译时类型与运行时类型各管一部分

变量的编译时类型决定哪些成员能直接调用以及重载解析使用哪些候选项。对象的运行时类型决定虚方法分派、模式匹配和引用强制转换的结果。把 string 赋给 object 不会改变对象本身,但通过该变量可见的静态成员集合变成了 object 的成员集合。

模式 value is string text 同时检查运行时类型,并在成功分支中创建静态类型为 string 的局部变量。相比先比较 GetType() 再强制转换,模式能正确表达兼容性并让编译器跟踪空状态。只有确实要求“恰好是这个运行时类型”时,才应直接比较 GetType()

dynamic 绕过部分编译时绑定,却不会取消运行时类型规则。不存在的成员、模糊的重载或无效转换会改在运行时失败。它适合少数动态互操作边界,不应当成为处理未知 JSON 或逃避泛型设计的默认容器。

默认值不等于有效领域值

default(T) 为任意 T 产生默认值。数值类型得到零,bool 得到 false,引用类型得到 null,结构体则把各字段设为各自的默认值。数组元素和未显式初始化的字段也从这些默认值开始。

这些值满足运行时初始化规则,却未必满足业务不变量。订单编号为 0、时间戳为 DateTime.MinValue 或结构体中的引用字段为 null,都可能是无效状态。值类型不能为 null,并不意味着它的所有实例都有业务意义。

类型default 结果容易误解的地方
int0零可能是合法数据,也可能表示尚未设置
boolfalse无法单独表达未知状态
stringnull即使变量注解为非可空,运行时默认值仍是 null
int?null与包含数值 0 的实例不同
自定义结构体所有字段的零初始化状态不保证满足构造函数中的验证

C# 允许结构体声明显式的无参数构造函数,但 default(MyStruct) 仍产生零初始化值,不会调用该构造函数。创建结构体数组也会先得到默认元素。这正是结构体设计必须能容忍默认状态,或在使用边界验证它的原因。

可空引用分析会对某些未初始化路径报警,但它不是运行时验证器。反射、反序列化、数组默认元素以及禁用可空上下文的旧代码仍可能带来 null。面向这些边界的类型应在构造或入口处建立不变量,而不是把注解当成数据清洗。

用额外状态表达“未知”

当零和 false 都是合法数据时,不能再借它们表示“缺失”。值类型可以用 T? 增加明确的无值状态,领域模型也可以用带名称的联合结果或状态枚举区分“未提供”“无效”和“有效”。选择取决于调用方是否只需要二元状态。

解析函数返回 decimal? 很简洁,但它把所有失败原因合并成 null。如果界面需要分别报告格式错误、负数和超范围,就应返回包含状态与错误信息的结果类型。类型应该保留调用方真正需要处理的差异,不必保留无用细节。

这个选择属于 API 契约,而不是语法偏好。先列出调用方必须区分的状态,再选择最小的类型表示。

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?