1.1 将源代码编译成托管模块
公共语言运行时(Common Language Runtime,CLR)是一个可由多种编程语言使用的“运行时”。CLR的核心功能(比如内存管理、程序集加载、安全性、异常处理和线程同步)可由面向CLR的所有语言使用。
可用支持CLR的任何语言创建源代码文件,然后用对应的编译器检查语法和分析源代码。无论选择哪个编译器,结果都是托管模块(managed module)。托管模块是标准的32位Microsoft Windows可移植执行体(PE32)文件,它们都需要CLR才能执行
托管模块的各个部分
组成部分 | 说明 |
PE32或PE32+头 | 标准Windows PE文件头,类似于“公共对象文件格式”(Common Object File Format,COFF)头。如果这个头使用PE32格式,文件能在Windows的32位或64位版本上运行。如果这个头使用PE32+格式,文件只能在Windows的64位版本上运行。这个头还表示了文件类型,包括GUI,CUI或者DLL,并包含一个时间标记来指出文件的生成时间。对于只包含IL代码的模块,PE32(+)头的大多数信息会被忽视。如果是包含本机(native)CPU代码的模块,这个头包含与本机CPU代码有关的信息 |
CLR头 | 包含使这个模块成为托管模块的信息(可由CLR和一些实用程序进行解释)。头中包含要求的CLR版本,一些标志(flag),托管模块入口方法(Main方法)的MethodDef元数据token以及模块的元数据、资源、强名称、一些标志及其他不太重要的数据项的位置/大小 |
元数据 | 每个托管模块都包含元数据表。主要有两种表:一种表描述源代码中定义的类型和成员,另一种描述源代码引用的类型和成员 |
IL(中间语言)代码(托管代码) | 编译器编译源代码时生成的代码。在运行时,CLR将IL编译成本机CPU指令 |
元数据简单地说就是一个数据表集合。有多种用途,下面仅列举一部分。
- 元数据避免了编译时对原生C/C++头和库文件的需求,因为在实现类型/成员的IL代码文件中,已包含有关引用类型/成员的IL代码文件中,已包含有关引用类型/成员的全部信息。编译器直接从托管模块读取元数据。
- Microsoft Visual Studio用元数据帮助你写代码。“智能感知”(IntelliSense)技术会解析元数据,告诉你一个类型提供了那些方法、属性、事件和字段。对于方法,还能告诉你需要的参数。
- CLR的代码验证过程使用元数据确保代码只执行“类型安全”的操作。(稍后就会讲到验证。)
- 元数据允许将对象的字段序列化到内存块,将其发送给另一台机器,然后反序列化,在远程机器上重建对象状态。
- 元数据允许垃圾回收器跟踪对象生存期。垃圾回收器能判断任何对象的类型,并从元数据知道那个对象中的那些字段引用了其他对象。
1.2 将托管模块合并成程序集
CLR实际不和模块工作。它和程序集工作。程序集(assembly)是抽象概念,初学者很难把握它的精髓。首先,程序集是一个或多个模块/资源问价的逻辑性分组。其次,程序集是重用、安全性以及版本控制的最小单元。取决于你选择的编译器或工具,既可生成单文件程序集,也可生成多文件程序集。在CLR的世界中,程序集相当于”组件“。
利用”程序集“这种概念性的东西,一组文件可作为一个单独的实体来对待
编译器默认将生成的托管模块转换成程序集。也就是说,C#编译器生成的是含有清单的托管模块。清单指出程序集只由一个文件构成。所以,对于只有一个托管模块而且无资源(或数据)文件的项目,程序集就是托管模块,生成过程中无需执行任何额外的步骤。但是,如果希望将一组文件合并到程序集中,就必须掌握更多的工具(比如程序集链接器 AL.exe)及其命令行选项。
在程序集的模块中,还包含与引用的程序集有关的信息(包括他们的版本号)。这些信息使程序集能够自描述(self-describing)。也就是说,CLR 能判断为了执行程序集中的代码,程序集的直接依赖对象(immediate dependency)是什么。不需要在注册表或 Active Directory Domain Services(ADDS)中保存额外的信息。由于无需额外信息,所以和非托管组件相比,程序集更容易部署。
1.3 加载公共语言运行时
生成的每个程序集既可以是可执行应用程序,也可以是DLL(其中含有一组由可执行程序使用的类型)。当然,最终是由 CLR 管理这些程序集中的代码的执行。这意味着目标机器必须安装好.NET Framework。
学习CLR具体如何加载之前,稍微花些时间了解Windows的32位和64位版本。如果程序集文件只包含类型安全的托管代码,代码在32位和64位Windows上都能正常工作。在这两种Windows上运行,源代码无需任何改动。事实上,编译器最终生成的EXE/DLL文件在 Windows的 x86和x64 版本上都能正常工作
可执行文件运行时, Windows检查文件头,判断需要32位还是64位地址空间。PE32文件在32位或64位地址空间中均可运行,PE32+文件则需要64位地址空间。Windows 还会检查头中嵌入的CPU架构信息,确保当前计算机的CPU符合要求。最后,WIndows的64位版本通过WoW64(Windows on Windows64)技术运行32位Windows应用程序。
下表总结了两方面的信息。其一,为C#编译器指定不同/platform命令行开关将得到哪种托管模块。其二 ,应用程序在不同版本的Windows上如何运行。
/platform 开关 | 生成的托管模块 | x86 Windows | x64 Windows | ARM Windows RT |
anycpu(默认) | PE32/任意CPU架构 | 作为32位应用程序运行 | 作为64位应用程序运行 | 作为32位应用程序运行 |
anycpu32bitpreferred | PE32/任意CPU架构 | 作为32位应用程序运行 | 作为WoW64应用程序运行 | 作为32位应用程序运行 |
x86 | PE32/x86 | 作为32位应用程序运行 | 作为WoW64应用程序运行 | 不运行 |
x64 | PE32+/x64 | 不运行 | 作为64位应用程序运行 | 不运行 |
ARM | PE32/ARM | 不运行 | 不运行 | 作为32位应用程序运行 |
Windows检查EXE文件头,决定是创建32位还是64位进程之后,会在进程地址空间加载MSCorEE.dll的 x86,x64或ARM版本。如果是Windows的x86或ARM版本,MSCorEE.dll的x86版本在%SystemRoot%\System32
目录中。如果是Windows的x64版本,MSCorEE.dll的x86版本在%SystemRoot%\SysWow64
目录中,64位版本则在%SystemRoot%\System32
目录中(为了向后兼容)。然后,进程的主线程调用MSCorEE.dll中定义的一个方法。这个方法初始化CLR,加载EXE程序集,再调用其入口方法(Main)。随后,托管应用程序启动并运行。【可在代码中查询Environment 的 Is64BitOperatingSystem属性,判断是否在64位 Windows 上运行。还可查询Environment 的 Is64BitProcess 属性,判断是否在64位地址空间中运行。】
1.4 执行程序集的代码
如前所述,托管程序集同时包含IL和元数据。IL 是与CPU无关的机器语言,是 Microsoft 在请教了外面的几个商业及学术性语言/编译器的作者之后,费尽心思开发出来的。IL 比大多数CPU机器语言都高级。IL 能访问和操作对象类型,并提供了指令来创建和初始化对象、调用对象上的虚方法以及直接操作数组元素。甚至提供了抛出和捕捉异常的指令来实现错误处理。可将IL 视为一种面向对象的机器语言。
开发人员一般用C#,Visual Basic 或F#等高级语言进行编程。它们的编译器将生成IL。
注意,高级语言通常只公开了CLR全部功能的一个子集。然而,IL汇编语言允许开发人员访问CLR的全部功能。所以,如果你选择的编程语言隐藏了你迫切需要的一个CLR功能,可以换用IL 汇编语言或者提供了所需功能的另一种编程语言来写那部分代码。
ps:C#不具有CLR的全部功能,目的是简化开发,使得程序员可以专注于更高层次的逻辑,而不需要直接操作底层的CLR细节。CLR提供了丰富的功能,如内存管理、线程管理、异常处理等,但是高级语言(比如C#)通常会屏蔽一些复杂的底层实现,提供更高层的抽象。
重要提示 在我看来,允在在不同编程语言之间方便地切换,同时又保持紧密集成,这是CLR的一个很出众的特点。遗憾的是,许多开发人员都忽视了这一点。例如,C# 和 Visual Basic 等语言能很好地执行I/O操作,APL语言能很好地执行高级工程或金融计算。通过CLR,应用程序的I/O部分可用C#编写,工程计算部分则换用APL编写。CLR在这些语言之间提供了其他技术无法媲美的集成度,使”混合语言编程“成为许多开发项目一个值得慎重考虑的选择。
为了执行方法,首先必须把方法的IL转换成本机(naive)CPU 指令。这是 CLR 的JIT(just-in-time或者”即时“)编译器的职责。下图展示了一个方法首次调用时发生的事情。
就在 Main方法执行之前,CLR会检测出 Main的代码引用的所有类型。这导致CLR分配一个内部数据结构来管理对引用类型的访问。上的Main 方法引用了一个 Console类型,导致CLR分配一个内部结构。在这个内部数据结构中,Console 类型定义的每个方法都有一个对应的记录项。每个记录项都含有一个地址,根据此地址即可找到方法的实现。对这个结构初始化时,CLR将每个及录项都设置成(指向)包含在CLR内部的一个未编档函数。将该函数称为JITCompiler。
Main 方法首次调用 WriteLine时,JITCompiler函数会被调用。JITCompiler函数负责将方法的IL 代码编译成本机CPU指令。由于IL 是”即时“(just in time)编译的,所以通常将CLR的这个组件称为JITter或者 JIT编译器。
注意 如果应用程序在Windows的x86版本或者WoW64中运行,JIT编译器将生成x86指令。作为64位应用程序在Windows的x64版本中运行,将生成x64指令。在Windows的ARM版本中运行,将生成ARM指令。
JITCompiler 函数被调用时,它知道要调用的是哪个方法,以及具体是什么类型定义了该方法。然后,JITCompiler 会在定义(该类型的)程序集的元数据中查找被调用方法的IL。接着,JITCompiler 验证IL代码,并将IL 代码编译成本机CPU指令。本机CPU指令保存到动态分配的内存块中。然后,JITCompiler 回到CLR 为类型创建的内部数据结构,找到与被调用方法对应的那条记录,修改最初对JITCompiler 的引用,使其指向内存块(其中包含了刚才编译好的本机CPU指令)的地址。最后,JITCompiler 函数跳转到内存块中的代码。这些代码正是WriteLine 方法(获取单个String 参数的那个版本)的具体实现。代码执行完毕并返回时,会回到Main 中的代码,并象往常一样继续执行。
现在, Main 要第二次调用 WriteLine。这一次,由于已对WriteLine 的代码进行了验证和编译,所以会直接执行内存块中的代码,完全跳过 JITCompiler 函数。 WriteLine 方法执行完毕后,会再次回到Main。下图展示了第二次调用WriteLine时发生的事情。
方法仅在首次调用时才会有一些性能损失。以后对该方法的所有调用都以本机代码的形式全速运行,无需重新验证IL并把它编译成本机代码。
JIT编译器将本机CPU指令存储到动态内存中。这意味着一旦应用程序终止,编译好的代码也会被丢弃。所以,将来再次运行应用程序,或者同时启动应用程序的两个实例(使用两个不同的操作系统进程),JIT编译器必须再次将IL编译成本机指令。对于某些应用程序,这可能显著增加内存耗用。
对于大多数应用程序,JIT编译造成的性能损失并不显著。大多数应用程序都反复调用相同的方法。应用程序运行期间,这些方法只会对性能造成一次性的影响。另外,在方法内部花费的时间很有可能比花在调用方法上的时间多得多。
还要注意,CLR 的JIT 编译器会对本机代码进行优化,这类似于非托管C++编译器的后端所做的事情。同样,可能花较多时间生成优化代码。但和没有优化时相比,代码优化后性能更佳。
编译器开关设置 | C#IL代码质量 | JIT本机代码质量 |
/optimize-/debug-(默认) | 未优化 | 有优化 |
/optimize-/debug(+/full/pdbonly) | 未优化 | 有优化 |
/optimize+/debug(-/+/full/pdbonly) | 有优化 | 有优化 |
使用 /optimize-,在C#编译器生成的未优化IL 代码中,将包含许多 NOP(no-operation,空操作)指令,还包含许多跳转到下一行代码的分支指令。Visual Studio利用这些指令在调试期间提供“编辑并继续”(edit-and-continue)功能。另外,利用这些额外的指令,还可在控制流程指令(比如 for, while, do, if, else, try, catch 和 finally语句块)上设置断点,使代码更容易调试。相反,如果生成优化的IL代码,C#编译器会删除多余的NOP和分支指令。而在控制流程被优化之后,代码就难以在调试器中进行单步调试了。另外,若在调试器中执行,一些函数求值可能无法进行。不过,优化的IL代码变得更小,结果 EXE/DLL文件也更小。
此外,只有指定 /debug(+/full/pdbonly)开关,编译器才会生成Program Database(PDB)文件。PDB文件帮助调试器查找局部变量并将IL指令映射到源代码。/debug:full开关告诉JIT编译器你打算调试程序集,JIT编译器会记录每条IL指令所生成的本机代码。这样一来,就可利用 Visual Studio 的“即时”调试功能,将调试器连接到正在运行的进程,并方便地对源代码进行调整。不指定/debug:full开关,JIT编译器默认不记录IL与本机代码的联系,这使JIT编译器运行得稍快,用的内存也稍少。如果进程用 Visual Studio 调试器启动,会强迫JIT编译器记录IL与本机代码的联系(无论 /debug开关的设置是什么)——除非在Visual Studio中关闭了“在模块加载时取消JIT优化(仅限托管)”选项。
在 Visual Studio 中新建 C#项目时,项目的“调试”(Debug)配置指定的是 /optimize-和 /debug:full开关,而“发布”(Release)配置指定的是 /optimize+ 和 debug:pdbonly 开关。
在现在这种托管环境中,代码的编译是分两个阶段完成的。首先,编译器遍历源代码,做大量工作来生成IL代码。但真要想执行,这些IL 代码本身必须在运行时编译成本机 CPU 指令,这需要分配更多的非共享内存,并要花费额外的CPU时间。
下面列举了托管代码相较于非托管代码的优势。
- JIT编译器能判断应用程序是否运行在Intel Pentinum 4 CPU上,并生成相应的本机代码来利用 Pentium 4 支持的任何特殊指令。相反,非托管应用程序通常是针对具有最小功能集合的CPU编译的,不会使用能提升性能的特殊指令。
- JIT编译器能判断一个特定的测试在它运行的机器上是否总是失败。例如,假定一个方法包含以下代码:
if (numberOfCPUs > 1){ …… }
如果主机只有一个CPU, JIT编译器不会为上述代码生成任何CPU指令。在这种情况下,本机代码将针对主机进行优化,最终代码变得更小,执行得更快。 - 应用程序运行时, CLR 可以评估代码的执行,并将 IL 重新编译成本机代码。重新编译的代码可以重新组织,根据刚才观察到的执行模式,减少不正确的分支预测。虽然目前版本的 CLR 还不能做到这一点,但将来的版本也许就可以了。
1.4.1 IL和验证
IL 基于栈。这意味着它的所有指令都要将操作数压入(push)一个执行栈,并从栈弹出(pop)结果。由于IL 没有提供操作寄存器的指令,所以人们可以很容易地创建新的语言的编译器,生成面向 CLR 的代码。
IL 指令还是“无类型”(typeless)的。例如,IL 提供了 add指令将压入栈的最后两个操作数加到一起。 add 指令不分 32 位和 64 位版本。add 指令执行时,它判断栈中的操作数的类型,并执行恰当的操作。
我个人认为,IL 最大的优势不是它对底层 CPU 的抽象,而是应用程序的健壮性和安全性。将IL 编译成本机CPU 指令时,CLR执行一个名为验证(verification)的过程。这个过程会检查高级IL 代码,确定代码所做的一切都会安全的。例如,会核实调用的每个方法都有正确数量的参数,传给每个方法的每个参数都有正确的类型,每个方法的返回值都得到了正确的使用,每个方法都有一个返回语句,等等。托管模块的元数据包含验证过程要用到的所有方法及类型信息。
这里有必要强调一下健壮性(鲁棒性)和可靠性的区别,两者对应的的英文单词分别是 robustness 和 reliability。健壮性主要描述系统对于参数变化的不敏感性,而可靠性主要描述系统的正确性,也就是在你固定提供一个参数时,它应该产生稳定的、能预测的输出。例如一个程序,它的设计目标是获取输入并输出值。假如它能正确完成这个设计目标,就说它是可靠的。但在这个程序执行完毕后,假如没有正确释放内存,或者说系统没有自动帮它释放占用的资源,就认为这个程序及其“运行时”不健壮。 ——译注
Windows 的每个进程都有自己的虚拟地址空间。独立地址空间之所以必要,是因为不能简单地信任一个应用程序的代码。应用程序完全可能读写无效的内存地址(令人遗憾的是,这种情况时有发生)。将每个Windows 进程都放到独立的地址空间,将获得健壮性与稳定性;一个进程干扰不到另一个进程。
然后,通过验证托管代码,可确保代码不会不正确地访问内存,不会干扰到另一个应用程序的代码。这样就可以放心地将多个托管应用程序放到同一个Windows 虚拟地址空间运行。
由于 Windows 进程需要大量操作系统资源,所以进程数量太多,会损害性能并制约可用的资源。用一个进程运行多个应用程序,可减少进程数,从而增强性能,减少所需的资源,健壮性也没有丝毫下降。这是托管代码相较于非托管代码的另一个优势。
事实上,CLR 确实提供了在一个操作系统进程中执行多个托管应用程序的能力。每个托管应用程序都在一个【AppDomain】中执行。每个托管EXE文件默认都在它自己的独立地址空间中运行,这个地址空间只有一个AppDomain。然而,CLR 的宿主进程(比如IIS 或者Microsoft SQL Server)可决定在一个进程中运行多个 AppDomain。
1.4.2 不安全的代码
Microsoft C#编译器默认生成安全(safe)代码,这种代码的安全性可以验证。然而,Microsoft C#编译器也允许开发人员写不安全的(unsafe)代码。不安全的代码允许直接操作内存地址,并可操作这些地址处的字节。这是非常强大的一个功能,通常只有在与非托管代码进行互操作,或者在提升对效率要求极高的一个算法的性能的时候,才需要这样做。
然而,使用不安全的代码存在重大风险:这种代码可能破坏数据结构,危害安全性,甚至造成新的安全漏洞。有鉴于此,C#编译器要求包含不安全代码的所有方法都用 unsafe 关键字标记。除此之外,C#编译器要求使用 /unsafe 编译器开关来编译源代码。
当 JIT 编译器编译一个 unsafe 方法时,会检查该方法所在的程序集是否被授予了 System.Security.Permissions.SecurityPermission 权限,而且 System.Security.Permissions.SecurityPermissionFlag 的 SkipVerification 标志是否设置。如果该标志已经设置,JIT 编译器会编译不安全的代码,并允许代码执行。CLR信任这些代码,并希望对地址及字节的直接操作不会造成损害。如果标志未设置,JIT编译器会抛出 System.InvalidProgramException 或 System.Security.VerificationException 异常,禁止方法执行。事实上,整个应用程序都有可能在这个时候终止,但这至少能防止造成损害。
注意 从本地计算机或“网络共享”加载的程序集默认被授予完全信任,这意味着它们能做任何事情,包括执行不安全代码。但通过Internet执行的程序集默认不会被授予执行不安全代码的权限。如果含有不安全的代码,就会抛出上述异常之一。管理员和最终用户可以修改这些默认设置;但在这种情况下,管理员要对代码的行为负全责。
1.5 本机代码生成器:NGen.exe
使用.NET Framework 提供的NGen.exe 工具,可以在应用程序安装到用户的计算机上时,将 IL代码编译成本机代码。由于代码在安装时已经编译好,所以 CLR 的 JIT 编译器不需要运行时编译 IL 代码,这有助于提升应用程序的性能。NGen.exe 能在以下两种情况下发挥重要作用。
- 提高应用程序的启动速度
运行 NGen.exe 能提高启动速度,因为代码已编译成本机代码,运行时不需要再花时间编译。 - 减小应用程序的工作集
如果一个程序集同时加载到多个进程中,对该程序集运行NGen.exe 可减小应用程序的工作集。NGen.exe 将 IL 编译成本机代码,并将这些代码保存到单独的文件中。该文件可以通过“内存映射”的方式,同时映射到多个进程地址空间中,使代码得到了共享,避免每个进程都需要一份单独的代码拷贝。
所谓工作集(working set),是指在进程的所有内存中,已映射的物理内存那一部分(即这些内存块全在物理内存中,并且 CPU 可以直接访问);进程还有一部分虚拟内存,它们可能在转换列表中(CPU 不能通过虚地址访问,需要 Windows 映射之后才能访问);还有一部分内存在磁盘上的分页文件中。——译注
现在,每当 CLR 加载程序集文件,都会检查是否存在一个对应的、由 NGen 生成的本机文件。如果找不到本机文件, CLR 就和往常一样对 IL 代码进行 JIT 编译。如果有对应的本机文件, CLR 就直接使用本机文件中编译好的代码,文件中的方法不需要在运行时编译。
表面上很完美!一方面,获得了托管代码的所有好处(垃圾回收、验证、类型安全等等);另一方面,没有托管代码(JIT 编译)的所有性能问题。但是,不要被表面所迷惑。NGen 生成的文件有以下问题。
- 没有知识产权保护
许多人以为发布 NGen 生成的文件(而不发布包含原始IL代码的文件)能保护知识产权。但遗憾的是,这是不可能的。在运行时, CLR 要求访问程序集的元数据(用于反射和序列化等功能),这就要求发布包含 IL 和元数据的程序集。此外,如果 CLR 因为某些原因不能使用 NGen 生成的文件(如后文所述),CLR 会自动对程序集的 IL 代码进行 JIT 编译,所以 IL 代码必须处于可用状态。 - NGen生成的文件可能失去同步
CLR 加载 NGen 生成的文件时,会将预编译代码的许多特征与当前执行环境进行比较。任何特征不匹配, NGen 生成的文件就不能使用。此时要改为使用正常的JIT 编译器进程。下面列举了必须匹配的部分特征。 - CLR版本:随补丁或 Service Pack 改变
- CPU类型:升级处理器发生改变
- Windows 操作系统版本: 安装新 Service Pack 后改变
- 程序集的标识模板版本ID(Module Version ID, MVID):重新编译后改变
- 引用的程序集的版本ID:重新编译引用的程序集后改变
- 安全性:吊销了之前授予的权限之后,安全性就会发生改变。这些权限包括声明性继承(declarative inheritance)、声明性链接时(declarative link-time)、 SkipVerification 或者 UnmanagedCode 权限。
- 较差的执行时性能
编译代码时, NGen 无法像 JIT 编译器那样对执行环境进行许多假定。这会造成 NGen.exe 生成较差的代码。例如,NGen 不能优化地使用特定 CPU 指令;静态字段只能间接访问,因为静态字段的实际地址只能在运行时确定。 NGen 到处插入代码来调用类构造器,因为它不知道代码的执行顺序,也不知道一个类构造器是否已经调用。(第 8 章 “方法”会详细讲述类构造器的问题)测试表明,相较于 JIT 编译的版本, NGen 生成的某些应用程序在执行时反而要慢 5% 左右。所以,假如考虑使用 NGen 版本不会变的更慢!对于某些应用程序,由于缩小工作集能提升性能,所以使用 NGen 仍有优势。
注意 可以用更新(update)模式运行 NGen.exe,为以前用 NGen 生成的所有程序集再次运行 NGen.exe。 用户一旦安装了.NET Framework 的新Service Pack,这个 Service Pack 的安装程序就会自动用更新模式运行 NGen.exe,使 NGen 生成的文件与新安装的 CLR 版本同步。
declarative inheritance 权限是派生出程序集的那个类所要求的;declarative link-time 权限是程序集调用的方法所要求的。另外,虽然文档将 declarative 翻译成“声明性”,但个人更喜欢“宣告式”。 —— 译注
正是由于这些问题,所以使用 NGen.exe 时必须谨慎。对于服务器端应用程序, NGen.exe 的作用并不明显,有时甚至毫无用处,这是因为只有第一个客户端请求才会感受到性能下降,后续所有客户端请求都能以全速运行。此外,大多数服务器应用程序只需要代码的一个实例,所以缩小工作集不能带来任何好处。
对于客户端应用程序,使用 NGen.exe 也许能提高启动速度,或者能缩小工作集(如果程序集同时由多个应用程序使用)。即便程序集不由多个应用程序使用,用 NGen 来生成也可能会增强工作集。此外,用 NGen.exe 生成客户端应用程序的所有程序集, CLR 就不需要加载 JIT 编译器了,从而进一步缩小工作集。当然,只要有一个程序集不是用NGen 生成的,或者程序集的一个由 NGen 生成的文件无法使用,那么还是会加载 JIT 编译器,应用程序的工作集将随之增大。
1.6 Framework 类库
.NET Framework 包含 Framework 类库(Framework Class Library, FCL)。FCL 是一组 DLL 程序集的统称,其中含有数千个类型定义,每个类型都公开了一些功能。
部分常规的 FCL 命名空间
命名空间 | 内容说明 |
System | 包含每个应用程序都要用到的所有基本类型 |
System.Data | 包含用于和数据库通信以及处理数据的类型 |
System.IO | 包含用于执行流I/O 以及浏览目录/文件的类型 |
System.Net | 包含进行低级网络通信,并与一些常用 Internet 协议协作的类型 |
System.Runtime.InteropServices | 包含允许托管代码访问非托管操作系统平台功能(比如 COM 组件以及 Win32 或定制DLL 中的函数)的类型 |
System.Security | 包含用于保护数据和资源的类型 |
System.Text | 包含处理各种编码(比如 ASCII 和 Unicode)文本的类型 |
System.Threading | 包含用于异步操作和同步资源访问的类型 |
System.Xml | 包含用于处理XML架构(XML Scheme)和数据的类型 |
1.7 通用类型系统
CLR 一切都围绕类型展开。到目前为止,这一点应该很清楚了。类型向应用程序和其他类型公开了功能。通过类型,用一种编程语言写的代码能与另一种编程语言写的代码沟通。由于类型是 CLR 的根本,所以 Microsoft 制定了一个正式的规范来描述类型的定义和行为,这就是“通用类型系统”(Common Type System, CTS)。
- 字段(Field)
作为对象状态一部分的数据变量。字段根据名称和类型来区分。 - 方法(Method)
针对对象执行操作的函数,通常会改变对象状态。方法有一个名称、一个签名以及一个或多个修饰符。签名指定参数数量(及其顺序);参数类型;方法是否有返回值;如果有返回值,还要指定返回值类型。 - 属性(Property)
对于调用者,属性看起来像是字段。但对于类型的实现者,属性看起来像是一个方法(或者两个方法)。属性允许在访问值之前校验输入参数和对象状态,以及/或者仅在必要时才计算某个值。属性还允许类型的用户采用简化的语法。最后,属性允许创建只读或只写的“字段”。 - 事件(Event)
事件在对象以及其他相关对象之间实现了通知机制。例如,利用按钮提供的一个事件,可在按钮被单击之后通知其他对象。
CTS 还指定了类型可见性规则以及类型成员的访问规则。例如,如果将类型标记为 public (在C# 中使用 public 修饰符),任何程序集都能看见并访问该类型。但是,如果标记为 assembly (在 C# 中使用 internal 修饰符),只有同一个程序集中的代码才能看见并访问该类型。所以,利用 CTS 指定的规则,程序集为一个类型建立了可视边界,CLR 则强制(贯彻)了这些规则。
调用者虽然能“看见”一个类型,但并不是说就能随心所欲地访问它的成员。可利用一下选项进一步限制调用者对类型中成员的访问。
- private
成员只能由同一个类(class)类型中的其他成员访问。 - faimly
成员可由派生类型访问,不管那些类型是否在同一个程序集中。注意,许多语言(比如C++和C#)都用 protected 修饰符来标识 family。 - family and assembly
成员可由派生类型访问,但这些派生类型必须在同一个程序集中定义,许多语言(比如C# 和 Visual Basic)都没有提供这种访问控制。当然, IL 汇编语言不在此列。 - assembly
成员可由同一个程序集中的任何代码访问。许多语言都用 internal 修饰符来标识 assembly。 - family or assembly
成员可由任何程序集中的派生类型访问。成员也可由同一个程序集中的任何类型访问。C# 用 protected internal 修饰符标识 family or assembly。 - public
成员可由任何程序集中的任何代码访问。
CTS 规定一个类型只能从一个基类派生(单继承)。因此,虽然C++ 语言允许一个类型继承自多个基类型(多继承),但 CTS 既不能接收、也不能操作这样的类型。为了帮助开发人员,Microsoft 的 C++/CLI 编译器一旦检测到你试图创建的托管代码含有从多个基类型派生的类型,就会报错。
下面是另一条 CTS 规则:所有类型最终必须从预定义的 System.Object 类型继承。可以看出, Object 是 System 命名空间定义的一个类型的名称。Object 是其他所有类型的根,因而保证了每个类型实例都有一组最基本的行为。具体地讲, System.Object 类型允许做下面这些事情。
- 比较两个实例的相等性。
- 获取实例的哈希码。
- 查询一个实例的真正类型。
- 执行实例的浅(按位)拷贝。
- 获取实例对象当前状态的字符串表示。
1.8 公共语言规范
不同语言创建的对象可通过 COM 相互通信。 CLR 则集成了所有语言,用一种语言创建的对象在另一种语言中,和用后者创建的对象具有相同地位。之所以能实现这样的集成,是因为 CLR 使用了标准类型集、元数据(自描述的类型信息)以及公共执行环境。
语言集成是一个宏伟的目标,最棘手的问题是各种编程语言存在极大区别。例如,有的语言不区分大小写,有的不支持 unsigned(无符号)整数、操作符重载或者参数数量可变的方法。
要创建很容易从其他编程语言中访问的类型,只能从自己的语言中挑选其他所有的语言都支持的功能。为了在这个方面提供帮助,Microsoft 定义了“公共语言规范”(Common Language Specification, CLS),它详细定义了一个最小功能集。任何编译器只有支持这个功能集,生成的类型才能兼容由其他符合 CLS、面向 CLR 的语言生成的组件。
CLR/CTS 支持的功能比 CLS 定义的多得多,CLS 定义的只是一个子集。
如下图所示, CLR/CTS 提供了一个功能集。有的语言公开了 CLR/CTS 的一个较大的子集。如果开发人员用 IL 汇编语言写程序,可以使用 CLR/CTS提供的全部功能。但是,其他大多数语言(比如C#、Visual Basic 和 Fortran)只向开发人员公开了 CLR/CTS 的一个功能子集。CLS 定义了所有语言都必须支持的最小功能集。
用一种语言定义类型时,如果希望在另一种语言中使用该类型,就不要再该类型的 public 和 protected 成员中使用位于 CLS 外部的任何功能。否则,其他开发人员使用其他语言写代码时,就可能无法访问这个类型的成员。
以下代码使用C#定义一个符合CLS的类型。然而,类型中含有几个不符合CLS的构造,造成C#编译器报错:
using System;
// 告诉编译器检查 CLS 相容性
[assembly: CLSCompliant(true)]
namespace SomeLibrary{
// 因为是public类,所以会显示警告
public sealed class SomeLibraryType{
// 警告:SomeLibrary.SomeLibraryType.Abc() 的返回类型不符合 CLS
public UInt32 Abc() { return 0; }
// 警告:仅大小写不同的标识符 SomeLibrary.SomeLibraryType.abc()
// 不符合CLS
public void abc() { }
// 不显示警告:该方法是私有的
private UInt32 ABC() { return 0; }
}
}
上述代码将 [assembly:CLSCompliant(true)]
这个特性应用于程序集,告诉编译器检查其中的任何公开类型,判断是否存在任何不合适的构造阻止了从其他编程语言中访问该类型。上述代码编译时,C#编译器会报告两条警告消息。第一个警告是因为 Abc 方法返回无符号整数,一些语言是不能操作无符号整数值的。第二个警告是因为该类型公开了两个 public 方法,这两个方法(Abc 和 abc)只是大小写和返回类型有别。 Visual Basic 和其他一些语言无法区分这两个方法。
有趣的是,删除 sealed class SomeLibraryType
之前的 public 字样,然后重新编译,两个警告都会消失。因为这样一来,SomeLibraryType 类型将默认为 internal(而不是public),将不再向程序集的外部公开。
现在提炼一下 CLS 的规则。在 CLR 中,类型的每个成员要么是字段(数据),要么是方法(行为)。这意味着每一种编程语言都必须能访问字段和调用方法。字段和方法以特殊或通用的方式调用。为简化编程,语言往往提供了额外的抽象,从而对这些常见的编程模式进行简化。例如,语言会公开枚举、数组、属性、索引器、委托、事件、构造器、终结器、操作符重载、转换操作符等概念。编译器在源代码中遇到其中任何一样,都必须将其转换成字段和方法,使 CLR 和其他任何编程语言能够访问这些构造。
以下类型包含一个构造器、一个终结器、一些重载的操作符、一个属性、一个索引器和一个事件。注意,目的只是让代码能通过编译,不代表类型的正确实现方式。
using System;
internal sealed class Test{
// 构造器
public Test(){ }
// 终结器 析构函数
~Test() { }
// 操作符重载
public static Boolean operator == (Test t1, Test t2){
return true;
}
public static Boolean operator != (Test t1, Test t2){
return false;
}
// 操作符重载
public static Test operator + (Test t1, Test t2){ return null; }
// 属性
public String AProperty {
get { return null; }
set { }
}
// 索引器
public String this[Int32 x]{
get {return null; }
set { }
}
// 事件
event EventHandler AnEvent;
}
编译上述代码得到含有大量字段和方法的一个类型。可用 .NET Framework SDK 提供的 IL 反汇编器工具(ILDasm.exe)检查最终生成的托管代码,如下图所示。
下表总结了编程语言的各种构造与 CLR 字段/方法的对应关系。
下表 Test 类型的字段和方法(从元数据中获取)
类型的成员 | 成员的类型 | 对应的编程语言 |
AnEvent | 字段 | 事件:字段名是 AnEvent,类型是 System.EventHandler |
.ctor | 方法 | 构造器 |
Finalize | 方法 | 终结器 |
add_AnEvent | 方法 | 事件的 add 访问器方法 |
get_Aproperty | 方法 | 属性的 get 访问器方法 |
get_Item | 方法 | 索引器的 get 访问器方法 |
op_Addition | 方法 | +操作符 |
op_Equality | 方法 | ==操作符 |
op_Inequality | 方法 | !=操作符 |
remove_AnEvent | 方法 | 事件的 remove 访问器方法 |
set_AProperty | 方法 | 属性的 set 访问器方法 |
set_Item | 方法 | 索引器的 set 访问器方法 |
Test 类型还有另一些节点未在表上中列出,包括 .class,.custom,AnEvent,AProperty以及Item —— 它们标识了类型的其他元数据。这些节点不映射到字段或方法,只是提供了类型的一些额外信息,供 CLR 、编程语言或者工具访问。例如,工具可以检测到 Test 类型提供了一个名为 AnEvent 的事件,该事件借由两个方法(add_AnEvent 和 remove_AnEvent)公开。
1.9 与非托管代码的互操作性
CLR 支持三种互操作情形。C# 互操作性入门系列(四):在C# 中调用COM组件
- 托管代码能调用DLL 中的非托管函数
托管代码通过 P/Invoke(Platform Invoke)机制调用 DLL 中的函数。毕竟,FCL 中定义的许多类型都要在内部调用从 Kernel32.dll、User32.dll 等导出的函数。许多编程语言都提供了机制方便托管代码调用 DLL 中的非托管函数。例如,C# 应用程序可调用从 Kernel32.dll 导出的 CreateSemaphore 函数。 - 托管代码可以使用现有 COM 组件(服务器)
许多公司都已经实现了大量非托管 COM 组件。利用来自这些组件的类型库,可创建一个托管程序集来描述 COM 组件。托管代码可像访问其他任何托管类型一样访问托管程序集中的类型。这方面的详情可以参考 .NET Framework SDK 提供的 TlbImp.exe 工具。有时可能没有类型库,或者想对 TlbImp.exe 生成的内容进行更多控制。这时可在源代码中手动构建一个类型,使 CLR 能用它实现正确的互操作性,例如可从 C# 应用程序中使用 DirectX COM 组件。 - 非托管代码可以使用托管类型(服务器)
许多现有的非托管代码要求提供 COM 组件来确保代码正确工作。使用托管代码可以更简单地实现这些组件,避免所有代码都不得不和引用计数以及接口打交道。例如,可用C# 创建 ActiveX 控件或 shell 扩展。这方面的详情可以参考 .NET Framework SDK 提供的 TlbExp.exe 和 RegAsm.exe 工具。