数据类型

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

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

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

trap

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

fix

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

是什么,为什么存在

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

C#类型主要分为两类: 值类型(value type) 变量直接包含一个值,引用类型变量则包含指向对象的引用。int、bool、enum和struct是值类型;class、interface、数组、委托、object与string是引用类型。

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

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

工作原理

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

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

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

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

可空值与可空引用

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

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

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

转换、强制转换与解析

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

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

引用强制转换检查对象的运行时类型,失败时抛出InvalidCastException。is模式适合同时检查和取得目标值,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副本,所以修改它不会触及original。secondCart得到的却是同一个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不是普通的int到long数值转换。正确顺序是先以(int)拆箱,再让结果隐式扩大为long。

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

陷阱

用内存位置解释类型类别

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

用!掩盖未知空状态

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

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

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

只检查最终赋值是否溢出

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

把数值转换当成拆箱

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

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

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

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

存储位置由上下文决定

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

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

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

参数传递仍然传值

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

ref、out和in参数改为传递变量存储位置的别名。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#的内置数值类型各自定义范围、表示方式和运算规则。更宽的类型可能容纳更大的范围,却不一定精确表示源类型的每个值。尤其是long到double属于隐式转换,但大整数可能在转换时舍入。

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

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

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

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

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

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

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

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

相等性取决于类型契约

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

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

装箱会再次影响所调用的契约。两个独立装箱的int是不同对象,所以把它们作为object使用==会比较引用并得到false;调用Equals则按装箱值的类型比较。int与long即使数值看似相同,装箱后的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上编辑 报告错误 讲清楚了吗?