C#精要 - 值类篇
什么是值类型,什么是引用类型?
结构体、枚举是值类型,类是引用类型。当然,所有类型都是隐式继承自Object的。
值类型有哪些
结构体、枚举。
所有“结构体struct”都是抽象类System.ValueType的直接派生类,因此它不能再继承类了。而System.ValueType本身又直接从System.Object派生。
所有“枚举enum”都从System.Enum抽象类派生,System.Enum本身有直接从System.ValueType派生。
// 哦?你说System.Enum和System.ValueType抽象类?
是的,虽然所有的枚举(enum type)都是值类型、内存分配也在栈上、会被装箱,但他们都继承自System.Enum抽象类,而System.Enum抽象类本身是引用类型。
结构体(struct type)也是一样的,System.ValueType抽象类型本身也是个类,是引用类型。
至于为什么,这是一种隐式继承,是由编译器做到的。怎么实现的我没有了解,但知道他们的IL代码不一样的,内部有很多字面值关键词 literal。
值类型特点
- 值类型实例一般在线程栈上分配(当然也可以作为字段嵌入引用类型的对象中,那就在堆上了)。
- 在代表值类型的实例中包含的是实例本身的字段,而不是引用or指针。
- 值类型的实例不受GC垃圾回收器的控制,意思是不会引起GC,从而有效减少了GC回数。当然,它在线程栈里,所以 方法体结束、栈帧展开后 他就没了。
引用类型有哪些
像所有的class都是引用类型,隐式继承Object类型。
abstarct是抽象类型,它不可以被实例化。用它是因为它可以在内部定义抽象成员(方法、属性、索引器、事件),编译器要求派生类必须实现这些成员。
⭐abstarct方法 和 virtual方法 区别是什么?
首先是定义上,abstarct方法只能在抽象类里定义,virtual方法则是所有类。
其次是使用上,抽象方法要求派生类必须override实现(因为它没有方法实体),而虚方法可以被派生类override重写、也可以不重写(因为它有方法实体)。
但是注意!abstarct方法 和 virtual方法 本质上是一样的:剥开外衣,它们的IL代码定义都是virtual。这里不展开,详细看《C#精要 - 类内成员篇》。
引用类型特点
- 引用类型只能在堆上分配。
- 引用类型的资源清理交给GC处理。
- 堆上分配的每个对象都有2个额外成员:类型对象指针和同步块索引。
装箱拆箱是什么?
实质是内存迁移。而这个“箱”其实就是托管堆。
装箱:值类型转换引用类型的时候,将栈上的数据拷贝到堆上。
拆箱:之前由值类型转换而来的对象类型再转回值类型,从堆拷贝回栈上。
装箱步骤
- 分配内存: 在托管堆中分配好内存,内存的大小是值类型的各个字段需要的内存量加上托管堆的所有对象都有的两个额外成员—类型对象指针和同步块索引—所需要的内存量之和。
- 复制对象: 将值类型的字段复制到新分配的内存中。
- 返回地址: 将已装箱的值类型对象的地址返回给引用类型的变量。
拆箱步骤
- 检查实例:首先检查变量的值是否为null,如果是则抛出NullReferenceException异常;再检查变量的引用指向的对象是不是给定值类型的已装箱对象,如果不是,则抛出InvalidCastException异常。
- 返回地址:返回已装箱实例中属于原值类型字段的地址,而两个额外成员(类型对象指针和同步块索引)则不会返回。
聊一下String
String是什么类型?
是个特殊的引用类型。
怎么个特殊法呢?
String类型对象直接派生自Object,所以String是引用类型,它在堆上分配内存。
但是String类型却又有值类型的特性,具体来说它用的时候像值类型一样,也就是不变性,两个变量赋一样的String类型,修改其中一个并不会让另一个变量跟着变。
聊聊不变性
不变性简单来说就是String对象一旦创建,就不能再更改,包括不能变长、变短或修改其中任何字符。
所以上面我提到的“修改其中一个变量”,实际上做的事是创建了一个新的String对象并让变量指向这个新对象。
不变性的好处:
- 项目里用ToUpper、Substring之类的获取新字符串,不会影响到原对象。
- 因为不可变,所以不会有线程同步问题
不变性的坏处:
- 项目里拼接的时候,比如我想用+操作符拼接字符串,过程中每一步都会生成一个新字符串。这样就增加了额外开销,影响GC。
想避免坏处可以使用StringBuilder类。不过呢,CLR有对string有着留用机制、字符串池这两项优化手段,所以一般来说问题不算大。
CLR字符串留用
比如你对两个变量都赋值一个字符串字面值,那么它们指向的都是堆上的同一个对象。这是CLR帮你优化的。
CLR初始化时,会在内部创建一个哈希表,这个表中,key是字符串,value则是托管堆中String对象的引用。然后编译时,CLR默认会对程序集的元数据中描述的所有字面值字符串进行留用,也就是让他们指向同一个对象。但是这并不可靠,有CompilationRelaxationsArrtibute和NoStringInterning两个特性会让CLR不留用,而且运算时确定的string值也是不会被留用的。
只有显示调用Intern
方法(获取参数String对象的哈希码,并在内部哈希表中检查是否有相匹配,没有就创建再返回引用)才能确定可靠的被留用。
字符串池
是和元数据量有关的。编译源代码时,编译器必须处理每个字面值字符串,并在托管模块的元数据中嵌入。为了让元数据小点,对于相同的字面值,在元数据中第一次会写入,后面只会引用元数据的同一个字符串。
使用StringBuilder避免坏处
由于String类型是不可变的,FCL推出StringBuilder类对应可变字符串的需求。
StringBuilder 对象包含一个字段,该字段引用了由Char结构构成的数组(字符数组)。StringBuilder提供很多方法来操作这个字符数组。正是这样避免了不可变性带来的坏处,每次操作不会像String一样新建对象。
它还会动态扩容。如果字符串变大,超过了事先分配的字符数组大小,StringBuilder会新建一个StringBuilder对象并对构造器传入自己,再将其作为前结点,然后将自己维护的char数组重新赋值为需要额外分配的chunk大小(new char[])。所以StringBuilder其实是一个单链表。
最后调用ToString方法把他转换为String型,就能正常使用了。