Contents
第1章CLR的执行模型... 4
1.1将源代码编译成托管代码模块... 4
1.2 将托管模块合并成程序集... 6
1.3加载公共语言运行时... 7
1.4执行程序集的代码... 8
1.5本地代码生成器:NGen.exe. 11
1.6 Framework类库... 11
1.7通用数据类型... 12
1.8公共语言规范... 12
第2章 生成、打包、部署和管理应用程序及类型... 13
2.1 .Net Framework部署目标... 13
2.2将类型生成到模块中... 13
2.3元数据概述... 13
2.4将模块合并成程序集... 14
第4章 基础类型... 14
4.1 所有类型都从System.Object派生... 14
4.2类型转换... 18
4.3命名空间和程序集... 20
4.4运行时的相互关系... 21
第5章 基元类型、引用类型和值类型... 21
5.1编程语言的基元类型... 21
5.2引用类型和值类型... 22
5.3 值类型的装箱和拆箱... 26
5.4对象哈希码(以后再看)... 29
5.5 dynamic基元类型... 30
第6章 类型和成员基础... 31
6.1类型的各种成员... 31
6.2类型的可见性... 33
6.3成员的可访问性accessibility. 34
6.4静态类... 35
6.5 分部类、结构和接口... 36
6.6组件、多态和版本控制... 37
第7章 常量和字段... 37
7.1常量... 37
7.2字段... 38
第8章 方法... 40
8.1实例构造器和类(引用类型)... 40
8.2实例构造器和结构(值类型)... 43
8.3类型构造器... 43
8.4操作符重载方法... 45
8.5转换操作符方法... 47
8.6扩展方法... 47
8.7分部方法... 49
第9章 参数... 50
9.1可选参数和命名参数... 50
9.2 隐式类型的局部变量... 53
9.3以传引用的方式向方法传递参数... 54
9.4向方法传递可变数量的参数... 54
9.5参数和返回类型的指导原则... 55
9.6 常量性... 55
第10章 属性... 55
10.1无参属性... 55
10.1.1自动实现的属性... 57
10.1.2合理定义属性... 57
10.1.3对象和集合初始化器... 57
10.1.4匿名类型... 59
10.1.5System.Tuple类型... 59
10.2有参属性... 59
第11章事件... 59
11.1设计要公开事件的类型... 60
11.1.1第一步:定义类型来容纳所有需要发送给事件通知接收者的附加信息... 60
11.1.2第二步:定义事件成员... 61
11.1.3 第三步:定义负责引发事件的方法来通知事件的登记对象... 61
第12章 泛型... 61
12.1 Framework类库中的泛型... 63
12.2Wintellect的Power Collections库... 63
12.3泛型基础结构... 63
12.3.1开放类型和封闭成员... 63
第13章 接口... 63
13.1类和接口继承... 64
13.2定义接口... 64
13.3继承接口... 65
第14章 字符、字符串和文本处理... 66
14.1字符... 66
14.2 System.String类型... 67
14.2.1构造字符串... 67
14.2.2字符串是不可变的... 68
14.2.3比较字符串... 68
14.2.4字符串留用... 69
14.2.5字符串池... 70
14.2.6检查字符串的字符和文本元素... 70
14.2.7其他字符串操作... 70
14.3高效率构造字符串... 70
第17章委托... 71
17.1初识委托... 73
17.2用委托回调静态方法... 75
17.3用委托回调实例方法... 75
17.4委托揭秘... 75
本书分为五个部分:
v 第一部分,CLR基础(CLR Basics),介绍CLR的执行模型,程序集概念,以及创建、打包、部署、管理程序集等。
v 第二部分,设计类型(Designing Types),包括CLR类型基础,基础类型,方法,特性(Property),事件,泛型,接口等内容。
v 第三部分,基本类型(Essential Types),包括字符、字符串及文本的处理,枚举类型,数组,委托(Delegate),自定义属性(Attribute),可控制类型等。
v 第四部分,核心设施(Core Facilities),包括异常与状态管理,自动内存管理(垃圾收集),CLR托管与应用程序域(AppDomain),程序集加载与反射,运行时序列化等。
v 第五部分,线程(Threading),这是第三版新增加的内容,包括线程基础,计算密集的异步操作,I/O密集的异步操作,基本的线程同步构造,混合的线程同步构造等。
第1章CLR的执行模型
本章内容:
v 将源代码编译成托管代码模块
v 将托管模块合并成程序集
v 加载公共语言运行时
v 执行程序集的代码
v 本地代码生成器:NGen.exe
v Framework类库入门
v 通用类型系统
v 公共语言规范(CLS)
v 与非托管代码的互操作性
1.1将源代码编译成托管代码模块
公共语言运行时(Common Language Runtime, CLR)是一个供多种编程语言使用的运行时。可用任何编程语言来开发代码,只要编译器是面向CLR的就行。
CLR也可以看作一个在执行时管理代码的代理,管理代码是CLR的基本原则。能够被管理的代码称为托管(managed)代码,反之称为非托管代码。
CLR的核心功能中的内存管理、程序集加载、安全性、异常处理和线程同步等可被面向CLR的所有语言使用。(不懂)
用支持CLR的任何一种语言创建源代码文件。用一个对应的编译器来检查语法和分析源代码。经编译器编译后生成托管模块(managed module),它是一个可移植执行体文件,它可能是32位(PE32)文件,也可能是64位(PE32+)文件。包括中间语言和元数据,需要CLR才能执行。
托管模块包含如下几个部分组成:
v PE32/PE32+ 头:标准的 Windows PE文件头。如果文件头使用PE32格式,则此文件只能在Windows 的32位或64位版本上运行;如果文件头使用PE32+格式,则此文件只能在Windows 的64位版本上运行。编译器在编译时,可通过编译平台/platform开关来指定该程序集包含一个PE32头或PE32+头。
v IL代码: 也是中间语言。编译器编译源代码时生成的中间代码,在执行环境中,这些IL代码将被CLR的JIT编辑器翻译成CPU能识别的指令,供CPU执行。
v 元数据
中间语言IL(Intermediate Language)代码:编译器编译源代码后生成的代码(.exe或.dll文件),但此时编译出来的程序代码并不是CPU能直接执行的机器代码。在运行时,CLR将IL代码编译成本地CPU指令。
CPU:中央处理器(Central Processing Unit),是一台计算机的运算核心和控制核心。它的功能主要是解释计算机指令以及处理计算机软件中的数据。
DLL (Dynamic Link Library) 文件为动态链接库文件,是一种作为共享函数库的可执行文件。动态链接提供了一种方法,使进程可以调用不属于其可执行代码的函数。可执行代码就是将编译器处理源代码后所生成的代码 连接后形成的可执行代码,它一般由机器代码或接近于机器语言的代码组成。
在Windows中,许多应用程序并不是一个完整的可执行文件,它们被分割成一些相对独立的动态链接库,即DLL文件,放置于系统中。当我们执行某一个程序时,相应的DLL文件就会被调用。多个应用程序也可以同时访问内存中单个 DLL 副本的内容。DLL 有助于共享数据和资源。
动态链接与静态链接的不同之处在于:动态链接允许可执行模块(.dll 文件或 .exe 文件)仅包含 在运行时定位 DLL 函数的可执行代码所需的信息。在静态链接中,链接器从静态链接库获取所有被引用的函数,并将库同代码一起放到可执行文件中。
使用中间语言的优点(跨平台,跨语言):
v 可以实现平台无关性,即与特定CPU无关。
v 只需把.NET框架中的某种语言编译成IL代码,就实现.NET框架中语言之间的交互操作。
本地代码编译器(native code compiler)生成的是面向特定CPU架构(比如x86,x64)的代码。相反,每个面向CLR的编译器生成的都是IL代码。
除了生成IL,面向CLR的每个编译器还要在每个托管模块中生成完整的元数据(Metadata)。元数据是描述数据(类型信息) 的数据,通常被解释为data about data,是由一组数据表构成的一个二进制数据块。元数据被CLR编译器编译后保存在Windows可移植执行体(PE)文件中,即和它描述的IL嵌入在EXE/DLL文件中,使IL和元数据永远同步。
PE (Portable Execute) 文件是微软Windows操作系统上的程序文件,EXE、DLL、OCX、SYS、COM都是PE文件。
元数据主要的类型表:
v 定义表 描述当前程序集中定义的类型和成员信息。
v 引用表 描述任何一个被内部类型引用的外部的类型和成员信息。
v 清单表包含了组成程序集所需要的所有信息,同时包含了对其他程序集的引用信息。
元数据的用途:
v 编译时,元数据消除了对本地C/C++头和库文件的需求,因为在负责实现类型/成员的IL代码文件中,已包含和 引用的类型/成员有关的全部信息。编译器可以直接从托管模块读取元数据。
v CLR的代码验证过程中使用元数据确保代码只执行“类型安全”的操作。
v VS使用元数据帮助您写代码。它的“智能感知”(IntelliSense)技术可以解析元数据,指出一个类型提供了哪些方法、属性、事件和字段
v 元数据允许将一个对象的字段序列化到一个内存中,将其发送给另一台机器。然后反序列化,在远程机器上重建对象的状态。(内存:与CPU进行沟通的桥梁,计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大)。
v 元数据允许垃圾回收器跟踪对象的生存期,垃圾回收器能判断任何对象的类型,并从元数据知道那个对象中哪些字段引用了其他对象。
1.2 将托管模块合并成程序集
程序集(assembly)是一个或多个托管模块,以及一些资源文件的逻辑组合。是重用、安全性以及版本控制的最小单元。
CLR不和托管模块一起工作,是和程序集一起工作的。CLR是通过程序集与托管模块进行沟通的。
C#编译器将生成的多个托管模块和资源文件合并成程序集。
在程序集内有一个清单,其描述了程序集内的文件列表,如托管模块、jpeg文件、XML文件等。
对于一个可重用的、可保护的、可版本控制的组件,程序集把它的逻辑表示(代码)和物理表示(资源)区分开。
1.3加载公共语言运行时
你生成的每个程序集既可以是可执行的应用程序(.EXE),也可以是DLL。最终都由CLR管理这些程序集中代码的执行。
Microsoft创建了重分发包(redistribution package),允许将.NET Framework免费分发并安装到你的用户的计算机上。
检查机器上是否安装好.Net Framework,只需在C:\Windows\System32下检查是否有MSCorEE.dll文件(Microsoft .NET Runtime Execution Engine)。mscoree.dll一定是唯一的,且总是处于系统目录的system32下。MSCorEE.dll负责选择.NET版本、调用和初始化CLR等工作。
Windows键+R \ regedit \ KEY_LOCAL_MACHINE \ SOFTWARE \ MICROSOFT NET Framework了解安装了哪些版本的.NET Framework。
如果程序集文件只包含类型安全的托管代码,那么无论在32位还是64位版本的Windows上,所写的代码都应该能正常运行。
X86指的是一种CPU的架构,是硬件。因为intel的8086,286,386~586而得名,amd开发的大部分CPU也是基于x86架构的。x86架构的特点是CPU的寄存器是32位的,因此也叫32位CPU。基于32位CPU开发的操作系统就叫32位操作系统。
C#编译器生成的程序集要么包含一个PE32头,要么包含一个PE32+的头。
加载CLR的过程:
v 当双击一个.exe文件时,Windows会检查EXE文件的头(PE32头或PE32+头),判断应用程序需要的是32位地址空间,还是64位地址空间。
v 会在进程的地址空间中加载MSCorEE.dll的x86,x64版本。
v 进程的主程序调用MSCorEE.dll中定义的_CorExeMain方法,这个方法初始化CLR,加载EXE程序集,然后调用其入口方法(Main)。
v 托管的应用程序将启动并运行。
初始化CLR包括:
v 分配一块内存空间,建立托管堆及其它必要的堆,由GC监控整个托管堆。
v 创建线程池。
v 创建应用程序域 (AppDomain):利用sos.dll可以查看CLR创建了哪些AppDomain。
1.4执行程序集的代码
托管程序集包含元数据和IL。IL是和CPU无关的机器语言,比大多数CPU机器语言都要高级,可将IL视为面向对象的机器语言。
IL能访问和操作对象类型,并提供了指令来创建和初始化对象、调用对象上的虚方法以及直接操作数组元素等。
就在Main方法执行之前,CLR会检测出Main的代码中引用的所有类型。这导致CLR为类型创建一个内部数据结构,它用于管理对所引用的类型的访问。
如上图中,Main方法引用了一个Console类型,这导致CLR分配一个内部数据结构。在这个结构中,Console类型定义的每个方法都有一个对应的记录项。每个记录项都容纳了一个地址,根据此地址即可找到方法的实现。对这个结构进行初始化时,CLR将每个记录项都指向包含在CLR内部的一个未文档化的函数(C#中没有函数的概念,一律称为方法)。这个函数称为JITCompiler。
Main方法首先调用WriteLine方法时,JITCompiler函数会被调用。JITCompiler函数负责将一个方法的IL代码即时编译成本地CPU指令。
JITCompiler函数被调用时,它知道要调用的是哪个方法,以及具体是什么类型定义了该方法。然后,JITCompiler会在定义该类型的程序集的元数据中查找被调用的方法的IL。接着,JITCompiler验证IL代码,并将IL代码即时编译成本地CPU指令。本地CPU指令被保存到一个动态分配的内存块中。然后,JITCompiler返回CLR为类型创建的内部数据结构,找到与被调用的方法对应的那一条记录,修改最初对JITCompiler的引用,让它现在指向内存块的地址。最后,JITCompiler函数跳转到内存块中的代码。这些代码正是WriteLine方法的具体实现。这些代码执行完毕并返还时,会返还到Main中的代码,并向往常一样继续执行。
现在,Main要第二次调用WriteLine。这一次由于已对WriteLine的代码进行了验证和编译,所以会直接执行内存块中的代码,完全跳过JITCompiler函数。
一个方法只有在首次调用时才会造成一些性能损失。以后对该方法的所有调用都以本地代码的形式全速进行,无需重新验证IL并把它编译成本地代码。
JIT编译器将本地CPU指令存储到动态内存中,一旦应用程序终止,编译好的代码也会被丢弃。所有,如果将来再次运行应用程序,JIT编译器必须再次将IL编译成本地指令。
在Visual Studio中新建一个C#项目时,项目的debug配置指定的是/optimize-和/debug: full开关。Release 配置指定的是/optimize-和/debug: pdbonly开关。
只有在指定/debug(+/full/pdbonly)开关的前提下,编译器才会生成一个Program Debug Database (PDB)文件。PDB文件帮助调试器查找局部变量并将IL指令映射到源代码。
1.4.1 IL和验证
IL是基于栈的。这意味着它的所有指令都要将操作数压入(push)一个执行栈,并从栈弹出(pop)结果。
操作数是操作符作用于的实体,是表达式中的一个组成部分,它规定了指令中进行数字运算的量 。操作数就是你直接处理的数据,操作数地址就是操作数存放在内存的物理地址。
表达式是操作数与操作符的组合。通常一条指令均包含操作符和操作数。
堆栈是两种数据结构。堆栈都是一种数据项按序排列的数据结构,都在进程的虚拟内存中,(在32位处理器上每个进程的虚拟内存为4GB),只能在一端(称为栈顶(top))对数据项进行插入和删除。要点:堆(heap),队列优先,先进先出(FIFO—first in first out)。栈(stack),先进后出(FILO—First-In/Last-Out)。
IL的最大优势并不在它对底层CPU的抽象,而在于应用程序的健壮性和安全性。将IL编译成本地代码CPU指令时,CLR会执行一个名为验证(verification)的过程。这个过程会检查高级IL代码,确定代码所做的一切都是安全的。列如,验证会核实调用的每个方法都有正确数量的参数,传给每个方法的每个参数都具有正确的类型,每个方法的返回值都得到了正确的使用,每个方法都有 一个返回语句,等等。
在windows中,每个进程都有它自己的虚拟地址空间,这是因为不能简单的信任一个应用程序代码。通过验证托管代码,可确保代码不会不正确地访问内存,不会干扰到另一个应用程序的代码。这样一来就可以放心的将多个托管应用程序放到一个Windows虚拟地址空间中运行。
事实上,CLR确实提供了在一个操作系统进程中执行多个托管应用程序的能力。每个托管的应用程序都在一个APPDomain中执行。默认情况下,每个托管的EXE文件都在它自己的独立地址空间中运行,这个空间地址只有一个APPDomain。然而,CLR的宿主进程(比如IIS,SQL Server)可决定在单个操作系统进程中运行多个APPDomain。
1.5本地代码生成器:NGen.exe
1.6 Framework类库
.Net Framework中包含了Framework类库(Framework Class Library, FCL)。FCL是一组DLL程序集的统称,其中包含了数千个类型定义,每个类型都公开了一些功能。
可以利用这些程序集来创建其中一部分应用程序
- Web服务(Web Service)利用Web Service或者WCF技术,可以简单的处理通过Internet发送的信息
- Web窗体应用程序(Web Form)开发基于HTML的应用程序(网站)。可以在Web窗体应用程序中查询数据库和调用Web服务,合并并筛选返回的信息,然后使用一个基于HTML的用户界面,在浏览器中显示这些信息
由于FCL包含数量极多的类型,所以有必要将相关的一组类型放到一个单独的命名空间中。System命名空间包含Object基类型,其他所有类型最终都是从这个基类型派生来的。
System命名空间包含用于整数、字符、字符串、异常处理以及控制台I/O的类型。
为了使用Framework的任何一个功能,必须知道这个功能是由什么类型提供的,以及该类型包含在哪个命名空间中。
1.7通用数据类型
CLR是完全围绕类型展开的。由于类型是CLR的根本,所以Microsoft制定了一个正式的规范,即“通用数据类型”(Common Type System),它描述了类型的定义和行为。
CTS规范规定一个类型可以零个或多个成员:
- 字段(Field)一个数据变量,是对象状态的一部分。字段根据名称和类型来区分
- 方法(Method)一个函数,能针对对象执行一个操作,通常会改变对象的状态。方法有一个名称,一个签名以及一个或多个修饰符。签名指定参数的数量(及其顺序);参数的类型;方法是否有返回值;如果有返回值,还要指定返回值的类型
- 属性(Property)getter和setter
- 事件(Event)事件在对象以及其他相关对象之间实现了一个通知机制。例如,利用按钮提供的一个事件,可以在按钮被点击之后通知其他对象
CTS指定了类型可视性规则以及类型成员的访问规则:
- public同一程序集中的任何其他代码或引用该程序集的其他程序集都可以访问该类型或成员
- private只有同一类或结构中的代码可以访问该类型或成员
- protected只有同一类或结构或者此类的派生类中的代码才可以访问的类型或成员
- internal同一程序集中的任何代码都可以访问该类型或成员,但其他程序集中的代码不可以
CTS还为类型继承、虚方法、对象生存期等定义了相应的规则。
所有类型最终必须从预定义的System.Object类型继承。Object是System命名空间中定义的一个类型的名称。Object是其他所有类型的根。
System.Object类型允许做下面事情:
- 比较两个类型的相等性
- 获取实例的哈希码
- 查询一个实例的真正类型
- 执行实例的浅拷贝
- 获取实例对象的当前状态的一个字符串的表示
1.8公共语言规范
CLR集成了所有语言,允许在一种语言中使用由另一种语言创建的对象,因为CLR使用了标准类型集、元数据以及公共执行环境。
Microsoft定义了一个公共语言规范(Common Language Specification),它详细定义了一个最小功能集。
第2章 生成、打包、部署和管理应用程序及类型
2.1 .Net Framework部署目标
2.2将类型生成到模块中
本节讨论如何将包含多个类型的源代码文件生成为一个可部署的文件。
System. Console是Microsoft已经实现好的一个类型,用于实现这个类型的各个方法的IL代码存储在MSCorLib.dll文件中。
2.3元数据概述
Program.exe中到底包含什么内容呢?
一个托管PE文件有4个部分组成:PE32(+)头、CLR头、元数据以及IL。
PE32(+)头是Windows要求的标准信息,
元数据是一个二进制数据块,由定义表(definition table)、引用表(reference table)和清单表(manifest table)构成。
对于这个如此小的Program.exe应用程序,PE头和元数据占据了文件相当大的一部分。当然随着应用程序规模的增大,它会重用它的大部分类型以及对其他类型
程序集的引用,造成元数据和头信息在整个文件中所占的比例逐渐减小。
2.4将模块合并成程序集
第4章 基础类型
4.1 所有类型都从System.Object派生
“运行时”要求每个类型最终都从System.Object 类型派生,所以可以保证每个类型的每个对象都有一组最基本的方法。
重写(override):继承时发生,在子类中重新定义父类中的方法,子类中的方法和父类的方法是一样的(即方法名,参数,返回值都相同),由 override 声明重写的方法称为重写基方法。
例如:基类方法中声明为virtual,派生类中使用override申明此方法的重写。
重写override一般用于接口实现和继承类的方法改写,要注意:
v 覆盖的方法的标志必须要和被覆盖的方法的名字和参数完全匹配,才能达到覆盖的效果;
v 覆盖的方法的返回值必须和被覆盖的方法的返回一致;
v 覆盖的方法所抛出的异常必须和被覆盖方法的所抛出的异常一致,或者是其子类;
v 被覆盖的方法不能为private,否则在其子类中只是新定义了一个方法,并没有对其进行覆盖。
v 不能重写非虚方法或静态方法。重写的基方法必须是 virtual、abstract 或 override 的。
namespace 方法重写
{
class TestOverride
{
public class Employee
{
public string name;
// Basepay is defined as protected, so that it may be accessed only by this class and derived classes.
protected decimal basepay;
// Constructor to set the name and basepay values.
public Employee(string name, decimal basepay)
{
this.name = name;
this.basepay = basepay;
}
// Declared virtual so it can be overridden.
public virtual decimal CalculatePay()
{
return basepay;
}
}
// Derive a new class from Employee.
public class SalesEmployee : Employee
{
// New field that will affect the base pay.
private decimal salesbonus;
// The constructor calls the base-class version, and initializes the salesbonus field.
public SalesEmployee(string name, decimal basepay,
decimal salesbonus)
: base(name, basepay)
{
this.salesbonus = salesbonus;
}
// Override the CalculatePay method to take bonus into account.
public override decimal CalculatePay()
{
return basepay + salesbonus;
}
}
static void Main()
{
// Create some new employees.
SalesEmployee employee1 = new SalesEmployee("Alice",
1000, 500);
Employee employee2 = new Employee("Bob", 1200);
Console.WriteLine("Employee4 " + employee1.name +
" earned: " + employee1.CalculatePay());
Console.WriteLine("Employee4 " + employee2.name +
" earned: " + employee2.CalculatePay());
}
}
/*
Output:
Employee4 Alice earned: 1500
Employee4 Bob earned: 1200
*/
}
System.Object的公共方法:
- Equals: 如果两个对象具有相同的值,就返回true。
- GetHashCode: 返回对象的值的一个哈希码。如果某个类型的对象要在一个哈希表集合中作为Key使用,该类型应该重写这个方法。
- ToString: 该方法默认返回类型的完整名称(this.GetType ().FullName)。然而,我们经常需要重写这个方法,使它返回一个String对象。
- GetType: 返回从Type派生的一个对象的实例,指出调用GetType的那个对象是什么类型。返回的Type对象可以和反射类配合使用,从而获取与对象的类型有关的元数据信息。GetType方法是非虚方法,这样可以防止一个类重写该方法,并隐瞒其类型,从而破坏类型安全性。
System.Object的受保护的方法:
- MemberwiseClone: 这个非虚方法能创建类型的一个新实例。
- Finalize: 在垃圾回收器判断对象应该被作为垃圾回收之后,在对象的内存被实际回收之前,会调用这个虚方法。简单的说,虚方法就是可以被子类重写(override)的方法,如果子类重写了虚方法,那么运行时将使用重写后的逻辑,如果没有重写,则使用父类中虚方法的逻辑。
内存格局通常分为四个区:
- 全局数据区:存放全局变量,静态数据,常量
- 代码区:存放所有的程序代码
- 栈区:存放为运行而分配的局部变量,参数,返回数据,返回地址等
- 堆区:即自由存储区
线程堆栈(Thread Stack)和托管堆(Managed Heap)
每个正在运行的程序都对应着一个进程(process),在一个进程内部,可以有一个或多个线程(thread),每个线程都拥有一块“自留地”,称为“线程堆栈”,大小为1M,用于保存自身的一些数据,比如函数中定义的局部变量、函数调用时传送的参数值等,这部分内存区域的分配与回收不需要程序员干涉,主要由操作系统管理。所有值类型的变量都是在线程堆栈中分配的。
另一块内存区域称为“堆(heap)”,在.NET 这种托管环境下,堆由CLR 进行管理,所以又称为“托管堆(managed heap)。
托管堆是CLR中自动内存管理的基础。初始化新进程时,运行时会为进程保留一个连续的地址空间区域。这个保留的地址空间被称为托管堆。托管堆维护着一个指针,用它指向将在堆中分配的下一个对象的地址。最初,该指针设置为指向托管堆的基址。
CLR要求所有类型对象都用new操作符来创建:Employee emp = new Employee (“ConstructorParam1”);
用new 关键字创建类的对象时,分配给对象的内存单元就位于托管堆中。在程序中我们可以随意地使用new 关键字创建多个对象,因此,托管堆中的内存资源是可以动态申请并使用的,当然用完了必须归还。
声明一个Employee的引用emp,在线程堆栈上给这个引用分配存储空间,这仅仅只是一个引用,不是实际的Employee对象。假定emp占4个字节的空间,包含了存储Employee的引用地址。接着分配托管堆上的内存来存储Employee对象的实例,假定Employee对象的实例是32字节,为了在托管堆上找到一个存储Employee对象的存储位置,.Net运行库在托管堆中搜索第一个从未使用的,32字节的连续块来存储Employee对象的实例,然后把分配给Employee对象实例的地址赋给emp变量。new执行了以上所有这些操作之后,会返回指向新建对象的一个引用。在前面的示例代码中,这个引用会保存到变量emp中。
以下是new操作符所做的事情:
- 它计算类型中定义的所有实例字段需要的字节数。堆上的每个对象都需要一些额外的成员“类型对象指针”和“同步块索引”。
- 它从托管堆中分配指定类型要求的字节数,从而分配对象的内存,分配的所有字节都设为0。
- 它初始化对象的“类型对象指针”和“同步块索引”。
- 调用类型的实例构造器,向其传入在对new的调用中指定的任何实参。
class SampleClass
{
public string name;
public int id;
public SampleClass() { }
public SampleClass(int id, string name)
{
this.id = id;
this.name = name;
}
}
class ProgramClass
{
static void Main()
{
SampleClass Employee2 = new SampleClass(1234, "Cristina Potra");
}
}
没有和new操作符对应的一个delete操作符。换言之,没有办法显示释放 为一个对象分配的内存。CLR采用了垃圾回收机制,能自动检测到一个对象不再被使用或访问,并自动释放对象的内存。
4.2类型转换
CLR最重要的特性之一就是类型安全性。在运行时,CLR总是知道一个对象是什么类型。调用GetType方法,总是知道一个对象确切的类型是什么。
CLR允许将一个对象转换为它的实际类型或者它的任何基类型。
C#不要求特殊语法即可将一个对象转换为它的任何基类型,因为向基类型转换被认为是安全的隐式转换。然而,将对象转换为它的某个派生类型时,C#要求开发人员只能进行显式转换。
隐式转换不需要在代码中指定转换类型,例如:int intNumber = 10; double doubleNumber = intNumber; intNumber会被隐式转换成double类型。
显式转换则相反,需要指定转换类型,例如:double doubleNumber = 10.1; int intNumber = (int)doubleNumber;
对于表示数值的基本数据类型来说,数值范围小的数据类型转换成数值范围大的数据类型可以进行隐式转换,而反过来则必须进行显示转换。
就像上面的两个例子一样。 对于类类型来说,子类转换成父类可以进行隐式转换,而反过来则必须进行显式转换,例如:string str1 = "abc";object obj = str1; //子类转换成父类,隐式转。 string str2 = (string)obj; //父类转换成子类,显式转换 如果两个类之间没有继承关系,则不能进行隐式转换或显式转换,此时必须在被转换的类中定义一个隐式转换方法或显式转换方法。
在Main方法中,会构造一个Manager对象,并将其传给PromoteEmployee。这些代码能成功编译并运行,因为Manager最终从Object派生的,而PromoteEmployee期待的正是Object。在PromoteEmployee内部,CLR核实o引用的是一个Employee对象,或者是从Employee派生的一个类型的对象。由于Manager是从Employee派生的,所以CLR执行类型转换,运行PromoteEmployee继续执行。
PromoteEmployee返回之后,Main继续构造一个DateTime对象,并将其传给PromoteEmployee。同样的,DateTime是从Object派生的,所以编译器会顺利编译调用
PromoteEmployee的代码。但在PromoteEmployee内部,CLR会检查类型转换,发现o引用的是一个DateTime对象,它既不是一个Employee,也不是从Employee派生的任何类型。所以CLR会禁止转型,并抛出一个System.InvalidCastException异常。
声明PromoteEmployee方法的正确方式是将参数类型指定Employee,而不是Object。
v 使用C#的is和as操作符来转型
is检查一个对象是否兼容于指定类型,并返还一个Boolean值:true或false。is操作符永远不会抛出异常。
如果对象引用为null,is操作符总会返还false,因为没有可检查其类型的对象。
as操作符的工作方式与强制类型转换一样,只是它永远不会抛出一个异常。
检查最终生成的引用是否为null。
4.3命名空间和程序集
命名空间用于对相关的类型进行逻辑性分组,开发人员使用命名空间来方便地定位一个类型。
密封类的修饰符,用了这个修饰符的类就不能被其他类继承了。
应该有一种简单的方式来直接引用FileStream和StringBuilder类型。C#编译器通过using指令来提供这种机制。
C#的using指令指示编译器尝试为一个类型附加不同的前缀,直到找到一个匹配项。
using指令允许为一个类型或命名空间创建别名。
在C#中namespace指令的作用:只是告诉编译器为源代码中出现的每个类型名称附加命名空间名称前缀,减少程序员的打字量。
命名空间和程序集不一定是相关的。
同一个命名空间的各个类型可能在不同的程序集中实现。例如:System.IO.FileStream类型是在MSCorLib.dll程序集中实现,而System.IO.FileSystemWatcher类型是在System.dll程序集中实现的。
4.4运行时的相互关系
参考http://www.cnblogs.com/MeteorSeed/archive/2012/01/24/2325575.html
本节将解释类型、对象、线程堆栈和托管堆在运行时的相互关系,以及调用静态方法、实例方法和虚方法的区别。
进程是指在系统中正在运行的一个应用程序;线程是系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元。
当系统加载一个CLR的进程,进程里面可能有多个线程,这时候系统会给这个进程创建一个大小为1M的线程堆栈。这个线程堆栈用来存放方法调用的实参,和方法内部定义的局部变量。
第5章 基元类型、引用类型和值类型
5.1编程语言的基元类型
编译器(Compiler)直接支持的数据类型称为基元类型(primitive type)。
我希望编译器根本不要提供基元类型名称,强制开发人员使用FCL(Framework类库)类型名称:
许多开发人员都困惑于到底应该使用string还是String。由于C#的string直接映射到System.String,所以两者是没有区别的。int始终映射到System.Int32,所以不管在什么操作系统上运行,代表的都是32位整数。
5.2引用类型和值类型
虽然FCL中大多数类型是引用类型,但程序员用的最多的还是值类型。
值类型:原类型(Sbyte、Byte、Short、Ushort、Int、Uint、Long、Ulong、Char、Float、Double、Bool、Decimal)、枚举(enum)、结构(struct)。
为提升简单的常用的类型的性能,CLR提供了“值类型”的轻量级类型。值类型的实例一般在线程堆栈上分配。
引用类型总是从托管堆上分配的,C#的new操作符会返回对象的内存地址——也就是指向对象数据的内存地址。
引用类型共有四种:类类型、接口类型、数组类型和委托类型。所有引用类型变量所引用的对象,其内存都是在托管堆中分配的。
使用引用类型时必须考虑性能问题,首先考虑以下事实:
- 内存必须从托管堆上分配
- 堆上分配的每个对象都有一些额外的成员(类型对象指针、同步块索引),这些成员必须初始化
- 对象中的其他字节(为字段而设)总是设为零
- 从托管堆中分配一个对象时,可能强制执行一次垃圾收集操作
在代表值类型实例的一个变量中,并不包含一个指向实例的指针。相反,变量包含了实例本身的字段。由于变量已经包含了实例的字段,所以为了操作实例中的字段,不再需要提领一个指针,值类型的实例不受垃圾回收器的控制,超出了作用范围,系统就会自动释放。因此值类型的使用缓解了托管堆中的压力,并减少了一个应用程序在其生存期内需要进行的垃圾回收次数。
在.Net Framework 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 ();
上面一行代码似乎要在托管堆上分配一个SomeVal实例。然而,C#编译器知道SomeVal是一个值类型,所以会生成相应的IL代码,在线程堆栈上分配一个SomeVal实例。C#还会确保值类型中所有字段都初始化为零。
以下条件都满足时,才应该将一个类型声明为值类型:
- 类型具有基元类型的行为。类型中没有成员会修改类型的任何实例字段。事实上,对于许多值类型来说,都建议将它们的字段都标记为readonly
- 类型不需要从其他任何类型继承
- 类型也不会派生出其他任何类型
声明为值类型除了满足上面3个条件外,还必须满足一下任何一个条件:
- 类型是实例较小(约为16字节或者更小)
- 类型的实例较大, 但不作为方法的实参传递,也不从方法返回
值类型和引用类型的区别:
- 值类型有两种表示形式:未装箱(unboxed)形式和已装箱(boxed)形式。相反,引用类型总是处于已装箱形式
- 值类型是从System.ValueType派生的。该方法提供了与System.Object定义的相同的方法。然而,System.ValueType重写了Equals方法,能在两个对象的字段完全匹配的前提下返回true。System.ValueType重写了GetHashCode方法,生成哈希码时,这个重写方法所用的算法会将对象的实例字段的值考虑在内。
- 由于不能将一个值类型作为基类型来定义一个新的值类型或者一个新的引用类型,所以不能在值类型中引入任何虚方法。所有方法都不能是抽象的,都隐式地为密封方法
- 引用类型的变量指向的是堆上的一个对象的地址。默认情况下,在创建一个引用类型的变量时,它被初始化为null,表明引用类型的变量,当前不指向一个有效的对象。试图使用一个为null的引用类型变量,会抛出NullReferenceException异常。值类型的变量总是包含其基础类型的一个值,而且值类型的所有成员都初始化为0 。由于值类型的变量不是指针,所有在访问一个值类型时,不可能抛出NullReferenceException异常。CLR为值类型提供了一个特殊的特性,能为值类型添加“可空性”
- 将一个值类型的变量赋给另一个值类型变量,会执行一次逐字段的复制(在线程堆栈上重新分配并复制成员)。将引用类型的变量赋给另一个引用类型变量时,只复制内存地址(堆中同一个类型的实例/对象。)
- 基于上一条,两个或多个引用类型的变量能引用堆中同一个对象,所有对一个变量执行的操作可能影响到另一个变量引用的对象。相反,值类型的变量是自成一体的对象,对一个值类型变量的操作不可能影响另一个值类型变量
- 由于未装箱的值类型不在堆上分配,所以一旦定义了该类型的一个实例的方法不再处于活动状态,为它们分配的存储就会被释放。这意味着值类型的实例在其内存被回收时,不会通过Finalize方法接收到一个通知
private static void Main()
{
int i;
MyClass mc;
i = 5;
mc = new MyClass();
}
当一个局部变量声明之后,就会在栈的内存中分配一块内存给这个变量,至于这块内存多大,里面存放什么东西,就要看这个变量是值类型还是引用类型了。
- 值类型
如果是值类型,为变量分配这块内存的大小就是值类型定义的大小,存放值类型自身的值(内容)。比如,对于上面的整型变量 i,这块内存的大小就是 4个字节(一个 int型定义的大小),如果执行 i = 5;这行代码,则这块内存的内容就是 5(如图 -1)。
对于任何值类型,无论是读取还是写入操作,可以一步到位,因为值类型变量本身所占的内存就存放着值。
- 引用类型
如果是引用类型,为变量分配的这块内存的大小,就是一个内存指针(实例引用、对象引用)的大小(在 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字段的地址。这个过程称为拆箱(unboxing)
第二步,将这些字段包含的值从堆中复制到基于栈的值类型实例中
拆箱不是直接将装箱过程倒过来。拆箱的代价比装箱低的多。拆箱其实就是获取一个指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。所以,和装箱不同,拆箱不要求在内存中复制任何字节,往往会紧接着拆箱操作后发生一次字段的复制操作。
一个已装箱的值类型实例在拆箱时,内部会发生下面这些事情:
- 如果包含了“对已装箱的值类型实例的引用”的变量为null,就抛出一个NullReferenceException异常
- 如果引用指向的对象不是所期待的值类型的一个已装箱实例,就抛出一个InvalidCastException异常
以上代码从逻辑上说,完全可以获取o所引用一个已装箱的Int32,然后将其强制转换为一个Int16。然而,在对一个对象进行拆箱的时候,只能将其转型为原先未装箱时的值类型---本例即为Int32
下面的代码是正确的写法:
overload:重载指的是同一个类中有两个或多个名字相同但是参数不同(参数个数和参数类型)的方法。
由于未装箱的值类型没有同步块索引,所以不能使用System.Threading.Monitor类型的各种方法,让多个线程同步对这个实例的访问。
5.3.2对象相等性和同一性(以后重看)
有时需要将对象放到一个集合中,并编写代码对集合中的对象进行排序、搜索或比较。
对于Object的Equals方法的默认实现来说,它实现的实际是同一性(identity),而非相等性(equality)。
5.4对象哈希码(以后再看)
哈希:通过将哈希算法应用到任意数量的数据所得到的固定大小的结果。如果输入数据中有变化,则哈希也会发生变化。哈希可用于许多操作,包括身份验证和数字签名。也称为“消息摘要”。
哈希表:根据设定的哈希函数和处理冲突方法将一组关键字映象到一个有限的地址区间上,并以关键字在地址区间中的象作为记录在表中的存储位置,这种表称为哈希表或散列,所得存储位置称为哈希地址或散列地址。作为线性数据结构与表格和队列等相比,哈希表无疑是查找速度比较快的一种。
在.NetFramework中,HashTable是System.Collections命名空间提供的容器,用来处理和表现类似keyvalue的键\值对。其中key区分大小写,通常用来快速查找。value用来储存对应于key的值。Hashtable中keyvalue键\值对均为object类型,所以Hashtable可以支持任何类型的keyvalue键\值对。
Hashtable是非泛型的集合,所以在检索和存储值类型时通常会发生装箱与拆箱的操作。
在哈希表中添加一个keyvalue键\值对:HashtableObject.Add(key,value);
在哈希表中去除某个keyvalue键\值对:HashtableObject.Remove(key);
从哈希表中移除所有元素:HashtableObject.Clear();
判断哈希表是否包含特定键key:HashtableObject.Contains(key);
哈希算法:将任意长度的二进制值映射为固定长度的较小二进制值,这个小的二进制值称为哈希值。哈希值是一段数据唯一且极其紧凑的数值表示形式。
hashcode标识对象的地址,用于区别不同的对象。
普通的查找慢是因为要一个一个比, Hash就是让把比较的次数降低 而降低的办法就是靠计算。
哈希算法会根据你要存入的数据,先通过该算法计算出一个地址值,这个地址值就是你需要存入到集合当中的数据的位置,而不会像数组那样一个个的进行挨个存储,挨个遍历一遍后面有空位就存这种情况了。而你查找的时候也是根据这个哈希算法来的。将你的要查找的数据进行计算,得出一个地址,这个地址会映射到集合当中的位置,这样就能够直接到这个位置上去找了,而不需要像数组那样,一个个遍历,一个个对比去寻找,这样自然增加了速度,提高了效率了。
如果能将任何对象的任何实例放到一个哈希表集合中,会带来很多好处。为此,System.Object提供了虚方法GetHashCode,它能获取任意对象的Int32哈希码。
如果你定义的一个类型重写了Equals方法,那么还应重写GetHashCode方法,确保相等性算法和对象哈希码算法是一致的。是因为在System.Collections.Hashtable类型、System.Collections.Generic.Dictionary类型以及其他一些集合的实现中,要求两个对象为了相等,必须具有相同的哈希码。
简单地说,在一个集合中添加一个键\值对时,首先会获取键对象的一个哈希码。这个哈希码指出键\值对应该储存到哪一个哈希桶(bucket)中。集合需要查找一个键时,会获取指定的键对象的哈希码。这个哈希码标识了现在要搜索的目标哈希桶,要在其中查找与指定键对象相等的一个键对象。采用这种算法来储存和查找键,意味中一旦修改了集合中的一个键对象,集合就再也找不到对象。所以,需要修改一个哈希表中的键对象时,正确的做法是移除原来的键\值对,修改键对象,再将新的键\值对添加回哈希表。
5.5 dynamic基元类型
C#是一种类型安全的编程语言。这意味着所有表达式都解析成某个类型的一个实例,在编译器生成的代码中,只会执行对这个类型来说有效的操作。
从面向对象的角度来看, 对象的实例表示的是 个体, 而static的属性和方法则表示 全体所共有的方法和属性 , 如“会员张三”、“会员李四”是“会员”的两个个体, 昵称、等级是他们各自不同的属性,而 会员总数、注册新会员 则是 全体会员所共享的属性和方法。 双比如 “圆”这个class, 半径、面积、周长是 个体的属性,而圆周率PI则是共性。
从应用的角度来看,本质就是为了节省内存,在内存中只有一个引用。
静态类的主要功能如下:
- 它们仅包含静态成员。
- 它们不能被实例化。
- 它们是密封的。
- 它们不能包含实例构造函数,不能使用 new 关键字创建静态类的实例。
在许多时候,程序仍需处理一些运行时才会知晓的消息。如果你写的是一个纯C#应用程序,那么只有在使用反射的时候,才会在运行时才能确定的信息打交道。然而,许多开发者在使用C#时,都要和一些不是和C#实现的组件进行通信。有的组件是.Net动态语言,比如Python或Ruby,有的是HTML文档对象模型(DOM)对象。
C#编译器允许将一个表达式的类型标记为dynamic。还可以将一个表达式的结果放到一个变量中,并将变量的类型标记为dynamic。然后可以用这个dynamic表达式/变量调用一个成员,比如字段、属性/索引器、方法、委托以及一元/二元/转换操作符。
代码使用dynamic表达式/变量调用一个成员时,编译器会生成一个特殊的IL代码来描述所需的操作。这种特殊的代码称为payload(有效载荷)。在运行是payload代码会根据当前由dynamic表达式/变量引用的对象的实际类型来决定具体执行的操作。
这些Payload代码使用了一个称为运行时绑定器(runtime binder)的类。C#的运行时绑定器的代码在Microsoft.CSharp.dll程序集中,构建使用dynamic关键字的项目时,必须引用该程序集。
Plus方法将参数的类型申明为dynamic,在方法内部,实参作为二元+操作符的两个操作数使用。由于arg是dynamic,所以C#编译器会生成payload代码,以便在运行时检查arg的实际类型,并决定+操作符实际要做的事情。
第一次调用Plus时,传递的是5(一个Int32),所以Plus向它的调用者返回值10。结果放到result变量(一个dynamic类型)中。然后调用M方法,将result传给它。针对对M的调用,编译器会生成payload代码,以便在运行时检查传给M的值的实际类型,以及应该调用M方法的重载版本。
第二次调用Plus时,同第一次的原理一样。
在字段类型、方法参数或方法返回类型被指定为dynamic的前提下,编译器会将这个类型转换为System.Object,并在元数据中向字段、参数或方法类型应用System.Runtime.ComplierServices.DynamicAttribute的一个实例。如果是一个局部变量被指定为dynamic,变量类型也会变成Object,但不会向局部变量应用DynamicAttribute,因为它的使用限制在方法之内。
在运行时,Microsoft.CSharp.dll程序集必须加载到AppDomain中,这会损坏应用程序的性能,并增大内存耗用。虽然能用动态功能简化语法,但也要看是否值得。
第6章 类型和成员基础
在本章及本部分后续的章节,我将解释如何在一个类型中定义不同种类的成员,从而设计出符合自己需要的类型。
6.1类型的各种成员
在一个类型中可以定义0个或多个以下种类的成员:
- 常量:常量就是指出数据值恒定不变的一个符号。这些符号通常用于使代码更容易阅读和维护。常量通常与类型关联,不与类型的实例关联。从逻辑上讲,常量始终是静态成员。
- 字段:字段表示一个只读或可读可写的数据值。字段可以是静态的,这种情况下,字段被认为是类型状态的一部分。字段也可以是实例(非静态),这种情况下,字段被认为是对象状态的一部分。强烈建议将字段声明为私有的,防止类型或对象的状态被该类型外部的代码破坏。
- 实例构造器:将对象的实例字段初始化为良好初始状态的一种特殊方法。
- 类型构造器:将类型的静态字段初始化为良好初始状态的一种特殊方法。
- 方法:方法是一种特殊的函数,作用是更改或查询一个类型或对象的状态。作用于类型时,称为静态方法;作用于对象时,称为实例方法。方法一般会对类型或对象的字段执行读写操作。
- 操作符重载: 操作符重载实际是一种方法,它定义了将一个特定的操作符作用于对象时,应该如何操作这个对象。
- 转换符重载:转换操作符是定义如何隐式或显示的将对象从一种类型转型为另一种类型的方法。
- 属性:领用属性(property),可以使用一种简单的、字段风格的语法来设置或查询类型或对象的部分逻辑状态,同时保证状态不被遭到破坏。作用于类型的称为静态属性,作用于对象的称为实例属性。属性可以是没有参数的,也可以是有多个参数的。
- 事件:利用静态事件,一个类型可以向一个或多个静态或实例方法发送通知。而利用实例事件,一个对象可以向一个或多个静态或实例方法发送通知。提供事件的类型或对象的状态发生改变,通常就会引发事件。事件包含两种方法,允许静态或实例方法登记或注销对该事件的关注。
- 类型:类型可定义嵌套于其中的其他类型。通常用这个办法将一个大的、复杂的类型分解成更小的构建单元(building block),以简化实现。
无论使用什么编程语言,它的编译器都必须能处理你的源代码,为上述列表中的每一种成员生成元数据和IL代码。无论使用的编程语言是什么,元数据的格式都是完全一致的。正是因为这个特点,才使CLR成为公共语言运行时。
CLR使用公共元数据格式决定常量、字段、构造器、方法、属性和事件在运行时的行为。元数据是整个.Net Framework开发平台的关键,它实现了编程语言、类型和对象的无缝集成。
以下C#代码展示了一个类型定义,其中包含了所有可能的成员。
6.2类型的可见性
在文件范围内定义类型时,可以将类型的可见性指定为public和internal。
public类型不仅对它的定义程序集中的所有代码可见,还对其他程序集中的代码可见。
internal类型仅对定义程序集中的所有代码可见,对其他程序集中的代码不可见。
定义类型时,如果不显式指定类型的可见性,C#编译器默认将类型的可见性设为internal(两者中较有限的那一个)。
友元程序集
友元程序集功能用于访问内部成员;私有类型和私有成员仍然不可访问。
若要使程序集(程序集 B)能够访问另一个程序集(程序集 A)的内部类型和成员,应使用程序集 A 中的 InternalsVisibleToAttribute 属性
6.3成员的可访问性accessibility
在代码中引用一个成员时,成员的可访问性指出这种引用是否合法。
- private:访问仅限于包含该成员的类型或任何嵌套类型中的方法访问。
- protected:访问仅限于当前类和其子类。
- internal:访问仅限于所属程序集。
- internal protected:访问仅限于当前程序集或其子类(子类可以不属于当前程序集)。
- public:访问不受限制。
当然,任何成员想要被别人访问到,都必须在一个可见的类型内定义。例如,如果程序集AssemblyA定义了一个internal类型,该类型有一个public方法,那么程序集AssemblyB中的代码不能调用AssemblyA中的public方法,因为internal类型对于AssemblyB是不可见的。
在C#中,如果没有显式声明成员的可访问性,编译器通常默认选择private(限制最大的那个)。CLR要求接口类型的所有成员都具有public可访问性。
6.4静态类
静态类是不能实例化的,例如Console,Math,Environment和ThreadPool类。这些类只有static成员。我们直接使用它的属性与方法,静态类最大的特点就是共享,作用是将一组相关的成员组合到一起。例如Math类中定义了一组执行数学运算的方法。Math 类:为三角函数、对数函数和其他通用数学函数提供常数和静态方法。
static关键字只能应用于类,不能应用于结构(值类型)。这是因为CLR总是允许值类型实例化。
静态类的主要特点如下:
- 它们仅包含静态成员。
- 它们不能被实例化。
- 它们是密封的。
- 它们不能包含实例构造函数。
C#编译器对静态类进行了如下限制:
- 静态类必须直接从基类System.Object派生,从其他任何基类派生没有任何意义。继承只适用于对象,而你不能创建静态类的实例
- 静态类不能实现任何接口,这是因为只有使用类的一个实例时,才可以调用类的接口方法
- 静态类只能定义静态成员(字段、方法、属性和事件),任何实例成员都将导致编译器报错
- 静态类不能作为字段、方法参数或局部变量使用,因为它们都代表引用了一个实例的变量,这是不允许的
在类或结构内部定义的类型称为嵌套类型。例如:
class Container
{
class Nested
{
Nested() { }
}
}
通过static关键字修饰,是属于类,实例成员属于对象,在这个类第一次加载的时候,这个类下面的所有静态成员会被加载。
实例构造函数:使用 new 表达式创建某个类的对象时,会使用实例构造函数创建和初始化所有实例成员变量。
只要创建基于 CoOrds 类的对象,就会调用此实例构造函数。 诸如此类不带参数的构造函数称为“默认构造函数”。 然而,提供其他构造函数通常十分有用。 例如,可以向 CoOrds 类添加构造函数,以便可以为数据成员指定初始值:
class CoOrds
{
public int x, y;
// constructor
public CoOrds()
{
x = 0;
y = 0;
}
public CoOrds(int x, int y)
{
this.x = x;
this.y = y;
}
}
class MainClass
{
static void Main()
{
CoOrds p1 = new CoOrds();
CoOrds p2 = new CoOrds(5, 3);
}
}
6.5 分部类、结构和接口
partial这个关键字告诉C#编译器,一个类、结构或者接口的定义源代码可能要分散到一个或多个源代码文件中。
当使用大项目或自动生成的代码(如由 Windows 窗体设计器提供的代码)时,将一个类、结构或接口类型拆分到多个文件中的做法就很有用。
局部类型适用于以下情况:
- 类型特别大,不宜放在一个文件中实现。
- 一个类型中的一部分代码为自动化工具生成的代码,不宜与我们自己编写的代码混合在一起。
- 需要多人合作编写一个类。
局部类型的注意点:
- 关键字partial是一个上下文关键字,只有和 class、struct、interface 放在一起时才有关键字的含义。因此partial的引入不会影响现有代码中名称为partial的变量。
- 局部类型的各个部分一般是分开放在几个不同的.cs文件中,但C#编译器允许我们将他们放在同一文件中。
6.6组件、多态和版本控制
组件软件编程(Component Software Programming)
下面列举了组件的一些特点:
- 组件(.Net中称为程序集)有已经发布的意思
- 组件有自己的标识(名称、版本、语言文化和公钥)
- 组件永远维持自己的标识(程序集中代码永远不会静态链接到另一个程序集中,.Net总是使用动态链接)
- 组件清楚指明它所依赖的组件(引用元数据表)
- 组件要文档化它的类和成员,C#语言通过源代码的XML文档和编译器的/doc命名行开关提供这个功能
- 组件必须指定它要求的安全权限,CLR的代码访问安全性(Code Access Security)机制提供了这个功能
- 组件要发布一个在任何“维护版本”中都不会改变的接口
在.Net中,版本号为1.2.3.4的程序集,其主版本号1,次版本号2,内部版本号3,修订号为4。
6.6.1 CLR如何调用虚方法、属性和事件
方法代表在类型或者类型的实例执行某些操作的代码。
在类型上执行操作称为静态方法。在类型的实例上执行操作称为非静态方法。
任何方法都有一个名称、一个签名和一个返回值(可以是void)。
第7章 常量和字段
7.1常量
常量(constant)是一个特殊的符号,它有一个从不变化的值。常量只能在声明中初始化。定义常量符号时,它的值必须在编译时确定。
常数表达式是在编译时可被完全计算的表达式。因此不能从一个变量中提取的值来初始化常量。
如果 const int a = b+1;b是一个变量,显然不能再编译时就计算出结果,所以常量是不可以用变量来初始化的。
确定之后,编译器将常量的值保存到程序集的元数据中。这意味着只能为编译器认定的基元类型定义常量。然而C#也允许定义一个非基元类型的常量变量(constant variable),前提是把它的值设为null。
class Calendar1
{
public const int months = 12;
}
代码引用一个常量符号时,编译器会在定义常量的程序集的元数据中查找该符号,提取 常量的值,并将值嵌入生成的IL代码中。由于常量的值直接嵌入代码,所以在运行时不需要为常量分配任何内存。
常量是在编译时已知并在程序的生存期内不发生改变的不可变值。常量使用const修饰符进行声明。
只有C#内置类型可以声明为const。用户定义的类型(类,结构和数组)不能为const。请用readonly修饰符创建在运行时初始化一次即不可更改的类、结构和数组。
可以使用枚举类型为整数内置类型(列如int、long等)定义命名常量。
当编译器遇到 C# 源代码中的常量修饰符时,将直接把文本值替换到它生成的中间语言 (IL) 代码中。因为在运行时没有与常量关联的变量地址,所以 const 字段不能通过引用传递。
常量可标记为 public、private、protected、internal 或 protectedinternal。
未包含在定义常量的类中的表达式必须使用类名、一个句点和常量名来访问该常量。例如:
int birthstones = Calendar.months;
由于常量的值从不变化,常量总是被视为静态成员,而不是实例成员。
常量没有很好的跨程序性版本控制特性。开发人员更改了常量的值后,应用程序要获取新的常量值,必须重新编译。如果在运行时,从一个程序集提取另一个程序集的值,那么不应该使用常量,而应该使用readonly字段。
7.2字段
字段(field)是一种数据成员,其中容纳了一个值类型的实例或者对一个引用类型的引用。
“字段”是直接在类或结构中声明的任何类型的变量。
字段修饰符:
v static:是类型状态的一部分,而不是对象状态的一部分。这使得调用方法在任何时候都能够使用字段,即使类没有任何实例。不管包含该静态字段的类生成多少个对象或根本无对象,该字段都只有一个实例,静态字段不能被撤销。必须采用如下方法引用静态字段:类名.静态字段名。
v readonly:只读字段只能在初始化期间(字段声明中)或在定义类的构造函数中赋值(这种构造器方法只能调用一次,也就是对象首次创建时),在其它任何地方都不能改变只读字段的值。注意:但可利用反射来修改readonly字段。
CLR支持类型(静态)字段和实例(非静态)字段。对于类型字段,用于容纳字段数据的动态内存是在类型对象中分配的,而类型对象是在类型加载到一个AppDomain时创建的。对于实例字段,用于容纳字段数据的动态内存则是在构造类型的实例时分配的。
AppDomain简单的说就是应用程序的边界。可以通过它对相同进程中进行再次隔离。一个程序在运行的时候,它和它所引用、反射加载的所有模块的集合构成了一个程序域。普通桌面程序,一个程序就是一个AppDomain。CLR允许在一个进程中托管多个程序(比如IIS一类程序),一个IIS是可以运行很多网站的,如果这些网站都放在一个AppDomain里,一个网站崩溃了,其他网站也不能访问了。如果每个网站都作为独立的程序,对机器的性能要求又太高,而且没法共享一些资源。所以.net就有AppDomain的概念,一个IIS进程里,给每个网站一个AppDomain,这个每个网站都相互独立。
由于字段存储在动态内存中,所以他们的值在运行时才能获取。字段可以是任何数据类型,不必像常量那样仅仅是编译器内置的基元类型。
第8章 方法
8.1实例构造器和类(引用类型)
构造器(constructor)是允许将类型的实例初始化为良好状态的一种特殊方法。
编译后,构造器方法在“方法定义元数据表”中始终叫.ctor。
创建一个引用类型的实例,首先为实例的数据字段分配内存,然后初始化对象的附加字段(类型对象指针和同步块索引),最后调用类型的实例构造器来设置对象实例的初始状态。
实例构造器永远不能被继承,派生类会自动调用基类的构造函数,也就是说类只有类自己定义的实例构造器。不能将以下修饰符应用于实例构造器:virtual, new, override, sealed和abstract。(实例构造器永远不能被继承,因为如果带参数的构造函数写了很多个,那用哪一个呢?)
如果你定义的类没有显示定义任何构造器,C#编译器将自动隐式生成一个默认(无参)构造器,同时将字段初始化为它们的默认值。看如下代码:
public class A
{
}
//可以理解为它已经存在一个如下的构造函数
public class A
{
public A()
{
}
}
派生类构造函数自动调用基类的不带参数的构造函数,看以下代码:
public class B : A
{
public B()
{
}
}
//相当于
public class B : A
{
public B()
: base()
{
}
}
基类中带参数的构造函数必须显式调用,如下:
public class A
{
public A()
{
}
public A(string str)
{
}
}
public class B : A
{
public B()
: base("aaa")
{
}
}
base关键字用于从派生类中访问基类的成员:https://msdn.microsoft.com/zh-cn/library/hfw7t1ce.aspx
v 调用基类上已被其他方法重写的方法。
v 指定创建派生类实例时应调用基类的构造方法。
什么是抽象类:
抽象类提供多个派生类共享基类的公共定义,它既可以提供抽象方法,也可以提供非抽象方法。
抽象类不能实例化,必须通过继承由派生类实现其抽象方法,因此对抽象类不能使用new关键字,也不能被密封。
如果派生类没有实现所有的抽象方法,则该派生类也必须声明为抽象类。另外,实现抽象方法由overriding方法来实现。
抽象类具有以下特性:
v 抽象方法是隐式的虚方法。
v 抽象类不能实例化。
v 不能用 sealed修饰符修饰抽象类,因为这两个修饰符的含义是相反的。
采用 sealed 修饰符的类无法继承,而 abstract 修饰符要求对类进行继承。
v 从抽象类派生的非抽象类必须包括继承的所有抽象方法和抽象访问器的实际实现。
v 抽象类可以包含抽象方法和抽象访问器。
v 只容许在抽象类中使用抽象方法声明。
v 因为抽象方法声明不提供实际的实现,所以没有方法体。
方法声明只是以一个分号结束,并且在签名后没有大括号“{}”。
v 在派生类中,通过包括使用override修饰符的属性声明,可以重写抽象的继承属性。
abstract class ShapesClass
{
abstract public int Area();
}
class Square : ShapesClass
{
int side = 0;
public Square(int n)
{
side = n;
}
// Area method is required to avoid
// a compile-time error.
public override int Area()
{
return side * side;
}
static void Main()
{
Square sq = new Square(12);
Console.WriteLine("Area of the square = {0}", sq.Area());
}
}
抽象方法和虚方法最重要的区别:
v 抽象方法不能实例化,要子类必须强制性的覆盖它的方法 。
而虚方法则是提供了选择,可以覆盖可以不覆盖,继承基类中的虚方法。
虚拟方法必须有一个实现部分,并为派生类提供了覆盖该方法的选项。
相反,抽象方法没有提供实现部分,强制派生类覆盖方法(否则 派生类不能成为具体类)。
v abstract方法只能在抽象类中声明,虚方法则不是。
v abstract方法必须在派生类中重写,而virtual则不必。
v abstract方法不能声明方法实体,虚方法则可以。
如果类的修饰符(modifier/modify declarations)为abstract,那么编译器生成的默认构造器的可访问性就为protected。
一个类型可以定义多个实例构造器。为了使代码可验证,类的实例构造器在访问从基类继承的任何字段前,必须先调用基类的构造器。
如果派生类的构造器没有显式调用基类的构造器,那么C#编译器会自动生成对默认的基类构造器的调用。
在极少数的情况下,可以在不调用实例构造器的前提下创建一个类型的实例。一个典型的例子是Object的MemberwiseClone方法。
该MemberwiseClone方法的作用是分配内存,初始化对象的附加字段(类型对象指针和同步块索引),然后将源对象的字节数据复制到新对象中。
C#语言提供了一个简单的语法,允许在构造引用类型的一个实例时,对类型中定义的字段进行初始化。换句话说,允许以内联(inline)方法初始化实例字段。
8.2实例构造器和结构(值类型)
值类型构造器的工作方式与引用类型的构造器截然不同。
CLR总是允许创建值类型的实例,并且没有办法阻止值类型的实例化。所有,值类型其实并不需要定义构造器,C#编译器根本不会为值类型生成默认的无参构造器。
8.3类型构造器
类型构造器也称为静态构造器、类构造器或者类型初始化器。
类型构造器的作用是设置类型的初始状态。实例构造器的作用是设置类型的实例的初始状态。
默认情况下,类型没有定义类型构造器。类型构造器永远没有参数。
如下,C#为引用类型和值类型定义一个类型构造器:
internal sealed class SomeRefType
{
static SomeRefType() { }
}
internal struct SomeValType
{
static SomeValType() { }
}
类型构造器的特点是:无参,static标记,而且可访问性都是private,但是不能显示指定为private。
定义类型构造器类似于定义无参实例构造器,区别在于必须将它们标记为static。但C#会自动把类型构造器标记为private。事实上如果在源代码中显示将类型构造器标记为private,C#编译器会显示以下错误消息“静态构造函数中不允许出现访问修饰符”。必须是私有的原因是,为了阻止任何由开发人员写的代码调用它,对它的调用总是由CLR完成的。
类型构造器的调用比较麻烦。当JIT编译器编译一个方法时,它会检查代码里面是否引入了其他类型。如果引入了其他类型的类型构造器,则JIT编译器会检测是否已经在AppDomain里面执行过。如果没有执行,则发起对类型构造器的调用,否则不调用。
多个线程同时调用某个类型的静态构造器时,如何确保构造器仅仅被执行一次:
在编译之后,线程会开始执行并最终获取调用构造函数的代码。实际上有可能是多个线程执行同一个方法,CLR想要确保一个类型构造器在一个AppDomain里面只执行一次。当一个类型构造器被调用时,调用的线程会获取一个互斥的线程同步锁,这时如果有其他的线程在调用,则会阻塞。第一个线程会执行静态构造器中的代码。当第一个线程执行完后离开,其他的线程被唤醒并发现构造器的代码执行过了,所以不会继续去执行了,从构造器方法返回。CLR通过这种方式来确保构造器仅仅被执行一次。
由于CLR会确保类型构造器在每一个AppDomain里面只会执行一次,是线程安全的。所以如果要初始化任何单例对象(singleton object),放在类型构造器里面是再合适不过了。
类型构造器里面的代码只能访问类型的静态字段,它的常规用途是初始化这些字段。C#提供了简单的语法来初始化类型的静态字段:
internal sealed class SomeType
{
private static Int32 s_x = 5;
}
上面的代码生成时,编译器自动回SomeType创建一个类型构造器如下:
internal sealed class SomeType
{
private static Int32 s_x;
static SomeType()
{
s_x = 5;
}
}
但是,C#不允许值类型使用内联字段初始化语法来实例化字段,所以下面这种方式就是错的:
internal sealed struct SomeType
{
private Int32 s_x = 5; //这样会报错,需要加static关键字
}
如果显式的定义了类型构造器,如下:
internal sealed class SomeType
{
private static Int32 s_x = 5;
static SomeType()
{
s_x = 10;
}
}
最终s_x的结果是10。这里,C#编译器首先会生成一个类型构造器方法,这个构造器首先初始化s_x为5,然后初始化为10。换句话说,在类型构造器里面的显示定义的代码会在 使用内联字段初始化语法来实例化静态字段之后执行。
只有当AppDomain卸载时,类型才会卸载。
类型构造器的性能(不懂)
8.4操作符重载方法
有的编程语言允许一个类型定义操作符应该如何操作类型的实例。比如System.String重载了相等(==)和不等(!=)操作符。CLR对操作符重载一无所知,它甚至不知道什么是操作符。是编程语言定义了每个操作符的含义,以及当这些操作符出现时,应该生成什么样的代码。
例如在C#中,向基元数字应用+符合,编译器会生成将两个数加到一起的代码。将+操作符应用于String对象,C#编译器会生成将两个字符串连接到一起的代码。
编译 源代码时,编译器会生成一个标识操作符行为的方法。CLR规范要求操作符重载方法必须是public和static方法。
以下C#代码中展示了一个类中定义的操作符重载方法:
namespace HelloCSharp
{
class OperatorTest
{
public int Value { get; set; }
public static void Main()
{
OperatorTest o1 = new OperatorTest();
o1.Value = 11;
OperatorTest o2 = new OperatorTest();
o2.Value = 22;
OperatorTest o3 = o1 + o2;
Console.WriteLine(o3.Value);
Console.ReadKey();
}
public static OperatorTest operator +(OperatorTest o1, OperatorTest o2)
{
OperatorTest o = new OperatorTest();
o.Value = o1.Value + o2.Value;
return o;
}
}
}
C# 允许用户定义的类型通过使用 operator 关键字定义静态成员函数来重载运算符。注意必须用public修饰,必须是类的静态的方法。同时,重载相等运算符(==)时,还必须重载不相等运算(!=)。< 和 > 运算符以及 <= 和 >= 运算符也必须成对重载。
8.5转换操作符方法
以后重看
8.6扩展方法
由于StringBuilder是可变的(mutable),所以它是处理字符串方法的首选。现在假定你想亲自定义一些缺失的方法,以方便操作一个StringBuilder。列如,StringBuilder中没有自定义的IndexOf方法,你也许想自己定义一个IndexOf方法。
C#扩展方法所做的事情是它允许你定义一个静态方法,并用实例方法的语法来调用它。为了将Indexof方法转变成扩展方法,只需在第一个参数前添加this关键字:
public static class StringBuilderExtensions
{
public static Int32 IndexOf(this StringBuilder sb, Char value)
{
for (Int32 index = 0; index < sb.Length; index++)
{
if (sb[index] == value)
return index;
}
return -1;
}
}
当C#编译器看到以下代码:
public class TestProgram
{
public static void Main()
{
StringBuilder sb = new StringBuilder("Hello. My name is Chris.");
Int32 index = sb.IndexOf('!');
//Int32 index1 = StringBuilderExtensions.IndexOf(sb, '!');
}
}
StringBuilderExtensions.IndexOf(sb, '!')影响了我们对代码行为的理解,StringBuilderExtensions的使用显得“小题大做”,造成程序员无法专注于当前要执行的操作:IndexOf。
编译器首先检查StringBuilder类或者它的任何基类是否提供了获取单个Char参数、名为IndexOf的一个实例方法。如果存在这样的一个实例方法,编译器会生成IL代码来调用它。如果没有发现匹配的实例方法,则继续检查是否存在任何静态类定义了一个名为IndexOf的静态方法。静态方法中的第一个参数的类型是和当前用于调用方法的那个表达式的类型匹配的一个类型,并且这个类型必须使用this关键字来标识。在本例中,表达式是sb,它是StringBuilder类型。编译器会查找一个静态IndexOf方法,它有两个参数:一个StringBuilder(用this关键字进行标记),以及一个Char。编译器发现了这个IndexOf方法,并生成IL代码来调用这个静态方法。
String(引用类型)的不变性(immutable):
v String最为显著的一个特点就是它具有恒定不变性:一旦创建了一个String对象,在managed heap 上为他分配了一块连续的内存空间,我们将不能以任何方式对这个String进行修改使之变长、变短、改变格式(不能修改String对象的值)。所有对这个String进行各项操作(比如调用ToUpper获得大写格式的String)而返回的String,实际上是另一个重新创建的String,其本身并不会产生任何变化。每次使用 String 类中的方法之一或进行运算时(如赋值、拼接等)时,都要在内存中创建一个新的字符串对象,这就需要为该新对象分配新的空间。
v StringBuilder此类表示值为可变字符序列的类似字符串的对象。之所以说值是可变的,是因为在通过追加、移除、替换或插入字符而创建它后可以对它进行修改。大多数修改此类的实例的方法都返回对同一实例的引用。实例的 int Capacity 属性,它表示内存中为存储字符串而物理分配的字符串总数。该数字为当前实例的容量。容量可通过 Capacity 属性或 EnsureCapacity 方法来增加或减少,但它不能小于 Length 属性的值。
注: .NET Framework中可变集合类如ArrayList 的Capacity 属性也类似这种自动分配机制。
8.6.1规则和原则
v C#只支持扩展方法,不支持扩展属性、扩展事件、扩展操作符等。
v 扩展方法(第一个参数前面有this的方法)必须在非泛型的静态类中声明。类名没有限制。扩展方法至少要有一个参数,而且只有第一个参数能用this关键字标记。
v C#编译器查找这些静态类中定义的扩展方法时,要求这些静态类本身必须具有文件作用域。换言之,此静态类不能嵌套在另一个类中。
v 扩展方法有潜在的版本控制问题。如果Microsoft未来为StringBuilder添加了IndexOf实例方法,那么在重新编译我们的代码时,编译器会重新绑定Microsoft的IndexOf的实例方法,而不是我们的静态IndexOf方法。
8.6.2用扩展方法扩展各种类型
8.6.3 ExtensionAttribute类
8.7分部方法
分部方法partial method在分部类型的一个部分中定义它的签名,并在该类型的另外一个部分中定义它的实现。
//工具生成的代码,存储在某个源代码文件中
internal sealed partial class Base
{
private String m_name;
//分布方法的声明
partial void OnNameChanging(String value);
public String Name
{
get { return m_name; }
set
{
OnNameChanging(value.ToUpper());
m_name = value;
}
}
}
//开发人员生成的代码,存储在另一个源代码文件中
internal sealed partial class Base
{
//分部方法的实现
partial void OnNameChanging(String value)
{
//Calling the base class OnNameChanging method:
//base.OnNameChanging(value);
if (String.IsNullOrEmpty(value))
{
throw new ArgumentNullException(value);
}
}
}
分部方法规则和原则:
v 它们只能在分部类中声明。
v 分部方法的返回类型始终是void,任何参数都不能用out修饰符标记(out和ref的区别就是传入的参数是否已经初始化了)。
原因:如果不是返回null,同时没有提供实现,那么调用一个未实现的方法,返回什么才合理呢?为了避免对返回值进行任何无端的猜测,c#的设计者决定只允许方法返回void。
v 分部方法的声明和实现必须具有完全一致的方法签名。
v 如果没有对应的实现部分,便不会在代码中创建一个委托来引用这个分部方法。
v 分部方法总是被视为隐式的private方法。但是C#编译器禁止你在分部方法声明之前添加访问修饰符关键字。
v 工具生成的代码,分布方法的声明要用partial关键字标记,无主体,没有方法实现。
v 开发者自己的代码中,分布方法的声明也要用partial关键字标记,有主体,有方法实现。
分部方法允许一个方法而不需要实现。如果没有实现分部方法,编译器会自动移除方法签名,不会生成任何代表分部方法的元数据。编译器也不会生成任何调用分部方法的IL指令。而且编译器也不会生成对本该传给分部方法的实参进行求值的IL的指令。在这个例子中,编译器不会生成调用ToUpper方法的指令。结果就是更少的元数据/IL,运行时的性能也得到大幅提升。
第9章 参数
9.1可选参数和命名参数
设计一个方法的参数时,可为部分或全部参数分配默认值。然后,调用这些方法的代码可以选择不指定部分实参,接受其默认值。
除此之外,调用方法时,还可通过指定参数名称的方式为其传递实参。
以下代码演示了可选参数和命名参数的用法:
public static class Program
{
private static Int32 s_n = 0;
private static void M(Int32 x = 9, String s = "A", DateTime dt = default (DateTime), Guid guid = new Guid())
{
Console.WriteLine("x={0},s={1},dt={2},guid={3}", x, s, dt, guid);
}
public static void Main() {
//如果调用时省略了一个实参,C#编译器会自动嵌入参数的默认值
M();
M(8, "x");
//为x显式传递5,指出我想为guid和dt的参数传递一个实参
M(5, guid: Guid.NewGuid(), dt: DateTime.Now);
M(s_n++, s_n++.ToString());
//使用已命名的参数传递实参
M(s: (s_n++).ToString(), x: s_n++);
}
}
Guid(Globally Unique Identifier 全球唯一标识符)一个通过特定算法产生的二进制长度为128位的数字标识符(16 字节),用于指示产品的唯一性。
向方法传递实参时,编译器按从左到右的顺序对实参进行求值。
C#中值传递与引用传递的区别:
把实参当做实参来传递时,就产生了一个新的拷贝。
class Test
{
static void Main(String[] args)
{
int x = 8;
Fo(x);
Console.WriteLine("x={0}", x);
}
static void Fo(int p)
{
p = p + 1;
Console.WriteLine("p={0}", p);
}
}
以上程序运行结果为p=9,x=8; 即X的值不会受P影响,给P赋一个新值并不会改变X的内容,因为P和X存在于内存中不同的位置。
同理,用传值的方式传递一个引用类型对象时,只是复制这个对象本身,即复制其地址值,而不是它指代的对象。下面代码中Fo中看到的StringBuilder对象,就是在Main方法中实例化的那一个,只是有不同的引用指向它而已。
class Test
{
static void Fo(StringBuilder foSB)
{
foSB.Append("test");
foSB = null;
}
static void Main()
{
StringBuilder sb = new StringBuilder();
Fo(sb);
Console.WriteLine(sb.ToString());
}
}
运行结果:test.
换句话说,sb和foSB是指向同一对象的不同引用变量。因为FoSB是引用的拷贝,把它置为null并没有把sb置为 null。
值传递:传的是对象的值拷贝。
引用传递:传的是栈中对象的地址。
ref和out:
ref和out关键字都导致参数通过引用传递。
传递到 ref 形参的实参必须先经过初始化,然后才能传递。
out 形参不同,在传递之前,不需要显式初始化该形参的实参,out形参必须在Method方法中初始化。
关键字相似。
class RefExample
{
static void Method(ref int i)
{
// Rest the mouse pointer over i to verify that it is an int.
// The following statement would cause a compiler error if i
// were boxed as an object.
i = i + 44;
}
static void Main()
{
int val = 1;
Method(ref val);
Console.WriteLine(val);
// Output: 45
}
}
class OutExample
{
static void Method(out int i)
{
i = 44;
}
static void Main()
{
int value;
Method(out value);
// value is now 44
}
}
通过引用传递的效果是,把变量作为参数传递给方法,在方法中修改该参数,会改变这个变量的值。
不要混淆通过引用传递的概念与引用类型的概念。无论方法参数是值类型还是引用类型,均可由 ref 修改。
当通过引用传递时,不会对值类型装箱。
若要使用 ref 参数,方法定义和调用方法均必须显式使用 ref 关键字。
尽管 ref 和 out 关键字会导致不同的运行时行为,它们并不被视为编译时方法签名的一部分。
9.1.1规则和原则
在你定义的方法中,如果为部分参数指定了默认值,请注意下述这些额外的规则和原则:
v 可以为方法,构造器方法和有参属性的参数指定默认值。
v 没有默认值的参数必须放在有默认值的参数之前。例如,如果删除s的默认值(“A“),就会出现编译错误。
v 默认值必须是编译时能确定的常量值。可以设置默认值的参数的类型是:C#认定的基元类型,枚举类型,以及能设为null的任何引用类型。
v 可以用default关键字或new关键字,将值类型的参数的默认值设为值类型的一个实例。
v 不要重命名参数变量(s, x, dt, guid)。否则,任何调用者以传参数名的方式传递实参,都必须修改它们的代码。
v 如果参数用ref或out关键字进行了标识,就不能设置默认值。因为没有办法为这些参数传递有意义的默认值。
v 实参可按任意顺序传递,但命名实参只能出现在实参列表的尾部。
v C#不允许省略逗号之间的实参,比如M(5, , dt: DateTime.Now);
9.1.2 DefaultParameterValueAttribute和OptionalAttribute
在C#中,一旦为某个参数分配了一个默认值,编译器就会在内部向该参数应用一个定制attribute,即System.Runtime.InteropServices.OptionalAttribute。这个attribute会在最终生成的文件的元数据中持久性的存储下来。
9.2 隐式类型的局部变量
针对一个方法中的局部变量,C#允许根据初始化表达式的类型来推断它的类型。
9.3以传引用的方式向方法传递参数
默认情况下,CLR假定所有方法参数都是传值的。传递引用类型的对象时,对一个对象的引用会传给方法。注意这个引用本身是以传值方式传给方法的。
在方法中,必须知道传递的每个参数是引用类型还是值类型,因为用于操纵不同类型的代码可能有显著的差异。
CLR允许以传引用而非传值的方式传递参数。在C#中,这是用关键字out或ref来做到的。这两个关键字都告诉C#编译器生成元数据来指明该参数是传引用的。编译器将生成代码来传递参数的地址,而不是传递参数本身。
如果方法的参数用out来标记,表明不指望调用者在调用方法之前初始化好对象。被调用的方法不能读取参数的值,而且在返回前必须向这个值写入。
public sealed class Program
{
public static void Main()
{
Int32 x;
GetVal(out x);//x在调用GetVal前不必初始化
Console.WriteLine(x);
}
private static void GetVal(out Int32 v)
{
v = 10;//返回前必须初始化v
}
}
在前面的代码中,x是存储在Main的栈帧中声明的,然后x的地址传递给GetVal。GetVal的v是一个指针,它指向Main栈中的Int32值。
栈帧:在执行线程的过程中进行的每个方法调用都会在调用栈中创建并压入一个StackFrame。
相反,如果方法的参数用ref来标记,调用者必须在调用方法前初始化参数的值,被调用的方法可以读取值以及或者向值写入。
9.4向方法传递可变数量的参数
有时候开发人员想定义一个方法来获取可变数量的参数。
public static class Program
{
static Int32 Add(params Int32[] values)
{
Int32 sum = 0;
if (values != null)
{
for (Int32 x = 0; x < values.Length; x++)
{
sum += values[x];
}
}
return sum;
}
public static void Main()
{
//Console.WriteLine(Add(new Int32[] { 1, 2, 3 }));
Console.WriteLine(Add(1, 2, 3));
Console.ReadKey();
}
}
除了params外,以上方法的一切对你来说都应该是非常熟悉的。
很明显数组能用任意数量的一组元素来初始化,再传给Add方法进行处理。
params只能用于方法签名中的最后一个参数。即在方法声明中的 params 关键字之后不允许任何其他参数,并且在方法声明中只允许一个 params 关键字。
params关键字告诉编译器向参数应用System.ParamArrayAttribute的一个实例。
C#编译器检测到一个方法调用时,会先检查所有具有指定名称、同时参数没有应用ParamArrayAttribute的方法。如果找到一个匹配的方法,编译器就生成调用它所需的代码。如果编译器没有找到一个以上匹配的方法,会接着检查应用了ParamArrayAttribute的方法。如果找到一个应用了ParamArrayAttribute的方法,编译器会先生成代码来构造一个数组,填充它的元素,在生成代码来调用选定的方法。
在前一个例子中,没有定义可获取3个Int32兼容实参的Add方法。但是编译器发现在一个Add方法调用中传递了一组Int32值,而且有一个Add方法的Int32数组参数应用了ParamArrayAttribute。因此,编译器会认为这是一个匹配,所以会生成代码,将实参保存到一组Int32数组中,再调用Add方法,并传递该实参。
最终的结果就是你可以直接向Add方法传递一组实参,编译器会生成代码,像上面例子中注释的代码一样,帮你构造和初始化一个数组来容纳实参。
9.5参数和返回类型的指导原则
声明方法的参数类型时,应尽量指定最弱的类型,最好是接口而不是基类。
(重看)
9.6 常量性
CLR没有提供对常量对象/实参的支持。
第10章 属性
属性允许源代码用一个简化的语法来调用一个方法。
CLR支持两种属性:无参属性 (parameterless property) ,有参属性(parameterful property)
C#中将有参属性称为索引器(indexer)
10.1无参属性
一般用类型的字段成员来实现获取或改变类型的状态信息。
面向对象设计和编程的重要原则之一就是数据封装(data encapsulation),它意味着类型的字段永远不应该公开。强烈建议将所有的字段都设为private。
要允许获取类型状态信息,就公开一个针对该用途的方法。
封装了字段访问的方法通常称为访问器(accessor)方法(如下面的GetName,SetName)。访问器方法可以对数据的合理性进行检查,确保对象的状态不被破坏。
public sealed class Employee
{
private String m_Nmae;
private Int32 m_Age;
public String GetName()
{
return m_Nmae;
}
public void SetName(String value)
{
m_Nmae = value;
}
public Int32 GetAge()
{
return m_Age;
}
public void SetAge(Int32 value)
{
if (value < 0)
{
throw new ArgumentOutOfRangeException("value", value.ToString(), "The value must be grater than or equal to 0");
}
m_Age = value;
}
public static void Main()
{
Employee e = new Employee();
e.SetName("Jeffery Richter");
String EmployeeName = e.GetName();
e.SetAge(41);
e.SetAge(-5);
Int32 EmployeeAge = e.GetAge();
}
}
将SetXxx方法标记为protected,就可以实现只允许派生类型修改值。
以上代码中,类型的用户必须调用方法,而不能直接引用一个字段名。
编程语言和CLR还提供了一种称为属性(property)的机制,如下:
public sealed class Employee
{
private String m_Nmae;
private Int32 m_Age;
public String Name
{
get { return (m_Nmae); }
set { m_Nmae = value; }
}
public Int32 Age
{
get { return (m_Age); }
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException("value", value.ToString(), "The value must be greater than or equal to 0");
}
m_Age = value;
}
}
public static void Main()
{
Employee e = new Employee();
e.Name = "Jeffery Richter";
String EmployeeName = e.Name;
e.Age = 41;
e.Age = -5;
Int32 EmployeeAge = e.Age;
}
}
可将属性想象成智能字段(smart field),即背后有额外逻辑的字段。
每个属性都有一个名称(Name,Age)和一个类型(String,Int32不能为void)。属性不能重载。定义属性时,可以省略set方法来定义一个只读属性,或者省略get方法来定义一个只写属性。
通过属性的get和set方法来操作类型内私有的字段,是一种很常见的做法。
以前面的Employee类型为例。编译器编译这个类型时,会发现其中的Name和Age属性。由于两个属性都有get和set访问器方法,所以编译器在Employee类型中生成4个方法定义。
10.1.1自动实现的属性
如果只是为了封装一个支持字段而创建一个属性,C#还提供了更简单的语法,称为自动实现的属性(automatically Implemented Property)。
10.1.2合理定义属性
我个人不喜欢属性
10.1.3对象和集合初始化器
System.Collections命名空间包含可使用的集合类和相关的接口,提供了集合的基本功能。
IEnumerable 接口
System.Collections
该枚举数支持在非泛型集合上进行简单迭代
所有继承了IEnumerable的类,要使用foreach迭代器时,就需要使用该方法。因此也只有实现了该接口的类才可以使用foreach。
名称 | 说明 |
GetEnumerator() | 返回循环访问集合的枚举数。 |
IList 接口
System.Collections
IList 是 ICollection 接口的子代,并且是所有(非???)泛型列表的基接口
IList继承自ICollection
名称 | 说明 |
Add(Object) | 将某项添加到 IList 中。 |
Clear() | 从 IList 中移除所有项。 |
Contains(Object) | 确定 IList 是否包含特定值。 |
CopyTo(Array, Int32) | 从特定的 Array 索引处开始,将 ICollection 的元素复制到一个 Array 中。(从 ICollection 继承。) |
GetEnumerator() | 返回循环访问集合的枚举数。(从 IEnumerable 继承。) |
IndexOf(Object) | 确定 IList 中特定项的索引。 |
Insert(Int32, Object) | 将一个项插入指定索引处的 IList。 |
Remove(Object) | 从 IList 中移除特定对象的第一个匹配项。 |
RemoveAt(Int32) | 移除指定索引处的 IList 项。 |
ICollection<T> 接口
System.Collections.Generic
定义操作泛型集合的方法。
ICollection继承自IEnumerable
名称 | 说明 |
Add(T) | 将某项添加到 ICollection<T> 中。 |
Clear() | 从 ICollection<T> 中移除所有项。 |
Contains(T) | 确定 ICollection<T> 是否包含特定值。 |
CopyTo(T[], Int32) | 从特定的 Array 索引开始,将 ICollection<T> 的元素复制到一个 Array 中。 |
GetEnumerator() | 返回一个循环访问集合的枚举器。(从 IEnumerable<T> 继承。) |
Remove(T) | 从 ICollection<T> 中移除特定对象的第一个匹配项。 |
ICollection主要针对静态集合;IList主要针对动态集合。
如果一个方法的返回值是IEnumerable<T> ,必须在方法后面使用.ToList()方法才能得到一个集合数据。
集合的初始化被认为是相加(Additive)操作,而非替换的操作。编译器发现Student属性的类型是List<String>,而且这个类型实现了IEnumerable<String>接口。如下:
public sealed class Classroom
{
private List<String> m_students = new List<String>();
public List<String> Students { get { return m_students; } }
public Classroom() { }
public static void Main()
{
Classroom classroom = new Classroom
{
Students = { "Chris","Jeff" }
};
//Classroom classroom = new Classroom();
//classroom.Students.Add("Chris");
//classroom.Students.Add("Jeff");
foreach (var student in classroom.Students)
Console.WriteLine(student);
}
}
10.1.4匿名类型
10.1.5System.Tuple类型
10.2有参属性
重看
第11章事件
如果类型定义了事件成员,那么类型就可以通知其他对象发生了特定的事情。
例如,Button类提供了一个名为Click的事件。应用程序中的一个或多个对象可能想接收关于这个事件的通知,以便在Button被单击后采取某些操作。事件是实现这种交互的类型成员。
具体的说,如果定义一个事件成员,意味着类型要提供以下能力:
- 方法可登记它对该事件的关注。
- 方法可注销它对该事件的关注。
- 该事件发生时,登记了的方法会收到通知。
类型之所以能提供事件通知功能,是因为类型维护了一个已登记方法的列表。事件发生后,类型将通知列表中所有已登记的方法。
CLR的事件模型建立在委托基础上的。委托是调用回调方法的一种类型安全的方式。对象凭借回调方法接收它们订阅的通知。
为了帮助你完全理解事件在CLR中的工作机制,先来描述一个事件很有用的场景。假定现在要设计一个电子邮件应用程序。电子邮件到达时,用户可能希望将该邮件转发给传真机。建构这个应用程序时,假定先设计了一个名为MailManager的类型,它负责接收传入的电子邮件。MailManager类型公开了一个名为NewMail的事件。其他类型(如Fax和Pager)的对象登记它们对这个事件的关注。MailManager收到一封新电子邮件时,会引发该事件。造成邮件分发给每一个已登记的对象。每个对象都用它们自己的方式处理该邮件。
应用程序初始化时,让我们只实例化一个MailManager实例。然后,应用程序可实例化任意数量的Fax和Pager对象。
11.1设计要公开事件的类型
MailManager示例应用程序展示了MailManager类型,Fax类型和Pager类型的所有源代码。
11.1.1第一步:定义类型来容纳所有需要发送给事件通知接收者的附加信息
事件引发时,引发事件的对象可能希望向接收事件通知的对象传递一些附加的信息。这些附加的信息需要封装到它自己的类中,该类通常包含一组私有字段,以及一些用于公开这些字段的只读公共属性。根据约定,这种类应该从EventArgs类派生,并且类名必须以EventArgs结束。
定义一个没有附加信息需要传递的事件时,可直接使用EventArgs.Empty,不用构造一个新的EventArg对象。
//第一步:定义一个类型来容纳所有应该发给事件通知接收者的附加信息
internal class NewMailEventArgs : EventArgs
{
private readonly String m_from, m_to, m_subject;
public NewMailEventArgs(String from, String to, String subject)
{
m_from = from; m_to = to; m_subject = subject;
}
public String From { get { return m_from; } }
public String To { get { return m_to; } }
public String Subject { get { return m_subject; } }
}
//后续的将在MailManager类中进行
internal class MailManager { }
11.1.2第二步:定义事件成员
internal class MailManager
{
public event EventHandler<NewMailEventArgs> NewMail;
void MethodName(object sender, NewMailEventArgs e);
}
事件成员使用C#关键字event来定义。每个事件成员都要指定以下内容:
- 一个可访问性标识符-public。
- 一个委托类型,它指出要调用的方法的原型- EventHandler<NewMailEventArgs>。
- 一个事件名称-NewMail
事件成员的类型是EventHandler<NewMailEventArgs>,意味着“事件通知”的所有接收者都必须提供一个原型和EventHandler<NewMailEventArgs>委托类型匹配的回调方法。
由于泛型EventHandler委托类型的定义如下:
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);
所以方法原型必须具有以下形式:
void MethodName(object sender, NewMailEventArgs e);
11.1.3 第三步:定义负责引发事件的方法来通知事件的登记对象
。。。
第12章 泛型
泛型(generic)是CLR和编程语言提供的一种特殊机制,它支持另一种形式的代码重用,即算法重用。
简单的说,开发人员先定义好一个算法,比如排序、搜索、交换、比较或者转换等。但是,定义算法的开发人员并不设定该算法要操作什么数据类型。该算法可以广泛地应用于不同类型的对象。然后,另一个开发人员,只有指定了算法要操作的具体数据类型,就可以开始使用这个现成的算法了。例如,可以用一个排序算法来操作Int32和String等类型对象。
大多数算法都封装在一个类型中,CLR允许创建泛型引用类型和泛型值类型,但不允许创建泛型枚举类型。CLR还允许创建泛型接口和泛型委托。
先来看一个简单的例子,Framework类库中定义了一个泛型列表算法,它知道如何管理一个对象集合。泛型算法没有设定这些对象的数据类型。
封装了泛型列表算法的FCL类称为List<T>。泛型List类的设计者紧接着在这个类名后添加一个<T>,表明它操作的是一个未指定的数据类型。
定义泛型类型时,它为类型指定的任何变量(比如T)都称为类型参数(type parameter)。T是一个变量名,在源代码中能够使用一个数据类型的任何位置,都能使用T。
列如:在List类定义中,
T被用作方法参数,Add方法接收一个T类型的参数public void Add(T item);
T被用作返回值,ToArray方法返回一组T类型的一维数组public T[] ToArray();
根据Microsoft的设计原则,泛型参数变量要么称为T,要么至少以大写T开头(如TKey和TValue)。
使用泛型类型或方法时,指定的具体数据类型称为类型实参(type argument)。
例如:开发人员可指定一个DateTime类型实参来使用List算法。
public class Program{
private static void SomeMethod()
{
//构造一个List来操作DateTime对象
List<DateTime> dtList = new List<DateTime>();
//向列表添加DateTime对象,不进行装箱
dtList.Add(DateTime.Now);
dtList.Add(DateTime.MinValue);
//尝试向列表中添加一个String对象,编译时报错,Invalid arguments
dtList.Add("1/1/2004");
//从列表提取一个DateTime对象
DateTime dt = dtList[0];
}
}
从以上代码可以看出,泛型为开发人员提供了以下优势:
- 源代码保护à使用一个泛型算法的开发人员不需要访问算法的源代码
- 类型安全à保证只有与指定具体数据类型兼容的对象才能随同算法使用
- 更加清晰的代码à DateTime dt = dtList[0];中不需要使用(DateTime)转型将索引器的结果存储在dt变量中
- 更佳的性能à现在能创建一个泛型算法来操作具体的数据类型,所有值类型的实例都能以传值方式传递,CLR不再需要执行任何装箱操作
12.1 Framework类库中的泛型
泛型最明显的应用就是集合类。
FCL定义的几个泛型集合类,大多数都在System.Collections.Generic和System.Collections.ObjectModel命名空间中。
12.2Wintellect的Power Collections库
12.3泛型基础结构
泛型是在CLR2.0版本中加入的,为了在CLR中加入泛型,Microsoft做了一下工作:
- 创建新的IL指令,使之能够识别类型实参。
- 修改现有元数据表的格式,以便表示具有泛型参数的类型名称和方法。
- 修改各种编程语言,以支持新的语法,允许开发人员定义和引用泛型类型和方法。
- 修改编译器,使之能生成新的IL指令和修改的元数据格式。
- 修改JIT编译器,使之能处理新的、支持类型实参的IL指令,以便生成正确的本地代码。
- 创建新的反射成员,是开发人员能查询类型和成员,以判断它们是否具有泛型参数。
- 修改调试显示器以显示和修改泛型类型、成员、字段以及局部变量。
- 修改Visual Studio的智能感知“IntelliSense”。
12.3.1开放类型和封闭成员
第13章 接口
多继承(multiple inheritance)是指一个类从两个或多个基类派生的能力。
CLR不支持多继承,CLR只是通过接口提供了“缩水版”的多继承。
实现接口的类或结构必须实现接口定义中指定的接口成员。
interface IEquatable<T>
{
bool Equals(T obj);
}
实现IEquatable<T>接口的任何类或结构都必须包含与该接口指定的签名匹配的Equals方法的定义。
public class Car : IEquatable<Car>
{
public string Make { get; set; }
public string Model { get; set; }
public string Year { get; set; }
public bool Equals(Car car)
{
if (this.Make == car.Make && this.Model == car.Model && this.Year == car.Year)
{
return true;
}
else
{
return false;
}
}
}
IEquatable<T>的定义不为Equals提供实现,该接口仅定义签名。
类或结构可以实现多个接口,但是类只能继承单个类(抽象或不抽象)。
接口可以包含方法、属性、事件、索引器或这四种成员类型的任意组合。
接口成员会自动成为公共成员,不能包含任何访问修饰符。成员也不能是静态成员。
若要实现接口成员,实现类的对应成员必须是公共、非静态,并且具有与接口成员相同的名称和签名。
接口具有以下属性:
- 接口类似于抽象基类。实现接口的任何类或结构都必须实现其所有成员。
- 接口无法直接进行实例化。其成员由实现接口的任何类或结构来实现。
- 接口可以包含事件、索引器、方法和属性。
- 接口不包含方法的实现。
- 一个类或结构可以实现多个接口。一个类可以继承一个基类,还可实现一个或多个接口。
13.1类和接口继承
从Object派生任何类实际都继承了以下内容:
- 方法签名
- 方法的实现(ToString, Equals, GetHashCode, GetType)
13.2定义接口
接口对一组方法签名进行了统一命名。接口还能定义事件,无参属性和索引器。所有这些本质上都是方法。但接口不能定义构造器方法。接口也不能定义任何实例字段。
C#禁止接口定义任何一种这样的静态成员。
在C#中是用interface关键字定义接口的。要为接口指定一个名称和一组实例方法签名。
对CLR而言,接口定义就像是一个类型定义。也就是说,CLR会为接口类型对象定义一个内部数据结构,同时可用反射机制来查询接口类型的功能。
和类型一样,接口可用在文件范围内定义,也可嵌套在另一个类型中定义。定义接口类型时,可指定你希望的任何可视性/可访问性(public, protect, internal等)。
接口成员会自动成为公共成员,不能包含任何访问修饰符。成员也不能是静态成员。
根据约定,接口类型名称要以大写I开头,目的是方便在源代码中辨认接口类型。
CLR支持泛型接口和在接口中的泛型方法。
13.3继承接口
以下代码展示了如何定义一个实现该接口的类型:
public interface IComparable<in T>
{
//接口成员不能包含任何访问修饰符,不能是静态成员,它会自动成为公共成员
int CompareTo(T other);
}
public sealed class Point : IComparable<Point>
{
private Int32 m_x, m_y;
//接口不能定义构造器方法,实现接口的类可以定义构造器方法
public Point(Int32 x, Int32 y)
{
m_x = x;
m_y = y;
}
//和接口对应的成员必须是公共非静态的,和接口成员相同的名称和签名
public Int32 CompareTo(Point other)
{
return Math.Sign(Math.Sqrt(m_x * m_x + m_y * m_y)
- Math.Sqrt(other.m_x * other.m_x + other.m_y * other.m_y));
}
public override String ToString()
{
return String.Format("{0},{1}", m_x, m_y);
}
}
public static class Program
{
public static void Main()
{
Point[] points = new Point[] { new Point(3, 3), new Point(1, 2) };
if (points[0].CompareTo(points[1]) > 0)
{
Point tempPoint = points[0];
points[0] = points[1];
points[1] = tempPoint;
}
Console.WriteLine("Points from closest to (0,0) to farthest:");
foreach (Point p in points)
Console.WriteLine(p);
}
}
C#编译器要求将用于实现一个接口的方法标记为public。
编译器会将实现接口的方法标记为virtual和sealed。
第14章 字符、字符串和文本处理
14.1字符
在.Net Framework中,字符总是表示成16位Unicode代码值,这简化了国际化应用程序的开发。
每个字符都表示成System.Char结构的一个实例。
针对Char的一个实例,可以调用静态GetUnicodeCategory方法,这个方法返回的是System.Globalization.UnicodeCategory枚举类型的一个值。
Char类型提供了几个静态方法,比如IsDigit,IsUpper等。注意,所有这些方法要么获取单个字符作为参数,要么获取一个String以及目标字符在这个String中的索引作为参数。
ToLower和ToUpper之所以需要语言文化信息,是因为字母的大小写转换是一种依赖于语言文化的操作。语言文化信息是这两个方法在内部查询System.Threading.Thread类的静态CurrentCulture属性获取的。
除了这些静态方法,Char类型还提供了几个实例方法。比如:Equals方法会在两个Char实例代表同一个16位Unicode码位的前提下返回true。CompareTo方法返回两个Char实例忽略语言文化的比较结果。GetNumericValue方法,它返回字符的数值形式,以下代码演示了这个方法。
public static class Program {
public static void Main() {
Double d;
d = Char.GetNumericValue('3');//3
Console.WriteLine(d.ToString());
d = Char.GetNumericValue('A');//-1
Console.WriteLine(d.ToString());
}
}
可以使用三种技术实现各个数值类型与Char实例的相互转换。
- 转型(强制类型转换)要将Char转换成一个数值,最简单的方法就是转型。
- 使用Convert类型 System.Convert类型提供了几个静态方法来实现Char和数值类型的相互转换。所有这些方法都以checked的方法来执行转换。所以一旦发现转换会造成数据丢失,就会抛出一个OverflowException异常。
- 使用IConvertible接口这种技术效率太差,因为在值类型上调用一个接口方法,要求对实例进行装箱-Char和所有数值类型都是值类型
以下代码演示了如何使用者三种技术:
public static class Program
{
public static void Main()
{
Char c;
Int32 n;
//使用C#强制类型转换
c = (Char)65;
n = (Int32)c;
//使用Convert类型
c = Convert.ToChar(65);
try
{
//700000000000对于Char的16位来说过大
c = Convert.ToChar(700000000000);
}
catch (OverflowException)
{
Console.WriteLine("Cannot convert 700000000000 to a Char");
}
//使用IConvertible接口
c = ((IConvertible)65).ToChar(null);
}
}
14.2 System.String类型
一个String代表一个不可变(immutable)的顺序字符集。String类型直接派生自Object,所以它是一个 引用类型。因此,String对象永远存在于堆上,永远不会跑到线程栈。
14.2.1构造字符串
C#将String视为一个基元类型-也就是说,编译器允许在源代码中直接表示文本常量字符串。编译器将这些文本常量字符串放到模块的元数据中,并在运行时加载和引用它们。
在C#中,不能使用new操作符从一个文本常量字符串构造一个String对象,必须使用简化过的语法。
public static class Program
{
//错误
String s = new String("Hi");
//正确
String s1 = "Hi";
}
对于换行符、回车符和退格符这样的特殊字符,C#采用转义机制。
\r return 回车
\n newline 换行
//包含回车换行符的字符串
String s = "Hi\r\nthere";
//以下是定义上述字符串的正确方式
String s1 = "Hi" + Environment.NewLine + "there";
可以使用C#的+操作符将几个字符串连接成一个。String s2 = "Hi" + "" + "there";
在上述代码中,由于所有字符串都是文本常量字符串,所以C#编译器会在编译时连接它们,最终只会将一个字符串(即"Hi there")放到模块的元数据中。对非文本常量字符串使用+操作符,连接则会在运行时进行。若要在运行时将几个字符串连接到一起,请避免使用+操作符,因为它会在堆上创建多个字符串对象,而堆是需要垃圾回收的,从而影响到性能。相反,应尽量使用String.Text.StringBuilder类型。
C#还提供了“逐字字符串(verbatim strings)”声明方式,通常用于指定文件或目录的路径,或者与正则表达式配合使用。
//不使用逐字字符串字符@来声明字符串
String file = "C:\\Windows\\System32\\Notepad.exe";
//使用逐字字符串字符@来声明字符串
String file = @"C:\Windows\System32\Notepad.exe";
在字符串之前添加@符号,是编译器知道字符串是一个逐字字符串。事实上,这告诉编译器将反斜杠字符视为文本常量,而不是转义字符,使文件路径在源代码中更易读。
14.2.2字符串是不可变的
String对象最重要的一个事实就是,它使不可变的。也就是字符串一经创建便不能更改,不能变长变短或修改其中的任何字符。
14.2.3比较字符串
一般会出于两方面的原因来比较字符串:
- 判断相等性
- 对字符串进行排序
进行排序时,应该总是执行区分大小写的比较。
Compare方法中ignoreCase设为true,不区分大小写。
判断字符串相等性或对字符串进行排序时,强烈建议调用下面的方法之一:
public bool Equals(string value, StringComparison comparisonType);
public static bool Equals(string a, string b, StringComparison comparisonType);
public static int Compare(string strA, string strB, StringComparison comparisonType);
public static int Compare(string strA, string strB, bool ignoreCase, CultureInfo culture);
public static int Compare(string strA, string strB, CultureInfo culture, CompareOptions options);
public static int Compare(string strA, int indexA, string strB, int indexB, int length, StringComparison comparisonType);
public static int Compare(string strA, int indexA, string strB, int indexB, int length, CultureInfo culture, CompareOptions options);
public static int Compare(string strA, int indexA, string strB, int indexB, int length, bool ignoreCase, CultureInfo culture);
public bool StartsWith(string value, StringComparison comparisonType);
public bool StartsWith(string value, bool ignoreCase, CultureInfo culture);
public bool EndsWith(string value, StringComparison comparisonType);
public bool EndsWith(string value, bool ignoreCase, CultureInfo culture);
许多程序都将字符串用于内部编程目的,比如路径名、文件名、URL、注册表项/值、环境变量、反射、XML等。出于编程目的而比较字符串时,应该总是使用StringComparison.Ordinal,这是执行字符串比较时最快的一种方式,因为在执行比较时,不需要考虑语言文化信息。
从现在起我们将讨论如何执行在语言文化上正确的比较。.Net Framework使用System.Globalization.CultureInfo表示一个“语言/国家”。
以下代码演示了序号比较和依赖语言文化比较的区别:
static void Main()
{
String s1 = "Strasse";
String s2 = "Straße";
Boolean eq;
//Compare返回非零值,如果传递Ordinal标志,Compare方法会忽略指定的语言文化
eq = String.Compare(s1, s2, StringComparison.Ordinal) == 0;
Console.WriteLine("Ordinal comparison:'{0}'{2}'{1}'", s1, s2, eq ? "==" : "!=");
//面向在德国说德语的人群
CultureInfo ci = new CultureInfo("de-DE");
//Compare返回零值
eq = String.Compare(s1, s2, true, ci) == 0;
Console.WriteLine("Cultural comparison:'{0}'{2}'{1}'", s1, s2, eq ? "==" : "!=");
}
14.2.4字符串留用
如上一节所述,检查字符串的相等性是许多应用程序的常见操作 - 这个任务可能严重损害性能。
执行序号ordinal相等性检查时,CLR快速检查两个字符串是否具有数量相同的字符。如果答案是肯定的,字符串有可能相等。然后CLR必须比较每个单独的字符才能确定。
除此之外,如果在内存中复制同一个字符串的多个实例,会造成内存的浪费,因为字符串是不可变的。如果只在内存中保留字符串的一个实例,那么将显著提高内存的利用率。需要引用字符串的所有变量只需指向单独一个字符串对象。
如果应用程序经常对字符串进行区分大小写的、序号式的比较,或者事先知道许多字符串对象都有相同的值,就可利用CLR的字符串留用(string interning)机制来显著提高性能。
CLR初始化时会创建一个内部哈希表,在这个表中,键(key)是字符串,而值(value)是对托管堆中的String对象的引用。
String类提供了两个方法,便于你访问这个内部哈希表:
public static string Intern(string str);
public static string IsInterned(string str);
Equals和ReferenceEquals的区别:
- ReferenceEquals: 永远比较2个引用对象所指向的地址是否相同,是比较引用。Object的静态方法,因此不能在继承类中重写该方法。判断值类型的时候需要装箱,肯定返回false,因为使用ReferenceEquals(object a,object b)方法后值类型被重新装箱为新的引用类型实例。String 类型因为有字符串驻留机制: string A="a"; string B="a"; 返回为True.
- Equals:永远比较2个对象 (无论引用,非引用) 的值是否相等,是比较值。
以下代码演示了字符串留用:
static void Main()
{
String s1 = "Hello";
String s2 = "Hello";
Boolean a = Object.ReferenceEquals(s1, s2);//true
s1 = String.Intern(s1);
s2 = String.Intern(s2);
Boolean b = Object.ReferenceEquals(s1, s2);//true
}
在对ReferenceEquals方法的第一个调用中,在CLR低版本中,s1引用堆中“Hello”字符串对象,而s2引用堆中另一个“Hello”字符串对象。在CLR的4.0版本上运行时,CLR选择忽视C#编译器生成的attribute/flag。但程序集加载到AppDomain中时,CLR会对文本常量字符串“Hello”进行默认留用。结果就为True。
在对ReferenceEquals方法的第二个调用之前,“Hello”字符串被显示留用,s1现在引用一个已留用的“Hello”。然后,通过再次调用Intern,s2被设置成s1引用的同一个“Hello”字符串。现在,当第二次调用ReferenceEquals时,就能保证获得一个True的结果,不管程序集在编译时是否设置了attribute/flag。
14.2.5字符串池
编译器有将单个字符串的多个实例合并成一个实例的能力。
14.2.6检查字符串的字符和文本元素
14.2.7其他字符串操作
还可以利用String类型提供的一些方法来复制一个字符串或者一部分。
public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count);
ToString:返回对同一个对象的引用
上述方法中将字符串的部分字符复制到字符数组中。
使用所有这些方法时都请牢记一点,它们返回的都是一个新的字符串对象。
14.3高效率构造字符串
由于String类型代表的是一个不可变字符串,所以FCL提供了另一个名为System.Text.StringBuilder的类型。可利用它高效地对字符串进行动态处理,最后基于处理结果创建一个String。
从逻辑上说,StringBuilder对象包含一个字段,该字段引用了由Char结构构成的一个数组。可利用StringBuilder的成员来操纵这个字符数组,高效地缩短字符串或更改字符串中的字符。
14.3.1构造StringBuilder对象
使用StringBuilder的方法时要记住,大多数方法返回的都是对同一个StringBuilder对象的引用。所以可以方便的将几个操作链接到一起完成:
static void Main()
{
StringBuilder sb = new StringBuilder();
String s = sb.AppendFormat("{0} {1}", "Jeffrey", "Richter").Replace(' ', '-').Remove(4, 3).ToString();
}
拼接字符串示例:
String[] value = { "1", "2", "3" };
String a = "";
StringBuilder str = new StringBuilder();
foreach (String text in value)
{
str.AppendFormat(",{0}", text);//将value数组中的值拼接成一个字符串,以逗号分隔
}
if (str != null && str.Length > 0)
{
str.Remove(0, 1);//移除第一个逗号
}
a = str.ToString();//要将StringBuilder转换成字符串
String和StringBuilder类提供的方法并不是完全匹配的。例如:String提供了ToLower,ToUpper,EndsWith,Trim等方法,但StringBuilder类没有提供任何与之对应的方法。另一方面,StringBuilder类提供了一个功能更全面的Replace方法,它允许替换作为一个字符串的一部分字符。而String类中Replace方法是public string Replace(char oldChar, char newChar);
由于这两个类中的方法不完全对应,所以有时需要在String和StringBuilder转换来完成特定的任务。
StringBuilder sb = new StringBuilder();
String s = sb.AppendFormat("{0}, {1}", "Jeffrey", "Richter").ToString();
s.ToUpper();
sb.Length = 0;
sb.Append(s).Insert(8, "Marc-");
s = sb.ToString(1, 2);
14.4获取对象的字符串表示:ToString(重看)
我们经常都要获取一个对象的字符串表示。可以调用ToString方法来获取任何对象的字符串表示。
无参ToStirng方法有两个问题。
String的Format方法。。。
14.5解析字符串来获取对象: Parse
解析字符串来获取一个对象,偶尔会用到。
Int32 x = Int32.Parse("1A", NumberStyles.HexNumber);//26
能解析一个字符串的任何类型都提供了Parse的一些public static方法。
先来看看如何将一个字符串解析成数值类型:
public static int Parse(string s, NumberStyles style, IFormatProvider provider);
s是字符串参数,NumberStyles是字符串参数s中运行的样式
Int32 x = Int32.Parse(" 123", NumberStyles.None); //要解析的字符串包含一个前导空白字符,会报FormatExpection异常
应该设成NumberStyles.AllowLeadingWhite
14.6 编码:字符和字节的相互转换
14.7安全字符串
Microsoft在FCL中添增了一个更安全的字符串类System.Security.SecureString
第15章 枚举类型和位标志
Enumeration提供了一些非常炫酷的功能,相信大多数开发人员都不熟悉。这些新功能极大的简化了应用程序开发。
15.1枚举类型
枚举类型(enumerated types)定义了一组“符号名称/值”配对。
以下Color类型定义了一组符号,每个符号都标识一种颜色:
internal enum Color
{
White,//赋值0
Red, //赋值1
Greed,//赋值2
Blue, //赋值3
Orange//赋值4
}
当然,也可以写个程序用0代表白色,1代表红色,以此类推。但不应该将这些数字硬编码到代码中,而应换用枚举类型,因为:
- 枚举类型使程序更容易编写、阅读和维护。
- 枚举类型是强类型的。
每个枚举类型都直接从System.Enum派生,后者从System.ValueType派生。而System.ValueType又从System.Object派生。所以,枚举类型是值类型,可表示成未装箱和已装箱形式。有别于其他值类型,枚举类型不能定义任何方法、属性和事件。
编译枚举类型时,C#编译器会把每个符号转换成类型的一个常量字段。例如,编译器会把前面的Color枚举类型看成以下代码:
C#编译器实际上并不编译这段代码,因为它禁止定义从System.Enum这一特殊类型派生的类型。
枚举类型定义的符号是常量值,所以当编译器一旦发现代码引用了一个枚举类型的符号,就会在编译时用数值替代符号,代码将不再引用定义了符号的枚举类型。
简单地说,枚举类型只是一个结构,其中定义了一组常量字段和一个实例字段。常量字段会嵌入程序集的元数据中,并可通过反射来访问。这意味着在运行时获得与一个枚举类型关联的所有符号及其值。还意味着可以将一个字符串符号转换成对应的数值。这些操作是通过System.Enum基类型来提供的。下面讨论其中的一些操作:
例如,System.Enum类型有一个名为GetUnderlyingType的静态方法,而System.Type类型有一个GetEnumUnderlyingType的实例方法。
public static Type GetUnderlyingType(Type enumType);
public virtual Type GetEnumUnderlyingType();
这些方法返回用于容纳一个枚举类型的值的基础类型。每个枚举类型都有一个基础类型,可以是byte,short,int(最常用,也是C#默认选择的),long。C#要求只能指定基元类型名称,如果使用FCL类型名称(比如Int32),会报错。
我们定义的枚举类型应该与需要调用它的那个类型同级。
以下代码演示了如何声明一个基础类型为byte的枚举类型:
internal enum Color : byte
{
White,
Red,
Greed,
Blue,
Orange
}
static void Main()
{
Console.WriteLine(Enum.GetUnderlyingType(typeof(Color))); //System.Byte
}
C# typeof() 和 GetType()区是什么?
- typeof(x)中的x,必须是具体的类名、类型名称等,不可以是变量名称。
- GetType()方法继承自Object,所以C#中任何对象都具有GetType()方法,它的作用和typeof()相同,返回Type类型的当前对象的类型。
C#编译器将枚举类型视为基元类型,所以,可以用许多熟悉的操作符(==,!=,<,>,<=,>=,+,-,^,&,|,++,--)来操纵枚举类型的实例。
所有这些操作符实际作用于每个枚举类型实例内部的value_实例字段。
给定一个枚举类型的实例,可调用从System.Enum继承的ToString方法:
public static class Program
{
static void Main()
{
//Console.WriteLine(Enum.GetUnderlyingType(typeof(Color)));
Color c = Color.Blue;
Console.WriteLine(c.ToString());//"Blue" 常规格式
Console.WriteLine(c.ToString("G"));//"Blue" 常规格式
Console.WriteLine(c.ToString("D"));//"3" 十进制格式
Console.WriteLine(c.ToString("X"));//"03" 十六进制格式
}
}
internal enum Color : byte
{
White,
Red,
Greed,
Blue,
Orange
}
Format:可调用它格式化一个枚举类型的值:
public static string Format(Type enumType, object value, string format);
Console.WriteLine(Enum.Format(typeof(Color), 3, "G"));//显示"Blue"
GetValues:获取枚举类型中定义的所有符号以及对应的值。
public static Array GetValues(Type enumType);
Color[] colors = (Color[])Enum.GetValues(typeof(Color));
Console.WriteLine("Number of symbols defined:" + colors.Length);
Console.WriteLine("Value\tSymbol\n-----\t------");
foreach (Color c in colors)
{
Console.WriteLine("{0,5:D}\t{0:G}", c);
}
GetName:返回数值的字符串表示。
Enum.GetName(typeof(Color), 3);//"Blue"
GetNames:返回一个String数组,每个符号都代表一个String。
Enum.GetNames(typeof(Color));
// {string[5]}
//[0]: "White"
//[1]: "Red"
//[2]: "Greed"
//[3]: "Blue"
//[4]: "Orange"
Parse, TryParse:将一个符号转换成枚举类型的实例。
public static object Parse(Type enumType, string value, bool ignoreCase);
Color c = (Color)Enum.Parse(typeof(Color), "orange", true); //Orange
Enum.Parse(typeof(Color), "0", true);//White
bool a=Enum.TryParse<Color>("Brown", false, out c);//false, 枚举中没有定义Brown
IsDefine:判断一个值对于一个枚举类型是否合法。
Enum.IsDefined(typeof(Color), "white");//false, 执行的是区分大小写的检查
Enum.IsDefined(typeof(Color), 5);//false, Color枚举类型没有与5对应的符号
15.2位标志
我们可以将位标志当做一种特殊的枚举类型。
FileAttributes类型是基本类型为Int32的枚举类型,其中每一位都反映文件的一项属性。
[Flags] //指示可以将枚举作为位域(即一组标志)处理。
public enum FileAttributes
{
ReadOnly = 1,
Hidden = 2,
System = 4,
Directory = 16,
Archive = 32,
Device = 64,
Normal = 128,
Temporary = 256,
SparseFile = 512,
ReparsePoint = 1024,
Compressed = 2048,
Offline = 4096,
NotContentIndexed = 8192,
Encrypted = 16384,
IntegrityStream = 32768,
NoScrubData = 131072
}
以上FileAttributes类型中,1的二进制为1,2的二进制为10,4的二进制为100。也就是说可以用每个二进制位来确认唯一性,这就是位标志的原理。
public static void Main()
{
//得到可执行文件(.exe文件)的相对路径(如:"...\bin\Debug\ConsoleApplication1.exe")
String file = Assembly.GetEntryAssembly().Location;
//调用System.IO.File类型的GetAttributes方法,会返回FileAttributes类型的一个实例
FileAttributes attributes = File.GetAttributes(file);
//因为二进制1&1才为1,所以只要存在最后的数值一定不为1,判断文件是否隐藏
Console.WriteLine("IS {0} hidden?{1}", file, (attributes & FileAttributes.Hidden) != 0);
//判断文件是否隐藏,换种写法。Enum有一个HasFlag方法,确定当前实例attributes中是否设置了一个或多个位域
Console.WriteLine("IS {0} hidden?{1}", file, attributes.HasFlag(FileAttributes.Hidden));
//将一个文件的属性改为只读和隐藏
File.SetAttributes(file, FileAttributes.ReadOnly | FileAttributes.Hidden);
}
16章 数组
数组是允许将多个数据项当作一个集合来处理的机制。CLR支持一维数组、多维数组和交错数组(即由数组构成的数组)。所有数组类型都隐式地从System.Array抽象类派生,意味着数组始终为引用类型,是在托管堆上进行内存分配的。在你的应用程序的变量或字段中,包含的是对数组的引用,而不是包含数组本身的元素。
Int32[] myIntegers;//声明一个数组引用
myIntegers = new Int32[100];//创建含有100个Int32的数组
在第一行代码中,myIntegers变量能指向一个一维数组。myIntegers刚开始被设为null,因为当时还没有分配数组。第二行代码中分配了含有100个Int32值的一个数组,所有Int32都被初始化为0。由于数组是引用类型,所以会在托管堆上分配容纳100个未装箱Int32所需的内存块。除了数组元素,数组对象占据的内存块还包含一个类型对象指针、一个同步块索引和一些额外的成员。该数组的内存块地址被返回并保存到myIntegers变量中。
还可以创建引用类型的数组:
第17章委托
本章要讨论回调函数。回调函数是一种非常有用的编程机制,它的存在已经有很多年了。
Microsoft .Net Framework通过委托(delegate)来提供了一种回调函数机制。
列如:委托确保回调方法是类型安全的。委托还允许顺序调用多个方法,并支持调用静态方法和实例方法。
C#中委托是在程序运行时可以使用它们来调用不同的函数。
举个简单的例子,你是编程的,你现在正在写一个ASP.NET网页,而JS是你不熟悉的,于是你委托你的一位同事来帮助你完成JS部分。这就是委托,把你所不能做的事情交给其他人去做。
1.简单的委托http://www.cnblogs.com/birdshover/archive/2008/01/07/1029471.html
那么委托需要承载哪些信息呢?首先它存储了方法名,还有参数列表(方法签名),以及返回类型,比如:
delegate String/*返回类型*/ ProcessDelegate(Int32 i);
蓝色部分是声明委托的关键字,红色是返回类型,黑色部分是委托的类型名,()里的就是参数部分。你要使用这个委托来做事情,必须满足一下条件:
- 返回类型和委托的返回类型一致,这里是String类型
- 参数列表能且只能有一个参数,并且是Int32类型
例如:
输出的结果是:Text1Tex2
public delegate String ProcessDelegate(String s1, String s2);
class Program
{
static void Main()
{
//使用委托ProcessDelegate来调用Process方法
ProcessDelegate pd = new ProcessDelegate(new Test().Process);
Console.WriteLine(pd("Text1", "Text2"));
}
}
public class Test
{
public String Process(String s1, String s2)
{
return s1 + s2;
}
}
2.回调函数
回调函数就是把一个方法传给另一个方法去执行。它与委托不同在于,它的方法参数,返回值都可以和调用者的参数,返回值可以不一样。
输出结果:
Text1Text2
Text1
Text2
Text2Text1
public delegate String ProcessDelegate(String s1, String s2);
class Program
{
static void Main()
{
Test t = new Test();
//Process方法(调用者)调用了一个回调函数Process1,当然这里只执行了回调函数。
//可以看出,可以把任意一个符合这个委托的方法传递进去,意思就是说这部分代码是可变的。
//将Process1 2 3方法传递给Process方法去执行
string r1 = t.Process("Text1", "Text2", new ProcessDelegate(t.Process1));
string r2 = t.Process("Text1", "Text2", new ProcessDelegate(t.Process2));
string r3 = t.Process("Text1", "Text2", new ProcessDelegate(t.Process3));
Console.WriteLine(r1);
Console.WriteLine(r2);
Console.WriteLine(r3);
}
}
public class Test
{
public String Process(String s1, String s2, ProcessDelegate process)
{
return process(s1, s2);
}
public String Process1(String s1, String s2)
{
return s1 + s2;
}
public String Process2(String s1, String s2)
{
return s1 + Environment.NewLine + s2;
}
public String Process3(String s1, String s2)
{
return s2 + s1;
}
}
17.1初识委托
以下代码演示了如何声明、创建和使用委托:
using System;
using System.Windows.Forms;
using System.IO;
namespace WindowsFormsApplication1
{
//声明一个委托类型,它的实例引用一个方法
//指定一个回调函数的签名,该方法获取一个Int32参数,返回void
internal delegate void Feedback(Int32 value);
public sealed class Program
{
public static void Main()
{
StaticDelegateDemo();
InstanceDelegateDemo();
ChainDelegateDemo1(new Program());
ChainDelegateDemo2(new Program());
}
public static void StaticDelegateDemo()
{
Console.WriteLine("----Static Delegate Demo----");
Counter(1, 3, null);
//前缀Program可选
Counter(1, 3, new Feedback(Program.FeedbackToConsole));
Counter(1, 3, new Feedback(FeedbackToMsgBox));
Console.WriteLine();
}
private static void InstanceDelegateDemo()
{
Console.WriteLine("----Instance Delegate Demo----");
Program p = new Program();
Counter(1, 3, new Feedback(p.FeedbackToFile));
Console.WriteLine();
}
private static void ChainDelegateDemo1(Program p)
{
Console.WriteLine("----Chain Delegate Demo 1----");
Feedback fb1 = new Feedback(FeedbackToConsole);
Feedback fb2 = new Feedback(FeedbackToMsgBox);
Feedback fb3 = new Feedback(p.FeedbackToFile);
Feedback fbChain = null;
fbChain = (Feedback)Delegate.Combine(fbChain, fb1);
fbChain = (Feedback)Delegate.Combine(fbChain, fb2);
fbChain = (Feedback)Delegate.Combine(fbChain, fb3);
Counter(1, 2, fbChain);
Console.WriteLine();
fbChain = (Feedback)Delegate.Remove(fbChain, new Feedback(FeedbackToMsgBox));
Counter(1, 2, fbChain);
}
private static void ChainDelegateDemo2(Program p)
{
Console.WriteLine("----Chain Delegate Demo 2----");
Feedback fb1 = new Feedback(FeedbackToConsole);
Feedback fb2 = new Feedback(FeedbackToMsgBox);
Feedback fb3 = new Feedback(p.FeedbackToFile);
Feedback fbChain = null;
fbChain += fb1;
fbChain += fb2;
fbChain += fb3;
Counter(1, 2, fbChain);
Console.WriteLine();
fbChain -= new Feedback(FeedbackToMsgBox);
Counter(1, 2, fbChain);
}
private static void Counter(Int32 from, Int32 to, Feedback fb)
{
for (Int32 val = from; val <= to; val++)
{
//如果指定了任何回调函数,就调用它们
if (fb != null)
fb(val);
}
}
private static void FeedbackToConsole(Int32 value)
{
Console.WriteLine("Item=" + value);
}
private static void FeedbackToMsgBox(Int32 value)
{
MessageBox.Show("Item=" + value);
}
private void FeedbackToFile(Int32 value)
{
StreamWriter sw = new StreamWriter("Status", true);
sw.WriteLine("Item=" + value);
sw.Close();
}
}
}
17.2用委托回调静态方法
在StaticDelegateDemo方法中,第一次调用Counter方法时,为第三个参数传递的是null。由于Counter的fb参数收到的是null,所以每个数据项在处理时,都不会调用回调函数。
接着StaticDelegateDemo方法再次调用Counter方法,为第三个参数传递一个新构造的Feedback委托对象。委托对象(new操作符新建的Feedback对象)是方法的一个包装器(wrapper),使方法能通过包装器来间接回调。
在本例中,静态方法的完整名称Program.FeedbackToConsole被传给Feedback委托类型的构造器。表明FeedbackToConsole就是要包装的方法。new操作符返回的引用作为Counter的第三个参数来传递。
在一个类型中,可以通过委托来调用另一个类型的私有成员时,只要委托对象是有具有足够安全性和可访问性的代码创建时,便不会有问题。
这个例子中的所有操作都是类型安全的。例如,在构造Feedback委托对象时,编译器确保Program的FeedbackToConsole方法的签名,兼容于Feedback委托定义的签名。具体的说,FeedbackToConsole必须获取一个参数,而且两者都必须有相同的返回类型(void)。
将一个方法绑定到委托时,C#和CLR都允许引用类型的协变形和逆变性。
17.3用委托回调实例方法
InstanceDelegateDemo中构造了一个名为p的Program对象。这个Program对象没有定义任何实例字段和属性。向Counter委托类型的构造函数传递的是p.FeedbackToFile,这导致委托包装对FeedbackToFile方法的一个引用,这个方法是实例方法,而不是静态方法。当Counter调用由其fb实参标识的回调函数时,会调用FeedbackToFile实例方法。
17.4委托揭秘
从表面看,委托似乎很容易使用:用C#的delegate关键字,用熟悉的new操作符构造委托实例。
CLR和编译器做了大量的工作来隐藏委托的复杂性。
首先让我们重新审视这一行代码:
第18章 定制attribute
第19章 可空值类型
为什么会有可空类型:
当我们设计一个数据库时,可将一个数据库字段的数据类型定义成一个32位整数,并映射到FCL的Int32数据类型对象。在数据库中的一个列可能允许值为空,但在C# 语言中是不能为null的。用.Net Framework处理数据库数据可能变得相当困难,因为在CLR中,没有办法将Int32值表示为null。为了解决这个问题,Microsoft在CLR中引入了可空值类型(nullable value type)的概念。
19.1 C#对可空值类型的支持
可空类型也是值类型,只是它是包含null的一个值类型。C#用问号表示法来声明并初始化变量。
这个”?”修饰符只是C#提供的一个语法糖 (所谓语法糖,就是C#提供的一种方便的形式,其实肯定没有Int32? 这个类型。这个Int32?编译器认为的就是Nullable< Int32>类型,即可空值类型)
public struct Nullable<T> where T : struct
C#允许在可空类型上执行转换和转型:
public static void ConversionsAndCasting()
{
Int32? a = 5; //从非可空的Int32转换为Nullable<Int32>, 等同与Nullable<Int32> a = 5;
Int32? b = null; //从null隐式转换为Nullable<Int32>
Int32 c = (Int32)a; //从Nullable<Int32>显式转换为非可空Int32
//在可空基元类型之间转换
Double? d = 5;//Int32转型为Double
Double? e = b;//Int32?转型为Double
}
C#允许向可空类型实例应用操作符:
public static void Operators()
{
Int32? a = 5;
Int32? b = null;
a++;
b = -b;
a = a + 3;
b = b * 3;
if (a == null) { } else { }
if (b == null) { } else { }
if (a != b) { } else { }
if (a < b) { } else { }
}
19.2 C#的空接合操作符
C#提供了空接合操作符,即??操作符,它要获取两个操作数。
假如左边的操作数不为null,就返回左边的这个操作数的值。如果左边的操作数为null,就返回右边的操作数的值。
利用空接合操作符,可以方便地设置变量的默认值,避免在代码中写if / else语句,简化代码数量,从而有利于阅读。
public static void NullCoalescingOperator()
{
Int32? b = null;
Int32 x = b ?? 123;//等价于x = (b.HasValue) ? b.Value : 123;
}
19.3 CLR对可空值类型的特殊支持
其实可空类型的装箱和拆箱操作大家可以就理解为非可空值类型的装箱和拆箱的过程,只是对于非可空类型因为包含null值,所以CLR会提前对它进行检查下它是否为空,为null就不不任何处理,如果不为null,就按照非可空值类型的装箱和拆箱的过程来装箱和拆箱。
第20章 异常和状态管理
错误处理要分几个部分。首先,我们要定义什么是错误。然后,我们要讨论如何判断代码正在经历一个错误,以及如何从这个错误中恢复。这个时候,状态就成为一个要考虑的问题,因为错误常常在不恰当的时机发生。代码可能在状态改变的中途发生错误。在这种情况下,就可能需要将一些状态还原成改变之前的状态。当然,我们还要讨论代码如何通知它的调用者检测到了一个错误。
本章要讨论针对未处理的异常、约束执行区域(constraind execution region, CER)、代码契约、运行时包装的异常以及未捕捉的异常。
20.1 定义”异常”
设计类型时,首先要想好类型的各种使用情况。类型名称通常是一个名词,例如FileStream或者StringBuilder。然后,要为类型定义属性、方法、事件等。这些成员(属性的数据类型、方法的参数、返回值等)的定义方式就是类型的编程接口。这些成员代表类本身或者类型实例可以执行的行动。行动成员通常用动词表示,例如Read,Write,Flush,Append,Insert和Remove等。当行动成员不能完成任务时,就应抛出异常。
面向对象的编程大大提高了开发人员的效率,因为我们可以这样写代码:
public bool TestFunc(string input)
{
return input.Substring(1, 1).ToUpper().EndsWith("E");
}
我们没有做任何的参数检查,而直接调用了一长串的方法。当input参数为null或空时,上面的代码就会抛出异常。即使方法为没有返回值的void型也应该报告错误,.Net Framework提供的这种机制就叫做异常处理(excepton handling)。
20.2异常处理机制
本节将介绍异常处理(exception handling)的机制,以及进行异常处理所需的C#构造(construct)。
下面的C#代码展示了异常处理机制的标准用法,通过它可以对异常处理及用途有一个初步认识,后续将对try,catch和finally块做进一步讲解。
private void SomeMethod()
{
try
{
//需要执行的代码放在这里
}
catch (InvalidOperationException) { }
catch (IOException)
{
//从IOException恢复的代码放在这里
}
catch
{
//从除上面的异常外的其他异常恢复的代码放在这里
throw; //重新抛出捕捉到的任何东西
}
finally
{
//这里的代码总是执行,对始于try块的任何操作进行清理
}
// 如果try块没有异常,或异常被捕获后没有抛出,就执行这里的代码
}
20.2.1 try块
try块包含的代码通常需要执行一些通用的资源清理操作,或者可能抛出异常需要从异常中恢复。清理代码应放在一个finally块中。try块还可包含也许会抛出异常的代码。异常恢复代码应该放在一个或多个catch块中。针对应用程序能从中安全恢复的每一种异常,都应该创建一个catch块。一个try块至少要有一个关联的catch块或finally块。
20.2.2 catch块
catch块包含的是响应一个异常需要执行的代码。如果try块中的代码没有造成异常的抛出,CLR永远不会执行它的任何catch块中的代码。线程将跳过所有catch块,直接执行finally中的代码。finally块中的代码执行完毕后,从finally块后面的代码继续执行。catch关键字后面的圆括号中的表达式称为捕捉类型(catch type)。在C#中必须将捕捉类型指定为System.Exception或者它的一个派生类型。
用VS调试catch块时,可通过在监视窗口中添加特殊的变量名称$exception来查看当前抛出的异常对象。
CLR自上而下搜索一个匹配的catch块,所以应该将较具体的异常放在顶部。也就是说,首先出现的是派生程度最大的异常类型,接着是它们的基类型,最后是System.Exception。
如果在try块中的代码抛出一个异常,CLR将搜索捕捉类型与抛出的异常相同的(或者是它的基类)catch块。没有捕捉类型的catch块将捕捉剩余的所有异常。
catch块中的代码通常执行一些对异常进行处理的操作。C#允许在捕捉异常后指定一个变量。捕捉到一个异常时,该变量将引用抛出的这个System.Exception派生对象。catch块中的代码,可以通过引用该变量来访问异常的具体信息。
20.2.3finally块
finally块包含的代码是保证会执行的代码。通常finally块中的代码执行的是try块中行动所要求的资源清理操作。
private void ReadData(String pathname)
{
FileStream fs = null;
try
{
fs = new FileStream(pathname, FileMode.Open);
//处理文件中的数据...
}
catch (IOException)
{
//在此添加从IOException恢复的代码
}
finally
{
//确保文件被关闭
if (fs != null)
fs.Close();
}
}
上述代码中,将关闭文件的语句放在finally块之后是不正确的,因为假若异物抛出但未被捕捉到,该语句就执行不到,造成文件打开状态,直到下一次垃圾回收。
try块并非一定要关联一个finally块。有时候try中的代码并不需要任何清理工作。但是,如果有finally块,它必须出现在所有catch块之后。记住,finally块中的代码是清理代码,这些代码只需负责对try块中发起的操作进行清理。
20.3 System.Exception类
微软定义了一个System.Exception类型,并规定所有公共语言规范(CLS)相容的编程语言都必须能抛出和捕捉派生自该类型的异常。C#只允许抛出CLS相容的异常。派生自System.Exception的异常类型被认为是CLS相容的。
最常用的Exception的属性是Message,StackTrace和InnerException。分别表示异常的文字消息,异常的方法堆栈信息,以及内部异常。
这里有必要讲一下System.Exception类型提供的只读属性StackTrace。catch块可读取该属性来获取一个堆栈跟踪(stack trace),它描述了异常发生之前调用的所有方法和签名,该属性对于调试非常有用。访问该属性时,实际要调用CLR中的代码,该属性并不是简单地返回一个字符串。构造Exception派生类型的一个新对象时,StackTrace属性被初始化为null。如果此时读取该属性,得到的不是堆栈追踪,而是一个null。
一个异常抛出时,CLR会在内部记录throw指令的位置。一个catch块捕捉到该异常时,CLR又会记录异常的捕捉位置。在catch块内访问被抛出的异常对象的StackTrace属性时,负责实现该属性的代码会调用CLR内部的代码,后者创建一个字符串来指出从异常抛出位置到异常捕捉位置的所有方法。
字符串--at ConsoleApplication2.Program.Main(String[] args) in d:\CLR练习\ConsoleApplication2\ConsoleApplication2\Program.cs:line 20
如果CLR能找到你的程序集的调试符号,那么在System.Exception属性返回的字符串中,将包含源代码文件路径和代码行号。
相反,如果仅仅使用throw关键字本身(删除后面的e)来重新抛出一个异常对象,CLR就不会重置堆栈的起点。
20.4FCL定义的异常类
20.5抛出异常
实现自己的方法时,如果方法无法完成方法名所指明的任务,就应抛出一个异常。
抛出异常时,需要注意两个问题:
- 抛出什么类型的Exception派生类型。应该选择一个有意义的类型,永远不要抛出一个System.Exception基类对象。
- 向异常类型的构造器传递什么字符串消息。抛出异常应包含一条字符串消息,详细说明方法为什么无法完成任务。
20.6定义自己的异常类
设计自己的异常不仅繁琐,还容易出错。主要原因是从Exception派生的所有类型都应该是可序列化的,使它们能穿越AppDomain边界边界或者写入日志/数据库。
下面是创建一个自定义异常类型的几个原则:
1,声明序列化,这样可以跨AppDomain访问。
2,添加默认构造函数。
3,添加只有一个message参数的构造函数。
4,添加包含message,内部异常参数的构造函数。
5,添加序列化信息的构造函数,访问级别设为private或protected。
定义自定义异常类型:
[Serializable]
public sealed class DiskFullException : Exception
{
public DiskFullException()
: base()
{ }
public DiskFullException(string message)
: base(message)
{ }
public DiskFullException(string message, Exception innerException)
: base(message, innerException)
{ }
public DiskFullException(SerializationInfo info, StreamingContext context)
: base(info, context)
{ }
}
使用例:
try
{
throw new DiskFullException("disk is full");
}
catch (DiskFullException ex)
{
Console.WriteLine(ex.Message);
}
20.7用可靠性换取开发效率
面向对象编程,编译器功能,CLR功能以及庞大的类库——使.Net Framework成为一个颇具吸引力的开发平台。但所有的这些东西,都会在你的代码中引入你没有什么控制权的“错误点”,如果 OutOfMemoryExcepton等。程序开发不可能对这些异常进行一一捕捉,让应用程序变得绝对健壮。意料意外的异常往往造成程序状态的破坏,为 了缓解对状态的破坏,可以做下面几件事:
●执行catch或finally块时,CLR不允许终止线程,所以可以向下面这样写是Transfer方法变得健壮:
private void Transfer(Account from, Account to, decimal amount)
{
try {/* 这里什么也没做*/ }
finally
{
from.Money -= amount;
//现在,这里不可能发生线程终止(由于Thread.Abort/AppDomain.Unload)
to.Money += amount;
}
}
但是,绝不建议将所有代码都放到finally块中!这个技术只适合于修改及其敏感的数据。
●可以用System.Diagnostics.Contracts.Constract类向方法应用代码契约。
●可以使用约束执行区域(Constrained Excecution Region,CER),它提供了消除CLR不确定性的一种方式。
●可利用事务(transaction)来确保状态要么修改,要么都不修改。如TransactionScope类。
●将自己的方法设计的更明确。如下面的Monitor类实现线程同步:
public static class SomeType
{
private static readonly object s_lockObject = new object();
public static void SomeMethod()
{
Monitor.Enter(s_lockObject);//如果抛出异常,是否获取了锁?
//如果已经获取了锁,它就得不到释放
try
{
//在这里执行线程安全的操作
}
finally
{
Monitor.Exit(s_lockObject);
}
}
}
由于存在上面展示的问题,这个重载的Monitor的Enter方法已经不再鼓励使用,建议像下面这样写:
public static class SomeType
{
private static readonly object s_lockObject = new object();
public static void SomeMethod()
{
bool lockTaken = false;//假定没有获取锁
try
{
Monitor.Enter(s_lockObject,ref lockTaken);//无论是否抛出异常,以下代码都能正常工作
//在这里执行线程安全的操作
}
finally
{
//如果以获取就释放它。
if(lockTaken == true) Monitor.Exit(s_lockObject);
}
}
}
虽然以上代码变得更明确,但在线程同步锁的情况下,现在的建议是根本不要随同异常处理使用它们。
●如果确定状态以损坏到无法修改的程度,就应销毁所有损坏的状态,防止它造成更多的伤害。然后重启应用程序,将应用程序恢复到一个良好的状态。由于托管代码不能泄露到一个AppDomain的外部,你可以调用AppDomain的Unload方法来卸载整个AppDomain。如果觉得状态过于糟糕,以至于需要终止这个进程,你可以调用Environment的FailFast方法。这个方法中可以指定异常消息,调用这个方法时,不会运行任何活动的try/finally块或者Finalize方法。然后它会将消息发送个Windows Application的日志。
20.8指导原则和最佳实践
我们认为finally块非常强悍!不管线程抛出什么样的异常,finally块中的代码都保证会执行。应该用finally块清理那些已成功启动的操 作,然后再返回调用者或执行finally块之后的代码。Finally块还经常用于显示释放对象以避免资源泄漏。如下例:
public static void SomeMethod()
{
FileStream fs = new FileStream(@"c:\test.txt", FileMode.Open);
try
{
//显示用100除以文件第一个字节的结果
Console.WriteLine(100 / fs.ReadByte());
}
finally
{
//清理资源,即使发生异常,文件都能关闭
fs.Close();
}
}
确保清理代码的执行时如此重要,以至于许多编程语言都提供了一些构造来简化清理代码的编写。例如:只要使用了lock,using和foreach语 句,C#编译器就会自动生成try/finally块。另外,重写类的析构器(Finalize)时,C#编译器也会自动生成try/catch块。使用 这些构造时,编译器将你写的代码放到try块内,并自动将清理代码放在finally块内,具体如下:
●使用lock语句,锁会在finally块中释放。
●使用using语句,会在finally块中调用对象的Dispose方法。
●使用foreach语句,会在finally块中调用IEnumerator对象的Dispose方法。
●定义析构方法时,会在finally块调用基类的Finalize方法。
例如,用using语句代替上面的代码,代码量更少,但编译后的结果是一样的。
public static void SomeMethod()
{
using (FileStream fs = new FileStream(@"c:\test.txt", FileMode.Open))
{
Console.WriteLine(100 / fs.ReadByte());
}
}
20.9未处理的异常
异常抛出时,CLR会在调用栈中向上查找与抛出的异常类型匹配的catch块。
异常抛出时,CLR会在调用栈中向上查找与抛出异常类型匹配的catch块。如果没有找到一个匹配的catch块,就发生一个未处理异常。CLR检测到进程中的任何线程有一个未处理的异常,就会终止进程。Microsoft的每种应用程序都有自己的与未处理异常打交道的方式。
●对于任何应用程序,查阅System.Domain的UnhandledException事件。
●对于WinForm应用程序,查阅System.Windows.Forms.NativeWindow的 OnThreadException虚方法,System.Windows.Forms.Application的OnThreadException虚 方法,System.Windows.Forms.Application的ThreadException事件。
●对于WPF应用程序,查阅System.Windows.Application的 DispatcherUnhandledException事件和System.Windows.Threading.Dispatcher的 UnhandledException和UnhandledExceptionFilter事件。
●对于Silverlight,查阅System.Windows.Forms.Application的ThreadException事件。
●对于ASP.NET应用程序,查阅System.Web.UI.TemplateControl的Error事件。 TemplateControl类是System.Web.UI.Page类和System.Web.UI.UserControl类的基类。另外还要查 询System.Web.HttpApplication的Error事件。
20.10对异常进行调试
20.11 异常处理的性能问题
20.12 约束执行区域 (CER)
约束执行区是必须对错误有适应能力的一个代码块,说白点,就是这个代码块要保证可靠性非常高,尽量不出异常。看看下面这段代码:
public static void Demo1()
{
try {
Console.WriteLine("In Try");
}
finally
{//Type1的静态构造器在这里隐式调用
Type1.M();
}
}
private sealed class Type1
{
static Type1()
{
//如果这里抛出异常,M就得不到调用
Console.WriteLine("Type1's static ctor called.");
}
public static void M() { }
}
运行上述代码,得到以下的结果:
In Try
Type1's static ctor called.
我们希望的目的是,除非保证finally块中的代码得到执行,否则try块中的代码根本就不要开始执行。为了达到这个目的,可以像下面这样修改代码:
public static void Demo1()
{
//强迫finally的代码块提前准备好
RuntimeHelpers.PrepareConstrainedRegions();
try {
Console.WriteLine("In Try");
}
finally
{//Type1的静态构造器在这里隐式调用
Type1.M();
}
}
private sealed class Type1
{
static Type1()
{
//如果这里抛出异常,M就得不到调用
Console.WriteLine("Type1's static ctor called.");
}
//应用p了1System.Runtime.ConstrainedExecution命?名?空o间的IReliabilityContract特A性á
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
public static void M() { }
}
得到的结果如下:
Type1's static ctor called.
In Try
PrepareConstrainedRegions是个非常特别的方法,JIT编译器遇到这个方法,就会提前编译与try关联的catch和 finally块中的代码。JIT编译器会加载任何程序集,创建任何类型,调用任何静态构造器,并对方法进行JIT编译,如果其中的任何操作发生异常,这 个异常会在try块钱抛出。
需要JIT提前准备的方法必须要应用ReliabilityContract特性,并且向这个特性传递的参数必须是 Consistency.WillNotCorruptState或Consistency.MayCorruptInstance。这是由于假如方法会 损坏AppDomain或进程的状态,CLR便无法对状态的一致性做出任何保证。请确保finally块中只有刚刚描述的应用了 ReliabilityContract特性的方法。向ReliabilityContract传递的另一个参数Cer.Success,表示保证该方法 不会失败,否则用Cer.MayFail。Cer.None这个值表明方法不进行CER保证。换言之,方法没有CER的概念。对于没有应用 ReliabilityContract特性的方法等价于下面这样
[ReliabilityContract(Consistency.MayCorruptProcess, Cer.None)]
迫使JIT编译器预先准备的还有几个静态方法,它们都定义在RuntimeHelper中:
public static void PrepareMethod(RuntimeMethodHandle method);
public static void PrepareMethod(RuntimeMethodHandle method, RuntimeTypeHandle[] instantiation);
public static void PrepareDelegate(Delegate d);
public static void PrepareContractedDelegate(Delegate d);
还应关注下RuntimeHelpers 的ExecuteCodeWithGuaranteedCleanup这个方法,它是在资源保证得到清理的前提下执行代码的另一种方式:
public static void ExecuteCodeWithGuaranteedCleanup(RuntimeHelpers.TryCode code, RuntimeHelpers.CleanupCode backoutCode, object userData);
调用这个方法要将try和finally块的主体作为回调方法传递,他们的原型要分别匹配以下的两个委托:
public delegate void TryCode(object userData);
public delegate void CleanupCode(object userData, bool exceptionThrown);
最后,另一种保证代码得以执行的方式是使用CriticalFinalizerObject类。
20.13 代码契约
代码契约(code contract)提供了直接在代码中申明代码设计决策的一种方式。
●前条件 一般用于参数的验证。
●后条件 方法因为一次普通的返回或者因为抛出一个异常而终止时,对状态进行验证。
●对象不变性(object Invariant) 用于对象的整个生命期内,保持对象字段的良好性状态。
代码契约有利于代码的使用、理解、进化、测试、文档和初期错误检查。可将前条件、后条件和对象不变性想象为方法签名的一部分。所以,代码新版本的契约可以变得更宽松,但是,除非破坏向后兼容性,否则代码新版本的契约不能变得更严格。
代码契约的核心是静态类System.Diagnostics.Contracts.Contract。由于该技术较新,实际中运用机会不多,故不再投入大量精力去研究。具体用时可以查阅MSDN相关文档。
第21章 自动内存管理(垃圾回收)
本章将讨论托管应用程序如何构造新对象,托管堆如何控制这些对象的生存期,以及如何回收这些对象的内存。简单的说,本章要解释CLR中的垃圾回收器是如何工作的,还要解释与它有关的性能问题。
21.1理解垃圾回收平台的基本工作原理
在.NET Framework中,内存中的资源(即所有二进制信息的集合)分为“托管资源”和“非托管资源”。托管资源必须接受.NET Framework的CLR的管理 (如内存类型安全性检查) 。而非托管资源则不必接受.NET Framework的CLR管理, 需要手动清理垃圾(显式释放)。注意,“垃圾回收”机制是.NET Framework的特性,而不是C#的。
每个程序都要使用这样或那样的资源,比如文件、内存缓冲区、屏幕空间、网络连接、数据库资源等。事实上,在面向对象的环境中,每个类型都代表可供程序使用的一种资源。要使用这些资源,必须为代表资源的类型分配内存。
以下是访问一个资源所需的具体步骤
- 在C#中使用new操作符创建一个新对象,编译器就会自动生成IL指令newobj,为代表此资源的类型分配内存。(如何分配见下面)
- 初始化内存,设置资源的初始状态,使资源可用。类型的实例构造器负责设置该初始化状态。
- 访问类型的成员来使用资源。
- 摧毁资源的状态以进行清理。
- 释放内存,垃圾回收器独自负责这一块。
垃圾回收(garbage collection)自动发现和回收不再使用的内存,不需要程序员的协助。使开发人员得到了解放,现在不必跟踪内存的使用,也不必知道在什么时候释放内存。但是,垃圾回收器不可以管理内存中的所有资源,对内存中的类型所代表的资源也是一无所知的。这意味着垃圾回收器不知道怎么执行“摧毁资源的状态以进行清理”。这部分资源就需要开发人员自己写代码实现回收。在.Net framework中,开发人员通常会把清理这类资源的代码写到Dispose,Finalize和Close方法中。
在.net中提供三种模式来回收内存资源:dispose模式,finalize方法,close方法:
- dispose提供了一种显示释放内存资源的方法。Dispose调用方法是: 要释放的资源对象.dispose()
- finalize方法是.net的内部的一个释放内存资源的方法。这个方法不对外公开,由垃圾回收器自动调用
- close和dispose其实一样,只不过有的对象没有提供dispose的方法,只提供了close方法,而close其实在那个对象的类中,依然是调用了一个私有的dispose方法,而finalize其实也是调用一个不对外公开的dispose方法
然而,值类型、集合类型、String、Attribute、Delegate和Exception所代表的资源无需执行特殊的清理操作。列如,只需销毁对象的内存中维护的字符数组,一个String资源就会被完全清理。
值类型(包括引用和对象实例)和引用类型的引用其实是不需要什么“垃圾回收器”来释放内存的,因为当它们出了作用域后会自动释放所占内存(因为它们都保存在“堆栈”中,学过数据结构可知这是一种先进后出的结构)。只有引用类型的引用所指向的对象实例才保存在“堆”中,而堆因为是一个自由存储空间,所以它并没有像“堆栈”那样有生存期 (“堆栈”的元素弹出后就代 表生存期结束,也就代表释放了内存)。并且非常要注意的是,“垃圾回收器”只对“堆”这块区域起作用。
从托管堆分配资源
.Net clr把所有的引用对象都分配到托管堆上,这一点很像c-runtime堆。初始化新进程时,运行时会为进程保留一个连续的地址空间区域。这个保留的地址空间被称为托管堆,并且这个地址空间最初并没有对应的物理存储空间。除值类型外,CLR要求所有资源都从托管堆分配。
托管堆还维护着一个指针,我把它称为NextObjPtr。它指向下一个对象在堆中的分配位置。
IL指令newobj用于创建一个对象。C#提供了new操作符,它导致编译器在方法IL代码中生成一个newobj指令。newobj指令将导致CLR执行以下步骤(如何为类型分配内存?):
- 计算类型的字段需要的字节数。
- 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。
- CLR检查保留区域是否能够提供分配对象所需的字节数,如有必要就提交存储。如果托管堆有足够的可用空间,对象会被放入。该对象是在NextObjPtr指针指向的地址放入的,并且为它分配的字节会被清零。接着,调用类型的实例构造器(为this参数传递NextObjPtr),IL指令newobj将返回对象的地址。就在地址返回之前,NextObjPtr指针的值会加上对象占据的字节数,这样会得到一个新值,它指向下一个对象放入托管堆时的地址。
下图展示了3个对象(A,B和C)的一个托管堆。如果要分配新对象,它将放在NextObjPtr指针指向的位置(紧接着对象C后)。
应用程序调用new操作符创建对象时,可能没有足够的地址空间来分配该对象。托管堆将对象需要的字节数加到NextObjPtr指针中的地址上来检测这个情况。如果结果值超过了地址空间的末尾,表明托管堆已满,必须执行一次垃圾回收。
21.2 垃圾回收算法
垃圾回收器检查托管堆中是否有应用程序不再使用的对象。如果有,它们使用的内存就可以被回收。那么,垃圾回收器是怎么知道一个对象不再被使用呢?
CPU寄存器(CPU Register)是CPU自己的“临时存储器”,比内存的存取还快。按与CPU远近来分,离得最近的是寄存器,然后缓存 (计算机一、二、三级缓存),最后内存。
每个应用程序都包含一组根(Roots)。每个根都是一个存储位置,他们可能指向托管堆上的某个地址,也可能是null。
类中定义的任何静态字段,方法的参数,局部变量(仅限引用类型变量)等都是根,另外cpu寄存器中的对象指针也是根。
例如,所有的全局和静态对象指针是应用程序的根,另外在线程栈上的局部变量/参数也是应用程序的根。只有引用类型的变量才被认为是根,值类型的变量永远不被认为是跟。
如果一个根引用了堆中的一个对象,则该对象为“可达”,否则即是“不可达”。被根引用的堆中的对象不被视为垃圾。
当垃圾回收器开始运行,它会假设托管堆上的所有对象都是垃圾。换句话说,它假设线程栈中没有引用堆中对象的变量,没有CPU寄存器引用堆中的对象,也没有静态字段引用堆中的对象。
垃圾回收分为2个阶段:
垃圾回收器的第一阶段是所谓的标记(marking)阶段。
垃圾回收器沿着线程栈上行以检查所有的根。如果发现一个根引用了一个对象,就在对象的“同步块索引字段”上开启一位(将这个bit设为1)---对象就是这样被标记的。当所有的根都检查完毕后,堆中将包含可达(已标记)与不可达(未标记)对象。
如下图,展示了一个堆,其中包含几个已分配的对象。应用程序的根直接引用对象ACDF,所有这些对象都被标记。标记好根和它的字段引用对象之后,垃圾回收器检查下一个根,并继续标记对象。如果垃圾回收器试图标记之前已经被标记过的对象,就会换一个路径继续遍历。这样做有两个目的:首先,垃圾回收器不会多次遍历一组对象,提高性能。其次,如果存在对象的循环链表,可以避免无限循环。
垃圾回收器的第二个阶段是压缩(compact)阶段。
在这个阶段中,垃圾回收器线性地遍历堆,以寻找未标记的连续内存块。如果发现大的可用的连续内存块,垃圾回收器会把非垃圾(标记/可达)的对象移动到这里来进行压缩堆。堆内存压缩后,托管堆的NextObjPtr指针将指向紧接在最后一个非垃圾对象之后的位置。这时候new操作符就可以继续成功的创建对象了。这个过程有点类似于磁盘空间的碎片整理。以此,对堆进行压缩,不会造成进程虚拟地址空间的碎片化。
如上图所示,绿色框表示可达对象,黄色框为不可达对象。不可达对象清除后,移动可达对象实现内存压缩(变得更紧凑)。
压缩之后,“指向这些对象的指针”的变量和CPU寄存器现在都会失效,垃圾回收器必须重新访问所有根,并修改它们来指向对象的新内存位置。这会造成显著的性能损失。这个损失也是托管堆的主要缺点。
基于以上特点,垃圾回收引发的回收算法也是一项研究课题。因为如果真等到托管堆满才开始执行垃圾回收,那就真的太“慢”了。
垃圾回收器的好处:
- 不必自己写代码来管理应用程序所用的对象的生存期。
- 不会发生对象泄漏的情况,因为任何对象只要没有应用程序的根引用它,就会在某个时刻被垃圾回收器回收。
垃圾回收算法 --- 分代(Generation)算法
代是CLR垃圾回收器采用的一种机制,它唯一的目的就是提升应用程序的性能。分代回收,速度显然快于回收整个堆。
CLR托管堆支持3代:第0代,第1代,第2代。第0代的空间约为256KB,第1代约为2M,第2代约为10M。新构造的对象会被分配到第0代。
如上图所示,当第0代的空间满时,垃圾回收器启动回收,不可达对象(上图C、E)会被回收,存活的对象被归为第1代。
当第0代空间已满,第1代也开始有很多不可达对象以至空间将满时,这时两代垃圾都将被回收。存活下来的对象(可达对象),第0代升为第1代,第1代升为第2代。
实际CLR的代回收机制更加“智能”,如果新创建的对象生存周期很短,第0代垃圾也会立刻被垃圾回收器回收(不用等空间分配满)。另外,如果回收了第0代,发现还有很多对象“可达”,
并没有释放多少内存,就会增大第0代的预算至512KB,回收效果就会转变为:垃圾回收的次数将减少,但每次都会回收大量的内存。如果还没有释放多少内存,垃圾回收器将执行完全回收(3代),如果还是不够,则会抛出“内存溢出”异常。
也就是说,垃圾回收器会根据回收内存的大小,动态的调整每一代的分配空间预算!达到自动优化!
.NET垃圾回收器的基本工作原理是:通过最基本的标记清除原理,清除不可达对象;再像磁盘碎片整理一样压缩、整理可用内存;最后通过分代算法实现性能最优化。
21.4使用终结操作来释放本地资源
终结(Finalization)是CLR提供的一种机制,允许对象在垃圾回收器回收其内存之前执行一些得体的清理工作。任何包装了本地资源(例如文件)的类型都必须支持终结操作。简单的说,类型实现了一个命名为Finalize的方法。当垃圾回收器判断一个对象是垃圾时,会调用对象的Finalize方法。
C#团队认为,Finalize方法是编程语言中需要特殊语法的一种方法。在C#中,必须在类名前加一个~符号来定义Finalize方法。
internal sealed class SomeType
{
~SomeType()
{
//这里的代码会进入Finalize方法
}
}
编译上述代码,会发现C#编译器实际是在模块的元数据中生成一个名为Finalize的protected override方法。方法主体被放到try块中,finally块放入了一个对base.Finalize的调用。
实现Finalize方法时,一般都会调用Win32 CloseHandle函数,并向该函数传递本地资源的句柄。
如果包装了本地资源的类型没有定义Finalize方法,本地资源就得不到关闭,导致资源泄露,直至进程终止。进程终止时,这些本地资源才会被操作系统回收。
21.5对托管资源使用终结操作
不要对托管资源进行终结操作,终结操作几乎专供释放本地资源。
21.6 什么会导致Finalize方法被调用
Finalize方法在垃圾回收结束时调用,有以下5种事件会导致开始垃圾回收:
- 第0代满 只有第0代满时,垃圾回收器会自动开始。该事件是目前导致调用Finalize方法最常见的一种方式。
- 代码显式调用System.GC的静态方法Collect 代码可以显式请求CLR执行即时垃圾回收操作。
- Windows内存不足 当Windows报告内存不足时,CLR会强制执行垃圾回收。
- CLR卸载AppDomain 一个ApppDomain被卸载时,CLR认为该AppDomain中不存在任何根,因此会对所有代的对象执行垃圾回收。
- CLR关闭 一个进程正常终止时(比如通过任务管理器关闭),CLR就会关闭。CLR关闭会认为进程中不存在 任何根,因此会调用托管堆中所有对象的Finalize方法。此时,CLR不会尝试压缩或释放内存,因为整个进程都要终止,由Windows回收进程中所有内存。
21.7终结操作揭秘
终结操作表面看起来简单:创建一个对象,当它被回收时,它的Finalize方法会得到调用。但深究下去,远没有这么简单。
应用程序创建一个新对象时,new操作符会从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器调用之前,会将一个指向该对象的指针放到一个终结列表 (finalization list) 中。终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象,在回收该对象之前,会先调用对象的Finalize方法。
下图展示了包含几个对象的一个托管堆。有的对象从应用程序的根可达,有的不可达(垃圾)。对象C,E,F,I,J被创建时,系统检测到这些对象的类型定义来了Finalize方法,所有指向这些对象的指针要添加到终结列表中。
垃圾回收开始时,对象B,E,G,H,I和J被判定为垃圾。垃圾回收器扫描终结列表以查找指向这些对象的指针。找到一个指针后,该指针会从终结列 表中移除,并追加到freachable队列中。freachable队列(发音是“F-reachable”)是垃圾回收器的内部数据结构。 Freachable队列中的每个指针都代表其Finalize方法已准备好调用的一个对象。
下图展示了回收完毕后托管堆的情况。从图中我们可以看出B,E和H已经从托管堆中回收了,因为它们没有Finalize方法,而E,I,J则暂时没有被回收,因为它们的Finalize方法还未调用。
一个特殊的高优先级的CLR线程负责调用Finalize方法。使用专用的线程可避免潜在的线程同步问题。freachable队列为空时,该线程将睡眠。当队列中有记录项时,该线程就会被唤醒,将每一项从freachable队列中移除,并调用每一项的 Finalize方法。
如果一个对象在freachable队列中,那么意味这该对象是可达的,不是垃圾。
原本,当对象不可达时,垃圾回收器将把该对象当成垃圾回收了,可是当对象进入freachable队列时,有奇迹般的”复活”了。然后,垃圾回收 器压缩(内存脆片整理)可回收的内存,特殊的CLR线程将清空freachable队列,并调用其中每个对象的Finalize方法。
垃圾回收器下一次回收时,发现已终结的对象成为真正的垃圾,因为应用程序的根不再指向它,freachhable队列也不再指向它。所以,这些对象的内存会直接回收。
整个过程中,可终结对象需要执行两次垃圾回收器才能释放它们占用的内存。可在实际开发中,由于对象可能被提升到较老的一代,所以可能要求不止两次进行垃圾回收。下图展示了第二次垃圾回收后托管堆中的情况。
21.8 Dispose模式:强制对象清理资源
Finalize方法非常有用,因为它确保了当托管对象的内存被释放时,本地资源不会泄漏。但是,Finalize方法的问题在于,他的调用时间不能保证。另外,由于他不是公共方法,所以类的用户不能显式调用它。
类型为了提供显式进行资源清理的能力,提供了Dispose模式。所有定义了Finalize方法的类型都应该同时实现Dispose模式,使类型的用户对资源的生存期有更多的控制。
类型通过实现System.IDisposable接口的方式来实现Dispose模式:
public interface IDisposable
{
void Dispose();
}
任何类型只有实现了该接口,将相当于声称自己遵循Dispose模式。无参Dispose和Close方法都应该是公共和非虚的。
21.9使用实现了Dispose模式的类型
FileStream类实现了System.IDisposable接口。
FileStream fs = new FileStream();
//显示关闭文件 Dispose/Close
fs.Dispose();
fs.Close();
fs.Write();
显示调用一个类型的Dispose或Close方法只是为了能在一个确定的时间强迫对象执行清理。这两个方法并不能控制托管堆中的对象所占用的内存的生存期。这意味着即使一个对象已完成了清理,仍然可在它上面调用方法,但会抛出ObjectDisposedException异常。
21.10 C#的using语句
如果决定显式地调用Dispose和Close这两个方法之一,强烈建议把它们放到一个异常处理finally块中。这样可以保证清理代码得到执行。
C#提供了一个using语句,这是一种简化的语法来获得上述效果。
using(FileStream fs = new FileStream()){
fs.Write();
}
在using语句中,我们初始化一个对象,并将它的引用保存到一个变量中。然后在using语句的大括号内访问该变量。编译这段代码时,编译器自动生成一个try块和一个finally块。在finally块中,编译器会生成代码将变量转型成一个IDispisable并调用Dispose方法。显然,using语句只能用于哪些实现了IDisposable接口的类型。
21.12手动监视和控制对象的生存期
CLR为每一个AppDomain都提供了一个GC句柄表 (GC Handle table) 。该表允许应用程序监视对象的生存期,或手动控制对象的生存期。
在一个AppDomain创建之初,该句柄表是空的。句柄表中的每个记录项都包含以下两种信息:一个指针,它指向托管堆上的一个对象;一个标志(flag),它指出你想如何监视或控制对象。
为了在这个表中添加或删除记录项,应用程序要使用System.Runtime.InteropServices.GCHandle类型。
21.13对象复活
前面说过,需要终结的一个对象被认为死亡时,垃圾回收器会强制该对象重生,使它的Finalize方法得以调用。Finalize方法调用之后,对象才真正的死亡。
需要终结的一个对象会经历死亡、重生、再死亡的“三部曲”。一个死亡的对象重生的过程称为复活(resurrection) 。复活一般不是一件好事,应避免写代码来利用CLR这个“功能”。
21.18 线程劫持
前面讨论的垃圾回收算法有一个很大的前提就是:只在一个线程运行。
在现实开发中,经常会出现多个线程同时访问托管堆的情况,或至少会有多个线程同时操作堆中的对象。一个线程引发垃圾回收时,其它线程绝对不能访问任何线程,因为垃圾回收器可能移动这些对象,更改它们的内存位置。
CLR想要进行垃圾回收时,会立即挂起执行托管代码中的所有线程,正在执行非托管代码的线程不会挂起。然后,CLR检查每个线程的指令指针,判断线程指向到哪里。接着,指令指针与JIT生成的表进行比较,判断线程正在执行什么代码。
如果线程的指令指针恰好在一个表中标记好的偏移位置,就说明该线程抵达了一个安全点。线程可在安全点安全地挂起,直至垃圾回收结束。如果线程指令指针不在表中标记的偏移位置,则表明该线程不在安全点,CLR也就不会开始垃圾回收。在这种情况下,CLR就会劫持该线程。也就是说,CLR会修改该线程栈,使该线程指向一个CLR内部的一个特殊函数。然后,线程恢复执行。当前的方法执行完后,他就会执行这个特殊函数,这个特殊函数会将该线程安全地挂起。
然而,线程有时长时间执行当前所在方法。所以,当线程恢复执行后,大约有250毫秒的时间尝试劫持线程。过了这个时间,CLR会再次挂起线程,并检查该线程的指令指针。如果线程已抵达一个安全点,垃圾回收就可以开始了。但是,如果线程还没有抵达一个安全点,CLR就检查是否调用了另一个方法。如果 是,CLR再一次修改线程栈,以便从最近执行的一个方法返回之后劫持线程。然后,CLR恢复线程,进行下一次劫持尝试。
所有线程都抵达安全点或被劫持之后,垃圾回收才能使用。垃圾回收完之后,所有线程都会恢复,应用程序继续运行,被劫持的线程返回最初调用它们的方法。
实际应用中,CLR大多数时候都是通过劫持线程来挂起线程,而不是根据JIT生成的表来判断线程是否到达了一个安全点。之所以如此,原因是JIT生成表需要大量内存,会增大工作集,进而严重影响性能。
21.20大对象
任何85000字节或更大的对象都被自动视为大对象(large object)。
大对象从一个特殊的大对象堆中分配。这个堆中采取和前面小对象一样的方式终结和释放。但是,大对象永远不压缩(内存碎片整理),因为在堆中下移850000字节的内存块会浪费太多CPU时间。
大对象总是被认为是第2代的一部分,所以只能为需要长时间存活的资源创建大对象。如果分配短时间存活的大对象,将导致第2代被更频繁地回收,进而会损害性能。
第22章 CLR寄宿和AppDomain
寄宿允许使任务应用程序都能利用CLR的功能。寄宿(hosting)允许使任务应用程序都能利用CLR的功能。另外,寄宿还为应用程序提供了通过编程来进行自定义和扩展能力。AppDomain允许第三方的,不受信任的代码在一个现有的进程中运行,而CLR保证数据结构、代码和安全上下文不会被滥用或破坏。
22.1 CLR寄宿
.Net Framework必须用Windows可以理解的技术来构建。首先,所有托管模块和程序集都必须使用Windows PE文件格式。
开发CLR时,Microsoft实际是将它实现成包含在一个DLL中的COM服务器。也就是说,Microsoft为CLR定义了一个标准的COM接口,并未该接口和COM服务器分配了GUID(全局通用标识符)。安装.Net Framework时,代表CLR的COM服务器和其他COM服务器一样在Windows注册表中注册。
任何Windows应用程序都可以寄宿CLR。非托管宿主应该调用MetaHost.h文件中声明的CLRCreateInstance函数。CLRCreateInstance函数是在MSCorEE.dll文件中实现的,该文件一般是在C:\Windows\System32目录中。这个DLL被称为“垫片”(shim),它的工作是决定创建哪个版本的CLR,注意垫片DLL本身并不包含CLR COM服务器。
一台机器可安装多个版本的CLR,但只有一个版本的MSCorEE.dll文件(垫片)。
CLRCreateInstance函数可以返回一个ICLRMetaHost接口。宿主应用程序可调用这个接口的GetRuntime函数,指定宿主要创建的CLR的版本。然后,垫片将所需版本的CLR加载到宿主的进程中。
宿主应用程序可调用ICLRRuntimeHost接口定义的方法做下面的事情:
- 设置宿主管理器。告诉CLR,宿主想参与涉及以下操作的决策:内存分配、线程调度/同步以及程序集加载等。宿主还可声明它想获得有关垃圾回收启动和停止以及特定操作超时的通知。
- 获取CLR管理器。告诉CLR阻止使用某些类/成员。另外,宿主能分辨哪些代码可以调试,哪些代码不能,以及当一个特定事件(例如AppDomain卸载、CLR停止或者堆栈溢出异常)发生时宿主应该调用哪个方法。
- 初始化并启动CLR。
- 加载一个程序集并执行其中的代码。
- 停止CLR,阻止任何更改的托管代码在Windows进程中运行。
寄宿使任何应用程序都能提供CLR功能和可编程性,以下是寄宿CLR的部分好处:
- 可以用任何编程语言来编写。
- 代码在JIT编译后执行,所有速度很快(而不是一边解释一边执行)
- 代码使用垃圾回收避免内存泄露和损坏
- 宿主不必操心提供一个丰富的开发环境
22.2 AppDomain
CLR COM服务器初始化时,会创建一个AppDomain。AppDomain是一组程序集的逻辑容器。CLR初始化时创建的第一个AppDomain称为默认AppDomain,这个默认的AppDomain只有在Windows进程终止时才会被销毁。
除了默认AppDomain,托管类型方法的一个宿主还可指示CLR创建额外的AppDomain。AppDomain唯一的作用是进行隔离。
下面总结了AppDomain的具体功能:
- 一个AppDomain中的代码创建的对象不能由另一个AppDomain中的代码直接访问
一个AppDomain中的代码创建了一个对象后,该对象被该AppDomain“拥有”。换言之,它的生存期不能比创建它的代码所在的AppDomain还要长。一个AppDomain中的代码为了访问另一个AppDomain中的对象,只能使用“按引用封送”或者“按值封送”的语义。这就加强了一个清晰的分隔和边界,因为一个AppDomian中的代码没有对另一个AppDomain中的代码所创建的对象的直接引用。
- AppDomain可以卸载
CLR不支持从AppDomain中卸载一个程序集的能力。但是,可以告诉CLR卸载一个AppDomain,从而卸载当前包含在该AppDomain内的所有程序集。
- AppDomain可以单独保护
AppDomain在创建之后,会应用一个权限集,它决定了向这个AppDomain中运行的程序集授予的最大权限。
- AppDomain可以单独实施配置
AppDomain在创建之后,会关联一组配置设置。
下图演示了一个Windows进程,其中运行着一个CLR COM服务器。该CLR当前管理着两个AppDomain。每个AppDomain都有自己的Loader堆,每个Loader堆都记录了自AppDomain创建以来已访问过哪些类型。Loader堆中的每个类型对象都有一个方法表,方法表中的每个记录项都指向JIT编译的本地代码(前提是方法至少执行过一次)。
除此之外,每个AppDomain都加载了一些程序集。AppDomain有三个程序集:MyApp.exe,TypeLib.dll和System.dll。AppDomain#2有两个程序集:Wintellect.dll和System.dll。
如图所示,System.dll程序集被加载到两个AppDomain中。如果这两个AppDomain都使用了来自System.dll的一个类型,那么在两个AppDomain的Loader堆中,都会为同一个类型分配一个类型对象;类型对象的内存不会由两个AppDomain共享。
AppDomain的全部目的就是提供隔离性;CLR要求在卸载某个AppDomain并释放它的所有资源的同时,不会对其他AppDomain产生负面影响。通过复制CLR的数据结构,就可以保证这一点。除此之外,还能保证由多个AppDomain使用的一个类型在每个AppDomain中都有一个静态字段。
AppDomain是CLR的功能,Windows对此一无所知。
CreateInstanceAndUnwrap方法导致调用线程从当前AppDomain到新的AppDomain。线程将制定程序集加载到新 AppDomain中,并扫描程序集类型定义元数据表,查找指定类型“MarshalByRefType”)。找到类型后,调用它的无参构造函数。然后, 线程又范围默认AppDomain,对CreateInstanceAndUnwrap返回的MarshalByRefType对象进行操作。
如何将一个对象从一个AppDomain(源AppDomain,这里指真正创建对象的地方)封送到另一个AppDomain(目标AppDomain,这里指调用CreateInstanceAndUnwrap的地方)?
1. Marshal-by-Reference
CLR会在目标AppDomain的Loader堆中定义一个代理类型。这个代理类型是用原始类型的数据定义的。因此,它看起来和原始类型完全一样;有完全一样的实例成员(属性、事件和方法)。但是,实例字段不会成为(代理)类型的一部分。
2. Marshal-by-Value
CLR将对象字段序列化一个字节数组。这个字节数组从源AppDomain复制到目标AppDomain。然后,CLR在目标AppDomain中 反序列化字节数组,这会强制CLR将定义了的“被反序列化的类型”的程序集加载到目标AppDomain中。接着,CLR创建类型的一个实例,并利用字节 数组初始化对象的字段,使之与源对象中的值相同。换言之,CLR在目标AppDomain中准确的复制了源对象。
22.3卸载AppDomain
AppDomain.Unload()中执行操作:
(1)CLR挂起进程中执行中执行的托管代码的所有线程;
(2)CLR检查所有线程栈,查看哪些线程正在执行要卸载的那个AppDomain中的代码,或者哪些线程会在某个时刻返回至要卸载的那个 AppDomain。在任何一个栈上,如果准备卸载的AppDomain,CLR都会强迫对应的线程抛出一个ThreadAbortException异 常(同时恢复线程的执行)。这将导致线程展开(unwind),在展开的过程中执行遇到的所有finally块中的内容,以执行资源清理代码。如果没有代 码捕捉ThreadAbortException,它最终会成为一个未处理的异常,CLR会“吞噬”这个异常,线程会终止,但进程可以继续运行。这一点是 非常特别的,因为对于其他所有未处理的异常,CLR都会终止进程。
重要提示:如果一个线程当前正在finally块、catch块、类构造器、临界执行区(critical execution region)域或非托管代码中执行,那么CLR不会立即终止该线程。否则,资源清理代码、错误恢复代码、类型初始化代码、关键代码或者其他任何CLR不 了解的代码都无法完成,导致应用程序的行为变得无法预测,甚至可能造成安全漏洞。线程在终止时,会等待这些代码块执行完毕。然后当代码块结束时,CLR再 强制线程抛出一个ThreadAbortException。
临界区是指线程终止或未处理异常的影响可能不限于当前任务的区域。相反,非临界区中的终止或失败只对出现错误的任务有影响。
(3)当上一步发现的所有线程都离开AppDomain后,CLR遍历堆,为引用了“已卸载的AppDomain创建的对象”的每一个代理都设置一 个标志(flag)。这些代理对象现在知道它们引用的真实对象已经不在了。如果任何代码在一个无效的代理对象上调用一个方法,该方法会抛出一个 AppDomainUnloadedException
(4)CLR强制垃圾回收,对现已卸载AppDomain创建的任何对象占用的内存进行回收。这些对象的Finalize方法被调用(如果存在Finalize方法),使对象有机会彻底清理它们占用的资源
(5)CLR恢复剩余所有线程的执行。调用AppDomain.Unload方法的线程将继续执行,对AppDomain.Unload的调用是同 步进行的在前面的例子中,所有工作都用一个线程来做。因此,任何时候只要调用AppDomain.Unload都不可能有另一个线程在要卸载的 AppDomain中。因此,CLR不必抛出任何ThreadAbortException异常。
第23章 程序集加载和反射
程序集加载和反射,实现了在编译时对一个类型一无所知的情况下,如何在运行时发现类型的信息,创建类型的实例以及访问类型的成员。显现的功能以及效果是十分强大的,比如使用第三方提供的程序集,以及创建动态可扩展应用程序。
23.1 程序集加载
JIT编译器在将方法的IL代码编译成本地代码时,会查看IL代码中引用了哪些类型。在运行时,JIT编译器查看元数据表TypeRef和AssemblyRef来确定哪一个程序集定义了所引用的类型。在AssemblyRef元数据表的记录项中,包含了构成程序集强名称的各个部分 :名称(无扩展名和路径),版本,语言文化和公钥(PublicKeyToken)。
JIT编译器利用以下这些信息,连接成字符串。例如:( StrongNameDLL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=07f452de4cf765d5 )。然后尝试将一个匹配的程序集加载到AppDomain中。如果是弱命名程序集,则只包含程序集的名称。
常见的程序集加载方式有三种:
(1)Assembly.Load
在内部,CLR使用System.Reflection.Assembly类的静态方法Load来尝试加载程序集,常用的版本原型:
public class Assembly
{
public static Assembly Load(AssemblyName assemblyRef);
public static Assembly Load(String assemblyString);
}
首先熟悉下这两个方法的使用,创建个强命名程序集,然后查看强命名信息(SN.exe, Reflector工具, Assembly.GetAssemblyName)
Assembly assemblyLoadString = Assembly.Load("StrongNameDLL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=07f452de4cf765d5");
Assembly assemblyLoadRef = Assembly.Load(new AssemblyName("StrongNameDLL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=07f452de4cf765d5"));
注意:
a.对于强命名程序集,在内部,Load导致CLR向程序集应用一个版本绑定重定向策略,并在GAC中查找程序集。如果没有找到,就接着去应用程序的基目录,私有路径子目录和codebase位置中查找
b.如果Load时传递的是一个弱命名程序集,Load就不会向程序集应用一个版本绑定重定向策略,CLR也不会去GAC中查找程序集
c.如果找到指定的程序集,返回的是那个程序集的一个Assembly对象的引用;如果没有找到指定的程序集,抛出System.IO.FileNotFoundException
d.对于需要加载为特定CPU架构生成的程序集,在指定程序集标识时,还可包括一个进程架构部分。
StrongNameDLL, Version=1.0.0.0, Culture=neutral, PublicKeyToken=07f452de4cf765d5 , ProcessorArchitecture=MSIL
CLR允许ProcessorArchitecture取4个值之一:MSIL, x86, IA64, AMD64
(2)Assembly.LoadFrom
使用Load时,它要求你事先掌握构成程序集标识的各个部分。在某些情况下,我们也可以指定一个程序集文件的路径名(包括文件扩展名),获取Assembly对象。
Assembly assemblyFromPath = Assembly.LoadFrom(@"E:\StrongNameDLL.dll");
对于使用LoadFrom,传入路径名的使用方式,需要了解内部的实现机制,避免误用的情况:
a.在内部,LoadFrom首先会调用System.Reflection.AssemblyName类的静态方法GetAssemblyName。
该方法打开指定的文件,提取AssemblyRef记录项中的程序集标识信息,然后以一个System.Reflection.AssemblyName对象的形式返回这些信息(文件同时关闭)。
b.LoadFrom方法在内部调用Assembly的Load方法,将AssemblyName对象传给它。
c.CLR会为应用版本绑定重定向策略,并在各个位置查找匹配的程序集。如果Load找到了匹配的程序集,就会加载它,并返回一个Assembly对象;LoadFrom返回这个值。如果没有找到匹配项,LoadFrom就会加载路径名中的程序集。
(3)Assembly.LoadFile
加载指定路径上的程序集文件。
Assembly assemblyFromPath = Assembly.LoadFile(@"E:\StrongNameDLL.dll");
注意:
a.可将具有相同标识的一个程序集多次加载到一个AppDomain中
b.CLR不会解析任何依赖性问题
c.必须向AppDomain的AssemblyResolve事件登记,并让事件回调方法显式地加载任何依赖的程序集
三者对比
既然已经对Load,LoadFrom,LoadFile有所了解,那么接着来看看这三者之间的区别与对比。
1.Load和LoadFrom
a.根据LoadFrom的内部实现机制,LoadFrom返回的实际是Load找到的匹配程序集的一个Assembly对象(在找到匹配的程序集的情况下)
b.LoadFrom存在多次打开文件的现象,并且内部还是要走一套Load的逻辑操作,还存在对比情况,所以LoadFrom比Load效率低
c.LoadFrom 要求指定路径中包含 FileIOPermissionAccess.Read 和 FileIOPermissionAccess.PathDiscovery 或 WebPermission
d. LoadFrom, and the probing path includes an assembly with the same identity but a different location, an InvalidCastException, MissingMethodException, or other unexpected behavior can occur. "> 如果用 LoadFrom 加载一个程序集,并且probing路径包括具有相同标识但位置不同的程序集,则发生 InvalidCastException 、 MissingMethodException 或其他意外行为。
2.LoadFrom和LoadFile
a.根据MSDN的解释,可以看出功能实现机制存在区别。
LoadFrom:已知程序集的文件名或路径,加载程序集。
LoadFile:加载指定路径上的程序集文件的内容。
b.LoadFrom会加载程序集的依赖项,但是LoadFile不会。
例如,上面的StrongName.dll存在引用程序集ReferenceDemo.dll。使用LoadFrom,StrongName.dll和 ReferenceDemo.dll都会被载入,但是使用LoadFile,ReferenceDemo.dll不会被载入。
c.可以使用LoadFile加载并检查具有相同标识但位于不同路径的程序集,但是LoadFrom不行, LoadFrom returns the loaded assembly even if a different path was specified. ">如果已加载一个具有相同标识的程序集,则即使指定了不同的路径,仍返回已加载的程序集。 。
例如,StrongName.dll存在于两个不同路径下,LoadFile可以正确载入两个程序集;但是LoadFrom如果已经加载了一次 StrongName.dll,再一次加载不同路径下的Assembly时,会先检查前面是否已经载入过相同名字的 Assembly,最终返回的还是第一次加载的Assembly对象的引用。
总结:
由于LoadFrom具有不少缺点,一般优先考虑使用Load方法加载程序集。而LoadFile则只在有限的情况下使用。
23.2 使用反射构建动态可扩展应用程序
第25章 线程基础
25.1 Windows为什么要支持线程
微软始终OS内核时,决定在一个进程(progress)中运行应用程序的每个实例。进程是应用程序的一个实例要使用的资源的一个集合。每个进程都被赋予了一个虚拟地址空间,确保一个进程使用的代码和数据无法由另一个进程访问。除此之外,OS的内核代码和数据是进程访问不到的。所以,应用程序代码破坏不了其他应用程序和OS自身。
上面听起来不错,但CPU本身呢?如果一个应用程序进入无限循环,如果机器只有一个CPU,它会执行无限循环,不会执行其他东西。微软要修复这个问题,提出了线程。作为一个Windows概率,线程(thread)的职责是对CPU进行虚拟化,可将线程理解为一个逻辑CPU。Windows为每个进程都提供了该进程专用的线程。如果引用程序的代码进入无限循环,与那个代码关联的进程会冻结,但其他进程不会冻结,因为它们有自己的线程。
25.2线程开销