CLR via C#深解笔记三 - 基元类型、引用类型和值类型 | 类型和成员基础 | 常量和字段

时间:2023-04-18 11:56:50
编程语言的基元类型
某些数据类型如此常用,以至于许多编译器允许代码以简化的语法来操纵它们。
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),目的是防止将一个值类型作为其他任何引用类型或者值类型的基类型。
CLR via C#深解笔记三 - 基元类型、引用类型和值类型 | 类型和成员基础 | 常量和字段
设计自己的类型时,仔细考虑是否应该将一个类型定义成值类型,而不是定义成引用类型。某些时候,值类型是可以提供更好的性能的。
值类型不在堆上分配内存,所以一旦定义了该类型的实例的方法不在处于活动状态,为它们分配的存储就会被释放,这也意味着类型的实例在其内存被回收时,不会通过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,基类就会丧失对它的行为和状态的部分控制权。
下面是定义类时会遵循的一些原则:
CLR via C#深解笔记三 - 基元类型、引用类型和值类型 | 类型和成员基础 | 常量和字段
CLR via C#深解笔记三 - 基元类型、引用类型和值类型 | 类型和成员基础 | 常量和字段
扩展阅读:
每个应用程序都要使用这样或者那样的资源,比如文件、内存缓冲区、屏幕空间、网络连接、数据库资源等。事实上,在面向对象的环境中,每个类型都代表可供程序使用的一种资源。
要使用这些资源,必须为代表资源的类型分配内存。
访问一个资源所需的具体步骤如下:
#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指针的值会加上对象占据的字节数,这样会得到一个新值,它就指向下一个对象放入托管堆时的地址。
CLR via C#深解笔记三 - 基元类型、引用类型和值类型 | 类型和成员基础 | 常量和字段
CLR via C#深解笔记三 - 基元类型、引用类型和值类型 | 类型和成员基础 | 常量和字段
作为对比,让我们看一下C语言运行时堆如何分配内存,它为对象分配内存需要遍历一个由数据结构组成的链表,一旦发现一个足够大的块,那个块就会被拆分,同时修改链表节点中的指针,以确保链表的完整性。
对于托管堆,分配对象只需在一个指针上加一个值 - 这显然要快得多。事实上,从托管堆中分配对象的速度几乎可以与从线程栈分配内存媲美!
另外,大多数堆(C运行时堆)都是在他们找到可用空间的地方分配对象。所以,如果连续创建几个对象,这些对象极有可能被分散,中间相隔MB的地址空间。但在托管堆中,连续分配的对象可以确保它们在内存中是连续的。
托管堆似乎在实现的简单性和速度方面远远优于普通的堆,如C运行时堆。而托管堆之所以有这些好处,是因为它做了一个相当大胆的假设 - 地址空间和存储是无限的。而这个假设显然是不成立的,也就是说托管堆必须通过某种机制来允许它做这样的假设。这个机制就是垃圾回收器。