编程语言的基元类型
某些数据类型如此经常使用,以致于许多编译器容许代码以简化的语法来操纵它们。
System.Int32 a = new System.Int32(); // a = 0
a = 1;
等价于:
int a = 1;
这种语法不只加强了代码的可读性,其生成的IL代码与使用System.Int32时生成的IL代码是彻底一致的。
编译器直接支持的数据类型称为基元类型(primitive type)。基元类型直接映射到Framework类库(FCL)中存在的类型。如C#中,int直接映射System.Int32类型。
C#的语言规范称:“从风格上说,最好是使用关键字,而不是使用完整的系统类型名称。”其实,也许使用FCL类型名称,避免使用基元类型名称才是更好的作法。
CLR支持两种类型:引用类型和值类型。FCL中的大多数类型都是引用类型,可是程序员用得最多的仍是值类型。
引用类型
引用类型老是从托管堆上分配的,C#的new 操做符会返回对象的内存地址 - 也就是指向对象数据的内存地址。使用引用类型时候,须注意到一些性能问题,即一下事实:
#1, 内存必须从托管堆上分配
#2, 堆上分配的每一个对象都有一些额外的成员,这些成员必须初始化
#3, 对象中的其余字节(为字段而设)老是设为零
#4, 从托管堆上分配一个对象时,可能强制执行一次垃圾收集操做
值类型
为了尽量的提升性能,提高简单的、经常使用的类型的性能,CLR提供了名为“值类型”的轻量级类型。值类型通常在线程栈上分配(由于也会做为字段,嵌入一个引用类型的对象中)。
在表明值类型实例的一个变量中,并不包含一个指向实例中的指针。相反,值类型的变量中包含了这个实例自己的字段(值),那么操做实例中的字段,也就再也不须要提领一个指针。值类型的实例不受垃圾回收器的控制。
值类型的设计和使用的好处以下:
#1, 缓解了托管堆中的压力
#2, 减小了应用程序在其内存期内须要进行的垃圾回收次数
自定义值类型不可有基类型,可是能够实现一个或则多个接口。除此以外,全部的值类型都是隐式密封的(sealed),目的是防止将一个值类型做为其余任何引用类型或者值类型的基类型。
设计本身的类型时,仔细考虑是否应该将一个类型定义成值类型,而不是定义成引用类型。某些时候,值类型是能够提供更好的性能的。
值类型不在堆上分配内存,因此一旦定义了该类型的实例的方法不在处于活动状态,为它们分配的存储就会被释放,这也意味着类型的实例在其内存被回收时,不会经过Finalize方法接受到一个通知。
CLR如何控制类型中的字段的布局
为了提升性能,CLR能按照它所选择的任何方式来排列类型的字段。如,CLR能够在内存中从新安排字段的顺序,从而将对象引用分为一组,同时正确排列和填充数据字段。然而,在定义一个类型时,针对类型的各个字段,
你能够指示CLR是严格按照本身指定的顺序排列,仍是采起CLR本身认为合适的方式从新排列。System.Runtime.InteropServices.StructLayoutAttribute。这反映在面向CLR的编译器作的事情。如Microsoft C# 编译器默认为引用类型(类)选择LayoutKind.Auto, 而为值类型(结构)选择LayoutKind.Sequential。
值类型的装箱和拆箱
值类型是比引用类型“轻型”的一种类型,由于他们不做为对象在托管堆中分配,不会被垃圾回收,也不经过指针来引用。
不少状况下,都须要获取对值类型的一个实例的引用。若是要获取对值类型的一个实例的引用,该实例就必须装箱。ArrayList对象的Add方法
public virtual Int32 Add(Object value);
ArrayList.Add须要获取对托管堆上的一个对象的引用(或指针)来做为参数。如果须要向ArrayList填充一组Int32数字,那么Int32数字必须转换成一个真正的、在托管堆的对象,并且获取对这个对象的一个引用。
如何将一个值类型转换成一个引用类型,要使用一个名为装箱(boxing)的机制。那么,值类型的一个实例进行装箱操做时在内部发生的事情:
#1, 在托管堆中分配好内存。分配的内存量是值类型的各个字段须要的内存量加上托管堆的全部对象都要的两个额外成员(类型对象指针和同步块索引)须要的内存量。
#2, 值类型的字段复制到新分配的对内存。
#3, 返回对象的地址。这个地址正是对这个对象的引用,值类型如今是一个引用类型。
注意,已装箱值类型的生存期超过了未装箱的值类型的生存期。FCL如今包含一组新的泛型集合类,它们使非泛型的集合类成为“过期”的东西。例如,泛型集合类进行了大量加强,性能也显著提高。
最大的一个加强就是泛型集合类容许开发人员在操做值类型的集合时不须要对集合中的项进行装箱/拆箱处理。除了提升性能上,还得到了编译时的类型安全性,源代码也由于强制类型转换的次数减小而变得更加清晰。
装箱以后,每每还要面临拆箱的可能。拆箱不是讲装箱倒过来进行,其代价也比装箱低不少。 如:Int32 i = (Int32)a[0]; 拆箱操做分为两步:
#1, 获取已装箱的对象中的各个字段的地址,这个过程就是拆箱。
#2, 将这些字段包含的值从堆中复制到基于栈(线程栈)的值类型实例中。
简单地说,若是获取对值类型的一个实例的引用,该实例就必须装箱。如上的ArrayList.Add 方法,将一个值类型的实例传给须要获取一个引用类型的方法,就会发生这种状况。
前面提到,未装箱的值类型是比引用类型更“轻型”的类型。这要归结于一下两个缘由:
#1, 它们不在托管堆上分配。
#2, 它们没有堆上的每一个对象都要的额外成员,也就是一个“类型对象指针”和一个“同步块索引”。
因此,因为未装箱的值类型没有同步块索引,就不能使用System.Threading.Monitor类型的各类方法(或者C#的lock语句)让多个线程同步对这个实例的访问。
关于值类型的几点说明:
#1, 值类型能够重写Equals, GetHashCode或者ToString的虚方法,CLR能够非虚地调用该方法,由于值类型是隐式密封的(即不存在多态性),没有任何类型可以从它们派生。
#2, 此外,用于调用方法的值类型实例不会被装箱。可是,若是你重写的虚方法要调用方法在基类中的实现,那么在调用基类的实现时,值类型实例就会装箱,以便经过this指针将对一个堆对象的引用传给基方法。
#3, 值类型调用一个非虚的、继承的方法时(好比GetType或MemberwiseClone),不管如何都要对值类型进行装箱。这是由于这些方法是由System.Object定义的,因此这些方法指望this实参是指向堆上一个对象的指针。
#4, 将值类型的一个未装箱实例转型为类型的某个接口时,要求对实例进行装箱。这是由于接口变量必须包含对堆上的一个对象的引用。
感言:
任何.NET Framework开发人员只有在切实理解了这些概念以后,才能保证本身开发程序的长期成功。由于只有深入理解了以后,才能更快、更轻松地构建高效率的应用程序。
重要提示:
在值类型中定义的成员不该该修改类型的任何实例字段。也就是说,值类型应该是不可变(immutable)的。事实上,我建议将值类型的字段都标记为readonly。这样一来,若是不慎写了一个方法企图更改一个字段,编译就没法经过。
由于假如一个方法企图修改值类型的实例字段,由于装箱的变化,调用这个方法就会产生非预期的行为。构造好一个值类型以后,若是不去调用任何会修改其状态的方法(或者若是根本不存在这样的方法),就不用再为何时候会发生装箱和拆箱/字段复制而担忧。若是一个值类型是不可变的,只需简单地复制相同的状态就能够了(不用担忧任何方法会修改这些状态),代码的任何行为都将在你的掌握之中。
也许,你在看到值类型的这些细微末节时远离自定义值类型,或者你历来就没用过自定义值类型。可是,FCL的核心值类型(Byte, Int32, ... 以及全部的enums 都是“不可变”的);而且了解并记住这些可能问题,当代码真正出现这些问题的时候,也就会心中有数。
对象相等性和同一性
对于Object的Equals方法的默认实现来讲,它实现的实际是同一性(identity),而非相等性(equality)。
对象哈希码
一种算法,让同一个类的对象按照本身不一样的特征尽可能的有不一样的哈希码,但不表示不一样的对象哈希码彻底不一样。”哈希码就是对象的身份证。“
dynamic
dynamic表达式实际上是和System.Object同样的类型。编译器假定你在表达式上进行的任何操做都是合法的,因此不会生成任何警告或者错误。但若是试图在运行时执行无效的操做,就会抛出异常。
类型和成员基础
类型的各类成员
常量、字段、实例构造器、类型构造器、方法、操做符重载、转换操做符、属性、事件、类型
类型的可见性
public: 不只对它的定义程序集中的全部代码可见,还对其余程序集中的代码可见。
internal: 类型仅对定义程序集中的全部代码可见,对其余程序集中的代码不可见。
若定义类型时,若是不显式指定类型的可见性,C#编译器默认将类型的可见性设为internal。
友元程序集
咱们但愿TeamA程序集能有一个办法将其工具类型定义为internal, 同时仍然容许团队TeamB访问这些类型。
CLR和C#经过友元程序集(friend assembly)来提供这方面的支持。若是但愿在一个程序集中包含代码,对另外一个程序集中的内部类型执行单元测试,友元程序集功能也能派上用场。
成员的可访问性
CLR本身定义了一组可访问性修饰符,但每种编程语言在向成员应用可访问性时,都选择了本身一组术语以及相应的语法。如,CLR使用Assembly来代表成员对同一程序集内的全部代码可见,而C#对应的术语是internal。
C#: private, protected, internal, protected internal, public
一个派生类型重写在它的基类型中定义的一个成员时,C#编译器要求原始成员和重写成员具备相同的可访问性。也就是说,若是基类中的成员是protected的,派生类中的重写成员必须也是protected的。可是,这只是C#语言自己的一个限制,而不是CLR的。从一个基类派生时,CLR容许放宽成员的可访问性限制,但不容许收紧。之因此不容许在派生类中将对一个基类方法的访问变得更严格,是由于CLR承诺派生类老是能够转型为基类,并获取对基类方法的访问权。若是容许在派生类中对重写方法进行更严格的访问限制,CLR的承诺就没法兑现了。
分部类、结构和接口
这个功能彻底是由C#编译器提供的,CLR对于分部类、结构和接口是一无所知的。partial 这个关键字告诉C#编译器,一个类,结构或者接口的定义,其源代码可能要分散到一个或者多个源代码文件中。
CLR如何调用虚方法、属性和事件
方法表明在类型或者类型的实例上执行某些操做的代码。在类型上执行操做,称为静态方法;在类型的实例上执行操做,称为非静态方法。任何方法都有一个名称、一个签名和一个返回值(能够是void)。
合理使用类型的可见性和成员的可访问性
使用.Net Framework时,应用程序极可能是使用多个公司声场的多个程序集所定义的类型构成的。这意味着开发人员对所用的组件(程序集)以及其中定义的类型几乎没有什么控制权。开发人员一般没法访问源代码(甚至不知道组件是用什么编程语言建立的),并且不一样组件的版本发布通常都基于不一样的时间表。除此以外,因为多态和受保护(protected)成员,基类开发人员必须信任派生类开发人员所写的代码。固然,派生类的开发人员也必须信任从基类继承的代码。设计组件和类型时,应该慎重考虑这些问题。具体地说,就是要着重讨论如何正确设置类型的可见性和成员的可访问性,以便取得最好的结果。
在定义一个新类型时,编译器应该默认生成密封类,使它不能做为基类使用。可是包括C#编译器在内的许多编译器都默认生成非密封类,固然容许开发人员使用sealed显式地将新类型标记为密封。
密封类之因此比非密封类更好,有如下三方面缘由:
#1, 版本控制:类开始是密封的,未来能够不破坏兼容性的前提下更改成非密封的。反之,否则。
#2, 性能:类是密封的,就确定不会有派生类,调用方法时,就不需判断是哪一个类型定义了要调用的方法,即不须在运行时查找对象的类型,而直接采用非虚的方式调用虚方法。
#3, 安全性和可预测性:派生类既可重写基类的虚方法,也可直接带哦用这个虚方法在基类中的实现。一旦将某个方法、属性或事件设为virtual,基类就会丧失对它的行为和状态的部分控制权。
下面是定义类时会遵循的一些原则:
扩展阅读:
每一个应用程序都要使用这样或者那样的资源,好比文件、内存缓冲区、屏幕空间、网络链接、数据库资源等。事实上,在面向对象的环境中,每一个类型都表明可供程序使用的一种资源。
要使用这些资源,必须为表明资源的类型分配内存。
访问一个资源所需的具体步骤以下:
#1,调用IL指令newobj, 为表明资源的类型分配内存。C#中使用new操做符,编译器就会自动生成该指令。
#2,初始化内存,设置资源的初始状态,使资源可用。类型的实例构造器负责设置该初始状态。
#3,访问类型的成员(可根据须要反复)来使用资源。
#4,摧毁资源的状态以进行清理。
#5,释放内存。垃圾回收将独自负责这一步。
须要注意的是,值类型(含全部枚举类型)、集合类型、String、Attribute、Delegate和Exception 所表明的资源无需执行特殊的清理操做。如,只要销毁对象的内存中维护的字符数组,一个String资源就会被彻底清理。
CLR要求全部的资源都从托管堆(managed heap)分配。应用程序不须要的对象会被自动清除。那么“托管堆又是如何知道应用程序再也不用一个对象?”
进程初始化时,CLR要保留一块连续的地址空间,这个地址空间最初并无对象的物理内存空间。这个地址空间就是托管堆。托管堆还维护着一个指针,我把它称为NextObjPtr。指向下一个对象在堆中的分配位置。刚开始时候,NextObjPtr设为保留地址空间的基地址。
IL指令newobj用于建立一个对象。许多语言都提供了一个new操做符,它致使编译器在方法的IL代码中生成一个newobj指令。newobj指令将致使CLR执行以下步骤:
#1,计算类型(极其全部基类型)的字段须要的字节数。
#2,加上字段的开销所需的字节数。每一个对象都有两个开销字段:一个是类型对象指针,和一个同步块索引。
#3,CLR检查保留区域是否可以提供分配对象所需的字节数,若有必要就提交存储(commit storage)。若是托管堆有足够的可用空间,对象会被放入。对象是在NextObjPtr指针指向的地址放入的,而且为它分配的字节会被清零。接着,调用类型的实例构造器(为this参数传递NextObjPtr), IL指令newobj(或者C# new 操做符)将返回对象的地址。就在地址返回以前,NextObjPtr指针的值会加上对象占据的字节数,这样会获得一个新值,它就指向下一个对象放入托管堆时的地址。
做为对比,让咱们看一下C语言运行时堆如何分配内存,它为对象分配内存须要遍历一个由数据结构组成的链表,一旦发现一个足够大的块,那个块就会被拆分,同时修改链表节点中的指针,以确保链表的完整性。
对于托管堆,分配对象只需在一个指针上加一个值 - 这显然要快得多。事实上,从托管堆中分配对象的速度几乎能够与从线程栈分配内存媲美!
另外,大多数堆(C运行时堆)都是在他们找到可用空间的地方分配对象。因此,若是连续建立几个对象,这些对象极有可能被分散,中间相隔MB的地址空间。但在托管堆中,连续分配的对象能够确保它们在内存中是连续的。
托管堆彷佛在实现的简单性和速度方面远远优于普通的堆,如C运行时堆。而托管堆之因此有这些好处,是由于它作了一个至关大胆的假设 - 地址空间和存储是无限的。而这个假设显然是不成立的,也就是说托管堆必须经过某种机制来容许它作这样的假设。这个机制就是垃圾回收器。