匹夫细说C#:可以为null的值类型,详解可空值类型

2015年09月29日 11:29 0 点赞 0 评论 更新于 2025-11-21 19:06

0x00 前言

C# 是一种强类型语言,在 Unity3D 游戏开发中,掌握好 C# 的类型特性至关重要。这一特性贯穿编程的诸多方面,本文要介绍的可空值类型就是一个很好的例子。我们将探讨可空值类型出现的原因、意义,以及如何在 Unity3D 游戏开发中使用它。接下来,请带着这些疑问,从下文寻找答案。

0x01 如果没有值?

熟悉 C# 基础知识的人都知道,值类型变量永远不会为 null,因为值类型的值就是其本身;而引用类型变量的值是对一个对象的引用。那么,空引用是表示一个值,还是表示没有值呢?若表示没有值,没有值能否算作一种有效的值?根据引用类型的定义,非空引用提供了访问对象的途径,空引用(null)也是一个特殊的值,表示该变量未引用任何对象。null 和其他引用的处理方式相同,在内存中以全 0 表示,这是因为这种操作开销最低,只需清除一块内存,这也是所有引用类型实例默认值为 null 的原因。

然而,值类型的值永远不能为 null,但在开发中,我们可能会遇到需要让值类型变量的值既不是负数也不是 0,而是真正不存在的情况,这种情况很常见。

最常见的情况之一是在设计数据库时,允许将一列的数据类型定义为 32 位整数,对应 C# 中的 Int32 类型。但数据库中的列可能存在空值,即某一行可能没有任何值,既不是 0 也不是负无穷,而是实实在在的空。这会带来诸多隐患,使 C# 处理数据库变得困难,因为在 C# 中无法将值类型表示为空。

还有其他情况,例如在开发手机游戏时,通过移动手指滑动选择区域内的游戏单位,一次拖动完成后,需要清空本次拖动的数据,以便开始下一次拖动。而这些拖动数据在 Unity3D 脚本语言中通常是值类型,无法直接设为空,给开发带来不便。

那么,如果没有让值类型直接表示空的方法,是否有其他手段实现类似功能呢?下面我们来探讨在没有可空类型的情况下,如何在逻辑上近似实现值类型表示空的功能。

0x02 表示空值的一些方案

假设没有直接表示空值的方案,我们可以考虑一些替代方案。本节将归纳三种表示空值的方案。

1. 使用魔值

值类型的值都有其本身的意义,而魔值方案违背了这一原则。魔值是指放弃一个有意义的值,用它来表示空值。例如,选择 -1000 作为魔值,那么 -1000 就不再表示 -1000,而是意味着空。

在数据库中,如果某列映射为 Int32 类型且存在空值,我们可以选择一个恰当的值作为魔值来表示空。这种方法的好处是不浪费内存,也无需定义新类型。但选择哪个值作为魔值需要慎重考虑,因为一旦做出选择,就意味着一个有意义的值的消失。而且,这种方案在实际开发中显得不够优雅,只是一种暂时的解决办法,并没有真正解决问题。

2. 使用标志位

如果不想牺牲一个有意义的值作为魔值,仅用一个值类型实例是不够的。此时,可以使用额外的 bool 型变量作为标识,来判断对应的值类型实例是否为空值。以下是具体代码示例:

using UnityEngine;
using System;
using System.Collections.Generic;

public class Example : MonoBehaviour {
private float _realValue;
private bool _nullFlag;

private void Update() {
this._realValue = Time.time;
this._nullFlag = false;
this.PrintNum(this._realValue);
}

private void LateUpdate() {
this._nullFlag = true;
this.PrintNum(this._realValue);
}

private void Start () {
}

private void PrintNum(float number) {
if (this._nullFlag) {
Debug.Log("传入的数字为空值");
return;
}
Debug.Log("传入的数字为:" + number);
}
}

在这段代码中,我们维护了两个变量:float 型的 _realValue 表示所需的值,bool 型的 _nullFlag 标识 _realValue 是否为空值(_realValue 本身不可能为空)。

这种使用额外标识的方法比魔值方案更好,因为没有牺牲有意义的值。但同时维护两个变量,且它们关联性强,容易产生 bug。我们可以将这两个值类型封装到一个新的值类型中,定义如下:

using System;
using System.Collections;
using System.Collections.Generic;

public struct NullableValueStruct {
private float _realValue;
private bool _nullFlag;

public NullableValueStruct(float value, bool isNull) {
this._realValue = value;
this._nullFlag = isNull;
}

public float Value {
get {
return this._realValue;
}
set {
this._realValue = value;
}
}

public bool IsNull {
get {
return this._nullFlag;
}
set {
this._nullFlag = value;
}
}
}

下面是使用这个新值类型的代码示例:

using UnityEngine;
using System;
using System.Collections.Generic;

public class Example : MonoBehaviour {
private NullableValueStruct number = new NullableValueStruct(0f, false);

private void Update() {
this.number.Value = Time.time;
this.number.IsNull = false;
this.PrintNum(this.number);
}

private void LateUpdate() {
this.number.IsNull = true;
this.PrintNum(this.number);
}

private void Start () {
}

private void PrintNum(NullableValueStruct number) {
if (number.IsNull) {
Debug.Log("传入的数字为空值");
return;
}
Debug.Log("传入的数字为:" + number.Value);
}
}

3. 借助引用类型来表示值类型的空值

既然值类型不能为 null,而引用类型可以,那么可以借助引用类型辅助值类型表示 null。具体有两种解决思路。

第一种思路是使用 System.Object 类代替值类型。因为 C# 中所有类型都派生自 System.Object 类,虽然值类型不能为 null,但 System.Object 类可以。以下是示例代码:

using UnityEngine;
using System;
using System.Collections.Generic;

public class Example : MonoBehaviour {
private void Update() {
this.PrintNum(Time.time);
}

private void Start () {
}

private void PrintNum(object number) {
if (number == null) {
Debug.Log("传入的数字为空值");
return;
}
float realNumber = (float)number;
Debug.Log("传入的数字为:" + realNumber);
}
}

这种方式会频繁进行引用类型(System.Object)和值类型的转换,涉及大量的装箱和拆箱操作,会产生很多垃圾,触发垃圾回收机制,影响游戏性能。

第二种思路是将值类型封装成引用类型。我们创建一个新的引用类型 NullableValueType,在其内部保留一个值类型的实例,该实例的值就是 NullableValueType 类所表示的值,当需要表示空值时,让 NullableValueType 类的实例为 null。以下是 NullableValueType 类的定义:

using System;
using System.Collections;
using System.Collections.Generic;

public class NullableValueType {
private float _value;

public NullableValueType(float value) {
this._value = value;
}

public float Value {
get {
return this._value;
}
set {
this._value = value;
}
}
}

下面是使用这种封装方式的代码示例:

using UnityEngine;
using System;
using System.Collections.Generic;

public class Example : MonoBehaviour {
private NullableValueType value;

private void Update() {
this.value.Value = Time.time;
this.PrintNum(this.value);
}

private void Start () {
this.value = new NullableValueType(0f);
}

private void PrintNum(NullableValueType number) {
if (number == null) {
Debug.Log("传入的数字为空值");
return;
}
Debug.Log("传入的数字为:" + number.Value);
}
}

这种方法的优点是无需进行引用类型和值类型之间的转换,能缓解装箱和拆箱操作的频率,减少垃圾产生。但缺点也很明显,使用引用类型封装值类型本质上是重新定义了一个新的类型,会增加代码量和维护成本。

0x03 使用可空值类型

通过上一节的内容,我们发现自己解决值类型空值问题的方案都存在各种问题。为了解决这个问题,C# 引入了可空值类型的概念。在介绍如何使用可空值类型之前,我们先来看看基础类库中定义的结构 System.Nullable<T>

using System;

namespace System {
using System.Globalization;
using System.Reflection;
using System.Collections.Generic;
using System.Runtime;
using System.Runtime.CompilerServices;
using System.Security;
using System.Diagnostics.Contracts;

[TypeDependencyAttribute("System.Collections.Generic.NullableComparer`1")]
[TypeDependencyAttribute("System.Collections.Generic.NullableEqualityComparer`1")]

[Serializable]

public struct Nullable<T> where T : struct {
private bool hasValue;
internal T value;

#if !FEATURE_CORECLR
[TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")]
#endif

public Nullable(T value) {
this.value = value;
this.hasValue = true;
}

public bool HasValue {
get {
return hasValue;
}
}

public T Value {
#if !FEATURE_CORECLR
[TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")]
#endif

get {
if (!HasValue) {
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_NoValue);
}
return value;
}
}

#if !FEATURE_CORECLR
[TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")]
#endif

public T GetValueOrDefault() {
return value;
}

public T GetValueOrDefault(T defaultValue) {
return HasValue ? value : defaultValue;
}

public override bool Equals(object other) {
if (!HasValue) return other == null;
if (other == null) return false;
return value.Equals(other);
}

public override int GetHashCode() {
return HasValue ? value.GetHashCode() : 0;
}

public override string ToString() {
return HasValue ? value.ToString() : "";
}

public static implicit operator Nullable<T>(T value) {
return new Nullable<T>(value);
}

public static explicit operator T(Nullable<T> value) {
return value.Value;
}
}

[System.Runtime.InteropServices.ComVisible(true)]

public static class Nullable {
[System.Runtime.InteropServices.ComVisible(true)]
public static int Compare<T>(Nullable<T> n1, Nullable<T> n2) where T : struct {
if (n1.HasValue) {
if (n2.HasValue) return Comparer<T>.Default.Compare(n1.value, n2.value);
return 1;
}
if (n2.HasValue) return -1;
return 0;
}

[System.Runtime.InteropServices.ComVisible(true)]

public static bool Equals<T>(Nullable<T> n1, Nullable<T> n2) where T : struct {
if (n1.HasValue) {
if (n2.HasValue) return EqualityComparer<T>.Default.Equals(n1.value, n2.value);
return false;
}
if (n2.HasValue) return false;
return true;
}

// If the type provided is not a Nullable Type, return null.
// Otherwise, returns the underlying type of the Nullable type
public static Type GetUnderlyingType(Type nullableType) {
if ((object)nullableType == null) {
throw new ArgumentNullException("nullableType");
}
Contract.EndContractBlock();
Type result = null;
if (nullableType.IsGenericType && !nullableType.IsGenericTypeDefinition) {
// instantiated generic type only
Type genericType = nullableType.GetGenericTypeDefinition();
if (Object.ReferenceEquals(genericType, typeof(Nullable<>))) {
result = nullableType.GetGenericArguments()[0];
}
}
return result;
}
}
}

System.Nullable<T> 结构的定义可以看出,它可以表示为 null 的值类型。System.Nullable<T> 本身是值类型,其实例分配在栈上,是“轻量级”实例,且实例大小与原始值类型基本一致,只是多了一个 bool 型字段。System.Nullable 的类型参数 T 被约束为结构 struct,无需考虑引用类型的情况,因为引用类型变量本身可以为 null。

以下是使用可空值类型的示例代码:

using UnityEngine;
using System;
using System.Collections;

public class NullableTest : MonoBehaviour {
void Start () {
Nullable<Int32> testInt = 999;
Nullable<Int32> testNull = null;
Debug.Log("testInt has value :" + testInt.HasValue);
Debug.Log("testInt  value :" + testInt.Value);
Debug.Log("testInt  value :" + (Int32)testInt);
Debug.Log("testNull has value :" + testNull.HasValue);
Debug.Log("testNull value :" + testNull.GetValueOrDefault());
}

void Update () {
}
}

运行这个游戏脚本,在 Unity3D 的调试窗口会输出以下内容:

testInt has value :True
UnityEngine.Debug:Log(Object)
testInt  value :999
UnityEngine.Debug:Log(Object)
testNull has value :False
UnityEngine.Debug:Log(Object)
testNull value :0
UnityEngine.Debug:Log(Object)

对这段代码进行分析,我们可以发现存在两个转换。第一个转换是 T 到 Nullable<T> 的隐式转换,转换后,Nullable<T> 实例的 HasValue 属性被设置为 true,Value 属性的值就是 T 的值。第二个转换是 Nullable<T> 显式转换为 T,这个操作和直接访问实例的 Value 属性效果相同,但需要注意的是,在没有真正的值可供返回时会抛出异常。为避免这种情况,Nullable<T> 引入了 GetValueOrDefault 方法,当 Nullable<T> 实例有值时,返回该值;当实例没有值时,返回一个默认值。该方法有两个重载,一个不需要参数,另一个可以指定要返回的默认值。

0x04 可空值类型的简化语法

C# 引入可空值类型的概念方便了我们处理值类型为空的情况,但如果只能使用上述示例的形式,会显得有些繁琐。好在 C# 允许使用更简单的语法来初始化 System.Nullable<T> 变量。C# 的开发团队希望将可空值类型集成到 C# 语言中,因此可以使用问号“?”来声明并初始化可空值类型变量。以下是使用简化语法的示例代码:

using UnityEngine;
using System;
using System.Collections;

public class NullableTest : MonoBehaviour {
void Start () {
Int32? testInt = 999;
Int32? testNull = null;
Debug.Log("testInt has value :" + testInt.HasValue);
Debug.Log("testInt  value :" + testInt.Value);
Debug.Log("testNull has value :" + testNull.HasValue);
Debug.Log("testNull value :" + testNull.GetValueOrDefault());
}

void Update () {
}
}

其中,Int32?Nullable<Int32> 的简化语法,它们是等价的。

此外,C# 语言还允许对可空值类型的实例执行转换和转型操作。以下是相关示例代码:

using UnityEngine;
using System;
using System.Collections;

public class NullableTest : MonoBehaviour {
void Start () {
// 从正常的不可空的值类型 int 隐式转换为 Nullable<Int32>
Int32? testInt = 999;
// 从 null 隐式转换为 Nullable<Int32>
Int32? testNull = null;
// 从 Nullable<Int32> 显式转换为不可空的值类型 Int32
Int32 intValue = (Int32) testInt;
}

void Update () {
}
}

C# 语言还允许可空值类型的实例使用操作符。以下是使用操作符的示例代码:

using UnityEngine;
using System;
using System.Collections;

public class NullableTest : MonoBehaviour {
void Start () {
Int32? testInt = 999;
Int32? testNull = null;

// 一元操作符 (+ ++ - -- ! ~)
testInt ++;
testNull = -testNull;

// 二元操作符 (+ - * / % & | ^ << >>)
testInt = testInt + 1000;
testNull = testNull * 1000;

// 相等性操作符 (== !=)
if (testInt != null) {
Debug.Log("testInt is not Null!");
}
if (testNull == null) {
Debug.Log("testNull is Null!");
}

// 比较操作符 (< > <= >=)
if (testInt > testNull) {
Debug.Log("testInt larger than testNull!");
}
}

void Update () {
}
}

C# 解析操作符的规则如下:

  • 一元操作符(+、++、-、--、!、~):如果操作数是 null,则结果为 null。
  • 二元操作符(+、-、*、/、%、&、|、^、<<、>>):如果两个操作数中有一个为 null,则结果为 null。
  • 相等操作符(==、!=):当两个操作数都是 null 时,两者相等;如果只有一个操作数是 null,则两者不相等;若两者都不是 null,则比较值来判断是否相等。
  • 关系操作符(<、>、<=、>=):如果两个操作数中有一个是 null,结果为 false;如果两个操作数都不是 null,则比较值。

C# 还提供了“??”操作符,称为“空接合操作符”。该操作符获取两个操作数,若左边操作数不是 null,则返回左边操作数的值;若左边操作数是 null,则返回右边操作数的值。“??”操作符为变量设置默认值提供了便捷的语法,它既可以用于引用类型,也可以用于可空值类型,并非简单的语法糖,而是在语法上有很大改进。以下是使用“??”操作符的示例代码:

using UnityEngine;
using System;
using System.Collections;

public class NullableTest : MonoBehaviour {
void Start () {
Int32? testNull = null;
// 这行代码等价于:
// testInt = (testNull.HasValue) ? testNull.Value : 999;
Int32? testInt = testNull ?? 999;
Debug.Log("testInt has value :" + testInt.HasValue);
Debug.Log("testInt  value :" + testInt.Value);
Debug.Log("testNull has value :" + testNull.HasValue);
Debug.Log("testNull value :" + testNull.GetValueOrDefault());
}

void Update () {
}
}

将这个游戏脚本加载到游戏场景中运行,在 Unity3D 编辑器的调试窗口会输出和之前相同的内容。

“??”操作符的优点还体现在对表达式的支持和简化复合情景中的代码。例如,获取游戏中英雄的名称,若获取不到正确名称,则使用默认名称:

Func<string> heroName = GetHeroName() ?? "DefaultHeroName";
string GetHeroName() {
//TODO
}

如果不使用“??”操作符,仅通过 lambda 表达式实现相同需求会变得繁琐:

Func<string> heroName = () => { var tempName = GetHeroName();
return tempName != null ? tempName : "DefaultHeroName";
}
string GetHeroName() {
//TODO
}

在复合操作中,如获取游戏单位的名称,需要分别查询英雄和士兵的名称,若查询结果都不可用,则返回默认名称:

string unitName = GetHeroName() ?? GetSoldierName() ?? "DefaultUnitName";
string GetHeroName() {
//TODO
}
string GetSoldierName() {
//TODO
}

若不使用“??”操作符,代码会变得复杂:

string unitName = String.Empty;
string heroName = GetHeroName();
if (heroName != null) {
unitName = heroName;
} else {
string soldierName = GetSoldierName();
if (soldierName != null) {
unitName = soldierName;
} else {
unitName = "DefaultUnitName";
}
}
string GetHeroName() {
//TODO
}
string GetSoldierName() {
//TODO
}

可见,“??”操作符不仅仅是简单的三元操作的简化语法糖,而是在语法逻辑上有重大改进。

此外,在 C#2 之前,“as”操作符只能作用于引用类型,随着可空值类型的出现,它也可以作用于可空值类型。因为可空值类型为值类型引入了空值的概念,符合“as”操作符的需求,其结果可以是可空值类型的某个值,包括空值和有意义的值。以下是使用“as”操作符的示例代码:

using UnityEngine;
using System;
using System.Collections;

public class NullableTest : MonoBehaviour {
void Start () {
this.CheckAndPrintInt(999999999);
this.CheckAndPrintInt("九九九九九九九九九");
}

void Update () {
}

void CheckAndPrintInt(object obj) {
int? testInt = obj as int?;
Debug.Log(testInt.HasValue ? testInt.Value.ToString() : "输出的参数无法转化为 int");
}
}

运行这个脚本,在 Unity3D 的调试窗口会输出:

999999999
UnityEngine.Debug:Log(Object)
输出的参数无法转化为 int
UnityEngine.Debug:Log(Object)

通过“as”操作符,我们优雅地实现了将引用转换为值的操作。

0x05 可空值类型的装箱和拆箱

可空值类型 Nullable<T> 是一个结构,属于值类型。因此,当代码中涉及将可空值类型转换为引用类型(如转换为 object)时,装箱操作不可避免。

普通的值类型不能为 null,装箱后的值也不为空,但可空值类型可以表示空值,那么装箱后如何正确表示呢?由于可空值类型的特殊性,Mono 运行时在进行可空值类型的装箱和拆箱操作时,有特殊行为:如果 Nullable<T> 的实例没有值,它会被装箱为空引用;如果 Nullable<T> 的实例有值,则会被装箱成 T 的一个已经装箱的值。

将已经装箱的值进行拆箱操作时,该值可以拆箱为普通类型(T)或对应的可空值类型(Nullable<T>)。需要注意的是,对空引用进行拆箱操作时,如果要拆箱成普通的值类型 T,运行时会抛出 NullReferenceException 异常,因为普通的值类型没有空值的概念;而如果拆箱成恰当的可空值类型,结果将是一个没有值的可空值类型实例。

以下是演示可空值类型装箱和拆箱操作的代码:

using UnityEngine;
using System;
using System.Collections;

public class NullableTest : MonoBehaviour {
void Start () {
// 从正常的不可空的值类型 int 隐式转换为 Nullable<Int32>
Int32? testInt = 999;
// 从 null 隐式转换为 Nullable<Int32>
Int32? testNull = new Nullable<int>();
object boxedInt = testInt;
Debug.Log("不为空的可空值类型实例的装箱:" + boxedInt.GetType());
Int32 normalInt = (int) boxedInt;
Debug.Log("拆箱为普通的值类型 Int32:" + normalInt);
testInt = (Nullable<int>) boxedInt;
Debug.Log("拆箱为可空值类型:" + testInt);
object boxedNull = testNull;
Debug.Log("为空的可空值类型实例的装箱:" + (boxedNull == null));
testNull = (Nullable<int>) boxedNull;
Debug.Log("拆箱为可空值类型:" + testNull.HasValue);
}

void Update () {
}
}

在这段代码中,我们演示了将不为空的可空值类型实例装箱后的值分别拆箱为普通的值类型和可空值类型,以及将没有值的可空值类型实例装箱为空引用,再拆箱为没有值的可空值类型实例。如果直接将空引用拆箱为普通的值类型,编译器会抛出 NullReferenceException 异常,有兴趣的读者可以自行尝试。

作者信息

洞悉

洞悉

共发布了 3994 篇文章