类型的分类
值类型和引用类型的主要区别在于复制策略的不同,这就造成了在内存中以不同的方式存储。
值类型
值类型的变量直接包含值,变量引用的位置就是值在内存中的实际存储位置。值类型变量之间进行赋值时,会在新变量的位置创建一个内存副本,不可能引用同一个副本(除非其中一个时out或ref参数,根据定义,表示这个参数是另一个变量的别名)
创建的值类型通常不要大于16字节,这样复制效率不如引用类型
值类型的值一般都是短时间存在的,大多情况下只是作为表达式的一部分或者是用于激活方法。在这种情况下,值类型的变量和临时值通常是存储在被称为“栈”的临时存储池中,他的清理代价低于需要进行垃圾回收的堆
引用类型
他的值是对一个对象实例的引用,
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ukrWYz3N-1657464445064)(C# 再述值类型/20200928233153432-16574464217701.png)]
结构 Struct
除了string和object外,所有C#内建类型都是值类型
对于使用值类型的一种良好规范是确保值类型不可变,一旦实例化了值类型就不能修改它。要修改就只能创建新的一个实例。
首先,在做整数之间的加法时,没有人会期望其中的值是会变化的,因此加数是不可变的,生成一个新的数作为返回结果
其次,因为值类型是按照值复制的,我们有时会被迷惑,认为一个值类型变量的变化在另一个变量上也会被观察到
我们要创建不可变的值类型
- 结构包含属性、字段,还可以包含方法和构造函数(可以包含静态成员),但不能定义无参的构造函数,只能用系统默认的无参构造函数。
- 结构中不能显式的在字段声明中初始化字段,只能通过构造函数来赋值,或者不赋值隐式的使用字段默认值。当然也可以通过只读自动属性来赋默认值——在初始化好所有字段之前,访问this是违法的(尤其注意省略掉
this.
的用法),需要直接初始化字段 - 值类型必须显式的初始化来避免编译时错误,结合第2条,值类型最好通过只读自动属性来初始化字段
- 所有值类型都是隐式密封的,所以值类型不能被继承,值类型只能继承自
System.ValueType
,当然值类型也能实现接口,例如一些比较的接口。
//这表示高精度的角,使用度、分、秒表示
stuct Angle{
public Angle(int degrees,int minutes,int seconds){
Degrees = degress;
Minutes = minutes;
Seconds = seconds;
}
public int Degrees{get;}
public int Minutes{get;}
public int Seconds{get;}
public Angle Move(int degrees,int minutes,int seconds){
return new Angle(
Degrees + degress,
Minutes + minutes,
Seconds + seconds,
)
}
}
所有属性都是用只读创建。
为值类型使用new
操作符会造成运行时在临时存储池中创建对象的新实例,并将所有字段初始化为默认值,再调用构造器(将临时存储位置作为ref变量以this传递给构造器),使其复制到最终目的地。
为引用类型使用new操作符,“运行时”会在托管堆上创建对象的新实例,将所有字段初始化为默认值,再调用构造函数,将对实例的引用以this的形式传递。new操作符最后返回对实例的引用,该引用被拷贝到和变量关联的内存位置。
结构并不支持终结器,因为难以知道什么时候能安全执行终结器并释放结构占用的非托管资源,垃圾回收器知道在什么时候没有了对引用类型实例的“活动”引用,可在此之后的任何时间运行终结器。但“运行时”没有任何机制能跟踪值类型在特定时刻有多少个拷贝。
装箱与拆箱
那么如果把值类型转换为它实现的某个接口或者object会怎么样,就像把int类型转为object类型会发生什么呢?转换的结果是对一个存储位置的引用,表面上包含引用类型的实例,实际上包含值类型的值。这种转换被称为装箱转换,一般分为以下几个步骤:
- 首先在堆上分配内存。它将用于存放值类型的数据以及少许额外开销(
SyncBlockIndex
和方法表指针)。需要这些开销,对象才能看起来像引用类型的托管对象实例。 - 接着发生一次内存拷贝动作,当前存储位置的值类型数据拷贝到堆上分配好的位置。
- 最后,转换结果是对堆上的新存储位置的引用。
相反的过程称为拆箱(unboxing):
- 首先核实已装箱值的类型兼容于要拆箱成的值的类型,也就是说装箱后虽然是object,但它本质是int,所以也是兼容于int类型的。
- 然后拷贝堆中存储的值
- 返回结果是堆上存储的值的拷贝。
性能问题
private static void Main()
{
List<double> list = new List<double>();
//1,装箱,将值类型装箱为object类型
list.Add((double)0);
list.Add((double)1);
for (int count = 2; count < list.Count; count++)
{
//2,先拆箱后装箱,object拆箱为double计算后再装箱到list里
list.Add(((double)list[count - 1] + (double)list[count - 2]));
}
foreach (double count in list)
{
//3,先拆箱后装箱,foreach遍历把list里的每一个object都拆箱为count赋值,
//然后再通过 Console.Write方法将count装箱为object
Console.Write("{0}, ", count);
}
}
每次装箱都涉及内存分配和拷贝;每次拆箱都涉及类型检查和拷贝。
如果所有操作都用已拆箱的类型完成,就可避免内存分配和类型检查。显然,可通过避免许多装箱操作来提升代码性能。例如上例的foreach
循环可将double换成object来改进。另一个改进是将ArrayList
数据类型更改为泛型集合。
类型检查
关于类型检查需要注意,就是要关心的是值类型实例的真正类型!例如number真正的类型是int,拆箱时就不能直接转为double,必须先转为对应的基础类型再做其他转换。
int number;
object thing;
double bigNumber;
number = 42;
thing = number;
// ERROR: InvalidCastException
// bigNumber = (double)thing;
bigNumber = (double)(int)thing;
容易忽视的装箱问题
interface IAngle{
void MoveTo(int degrees,int minutes,int seconds);
}
struct Angle:IAngle{
public void MoveTo(int degrees,int minutes,int seconds){
//...
}
}
public static void Main()
{
// ...
Angle angle = new Angle(25, 58, 23);
// Example 1: Simple box operation
object objectAngle = angle; // Box
Console.Write(((Angle)objectAngle).Degrees); //方法参数要求值类型
// Example 2: Unbox, modify unboxed value, and discard value
((Angle)objectAngle).MoveTo(26, 58, 23);
Console.Write(", " + ((Angle)objectAngle).Degrees);
// Example 3: Box, modify boxed value, and discard reference to box
((IAngle)angle).MoveTo(26, 58, 23);
Console.Write(", " + ((Angle)angle).Degrees);
// Example 4: Modify boxed value directly
((IAngle)objectAngle).MoveTo(26, 58, 23);
Console.WriteLine(", " + ((Angle)objectAngle).Degrees);
}
IAngle.MoveTo()
让Angle变成了可变类型!!当我们要避免值类型可变,因为这个变化十分让人迷惑
- 第一种情况,装箱返回一个引用类型的地址,然后输出时又进行拆箱返回堆里值的拷贝,所以还是25没有问题。
- 第二种情况,先对引用类型拆箱返回一个值类型的拷贝,然后调用
MoveTo
方法修改该拷贝的值为26,但是打印的时候还是打印原objectAngle
引用的堆上的值,也就是25. - 第三种情况,先对值类型angle装箱为接口,在堆上创建值的拷贝,然后调用方法修改了箱子中的拷贝值为26,但是打印的时候其实使用的还是该值类型再次装箱后的值,也就是25
- 第四种情况,将引用类型
objectAngle
转为接口,这是引用转换不算装箱,调用方法将修改新箱子中存储的值为26,然后拆箱返回修改值26
每次拆箱后调用都会重复以下步骤:类型检查,拆箱生成值的存储位置,分配临时变量,将值复制到临时变量,调用方法传递临时变量位置。因此在已装箱的值类型上调用接口方法(即第四种情况),可以避免内存开销(每次装箱调用方法都会拷贝一次),以及保证一致性。
枚举
枚举是由开发者声明的可读性更强的值类型。
-
枚举总是具有一个基础类型,可以是除了char之外的任何整型。
-
能转换成基础类型,就能转换成枚举类型。该设计的优点在于可在未来的API版本中为枚举添加新值,同时不会破坏早期版本。例如以后如果需要一个新的类型作为枚举,那么只要能转基础类型,就能转。
-
枚举值为已知值提供了名称,同时允许在运行时分配未知的值。该设计的缺点在于编码时须谨慎,要主动考虑到未命名值的可能性。例如,将
case ConnectionState.Disconnecting
替换成default,并认定default case唯一可能的值就是ConnectionState.Disconnecting
,那么就是不明智的。相反,应显式处理Disconnecting这一case,而让default case报告一个错误,或者执行其他无害的行为。 -
从枚举转换为基础类型以及从基础类型转换为枚举类型都涉及显式转型,而不是隐式转型
-
枚举值在显示赋值的状态之后自动递增
FlagAttribute
(位标志枚举)
如果决定使用必须要使用FlagAttribute
标记
[Flags]
public enmu FileAttributes{
ReadOnly = 1<<0,
Hidden = 1<<1,
//...
}
这样就能通过位操作符来进行状态之间的判断和转换
总结
装箱有一些容易被忽视的特性,大多数会在执行时出问题(而不是编译时),不需要过多关注,只需要记住“不要创建可变的值类型”
至于循环内的反复装箱操作,试用泛式就能显著减少装箱,即使没使用,除非是特定高效率算法,否则影响也不大
struct
自定义结构数量使用较少,只有在需要与托管代码进行互操作时才会大量使用
除非在逻辑上代表单个值,消耗16字节以下,不可变,很少装箱,否则不要定义值类型