C#/.NET值类型

值类型 (Value Type) 包括两个成员:结构体和枚举类型。

通常来说,值类型就是字面意义上的那种值,例如整数 int,小数 float/double,布尔值等。

而实际上,整数、小数,布尔值等全部都是结构体。

值类型的默认值一般为 0,例如整数和小数的默认值都是 0,枚举类型的默认值也为 0, char 的默认值为 ‘/0’。和引用类型相比,值类型的内存分配简单得多。

基元类型

之前讲过,C# 和其他 .NET 语言都是运行在通用类型系统(CTS)上的,而 CTS 提供 一些“基本的”类型一基元类型(Primitive Type)。

各个 .NET 语言分别使用不同的关键字, 但最终它们都会被映射到同一个 IL 类型。这样的类型就叫做基元类型,它们由 CTS 定义,由编译器与 BCL 直接支持,属于 BCL 而非任何某个语言。

基元类型包括了几乎所有的值类型(除了用户定义的结构体和枚举)以及字符串,object 和 dynamic。

Primitive 有原始的意思,可以将基元类型理解为基本的、原始的类型,少了它们就什么都做不了。

有了基元类型,各个 .NET 语言的互操作性就可以实现了,例如,通过 ildasm 工具,我们可以查看到 int i=1 对应的 IL 代码为(这里省略了赋值的那一句 IL代码):

.locals init ([0] int32 i)

这说明了在 IL 中 int 对应的基元类型为 Int32。当然,在 C# 中也可以直接写 Int32 i=1,不过,这样并不会给你带来任何好处。

而对于VB.NET,int 的关键字为 Integer,如果你在 VB.NET 中声明了一个Integer,你也可以通过 ildasm 发现,它对应的类型仍然为 Int32。

值类型的内存分配

值类型的内存分配分为以下几种情况:

  • 值类型作为局部变量。
  • 值类型作为引用类型的成员。
  • 值类型中包含引用类型。

1) 值类型作为局部变量

普通的值类型总是分配在栈上。例如以最简单的 int 为例,inti=1 意味着我们在栈上开辟了一块空间存储这个值类型。

注意,int 实际上是一个结构体,它有 2 个值类型成员(最大值,最小值),它们是常量,所以是静态的(const=static readonly)。

静态的成员和 int 的方法均存储在加载堆中。

值类型也没有同步块索引和类型对象指针。所以,新建一个 int,不会重新复制它的最大值和最小值,int 的开销永远是 4 个字节(就是它自己)。

即使机器是 64 位机,int 的大小永远是 32 位,因为 int 实质上是 Int32。Int64 这个基元类型在 C# 中对应 long。

对于局部变量的复制来说,情况非常简单。我们知道,值类型复制时,将只复制值的副本。所以更改原值对复制的新值不会有影响。

var i = 1;
var j = i;
i = 2 ;
//输出1
Console.WriteLine(j);

当执行代码var j = i时,将会在栈上新建一个名为 j 的变量,然后将 i 的值复制给 j,它和 i 没有任何关系。值类型也不可能有浅复制。

2) 值类型作为引用类型的成员

如果值类型为引用类型的成员,则遵从引用类型的内存分配和复制方式。例如:

public class AClass
{
    public int a;
    public string b;
}

在创建一个该类的实例时,遵从引用类型的内存分配方式。

下面的代码会实例化一个 AClass 对象:

var a = new AClass();
a. a = 1;
a.b = "hey";

执行完上面的代码之后,内存的分配如下图所示。

值类型作为引用类型的成员

其中,托管堆的方法表指针还指向 AClass 的类型对象,其位于加载堆中,图上没有画出来。

3) 值类型中包含引用类型

如果一个结构体中包含了引用类型(例如结构体),例如包含了一个字符串或者类,则它引用类型的那部分会遵从引用类型创建的内存分配,值类型的那部分则遵从值类型创建的内存分配。

如果我们有这样的结构体:

publie class AClass
{
    public int a;
    public string b;
}
public struct AStruct
{
    public AClass ac;
    public double c;
}

则新建这个值类型的实例时,它的引用类型成员遵循引用类型成员分配的机制,值类型成员则储存在栈上。

var a = new AStruct();
a.ac = new AClass();
a.c = 1;
a.ac.a = 2 ;
a.ac.b = "hey";

在运行完上面的代码之后,内存中的情况如下图所示。

值类型中包涵引用类型

为了看得更清楚,我们复制它,然后试图更改其成员的值:

var b = a;
b.c = 999;
b.ac.a = 888;
b.ac.b = "bye";
Console.WriteLine(a.c); //1
Console.WriteLine(a.ac.a); //888
Console.WriteLine(a.ac.b); //bye

此时内存的情况,如下图所示。

值类型中包涵引用类型

通过下面的代码,我们可以证明 a.ac 和 b.ac 指向同一个对象,而 a.c 和 b.c 没有关系:

Console.WriteLine(ReferenceEquals(a.ac, b.ac));  // True
Console.WriteLine(ReferenceEquals(a.c, b.c));   // False

我们可以发现,即使值类型复制可以保证值类型中的值类型成员相互无关,它内部的引用类型仍然是浅复制。

值类型的构造函数

可以为值类型和引用类型定义构造函数。但对于值类型,在构造函数中必须为所有的成员赋值,不支持无参构造函数。

例如 DateTime 类型,其为一个结构体,所以是值类型。它的成员有年、月、日、时、分、秒、DayOfWeek(代表星期几)等。

它有很多个构造函数,每个都拥有一至多个输入。例如:

public DateTime(int year, int month, int day);

虽然在这个构造函数中,我们没有传入 DayOfWeek,但在构造函数中,C# 会算出 DayOfWeek。

因此,我们可以访问到 2017 年 9 月 8 日的星期信息:

var a = new DateTime( 2017, 9, 8);
Console.WriteLine( a.DayOfWeek); // Friday

如果没有自定义构造函数,C# 默认会生成一个无参构造函数,它遍历类型中所有的成员,并将它们设置为默认值,如默认值表所列。

值类型 默认值
bool false
byte 0
char '/0'
decimal 0M
double 0.0D
enum 表达式 (E)0 生成的值,其中 E 是枚举标识符。
float 0.0F
int 0
long 0L
sbyte 0
short 0
struct 通过如下设置生成的值:将所有值类型的字段设置为其默认值,将所有引用类型的字段设置为 null。
uint 0
ulong 0
ushort 0

何时考虑使用值类型

人们通常将值类型认为是轻量级的引用类型,设计它的目的是提高程序的性能。

如果所有类型都是引用类型,则性能将大大下降,这是因为:

每次新声明变量都要建立类型对象指针和同步块索引。
内存分配必定牵扯到堆,增加 GC 的压力。

当结构体的全部属性都是值类型时,结构体不会和堆扯上关系(例如,int 就是这样的结构体),这样做可以减轻垃圾收集器的压力。

另外,选择结构体而不是类,会在初始化时提高性能,因为不需要初始化类的两个“标准配置”,即类型对象指针和同步块索引。

因此,对于一些常见的轻量级的类型,可以考虑选择结构体。

最常见的结构体莫非 int, DateTime 莫属。这里我们就拿 DateTime 做个例子。

DateTime 的属性都是值类型,其中包括 int、DateTime、TimeSpan、long 和几个枚举。

而且在创建 DateTime 时要给所有属性赋予一个有意义的值,比如 DayOfWeek、DayOfYear 等。

当你初始化一个 DateTime 时,通常都是传入年月日,系统负责将其他属性初始化,例如计算出 DayOfWeek 等。

所以,以下情况适合使用结构体:

  • 当对象的所有属性都需要在创建之初即赋值时。
  • 当对象的全部属性都是值类型时(如果存在引用类型,就会牵扯到内存分配到堆上的问题,就无法减轻垃圾收集的压力了)。
  • 当对象不需要被继承时。

例如,二维坐标系(包括两个 double),长方形(包括长、宽、面积等一些 double)这样的对象,适合使用结构体。

值类型是密封的

值类型一定是密封的,不支持继承。之所以这样设计,是因为如果值类型可以被其他类型继承(尤其是引用类型),那么它的创建就会牵扯到堆上的内存分配,这违反了设计值 类型的初衷。

值类型和引用类型的区别与联系

值类型和引用类型的区别主要有:

1) 所有值类型隐式派生自 System.ValueType。该类确保值类型所有的成员全部分配在 栈上。有三个例外:

  • 结构体如果含有引用类型成员,则该成员也会牵扯到堆的分配。
  • 静态类型,如果一个变量是静态的,则无论它是什么类型,都会分配在加载堆上。
  • 局部变量被捕获升级为密封类,这个现象在闭包中会讲到。

所以,“值类型都分配在栈上,引用类型都分配在堆上”这样的说法并不准确。

2) 引用类型的初值为 null。值类型则是 0,因为字符串的初值为 null,故字符串为引用类型。

3) 对于引用类型,栈中会有一个变量名和变量类型,指向堆中对象实例的地址。值类 型仅有栈中的变量名和类型,不包括指向实例的指针。

4) 值类型不能被继承,引用类型则可以。典型的例子是结构体,它是值类型,所以结 构体不能被继承。但结构体里面可以包括引用类型。值类型也可以有自己的方法,例如 Int.TryParse 方法。

5) 值类型的生命周期是其定义域。当值类型离开其定义域后将被立刻销毁。引用类型则会进入垃圾回收分代算法。我们不知道何时才会销毁。

6) 值类型的构造函数必须为所有成员赋值。

7) 可以重写引用类型的析构函数。值类型不需要析构函数,因为析构函数只会被垃圾收集器调用。

8) 值类型没有同步块索引,不能作为线程同步工具。

值类型和引用类型的的联系主要有:

  • 值类型和引用类型可以通过装箱和拆箱互相转化。
  • 所有值类型都派生自 System.ValueType,它是 System.Object 的子类。
  • 类和结构体都可以实现接口。类实现接口的例子就不说了,结构体例如 int,DateTime 等都实现了 IComparable 接口,使得它们可以比较大小。

嵌套:值类型与引用类型

我们都知道链表是这样定义的:

publie class LinkedList<T>
{
    public T data { get;  set;  }
    public LinkedList<T> next { get;  set;  }
}

现在问题来了,假设 T 是 int,那么在 32 位机器上 new 这个对象时,需要在堆上开辟多少字节内存?既然 LinkedList<T> 是一个类,那么,next 属性是一个引用类型。

当 new 这个对象时,我们需要 4 个字节的 int 以及一个地址,而 32 位操作系统的地址是 4 个字节。

所以,对于 32 位操作系统,创建一个 LinkedList<T> 需要堆上的 8 个字节,还有类的两个 标配一类型对象指针和同步块索引,共 16 字节。

假设我们将上面代码中的 class 改为 struct,其他完全不变,会发生什么?结论是编译不通过。

因为对于一个结构,CLR 无法计算出新建这个结构时,需要多少字节分配给栈。

这是因为,此时 next 属性不再是一个引用类型,而是一个值类型,所以它的大小取决于它所有的成员大小之和(data 和 next),而它的成员又包括它自己,所以构成无限循环。

而对于引用类型,栈上只需要一个 4 字节的地址,所以不会循环下去。

原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/tech/pnotes/22363.html

(0)
上一篇 2021年7月20日 10:37
下一篇 2021年7月20日 10:37

相关推荐

发表回复

登录后才能评论