文中所有内容均代表本人对问题的理解,可能与实际有所差别!文中C语言代码的调试环境为MyTc 5.4.1,C#代码调试环境为VS.NET 2003。
为什么VB.net的Shared(共享)方法在C#中叫Static(静态)? 这个问题看起来很愚蠢,但是透彻的了解它确需要掌握面向对象程序设计语言中深层次、本质上的内容。本文将通过以下几个层面的分析深入剖析隐藏在Shared与Static背后的究竟是什么。
另外本题目仅仅是个引子,本文除了讨论静态外,同时还要更多的讨论"动态"方法(Object Method或Instance Method),并试图揭示面向对象的本质。
下面的案例摘自微软课程《Course 2124C: Programming with C#》。
继承、封装、多态性可以说是面向对象程序设计的三大特征。我们先来看看封装这个概念。在传统的C语言程序设计中是没有封装一说的(注意:这里暂不谈C语言中的指针使用),如果两个函数需要对同一个变量进行操作的话,不得不将该变量声明为公有,但这一来就不能排除其它人不通过正当渠道而直接修改公有数据,如下图左边所示:
我们通过Deposit方法与Withdraw方法完成存钱与取钱的动作,进而修改帐户余额balance。然而公有的balance变量允许用户不经存、取钱动作就可以直接修改帐户余额, 给程序带来了很大的风险。如果用C语言实现的话,代码可以表示为:
#include <stdio.h> double balance = 0.0; main() { Deposit(10000.0); Withdraw(2000.0); ShowBalance(); balance = 999999999.9; ShowBalance(); printf("Oh! my God!\n"); } Withdraw(double amount) { balance = balance - amount; } Deposit(double amount) { balance = balance + amount; } ShowBalance() { printf("%12.2f\n", balance); }
面向对象语言的出现引入了封装的概念,通过构筑一个“胶囊”,胶囊的边界将空间划分为“内”和“外”,将数据放到里面作为私有数据,而将方法提供给外面以供调用,这样就有效保护了数据,防止非法访问。这个“胶囊”就是class。如上图右边所示。用C#实现的代码如下:
using System ; public class Account { private double balance = 0; public void Withdraw(double amount) { balance -= amount; } public void Deposit(double amount) { balance += amount; } public void ShowBalance() { Console.WriteLine("Current balance is {0}", balance); } } public class Client { public static void Main() { Account account1 = new Account(); Account account2 = new Account(); account1.Deposit(10000); account2.Deposit(7000); account1.ShowBalance(); account2.ShowBalance(); } }
帐户类仅仅定义了一个模子,不同人应当拥有自身的帐户以及各自的存取记录和帐户余额。在这里每个人的帐户就是帐户类的一个实例。实例和实例间的数据是不同的,如果两个人的帐户余额相同纯属偶然。如上面代码所示,我们通过new命令创建一个帐户类的实例。不同实例间(account1与account2)可以拥有不同的帐户余额。
实例和实例之间往往需要共享一些数据(例如存款利率),将这些共享数据存放在每一个实例中显然不是什么好的办法,我们需要将它们抽取出来单独存放并被所有实例所“共享”,这就是Shared的来源,在C#语言中叫做Static。如下图所示:
图中的Shared与非Shared的代码和数据是分开的,然而在编写程序时,我们需要将它们写在同一个类里面,但它们在运行时却有着不同的表现,初学者一定要注意这一点。C#代码实现如下:
using System ; public class Account { private double balance = 0; private static double interest = 0.07; public void Withdraw(double amount) { balance -= amount; } public void Deposit(double amount) { balance += amount; } public static double InterestRate() { return interest; } public void ShowBalance() { Console.WriteLine("Current balance is {0}", balance); } } public class Client { public static void Main() { Account account1 = new Account(); Account account2 = new Account(); account1.Deposit(10000); account2.Deposit(7000); account1.ShowBalance(); account2.ShowBalance(); Console.WriteLine("InterestRate is: {0}", Account.InterestRate()); } }
对于这类方法和属性我们在前面标示上Shared(VB.net)或static(C#),当看到这些标示后,我们就知道这些内容是用来被所有实例所共享的。这就算是Shared的起源吧。
让我们再来举另外一个例子。假如有一学生类。有两个方法:自报姓名以及火车票打折。我们可以看到报姓名的方法是实例方法,因为不同人有不同的姓名,而火车票打折是对所有学生而言的,因此应当属于共享的方法。代码如下:
using System ; public class Student { private string name; public Student(string name) { this.name = name; } public void TellName() { Console.WriteLine("My name is: {0}", name); } public static double CalcTicketDiscount(double price) { return price/2; } } public class Client { public static void Main() { Student stu1 = new Student("Tom"); Student stu2 = new Student("Jim"); stu1.TellName(); stu2.TellName(); Console.WriteLine("Discount: {0:C2}",Student.CalcTicketDiscount(100)); } }
从上面的分析可以看出,Shared在描述问题上似乎更有道理、更容易理解,那么为什么在C#语言中没有使用shared一词,反而使用了static(静态)呢? 这里的静态又是指什么?我们将在下一部分内容中加以分析。
我们在C#中所说的静态和传统C语言中的静态其实不是一个概念。C语言中的静态指当多次进入某一方法时,该方法中的静态变量能够保持上次调用时的值。举例来说,下面的C语言程序的打印结果是1、2。当第一次调用sub后,静态变量i中的值被保留了下来。
#include <stdio.h> main() { sub(); sub(); } sub() { static int i = 0; i++; printf("%d\n", i); }
而C#中的静态则不是这个概念。它往往指“方法”或“属性”是固定的、不会发生变化的、编译时就能确定下来的。这似乎很难懂,我们还是看看以下两个例子。
第一个例子是C语言的例子,在C语言中,函数与函数不能出现重名,因此当告知调用的函数名时,这个函数是什么,代码在什么地方就可以唯一的确定下来,不会有任何的歧义。C#中管这种唯一能够确定代码位置的,不会产生任何歧义的方法叫做静态方法。
#include <stdio.h> main() { sub1(); } sub1() { printf("This is sub1"); } sub2() { printf("This is sub2"); }
上面的例子中,当主程序发出sub1方法的调用命令后,我们可以唯一的确定该方法在什么地方,去哪里执行代码,因此我们说sub1方法是“静态”的。简直是废话!不过不要着急,在C#中情况就不同了,当你调用某一方法时,你可能根本就不知到该方法在什么地方,会执行什么样的代码。
我们看下面的案例:
using System; public abstract class Light { public abstract void TurnOn(); } public class BulbLight : Light { public override void TurnOn() { Console.WriteLine("Bulb Light is turned on!"); } } public class TubeLight : Light { public override void TurnOn() { Console.WriteLine("Tube Light is turned on!"); } } public class Client { public static void Main() { Light light; light = new BulbLight(); TurnOnLight(light); light = new TubeLight(); TurnOnLight(light); } public static void TurnOnLight(Light light) { light.TurnOn(); } }
不知各位能否说出最后一行代码light.TurnOn()方法在什么地方吗?恐怕不能。因为主程序中先后两次调用TurnOnLight方法,然而所运行的代码位置不同,产生的结果也各不相同。因此,我们说TurnOn方法是非静态的,究竟调用哪段代码要在运行时决定,而不能在编译时决定。其实这就是C#语言中的实例方法(Instance Method),也叫做对象方法(Object Method)。不过我个人更喜欢叫它动态方法(Dynamic Method),因为TurnOn方法的代码位置是在运行时动态决定的。(注意:“动态方法”这个名称是错误的,因为动态方法与实例方法是两个孑然不同的概念!本文最后要比较实例方法和动态方法的区别。在不是很严格的情况下,我总是喜欢将它们混为一谈)
简单说来,C#中的静态是指在编译时就能够唯一确定其代码位置的,调用时目的地静止不动、不会发生变化的方法。而非静态方法往往指在运行时动态决定代码的调用位置、能够呈现出多态特征的方法,其调用往往绑定到某一对象的具体实例上。
关于实例方法为什么不能叫做“动态方法”以及实例方法究竟是怎么一回事,我们将在下一部分内容中加以详细介绍。
现在我们可以思考一下为什么Main方法必须是静态的?从一个角度上说,Main是程序的入口点,它在编译时必须是能够唯一确定其位置的,不能发生变化的方法,其调用不能产生任何的二意性,因此必须是静态的。当然这里的分析还比较片面,当我们深入学习了后面的实例方法后,对Main方法为什么是静态的会有更深入的了解。
对于第一部分中银行帐户案例里银行利率必须是Shared属性的解释,我们现在可以站在static的角度加以分析了。因为银行利率的存储地址必须是唯一的,当你访问银行利率时必须没有二意性。从这个角度上说,银行利率必须是“静态”的。
在前面分析了静态方法后,我们在这部分内容中将重点说说什么是实例方法、实例方法的威力以及它是如何在内部实现的。另外我们还要讨论为什么实例方法不能叫做动态方法。
关于这个问题我不准备多说什么了,有太多的资料介绍这个问题。如果现在还不知道什么是值类型和引用类型的话,我想下面的那部分内容也就可以不用读了。
首先看看下面这段程序:
using System ; public class Car { public int speed = 0; public void SetSpeed(int speed) { this.speed = speed; } } public class Client { public static void Main() { Car car1 = new Car(); Car car2 = new Car(); car1.SetSpeed(60); car2.SetSpeed(100); } }
大家注意,我们在主程序中声明了两个Car的实例,分别是car1与car2,然后分别调用其SetSpeed方法,于是两个car实例被赋予了不同的速度值。然而SetSpeed方法是如何知道究竟是修改car1的speed还是car2的呢?这两个speed又在什么地方呢?SetSpeed方法中的this.speed是谁,speed又是谁呢?
其实实例方法在调用时隐含的传递了一个this指针,使得我们的SetSpeed知道到哪里去修改相应的数据。上面的程序运行时内存布局如下图所示:
当创建了car1对象与car2对象后,实际上在堆中给这两个对象分配了相应的内存并初始化了字段的取值。由于对象是引用类型,因此变量car1与car2存储的是内存地址。紧接着我们要调用SetSpeed方法,上面说过,实例方法在调用时实际上隐含的传递了一个this指针。因此程序实际执行时,SetSpeed方法实际上形式如下:
SetSpeed(Car this, int speed)
注意:上面的形式并不完全正确,仅仅说明了非virtual的Instance方法的调用方式,在后面说完VMT的概念后再说virtual方法的调用方式。
在上面的SetSpeed方法调用中,第一个参数是一个Car类型的参数,参数名是this,第二个参数才是代码中书写的int speed参数。为什么会是这样呢?我们看看SetSpeed方法实际被调用时的情形:
当调用car1.SetSpeed(60);
时,实际上后台执行的代码是SetSpeed(car1, 60);
。 将car1的引用地址传了进去并赋值给this参数。这样一来,“this.speed = speed;
”就不难理解了,this.speed就是顺着地址00A0找到的speed属性(即car1.speed),而第二个speed是SetSpeed方法中的第二个参数。这句代码的意思就很明显了。
型与值在编程语言中通常是统一的。例如代码“int i = 10;
”, 变量i的型是整型,而变量i的值是10,10是整型,因此我们说i的型与值是一致的。这种例子多得是,我就不再多说了。然而在面向对象程序设计中,还存在一种型与值不一致的情形(注意:严格的说,并不存在型与值不一致的情形,型只是编译时而已,而值才是运行时,在运行时,型和值其实是一致的。关于这点我们将在后面再探讨)。请看下面的代码:
using System; public abstract class Light { public abstract void TurnOn(); } public class BulbLight : Light { public override void TurnOn() { Console.WriteLine("Bulb Light is turned on!"); } } public class TubeLight : Light { public override void TurnOn() { Console.WriteLine("Tube Light is turned on!"); } } public class Client { public static void Main() { Light light = new BulbLight(); light.TurnOn(); } }
在上面的案例中,变量light的值是灯泡(BulbLight),然而它的型确是灯(Light),值是型的子类,因此程序在执行时会呈现出多态的行为:当我们调用灯的开灯方法时,会调用灯泡的开灯方法。这又是如何实现的呢?其实一切秘密都隐藏在虚拟方法表中(VMT, Virtual Method Table)。
实例方法之所以在运行时能够产生“动态”的行为,其秘密就在于虚拟方法表(VMT)。其实,使用虚拟方法表的目的就是让子类与父类中相同的方法与属性在各自的虚拟方法表中具有相同的偏移量(当然也有不同的实现,比如后面说的Dynamic方法,实现手段就不是这样)。用一个图来描述的话就是:
该图对于属性和方法是同样适用的,只是属性并不存放在虚拟方法表中。从图中可以看出,子类包含了父类的所有方法和属性(包括私有成员),并且它们的相对位置(偏移量)是完全相同的。
另外,每个对象实例都会有一个指向自己虚拟方法表的指针(如下图左侧的 VMT ptr),实例属性就存储在堆中,每个实例拥有自己的属性值,而虚拟方法表中记录下了各个实例方法的具体代码位置(指向函数的指针)。值得注意的是,虚拟方法表除了存储实例方法指针外,还会存储一些其它信息,例如类的类名(下图中间最上面的name指针),并且是负偏移量。不同语言的实现手段也各不相同。如果对此感兴趣的话,可以分析一下Delphi的代码。网上也有很多文章介绍Delphi中虚拟方法表是什么样子的,在这里就不再重复。感兴趣的人也可以读一读台湾李维的《Inside VCL(深入核心——VCL架构剖析)》一书。
下图演示了同一个对象的多个实例共享一个VMT,但各自存储自己的属性。
面向对象程序设计的魅力所在就是“针对抽象编程”,而没有多态,针对抽象编程就会成为空谈。关于多态是什么不是本文要论述的问题,我在这里重点说说多态是如何通过虚拟方法表实现的。假设有如下图所示的类继承关系:
Motorcycle与Car继承自Vehicle,Mortorcycle覆写了Drive方法与Stop方法;Car覆写了Drive方法并提供了一个新属性maxSpeed和一个新方法SetMaxSpeed;PassengerCar与Truck继承自Car,并分别提供了各自的新属性与方法,另外Truck覆写了Stop方法。如果用C#代码书写下来的话,可以表示成:
using System ; public class Vehicle { public int wheels; public int speed; public virtual void Drive() { Console.WriteLine("Drive Vehicle."); } public virtual void Stop() { Console.WriteLine("Stop Vehicle."); } } public class Motorcycle : Vehicle { public override void Drive() { Console.WriteLine("Drive Motorcycle."); } public override void Stop() { Console.WriteLine("Stop Motorcycle."); } } public class Car : Vehicle { public int maxSpeed; public override void Drive() { Console.WriteLine("Drive Car."); } public virtual void SetMaxSpeed(int maxSpeed) { this.maxSpeed = maxSpeed; Console.WriteLine("Set car max speed to {0}.", maxSpeed); } } public class PassengerCar : Car { public int maxPassengers; public void SetMaxPassengers(int maxPassengers) { this.maxPassengers = maxPassengers; Console.WriteLine("Set max passengers to {0}.", maxPassengers); } } public class Truck : Car { public int carryingCapacity; public override void Stop() { Console.WriteLine("Stop Truck."); } public void SetCarryingCapacity(int carryingCapacity) { this.carryingCapacity = carryingCapacity; Console.WriteLine("Set carrying capacity to {0}t.", carryingCapacity); } } public class Client { public static void Main() { Vehicle v = new Truck(); v.Drive(); v.Stop(); Car c = (Car)v; c.SetMaxSpeed(90); Truck t = (Truck)c; t.SetCarryingCapacity(10); Car car = new PassengerCar(); car.Drive(); PassengerCar p = (PassengerCar)car; p.SetMaxPassengers(21); Truck truck = new Truck(); truck.SetCarryingCapacity(20); } }
注意客户端调用代码中使用了型与值不一致的表示方法,将子类的实例赋值给了父类,并进行了多次强制类型转换。那么上面那段代码在执行时内存的布局是什么样子的呢?我们可以使用下图来描述:
从上图中我们可以得出以下结论:
1)当子类覆写了父类的方法时,相当于将VMT中对应方法指针指向了新的代码位置。
子类与父类中相同的方法具有相同的偏移量(相对位置),如果子类override了父类的方法,将使得该位置上的方法指针指向新的位置。上图中,PassengerCar通过Car继承自Vehicle,PassengerCar与Car均没有覆写Vehicle的Stop方法,因此,PassengerCar的VMT中Stop方法仍然指向Vehicle.Stop法方法。而Car覆写了Vehicle的Drive方法,PassengerCar没有覆写Car的Drive方法,导致PassengerCar的Drive方法指向了Car的Drive方法。依此类推……
2)非静态方法在编译时能够确定的仅仅是虚拟方法表中的偏移量
如上图,由于子类与父类中相同的方法具有相同的偏移量(相对位置),因此当程序对虚拟方法进行调用时,实际上是对VMT中指定位置索引的方法进行调用,于是产生了多态。在上面的例子中,当执行Vehicle v = new Truck();
后,系统在堆中创建了一个值为Truck的对象(虚拟方法表的指针指向了Truck的VMT),并将该对象的地址赋值给了一个型为Vehicle的变量v。当我们调用v.Drive()
时,其实在后台执行的代码是(* v.VMT[idx])(v)
,在这里idx是Drive方法在VMT中的偏移量,即0(该偏移量可以在编译时就确定下来)。整个命令可以表示成:调用v所指向的VMT中偏移量为0处的方法,并将v的引用传递给该方法(这就是前面所说的隐含传递了一个this指针)。
因此,我们说非静态方法的调用地址是不固定的,唯一能够在编译时确定下来的就是方法在虚拟方法表中的相对偏移位置。
3)型是编译时、值是运行时
通过上面的分析我们还可以得到一个结论:型是编译时,值是运行时(尽管在IL代码中仍然保留了型的成分,但是我始终坚信在最终的机器代码中是没有型这个概念的)。关于这个结论我们可以从程序运行时内存布局上看出。注意在前面的内存布局图中,t、c、v分别表示了型为Truck、Car与Vehcile的三个对象,然而在内存中它们是完全一样的。型的存在是为了提供多态、提供“针对抽象编程”生存的环境,同时帮助编译器确认方法偏移量,而真正程序执行时只有值,没有型。我们也可以从下面这段代码中看出型仅仅是编译时:
using System ; public class Light {} public class BulbLight : Light {} public class Client { public static void Main() { Light l = new BulbLight(); Console.WriteLine(l.GetType().ToString()); } }
注意变量l的型是Light,而它的值是BulbLight,当我们调用l的GetType方法时,它返回的不是Light型,而是BulbLight型,究其原因,其实这个Type是根据对象的虚拟方法表(及相关因素)产生的,而l的虚拟方法表就是指向BulbLight的,因此程序执行的结果是BulbLight而不是Light。这也从一个侧面说明了“型是编译时、值是运行时”,程序一旦运行起来就没有型的概念了。
有人可以会提及down cast或者up cast的概念以及is运算符与as运算符,这些不都是和型相关吗?我个人认为这些东西在运行时所做工作仅仅是根据虚拟方法表(很多虚拟方法表中都提供了一个指向父类VMT的指针,于是便串成了一个链表)判断类型是否匹配而已,如果类型匹配,则无需再做任何工作。
也许有人会提及接口的cast是如何进行的,其实接口的内存实现与VMT不一样,应当单独讨论,我个人理解应当有接口表,每个接口又对应VMT中的不同方法,不过这也仅仅是根据Assembly内部结构作的推断而已,就不再加以讨论。
与静态方法相对应的应当叫做“动态方法”,然而我们可以在C#教材上看到“实例方法”、“对象方法”的称呼,唯独没有叫“动态方法”,尽管这种叫法显得比较自然,这是为什么呢?其原因就在于“动态方法”其实另有它指。在Delphi语言中动态方法有自己专门的实现机制。“动态方法”的英文名字叫做“Dynamic Method”,尽管从功能上和我们的实例方法没有任何区别,然而在实现技术上确相差很大,因为动态方法使用的不是VMT,而是DMT(Dynamic Method Table)。DMT是什么样子?又有什么好处呢?针对前面的Vehicle案例,我们如果用动态方法表表述的话可以描述成下图:
可以看到相对于VMT,每个DMT的子类中不再包含父类中所有项目,相同方法也不再拥有相同的相对位置,取而代之的是每个DMT都有一个指向父类DMT的指针,并且每个DMT中只包含有自己覆写或实现的方法。这样一来节省了内存的占用,然而确影响了调用效率。
由于DMT在父类和子类中的大小不一定相同,同一个方法在DMT中的位置也不一定相同,所以动态方法在调用时必须从当前类的DMT向上动态查找,调用效率就低一些。而VMT能够在编译时就确定方法偏移量,因此调用很快,但使用的内存要比DMT多。
因此,从运行时的表现上,“动态方法”和“实例方法”是完全相同的,但从实现机制上说又各不相同,在不是很严格的情况下可以混为一谈。如果深入实现机理,我们还要搞清楚“动态”的真正含义。
看到装配脑袋和双鱼座的回复后,发现文中确实有几处漏洞,在这里补充一下:
我对static的看法仅仅是本文的一个引子,观点似乎不完全正确(地址固定的、不会发生变化的方法),如果按照我的观点,非virtual的实例方法也有某种静态成分在里面,这显然有点离谱,所以我还是赞同双鱼座所说,纯粹为了尊重群体习惯,我有点偷换概念了。
另外对static、非virtual的Instance方法以及virtual的Instance方法在这里再作个归纳,希望批评指正:
1)静态方法:MethodName(parameters);其实在调用时也应当传递了一个this指针,只不过该指针指向的是类信息地址,该地址其实也是一个实例,只不过是一个更低层次上的、且结构完全固定的实例(即所谓元数据的元数据)。
2)非virtual的Instance方法:MethodName(this, parameters);
3)virtual的Instance方法:(* obj.VMT[idx])(this, parameters);
注:文中有关面向对象实现方面资料部分参考了《Introduction to Object Oriented Programming, 3rd Ed》一书第27章,可以从: