购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.2 .NET基础知识

2.2.1 基本概念

1.公共类型系统

公共类型系统(Common Type System,CTS)是一个正式的规范,完整地描述了CLR所支持的所有数据类型和编程结构,指定了这些实体之间如何交互,并规定了它们在.NET元数据中如何表示。通常只有那些设计.NET平台工具或者开发编译器的人员才对CTS的内部工作非常关心,但是.NET编程人员必须了解CTS定义的常用类型,具体如表2-2所示。

表2-2 CTS定义的5种常用类型

另外,不同的语言用于声明内建CTS数据类型的关键字一般是不同的,但是所有语言的关键字最终都将解释成定义在mscorlib.dll程序集中的相同类型。

2.公共语言运行时

公共语言运行时(CLR)专为.NET Framework提供托管运行环境。我们开发的.NET程序都是基于CLR的类库实现的,并运行在CLR的引擎之上,因此通常所说的.NET框架就是CLR。其主要作用是系统调用、内存管理、程序编译启动或停止、线程管理等,可以被支持.NET的所有语言和平台共享。

(1)CLR版本

CLR是.NET Framework的子集,但是两者的版本策略不同。截至2019年,微软发布了4个版本的CLR,两者对应关系如表2-3所示。

表2-3 CLR和.NET Framework两者对应关系

因此,使用ASP.NET Web Form开发的应用程序,在部署到IIS服务器时,不同的CLR版本需要选择不同的托管管道模式,如图2-5所示。

图2-5 选择IIS应用程序池中不同的CLR版本

(2)CLR初始化加载

由于CLR是托管环境,因此运行时中的多个组件需要在执行任何代码之前进行初始化。初始化时有60件以上的事情需要CLR帮助我们完成,60只是一个粗略的统计,具体的事件数量取决于当前系统使用的.NET运行时版本以及启用了哪些功能。

以最简单的控制台程序为例,将“Hello World!”在控制台窗口进行打印,代码如下所示。

将上述.NET代码生成可执行文件,运行时控制权会交由EE执行引擎,这个引擎由ceemain.cpp文件进行编译,最终会通过EEStartupHelper()方法启动执行。为了使它们更容易理解,我们将它们分为5个不同的阶段,下面分别进行说明。

1)第一个阶段主要对CLR加载时做基础设施设置,如表2-4所示。

表2-4 CLR加载基础设施设置

2)第二个阶段主要对CLR核心和底层的组件做初始化配置,如表2-5所示。

表2-5 CLR加载核心组件

3)第三个阶段CLR开始启动错误处理、分析API等功能,如表2-6所示。

表2-6 CLR启动错误处理等功能

(续)

4)第四个阶段CLR开始启动垃圾回收、AppDomain等功能,如表2-7所示。

表2-7 CLR启动AppDomian和垃圾回收等功能

5)第五个阶段EE启动后开启通知其他组件等功能,如表2-8所示。

表2-8 EE启动后通知其他组件

3.基础类库

基础类库(BCL)是由.NET平台提供的,适用于全部.NET程序语言,封装了线程、文件I/O、图形绘制、硬件交互及其他应用服务等。比如,常见的命名空间System.*也属于BCL。

BCL定义了一些可以创建任意类型应用软件的基础能力,如使用ASP.NET创建Web应用,使用WCF创建网络通信服务,使用Windows Form/WPF创建桌面GUI应用,使用ADO.NET与关系数据库交互、XML操作、文件系统交互等。

4.公共语言规范

公共语言规范(Common Language Specification,CLS)是一套规则,描述了.NET的编译器必须支持的最小的和完全的特征集,以生成可由CLR承载的代码,同时可以被所有.NET语言用统一的方式进行访问。CLS和.NET语言之间的关系如图2-6所示。

图2-6 CLS和.NET语言之间的关系

CLS可以看成CTS所定义完整功能的一个子集。.NET中可以使用特性来让编译器检查代码是否遵循CLS规则,代码如下所示。

Add方法不遵循CLS,声明时使用了无符号数unit,不符合CLS约束,因为某些.NET语言不支持无符号数unit。而第二个Sub方法遵循CLS,只在方法内部使用了无符号数(uint)x,并未在方法声明时使用unit。

5.通用中间语言

通用中间语言(Common Intermediate Language,CIL)简称MSIL或IL,它运行于CLR之上,支持C#、Visual Basic.NET等托管的编程语言,当编译器构建.NET程序集时就会把源码翻译成CIL,这样可有效地转换为本机代码且独立于CPU的指令。

CIL由一组CIL指令、CIL特性、CIL操作码构成,下面分别对它们展开详细介绍。

(1)CIL指令

CIL指令是用于描述.NET程序集总体结构的标记,并且通知CIL编译器如何定义在程序集中用到的命名空间、类、成员。

以一个点号开头,如.namespace、.class、.property、.method、.assembly等,具体说明如表2-9所示。

表2-9 CIL指令

(2)CIL特性

CIL特性是在CIL指令并不能完全说明.NET成员和类的特性的情况下,对CIL指令进行补充说明的。比如一个自定义类声明是公共的,继承自某个父类,这时就需要用public、extends或implements特性对类的.class指令进行补充说明。图2-7所示是一个.class指令的特性。

图2-7 .class指令特性

对于.class指令使用的特性,其使用说明如表2-10所示。

表2-10 .class指令

(3)CIL操作码

CIL操作码是对类或方法的内部逻辑进行描述和操作的代码,比如Add操作码表示将两个值相加并将结果推送到堆栈中。Main方法操作码如图2-8所示。

图2-8 Main方法操作码

CIL操作码有很多,图2-8中使用到的操作码也是常见的,关于更多操作码的详细说明如表2-11所示。

表2-11 CIL操作码

(续)

6.Emit动态生成

.NET可以由VB、C#等语言进行编写,这些语言会被不同的编译器解释为IL代码并执行,而Emit类库的作用就是用这些语言来编写生成IL,并交给CLR进行执行。

.NET编译后的每个.dll或.exe文件称为程序集(Assembly),而在一个程序集中内部包含和定义了许多命名空间,这些命名空间被称为模块(Module),而模块正是由一个个类型(Type)组成,如图2-9所示。

图2-9 程序集的组成

所以我们必须先定义Assembly、Module、Type才能进行下一步工作,在Emit中所有创建类型均以Builder结尾,如表2-12所示。

表2-12 程序集和Emit对比

(1)AssemblyBuilder和ModuleBuilder

由于创建程序集需要从Assembly开始创建,所以入口是AssemblyBuilder,而ModuleBuilder用于创建程序集中的模块。通过这两者可以动态生成包含类型和方法的程序集,代码如下所示。

(2)TypeBuilder

TypeBuilder用于创建动态类型。可以使用DefineType方法来定义类型,并指定其名称、基类、接口等信息,代码如下所示。

(3)MethodBuilder

MethodBuilder用于创建动态方法。通过DefineMethod方法定义方法,然后使用GetIL-Generator获取IL生成器,向方法中插入如下IL代码。

下面是一段通过Emit动态技术实现控制台输出Hello World的完整示例,代码如下:

首先需要引入类库的命名空间System.Reflection.Emit,接着向IL生成器插入相应的操作码,这些操作码代表MSIL中的不同指令。比如使用OpCodes.Ldstr向堆栈压入字符串Hello World!,OpCodes.Call调用Console.WriteLine方法,最后通过调用CreateType和CreateDelegate,可以将动态生成的类型转换为委托,从而在运行时执行动态生成的代码,启动后控制台输出Hello World!,如图2-10所示。

图2-10 Emit动态编译

7.即时编译

即时(JIT)编译是一种执行计算机代码的方式,在程序运行时而不是在执行之前进行编译,JIT也是CLR的一部分,编译器负责加快代码执行速度,并提供对多平台的支持,工作原理如图2-11所示。

图2-11 即时编译的工作原理

从图2-11中可知,基于C#、VB.NET、F#开发的托管文件或.dll文件都不是本地代码,不能像C或C++编写的代码那样直接运行在CPU平台上,因此启动托管PE文件都会被JIT编译成本地代码运行。

由于即时编译面临性能损耗的问题,于是微软又提供了预编译方式,简称Pre-JIT,在.NET Framework中使用本机图像生成器Ngen.exe将整个源代码直接转换为本地代码,这样就可以从缓存中使用本机代码,而不是调用JIT编译器。预编译的工作原理如图2-12所示。

图2-12 预编译的工作原理

这些方法在第一次调用时被编译后存储在缓存中,当再次调用相同的方法时,将使用缓存中的编译代码来执行,因此加快了执行速度。

与.NET Framework不同的是,.NET Core提供了一个叫作ReadToRun的功能,它可以预先将IL代码编译成本地代码。要使用这个功能,只需在程序发布的时候执行CIL命令:dotnet publish -c Release -r win-x64 -p:PublishReadToRun=true,本质上ReadToRun也是AOT的一种形式。

另一种方式是使用.NET 5新增的AOT编译功能,AOT编译也是提前将IL代码编译成本地代码,不同的是在发布时生成的单个文件还包含一个精简版的本地运行时。

JIT编译器的优点在于使用的内存较少,因为JIT编译器仅将运行时所需的方法编译为机器代码。缺点是对性能的损耗,当庞大的应用程序最初执行时,JIT编译器需要更多的启动时间。

2.2.2 程序集

通俗来说,我们编写的C#代码经过编译会生成.dll或.exe文件,但这些文件必须在.NET运行时下才能运行,这样的代码称为托管代码,包含这些托管代码的二进制单元就是.NET的程序集。尽管.NET的程序集文件与非托管的Windows二进制文件采用相同的文件扩展名(*.dll),但它们的内部完全不同。

1.程序集的组成

每个程序集文件主要由IL代码、元数据(Metadata)、清单(Manifest)和资源文件组成。其中,IL代码和元数据会先被编译为一个或多个托管模块,然后托管模块和资源文件会被合并成程序集。

程序集文件中占比最大的一般是IL代码。IL代码和Java字节码相似,它不包含平台特定的指令,它只在必要的时候被.NET Core运行时中的JIT编译器编译成机器码。

当托管模块和资源文件合并成程序集时,会生成一份清单,它是专门用来描述程序集本身的元数据。清单包含程序集的当前版本信息、本地化信息,以及正确执行所需的所有外部引用程序集列表等。

2.IL代码

我们先来看看下面一段简单的C#代码被编译成IL代码会是什么样子。

经过编译后,在项目的bin\Debug目录下会生成一个与项目名称同名的dll程序集文件。我们使用ildasm.exe工具打开这个文件,定位到Calculator的Add方法,可以看到Add方法的IL代码,如图2-13所示。

这就是IL代码,如果使用VB或F#编写相同的Add方法,它生成的IL代码也是一样的。由于程序集中的IL代码不是平台特定的指令,因此IL代码必须在使用前调用JIT编译器进行即时编译,将其编译成特定平台的本地代码,才能在该平台运行。

3.程序集清单

.NET Core程序集还包含描述程序集本身的元数据,称之为清单。清单记录了当前程序集正常运行所需的所有外部程序集、程序集的版本号、版权信息等。与类型元数据一样,生成程序集清单也是由编译器完成的。同样地,仍以上面Calculator类所在项目为例,在ildasm.exe工具打开的程序集的目录树中,双击MAINFEST即可查看程序集的清单内容,如图2-14所示。

图2-13 IL代码视图

图2-14 IL程序集清单

可以看到,程序集清单首先通过.assembly extern指令记录了它所引用的外部程序集。接着是当前程序集本身的信息,如版本号、模块名称等。

4.私有程序集

.NET私有程序集是指仅在特定应用程序或组件内部使用的程序集,通常不会被部署到全局程序集缓存(Global Assembly Cache,GAC)中,而是随着应用程序的打包发布部署在文件夹中。比如构建ConsoleJSON.exe可执行文件时,由于内部添加引用了JSON.NET这个开源组件,因此Visual Studio会把Newtonsoft.Json.dll复制到bin目录下,如图2-15所示。

图2-15 bin目录下的私有程序集dll

私有程序集的优点在于灵活部署,即使不小心移除了私有程序集,也不用担心会破坏主机上其他应用程序的正常运行。

5.共享程序集

与私有程序集相对的是共享程序集,在大多数情况下,共享程序集安装在GAC中,而不是部署在应用程序目录下,程序对它的引用不会产生文件副本。比如经常使用的System.Diagnostics.Process类位于mscorlib.dll程序集,但这个程序集并不会被复制到bin目录下,因此这个程序集就是一个共享程序集。

(1)GAC

共享程序集安装在GAC中,GAC的实际位置取决于安装的.NET版本。在.NET<4.0环境中,GAC安装在C:\Windows\Assembly目录下,.NET 4.0发布后,微软决定将共享程序集隔离到C:\Windows\assembly\GAC_MSIL目录下,如图2-16所示。

图2-16 Windows共享程序集目录

该目录下存在很多的子目录,在每个子目录下会发现另一个以类似“3.5.0.0__b77a5c561934e089”方式命名的文件,前缀3.5表示由.NET 3.5或更高的版本编译,两个下划线之后的一串像md5哈希的字符串称为publickey标记,这个公共标记是程序集强名称的一部分,如图2-17所示。

图2-17 查看共享程序集目录

(2)强名称

在部署程序集到GAC中之前,必须要赋予它一个强名称,强名称是由程序集的标识加上公钥和数字签名组成的。强名称在.NET中的作用好比全局唯一标识符(Globally Unique Identifer,GUID),可以确保唯一性。

我们可以使用Visual Studio创建强名称,以ConsoleJSON.exe控制台项目为例,打开项目的属性页,选择“签名”选项卡,勾选“为程序集签名”复选框,并在“选择强名称密钥文件”下拉列表中选择“新建”选项,然后在弹出的对话框中需要指定新的密钥文件名称,这里是“dotNetDLLPair.snk”,“签名算法”默认为sha256RSA,如图2-18所示。

图2-18 创建强名称密钥

此时在资源管理器中可以看到*.snk文件,每次生成应用程序时都会为这个.dll文件生成一个强名称,然后在GAC中安装共享程序集,Visual Studio提供的命令行工具gacutil.exe可以将具有强名称的程序集添加至GAC中。命令格式为gacutil.exe -i Calculator.dll。注意,只有拥有管理员权限才能与GAC进行交互,如果以普通用户身份运行,则添加失败,如图2-19所示。

图2-19 普通用户身份添加程序集缓存失败

因此以管理员身份运行命令提示符,运行结果如图2-20所示。

图2-20 管理员身份添加程序集缓存成功

然后转到C:\Windows\Microsoft.NET\assembly\GAC_MSIL目录,会发现包含了一个新的Calculator文件夹,如图2-21所示。

图2-21 Calculator已成功添加程序集缓存

(3)加载程序集

.NET查找和加载的程序集方式根据程序集的特征主要分为共享程序集和私有程序集两种场景。

共享程序集加载顺序见表2-13。

表2-13 共享程序集加载顺序

私有程序集加载顺序见表2-14。

表2-14 私有程序集加载顺序

可以看出,通过codeBase和privatePath可以自定义指定程序集加载路径。下面分别具体介绍<codeBase>和<probing privatePath>,它们是用于配置程序集加载策略的标签。

1)<codeBase>。

可以在web.config文件的<runtime>元素中使用<codeBase>,对于所有具有强名称的程序集,要求具有version属性,但不具有强名称的程序集应省略。<codeBase>元素要求具有href属性。在<codeBase>元素中不能指定版本范围,具体配置请参考下面示例。

2)<probing privatePath>。

默认情况下,.NET应用会尝试在.exe文件所在的目录或者bin目录下搜索程序集文件(.dll),如果引用的外部程序集较多会显得非常杂乱,因此.NET提供的一种解决方式是向web.config或app.config中添加privatePath属性,表示搜索私有路径,这种方式对于大型.NET项目来说非常有用,通常用于配置第三方插件应用。具体配置请参考下面示例。

.NET Web启动时除了加载bin目录外,还会从Libs文件夹中加载任意的程序集文件,如果攻击者可以向此目录上传或解压恶意的程序集文件,可能会触发RCE(Remote Code Execution,远程代码执行)漏洞,因此在代码审计或者实战中须关注此配置项。

总的来说,程序集就是.NET Core在编译后生成的.dll文件,它包含托管模块、资源文件和程序集清单,其中托管模块由IL代码和元数据组成。

需要说明的是,.NET Core与.NET Framework不同,.NET Core始终只会生成.dll格式的程序集文件,即使像控制台应用这样的可执行项目也不会生成.exe格式的程序集文件。

那我们在.NET Core项目的bin目录中看到与项目名称相同的.exe文件是怎么回事呢?这个文件并不是一个程序集文件,而是专门为Windows平台生成的一个可执行的快捷方式。在Windows平台双击这个文件等同于执行dotnet <assembly name>.dll命令。在我们安装的.NET Core目录中有一个dotnet.exe命令文件(如Windows系统默认位置是C:\Program Files\dotnet\dotnet.exe),在编译时,该文件会被复制到构建目录中,并重命名为与项目名称相同的<assembly name>.exe文件。

2.2.3 命名空间

.NET平台为了确保基础类库中的所有类型能清晰地组织在一起,提出了命名空间的概念。简单地说,命名空间就是一个程序集内相关类的分组。比如System.IO命名空间包含文件操作的类型,System.Data命名空间定义了基本的数据库类型。表2-15简要介绍了一些常见的.NET命名空间。

表2-15 常见的.NET命名空间

为了更清楚地展示命名空间的结构,Visual Studio提供了一个小工具—对象浏览器,这个工具可以用来查看当前项目引用的程序集命名空间和具体类型,如图2-22所示。

图2-22 对象浏览器

2.2.4 成员封装

1.类的属性

在.NET中,一个公开类的属性用于提供对该类的成员的访问,允许类的数据成员被外部代码读取和修改。这种类属性通常包括Getter和Setter方法,这些方法定义了如何访问属性的值。下面结合一个简单的示例,介绍.NET类和属性及成员之间的关系。

示例代码如下:

这个例子中定义了一个Person类的私有成员_name,然后使用.NET属性进行封装,这样便于外部的调用私有成员_name。具体来说,定义一个公开的Name属性,包括Getter读取器和Setter写入器。通过Setter方法设置私有成员的值,这里使用了上下文关键字value,然后通过Getter方法返回成员的值。

因此从外部的角度看,Name属性可以像字段一样访问,而不需要调用方法,具体调用读写代码如下所示。

2.自动属性

在创建.NET类的属性时还可以做进一步简化,它们不需要包含自定义的Getter和Setter方法,而由C#自动生成,代码如下所示。

在上述示例中,编译器会自动为Name属性生成Getter和Setter方法,无须显式编写。这在某些场景下非常方便。

2.2.5 反射机制

反射是.NET中的一项技术,允许程序在运行时动态地访问和操作程序集、类型和对象的信息。通过反射,能够在编译时进行动态加载程序集、创建对象实例、调用对象方法、访问属性和字段等操作。

1.获取Type类的成员

Type类的GetMembers方法用来获取该类的所有成员,包括方法和属性,可通过BindingFlags标志来筛选这些成员。下面这段代码使用反射获取.NET基类object的所有成员,并输出名称和成员类型。

代码中有3个BindingFlags标志:Public表示获取公共成员,Static表示获取静态成员,Instance表示获取可以被实例化的成员。运行后控制台输出的结果如图2-23所示。

GetMembers方法也可以不传BindingFlags标志,则默认返回的是所有公开的成员,如果要获取私有的方法,需要指定BindingFlags.NonPublic。

图2-23 控制台输出反射的结果

2.调用对象的方法

Type类的GetMethod方法用来获取该类的MethodInfo,然后可通过MethodInfo动态调用该方法,对于非静态方法,需要传递对应的实例作为参数。下面这段代码使用反射获取字符串类型,然后调用Substring方法截取指定长度的字符,具体代码如下所示。

Invoke方法接收两个参数,即要调用方法的实例str和方法的参数数组new object[]{0,4},相当于str.Substring(0,4),运行后如图2-24所示。

对于静态方法,反射时则对象参数传空,以反射获取Math类的类型调用Exp方法为例,具体代码如下。

以上代码的结果是7.38905609893065,因为Math.Exp(2)返回e的2次方的近似值。这个示例演示了如何使用反射来调用静态方法,尽管反射通常用于处理实例方法,但它也可以用于处理静态方法,运行结果如图2-25所示。

3.创建类的实例

反射动态创建一个类的实例有多种方式,使用Activator类动态创建一个类的实例是最常见的做法,下面以创建一个BigInteger类的实例为例,具体代码如下。

图2-24 调用Substring方法返回字符串hell

图2-25 调用Exp方法进行计算

默认情况下,BigInteger的实例值为0,当再次使用Activator.CreateInstance(type,123)时传递了一个参数123给构造函数,返回值为123。运行后控制台输出的结果如图2-26所示。

图2-26 反射实例化对象

2.2.6 泛型

泛型(Generic)提供了一种更优雅的方式,可以让多个类型共享一组代码。泛型允许声明类型参数化的代码,可以用不同的类型进行实例化。即可以用“类型占位符”来写代码,然后在创建类的实例时指明真实的类型。因此,我们可以这样理解:类型不是对象而是对象的模板,泛型类型也不是类型,而是类型的模板。

下面通过声明一个自定义的类MyIntStack和一个支持泛型的类MyStack来进行对比说明,具体代码如下所示。

这种声明方式是可行的,但缺点在于每次需要新的类型时,我们都需要重复这个过程,容易造成代码冗余。下面再看看支持泛型的类MyStack的声明,具体代码如下所示。

可以看出非常明显的变化,泛型的类名由一对尖括号和大写字母T构成,这里的T是类型占位符,可以改成其他字母,默认为T,在运行时每个T都会被编译器替换成实际的类型。

1.类型参数约束

符合约束的类型参数叫作未绑定的类型参数,如果代码尝试使用其他成员,编译器会产生一个错误信息,具体代码如下所示。

返回错误信息“不是所有的类都实现了小于运算符”,因此需要提供额外的信息让编译器知道参数可以接受哪些类型。这些额外的信息就叫作约束,只有符合约束的类型才能替代给定的类型参数来产生构造类型。

泛型中的约束使用where子句,每一个有约束的类型参数都有自己的where子句。基本语法如下。

约束出现在类型参数列表<>之后,当有多个约束时,它们之间不使用逗号或者其他符号,需要换行进行展示,具体的代码示例如下。

MyClass类有3个类型参数,T1是未绑定约束的类型参数,T2、T3分别绑定了Customer类和IComparable接口类,只有实现这两个类的类型才能作为MyClass的实际参数。

2.泛型方法

与其他泛型不一样,方法是成员,不是类型。泛型方法可以在泛型、非泛型、结构和接口中声明。泛型方法有两个参数列表,分别是在尖括号内的参数列表和在圆括号内的参数列表,前者称为类型参数列表,后者称为方法参数列表,具体如下所示。

约束子句一般放在方法参数列表之后,类型参数列表在方法名称之后,在方法参数列表之前。

调用泛型方法要在方法调用时提供类型实参,比如MyMethod<short,int>();,如果我们为方法传入参数,编译器可以从方法参数中推断出泛型方法的类型形参中用到的是哪些类型。例如如下代码,编译器可以从方法参数中得知是int类型。

由于编译器可以从方法参数中推断出类型参数,我们可以省略类型参数和尖括号,从而简化成MyMethod(myInt);。

为了更好地帮助读者理解泛型方法的调用,我们创建一个非泛型类Simple,并在内部声明一个泛型方法ReverseAndPrint,这个方法将任意类型的数组作为参数,具体代码如下所示。

然后在Main方法中声明了int和string两个不同的数组类型,分别用显式调用和编译器推断类型调用了2次,代码如下所示。

运行后的输出结果如图2-27所示,可以看到通过编译器推断类型调用的结果并没有对数组的元素进行反转。

图2-27 使用泛型方法

3.扩展方法

扩展方法可以与泛型类结合使用,它允许将类中的静态方法关联到不同的泛型类上,还允许像调用类构造实例的实例方法一样来调用方法。泛型类的扩展方法需满足以下条件。

❑方法必须声明为static。

❑方法必须是静态类的成员。

❑方法第一个参数类型必须有关键字this,后面是扩展的泛型类的名字。

下面结合一个简单示例演示扩展方法的定义和使用。首先定义一个泛型类Holder<T>,该类的构造方法可接受3个任意类型的参数,并且还包含一个GetValues方法,具体代码如下所示。

在此基础上,接着再声明一个非泛型的类ExtendHolder,包含一个静态的泛型方法Print,并且通过参数扩展Holder<T>泛型类。代码如下所示。

然后在Main方法中通过实例化泛型类Holder,调用它的扩展方法Print(),代码如下。

分别传入int和string类型数据,运行后返回的结果如图2-28所示。

图2-28 使用泛型扩展方法

2.2.7 委托

在.NET平台下,委托类型用来定义和响应应用程序中的回调。与传统的C++函数指针不同,委托是内置支持多路广播和异步调用的。

实际上,委托和类一样,是一种用户定义的类型,类表示的是数据和方法的集合,而委托则是持有一个或多个方法的对象,但委托与传统的对象不同,执行委托就是执行委托对象所持有的方法,如图2-29所示。

图2-29 委托调用列表

在调用委托的时候,会执行其调用列表中的所有方法,这些方法可以是实例方法,也可以是静态方法,且方法可以来自任何类或结构,只要委托的返回类型、委托的签名与方法相匹配即可。

1.基本用法
(1)声明委托

委托是类型,与类一样,委托必须在被使用前声明,委托的声明语法如下:

delegate返回类型 委托类型名(签名);

比如delegate void MyDel(int x),这段声明指定了MyDel类型的委托只会接受不带返回值且有单个int类型参数的方法。

(2)创建委托

通常使用new运算符创建委托对象,比如MyDel dvar = new MyDel(obj.M1),这里的圆括号包含的值obj.M1作为调用列表中第一个成员的方法名称,该方法可以是实例或者静态方法。

2.组合委托

创建委托需要使用delegate关键字,委托也是方法的容器,可以在被委托的对象中添加和移除方法,具体操作是:使用运算符“+=”为委托增加方法,使用运算符“-=”为委托移除方法。下面以WinForm为例,具体代码如下。

在窗体Form1类外部定义一个委托类dg_SayHi(),注意这里的签名需要匹配未来指向方法的签名,因为未来指向的SayHiCN和SayHiEN均无参数,所以此处的委托类dg_SayHi()也是一个无参委托。

创建一个按钮事件,并在代码内部新建委托对象objSayHi,然后向委托对象添加和移除一个方法,最后调用委托,因为委托是方法的容器,调用时只要在委托对象里面的方法均会被调用,运行时单击按钮后结果如图2-30所示。

图2-30 组合委托成功调用

3.泛型委托

在.NET中,泛型委托与普通委托类似,不同之处在于泛型委托要指定泛型参数。泛型委托能够定义具有不同类型参数的委托,从而提高代码的重用性。以下是一个简单的.NET泛型委托的示例。

在上述示例中,我们定义了一个泛型委托CalculatorDelegate<T>,接受两个泛型类型为T的参数,然后创建了一个Calculator类,其中包含一个泛型方法Calculate,它接受两个操作数和一个泛型委托,用于执行外部传入的特定操作。

在Main方法中,我们使用泛型委托定义了加法操作,并将它们传递给Calculate方法进行计算,运算后得到的总和为8,如图2-31所示。

图2-31 调用泛型委托进行加法计算

如果修改成乘法操作,只需要动态修改泛型委托CalculatorDelegate,而无须在Calculator内部硬编码,再创建一个名为multiply的泛型委托即可,具体代码如下所示。

单击button1_Click按钮,触发单击事件,运算后的结果为10,如图2-32所示。

图2-32 调用泛型委托进行乘法计算

(1)Action泛型委托

Action<T>是.NET Framework内置的一个无返回值的泛型委托,可以使用Action<T>委托以参数形式传递方法,而不用显式声明自定义的委托。以下是上面示例的修改版本,使用Action泛型委托来执行加法操作,具体代码如下。

在button1_Click事件中创建Action委托add,并传递给Calculator方法执行加法操作,代码如下所示。

最后调用Calculate方法会执行传递的Action委托,运行结果如图2-33所示。

图2-33 Action泛型委托进行计算

(2)Func泛型委托

当使用Func泛型委托时,可以指定一个返回类型。在这种情况下,可以将Func委托用于执行操作并返回结果。以下代码是上面示例的修改版本,使用Func泛型委托来执行加法操作。

Func泛型委托的最后一个参数TResult用于返回结果类型,在button1_Click事件中创建Func委托add,并传递给Calculator方法执行加法操作,具体代码如下所示。

4.开放委托

在.NET中,开放委托(Open Delegate)是一种特殊的委托类型,可使用Delegate.CreateDelegate方法来创建。开放委托提供一种动态地调用方法的机制,可以在事先不知道签名的情况下调用特定的方法。

这对于使用反射或动态代码生成的情况非常有用,以下面这段代码为例。

如果要创建TestStaticMethod方法的委托,则需要使用Action<string>委托类型,代码如下:

得到的委托的效果与TestStaticMethod(arg1)相同。

如果要创建TestMethod方法的委托,则需要使用Action<TestClass,string>委托类型,代码如下,第一个参数表示要在其上调用方法的TestClass的实例:

得到的委托的效果与arg1.TestMethod(arg2)相同。

2.2.8 Lambda

1.匿名方法

匿名方法是在实例化委托时内联声明的方法,当一个方法只会被调用一次的时候,没有必要创建独立的具名方法,这时可以使用匿名方法。

示例代码如下:

上述代码使用具名方法声明并使用了一个名为Add20的方法,改用匿名方法的代码如下:

由此可见,匿名方法的语法结构为:delegate(参数列表){语句块}。其中,“参数列表”表示如果没有任何参数时可以省略。需要说明的是,匿名方法不会显式声明返回值,因此会返回与委托返回类型一致的类型,比如委托delegate int OtherDel(int InParam);返回int,匿名方法返回的也必须是int类型。

2.Lambda表达式

在匿名方法的语法中,delegate关键字有点多余,因为编译器已经知道我们将方法赋值给委托。因此可以删除delegate关键字,将匿名方法转换为Lambda表达式,转换通过Lambda表达式的运算符“=>”实现,此符号读作“goes to”,用于定义一个匿名函数或表达式,并指定其返回值,常用在参数列表和匿名方法语句块之间。具体请参考如下代码。

可见,委托le1使用Lambda表达式之后可省略delegate关键字,并且是一个带有参数列表(int x)的显式类型。除了这种简单的转换,通过编译器的自动推断,我们可以更进一步简化Lambda表达式,比如编译器可以从委托的声明中知道委托参数的类型,因此Lambda表达式允许省略类型参数,如委托le2代码如下:

如果只有一个隐式类型参数,还可以省略括号,如委托le3代码如下:

最后,Lambda表达式允许表达式的主体是语句块或表达式。如果语句块包含一个返回语句,可以将语句块替换为return关键字后的表达式,如委托le4代码如下:

这段完整的Lambda表达式的示例,运行后控制台打印输出的结果符合预期,如图2-34所示。

图2-34 Lambda表达式和使用匿名方法

需要说明的是,Lambda表达式参数列表有如下要点需掌握:

❑表达式的参数列表中的参数必须在参数数量、类型和位置上与委托相匹配。

❑表达式的参数列表中的参数不一定需要包含类型(隐式类型),除非委托有ref或out参数,此时必须注明类型(显式类型)。

❑如果只有一个参数,且是隐式类型的,括号可以省略,否则必须有括号。

❑如果没有参数,必须使用一组空的圆括号。

2.2.9 事件

事件是由.NET框架提供的一种机制,通过事件可以向其他对象通知发生的相关动作。发送通知的对象称为事件发布者,接收通知的对象称为事件订阅者。

1.事件的组成部分

❑ Event Publisher:事件发布者,负责声明和触发事件。定义了事件的委托类型,通常使用EventHandler委托事件和触发方法。

❑ Event Subscriber:事件订阅者,负责接收事件,包括事件处理程序方法。

❑ Event Handler:事件处理程序是一个方法,它包含事件的实际处理逻辑。事件处理程序方法的签名必须与事件的委托类型相匹配。

.NET中通常使用event关键字声明事件,因此在编译器处理event关键字时会自动注册和注销方法以及任何必要的委托类型成员。下面是一段用于模拟事件的触发和处理的代码示例。

上述代码中,Doorbell类是事件发布者,它声明了一个名为Ring的事件,并且使用EventHandler委托来表示。Press方法模拟按下门铃,在内部调用OnRing方法来引发事件。在OnRing方法中,我们使用事件的标准模式来触发事件,即Ring?.Invoke(this,EventArgs.Empty);,代码如下所示。

Homeowner类是事件订阅者,它包含一个事件处理程序方法AnswerDoor,以下面所示代码为例,用于响应事件单击button1_Click按钮,创建Doorbell和Homeowner对象,并将Homeowner对象的AnswerDoor方法订阅到Doorbell对象的Ring事件。

当门铃按下时,doorbell.Press()方法触发Ring事件,进而调用Homeowner的AnswerDoor方法,如图2-35所示。

图2-35 AnswerDoor方法在Ring事件触发时调用

2.标准事件EventHandler

对于事件的使用,.NET框架提供了一个标准模式:EventHandler委托类型。签名如下:

其中,第二个参数EventArgs不传递任何数据,如果希望传递数据,必须声明一个派生自EventArgs的类用来保存状态信息,指明什么类型适用于该应用程序。

下面改写上述代码,使用泛型委托EventHandler实现Ring事件,为了向第二个参数传入数据,需要声明一个派生自EventArgs的自定义类RingEventArgs,用于保存要传入的信息。类的名称以EventArgs结尾。

然后,我们将EventHandler<RingEventArgs>委托用于Ring事件,并传递RingEventArgs参数,具体代码如下所示。

按下门铃按钮后,Press方法触发Ring事件,内部创建了一个新的RingEventArgs对象,将其传递给OnRing方法。这样,事件可以读取消息并作出适当的响应。运行结果如图2-36所示。

2.2.10 枚举器和迭代器

1.枚举器

我们都知道,在.NET中可以使用foreach语句遍历数组中的元素,那么为什么数组可以被foreach语句处理呢?原因是数组可以按需提供一个叫作枚举器(enumerator)的对象,枚举器可以依次返回请求的数组元素,对于枚举器的类型而言,必须有一个方法来获取它。获取一个对象枚举器的方法是调用对象的GetEnumerator方法。实现GetEnumerator方法的类型叫作可枚举类型(enumerable type或enumerable)。数组就是可枚举类型。

图2-36 AnswerDoor方法在OnRing触发时被调用

(1)IEnumerator接口

实现了IEnumerator接口的枚举器包含3个函数成员:Current、MoveNext和Reset,三者使用介绍见表2-16。

表2-16 枚举器的3个函数成员

有了集合的枚举器,就可以使用MoveNext和Current成员来模仿foreach循环遍历集合中的项,因此下面手动编写代码实现foreach语句执行的操作。

(2)IEnumerable接口

枚举类是指实现了IEnumerable接口的类,IEnumerable接口只有一个成员,就是GetEnumerator方法,返回对象的枚举器。如下声明了一个可枚举的MyClass类,必须要实现IEnumerable接口。

(3)自定义枚举器的编码实现

下面的代码展示了一个自定义的枚举器ColorEnumerator,由前面知识可知必须实现IEnumerator接口,用于模拟foreach循环,参考代码如下所示。

然后再自定义一个可枚举类Spectrum,此类必须实现IEnumerable接口:

最后创建Spectrum类的实例,完成循环调用,代码如下:

运行后,控制台枚举输出这一数组中的元素数据,完全符合预期,如图2-37所示。

图2-37 Spectrum枚举后的输出结果

(4)泛型枚举接口

目前我们描述的枚举接口都是非泛型接口的。然而,在大多数情况下应该使用泛型接口IEnumerable<T>和IEnumerator<T>。两者之间的差别如下。

❑对于非泛型接口,IEnumerable接口的GetEnumerator方法返回实现IEnumerator枚举器类的实例。实现IEnumerator的类实现了Current属性,返回object类型的引用,然后我们必须把它转化为实际类型的对象。

❑对于泛型接口,IEnumerable<T>接口的GetEnumerator方法返回实现IEnumerator<T>枚举器类的实例。实现IEnumerator<T>的类实现了Current属性,返回实际类型的对象,而不是object类型的引用。

我们目前所看到的非泛型接口的实现不是类型安全的。它们返回object类型的引用,然后必须转化为实际类型。而泛型接口的枚举器是类型安全的,它返回实际类型的引用。因此微软推荐使用泛型枚举接口,只在老版本的.NET中才使用非泛型枚举接口。

2.迭代器

可枚举类和枚举器在.NET集合类中被广泛使用,所以熟悉它们如何工作非常重要。不过C#从2.0版本开始,提供了更简单的创建枚举器和可枚举类型的方式,叫作迭代器(Iterator)。

下面的BlackAndWhite方法创建了一个迭代器,并返回一个字符串对象的泛型枚举器。

yield是C#为了简化遍历操作实现的语法糖,代替了某个类型实现IEnumerable接口的方式。

(1)编码迭代器创建枚举器

在下面的代码中,BlackAndWhite方法是一个迭代器块,可以为MyClass类产生一个返回是string类型的枚举器类型。

MyClass类还实现了GetEnumerator方法,内部通过调用BlackAndWhite方法返回枚举器,因此可以通过foreach语句直接循环读取MyClass对象。

(2)编码迭代器创建可枚举类型

通过迭代器还可以创建可枚举类型,而不是枚举器。在上个例子中,BlackAndWhite迭代器返回的是IEnumerator<string>,现在要改写成返回IEnumerable<string>,具体实现代码如下所示。

MyClass类首先调用BlackAndWhite方法获取可枚举类型对象,然后调用对象的GetEnumerator获取结果。

(3)编码迭代器实现属性

下面这段代码声明了两个属性来定义两个不同的枚举器UVToIR和IRToUV,并且演示了迭代器如何实现属性而不是方法。

在以上代码中,GetEnumerator方法根据listFromUVToIR布尔变量的值返回两个枚举器中的一个,如果是true,返回UVToIR枚举器,如果是false,返回IRToUV枚举器,重要的是这两个枚举器都是属性,并且由迭代器生成。运行结果如图2-38所示。

图2-38 迭代器用法

2.2.11 LINQ

LINQ(Language Integrated Query,语言集成查询)是一组语言特性和接口,使得可以使用统一的方式编写各种查询。它在对象领域和数据领域之间架起了一座桥梁,用于保存和检索来自不同数据源的数据,从而解决了编程语言和数据库之间的不匹配问题,以及为不同类型的数据源提供单个查询接口。

LINQ总是使用对象,因此可以使用相同的查询语法来查询和转换XML、对象集合、SQL数据库、ADO.NET数据集以及任何其他可用的LINQ提供程序格式的数据。

1.匿名类型

在.NET中有一种特殊的类型,该类型没有名字和构造函数,这样的类型被称为匿名类型。匿名类型一般用于表示LINQ返回的结果。示例代码如下。

由于匿名类型没有名字,必须使用var关键字作为变量类型,student变量是一个具有两个string属性和一个int属性的匿名类型。运行结果如图2-39所示。

图2-39 匿名对象student输出属性

匿名对象的初始化,除了上述的基本赋值形式外,还有两种特殊形式:简单标识符和成员访问表达式。具体代码如下所示。

在上述代码中var student = new {Age = 19,Other.Name,Major };包含了赋值形式(Age)、成员访问(Other.Name)、标识符(Major)。

2.查询的语法

我们在写LINQ语句的时候可以使用两种形式的语法,分别是查询语法、方法语法。

(1)查询语法

查询语法是声明式的,也就是说,查询描述的LINQ语句是你想返回的东西,但并没有指明如何执行这个查询,看上去和SQL语句很相似,使用查询表达式形式书写。代码如下所示。

在LINQPad运行后输出“2,5,17,16”这四个小于20的数字,如图2-40所示。

图2-40 查询小于20的值

(2)方法语法

方法语法是命令式的,指明了查询方法调用的顺序,这些方法是一组叫作标准查询运算符的方法。在如下代码中,Where方法的参数中使用了Lambda表达式。

微软推荐使用查询语法,因为它更容易读,且能更清晰地表明查询意图,不容易出错。然而,有些情况下还可以两者结合在一起使用,如图2-41所示。

3.表达式

查询表达式由from子句和查询主体组成,常见的查询表达式还有join子句和from...let...where子句。

(1)from子句

from子句指定了要作为数据源使用的数据集合,默认引入了迭代变量和查询的集合名,代码如下所示。

图2-41 LINQ语句使用Count方法

上述代码中n就是一个迭代变量,表示数组中的每个元素,而numbers则表示被查询的集合。

(2)join子句

LINQ中的join子句与SQL中的join很相似,用于接收两个集合并创建一个新的集合,每一个元素包含两个集合中的原始成员。下面声明Student和CourseStudent两个类,分别包含学生姓名和参与的课程,它们之间通过StID关联,实现代码如下。

使用foreach循环遍历查询结果,并输出每位学生姓名以及选修的课程,结果如图2-42所示。

图2-42 LINQ查询使用join语句

(3)from...let...where子句

可选的from...let...where部分是查询主体的第一部分,由任意数量的3种子句组成,即from子句、let子句和where子句。

查询表达式必须从from子句开始,后面是查询主体,每一个form子句都指定了一个额外的源数据集合并引入了要在之后运算的迭代变量。

let子句接受一个表达式的运算并且把运算的结果赋值给一个需要在其他运算中使用的标识符,例如将数组groupA的每个成员与groupB的每个成员交叉做加法运算,并筛选出等于12的组合,具体实现代码如下所示。

上述代码中,语句let sum=a+b表示在新的变量中保存运算的结果,结果如图2-43所示。

图2-43 LINQ查询使用from...let...where语句

4.查询运算符

查询运算符由一系列接口方法组成,支持查询任何数组或集合,被查询的集合对象叫作序列,这些序列必须实现IEnumerable<T>接口,此处的T表示类型,因此序列特指实现了IEnumerable<>接口的类。

另外,查询运算符还有一些重要的特性。

❑查询运算符使用方法语法。

❑一些返回标量的运算符会立即执行查询,并返回一个值,比如ToArray()、ToList()等。

下面以Sum和Count运算符为例演示如何使用查询运算符,具体代码如下。

以上代码返回int类型的两个变量,此处称为标量对象,数组numbers称为序列。运行后如图2-44所示。

图2-44 使用Sum和Count运算符

LINQ提供了大量查询运算符,可用来操作一个或多个序列,包括常见的List<>、Stack<>、Dictionary<>等,这些运算符帮助我们查询和操作这些类型对象。

表2-17列出了部分常用运算符,并给出了对应的描述。

表2-17 LINQ常用运算符

另外,每个运算符的第一个参数是IEnumerable<T>对象的引用,之后的参数可以是任何类型。很多运算符接受泛型委托作为参数,泛型委托用于为运算符提供用户自定义代码,以Count运算符为例,支持泛型重载的方法的签名如下所示。

其中,Func<T,bool> predicate泛型委托作为参数,它接受单个T类型的输入参数,并返回布尔值的委托对象。有一个实际应用的场景,比如计算数组中奇数元素的总数量,要实现这一点必须为Count方法提供检测整数是否为奇数的代码,具体代码如下所示。

以上代码先声明IsOdd方法,接受单个int参数,通过return x % 2!=0判断返回是否奇数的布尔值,然后创建一个类型为Func<int,bool>、名为MyDel的委托对象,并使用IsOdd方法来初始化委托对象。运行后如图2-45所示。

图2-45 使用委托作为参数

上述实现代码还可以进一步使用Lambda表达式简化,表达式输入的值是奇数时返回true,具体代码如下所示。

2.2.12 表达式树

表达式树是C#中的一种数据结构,它以树的形式表示某些代码内部的结构,每个节点是一种称为表达式的C#对象。在.NET中,表达式树使表达式的结构和操作在编译时被保留下来,而不是像通常的.NET代码那样被直接编译成IL,常用于创建动态查询和解析、处理和执行命令模式,表达式树可以利用Lambda表达式创建,然后可以被编译并执行。

总的来说,Lambda表达式是创建表达式树和委托实例的一种方式,委托是一种可以引用方法的类型,而表达式树则提供了一种灵活处理代码的方式,使得可以在运行时操作和执行代码。

代码示例如下:

在这个示例中,我们通过多个功能不同的表达式组合创建了一个完整的表达式树来表示“num > 5”运算,然后把这个表达式树转换为一个Lambda表达式,且编译并运行这个Lambda表达式,运行后输出结果如图2-46所示。

图2-46 比较表达式树

在.NET中,表达式树和反射都可以用来在运行时动态地生成和执行代码,而表达式树实际上是一个数据结构,它以树的形式表示代码,我们可以创建和修改表达式树,然后将其编译为委托并执行。表达式树的主要优点在于可以在运行时生成和编译,从而提供了比反射更高的执行效率。

下面通过一个例子来比较一下如何通过反射和表达式树访问对象的属性。创建一个Person类,该类只包含一个成员Name,代码如下所示。

使用反射的GetProperty获得Name属性,接着读取此类的Name属性,具体实现代码如下所示。

改用表达式树实现起来更加简洁易懂,使用Expression.Property读取Name属性,代码如下。

可以看到,虽然表达式树的代码复杂一些,但实际上它运行得更快,特别是在需要重复执行的情况下,因为编译过的委托可以重复使用,而反射每次都需要重新解析类型信息和方法信息。运行后如图2-47所示。

图2-47 使用表达式树获取Name属性

表达式树可以被动态生成,这是表达式树的一个重要特性。另外,由于表达式树是代码的数据结构表示,因此可以将其序列化为二进制或文本格式,然后在另一个进程或机器中反序列化并执行,这对于RPC远程调用和分布式计算等场景非常有用。

2.2.13 特性

在.NET中,特性(Attribute)常用于为元数据添加内容,元数据是程序中各种元素的信息,如类、方法、属性等,Attribute允许开发者在这些元素上附加额外的信息,以提供更多的上下文或指导编译器、工具或运行时环境的行为。表2-18列出了一些常见特性。

表2-18 常见特性

在.NET中,如果要自定义一个Attribute,就需要创建一个继承自System.Attribute类的新类。以下是创建自定义Attribute的基本步骤。

1)创建一个新的类MyCustomAttribute并继承自System.Attribute,这个类将成为自定义Attribute。在自定义的Attribute类中,可以定义属性Description,这个属性将作为元数据的一部分。

2)在需要使用自定义Attribute的地方将MyCustomAttribute应用到其他类、方法、属性中。使用中括号[]将Attribute应用于目标,传递适当的参数。

3)使用反射来获取和读取应用了自定义Attribute的信息。可以在运行时检查元数据以获取Attribute的值。需要注意的是,Attribute的类名通常以“Attribute”结尾,但在应用Attribute时通常省略这个后缀。所以,可以使用[MyCustom("...")]而不是[MyCustomAttribute("...")]。

运行时反射成功获取自定义的Attribute,通过弹出对话框获取值,如图2-48所示。

图2-48 反射获取Form1的Attribute

Attribute在软件设计上的意义在于提供了一种灵活的元数据机制,可以用来描述、配置和控制代码的行为和特性。它们有助于提高代码的可维护性、可读性和灵活性,同时也为自动化工具和框架提供了丰富的支持。因此,合理使用Attribute可以改善软件的质量和开发效率。

2.2.14 不安全的代码

在.NET中,unsafe关键字被用来定义一种特殊的代码上下文,在该上下文中可以使用指针类型和直接操作内存地址,由于Windows系统很多的API使用C和C++编写程序集文件,因此使用的场景通常发生在需要与非托管代码交互时,必须通过unsafe context才能调用。

与unsafe关键字结合使用的关键字和运算符主要包括以下几种。

1)指针操作符:用于处理指针变量,常用的指针操作符有*、->、&等,详细说明见表2-19。

表2-19 unsafe指针操作符

2)fixed:在unsafe代码块中,可以使用fixed语句来固定一个变量,防止垃圾收集器移动它。这对于需要直接操作内存的代码段非常重要。

3)stackalloc:用于在栈上分配一块内存区域。这块内存区域在所属的方法执行完毕后会被自动释放。

4)sizeof:在unsafe代码块中,sizeof运算符可以用来获取未托管类型的大小。

在Visual Studio中,默认情况下禁用了unsafe代码选项,这意味着如果尝试编写包含不安全代码块的C#代码时,编译将会失败,不过Visual Studio在项目“生成”菜单中提供了一个“允许不安全代码”选项,只需勾选便允许应用程序启用unsafe代码,如图2-49所示。

图2-49 Visual Studio编译允许运行unsafe z6r+bU6GblH1SrsAcWuZntkkULZzMnk78JOMi788U3Ba3jiZxxPh0a+tzWerwfut

点击中间区域
呼出菜单
上一章
目录
下一章
×