第5章 基元类型、引用类型和值类型
5.1基元类型
编译器(Compiler)直接支持的数据类型称为基元类型(primitive type)。
所有基元类型可以直接映射到FCL(Framework class library)中存在的类型。
比如C#中int直接映射到System.Int32类型,且在编译为IL(中间语言)时,他们将会是一模一样的。
int a = 0;//最方便的语法 System.Int32 a = 0;//方便的语法 int a = new int();//不方便的语法 System.Int32 a = new System.Int32();//最不方便的语法
许多开发人员都困惑于到底应该使用string还是String。由于C#的string直接映射到System.String,所以两者是没有区别的。
int始终映射到System.Int32,所以不管在什么操作系统上运行,代表的都是32位整数。
5.2引用类型和值类型
虽然FCL中大多数类型是引用类型,但程序员用的最多的还是值类型。
引用类型和值类型都是从最终的基类 Object 派生出来的。
值类型:原类型(Sbyte、Byte、Short、Ushort、Int、Uint、Long、Ulong、Char、Float、Double、Bool、Decimal)、枚举(enum)、结构(struct)。
为提升简单常用的类型的性能,CLR提供了“值类型”的轻量级类型。值类型的实例一般在线程栈上分配。int a = new int();
在代表值类型实例的一个变量中,并不包含一个指向实例的指针。相反,变量包含了实例本身的字段。由于变量已经包含了实例的字段,所以为了操作实例中的字 段,不再需要提领一个指针。值类型的实例不受垃圾回收器的控制,超出了作用范围,系统就会自动释放。因此值类型的使用缓解了托管堆中的压力,并减少了一个 应用程序在其生存期内需要进行的垃圾回收次数。
引用类型:类类型、接口类型、数组类型和委托类型。所有引用类型变量所引用的对象,其内存都是在托管堆中分配的。
使用引用类型时必须考虑性能问题,首先考虑以下事实:
- 内存必须从托管堆上分配
- 堆上分配的每个对象都有一些额外的成员(类型对象指针、同步块索引),这些成员必须初始化
- 对象中的其他字节(为字段而设)总是设为零
- 从托管堆中分配一个对象时,可能强制执行一次垃圾收集操作
SDK文档中,任何称为“类”的类型都是引用类型。例如:System.Exception类、System.IO.FileStream类以及System.Random类都是引用类型。
相反,文档中将所有值类型称为结构或枚举。例如:System.Int32结构、System.TimeSpan结构、System.DayofWeek枚举。
所有结构都是抽象类型System.ValueType的直接派生类。所有枚举都从System.Enum 抽象类型派生。System.Enum又是从System.ValueType派生。
所有值类型都是隐式密封的(sealed),目的是防止将一个值类型用作其他任何引用类型或值类型的基类型。
在代码中使用类型时,必须注意该类型是引用类型还是值类型。
SomeVal v1 = new SomeVal ();
C#编译器知道SomeVal是一个值类型,所以会生成相应的IL代码,在线程栈上分配一个SomeVal实例。C#还会确保值类型中所有字段都初始化为零。
将一个类型声明为值类型要满足的条件:
- 类型具有基元类型的行为。类型中没有成员会修改类型的任何实例字段。所以,对于许多值类型来说,都建议将它们的字段都标记为readonly
- 类型不需要从其他任何类型继承
- 类型也不会派生出其他任何类型
值类型和引用类型的区别:
- 已装箱和未装箱:值类型有两种表示形式:未装箱(unboxed)形式和已装箱(boxed)形式。||相反,引用类型总是处于已装箱形式
- 重写方法:值类型是从System.ValueType派生的。该方法提供了与System.Object定义的相同的方法。然而,System.ValueType重写了Equals方法,能在两个对象的字段完全匹配的前提下返回true。System.ValueType重写了GetHashCode方法。
- 不能有虚方法:由于不能将一个值类型作为基类型来定义一个新的值类型或者一个新的引用类型,所以不能在值类型中引入任何虚方法。所有方法都不能是抽象的,都隐式地为密封方法
- NullReferenceException异常:引用类型的变量指向的是堆上的一个对象的地址。默认情况下,在创建一个引用类型的变量时,它被初始化为null,表明引用类型的变量,当前不指向一个有效的对象。试图使用一个为null的引用类型变量,会抛出NullReferenceException异常。||值类型的变量总是包含其基础类型的一个值,而且值类型的所有成员都初始化为0 。由于值类型的变量不是指针,所有在访问一个值类型时,不可能抛出NullReferenceException异常。CLR为值类型提供了一个特殊的特性,能为值类型添加“可空性”
- 变量赋给:将一个值类型的变量赋给另一个值类型变量,会执行一次逐字段的复制(在线程堆栈上重新分配并复制成员)。||将引用类型的变量赋给另一个引用类型变量时,只复制内存地址(堆中同一个类型的实例/对象。)
- 变量操作影响:基于上一条,两个或多个引用类型的变量能引用堆中同一个对象,所有对一个变量执行的操作可能影响到另一个变量引用的对象。||相反,值类型的变量是自成一体的对象,对一个值类型变量的操作不可能影响另一个值类型变量
- 内存释放:由于未装箱的值类型不在堆上分配,所以一旦定义了该类型的一个实例的方法不再处于活动状态,为它们分配的存储就会被释放。这意味着值类型的实例在其内存被回收时,不会通过Finalize方法接收到一个通知||引用类型是在堆上分配,受垃圾回收器的控制
当一个局部变量声明之后,就会在线程栈的内存中分配一块内存给这个变量,至于这块内存多大,里面存放什么东西,就要看这个变量是值类型还是引用类型了。
- 值类型
private static void Main() { int i; i = 5; }
如果是值类型,为变量分配这块内存的大小就是值类型定义的大小,存放值类型自身的值。比如,对于上面的整型变量 i,这块内存的大小就是 4个字节(一个 int型定义的大小),如果执行 i = 5;这行代码,则这块内存的内容就是 5(如图 -1)。
对于任何值类型,无论是读取还是写入操作,可以一步到位,因为值类型变量本身所占的内存就存放着值。
- 引用类型
private static void Main() { MyClass mc; mc = new MyClass(); }
如果是引用类型,为变量mc分配的这块内存的大小,就是一个内存指针(实例引用、对象引用)的大小(在 32位系统上为 4字节,在 64位系统上为 8字节)。因为所有引用类型的实例都是创建在托管堆上的,而这个为变量分配的内存就存放变量对应在堆上的实例的内存首地址(内存指针),也叫实例(对象)的引用。
由图-2可知,变量 mc中存放的是 MyClass实例(对象)的对象引用。
如果需要访问 mc实例,系统需要首先从 mc变量中得到实例的引用(在堆中的地址),然后用这个引用(地址)找到堆中的实例,再进行访问。需要至少 2步操作才可以完成实例访问。
5.3 值类型的装箱和拆箱
值类型是比引用类型更“轻型”的一种类型,因为它不作为对象在托管堆中分配,不会被垃圾回收,也不通过指针来引用。
但在许多情况下,都需要获取对值类型的一个实例的引用,即将值类型转换成引用类型。
如上面代码,创建一个ArrayList对象(System.Collections命名空间中定义的一个类型)来容纳一组Point结构。
每一次循环迭代都会初始化值类型字段(x和y)。然后这个Point会存储到ArrayList中。但ArrayList中究竟存储的是什么?是Point结构,还是其他什么东西。我们必须研究ArrayList的Add方法,了解它的参数被定义成什么类型。
Add需要获取一个Object参数。换言之,Add需要获取对托管堆上的一个对象的引用(指针)来作为参数。但在之前的代码中,传递的是p,也就是一个Point,是一个值类型。为了将一个值类型转换成引用类型,要使用一个名为装箱(boxing)的机制。
对值类型的一个实例进行装箱操作时在内部发生的事情:
- 在托管堆上分配好内存。分配的内存量=值类型的各个字段需要的内存量+托管堆的所有对象都有的类型对象指针和同步块索引需要的内存量
- 值类型的字段复制到新分配的堆内存中
- 返回对象的地址,这个地址是对一个对象的引用,值类型现在是一个引用类型
C#编译器会自动生成对一个值类型的实例进行装箱所需的IL代码。
在上述代码中,C#编译器检测到是向一个需要引用类型的方法传递一个值类型,所以会自动生成代码对对象进行装箱。在运行时,当前存在于Point值类型实例p中字段会复制到新分配的Point对象中。已装箱的Point对象(现在是一个引用类型)的地址会返回给Add方法。Point对象会一直存在于堆中,直到被垃圾回收。Point值类型变量p可以重用,因为ArrayList根本不知道关于它的任何事情。在这种情况下,已装箱的值类型的生存期超过了未装箱的值类型的生存期。
在知道装箱如何进行之后,接着谈谈拆箱。
假定需要使用以下代码获取ArrayList的第一个元素:
现在是要获取ArrayList的元素0中包含的引用(或指针),并试图将其放到一个Point值类型的实例p中。包含在已装箱Point对象中的所有字段都必须复制到值类型变量p中,后者在线程栈上。
CLR分两步完成这个拆箱操作(获取地址/复制)。
- 获取已装箱的Point对象中的各个Point字段的地址。
- 将这些字段包含的值从堆中复制到基于线程栈的值类型实例中。
一个已装箱的值类型实例在拆箱时,内部会发生下面的异常:
- 如果包含了“对已装箱的值类型实例的引用”的变量为null,就抛出一个NullReferenceException异常
- 如果引用指向的对象不是所期待的值类型的一个已装箱实例,就抛出一个InvalidCastException异常,如下
以上代码从逻辑上说,完全可以获取o所引用一个已装箱的Int32,然后将其强制转换为一个Int16。
然而,在对一个对象进行拆箱的时候,只能将其转型为原先未装箱时的值类型---本例即为Int32,再进行转型。
下面的代码是正确的写法:
由于未装箱的值类型没有同步块索引,所以不能使用System.Threading.Monitor类型的各种方法,让多个线程同步对这个实例的访问。
5.4对象哈希码
哈希:通过将哈希算法应用到任意数量的数据所得到的固定大小的结果。如果输入数据中有变化,则哈希也会发生变化。
哈希算法:将任意长度的二进制值映射为固定长度的较小二进制值,这个小的二进制值称为哈希值。哈希值是一段数据唯一且极其紧凑的数值表示形式。
哈希表:
哈希表(Hashtable):根据设定的哈希函数和处理冲突方法将一组关键字映象到一个有限的地址区间上,并以关键字在地址区间中的象作为记录在表中的存储位置,这种表称为哈希表或散列,所得存储位置称为哈希地址或散列地址。作为线性数据结构与表格和队列等相比,哈希表无疑是查找速度比较快的一种。
HashTable是System.Collections命名空间提供的容器,用来处理和表现类似key value的键\值对。
其中key区分大小写,通常用来快速查找。value用来储存对应于key的值。Hashtable中key value键\值对均为object类型,所以Hashtable可以支持任何类型的keyvalue键\值对。
Hashtable是非泛型的集合,所以在检索和存储值类型时通常会发生装箱与拆箱的操作。
- 在哈希表中添加一个key value键\值对:HashtableObject.Add(key,value);
- 在哈希表中去除某个key value键\值对:HashtableObject.Remove(key);
- 从哈希表中移除所有元素:HashtableObject.Clear();
- 判断哈希表是否包含特定键key:HashtableObject.Contains(key);
GetHashCode:
System.Object提供了虚方法GetHashCode,它能获取任意对象的Int32哈希码。
如果你定义的一个类型重写了Equals方法,那么还应重写GetHashCode方法,确保相等性算法和对象哈希码算法是一致的。
5.5 dynamic基元类型
namespace DynamicName { internal static class DynamicDemo { public static void Main() { for (Int32 demo = 0; demo < 2; demo++) { dynamic arg = (demo == 0) ? (dynamic)5 : (dynamic)"A"; dynamic result = Plus(arg); } } private static dynamic Plus(dynamic arg) { return arg + arg; } } }
静态类:
从面向对象的角度来看, 对象的实例表示的是个体, 而static的属性和方法则表示 全体所共有的方法和属性 ,比如 “圆”这个class, 半径、面积、周长是 个体的属性,而圆周率PI则是共性。
从应用的角度来看,本质就是为了节省内存,在内存中只有一个引用。
静态类的主要功能如下:
- 它们仅包含静态成员。
- 它们不能被实例化。
- 它们是密封的。
- 它们不能包含实例构造函数,不能使用 new 关键字创建静态类的实例。
dynamic:
在许多时候,程序仍需处理一些运行时才会知晓的消息。如果你写的是一个纯C#应用程序,那么只有在使用反射的时候,才会在运行时才能确定的信息打交道。
构建使用dynamic关键字的项目时,必须引用Microsoft.CSharp.dll程序集。在运行时,Microsoft.CSharp.dll程序集必须加载到AppDomain中,这会损坏应用程序的性能,并增大内存耗用。
有时候三元运算符会遇到前后类型不一致的情况,如下图。
dynamic arg = (demo == 0) ? (dynamic)5 : (dynamic)"A";
C#编译器允许将一个表达式的类型标记为dynamic。还可以将一个表达式的结果放到一个变量中,并将变量的类型标记为dynamic。
代码使用dynamic表达式/变量调用一个成员时,编译器会生成一个特殊的IL代码来描述所需的操作。这种特殊的代码称为payload(有效载荷)代码。
在运行时payload代码会根据当前由dynamic表达式/变量引用的对象的实际类型来决定具体执行的操作。
Plus方法将参数的类型申明为dynamic,在方法内部,实参作为二元+操作符的两个操作数使用。
由于arg是dynamic,所以C#编译器会生成payload代码,以便在运行时检查arg的实际类型,并决定+操作符实际要做的事情。
第一次调用Plus时,传递的是5(一个Int32),所以Plus向它的调用者返回值10。结果放到result变量(一个dynamic类型)中。
第二次调用Plus时,同第一次的原理一样。
在字段类型、方法参数或方法返回类型被指定为dynamic的前提下,编译器会将这个类型转换为System.Object,并在元数据中向字段、参数或方法类型应用System.Runtime.ComplierServices.DynamicAttribute的一个实例。