CLR笔记:5.基元,引用和值类型
5.1基元类型
编译器(C#)直接支持的任何数据类型都称为基元类型(primitive type),基元类型直接映射到FCL中存在的类型。可以认为 using string = System.String;自动产生。
FCL中的类型在C#中都有相应的基元类型,但是在CLS中不一定有,如Sbyte,UInt16等等。
C#允许在“安全”的时候隐式转型——不会发生数据丢失,Int32可以转为Int64,但是反过来要显示转换,显示转换时C#对结果进行截断处理。
unchecked和check控制基元类型操作
C#每个运算符都有2套IL指令,如+对应Add和Add.ovf,前者不执行溢出检查,后者要检查并抛出System.OverflowException异常。
溢出检查默认是关闭的,即自动对应Add这样的指令而不是Add.ovf。
控制C#溢出的方法:
1.使用 /check+编译器开关
2.使用操作符checked和unchecked:
//b = checked((short)(b + 32767)); throw System.OverflowException
b = (short)checked(b + 32767); //return -2
这里,被注释掉的语句肯定会检查到溢出,运行期抱错;而第二句是在Int32中检查,所以不会溢出。注意这两条语句只是为了说明check什么时候发挥作用,是两条不同语义的语句,而不是一条语句的正误两种写法。
3.使用 checked和unchecked语句,达到与check操作符相同的效果:
checked
{
b = b + 32767;
}
return (short)b;
System.Decimal类型在C#中是基元,但在CLR中不是,所以check对其无效。
5.2 引用类型和值类型
引用类型从托管堆上分配内存,值类型从一个线程堆栈分配。
值类型不需要指针,值类型实例不受垃圾收集器的制约
struct和enum是值类型,其余都是引用类型。这里,Int32,Boolean,Decimal,TimeSpan都是结构。
struct都派生自System.ValueType,后者是从System.Object派生的。enum都派生自System.Enum,后者是从System.ValueType派生的。
值类型都是sealed的,可以实现接口。
new操作符对值类型的影响:C#确保值类型的所有字段都被初始化为0,如果使用new,则C#会认为实例已经被初始化;反之也成立。
Int32 a1 = v1.x; //已经初始化为0
SomeVal v2;
Int32 a2 = v2.x; //编译器报错,未初始化
使用值类型而不是引用类型的情况:
1.类型具有一个基元类型的行为:不可变类型,其成员字段不会改变
2.类型不需要从任何类型继承
3.类型是sealed的
4.类型大小:或者类型实例较小(<16k);或者类型实例较大,但不作为参数和返回值使用
值类型有已装箱和未装箱两种形式;引用类型总是已装箱形式。
System.ValueType重写了Equals()方法和GetHashCode()方法;自定义值类型也要重写这两个方法。
引用类型可以为null;值类型总是包含其基础类型的一个值(起码初始化为0),CLR为值类型提供相应的nullable。
copy值类型变量会逐字段复制,从而损害性能,copy引用类型只复制内存地址。
值类型的Finalize()方法是无效的,不会在垃圾自动回收后执行——就是说不会被垃圾收集。
CLR控制类型字段的布局:System.Runtime.InteropServices.StructLayoutAttribute属性,LayoutKind.Auto为自动排列(默认),CLR会选择它认为最好的排列方式;LayoutKind.Sequential会按照我们定义的字段顺序排列;LayoutKind.Explicit按照偏移量在内存中显示排列字段。
struct SomeVal
{
public Int32 x;
public Byte b;
}
Explicit排列,一般用于COM互操作
struct SomeVal
{
[FieldOffset(0)]
public Int32 x;
[FieldOffset(0)]
public Byte b;
}
5.3 值类型的装箱和拆箱
boxing机制:
1.从托管堆分配内存,包括值类型各个字段的内存,以及两个额外成员的内存:类型对象指针和同步块索引。
2.将值类型的字段复制到新分配的堆内存。
3.返回对象的地址。
——这样一来,已装箱对象的生存期 超过了 未装箱的值类型生存期。后者可以重用,而前者一直到垃圾收集才回收。
unboxing机制:
1.获取已装箱对象的各个字段的地址。
2.将这些字段包含的值从堆中复制到基于堆栈的值类型实例中。
——这里,引用变量如果为null,对其拆箱时抛出NullRefernceException异常;拆箱时如果不能正确转型,则抛出InvalidCastException异常。
装箱之前是什么类型,拆箱时也要转成该类型,转成其基类或子类都不行,所以以下语句要这么写:
Object o = x;
Int16 y = (Int16)(Int32)o;
拆箱操作返回的是一个已装箱对象的未装箱部分的地址。
大多数方法进行重载是为了减少值类型的装箱次数,例如Console.WriteLine提供多种类型参数的重载,从而即使是Console.WriteLine(3);也不会装箱。注意,也许WriteLine会在内部对3进行装箱,但无法加以控制,也就默认为不装箱了。我们所要做的,就是尽可能的手动消除装箱操作。
可以为自己的类定义泛型方法,这样类型参数就可以为值类型,从而不用装箱。
最差情况下,也要手动控制装箱,减少装箱次数,如下:
Console.WriteLine("{0}, {1}, {2}", v, v, v); //要装箱3次
Object o = v; //手动装箱
Console.WriteLine("{0}, {1}, {2}", o, o, o); //仅装箱1次
由于未装箱的值类型没有同步块索引,所以不能使用System.Threading.Monitor的各种方法,也不能使用lock语句。
值类型可以使用System.Object的虚方法Equals,GetHashCode,和ToString,由于System.ValueType重写了这些虚方法,而且希望参数使用未装箱类型。即使是我们自己重写了这些虚方法,也是不需要装箱的——CLR以非虚的方式直接调用这些虚方法,因为值类型不可能被派生。
值类型可以使用System.Object的非虚方法GetType和MemberwiseClone,要求对值类型进行装箱
值类型可以继承接口,并且该值类型实例可以转型为这个接口,这时候要求对该实例进行装箱
5.4使用接口改变已装箱值类型
{
void Change(int x);
}
struct Point : IChangeBoxedPoint
{
int x;
public Point(int x)
{
this.x = x;
}
public void Change(int x)
{
this.x = x;
}
public override string ToString()
{
return x.ToString();
}
class Program
{
static void Main(string[] args)
{
Point p = new Point(1);
Object obj = p;
((Point)obj).Change(3);
Console.WriteLine(obj); //输出1,因为change(3)的对象是一个临时对象,并不是obj
((IChangeBoxedPoint)p).Change(4);
Console.WriteLine(p); //输出1,因为change(4)的对象是一个临时的装箱对象,并不是对p操作
((IChangeBoxedPoint)obj).Change(5);
Console.WriteLine(obj); //输出5,因为change(5)的对象是(IChangeBoxedPoint)obj装箱对象,于是使用接口方法,修改引用对象obj
}
}
}
5.5 对象相等性和身份标识
相等性:equality
同一性:identity
System.Object的Equal方法实现的是同一性,这是目前Equal的实现方式,也就是说,这两个指向同一个对象的引用是同一个对象:
{
public virtual Boolean Equals(Object obj)
{
if (this == obj) return true; //两个引用,指向同一个对象
return false;
}
}
但现实中我们需要判断相等性,也就是说,可能是具有相同类型与成员的两个对象,所以我们要重写Equal方法:
{
public virtual Boolean Equals(Object obj)
{
if (obj == null) return false; //先判断对象不为null
if (this.GetType() != obj.GetType()) return false; //再比较对象类型
//接下来比较所有字段,因为System.Object下没有字段,所以不用比较,值类型则比较引用的值
return true;
}
}
如果重写了Equal方法,就又不能测试同一性了,于是Object提供了静态方法ReferenceEquals()来检测同一性,实现代码同重写前的Equal()。
检测同一性不应使用C#运算符==,因为==可能是重载的,除非将两个对象都转型为Object。
System.ValueType重写了Equals方法,检测相等性,使用反射技术——所以自定义值类型时,还是要重写这个Equal方法来提高性能,不能调用base.Equals()。
重写Equals方法的同时,还需要:
让类型实现System.IEquatable<T>接口的Equals方法。
运算符重载==和!=
如果还需要排序功能,那额外做的事情就多了:要实现System.IComparable的CompareTo方法和System.IComparable<T>的CompareTo方法,以及重载所有比较运算符<,>,<=,>=
5.6 对象哈希码
重写Equals方法的同时,要重写GetHashCode方法,否则编译器会有警告。
——因为System.Collection.HashTable和Generic.Directory的实现中,要求Equal的两个对象要具有相同的哈希码。
HashTable/Directory原理:添加一个key/value时,先获取该键值对的HashCode;查找时,也是查找这个HashCode然后定位。于是一旦修改key/value,就再也找不到这个键值对,所以修改的做法是,先移除原键值对,在添加新的键值对。
不要使用Object.GetHashCode方法来获取某个对象的唯一性。FCL提供了特殊的方法来做这件事:
RuntimeHelpers.GetHashCode(Object o)
这个GetHashCode方法是静态的,并不是对System.Object的GetHashCode方法重写。
System.ValueType实现的GetHashCode方法使用的是反射技术。