通常来说,值类型就是字面意义上的那种值,例如整数 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