十八、动态类型和动态语言运行时
NET 4.0 为 C# 语言引入了一个新的关键字,具体来说就是dynamic
。该关键字允许您将类似脚本的行为合并到类型安全、分号和花括号的强类型世界中。使用这种松散的类型,您可以极大地简化一些复杂的编码任务,还可以获得与许多动态语言进行互操作的能力。网核悟性。
在这一章中,将向您介绍 C# dynamic
关键字,并理解如何使用动态语言运行时(DLR)将松散类型的调用映射到正确的内存对象。在理解了 DLR 提供的服务之后,您将会看到使用动态类型来简化如何执行延迟绑定方法调用(通过反射服务)以及如何轻松地与传统 COM 库进行通信的示例。
Note
不要混淆 C# dynamic
关键字和动态汇编的概念(参见第十九章)。虽然在构建动态程序集时可以使用dynamic
关键字,但这最终是两个独立的概念。
C# 动态关键字的作用
在第三章中,你学习了var
关键字,它允许你以这样一种方式定义局部变量,即底层数据类型是在编译时根据初始赋值确定的(回想一下这被称为隐式类型化)。一旦进行了初始赋值,就有了一个强类型变量,任何试图赋值不兼容的值都会导致编译器错误。
要开始研究 C# dynamic
关键字,请创建一个名为 DynamicKeyword 的新控制台应用项目。现在,在您的Program
类中添加下面的方法,并验证如果取消注释,最终的代码语句确实会触发编译时错误:
static void ImplicitlyTypedVariable()
{
// a is of type List<int>.
var a = new List<int> {90};
// This would be a compile-time error!
// a = "Hello";
}
仅仅为了这样做而使用隐式类型被一些人认为是不好的风格(如果你知道你需要一个List<int>
,只需要声明一个List<int>
)。然而,正如你在第十三章中看到的,隐式类型对 LINQ 很有用,因为许多 LINQ 查询返回匿名类的枚举(通过投影),你不能在你的 C# 代码中直接声明。然而,即使在这种情况下,隐式类型变量实际上也是强类型的。
与此相关,正如你在第六章中了解到的,System.Object
是。NET 核心框架,可以代表任何东西。同样,如果你声明了一个类型为object
的变量,你就有了一个强类型的数据;但是,它在内存中指向的内容会因引用的赋值而不同。要访问内存中对象引用所指向的成员,需要执行显式强制转换。
假设您有一个名为Person
的简单类,它定义了两个自动属性(FirstName
和LastName
),这两个属性都封装了一个string
。现在,观察下面的代码:
static void UseObjectVariable()
{
// Assume we have a class named Person.
object o = new Person() { FirstName = "Mike", LastName = "Larson" };
// Must cast object as Person to gain access
// to the Person properties.
Console.WriteLine("Person's first name is {0}", ((Person)o).FirstName);
}
现在,回到dynamic
关键词。从高层次来看,您可以将dynamic
关键字视为System.Object
的一种特殊形式,因为任何值都可以赋给动态数据类型。乍一看,这可能会令人非常困惑,因为现在您似乎有三种方法来定义其基础类型没有在您的代码库中直接指示的数据。例如,这种方法
static void PrintThreeStrings()
{
var s1 = "Greetings";
object s2 = "From";
dynamic s3 = "Minneapolis";
Console.WriteLine("s1 is of type: {0}", s1.GetType());
Console.WriteLine("s2 is of type: {0}", s2.GetType());
Console.WriteLine("s3 is of type: {0}", s3.GetType());
}
如果从Main()
调用,将打印出以下内容:
s1 is of type: System.String
s2 is of type: System.String
s3 is of type: System.String
动态变量与隐式声明或通过System.Object
引用声明的变量有很大不同,因为它是而不是强类型的。换个方式说,动态数据不是静态类型化的。就 C# 编译器而言,用dynamic
关键字声明的数据点可以被赋予任何初始值,也可以在其生命周期内被重新赋予任何新的(也可能是不相关的)值。考虑以下方法和结果输出:
static void ChangeDynamicDataType()
{
// Declare a single dynamic data point
// named "t".
dynamic t = "Hello!";
Console.WriteLine("t is of type: {0}", t.GetType());
t = false;
Console.WriteLine("t is of type: {0}", t.GetType());
t = new List<int>();
Console.WriteLine("t is of type: {0}", t.GetType());
}
t is of type: System.String
t is of type: System.Boolean
t is of type: System.Collections.Generic.List`1[System.Int32]
在您研究的这一点上,请注意,如果您将t
变量声明为System.Object
,前面的代码会以相同的方式编译和执行。然而,你很快就会看到,dynamic
关键字提供了许多额外的功能。
对动态声明的数据调用成员
假设一个动态变量可以动态地呈现任何类型的身份(就像类型为System.Object
的变量一样),那么您想到的下一个问题可能是关于调用动态变量的成员(属性、方法、索引器、注册事件等)。).嗯,从语法上来说,它看起来也没有什么不同。只需对动态数据变量应用点运算符,指定一个公共成员,并提供任何参数(如果需要)。
然而(这是一个非常大的“然而”),你指定的成员的有效性不会被编译器检查!记住,与定义为System.Object
的变量不同,动态数据不是静态类型的。直到运行时,您才会知道您调用的动态数据是否支持指定的成员,您是否传入了正确的参数,您是否正确地拼写了成员,等等。因此,尽管看起来很奇怪,下面的方法可以完美地编译:
static void InvokeMembersOnDynamicData()
{
dynamic textData1 = "Hello";
Console.WriteLine(textData1.ToUpper());
// You would expect compiler errors here!
// But they compile just fine.
Console.WriteLine(textData1.toupper());
Console.WriteLine(textData1.Foo(10, "ee", DateTime.Now));
}
注意对WriteLine()
的第二次调用试图在动态数据点上调用名为toupper()
的方法(注意不正确的大小写——应该是ToUpper()
)。如您所见,textData1
的类型是string
,因此,您知道它没有一个全部用小写字母命名的方法。此外,string
肯定没有一个名为Foo()
的方法接受int
、string
和DateTime
对象!
尽管如此,C# 编译器还是满意的。然而,如果您从Main()
中调用这个方法,您将得到如下输出所示的运行时错误:
Unhandled Exception: Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
'string' does not contain a definition for 'toupper'
对动态数据和强类型数据调用成员的另一个明显区别是,当您对一段动态数据应用点运算符时,您将而不是看到预期的 Visual Studio IntelliSense。IDE 将允许您输入任何您能想到的成员名称。
智能感知对于动态数据是不可能的,这应该是有道理的。但是,请记住,这意味着当您在这样的数据点上键入 C# 代码时,您需要非常小心。成员的任何拼写错误或不正确的大写都会引发运行时错误,特别是RuntimeBinderException
类的实例。
RuntimeBinderException
表示一个错误,如果你试图调用一个实际上并不存在的动态数据类型的成员,就会抛出这个错误(就像在toupper()
和Foo()
方法的情况下)。如果您为确实存在的成员指定了错误的参数数据,也会引发同样的错误。
因为动态数据非常不稳定,所以每当您调用用 C# dynamic
关键字声明的变量的成员时,您可以将调用包装在适当的try
/ catch
块中,并以优雅的方式处理错误,如下所示:
static void InvokeMembersOnDynamicData()
{
dynamic textData1 = "Hello";
try
{
Console.WriteLine(textData1.ToUpper());
Console.WriteLine(textData1.toupper());
Console.WriteLine(textData1.Foo(10, "ee", DateTime.Now));
}
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex)
{
Console.WriteLine(ex.Message);
}
}
如果再调用这个方法,会发现对ToUpper()
(注意大写 T 和 U 的调用工作正常;但是,您会发现控制台上显示了错误数据。
HELLO
'string' does not contain a definition for 'toupper'
当然,将所有动态方法调用包装在一个try
/ catch
块中的过程相当繁琐。如果您注意拼写和参数传递,这不是必需的。但是,如果您事先不知道某个成员是否会出现在目标类型上,那么捕捉异常就非常方便。
动态关键字的范围
回想一下,隐式类型数据(用var
关键字声明)只可能用于成员范围内的局部变量。var
关键字永远不能用作返回值、参数或类/结构的成员。然而,dynamic
关键词却不是这种情况。考虑下面的类定义:
namespace DynamicKeyword
{
class VeryDynamicClass
{
// A dynamic field.
private static dynamic _myDynamicField;
// A dynamic property.
public dynamic DynamicProperty { get; set; }
// A dynamic return type and a dynamic parameter type.
public dynamic DynamicMethod(dynamic dynamicParam)
{
// A dynamic local variable.
dynamic dynamicLocalVar = "Local variable";
int myInt = 10;
if (dynamicParam is int)
{
return dynamicLocalVar;
}
else
{
return myInt;
}
}
}
}
您现在可以像预期的那样调用公共成员;但是,当您操作动态方法和属性时,您不能完全确定数据类型是什么!可以肯定的是,VeryDynamicClass
的定义在现实世界的应用中可能没有用,但是它确实说明了可以应用这个 C# 关键字的范围。
动态关键字的限制
虽然使用关键字dynamic
可以定义很多东西,但是它的用法有一些限制。虽然它们并不引人注目,但是要知道,在调用方法时,动态数据项不能使用 lambda 表达式或 C# 匿名方法。例如,下面的代码总是会导致错误,即使目标方法确实接受了一个委托参数,该参数接受一个string
值并返回void
:
dynamic a = GetDynamicObject();
// Error! Methods on dynamic data can't use lambdas!
a.Method(arg => Console.WriteLine(arg));
为了规避这个限制,你需要使用第十二章中描述的技术直接使用底层委托。另一个限制是数据的动态点不能理解任何扩展方法(参见第十一章)。不幸的是,这也将包括来自 LINQ API 的任何扩展方法。因此,用dynamic
关键字声明的变量在 LINQ 中的使用仅限于对象和其他 LINQ 技术。
dynamic a = GetDynamicObject();
// Error! Dynamic data can't find the Select() extension method!
var data = from d in a select d;
动态关键字的实际应用
假设动态数据不是强类型的,没有在编译时进行检查,没有能力触发智能感知,也不能成为 LINQ 查询的目标,那么您完全有理由认为仅仅为了这样做而使用dynamic
关键字是一种糟糕的编程实践。
然而,在少数情况下,dynamic
关键字可以从根本上减少您需要手工编写的代码量。具体来说,如果您正在构建一个大量使用延迟绑定(通过反射)的. NET 核心应用,那么dynamic
关键字可以节省您的输入时间。同样,如果您正在构建一个需要与遗留 COM 库(如微软 Office 产品)通信的. NET 核心应用,您可以通过dynamic
关键字极大地简化您的代码库。最后一个例子,使用 ASP.NET 核心构建的 web 应用经常使用ViewBag
类型,也可以使用dynamic
关键字以简化的方式访问。
Note
COM 交互严格来说是一种 Windows 范式,它消除了应用的跨平台能力。
像任何“捷径”一样,你需要权衡利弊。关键字dynamic
的使用是代码简洁和类型安全之间的权衡。虽然 C# 本质上是一种强类型语言,但您可以根据调用情况选择加入(或退出)动态行为。永远记住你永远不需要使用dynamic
关键字。您可以通过手工编写替代代码(通常更多)来获得相同的最终结果。
动态语言运行库的作用
现在您更好地理解了“动态数据”是什么,让我们学习它是如何处理的。自从发布以来。NET 4.0 中,公共语言运行时(CLR)补充了一个名为动态语言运行时的补充运行时环境。“动态运行时”的概念当然不是新的。事实上,许多编程语言如 JavaScript、LISP、Ruby 和 Python 已经使用它很多年了。简而言之,动态运行时允许动态语言在运行时完全发现类型,无需编译时检查。
Note
虽然大量的 DLR 被移植到。NET Core(从 3.0 开始),具有 DLR 之间的奇偶校验功能.NETCore 5 和。NET 4.8 还没做到。
如果你有强类型语言(包括 C#,没有动态类型)的背景,那么这种运行时的概念可能是不可取的。毕竟,您通常希望尽可能接收编译时错误,而不是运行时错误。然而,动态语言/运行时确实提供了一些有趣的特性,包括:
-
极其灵活的代码库。您可以重构代码,而无需对数据类型进行大量更改。
-
一种与不同平台和编程语言中构建的不同对象类型进行互操作的简单方法。
-
一种在运行时在内存中添加或移除类型成员的方法。
DLR 的一个作用是支持各种动态语言与。NET 运行库,并为它们提供了一种与其他。NET 代码。这些语言生活在一个动态的世界中,类型只在运行时被发现。然而,这些语言拥有丰富的。NET 基础类库。更好的是,由于包含了dynamic
关键字,它们的代码库可以与 C# 互操作(反之亦然)。
Note
本章不会讨论如何使用 DLR 来集成动态语言。
表达式树的作用
DLR 利用表达式树来捕捉中性术语中动态调用的含义。例如,以下面的 C# 代码为例:
dynamic d = GetSomeData();
d.SuperMethod(12);
在这个例子中,DLR 将自动构建一个表达式树,实际上就是“调用对象d
上名为SuperMethod
的方法,将数字12
作为参数传入”该信息(正式名称为有效负载)随后被传递给正确的运行时绑定器,该绑定器也可以是 C# 动态绑定器,甚至是(简单解释一下)遗留 COM 对象。
从这里,请求被映射到目标对象所需的调用结构中。这些表达式树的好处(除此之外,您不需要手动创建它们)是,这允许您编写固定的 C# 代码语句,而不用担心底层目标实际上是什么。
表达式树的动态运行时查找
如前所述,DLR 会将表达式树传递给目标对象;但是,这种调度会受到一些因素的影响。如果动态数据类型在内存中指向一个 COM 对象,则表达式树被发送到一个名为IDispatch
的低级 COM 接口。正如您可能知道的,这个接口是 COM 合并它自己的一组动态服务的方式。然而,COM 对象可以在不使用 DLR 或 C# dynamic
关键字的情况下在. NET 应用中使用。然而,这样做(正如您将看到的),往往会导致更复杂的 C# 编码。
如果动态数据没有指向 COM 对象,表达式树可以被传递给实现IDynamicObject
接口的对象。该接口在后台使用,允许诸如 IronRuby 之类的语言获取 DLR 表达式树并将其映射到 Ruby 细节。
最后,如果动态数据指向的对象是而不是COM 对象,并且而不是实现了IDynamicObject
,那么这个对象就是一个普通的日常对象。NET 对象。在这种情况下,表达式树被分派到 C# 运行时绑定器进行处理。将表达式树映射到。NET specifications 涉及反射服务。
在表达式树被给定的绑定器处理后,动态数据将被解析为真正的内存数据类型,之后使用任何必要的参数调用正确的方法。现在,让我们看看 DLR 的一些实际用途,从延迟绑定的简化开始.NET 电话。
使用动态类型简化延迟绑定调用
您可能决定使用dynamic
关键字的一个实例是当您使用反射服务时,特别是在进行延迟绑定方法调用时。在第十七章中,你看到了一些这种类型的方法调用有用的例子,最常见的是在你构建某种类型的可扩展应用时。那时,您学习了如何使用Activator.CreateInstance()
方法来创建一个object
,对此您没有任何编译时知识(除了它的显示名称)。然后,您可以利用System.Reflection
名称空间的类型通过延迟绑定来调用成员。回想一下第十七章中的例子:
static void CreateUsingLateBinding(Assembly asm)
{
try
{
// Get metadata for the Minivan type.
Type miniVan = asm.GetType("CarLibrary.MiniVan");
// Create the Minivan on the fly.
object obj = Activator.CreateInstance(miniVan);
// Get info for TurboBoost.
MethodInfo mi = miniVan.GetMethod("TurboBoost");
// Invoke method ("null" for no parameters).
mi.Invoke(obj, null);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
虽然这段代码像预期的那样工作,但您可能会认为它有点笨拙。您必须手动使用MethodInfo
类,手动查询元数据,等等。下面是同一方法的一个版本,现在使用 C# dynamic
关键字和 DLR:
static void InvokeMethodWithDynamicKeyword(Assembly asm)
{
try
{
// Get metadata for the Minivan type.
Type miniVan = asm.GetType("CarLibrary.MiniVan");
// Create the Minivan on the fly and call method!
dynamic obj = Activator.CreateInstance(miniVan);
obj.TurboBoost();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
通过使用dynamic
关键字声明obj
变量,反射的繁重工作由 DRL 代表您完成。
利用动态关键字传递参数
当您需要对接受参数的方法进行延迟绑定调用时,DLR 的用处会变得更加明显。当你使用“手写”反射调用时,参数需要打包成一个数组objects
,传递给MethodInfo
的Invoke()
方法。
为了使用新的示例进行说明,首先创建一个名为 LateBindingWithDynamic 的新 C# 控制台应用项目。接下来,添加一个名为 MathLibrary 的类库项目。将 MathLibrary 项目的初始文件Class1.cs
重命名为SimpleMath.cs
,并如下实现该类:
namespace MathLibrary
{
public class SimpleMath
{
public int Add(int x, int y)
{
return x + y;
}
}
}
用以下内容更新MathLibrary.csproj
文件(将编译后的程序集复制到LateBindingWithDynamic
目标目录):
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="copy $(TargetPath) $(SolutionDir)LateBindingWithDynamic\$(OutDir)$(TargetFileName) /Y
copy $(TargetPath) $(SolutionDir)LateBindingWithDynamic\$(TargetFileName) /Y" />
</Target>
Note
如果这些项目构建事件对您来说是新的,请回顾第十七章中的技术以获得完整的细节。
现在,回到 LateBindingWithDynamic 项目,将System.Reflection
和Microsoft.CSharp.RuntimeBinder
名称空间导入到Program.cs
文件中。接下来,将下面的方法添加到Program
类,该类使用典型的反射 API 调用来调用Add()
方法:
static void AddWithReflection()
{
Assembly asm = Assembly.LoadFrom("MathLibrary");
try
{
// Get metadata for the SimpleMath type.
Type math = asm.GetType("MathLibrary.SimpleMath");
// Create a SimpleMath on the fly.
object obj = Activator.CreateInstance(math);
// Get info for Add.
MethodInfo mi = math.GetMethod("Add");
// Invoke method (with parameters).
object[] args = { 10, 70 };
Console.WriteLine("Result is: {0}", mi.Invoke(obj, args));
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
现在,通过下面的新方法,考虑用关键字dynamic
简化前面的逻辑:
private static void AddWithDynamic()
{
Assembly asm = Assembly.LoadFrom("MathLibrary");
try
{
// Get metadata for the SimpleMath type.
Type math = asm.GetType("MathLibrary.SimpleMath");
// Create a SimpleMath on the fly.
dynamic obj = Activator.CreateInstance(math);
// Note how easily we can now call Add().
Console.WriteLine("Result is: {0}", obj.Add(10, 70));
}
catch (RuntimeBinderException ex)
{
Console.WriteLine(ex.Message);
}
}
不算太寒酸!如果您调用这两个方法,您将看到相同的输出。然而,当使用dynamic
关键字时,您为自己节省了相当多的工作。使用动态定义的数据,您不再需要手动将参数打包为对象数组、查询程序集元数据或其他类似的详细信息。如果您正在构建一个大量使用动态加载/延迟绑定的应用,我相信您可以看到这些代码节省是如何随着时间的推移而增加的。
使用动态数据简化 COM 互操作性(仅限 Windows)
让我们看一下在 COM 互用性项目的上下文中dynamic
关键字的另一个有用的例子。现在,如果您对 COM 开发没有太多的背景知识,请注意下一个例子,编译后的 COM 库包含元数据,就像. NET 核心库一样;但是,格式完全不同。正因为如此,如果一个. NET 核心程序需要与一个 COM 对象通信,首先要做的就是生成一个所谓的互操作程序集(将在下面的段落中描述)。这样做非常简单。
Note
如果您没有安装 Visual Studio Tools for Office(VSTO)单个组件或“Office/SharePoint development”工作负载,则需要这样做才能完成本节。您可以重新运行安装程序来选择缺少的组件,也可以使用 Visual Studio 快速启动(Ctrl+Q)。在快速启动中键入Visual Studio Tools for Office并选择安装选项。
首先,创建一个名为 ExportDataToOfficeApp 的新控制台应用,通过在解决方案资源管理器中右击该项目来激活“添加 COM 引用”对话框,然后选择“添加➤ COM 引用”。选中 COM 页签,找到你要使用的 COM 库,就是微软 Excel 16.0 对象库(见图 18-1 )。
图 18-1。
“添加引用”对话框的“COM”选项卡将显示计算机上所有注册的 COM 库
一旦选择了 COM 库,IDE 将通过生成包含。COM 元数据的. NET 描述。正式来说,这些被称为互用性程序集(或者简称为互操作程序集)。互操作程序集不包含任何实现代码,除了少量帮助将 COM 事件转换为.NETCore 事件。但是,这些互操作程序集是有用的,因为它们屏蔽了您的。NET 核心代码库来自 COM 内部的复杂底层。
在 C# 代码中,您可以直接针对 interop 程序集进行编程,该程序集映射。NET 核心数据类型转换为 COM 类型,反之亦然。在后台,数据在。NET 核心和 COM 应用使用运行时可调用包装器(RCW),这基本上是一个动态生成的代理。这个 RCW 代理将会整理和转换。NET 核心数据类型转换为 COM 类型,并将任何 COM 返回值映射到。核心等价物净额。
主互操作程序集的角色
许多由 COM 库供应商创建的 COM 库(例如允许访问 Microsoft Office 产品的对象模型的 Microsoft COM 库)提供了一个“官方”的互操作程序集,称为主互操作程序集 (PIA)。pia 是优化的互操作程序集,它清理(并可能扩展)通常在使用“添加引用”对话框引用 COM 库时生成的代码。
引用 Microsoft Excel 16.0 对象库后,在解决方案资源管理器中检查项目。在 Dependencies 节点下,您将看到一个新节点(COM ),其中包含一个名为 interop . Microsoft . office . interop . excel 的项。
嵌入互操作元数据
在发布之前。NET 4.0 中,当 C# 应用使用 COM 库(PIA 或其他)时,您需要确保客户端计算机在其计算机上有一个 interop 程序集的副本。这不仅增加了应用安装程序包的大小,而且安装脚本必须检查 PIA 程序集是否确实存在,如果不存在,就将一个副本安装到全局程序集缓存(GAC)中。
Note
全局程序集缓存是。NET framework 程序集。它不再用于。NET 核心。
然而,随着。NET 4.0 和更高版本,您现在可以将互用性数据直接嵌入到您编译的应用中。当您这样做时,您不再需要随您的一起提供互用性程序集的副本。NET 核心应用,因为必要的互用性元数据是硬编码在程序中的。和。NET 核心,嵌入 PIA 是必需的。
要使用 Visual Studio 嵌入 PIA,请展开项目下的“依赖项”节点,展开“COM”节点,右键单击“互操作”。然后选择属性。在属性对话框中,将嵌入互操作类型的值更改为 Yes,如图 18-2 所示。
图 18-2。
嵌入互操作类型
要通过项目文件改变属性,添加<EmbedInteropTypes>True</EmbedInteropTypes >
,如下图所示:
<ItemGroup>
<COMReference Include="Microsoft.Office.Excel.dll">
<Guid>00020813-0000-0000-c000-000000000046</Guid>
<VersionMajor>1</VersionMajor>
<VersionMinor>9</VersionMinor>
<WrapperTool>tlbimp</WrapperTool>
<Lcid>0</Lcid>
<Isolated>false</Isolated>
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
</ItemGroup>
C# 编译器将只包含您正在使用的互操作库部分。因此,如果真正的互操作库。NET 核心描述数百个 COM 对象,您将只引入您在 C# 代码中真正使用的子集的定义。除了减少必须部署的文件大小,您还有一个更容易的安装路径,因为您不需要在目标机器上安装任何缺失的 pia。
常见的 COM 互操作难点
许多 COM 库定义了采用可选参数的方法,这在 C# 中直到才得到支持。净 3.5。这要求您为可选参数的每次出现指定值Type.Missing
。谢天谢地有了。NET 3.5 及以上(包括。NET Core),如果您没有指定一个特定的值,Type.Missing
值将在编译时被插入。
与此相关的是,许多 COM 方法提供了对命名参数的支持,正如你在第四章中回忆的那样,它允许你以任何你需要的顺序将值传递给成员。假设 C# 支持这个相同的特性,很容易“跳过”一组您不关心的可选参数,只设置您关心的几个参数。
另一个常见的 COM 互操作痛点是,许多 COM 方法被设计为接受和返回一个特定的数据类型,称为Variant
。很像 C# dynamic
关键字,Variant
数据类型可以动态地分配给任何类型的 COM 数据(字符串、接口引用、数值等)。).在拥有dynamic
关键字之前,传递或接收Variant
数据点需要一些跳跃,通常是通过大量的转换操作。
当您将“嵌入互操作类型”属性设置为 True 时,所有 COM Variant
类型都会自动映射到动态数据。这不仅会减少在处理底层 COM Variant
数据类型时对额外转换操作的需求,还会进一步隐藏一些 COM 复杂性,比如处理 COM 索引器。
使用 COM Interop 和。NET 5 缺乏构建和运行时支持。那个。MSBuild . NET 5 版本无法解析互操作库,因此。使用 COM interop 的. NET Core 项目不能使用。NET Core CLI。它们必须使用 Visual Studio 构建,并且编译后的可执行文件可以按预期运行。
使用 C# 动态数据的 COM 互操作
为了展示 C# 可选参数、命名参数和dynamic
关键字如何一起简化 COM 互操作,现在您将构建一个使用 Microsoft Office 对象模型的应用。添加包含以下代码的新类名Car.cs
:
namespace ExportDataToOfficeApp
{
public class Car
{
public string Make { get; set; }
public string Color { get; set; }
public string PetName { get; set; }
}
}
接下来,将以下using
语句添加到Program.cs
的顶部:
using System;
using System.Collections.Generic;
using System.Reflection;
using Excel = Microsoft.Office.Interop.Excel;
using ExportDataToOfficeApp;
请注意 Excel 命名空间别名。虽然在与 COM 库交互时不需要定义别名,但它为所有导入的 COM 对象提供了一个缩短的限定符。这不仅减少了输入,还可以解决 COM 对象的名称与.NETCore 类型。
// Create an alias to the Excel object model.
using Excel = Microsoft.Office.Interop.Excel;
接下来,在Program.cs
的顶层语句中创建一个Car
记录列表:
List<Car> carsInStock = new List<Car>
{
new Car {Color="Green", Make="VW", PetName="Mary"},
new Car {Color="Red", Make="Saab", PetName="Mel"},
new Car {Color="Black", Make="Ford", PetName="Hank"},
new Car {Color="Yellow", Make="BMW", PetName="Davie"}
};
因为您使用 Visual Studio 导入了 COM 库,所以 PIA 已被自动配置,以便所使用的元数据将被嵌入到。NET 核心应用。因此,所有 COM Variant
数据类型都实现为dynamic
数据类型。此外,您可以使用 C# 可选参数和命名参数。考虑下面的ExportToExcel()
实现:
void ExportToExcel(List<Car> carsInStock)
{
// Load up Excel, then make a new empty workbook.
Excel.Application excelApp = new Excel.Application();
excelApp.Workbooks.Add();
// This example uses a single workSheet.
Excel._Worksheet workSheet = (Excel._Worksheet)excelApp.ActiveSheet;
// Establish column headings in cells.
workSheet.Cells[1, "A"] = "Make";
workSheet.Cells[1, "B"] = "Color";
workSheet.Cells[1, "C"] = "Pet Name";
// Now, map all data in List<Car> to the cells of the spreadsheet.
int row = 1;
foreach (Car c in carsInStock)
{
row++;
workSheet.Cells[row, "A"] = c.Make;
workSheet.Cells[row, "B"] = c.Color;
workSheet.Cells[row, "C"] = c.PetName;
}
// Give our table data a nice look and feel.
workSheet.Range["A1"].AutoFormat(Excel.XlRangeAutoFormat.xlRangeAutoFormatClassic2);
// Save the file, quit Excel, and display message to user.
workSheet.SaveAs($@"{Environment.CurrentDirectory}\Inventory.xlsx");
excelApp.Quit();
Console.WriteLine("The Inventory.xslx file has been saved to your app folder");
}
该方法首先将 Excel 加载到内存中;但是,您不会在电脑桌面上看到它。对于这个应用,您只对使用内部 Excel 对象模型感兴趣。但是,如果您确实想显示 Excel 的用户界面,请用下面的代码行更新您的方法:
static void ExportToExcel(List<Car> carsInStock)
{
// Load up Excel, then make a new empty workbook.
Excel.Application excelApp = new Excel.Application();
// Go ahead and make Excel visible on the computer.
excelApp.Visible = true;
...
}
创建一个空工作表后,添加三列,它们的名称类似于Car
类的属性。然后,用List<Car>
的数据填充单元格,并以(硬编码的)名称Inventory.xlsx
保存文件。
此时,如果您运行您的应用,您将能够打开Inventory.xlsx
文件,该文件将被保存到项目的\bin\Debug\net5.0
文件夹中。
虽然在前面的代码中似乎没有使用任何动态数据,但要知道 DLR 提供了重要的帮助。如果没有 DLR,代码应该是这样的:
static void ExportToExcelManual(List<Car> carsInStock)
{
Excel.Application excelApp = new Excel.Application();
// Must mark missing params!
excelApp.Workbooks.Add(Type.Missing);
// Must cast Object as _Worksheet!
Excel._Worksheet workSheet =
(Excel._Worksheet)excelApp.ActiveSheet;
// Must cast each Object as Range object then call low-level Value2 property!
((Excel.Range)excelApp.Cells[1, "A"]).Value2 = "Make";
((Excel.Range)excelApp.Cells[1, "B"]).Value2 = "Color";
((Excel.Range)excelApp.Cells[1, "C"]).Value2 = "Pet Name";
int row = 1;
foreach (Car c in carsInStock)
{
row++;
// Must cast each Object as Range and call low-level Value2 prop!
((Excel.Range)workSheet.Cells[row, "A"]).Value2 = c.Make;
((Excel.Range)workSheet.Cells[row, "B"]).Value2 = c.Color;
((Excel.Range)workSheet.Cells[row, "C"]).Value2 = c.PetName;
}
// Must call get_Range method and then specify all missing args!
excelApp.get_Range("A1", Type.Missing).AutoFormat(
Excel.XlRangeAutoFormat.xlRangeAutoFormatClassic2,
Type.Missing, Type.Missing, Type.Missing,
Type.Missing, Type.Missing, Type.Missing);
// Must specify all missing optional args!
workSheet.SaveAs(
$@"{Environment.CurrentDirectory}\InventoryManual.xlsx",
Type.Missing, Type.Missing, Type.Missing,
Type.Missing, Type.Missing, Type.Missing,
Type.Missing, Type.Missing, Type.Missing);
excelApp.Quit();
Console.WriteLine("The InventoryManual.xslx file has been saved to your app folder");
}
这就结束了你对 C# dynamic
关键字和 DLR 的研究。我希望您能看到这些特性如何简化复杂的编程任务,并且(也许更重要的是)理解其中的利弊。当您选择使用动态数据时,您确实失去了大量的类型安全性,并且您的代码库容易出现更多的运行时错误。
虽然关于 DLR 肯定还有更多要说的,但本章试图将重点放在日常编程中实用和有用的主题上。如果您想了解更多关于动态语言运行库的高级功能,如与脚本语言集成,请务必参考。NET Core SDK 文档(查阅主题“动态语言运行时概述”开始)。
摘要
关键字dynamic
允许你定义直到运行时才知道其身份的数据。当由动态语言运行时处理时,自动创建的“表达式树”将被传递到正确的动态语言绑定器,在那里有效负载将被解包并发送到正确的对象成员。
使用动态数据和 DLR,可以从根本上简化复杂的 C# 编程任务,尤其是将 COM 库合并到。NET 核心应用。正如您在本章中看到的,这为 COM interop 提供了许多进一步的简化(与动态数据无关),例如将 COM interop 数据嵌入到您的应用、可选参数和命名参数中。
虽然这些特性确实可以简化您的代码,但是请记住,动态数据会使您的 C# 代码的类型安全性大大降低,并且容易出现运行时错误。一定要权衡在 C# 项目中使用动态数据的利弊,并进行相应的测试!
十九、理解 CIL 和动态程序集的作用
当你建造一个全尺寸的。NET 核心应用,鉴于 C#(或类似的托管语言,如 Visual Basic)固有的生产力和易用性,您肯定会使用它。然而,正如您在本书开头所学的,托管编译器的作用是将*.cs
代码文件翻译成 CIL 代码、类型元数据和汇编指令清单。事实证明,CIL 是一个成熟的。NET 核心编程语言,有自己的语法、语义和编译器(ilasm.exe
)。
在这一章中,你将参观。网芯的母语。在这里,你会明白 CIL 指令、CIL 属性和 CIL 操作码之间的区别。然后,您将了解. NET 核心程序集和各种 CIL 编程工具的往返工程的作用。本章的剩余部分将带你了解使用 CIL 语法定义命名空间、类型和成员的基本知识。本章将以对名称空间System.Reflection.Emit
的角色的检查结束,并解释如何在运行时动态地构造一个汇编(用 CIL 指令)。
当然,很少有程序员需要在日常工作中使用原始的 CIL 代码。因此,本章一开始,我将研究一下为什么要了解这个底层的语法和语义。NET 核心语言可能值得你花时间。
学习 CIL 语法的动机
CIL 语是美国人真正的母语。NET 核心平台。当您使用您选择的托管语言(C#、VB、F# 等)构建. NET 核心程序集时。),相关的编译器将你的源代码翻译成 CIL。像任何编程语言一样,CIL 提供了许多结构化和以实现为中心的标记。鉴于 CIL 只是另一个。NET 核心编程语言,所以构建您的。NET 核心汇编直接使用 CIL 和 CIL 编译器(ilasm.exe
)。
Note
如第一章所述,ildasm.exe
和ilasm.exe
都不附带。NET 5 运行时。获得这些工具有两种选择。首先是编译。NET 5 运行时从位于 https://github.com/dotnet/runtime
的源代码。第二种,也是更容易的方法,是从 www.nuget.org
中下拉想要的版本。在 NuGet 上 ILDasm 的 URL 是 https://www.nuget.org/packages/Microsoft.NETCore.ILDAsm/
,对于ILAsm.exe
是 https://www.nuget.org/packages/Microsoft.NETCore.ILAsm/
。确保选择正确的版本(对于本书,您需要 5.0.0 或更高版本)。使用以下命令将 ILDasm 和 ILAsm NuGet 包添加到项目中:
微软的 dotnet 添加包。NETCore.ILDAsm -版本 5.0.0
微软的 dotnet 添加包。NETCore.ILAsm -版本 5.0.0
这实际上并没有将ILDasm.exe
或ILAsm.exe
添加到您的项目中,而是将它们放在您的包文件夹中(在 Windows 上):
%userprofile%\。nu get \ packages \ Microsoft . netcore . ilasm \ 5 . 0 . 0 \ runtimes \ native \
%userprofile%\。nu get \ packages \ Microsoft . netcore . ildasm \ 5 . 0 . 0 \ runtimes \ native \
我还将这两个程序的 5.0.0 版本包含在本书的 GitHub repo 的第十九章文件夹中。
现在虽然这是事实,很少(如果有的话!)程序员会选择构建一个完整的。NET 核心应用直接与 CIL,CIL 仍然是一个极其有趣的智力追求。简单地说,你对 CIL 的语法理解得越多,你就越有能力进入高级领域。净核心开发。通过一些具体的例子,了解 CIL 教的个人能够做到以下几点:
-
拆卸现有的。NET 核心程序集,编辑 CIL 代码,并将更新后的代码库重新编译为修改后的。网芯二进制。例如,在某些情况下,您可能需要修改 CIL 来与一些高级 COM 功能进行互操作。
-
使用
System.Reflection.Emit
名称空间构建动态程序集。这个 API 允许您在内存中生成一个。NET 核心程序集,它可以选择保存到磁盘上。对于需要动态生成程序集的工具构建者来说,这是一种非常有用的技术。 -
理解高级管理语言不支持但在 CIL 级别存在的 cts 方面。可以肯定的是,CIL 是唯一的。NET 核心语言,允许您访问 CTS 的各个方面。例如,使用原始 CIL,您可以定义全局级别的成员和字段(这在 C# 中是不允许的)。
同样,非常清楚的是,如果你选择而不是来关注 CIL 代码的细节,你仍然能够掌握 C# 和。NET 核心基本类库。在许多方面,CIL 的知识类似于 C(和 C++)程序员对汇编语言的理解。那些知道底层“goo”的来龙去脉的人可以为手头的任务创建更高级的解决方案,并对底层编程(和运行时)环境有更深入的理解。所以,如果你准备好迎接挑战,让我们开始研究 CIL 的细节。
Note
理解这一章并不打算是 CIL 语法和语义的全面处理。
检查 CIL 指令、属性和操作码
当你开始研究像 CIL 这样的低级语言时,你肯定会为熟悉的概念找到新的(通常听起来吓人的)名称。例如,在文本的这一点上,如果向您显示以下一组项目:
{new, public, this, base, get, set, explicit, unsafe, enum, operator, partial}
您肯定会认为它们是 C# 语言的关键字(这是正确的)。但是,如果您更仔细地观察这个集合的成员,您可能会发现虽然每个条目确实是一个 C# 关键字,但是它具有完全不同的语义。例如,enum
关键字定义了一个System.Enum
派生的类型,而this
和base
关键字允许你分别引用当前对象或对象的父类。unsafe
关键字用于建立一个不能被 CLR 直接监控的代码块,而operator
关键字允许你构建一个隐藏的(特别命名的)方法,当你应用一个特定的 C# 操作符(比如加号)时,这个方法将被调用。
与 C# 这样的高级语言形成鲜明对比的是,CIL 本身并不只是简单地定义一组通用的关键字。相反,CIL 编译器所理解的标记集根据语义被细分为以下三大类:
-
cil 指令
-
CIL 属性
-
CIL 操作码
每一类 CIL 令牌都使用特定的语法来表示,并且这些令牌被组合起来以构建有效的。NET 程序集。
CIL 指令的作用
首先,有一组众所周知的 CIL 标记,用于描述. NET 程序集的整体结构。这些令牌被称为指令。CIL 指令用于通知 CIL 编译器如何定义将填充程序集的命名空间、类型和成员。
指令在语法上使用单个点(.
)前缀(例如.namespace
、.class
、.publickeytoken
、.method
、.assembly
等)来表示。).因此,如果您的*.il
文件(包含 CIL 代码的文件的传统扩展名)有一个.namespace
指令和三个.class
指令,CIL 编译器将生成一个定义单个。NET 核心命名空间包含三个。NET 核心类类型。
CIL 属性的作用
在许多情况下,CIL 指令本身的描述性不足以完全表达给定的定义。NET 类型或类型成员。鉴于这一事实,许多 CIL 指令可以进一步指定各种 CIL 属性来限定指令应该如何被处理。例如,.class
指令可以用public
属性(建立类型可见性)、extends
属性(显式指定类型的基类)和implements
属性(列出该类型支持的接口集)来修饰。
Note
不要混淆. NET 属性和 CIL 属性,这是两个非常不同的概念。
CIL 操作码的作用
一旦使用各种指令和相关属性按照 CIL 定义了. NET 核心程序集、命名空间和类型集,剩下的最后一项任务就是提供类型的实现逻辑。这是操作码,或者简称为操作码的工作。在其他低级语言的传统中,许多 CIL 操作码往往是神秘的,对于我们这些普通人来说完全无法发音。例如,如果你需要将一个string
变量加载到内存中,你不需要使用一个友好的操作码LoadString
,而是使用ldstr
。
现在,公平地说,一些 CIL 操作码确实非常自然地映射到它们的 C# 对应物(例如,box
、unbox
、throw
和sizeof
)。正如您将看到的,CIL 的操作码总是在成员的实现范围内使用,并且不像 CIL 指令,它们从不带有点前缀。
CIL 操作码/CIL 助记符的区别
如前所述,操作码如ldstr
用于实现给定类型的成员。然而,像ldstr
这样的记号是实际的二进制 CIL 操作码的 CIL 助记符。为了澄清区别,假设您已经在名为 FirstSamples 的. NET 核心控制台应用中用 C# 编写了以下方法:
int Add(int x, int y)
{
return x + y;
}
两个数相加的行为用 CIL 操作码0X58
来表示。类似地,用操作码0X59
来表示减去两个数字,并且使用0X73
操作码来实现在托管堆上分配新对象的动作。鉴于这一现实,请理解由 JIT 编译器处理的“CIL 代码”只不过是二进制数据块。
谢天谢地,对于 CIL 的每一个二进制操作码,都有相应的助记符。例如,可以使用add
助记符而不是0X58
、sub
而不是0X59
、newobj
而不是0X73
。考虑到操作码/助记符的区别,CIL 反编译器如ildasm.exe
将汇编的二进制操作码翻译成相应的 CIL 助记符。例如,这里是ildasm.exe
为之前的 C# Add()
方法提供的 CIL(根据您的版本,您的确切输出可能会有所不同.NETCore):
.method assembly hidebysig static int32 Add(int32 x,int32 y) cil managed
{
// Code size 9 (0x9)
.maxstack 2
.locals init ([0] int32 int32 V_0)
IL_0000: /* 00 | */ nop
IL_0001: /* 02 | */ ldarg.0
IL_0002: /* 03 | */ ldarg.1
IL_0003: /* 58 | */ add
IL_0004: /* 0A | */ stloc.0
IL_0005: /* 2B | 00 */ br.s IL_0007
IL_0007: /* 06 | */ ldloc.0
IL_0008: /* 2A | */ ret
} //end of method
除非你是建一些极低级的。NET 核心软件(如定制的托管编译器),你将永远不需要关心自己的文字数字二进制操作码的 CIL。实际上,当。NET 核心程序员谈论“CIL 操作码”,他们指的是一组友好的字符串标记助记符(正如我在本文中所做的,并将在本章的剩余部分中做的),而不是底层的数值。
推进和弹出:CIL 基于堆栈的本质
更高级的。NET 核心语言(如 C#)试图尽可能隐藏低级的 CIL 垃圾。的一方面。NET 核心开发中隐藏得特别好的一点是,CIL 是一种基于堆栈的编程语言。回想一下对集合名称空间的检查(参见第十章),Stack<T>
类可以用来将一个值压入堆栈,也可以将最顶端的值弹出堆栈以供使用。当然,CIL 开发人员不会使用类型为Stack<T>
的对象来加载和卸载要评估的值;然而,同样的推动和弹出心态仍然适用。
从形式上讲,用来保存一组待评估值的实体被称为虚拟执行堆栈。正如您将看到的,CIL 提供了几个操作码,用于将一个值推送到堆栈上;这个过程被称为装载。同样,CIL 定义了额外的操作码,这些操作码使用称为存储的过程将栈顶的值转移到内存中(比如一个局部变量)。
在 CIL 的世界里,不可能直接访问一个数据点,包括本地定义的变量、传入的方法参数或某种类型的字段数据。相反,您需要显式地将该项加载到堆栈中,然后弹出它以备后用(记住这一点,因为它将有助于解释为什么给定的 CIL 代码块看起来有点多余)。
Note
回想一下,CIL 不是直接执行的,而是按需编译的。在编译 CIL 代码的过程中,许多实现冗余被优化掉了。此外,如果为当前项目启用代码优化选项(使用 Visual Studio 项目属性窗口的“生成”选项卡),编译器还将移除各种 CIL 冗余。
为了理解 CIL 如何利用基于堆栈的处理模型,考虑一个简单的 C# 方法PrintMessage()
,它没有参数,返回void
。在这个方法的实现中,您只需将一个本地字符串变量的值打印到标准输出流中,如下所示:
void PrintMessage()
{
string myMessage = "Hello.";
Console.WriteLine(myMessage);
}
如果您要研究 C# 编译器如何根据 CIL 来翻译这个方法,您首先会发现PrintMessage()
方法使用.locals
指令为局部变量定义了一个存储槽。然后使用ldstr
(加载字符串)和stloc.0
操作码(可以理解为“将当前值存储在存储槽零的局部变量中”)加载并存储局部字符串。
然后,使用ldloc.0
(“在索引 0 处加载本地参数”)操作码将该值(同样在索引 0 处)加载到内存中,以供System.Console.WriteLine()
方法调用(使用call
操作码指定)使用。最后,函数通过ret
操作码返回。下面是PrintMessage()
方法的(带注释的)CIL 代码(注意,为了简洁起见,我已经从清单中删除了nop
操作码):
.method assembly hidebysig static void PrintMessage() cil managed
{
.maxstack 1
// Define a local string variable (at index 0).
.locals init ([0] string V_0)
// Load a string onto the stack with the value "Hello."
ldstr " Hello."
// Store string value on the stack in the local variable.
stloc.0
// Load the value at index 0.
ldloc.0
// Call method with current value.
call void [System.Console]System.Console::WriteLine(string)
ret
}
Note
如您所见,CIL 支持使用双斜线语法(以及/*...*/
语法)的代码注释。和 C# 一样,CIL 编译器完全忽略代码注释。
现在您已经有了 CIL 指令、属性和操作码的基础,让我们看看 CIL 编程的实际应用,从往返工程的主题开始。
了解往返工程
你知道如何使用ildasm.exe
来查看 C# 编译器生成的 CIL 代码(参见第一章)。然而,您可能不知道的是,ildasm.exe
允许您将加载到ildasm.exe
的程序集中包含的 CIL 转储到外部文件。一旦你有了 CIL 代码,你就可以使用 CIL 编译器ilasm.exe
自由地编辑和重新编译代码库。
从形式上来说,这种技术被称为往返工程,它在特定的情况下很有用,例如:
-
您需要修改不再有源代码的程序集。
-
你正在和一个不完美的人一起工作。NET 核心语言编译器发出了无效(或完全不正确)的 CIL 代码,并且您想要修改代码库。
-
您正在构建一个 COM 互用性库,并希望解决在转换过程中丢失的一些 COM IDL 属性(如 COM
[helpstring]
属性)。
为了说明往返过程,首先创建一个新的 C#。NET 核心控制台应用命名为往返使用。NET 核心命令行界面(CLI)。
dotnet new console -lang c# -n RoundTrip -o .\RoundTrip -f net5.0
将顶级语句更新为以下内容:
// A simple C# console app.
Console.WriteLine("Hello CIL code!");
Console.ReadLine();
使用。NET Core CLI。
dotnet build
Note
回忆一下第一章。NET 核心程序集(类库或控制台应用)被编译成扩展名为*.dll
的程序集。它们是使用。NET Core CLI。新进。NET Core 3+(及更高版本),将dotnet.exe
文件复制到输出目录中,并重命名以匹配程序集名称。因此,虽然看起来像是你的项目被编译到了RoundTrip.exe,
,但是它被编译到了RoundTrip.dll
,而dotnet.exe
被复制到了RoundTrip.exe
,同时还有执行Roundtrip.dll.
所需的命令行参数
接下来使用以下命令对RoundTrip.dll
执行ildasm.exe
(从解决方案文件夹级别执行):
ildasm /all /METADATA /out=.\RoundTrip\RoundTrip.il .\RoundTrip\bin\Debug\net5.0\RoundTrip.dll
Note
ildasm.exe
在将汇编的内容转储到文件时也会生成一个*.res
文件。在本章中,这些资源文件可以被忽略(和删除),因为您不会用到它们。该文件包含一些低级别的 CLR 安全信息(以及其他信息)。
现在您可以使用您选择的文本编辑器查看RoundTrip.il
。下面是(稍微重新格式化和注释的)结果:
// Referenced assemblies.
.assembly extern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A)
.ver 5:0:0:0
}
.assembly extern System.Console
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 5:0:0:0
}
// Our assembly.
.assembly RoundTrip
{
...
.hash algorithm 0x00008004
.ver 1:0:0:0
}
.module RoundTrip.dll
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003
.corflags 0x00000001
// Definition of Program class.
.class private abstract auto ansi beforefieldinit '<Program>$'
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= ( 01 00 00 00 )
.method private hidebysig static void '<Main>$'(string[] args) cil managed
{
// Marks this method as the entry point of the executable.
.entrypoint
.maxstack 8
IL_0000: ldstr "Hello CIL code!"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: nop
IL_000b: call string [System.Console]System.Console::ReadLine()
IL_0010: pop
IL_0011: ret
} // end of method '<Program>$'::'<Main>$'
} // end of class '<Program>$'
首先,请注意,*.il
文件是通过声明每个外部引用的程序集来打开的,当前程序集是针对该程序集编译的。如果你的类库在其他引用的程序集中使用了额外的类型(除了System.Runtime
和System.Console)
,你会发现额外的.assembly extern
指令。
接下来,您会发现使用各种 CIL 指令(例如.module
、.imagebase
等)描述的RoundTrip.dll
程序集的正式定义。).
在记录外部引用的程序集并定义当前程序集之后,您会发现一个从顶级语句创建的Program
类型的定义。请注意,.class
指令有各种属性(其中许多是可选的),如这里所示的extends
,它标记了该类型的基类:
.class private abstract auto ansi beforefieldinit '<Program>$'
extends [System.Runtime]System.Object
{ ... }
大部分 CIL 代码代表了类的默认构造函数和自动生成的Main()
方法的实现,这两者都是用.method
指令定义的(部分)。一旦使用正确的指令和属性定义了成员,就可以使用各种操作码来实现它们。
了解这一点非常重要。NET 核心类型(比如 CIL 的System.Console
),你会总是需要使用该类型的完全限定名。此外,类型的完全限定名必须始终以定义程序集的友好名称为前缀(在方括号中)。考虑下面的Main()
的 CIL 实现:
.method private hidebysig static void '<Main>$'(string[] args) cil managed
{
// Marks this method as the entry point of the executable.
.entrypoint
.maxstack 8
IL_0000: ldstr "Hello CIL code!"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: nop
IL_000b: call string [System.Console]System.Console::ReadLine()
IL_0010: pop
IL_0011: ret
} // end of method '<Program>$'::'<Main>$'
CIL 代码标签的作用
你肯定注意到的一件事是每一行实现代码。
以形式为IL_XXX:
的标记为前缀(例如IL_0000:
、IL_0001:
等)。).这些标记被称为代码标签,可以以您选择的任何方式命名(前提是它们在同一个成员范围内不重复)。当您使用ildasm.exe
将一个程序集转储到文件时,它将自动生成遵循IL_XXX:
命名约定的代码标签。但是,您可以更改它们以反映更具描述性的标记。这里有一个例子:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
Nothing_1: nop
Load_String: ldstr "Hello CIL code!"
PrintToConsole: call void [System.Console]System.Console::WriteLine(string)
Nothing_2: nop
WaitFor_KeyPress: call string [System.Console]System.Console::ReadLine()
RemoveValueFromStack: pop
Leave_Function: ret
}
事实是,大多数代码标签是完全可选的。代码标签唯一真正必需的时候是在你编写使用各种分支或循环结构的 CIL 代码的时候,因为你可以通过这些代码标签指定逻辑流向哪里。对于当前示例,您可以完全删除这些自动生成的标签,而不会产生不良影响,如下所示:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
nop
ldstr "Hello CIL code!"
call void [System.Console]System.Console::WriteLine(string)
nop
call string [System.Console]System.Console::ReadLine()
pop
ret
}
与 CIL 互动:修改一个*。il 文件
现在,您对基本的 CIL 文件是如何构成的有了更好的理解,让我们完成往返实验。这里的目标很简单:改变输出到控制台的消息。您可以做更多的事情,比如添加程序集引用或创建新的类和方法,但我们将保持简单。
要进行更改,您需要改变顶级语句的当前实现,创建为<Main>$
方法。在*.il
文件中找到这个方法,并将消息改为“Hello from altered CIL 代码!”
实际上,您已经更新了 CIL 代码,以对应下面的 C# 类定义:
static void Main(string[] args)
{
Console.WriteLine("Hello from altered CIL code!");
Console.ReadLine();
}
编译 CIL 代码
以前版本的。NET 允许你使用ilasm.exe.
编译*.il
文件,这在。NET 核心。要编译*.il
文件,您必须使用一个Microsoft.NET.Sdk.IL
项目类型,在撰写本文时,这仍然不是标准 SDK 的一部分。
首先在您的机器上创建一个新目录。在这个目录中,创建一个global.json
文件。global.json
文件适用于当前目录及其下的所有子目录。它用于定义运行时将使用哪个 SDK 版本。NET Core CLI 命令。将文件更新为以下内容:
{
"msbuild-sdks": {
"Microsoft.NET.Sdk.IL": "5.0.0-preview.1.20120.5"
}
}
下一步是创建项目文件。创建一个名为RoundTrip.ilproj
的文件,并将其更新为以下内容:
<Project Sdk="Microsoft.NET.Sdk.IL">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<MicrosoftNetCoreIlasmPackageVersion>5.0.0-preview.1.20120.5</MicrosoftNetCoreIlasmPackageVersion>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
</PropertyGroup>
</Project>
最后,将更新后的RoundTrip.il
文件复制到目录中。使用编译该程序集。NET Core CLI:
dotnet build
您将在常用的bin\debug\net5.0
文件夹中找到结果文件。此时,您可以运行您的新应用了。果然,您将看到控制台窗口中显示更新的消息。虽然这个简单示例的输出并不那么壮观,但它确实说明了编程在 CIL 的一个实际应用:往返。
了解 CIL 指令和属性
现在您已经了解了如何转换。NET 核心程序集编译成 IL 并将 IL 编译成程序集之后,您就可以着手检查 CIL 本身的语法和语义了。接下来的几节将带您完成创作包含一组类型的自定义名称空间的过程。然而,为了简单起见,这些类型不会包含其成员的任何实现逻辑。在理解了如何创建空类型之后,就可以将注意力转向使用 CIL 操作码定义“真实”成员的过程了。
在 CIL 指定外部引用的程序集
在一个新目录中,复制上一个示例中的global.json
和NuGet.config
文件。创建一个名为CILTypes.ilproj,
的新项目文件,并将其更新为:
<Project Sdk="Microsoft.NET.Sdk.IL">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<MicrosoftNetCoreIlasmPackageVersion>5.0.0-preview.1.20120.5</MicrosoftNetCoreIlasmPackageVersion>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
</PropertyGroup>
</Project>
接下来,使用您选择的编辑器创建一个名为CILTypes.il
的新文件。CIL 项目要求的第一项任务是列出当前程序集使用的外部程序集。对于这个例子,您将只使用在System.Runtime.dll
中找到的类型。为此,将使用external
属性来限定.assembly
指令。当你引用一个强命名的程序集时,比如System.Runtime.dll
,你会想要指定.publickeytoken
和.ver
指令,就像这样:
.assembly extern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 5:0:0:0
}
定义 CIL 的当前程序集
下一步是使用.assembly
指令定义您感兴趣的程序集。在最简单的层次上,可以通过指定二进制文件的友好名称来定义程序集,如下所示:
// Our assembly.
.assembly CILTypes { }
虽然这确实定义了一个新的。NET 核心程序集,您通常会在程序集声明的范围内放置附加指令。对于本例,使用.ver
指令更新您的程序集定义以包括版本号 1.0.0.0(注意每个数字标识符由冒号分隔,而不是以 C# 为中心的点符号),如下所示:
// Our assembly.
.assembly CILTypes
{
.ver 1:0:0:0
}
假设CILTypes
程序集是一个单文件程序集,您将使用下面的单个.module
指令来完成程序集定义,该指令标记了您的。CILTypes.dll
净二进制:
.assembly CILTypes
{
.ver 1:0:0:0
}
// The module of our single-file assembly.
.module CILTypes.dll
除了.assembly
和.module
之外,CIL 指令进一步限定了的整体结构。您正在编写的. NET 二进制文件。表 19-1 列出了一些更常见的汇编级指令。
表 19-1。
其他以程序集为中心的指令
|管理的
|
生命的意义
|
| — | — |
| .mresources
| 如果您的程序集使用内部资源(如位图或字符串表),此指令用于标识包含要嵌入的资源的文件的名称。 |
| .subsystem
| 这个 CIL 指令用于建立程序集希望在其中执行的首选用户界面。例如,2
的值表示程序集应该在 GUI 应用中运行,而3
的值表示控制台可执行程序。 |
在 CIL 中定义名称空间
既然已经定义了程序集的外观(以及所需的外部引用),就可以使用.namespace
指令创建一个. NET 核心命名空间(MyNamespace
),如下所示:
// Our assembly has a single namespace.
.namespace MyNamespace {}
像 C# 一样,CIL 命名空间定义可以嵌套在更多的命名空间中。这里不需要定义根命名空间;然而,为了便于讨论,假设您想要创建以下名为MyCompany
的根名称空间:
.namespace MyCompany
{
.namespace MyNamespace {}
}
像 C# 一样,CIL 允许您定义嵌套的名称空间,如下所示:
// Defining a nested namespace.
.namespace MyCompany.MyNamespace {}
在 CIL 定义分类类型
空的名称空间并不十分有趣,所以现在让我们看看使用 CIL 定义类类型的过程。毫不奇怪,.class
指令被用来定义一个新的类。但是,这个简单的指令可以用许多附加属性来修饰,以进一步限定类型的性质。举例来说,向名为MyBaseClass
的名称空间添加一个公共类。和 C# 一样,如果你不指定一个显式基类,你的类型将自动从System.Object
派生。
.namespace MyNamespace
{
// System.Object base class assumed.
.class public MyBaseClass {}
}
当您构建一个从除了System.Object
之外的任何类派生的类类型时,您使用extends
属性。每当您需要引用在同一程序集中定义的类型时,CIL 要求您也使用完全限定名(但是,如果基类型在同一程序集中,您可以省略程序集的友好名称前缀)。因此,以下扩展MyBaseClass
的尝试会导致编译器错误:
// This will not compile!
.namespace MyNamespace
{
.class public MyBaseClass {}
.class public MyDerivedClass
extends MyBaseClass {}
}
为了正确定义MyDerivedClass
的父类,您必须指定MyBaseClass
的全名,如下所示:
// Better!
.namespace MyNamespace
{
.class public MyBaseClass {}
.class public MyDerivedClass
extends MyNamespace.MyBaseClass {}
}
除了public
和extends
属性之外,CIL 类定义可能会采用许多额外的限定符来控制类型的可见性、字段布局等等。表 19-2 说明了一些(但不是全部)可能与.class
指令结合使用的属性。
表 19-2。
与.class
指令结合使用的各种属性
属性
|
生命的意义
|
| — | — |
| public
、private
、nested assembly
、nested famandassem
、nested family
、nested famorassem
、nested public
、nested private
| CIL 定义了各种属性,用于指定给定类型的可见性。正如你所看到的,除了 C# 提供的,原始 CIL 还提供了许多其他的可能性。如有兴趣,请参考 ECMA 335 了解详情。 |
| abstract
,sealed
| 这两个属性可以附加到一个.class
指令上,分别定义一个抽象类或密封类。 |
| auto
、sequential
、explicit
| 这些属性用于指示 CLR 如何在内存中布置字段数据。对于类类型,默认布局标志(auto
)是合适的。如果您需要使用 P/Invoke 来调用非托管 C 代码,更改此默认值会很有帮助。 |
| extends
,implements
| 这些属性允许你定义一个类型的基类(通过extends
)或者实现一个类型的接口(通过implements
)。 |
在 CIL 中定义和实现接口
看起来很奇怪,接口类型在 CIL 是用.class
指令定义的。然而,当.class
指令用interface
属性修饰时,该类型被实现为 CTS 接口类型。一旦定义了一个接口,就可以使用 CIL implements
属性将它绑定到一个类或结构类型,如下所示:
.namespace MyNamespace
{
// An interface definition.
.class public interface IMyInterface {}
// A simple base class.
.class public MyBaseClass {}
// MyDerivedClass now implements IMyInterface,
// and extends MyBaseClass.
.class public MyDerivedClass
extends MyNamespace.MyBaseClass
implements MyNamespace.IMyInterface {}
}
Note
extends
子句必须在implements
子句之前。同样,implements
子句可以包含逗号分隔的接口列表。
正如你在第十章中回忆的那样,接口可以作为其他接口类型的基础接口来构建接口层次结构。然而,与您的想法相反,extends
属性不能用于从接口 b 派生接口 A。extends
属性仅用于限定类型的基类。当您想要扩展一个接口时,您将再次使用implements
属性。这里有一个例子:
// Extending interfaces in terms of CIL.
.class public interface IMyInterface {}
.class public interface IMyOtherInterface
implements MyNamespace.IMyInterface {}
定义 CIL 的结构
如果类型扩展了System.ValueType
,则.class
指令可用于定义 CTS 结构。同样,.class
指令必须用sealed
属性限定(因为结构永远不能成为其他值类型的基础结构)。如果你试图不这样做,ilasm.exe
将发布一个编译错误。
// A structure definition is always sealed.
.class public sealed MyStruct
extends [System.Runtime]System.ValueType{}
请注意,CIL 提供了一种定义结构类型的简写符号。如果您使用value
属性,新类型将自动从[System.Runtime]System.ValueType
派生类型。因此,您可以将MyStruct
定义如下:
// Shorthand notation for declaring a structure.
.class public sealed value MyStruct{}
在 CIL 定义枚举
。NET Core 枚举(正如您回忆的那样)源自System.Enum
,它是一个System.ValueType
(因此也必须是密封的)。当你想用 CIL 定义一个枚举时,只需扩展[System.Runtime]System.Enum
,就像这样:
// An enum.
.class public sealed MyEnum
extends [System.Runtime]System.Enum{}
像结构定义一样,枚举可以用简写符号使用enum
属性来定义。这里有一个例子:
// Enum shorthand.
.class public sealed enum MyEnum{}
稍后您将看到如何指定枚举的名称-值对。
在 CIL 定义仿制药
泛型类型在 CIL 语法中也有特定的表示。回想一下第十章中,一个给定的泛型类型或泛型成员可能有一个或多个类型参数。例如,List<T>
类型只有一个类型参数,而Dictionary<TKey, TValue>
有两个。就 CIL 而言,类型参数的数量是使用一个向后倾斜的单引号(```cs)来指定的,后跟一个表示类型参数数量的数值。像 C# 一样,类型参数的实际值被放在尖括号内。
Note
在美式键盘上,您可以在 Tab 键上方的键上找到`字符(在 1 键的左侧)。
例如,假设您想要创建一个List<T>
变量,其中T
的类型是System.Int32
。在 C# 中,您应该键入以下内容:
void SomeMethod()
{
List<int> myInts = new List<int>();
}
```cs
在 CIL,您将编写以下代码(它可能出现在任何 CIL 方法范围内):
// In C#: List myInts = new List();
newobj instance void class [System.Collections]
System.Collections.Generic.List`1::.ctor()
注意,这个泛型类被定义为`List`1<int32>`,因为`List<T>`只有一个类型参数。然而,如果你需要定义一个`Dictionary<string, int>`类型,你可以这样做:
// In C#: Dictionary<string, int> d = new Dictionary<string, int>();
newobj instance void class [System.Collections]
System.Collections.Generic.Dictionary`2<string,int32>
::.ctor()
作为另一个示例,如果您有一个使用另一个泛型类型作为类型参数的泛型类型,您将编写如下所示的 CIL 代码:
// In C#: List<List> myInts = new List<List>();
newobj instance void class [mscorlib]
System.Collections.Generic.List1<class [System.Collections] System.Collections.Generic.List
1>
::.ctor()
### 编译 CILTypes.il 文件
即使您还没有向您定义的类型添加任何成员或实现代码,您也能够将这个`*.il`文件编译成一个. NET 核心 DLL 程序集(这是您必须做的,因为您还没有指定一个`Main()`方法)。打开命令提示符,输入以下命令:
dotnet build
这样做之后,现在可以在`ildasm.exe`中打开编译后的程序集来验证每个类型的创建。要理解如何用内容填充类型,首先需要研究 CIL 的基本数据类型。
## 。NET 基类库、C# 和 CIL 数据类型映射
表 19-3 说明了. NET 基类类型如何映射到相应的 C# 关键字,以及每个 C# 关键字如何映射到原始 CIL。同样,表 19-3 记录了用于每种 CIL 类型的简写常量符号。正如你马上会看到的,这些常量经常被许多 CIL 操作码引用。
表 19-3。
映射。NET 基类类型转换为 C# 关键字,C# 关键字转换为 CIL
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"> <col class="tcol3 align-left"> <col class="tcol4 align-left"></colgroup>
|
。NET Core 基类类型
|
C# 关键字
|
CIL 代表
|
CIL 常量符号
|
| --- | --- | --- | --- |
| `System.SByte` | `sbyte` | `int8` | `I1` |
| `System.Byte` | `byte` | `unsigned int8` | `U1` |
| `System.Int16` | `short` | `int16` | `I2` |
| `System.UInt16` | `ushort` | `unsigned int16` | `U2` |
| `System.Int32` | `int` | `int32` | `I4` |
| `System.UInt32` | `uint` | `unsigned int32` | `U4` |
| `System.Int64` | `long` | `int64` | `I8` |
| `System.UInt64` | `ulong` | `unsigned int64` | `U8` |
| `System.Char` | `char` | `char` | `CHAR` |
| `System.Single` | `float` | `float32` | `R4` |
| `System.Double` | `double` | `float64` | `R8` |
| `System.Boolean` | `bool` | `bool` | `BOOLEAN` |
| `System.String` | `string` | `string` | 不适用的 |
| `System.Object` | `object` | `object` | 不适用的 |
| `System.Void` | `void` | `void` | `VOID` |
Note
`System.IntPtr`和`System.UIntPtr`类型映射到本机`int`和本机`unsigned int`(知道这一点很好,因为许多 COM 互操作性和 P/Invoke 场景广泛使用这些)。
## 在 CIL 中定义类型成员
正如你已经知道的。NET 核心类型可以支持各种成员。枚举有一些名称-值对。结构和类可能有构造函数、字段、方法、属性、静态成员等等。在本书的前 18 章中,您已经看到了前面提到的项目的部分 CIL 定义,但是尽管如此,这里还是快速回顾一下各种成员如何映射到 CIL 原语。
### 定义 CIL 的字段数据
枚举、结构和类都可以支持字段数据。在每种情况下,都将使用`.field`指令。例如,让我们给骨架`MyEnum`枚举注入一些活力,并定义以下三个名称-值对(注意这些值是在括号内指定的):
.class public sealed enum MyEnum
{
.field public static literal valuetype
MyNamespace.MyEnum A = int32(0)
.field public static literal valuetype
MyNamespace.MyEnum B = int32(1)
.field public static literal valuetype
MyNamespace.MyEnum C = int32(2)
}
使用`static`和`literal`属性限定位于. NET 核心`System.Enum`派生类型范围内的字段。正如您所猜测的,这些属性将字段数据设置为可从类型本身访问的固定值(例如,`MyEnum.A`)。
Note
分配给枚举值的值也可以是带有`0x`前缀的十六进制值。
当然,当您想要在类或结构中定义一个字段数据点时,您并不局限于一个公共静态文本数据点。例如,您可以更新`MyBaseClass`来支持两点私有的、实例级的字段数据,设置为默认值。
.class public MyBaseClass
{
.field private string stringField = “hello!”
.field private int32 intField = int32(42)
}
与 C# 一样,类字段数据将自动初始化为适当的默认值。如果您希望允许对象用户在创建私有字段数据的每个点时提供自定义值,您(当然)需要创建自定义构造函数。
### 在 CIL 中定义类型构造函数
CTS 支持实例级和类级(静态)构造函数。根据 CIL,实例级构造函数使用`.ctor`标记表示,而静态级构造函数通过`.cctor`(类构造函数)表示。两个 CIL 令牌都必须使用`rtspecialname`(返回类型特殊名称)和`specialname`属性进行限定。简而言之,这些属性用于标识一个特定的 CIL 令牌,该令牌可以由给定的。NET 核心语言。例如,在 C# 中,构造函数不定义返回类型;然而,就 CIL 而言,构造函数的返回值确实是`void`。
.class public MyBaseClass
{
.field private string stringField
.field private int32 intField
.method public hidebysig specialname rtspecialname
instance void .ctor(string s, int32 i) cil managed
{
// TODO: Add implementation code…
}
}
注意,`.ctor`指令已经用`instance`属性限定了(因为它不是一个静态构造函数)。`cil managed`属性表示该方法的范围包含 CIL 代码,而不是非托管代码,这些代码可能会在平台调用请求期间使用。
### 在 CIL 定义属性
属性和方法也有特定的 CIL 表示。举例来说,如果`MyBaseClass`被更新以支持名为`TheString`的公共属性,您将编写以下 CIL(再次注意`specialname`属性的使用):
.class public MyBaseClass
{
…
.method public hidebysig specialname
instance string get_TheString() cil managed
{
// TODO: Add implementation code…
}
.method public hidebysig specialname
instance void set_TheString(string ‘value’) cil managed
{
// TODO: Add implementation code…
}
.property instance string TheString()
{
.get instance string
MyNamespace.MyBaseClass::get_TheString()
.set instance void
MyNamespace.MyBaseClass::set_TheString(string)
}
}
根据 CIL,一个属性映射到一对带有前缀`get_`和`set_`的方法。`.property`指令利用相关的`.get`和`.set`指令将属性语法映射到正确的“专门命名的”方法。
Note
请注意,属性的`set`方法的传入参数放在单引号中,单引号表示在方法范围内赋值运算符右侧使用的标记的名称。
### 定义成员参数
简而言之,在 CIL 中指定参数(或多或少)与在 C# 中相同。例如,每个参数都是通过指定其数据类型,后跟参数名称来定义的。此外,像 C# 一样,CIL 提供了一种定义输入、输出和按引用传递参数的方法。同样,CIL 允许你定义一个参数数组实参(又名 C# `params`关键字),以及可选的参数。
为了说明在原始 CIL 中定义参数的过程,假设您想要构建一个方法,该方法采用一个`int32`(按值)、一个`int32`(按引用)、一个`[mscorlib]System.Collection.ArrayList`和一个单一输出参数(类型为`int32`)。就 C# 而言,此方法类似于以下内容:
public static void MyMethod(int inputInt,
ref int refInt, ArrayList ar, out int outputInt)
{
outputInt = 0; // Just to satisfy the C# compiler…
}
如果您将这个方法映射到 CIL 术语中,您会发现 C# 引用参数用一个&符号(`&`)标记,其后缀是参数的基础数据类型(`int32&`)。
输出参数也使用`&`后缀,但是它们使用 CIL `[out]`标记进一步限定。还要注意,如果参数是引用类型(在本例中是`[mscorlib]System.Collections.ArrayList`类型),那么`class`标记将作为数据类型的前缀(不要与`.class`指令混淆!).
.method public hidebysig static void MyMethod(int32 inputInt,
int32& refInt,
class [System.Runtime.Extensions]System.Collections.ArrayList ar,
[out] int32& outputInt) cil managed
{
…
}
## 检查 CIL 操作码
你将在本章研究的 CIL 码的最后一个方面与各种操作码(操作码)的作用有关。回想一下,操作码只是一个 CIL 令牌,用于为给定成员构建实现逻辑。完整的 CIL 操作码集(很大)可以分为以下几大类:
* 控制程序流程的操作码
* 计算表达式的操作码
* 访问内存值的操作码(通过参数、局部变量等。)
为了通过 CIL 提供对成员实现世界的一些洞察,表 19-4 定义了一些与成员实现逻辑相关的更有用的操作码,按相关功能分组。
表 19-4。
各种特定于实现的 CIL 操作码
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
操作码
|
生命的意义
|
| --- | --- |
| `add`、`sub`、`mul`、`div`、`rem` | 这些 CIL 操作码允许你加、减、乘、除两个值(`rem`返回除法运算的余数)。 |
| `and`、`or`、`not`、`xor` | 这些 CIL 操作码允许您对两个值执行逐位运算。 |
| `ceq`、`cgt`、`clt` | 这些 CIL 操作码允许你以不同的方式比较堆栈上的两个值。以下是一些例子:`ceq`:比较是否相等`cgt`:比较大于`clt`:比较小于 |
| `box`,`unbox` | 这些 CIL 操作码用于在引用类型和值类型之间进行转换。 |
| `Ret` | 这个 CIL 操作码用于退出一个方法并向调用者返回值(如果需要的话)。 |
| `beq`、`bgt`、`ble`、`blt`、`switch` | 这些 CIL 操作码(以及许多其他相关操作码)用于控制方法内的分支逻辑。以下是一些例子:`beq`:如果相等,则断开代码标签`bgt`:如果大于,则断开代码标签`ble`:如果小于或等于,则中断到代码标签`blt`:如果小于,则中断到代码标签所有以分支为中心的操作码都要求您指定一个 CIL 代码标签,以便在测试结果为真时跳转到该标签。 |
| `Call` | 这个 CIL 操作码用于调用给定类型的成员。 |
| `newarr`,`newobj` | 这些 CIL 操作码允许你分配一个新的数组或新的对象类型到内存中。 |
下一大类 CIL 操作码(其子集如表 19-5 所示)用于将参数加载(推送)到虚拟执行堆栈上。请注意这些特定于加载的操作码是如何使用`ld` (load)前缀的。
表 19-5。
CIL 的主要堆栈中心操作码
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
操作码
|
生命的意义
|
| --- | --- |
| `ldarg`(变化多端) | 将方法的参数加载到堆栈上。除了一般的`ldarg`(它与标识参数的给定索引一起工作),还有许多其他的变化。例如,`ldarg`带有数字后缀(`ldarg.0`)的操作码硬编码加载哪个参数。同样,`ldarg`操作码的变体允许您使用表 19-4 ( `ldarg_I4`表示`int32`)中所示的 CIL 常量符号硬编码数据类型,以及数据类型和值(`ldarg_I4_5`,用值`5`加载`int32`)。 |
| `ldc`(变化多端) | 将常量值加载到堆栈上。 |
| `ldfld`(变化多端) | 将实例级字段的值加载到堆栈上。 |
| `ldloc`(变化多端) | 将局部变量的值加载到堆栈上。 |
| `Ldobj` | 获取由基于堆的对象收集的所有值,并将它们放在堆栈上。 |
| `Ldstr` | 将字符串值加载到堆栈上。 |
除了一组特定于加载的操作码,CIL 还提供了许多操作码,*显式地*弹出栈顶的值。如本章前几个例子所示,从堆栈中弹出一个值通常涉及到将该值存储到临时本地存储中以备将来使用(如即将到来的方法调用的参数)。鉴于此,请注意有多少从虚拟执行堆栈中弹出当前值的操作码带有`st`(存储)前缀。表 19-6 击中亮点。
表 19-6。
各种以流行为中心的操作码
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
操作码
|
生命的意义
|
| --- | --- |
| `Pop` | 移除当前位于计算堆栈顶部的值,但不存储该值 |
| `Starg` | 将堆栈顶部的值存储到指定索引处的方法参数中 |
| `stloc`(变化多端) | 从计算堆栈顶部弹出当前值,并将其存储在指定索引处的局部变量列表中 |
| `Stobj` | 将指定类型的值从计算堆栈复制到提供的内存地址中 |
| `Stsfld` | 用计算堆栈中的值替换静态字段的值 |
请注意,各种 CIL 操作码将*隐式*从堆栈中弹出值来执行手头的任务。例如,如果您试图使用`sub`操作码减去两个数字,应该清楚的是`sub`必须弹出下两个可用值才能执行计算。一旦计算完成,值(surprise,surprise)的结果再次被压入堆栈。
### 那个。maxstack 指令
当您使用原始 CIL 编写方法实现时,您需要注意一个名为`.maxstack`的特殊指令。顾名思义,`.maxstack`建立了在方法执行期间的任何给定时间可以被推到堆栈上的变量的最大数量。好消息是,`.maxstack`指令有一个默认值(`8`,对于您可能正在创作的绝大多数方法来说,这应该是安全的。但是,如果您想要显式的,您可以手动计算堆栈上局部变量的数量,并显式定义该值,如下所示:
.method public hidebysig instance void
Speak() cil managed
{
// During the scope of this method, exactly
// 1 value (the string literal) is on the stack.
.maxstack 1
ldstr “Hello there…”
call void [mscorlib]System.Console::WriteLine(string)
ret
}
### 在 CIL 声明局部变量
让我们先看看如何声明一个局部变量。假设您想在 CIL 构建一个名为`MyLocalVariables()`的方法,该方法不带参数并返回`void`。在这个方法中,您想要定义三个类型为`System.String`、`System.Int32`和`System.Object`的局部变量。在 C# 中,此成员将如下所示(回想一下,局部作用域的变量不接收默认值,应该在进一步使用之前设置为初始状态):
public static void MyLocalVariables()
{
string myStr = “CIL code is fun!”;
int myInt = 33;
object myObj = new object();
}
如果您要在 CIL 直接构建`MyLocalVariables()`,您可以编写以下代码:
.method public hidebysig static void
MyLocalVariables() cil managed
{
.maxstack 8
// Define three local variables.
.locals init (string myStr, int32 myInt, object myObj)
// Load a string onto the virtual execution stack.
ldstr “CIL code is fun!”
// Pop off current value and store in local variable [0].
stloc.0
// Load a constant of type “i4”
// (shorthand for int32) set to the value 33.
ldc.i4.s 33
// Pop off current value and store in local variable [1].
stloc.1
// Create a new object and place on stack.
newobj instance void [mscorlib]System.Object::.ctor()
// Pop off current value and store in local variable [2].
stloc.2
ret
}
在原始 CIL 中分配局部变量的第一步是使用`.locals`指令,它与`init`属性成对出现。每个变量由其数据类型和可选的变量名来标识。定义局部变量后,将值加载到堆栈上(使用各种以加载为中心的操作码)并将值存储在局部变量中(使用各种以存储为中心的操作码)。
### 将参数映射到 CIL 的本地变量
您已经看到了如何使用`.locals init`指令在原始 CIL 中声明局部变量;但是,您还没有看到如何将传入的参数映射到本地方法。考虑下面的静态 C# 方法:
public static int Add(int a, int b)
{
return a + b;
}
就 CIL 而言,这种看似无辜的方法有很多话要说。首先,必须使用`ldarg`(加载参数)操作码将传入的参数(`a`和`b`)推送到虚拟执行堆栈上。接下来,`add`操作码将用于从堆栈中弹出接下来的两个值,找到总和并将值再次存储在堆栈中。最后,这个总和被弹出堆栈,并通过`ret`操作码返回给调用者。如果您使用`ildasm.exe`反汇编这个 C# 方法,您会发现构建过程注入了许多额外的标记,但是 CIL 代码的关键非常简单。
.method public hidebysig static int32 Add(int32 a,
int32 b) cil managed
{
.maxstack 2
ldarg.0 // Load “a” onto the stack.
ldarg.1 // Load “b” onto the stack.
add // Add both values.
ret
}
### 隐藏此引用
注意,假定虚拟执行堆栈从位置 0 开始索引,那么两个传入参数(`a`和`b`)在 CIL 代码中使用它们的索引位置(索引 0 和索引 1)来引用。
在检查或创作 CIL 代码时要注意的一点是,每个接受传入参数的非静态方法都会自动接收一个隐式附加参数,该参数是对当前对象的引用(如 C# `this`关键字)。鉴于此,如果将`Add()`方法定义为*非静态*,就像这样:
// No longer static!
public int Add(int a, int b)
{
return a + b;
}
然后使用`ldarg.1`和`ldarg.2`加载传入的`a`和`b`参数(而不是预期的`ldarg.0`和`ldarg.1`操作码)。同样,原因是槽 0 包含隐式的`this`引用。考虑下面的伪代码:
// This is JUST pseudocode!
.method public hidebysig static int32 AddTwoIntParams(
MyClass_HiddenThisPointer this, int32 a, int32 b) cil managed
{
ldarg.0 // Load MyClass_HiddenThisPointer onto the stack.
ldarg.1 // Load “a” onto the stack.
ldarg.2 // Load “b” onto the stack.
…
}
### 在 CIL 中表示迭代构造
C# 编程语言中的迭代结构使用`for`、`foreach`、`while`和`do`关键字来表示,其中每一个关键字在 CIL 中都有特定的表示。考虑下面这个经典的`for`循环:
public static void CountToTen()
{
for(int i = 0; i < 10; i++)
{
}
}
现在,你可能还记得,`br`操作码(`br`、`blt`等)。)用于在满足某些条件时控制流动的中断。在本例中,您已经设置了一个条件,当本地变量`i`等于或大于值 10 时,`for`循环应该中断。每次通过时,值 1 被加到`i`,此时再次评估测试条件。
还记得当您使用任何 CIL 分支操作码时,您将需要定义一个特定的代码标签(或两个)来标记当条件确实为真时跳转到的位置。考虑到这几点,思考下面通过`ildasm.exe`生成的(编辑过的)CIL 代码(包括自动生成的代码标签):
.method public hidebysig static void CountToTen() cil managed
{
.maxstack 2
.locals init (int32 V_0, bool V_1)
IL_0000: ldc.i4.0 // Load this value onto the stack.
IL_0001: stloc.0 // Store this value at index “0”.
IL_0002: br.s IL_000b // Jump to IL_0008.
IL_0003: ldloc.0 // Load value of variable at index 0.
IL_0004: ldc.i4.1 // Load the value “1” on the stack.
IL_0005: add // Add current value on the stack at index 0.
IL_0006: stloc.0
IL_0007: ldloc.0 // Load value at index “0”.
IL_0008: ldc.i4.s 10 // Load value of “10” onto the stack.
IL_0009: clt // check less than value on the stack
IL_000a: stloc.1 // Store result at index “1”
IL_000b: ldloc.1 // Load value at index “1”
IL_000c: brtrue.s IL_0002 // if true jump back to IL_0002
IL_000d: ret
}
简而言之,这段 CIL 代码从定义局部变量`int32`并将其加载到堆栈开始。此时,您在代码标签`IL_0008`和`IL_0004`之间来回跳转,每次将`i`的值增加 1,并测试`i`是否仍然小于值 10。如果是,则退出该方法。
### 关于 CIL 的最后一句话
现在您已经看到了从一个`*.IL`文件创建可执行文件的过程,您可能会想“这是一个可怕的工作量”,然后想知道“有什么好处?”对于绝大多数人来说,您永远不会从 IL 创建一个. NET 核心可执行文件。但是,如果您试图深入研究一个没有源代码的程序集,能够理解 IL 会很有帮助。
也有一些商业项目可以将. NET 核心程序集逆向工程成源代码。如果你曾经使用过这些工具,现在你知道它们是如何工作的了!
## 了解动态程序集
可以肯定的是,建造一个综合体的过程。NET 核心应用在 CIL 将是相当爱的劳动。一方面,CIL 是一种极具表现力的编程语言,它允许你与 cts 允许的所有编程结构进行交互。另一方面,创作原始 CIL 是单调乏味、容易出错和痛苦的。诚然,知识就是力量,但你可能真的想知道记住 CIL 语法法则有多重要。答案是“看情况。”可以肯定的是,你的大部分。NET 核心编程工作不需要您查看、编辑或创作 CIL 代码。然而,有了 CIL 初级读本,你现在可以研究动态程序集的世界(相对于静态程序集)和`System.Reflection.Emit`名称空间的角色了。
你可能有的第一个问题是“静态和动态程序集之间到底有什么区别?”根据定义,*静态程序集*是。NET 二进制文件直接从磁盘存储中加载,这意味着当 CLR 请求它们时,它们位于硬盘上的某个物理文件中(或者在多文件程序集的情况下可能是一组文件)。正如你可能猜到的,每次你编译你的 C# 源代码,你都会得到一个静态汇编。
另一方面,*动态程序集*是使用`System.Reflection.Emit`名称空间提供的类型在内存中动态创建的。`System.Reflection.Emit`名称空间使得在*运行时*创建一个程序集及其模块、类型定义和 CIL 实现逻辑成为可能。完成之后,您就可以将内存中的二进制文件保存到磁盘上了。这当然会产生一个新的静态程序集。可以肯定的是,使用`System.Reflection.Emit`命名空间构建动态程序集的过程确实需要对 CIL 操作码的本质有一定程度的理解。
尽管创建动态程序集是一项高级(且不常见)的编程任务,但它们在各种情况下都很有用。这里有一个例子:
* 您正在构建一个需要根据用户输入按需生成程序集的. NET 编程工具。
* 您正在构建一个程序,该程序需要根据获得的元数据动态生成远程类型的代理。
* 您希望加载静态程序集并将新类型动态插入二进制映像。
让我们来看看`System.Reflection.Emit`中的类型。
### 探索系统。Reflection.Emit 命名空间
创建一个动态程序集需要你对 CIL 操作码有一些熟悉,但是`System.Reflection.Emit`命名空间的类型尽可能地隐藏了 CIL 的复杂性。例如,不用指定必要的 CIL 指令和属性来定义类类型,您可以简单地使用`TypeBuilder`类。同样,如果您想定义一个新的实例级构造函数,您不需要发出`specialname`、`rtspecialname`或`.ctor`标记;相反,你可以使用`ConstructorBuilder`。表 19-7 记录了`System.Reflection.Emit`名称空间的关键成员。
表 19-7。
选择`System.Reflection.Emit`名称空间的成员
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
成员
|
生命的意义
|
| --- | --- |
| `AssemblyBuilder` | 用于在运行时创建一个装配(`*.dll`或`*.exe`)。`*.exe` s 必须调用`ModuleBuilder.SetEntryPoint()`方法来设置作为模块入口点的方法。如果没有指定入口点,将会生成一个`*.dll`。 |
| `ModuleBuilder` | 用于定义当前程序集中的模块集。 |
| `EnumBuilder` | 用于创建. NET 枚举类型。 |
| `TypeBuilder` | 可用于在运行时在模块内创建类、接口、结构和委托。 |
| `MethodBuilder LocalBuilder PropertyBuilder FieldBuilder ConstructorBuilder CustomAttributeBuilder ParameterBuilder EventBuilder` | 用于在运行时创建类型成员(如方法、局部变量、属性、构造函数和特性)。 |
| `ILGenerator` | 向给定的类型成员发出 CIL 操作码。 |
| `OpCodes` | 提供了许多映射到 CIL 操作码的字段。该类型与`System.Reflection.Emit.ILGenerator`的各种成员一起使用。 |
一般来说,`System.Reflection.Emit`名称空间的类型允许您在动态程序集的构造过程中以编程方式表示原始 CIL 标记。您将在下面的示例中看到许多这样的成员;然而,`ILGenerator`型值得一试。
### 系统的作用。Reflection.Emit.ILGenerator
顾名思义,`ILGenerator`类型的作用是将 CIL 操作码注入给定的类型成员。但是,您不能直接创建`ILGenerator`对象,因为这种类型没有公共构造函数;相反,您通过调用以构建者为中心的类型的特定方法(例如`MethodBuilder`和`ConstructorBuilder`类型)来接收一个`ILGenerator`类型。这里有一个例子:
// Obtain an ILGenerator from a ConstructorBuilder
// object named “myCtorBuilder”.
ConstructorBuilder myCtorBuilder = /* */;
ILGenerator myCILGen = myCtorBuilder.GetILGenerator();
0
一旦你手中有了一个`ILGenerator`,你就可以用任何方法发出原始的 CIL 操作码。表 19-8 记录了`ILGenerator`的一些(但不是全部)方法。
表 19-8。
`ILGenerator`的各种方法
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
方法
|
生命的意义
|
| --- | --- |
| `BeginCatchBlock()` | 开始一个`catch`块 |
| `BeginExceptionBlock()` | 开始异常的异常范围 |
| `BeginFinallyBlock()` | 开始一个`finally`块 |
| `BeginScope()` | 开始词法范围 |
| `DeclareLocal()` | 声明一个局部变量 |
| `DefineLabel()` | 声明一个新标签 |
| `Emit()` | 被重载多次,以允许您发出 CIL 操作码 |
| `EmitCall()` | 将一个`call`或`callvirt`操作码推入 CIL 流 |
| `EmitWriteLine()` | 用不同类型的值发出对`Console.WriteLine()`的调用 |
| `EndExceptionBlock()` | 结束异常块 |
| `EndScope()` | 结束词法范围 |
| `ThrowException()` | 发出引发异常的指令 |
| `UsingNamespace()` | 指定用于计算当前活动词法范围的局部变量和监视器的命名空间 |
`ILGenerator`的关键方法是`Emit()`,它与`System.Reflection.Emit.OpCodes`类类型一起工作。正如本章前面提到的,这种类型公开了大量映射到原始 CIL 操作码的只读字段。所有这些成员都记录在联机帮助中,您将在接下来的页面中看到各种示例。
### 发出动态程序集
为了说明在运行时定义. NET 核心程序集的过程,让我们浏览一下创建单文件动态程序集的过程。在这个集合中有一个名为`HelloWorld`的类。`HelloWorld`类支持一个默认的构造函数和一个自定义的构造函数,用于为`string`类型的私有成员变量(`theMessage`)赋值。此外,`HelloWorld`支持一个名为`SayHello()`的公共实例方法,它向标准 I/O 流打印问候,还支持另一个名为`GetMsg()`的实例方法,它返回内部私有字符串。实际上,您将通过编程生成以下类类型:
// This class will be created at runtime
// using System.Reflection.Emit.
public class HelloWorld
{
private string theMessage;
HelloWorld() {}
HelloWorld(string s) {theMessage = s;}
public string GetMsg() {return theMessage;}
public void SayHello()
{
System.Console.WriteLine(“Hello from the HelloWorld class!”);
}
}
假设您已经创建了一个名为 DynamicAsmBuilder 的新控制台应用项目,并添加了系统。发出 NuGet 包。接下来,导入`System.Reflection`和`System.Reflection.Emit`名称空间。在`Program`类中定义一个名为`CreateMyAsm()`的静态方法。这种方法监督以下内容:
* 定义动态程序集的特征(名称、版本等。)
* 实现`HelloClass`类型
* 将 AssemblyBuilder 返回到调用方法
下面是完整的代码,并附有分析:
static AssemblyBuilder CreateMyAsm()
{
// Establish general assembly characteristics.
AssemblyName assemblyName = new AssemblyName
{
Name = “MyAssembly”,
Version = new Version(“1.0.0.0”)
};
// Create new assembly.
var builder = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,AssemblyBuilderAccess.Run);
// Define the name of the module
.
ModuleBuilder module =
builder.DefineDynamicModule(“MyAssembly”);
// Define a public class named “HelloWorld”.
TypeBuilder helloWorldClass =
module.DefineType(“MyAssembly.HelloWorld”,
TypeAttributes.Public);
// Define a private String variable named “theMessage”.
FieldBuilder msgField = helloWorldClass.DefineField(
“theMessage”,
Type.GetType(“System.String”),
attributes: FieldAttributes.Private);
// Create the custom ctor.
Type[] constructorArgs = new Type[1];
constructorArgs[0] = typeof(string);
ConstructorBuilder constructor =
helloWorldClass.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
constructorArgs);
ILGenerator constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
Type objectClass = typeof(object);
ConstructorInfo superConstructor =
objectClass.GetConstructor(new Type[0]);
constructorIl.Emit(OpCodes.Call, superConstructor);
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
constructorIl.Emit(OpCodes.Stfld, msgField);
constructorIl.Emit(OpCodes.Ret);
// Create the default ctor.
helloWorldClass.DefineDefaultConstructor(
MethodAttributes.Public);
// Now create the GetMsg() method.
MethodBuilder getMsgMethod = helloWorldClass.DefineMethod(
“GetMsg”,
MethodAttributes.Public,
typeof(string),
null);
ILGenerator methodIl = getMsgMethod.GetILGenerator();
methodIl.Emit(OpCodes.Ldarg_0);
methodIl.Emit(OpCodes.Ldfld, msgField);
methodIl.Emit(OpCodes.Ret);
// Create the SayHello method
.
MethodBuilder sayHiMethod = helloWorldClass.DefineMethod(
“SayHello”, MethodAttributes.Public, null, null);
methodIl = sayHiMethod.GetILGenerator();
methodIl.EmitWriteLine(“Hello from the HelloWorld class!”);
methodIl.Emit(OpCodes.Ret);
// “Bake” the class HelloWorld.
// (Baking is the formal term for emitting the type.)
helloWorldClass.CreateType();
return builder;
}
### 发射组件和模块组
方法体首先使用`AssemblyName`和`Version`类型(在`System.Reflection`命名空间中定义)建立关于程序集的最小特征集。接下来,通过静态的`AssemblyBuilder.DefineDynamicAssembly()`方法获得一个`AssemblyBuilder`类型。
调用`DefineDynamicAssembly()`时,必须指定要定义的程序集的访问模式,最常用的值如表 19-9 所示。
表 19-9。
`AssemblyBuilderAccess`枚举的公共值
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
**值**
|
**人生意义**
|
| --- | --- |
| `RunAndCollect` | 该程序集将被立即卸载,一旦不再可访问,其内存将被回收。 |
| `Run` | 表示动态程序集可以在内存中执行,但不能保存到磁盘。 |
下一个任务是为新的程序集定义模块集(及其名称)。一旦`DefineDynamicModule()`方法返回,就会为您提供一个对有效的`ModuleBuilder`类型的引用。
// Create new assembly.
var builder = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,AssemblyBuilderAccess.Run);
### ModuleBuilder 类型的作用
`ModuleBuilder`是动态程序集开发过程中使用的键类型。如您所料,`ModuleBuilder`支持几个成员,允许您定义给定模块中包含的类型集(类、接口、结构等)。)以及嵌入式资源集(字符串表、图像等)。)包含在内。表 19-10 描述了两种以创造为中心的方法。(请注意,每个方法都将向您返回一个相关的类型,该类型表示您想要构造的类型。)
表 19-10。
选择`ModuleBuilder`类型的成员
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
方法
|
生命的意义
|
| --- | --- |
| `DefineEnum()` | 用于发出. NET 枚举定义 |
| `DefineType()` | 构造一个`TypeBuilder`,它允许您定义值类型、接口和类类型(包括委托) |
需要注意的`ModuleBuilder`类的关键成员是`DefineType()`。除了指定类型的名称(通过一个简单的字符串),您还将使用`System.Reflection.TypeAttributes`枚举来描述类型本身的格式。表 19-11 列出了`TypeAttributes`枚举的一些(但不是全部)关键成员。
表 19-11。
选择`TypeAttributes`枚举的成员
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
成员
|
生命的意义
|
| --- | --- |
| `Abstract` | 指定该类型是抽象的 |
| `Class` | 指定该类型是类 |
| `Interface` | 指定该类型是接口 |
| `NestedAssembly` | 指定该类嵌套有程序集可见性,因此只能由其程序集中的方法访问 |
| `NestedFamANDAssem` | 指定该类嵌套有程序集和族可见性,因此只能由位于其族和程序集的交集中的方法访问 |
| `NestedFamily` | 指定该类是用族可见性嵌套的,因此只能由它自己的类型和任何子类型中的方法访问 |
| `NestedFamORAssem` | 指定该类是用族或程序集可见性嵌套的,因此只能由位于其族和程序集的联合中的方法访问 |
| `NestedPrivate` | 指定该类嵌套有私有可见性 |
| `NestedPublic` | 指定该类嵌套有公共可见性 |
| `NotPublic` | 指定该类不是公共的 |
| `Public` | 指定该类是公共的 |
| `Sealed` | 指定该类是具体的,不能扩展 |
| `Serializable` | 指定该类可以序列化 |
### 发出 HelloClass 类型和字符串成员变量
现在您已经对`ModuleBuilder.CreateType()`方法的作用有了更好的理解,让我们看看如何发出公共`HelloWorld`类类型和私有字符串变量。
// Define a public class named “HelloWorld”.
TypeBuilder helloWorldClass =
module.DefineType(“MyAssembly.HelloWorld”,
TypeAttributes.Public);
// Define a private String variable named “theMessage”.
FieldBuilder msgField = helloWorldClass.DefineField(
“theMessage”,
Type.GetType(“System.String”),
attributes: FieldAttributes.Private);
注意`TypeBuilder.DefineField()`方法如何提供对`FieldBuilder`类型的访问。`TypeBuilder`类还定义了提供对其他“构建器”类型访问的其他方法。例如,`DefineConstructor()`返回一个`ConstructorBuilder`,`DefineProperty()`返回一个`PropertyBuilder`,以此类推。
### 发出构造函数
如前所述,`TypeBuilder.DefineConstructor()`方法可以用来为当前类型定义一个构造函数。然而,当实现`HelloClass`的构造函数时,您需要将原始的 CIL 代码注入构造函数体,它负责将传入的参数分配给内部私有字符串。为了获得一个`ILGenerator`类型,您从您引用的相应“构建器”类型(在本例中是`ConstructorBuilder`类型)中调用`GetILGenerator()`方法。
`ILGenerator`类的`Emit()`方法是负责将 CIL 放入成员实现的实体。`Emit()`本身经常使用`OpCodes`类类型,它使用只读字段公开 CIL 的操作码集。例如,`OpCodes.Ret`表示方法调用的返回,`OpCodes.Stfld`对成员变量赋值,`OpCodes.Call`用于调用给定的方法(在本例中,是基类构造函数)。也就是说,思考下面的构造函数逻辑:
// Create the custom ctor taking single string arg.
Type[] constructorArgs = new Type[1];
constructorArgs[0] = typeof(string);
ConstructorBuilder constructor =
helloWorldClass.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
constructorArgs);
//Emit the necessary CIL into the ctor
ILGenerator constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
Type objectClass = typeof(object);
ConstructorInfo superConstructor =
objectClass.GetConstructor(new Type[0]);
constructorIl.Emit(OpCodes.Call, superConstructor);
//Load this pointer onto the stack
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
//Load argument on virtual stack and store in msdField
constructorIl.Emit(OpCodes.Stfld, msgField);
constructorIl.Emit(OpCodes.Ret);
现在,如您所知,一旦您为类型定义了自定义构造函数,默认构造函数就会被自动移除。要重新定义无参数构造函数,只需调用`TypeBuilder`类型的`DefineDefaultConstructor()`方法,如下所示:
// Create the default ctor.
helloWorldClass.DefineDefaultConstructor(
MethodAttributes.Public);
### 发出 SayHello()方法
最后,让我们检查一下发射`SayHello()`方法的过程。第一个任务是从`helloWorldClass`变量中获取一个`MethodBuilder`类型。这样做之后,定义方法并获得底层的`ILGenerator`来注入 CIL 指令,如下所示:
// Create the SayHello method.
MethodBuilder sayHiMethod = helloWorldClass.DefineMethod(
“SayHello”, MethodAttributes.Public, null, null);
methodIl = sayHiMethod.GetILGenerator();
//Write to the console
methodIl.EmitWriteLine(“Hello from the HelloWorld class!”);
methodIl.Emit(OpCodes.Ret);
在这里,您已经建立了一个公共方法(`MethodAttributes.Public`),它不接受任何参数,也不返回任何内容(由包含在`DefineMethod()`调用中的空条目标记)。也注意到了`EmitWriteLine()`的称呼。这个`ILGenerator`类的助手成员自动地向标准输出写了一行,最大限度地减少了麻烦。
### 使用动态生成的程序集
现在您已经有了创建程序集的逻辑,接下来需要做的就是执行生成的代码。调用代码中的逻辑调用`CreateMyAsm()`方法,获取对创建的 AssemblyBuilder 的引用。
接下来,你将练习一些延迟绑定(参见第十七章)来创建一个`HelloWorld`类的实例并与其成员交互。按如下方式更新顶级语句:
using System;
using System.Reflection;
using System.Reflection.Emit;
Console.WriteLine(“***** The Amazing Dynamic Assembly Builder App *****”);
// Create the assembly builder using our helper f(x).
AssemblyBuilder builder = CreateMyAsm();
// Get the HelloWorld type.
Type hello = builder.GetType(“MyAssembly.HelloWorld”);
// Create HelloWorld instance and call the correct ctor.
Console.Write("-> Enter message to pass HelloWorld class: ");
string msg = Console.ReadLine();
object[] ctorArgs = new object[1];
ctorArgs[0] = msg;
object obj = Activator.CreateInstance(hello, ctorArgs);
// Call SayHello and show returned string.
Console.WriteLine(“-> Calling SayHello() via late binding.”);
MethodInfo mi = hello.GetMethod(“SayHello”);
mi.Invoke(obj, null);
// Invoke method.
mi = hello.GetMethod(“GetMsg”);
Console.WriteLine(mi.Invoke(obj, null));
实际上,您已经创建了一个能够创建和执行的. NET 核心程序集。运行时的. NET 核心程序集!这就结束了对 CIL 和动态程序集的作用的研究。我希望这一章加深了你对。NET 核心类型系统,CIL 的语法和语义,以及 C# 编译器如何在编译时处理您的代码。
## 摘要
本章概述了 CIL 的语法和语义。与 C# 等更高级别的托管语言不同,CIL 不仅定义了一组关键字,还提供了指令(用于定义程序集及其类型的结构)、属性(进一步限定给定的指令)和操作码(用于实现类型成员)。
向您介绍了一些以 CIL 为中心的编程工具,并学习了如何使用往返工程用新的 CIL 指令改变. NET 程序集的内容。此后,您花时间学习了如何建立当前(和引用的)程序集、命名空间、类型和成员。最后,我用一个简单的例子来构建一个. NET 代码库和可执行文件,这个例子只使用了 CIL、命令行工具和一些额外的工作。
最后,您介绍了创建一个*动态装配*的过程。使用`System.Reflection.Emit`名称空间,可以在运行时在内存中定义一个. NET 核心程序集。正如您亲眼所见,使用这个 API 需要您详细了解 CIL 代码的语义。虽然构建动态程序集的需求对于大多数人来说肯定不是一项常见的任务。NET 核心应用,对于那些需要构建支持工具和其他编程实用程序的人来说,它可能很有用。