从C#方法表看透方法调用的本质
类、结构和接口可以拥有自己的方法。委托作为一个特殊的类,也有自己的方法。
引用类型通过类型对象指针可以找到类型方法表,从而调用方法。
对于值类型,也有方法表(任何类型都有方法表),但值类型的实例没有类型对象指针指向它,需要访问类型元数据获得方法表。
类型方法表是在类型加载的过程中建立的,和类型对象的建立位于相同的阶段。
我们使用下面的示例类型:
在上面的代码中,示例类型含有三个普通方法:两个虚方法,一个来自接口继承的方法,以及实例构造函数 .ctor 和静态构造函数 .cctor (因为包含了对静态字段的赋值),可以在方法表中找到它们。
另外,方法表还含有类型所有父类的虚方法,父类的其他方法不出现在子类的方法表中。
方法表的排列顺序严格按照方法定义的顺序,并从最高辈分开始往下排。因此,示例类型的方法表含有下面的成员(还有其他成员):
这些成员组成了方法表的一部分——方法槽表(method slot table)。
方法槽表按照如下顺序排列:继承的虚方法、自己继承自接口的方法、自己的虚方法、构造函数、自己的实例方法、自己的静态方法(不同版本的 CLR,顺序可能不同)。
我们可以看到 Object 类中有一些方法不在其中,这是因为它们不是虚方法。
方法槽表的每一个成员都包含着另一个表,即方法描述(MethodDesc)的其中一个位置,和那个表实现一一对应关系。
我们通过栈上的引用找到类型的方法表指针,它又指向方法表的开头。
通过确定的偏移量,CLR 马上就可以定位到接口虚表的开头,或者方法槽表的开头。
对前者来说,假设要调用的方法 X 位于接口 Y 中,接下来的事情就是查找到指向 Y 的方法表在接口虚表中的位置(不会顺序寻找,因为每个接口的偏移量都已经被索引好了)。
然后,就可以定位到 Y 的方法表,之后,再次通过偏移量跳转到 Y 的方法槽表的开头,最后就和后者相同。
对于后者来说,CLR 在方法槽表中找到方法,然后找到存根例程,根据它后面的 jmp 指令,就可以被引导到 JIT 编译器代码或者机器码,从而继续方法的调用。
而实际上,每个方法都有自己的偏移量(在类型加载时,方法表的顺序就已经确定了,偏移量也就可以被计算出来了)。
因此,CLR 是不会一个一个地寻找的,它总是一步到位。
这种类型的方法调用甚至不需要一个对应类型的实例,因为我们是直接从类型对象(元数据的一部分)出发的,而传统的方式是从栈上的引用(实例)出发的,这样的做法就叫做反射(reflection),如下图所示。
方法的元数据被打包在 MethodInfo 对象中,通过 MethodInfo.Invoke 调用方法,不需要实例对象。
这种方法的好处是:
当然,这种方法的调用也有代价,就是远远慢于直接调用,这是因为在对传入参数类 型的验证上牺牲了性能。
实际上,如果我们获得了方法表的地址,那么我们也可以通过直接书写IL的形式,通 过calli指令调用方法表上对应的方法(通过合适的偏移量)。
委托具有一个自定义的签名,然后,它便可以被绑定到与签名匹配的方法上。
因此,结合前面的知识, 委托的绑定需要如下的信息:
只需要这两个就够了,而且,如果绑定的是静态方法,那么实例可以为 null。
在 System.Delegate 中,可以找到这两个成员(_methodPtr 和 _target)。
每个委托都继承自 MulticastDelegate 类,它再继承自 System.Delegate。MulticastDelegate 类提供了 Invoke 方法供我们调用 methodPtr 指向的方法,它是强类型的,因此委托调用不需要类型验证,速度远快于反射调用,和直接调用没有什么区别。
如果需要一种通用的方式,System.Delegate 提供了 DynamicInvoke 方法,和反射的 Invoke 方法类似,允许传入一个 object 类型的数组。
显而易见,这样的调用的性能又会跌到和反射差不多。
不过,C# 并不支持这样的行为。取而代之的是,它做了一个再包装(委托),将真正的指针 _methodPtr 打包在里面,并且,我们无法修改它。
这样做的主要目的是为了类型安全。如果要在 C# 上完成使用指针来调用方法这个动作,必须通过 Emit 类书写 IL。或者,也可以直接写 IL 来做到,这是一件颇有难度的事情。
如果是静态方法则直接从类型对象开始。
它们的代码是 extern 的,并借助线程池来完成异步请求。
引用类型通过类型对象指针可以找到类型方法表,从而调用方法。
对于值类型,也有方法表(任何类型都有方法表),但值类型的实例没有类型对象指针指向它,需要访问类型元数据获得方法表。
类型方法表是在类型加载的过程中建立的,和类型对象的建立位于相同的阶段。
我们使用下面的示例类型:
interface ITest { string interfaceMethod(); } class FatherClass : ITest { public static int i = 1; public string interfaceMethod() { Console.WriteLine("继承接口的方法"); return "test"; } public int NormalMethod(int a) { return a + 1; } public int NormalMethod2(int a) { return a + 2; } public int NormalMethod3(int a) { return a + 3; } public virtual void VirtualMethod1() { Console.WriteLine("VirtualMethod1"); } public virtual void VirtualMethod2() { Console.WriteLine("VirtualMethod2"); } }主程序:
static void Main(string[] args) { var a = new FatherClass(); Console.WriteLine("没调用方法"); Console.ReadKey(); a.NormalMethod(100); Console.WriteLine("调用方法"); Console.ReadKey(); }
方法表
类型对象中最重要的部分无疑是方法表,它是类加载过程中生成的。在上面的代码中,示例类型含有三个普通方法:两个虚方法,一个来自接口继承的方法,以及实例构造函数 .ctor 和静态构造函数 .cctor (因为包含了对静态字段的赋值),可以在方法表中找到它们。
另外,方法表还含有类型所有父类的虚方法,父类的其他方法不出现在子类的方法表中。
方法表的排列顺序严格按照方法定义的顺序,并从最高辈分开始往下排。因此,示例类型的方法表含有下面的成员(还有其他成员):
- Object 的四个非虚方法
- 自己继承自接口的方法
- 自己的虚方法
- 两个构造函数
- 自己的普通方法
这些成员组成了方法表的一部分——方法槽表(method slot table)。
方法槽表按照如下顺序排列:继承的虚方法、自己继承自接口的方法、自己的虚方法、构造函数、自己的实例方法、自己的静态方法(不同版本的 CLR,顺序可能不同)。
我们可以看到 Object 类中有一些方法不在其中,这是因为它们不是虚方法。
方法槽表的每一个成员都包含着另一个表,即方法描述(MethodDesc)的其中一个位置,和那个表实现一一对应关系。
方法调用
方法的调用是 .NET 框架中最有趣的功能之一。简单来说,方法的调用是一个路由的过程。我们通过栈上的引用找到类型的方法表指针,它又指向方法表的开头。
通过确定的偏移量,CLR 马上就可以定位到接口虚表的开头,或者方法槽表的开头。
对前者来说,假设要调用的方法 X 位于接口 Y 中,接下来的事情就是查找到指向 Y 的方法表在接口虚表中的位置(不会顺序寻找,因为每个接口的偏移量都已经被索引好了)。
然后,就可以定位到 Y 的方法表,之后,再次通过偏移量跳转到 Y 的方法槽表的开头,最后就和后者相同。
对于后者来说,CLR 在方法槽表中找到方法,然后找到存根例程,根据它后面的 jmp 指令,就可以被引导到 JIT 编译器代码或者机器码,从而继续方法的调用。
而实际上,每个方法都有自己的偏移量(在类型加载时,方法表的顺序就已经确定了,偏移量也就可以被计算出来了)。
因此,CLR 是不会一个一个地寻找的,它总是一步到位。
1) 方法的反射调用
我们可以想象,如果直接访问类型对象获得了某个方法的地址,并传入必须的参数,那么我们是不是就可以进行方法调用了呢?答案是肯定的。这种类型的方法调用甚至不需要一个对应类型的实例,因为我们是直接从类型对象(元数据的一部分)出发的,而传统的方式是从栈上的引用(实例)出发的,这样的做法就叫做反射(reflection),如下图所示。
方法的元数据被打包在 MethodInfo 对象中,通过 MethodInfo.Invoke 调用方法,不需要实例对象。
这种方法的好处是:
- 不需要实例对象,也不需要对方法的签名有先验的了解(MethodInfo.Invoke 的其中一个重载接受一个 object 类型的数组)
- 可以调用到任意访问级别的方法,即使是 private 也可以
当然,这种方法的调用也有代价,就是远远慢于直接调用,这是因为在对传入参数类 型的验证上牺牲了性能。
实际上,如果我们获得了方法表的地址,那么我们也可以通过直接书写IL的形式,通 过calli指令调用方法表上对应的方法(通过合适的偏移量)。
2) 方法的委托调用
当想要将一个特定的方法绑定到一个特定的对象时,我们通常使用委托。委托具有一个自定义的签名,然后,它便可以被绑定到与签名匹配的方法上。
因此,结合前面的知识, 委托的绑定需要如下的信息:
- 方法的地址(方法指针)。CLR 可以通过 ldftn 或 ldvirtftn 操作符获得方法的地址。
- 一个类型的实例。当方法和类型内部的成员发生互动时,将可以提供必要的信息。
只需要这两个就够了,而且,如果绑定的是静态方法,那么实例可以为 null。
在 System.Delegate 中,可以找到这两个成员(_methodPtr 和 _target)。
每个委托都继承自 MulticastDelegate 类,它再继承自 System.Delegate。MulticastDelegate 类提供了 Invoke 方法供我们调用 methodPtr 指向的方法,它是强类型的,因此委托调用不需要类型验证,速度远快于反射调用,和直接调用没有什么区别。
如果需要一种通用的方式,System.Delegate 提供了 DynamicInvoke 方法,和反射的 Invoke 方法类似,允许传入一个 object 类型的数组。
显而易见,这样的调用的性能又会跌到和反射差不多。
3) 通过 calli 间接调用方法
上面我们谈到了使用 ldftn 操作符,可以获得方法的地址。然后,我们可以使用 calli 来调用方法,这样等于引入一个函数指针,我们可以像 C 语言一样使用指针来调用方法。不过,C# 并不支持这样的行为。取而代之的是,它做了一个再包装(委托),将真正的指针 _methodPtr 打包在里面,并且,我们无法修改它。
这样做的主要目的是为了类型安全。如果要在 C# 上完成使用指针来调用方法这个动作,必须通过 Emit 类书写 IL。或者,也可以直接写 IL 来做到,这是一件颇有难度的事情。
4) 方法调用小结
在 C# 中,有如下的方法调用方式:直接调用(实例方法则必须先要有一个实例)
这样会从栈上的对象出发,然后通过方法表指针找到类型对象上的方法表,之后通过查找元数据,找到偏移量,进行方法调用。如果是静态方法则直接从类型对象开始。
反射调用
无论是实例还是静态方法,都必须先要拿到类型对象(通过 GetType 方法),然后,反射出方法资料 Methodinfo,之后调用 Invoke 方法,传入变量。Calli 调用
通过 ldftn 获得函数指针,然后使用 calli 指令。这类似 C 的函数指针,但 C# 不支持这种行为。委托调用
第三种调用的官方版本。微软将函数指针和实例对象打包成一个 Delegate 类型,它提供 DynamicInvoke 和 Invoke 方法。异步调用
第四种调用中,Delegate 类型的子类 MulticastDelegate 还提供 BeginInvoke 和 EndInvoke 方法。它们的代码是 extern 的,并借助线程池来完成异步请求。
表达式调用
任何东西都是表达式,方法调用也不例外(Expression.Call 表达式)。 不同的方法调用方式有不同的使用场景。所有教程
- C语言入门
- C语言编译器
- C语言项目案例
- 数据结构
- C++
- STL
- C++11
- socket
- GCC
- GDB
- Makefile
- OpenCV
- Qt教程
- Unity 3D
- UE4
- 游戏引擎
- Python
- Python并发编程
- TensorFlow
- Django
- NumPy
- Linux
- Shell
- Java教程
- 设计模式
- Java Swing
- Servlet
- JSP教程
- Struts2
- Maven
- Spring
- Spring MVC
- Spring Boot
- Spring Cloud
- Hibernate
- Mybatis
- MySQL教程
- MySQL函数
- NoSQL
- Redis
- MongoDB
- HBase
- Go语言
- C#
- MATLAB
- JavaScript
- Bootstrap
- HTML
- CSS教程
- PHP
- 汇编语言
- TCP/IP
- vi命令
- Android教程
- 区块链
- Docker
- 大数据
- 云计算