为理解可空类型是如何工作的,我们来看一看System.Nullable<T>类,它是在FCL中定义的。
[Serializable, StructLayout(LayoutKind.Sequential)]
public struct Nullable<T> where T : struct
{
//这两个字段表示状态
private Boolean hasValue = false; //假定null
internal T value = default(T); //假定所有比特都是零
public Nullable(T value)
{
this.value = value;
this.hasValue = true;
}
public Boolean HasValue { get { return hasValue; } }
public T Value
{
get
{
if (!hasValue)
{
throw new InvalidOperationException("Nullable object must have a value.");
}
return value;
}
}
public T GetValueOrDefault() { return value; }
public T GetValueOrDefault(T defaultValue)
{
if(!HasValue) return defaultValue;
return value;
}
public override Boolean Equals(object other)
{
if(!HasValue) return (other == null);
if(other == null) return false;
return value.Equals(other);
}
public override int GetHashCode()
{
if(!HasValue) return 0;
return value.GetHashCode();
}
public override string ToString()
{
if(!HasValue) return "";
return value.ToString();
}
public static implicit operator Nullable<T>(T value)
{
return new Nullable<T>(value);
}
}
可以看出,这个类封闭了一个值类型的表示,这个值类型也可能为null。由于Nullable<T>本身是一个值类型,所以它的实例仍然是“轻量级”的。也就是说,实例仍然可以在堆栈上,而且一个实例具有原始值类型的大小加上一个Boolean字段的大小。注意,Nullable的类型参数T被约束为一个struct。
如果想在代码中使用一个可空的int,可像下面这样写:
Nullable<int> x = 5;
Nullable<int> y = null;
Console.WriteLine("x: HasValue={0}, Value={1}", x.HasValue, x.Value);
Console.WriteLine("y: HasValue={0}, Value={1}", y.HasValue, y.GetValueOrDefault());
编译并运行上述代码,会得到以下输出:
x: HasValue=True, Value=5
y: HasValue=False, Value=0
C#对可空值类型的支持
C#提供了一个更清晰的语法来操纵可空值类型:
Int32? x = 5;
Int32? y = null;
在C#中,Int32?等价于Nullable<Int32>。并且,C#在此基础上更进一步,允许开发人员在可空实例上执行转换和转型。C#还允许向可空实例应用操作符:
private static void ConversionAndCasting()
{
//从非可空int转换为Nullable<int>
int? a = 5;
//从null隐式转换为Nullable<int>
int? b = null;
//从Nullable<int>显式转换为非可空in
int c = (int)a;
//可空基元类型间的转型
double? d = 5; //int->double?(d是double值5.0)
double? e = b; //int?->double?(e是null)
}
C#还允许向可空实例应用操作符,如下所示:
private static void Operators()
{
int? a = 5;
int? b = null;
//一元操作符(+ ++ = == ! ~)
a++; //a = 6
b = -b; //b = null;
//二元操作符(+ - * / % & | ^ << >>)
a = a + 3; //a=9
b = b + 3; //b = null
//相等性操作符(== !=)
if(a == null) ... else ...
if(b == null) ... else ...
if(a != b) ... else ...
//比较操作符
if(a < b) ... else ...
}
下面总结了C#如何对操作符的解释:
1. 一元操作符(+ ++ - -- ! ~)。如果操作数为null,结果为null。
2. 二元操作符(+ - * / % | ^ << >>)。两个操作数中任何一个为null,结果为null。
3. 相等性操作符(== !=)。如果两个操作数都为null,两者相等。如果一个操作数为null,则两者不相等。如果两个操作数都不为null,就对值进行比较,判断它们是否相等。
4. 比较操作符(< > <= >=)。两个操作数中任何一个为null,如果为false。如果两个操作数都不为null,就对值进行比较。
应该注意的是,在操纵可空实例时,会生成大量代码,如以下方法:
private static int? NullableCodeSize(int? a, int? b)
{
return a + b;
}
编译这个方法时,会生成相当多的IL代码。编译器生成的IL代码等价于以下的C#代码:
private static Nullable<int> NullableCodeSize(Nullable<int> a, Nullable<int> b)
{
Nullable<int> nullable1 = a;
Nullable<int> nullable2 = b;
if(!(nullable1.HasValue & nullable2.HasValue))
return new Nullable<int>();
else
return new Nullable<int>(nullable1.GetValueOrDefault() + nullable2.GetValueOrDefault());
}
C#的空接合操作符
C#提供了一个所谓的“空接合操作符”,即“??”操作符,它要获取两个操作数。假如左边的操作数不为null,就返回这个操作数的值。如果左边的操作数为null,就返回右边的操作数的值。利用空接合操作符,可方便地设置变量的默认值。
空接合操作符的一个好处在于,它既能用于引用类型,也能用于可空值类型。如下所示:
private static void NullCoalescingOperator()
{
int? b = null;
//下面这行等价于:
//x = b.HasValue ? b.Value : 123
Int32 x = b ?? 123;
Console.WriteLine(x); //"123"
//下面这行等价于:
//String temp = GetFilename();
//filename = (temp != null) ? temp : "Untitled";
String filename = GetFilename() ?? "Untitled";
}
对可空值类型进行装箱
假定有一个Nullable<int>变量,它被逻辑上设为null。假如将这个变量传给一个方法,而该方法期望的是一个object,那么必须对这个变量执行装箱,并将对已装箱的Nullable<int>的一个引用传给方法。但并不是一个理想的结果,因为方法现在是作为一个非空的值传递的,即使Nullable<int>变量逻辑上包含null值。为解决这个问题,CLR会在对一个可空变量装箱的时候执行一些特殊代码,以维护可空类型在表面上的合法地位。
具体地说,当CLR对一个Nullable<T>实例进行装箱时,它会检查它是否为null。如果是,CLR就不实际进行任何装箱操作,并会返回null值。如果可空实例不为null,CLR就从可空实例中取出值,并对其进行装箱。也就是说,一个值为5的Nullable<int>会装箱成值为5的一个已装箱Int32。如下所示:
//对Nullable<T>进行装箱,要么返回null,要么返回一个已装箱的T
int? n = null;
object o = n; //o为null
Console.WriteLine("o is null={0}", o == null); //"true"
n = 5;
o = n; //o引用一个已装箱的int
Console.WriteLine("o's type={0}", o.GetType()); //"System.Int32"
对可空值类型进行拆箱
CLR允许将一个已装箱的值类型T拆箱为一个T,或者一个Nullable<T>。假如对已装箱值类型的引用是null,而且要把它拆箱为Nullable<T>,那么CLR会将Nullable<T>的值设为null。以下代码对这个行为进行了演示:
//创建一个已装箱的int
object o = 5;
//把它拆箱为一个Nullable<int>和一个int
int? a = (int?)o; //a=5
int b = (int)o; //b=5
//创建初始化为null的一个引用
o = null;
//把它“拆箱”为一个Nullable<int>和一个int
a = (int?)o; //a=null;
b = (int)0; //NullReferenceException
将一个值类型拆箱为值类型的一个可空的版本时,CLR可能必须分配内存。这是极其特殊的一个行为,因为在其他所有情况下,拆箱永远不会导致内存的分配。如下所示:
private static void UnboxingAllocations()
{
const int count = 1000000;
//创建一个已装箱的int
object o = 5;
int numGCs = GC.CollectionCount(0);
for(int x = 0; x < count; x++)
{
int unboxed = (int)o;
}
Console.WriteLine("Number of GCs={0}", GC.CollectionCount(0) - numGCs);
numGCs = GC.CollectionCount(0);
for(int x = 0; x < count; x++)
{
int? unboxed = (int)o;
}
Console.WriteLine("Number of GCs={0}", GC.CollectionCount(0) - numGCs);
}
编译并运行这个方法,会得到以下输出:
Number of GCs=0
Number of GCs=30
所谓“拆箱”,其实就是获取对已装箱对象的未装箱部分的一个引用。现在问题在于:一个已装箱的值类型不能简单地拆箱为那个值类型的一个可空的版本,因为已装箱的值类型不包含Boolean hasValue字段。所以,将值类型拆箱为一个可空版本时,CLR必须分配一个Nullable<T>对象,将hasValue字段初始化为true,并将value字段设为和已装箱值类型中的相同的值。这会影响到应用程序的性能。
通过可空值类型来调用GetType
在一个Nullable<T>对象上调用GetType时,CLR实际会采取欺骗的手法,返回类型T,而不是返回类型Nullable<T>。如下所示:
int? x = 5;
Console.WriteLine(x.GetType()); //输出System.Int32
通过可空值类型调用接口方法
下面的代码中,将一个Nullable<int>类型的变量n转型为一个IComparable<int>,也就是一个接口类型。然而,Nullable<T>不像int那样实现了ICompar-able<int>接口。C#编译器允许这样的代码通过编译,且CLR的校验器会认为这样的代码是可验证的,从而允许我们使用这种更简洁的语法:
int? n = 5;
int result = ((IComparable)n).CompareTo(5); //能顺利编译和运行
Console.WriteLine(result);
如果CLR没有提供这一特殊支持,那就必须对已拆箱的值类型进行转型,然后才能转型成接口以发出调用:
int result = ((IComparable)(int)n).CompareTo(5);
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· .NET周刊【3月第1期 2025-03-02】
· [AI/GPT/综述] AI Agent的设计模式综述