大章5:值类型与引用类型

基元类型

什么是基元类型?

  • 有些数据类型太常用了,为了方便程序员书写,C#编译器允许代码以简化语法来操纵。这些编译器直接支持的数据类型叫做基元类型(primitive type)
  • 注意一下,一般国人说的类型是指值类型和引用类型的分类,和这里的类型不是一个意思。

比如以下的代码

1
2
3
4
int a = 0;
System.Int32 a = 0;
int a = new int();
System.Int32 a = new System.Int32();

他们4个生成的IL代码是完全一样的。

基元类型一览

基元类型的映射

  • string和String都是直接映射System.String,所以他们是完全一样的,所以基元类型的本质是映射到FCL(.NET 的 Framework Class Library)类型
  • 不同语言编译器,对于映射的处理不同,比如C#会将long映射到System.Int64而C++是映射Int32。
  • 隐式转换,C#只会在转换安全的情况下才允许。所谓安全,就是说不会发生数据丢失的情况,比如从低精到高精。显式转换可以允许数据丢失,C#总是会选择将数据截断,而不是取上取整。

基元类型的溢出检查

比如以下代码就会溢出:

1
2
Byte b = 100;
b = (Byte)(b + 200);// 溢出了,b已经超过了2^8

溢出处理根据不同编译器,处理结果是不同的。C++不将溢出视为错误,允许值回滚(wrap),程序会继续执行;而C#允许程序员自己选择最合适的处理,是否检查溢出抛错。
C#溢出检查默认关闭,意味着我们编译生成的IL代码是无溢出检查版本,代码能更快运行,但要求开发人员能保证不发生溢出。如果执行的是溢出检查版本,运算执行时会稍慢一些,且检查不过关会抛出OverflowException异常。

  • C#全局开启溢出检查:编译时使用 /checked+编译器开关。
  • C#局部开启溢出检查:用 checked 和 unchecked 关键字
  • 比较好的控制溢出方案是,对于不希望溢出的核心数据,放在checked中抛异常catch住,然后再catch里得体恢复数据。

此外,decimal类型特别特殊,结构和编译器处理不同,上面的checked和unchecked不会起效,一定会抛出OverflowException异常

引用类型和值类型

CLR允许2种类型:引用类型和值类型。
我们要阐明两者的区别。首先说一下引用类型。

① 内存分配差异

引用类型
5个特点

  1. 内存必须从托管堆分配。
  2. C#的new操作符返回对象内存地址——即指向对象数据的内存地址。
  3. 堆上分配的每个对象都有一些额外成员,这些额外成员必须初始化。
  4. 对象中的其它字节(为字段而设)总是设为0。
  5. 从托管堆分配对象时,可能强制执行一次GC。

可以看到,引用类型的开销非常之大,如果全是引用类型,那么程序的性能会显著下降。为了提升简单常用类型的性能,CLR提供了“值类型”的轻量级类型。比较一下。

vs 值类型

  1. 值类型实例一般在线程栈上分配(当然也可以作为字段嵌入引用类型的对象中,那就在堆上了)。
  2. 在代表值类型的实例中包含的是实例本身的字段,而不是引用or指针。
  3. 值类型的实例不受GC垃圾回收器的控制,意思是不会引起GC,从而有效减少了GC回数。
  4. 未装箱的值类型不会在堆上分配,意味着一旦定义了该类型的实例得方法不再活动,为其分配的存储空间会立刻被释放。

微软DOCS会清楚指出哪些类是引用类型,哪些是值类型。叫“类”的是引用类型,叫“结构”、“枚举”的是值类型
继续研究会发现,所有“结构”都是抽象类型System.ValueType的直接派生类,System.ValueType本身又直接从System.Object派生。所有“枚举”都从System.Enum抽象类型派生,System.Enum本身有直接从System.ValueType派生。简单来说就是:

  • System.Enum << System.ValueType << System.Object

值类型是密封类,目的是为了防止被作为基类,当然可以选择给值类型实现多个接口。

② 两者使用分配流程

没啥好说的,总结一下,具体的看图吧。

  • 值类型,真实的值放在线程栈上;赋值就是深拷贝,我们自己想写值类型用struct关键字。
  • 引用类型,线程栈上只持有指针,真实的值放在托管堆上,哪怕类里面有值类型字段,值类型字段也会被放在托管堆上;赋值只是浅拷贝,线程栈上给赋个指针,我们自己写用class关键字。
  • 还有就是,虽然①里说了,值类型拥有减少GC得好处,但是值类型需要逐位复制,所以期望其类型得实例比较小(小于16字节)。而且struct是密封的,意味着值类型不能作为基类,对代码复用不友好。

③ 值类型的装箱和拆箱

什么是装箱?

  • 一种机制。将值类型转换成引用类型的就叫做装箱机制。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Point{
public Int32 x,y;
}

public sealed class Program{
public static void Main(){
ArrayList a = new ArrayList();
Point p;
for(Int32 i = 0; i < 10 ; i ++){
p.x = p.y = i;
a.Add(p); // 对值类型装箱,将引用添加到ArrayList中
}
}
}

// 补充:下面是ArrayList.Add()方法的接口
public virtual Int32 Add(Object value);

装箱时发生了什么?

换句话说也就是将值类型转换成引用类型时发生了什么。

  1. 在托管堆中分配内存。分配的内存量时值类型各字段所需的内存量 + 托管堆所有对象都有的两个额外成员(类型对象指针 和 同步块索引)所需的内存量。
  2. 值类型的字段复制到新分配的堆内存中。
  3. 返回对象地址。

结束这套流程后,返回的是地址,所以变成引用类型了。
C#编译器检测到上述代码是向要求引用类型的方法传递值类型,所以自动生成IL代码对对象进行装箱。具体发生的事看下面。

C#编译器做 装箱 时具体干了什么?

检测到上述情况后,会把当前存在于Point值类型实例p中的字段复制到新分配的对象中,再把这个对象的引用返回到Add方法作为入参继续执行。外部只知道p是引用类型,可以被继续引用标记,且已装箱的值类型的生存期超过了未装箱值类型的生存期。

什么是拆箱?

  • 拆箱不是直接将装箱的过程倒过来!它的代价比装箱小得多。
  • 拆箱只是指获取指针的过程,获取的是指向包含在一个引用对象中的原始值类型(也就是字段,比如上面的x、y字段)的指针。
1
2
// 假定要用以下代码获取ArrayList的第一个元素
Point p = (Point) a[0];

拆箱时发生了什么?

  1. 获取已装箱Point对象中的各个字段的地址。(这个叫拆箱)
  2. 将字段包含的值从堆中复制到栈的值类型实例中。(拆箱操作经常紧接着一次字段复制)

注意,以上情况中,如果引用的对象不是所需值类型的已装箱实例,会抛出InvalidCastException异常。比如,

1
2
3
4
5
public static void Main(){
Int32 x = 5;
Object o = x; // 对x装箱,o引用已装箱对象
Int16 y = (Int16) o; // 抛出InvalidCastException异常
}

从逻辑上说,完全能把o从Int32转换成Int16,但是不行,只能转换为最初未装箱时的值类型。

C#编译器做 拆箱 时具体干了什么?

其实和上面讲述的是一样的。

  1. 先对o拆箱,生成一条IL指令,获取已装箱对象中的各个字段的地址。
  2. 再复制,生成一条IL指令,将这些字段从堆复制到栈变量中。

简单的拆装箱优化

  • 一些常用方法有很多重载,根据入参的不同选择最合适的入参,减少拆装需求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void Main(){
Int32 v = 5;
Object o = v;
v = 123;

// 下面的输出结果都一样

// 下面这一句需要装2次拆1次
Console.WriteLine(v + "," + (Int32) o); // 为了输出,需要拼接3个字段,底层用的是 public static String Concat (Object arg0, Object arg1, Object arg2)

// 下面这一句需要装1次
Console.WriteLine(v + "," + o);

// 下面这一句需要装0次
Console.WriteLine(v.ToString() + "," + o); // String已经是引用类型

// 下面这一句需要装0次
Console.WriteLine(v); // 底层优化过了,用的是 public static void WriteLine(Int32 value);

}
  • 同样的类多次装箱or拆箱,可以合并为一次
1
2
3
4
5
6
7
8
// v是个Int32型
// 下面一句要装3次
Console.WriteLine("{0},{1},{2}",v,v,v);

// 下面只要装1次
Object o = v;
Console.WriteLine("{0},{1},{2}",o,o,o);

未装箱值类型vs引用类型

未装箱值类型更轻:

  • 不再托管堆上分配。
  • 没有每个对象都有的额外成员:“类型对象指针” 和 “同步块索引”。

因为没有同步块索引,所以无法用lock等。

除此之外,调用一些 未装箱值类型 自己的方法或者重写的方法时是不需要装箱的(比如在struct定义中重写public override String ToString() );
而由 System.Object 所定义的方法(非虚继承),那么就需要装箱,比如p.GetType();
或者你重写的方法里,还调用了基类型的方法,那么就要装箱。

最后聊一下

已装箱过的值类型,再拆箱更改其字段的话只是改了拆箱后的栈字段,在堆上的已装箱值类型数据是不会被修改的。

1
2
3
4
Point p = new Point(1,1);	// Point是struct类
Object o = p; // 已装箱过的值类型o
((Point) o).Change(3,3);
Console.WriteLine(o); // 输出1,1

另外,可以用接口欺骗C#,具体的就不说了,可以看书P122。

对象相等性与同一性

同一性 identity

这两个概念都是基于比较对象的概念,比如System.Object类提供的Equals虚方法,是这样的

1
2
3
4
5
6
7
public class Object{
public virtual Boolen Equals(Object obj){
// 如果两个引用指向一个对象,他们肯定包含相同的值
if(this == obj) return true;
return false;
}
}

很显然,这不能称之为比较两者是否相等,而是在比较两者指针是否指向同一个堆中对象,这叫做同一性(identity)
由于是虚方法可以被重写,所以真的想比较 同一性 的话,会选择静态方法 ReferenceEquals。

相等性 equality

因为System.Object类提供的Equals方法比较混淆,所以System.ValueType就重写了Object的Equals方法:

  1. 如果obj实参为null,就返回false
  2. 如果this和obj实参引用不同类型的对象(不是对象指针,是之前提过的每个类都会持有自己的类型!),就返回false
  3. 针对类型定义的每个实例字段,进行一一比较(调用字段的Equals方法)。任何字段不相等,就返回false
  4. 返回true!ValueType的Equals方法不调用父类Object的Equals方法

上述3中的比较,是通过反射完成的。

重写自己的Equals

如果想要重写Equals,需要满足下面4个特性:

  1. Equals必须自反:x.Equals(x)肯定返回true;
  2. Equals必须对称:x.Equals(y)与y.Equals(x)返回相同的值;
  3. Equals必须可传递:x.Equals(y)为true、y.Equals(z)也为true,那么x.Equals(z)一定为true
  4. Equals必须一致

且我们实际上想写自己的Equals的时候,会这样:

  • 实现IEquatable接口
  • 重载==和!=操作符

如果还需要排序,需要手动实现:

  • IComparable接口的CompareTo方法
  • IComparable接口的类型安全的CompareTo方法
  • 重载比较操作符(<、<=、>、>=),在内部调用类型安全的CompareTo方法

对象哈希码

在按照上面重写Equals时会发现,编译器会提示你要求一起重写Object.GetHasCode()方法。包括上面的类型安全实现接口IEquatable,也要求你实现GetHasCode()方法。
这是因为由于在System.Collections.Hastable类型、System.Collections.Generic.Dictionary类型以及一些其他集合实现中,要求两个对象必须具有相同哈希码才被视为相等。
所以重写Equals就必须重写GetHashCode以保持对象哈希码算法与相等性算法保持一致。

哈希码是什么

这里介绍一下哈希码。
向集合添加key-val对,首先需要获取key的哈希码。哈希码指出key-val对要存储到哪个哈希桶(bucket)中。
集合需要查找键时,会获取指定键对象的哈希码,该哈希码标识了即将要以顺序方式搜索的哈希桶,将会在桶里找对应的key。
所以按照上述算法,如果修改了一个键值对的key让它没有存在于正确的哈希桶中,那么就永远找不到它了。所以正确修改key的方法是先删后加。

重写自己的GetHashCode

以下规则,全文背诵:

  • 这个算法要提供良好的随机分布,使哈希表获得最佳性能
  • 一般不要直接用Object或者ValueType的GetHashCode方法,因为他们性能都不高
  • 算法至少使用一个实例字段
  • 理想状态下,算法使用的字段应不可变,在构造时初始化后就不再变是最好的
  • 算法执行速度尽量快
  • 包含相同值得不同对象应返回相同哈希码
  • 不要将哈希码持久化,比如记录进数据库,因为GetHashCode的算法很可能被更新

举个例子,上面提到的Point类可以这么写:

1
2
3
4
5
6
internal sealed class Point{
private readonly Int32 m_x,m_y;
public override Int32 GetHasCode(){
return m_x ^ m_y;
}
}

dynamic基元类型

dynamic是什么

dynamic关键字,我的理解,是一个动态决定类型的var。他是为了方便程序员简化代码而推出的不安全类型。具体如下:

1
2
3
4
5
6
dynamic value;
for(var i = 0;i < 2;i++){
value = (demo == 0) ? (dynamic) 5 : (dynamic) "A" ;
value = value + value;
Console.WriteLine(value); // 输出10和AA
}

动态决定类型,比起纯安全类型,可以省去大量代码。
编译器在为dynamic生成特殊IL代码来描述所需要的操作,这种特殊代码叫做payload(有效载荷)。运行时,payload代码根据dynamic引用的对象的实际类型来决定具体执行操作。

实际上,dynamic声明一个变量,编译器会把它当作Object来用,IL代码里也就是object,一样的,直到变量被赋值确定了具体类型。

再来一个简化代码例子来理解dynamic:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 利用反射调用String.Contains方法
Object target = "AAAAAaaa";
Object arg = "a";

Type[] argTypes = new Type[]{ arg.GetType() };
MethodInfo method = target.GetType().GetMethod("Contains",argTypes);
Object[] arguments = new Object[]{ arg };
bool result = Convert.ToBoolean(method.Invoke(target,arguments));

// 下面是利用dynamic改写上述需求
dynamic target = "AAAAAaaa";
dynamic arg = "a";
bool result = target.Contains(arg); // 注意了,这里Contains是没有智能提示的,但是可以编译过且哪怕没有Contains方法也能编译过。如果不存在该名字的实例方法,执行会抛错。

dynamic调用vs反射调用

这个要看IL实现,具体的不多深究。总的来说,

  • dynamic调用方法是用到0级缓存,会比反射快一些。
  • 代价是需要用到运行时绑定器(runtime binder类),它存在于Microsoft.CSharp.dll程序集中,所以必须将该程序集加载到AppDomain中(程序集会进内存),且在payload代码执行时生成动态代码(会进程序集也就是内存里),使得会损害应用程序的性能,增大内存消耗。
  • dynamic无法调用到类型静态方法,只能调用实例方法。

所以,即使上述代码用dynamic写很整洁,但如果需要用dynamic的地方很少,那一般还是会选择反射实现。