先来点高层次的东西……
什么样的语言才叫做动态语言?有一些作者已经给出了完整、详细的描述( http://www.tcl.tk/doc/scripting.html)。我个人喜欢把具有如下特征的语言叫作动态语言:无类型的,或叫做loosedly typed的语法;REPL(Read-Eval-Print Loop);以及最为重要的,表达式求值是晚绑定(later-binder,也译作迟绑定或运行时绑定)的。当然,动态语言的涵义远不止这些,但本文将把讨论范围限制在和这几个特性有关的想法上。有哪些实现在CLR上的动态语言可以供我们学习?
IronPython: http://workspaces.gotdotnet.com/ironpython/.JScript.NET(JavaScript for CLR)
VB.NET——嗯,VB.NET也可以称作一种“动态语言”,如果你显式地声明需要如此:在vb源文件开头关闭Strict选项。
Phalander(PHP for CLR): http://www.php-compiler.net/
当然还有很多。你可以去下面这个列表里找找:
http://www.dotnetpowered.com/languages.aspx
简明定义:无类型
C#是一种静态类型的语言。看看你的C#程序,里面总是包含很多声明语句:
string
s;
Foo foo = new Foo();
...
Foo foo = new Foo();
...
所有变量都有一个类型:"s"是string类型的,"foo"是Foo类型的。如果把一个整数赋给foo,C#编译器会给你一个错误报告。这是因为C#是一种强类型的语言——变量的类型在定义时就已经确定,在其后的代码段中不能再改变。顾名思义,无类型语言就没有此类限制。比如可以这么写(IronPython):
a
=
"
hello,world
"
print (a)
a = 1
print (a)
print (a)
a = 1
print (a)
输出:
hello,world
1
记住:强类型(静态类型)语言——声明类型,然后一直保持;动态类型(无类型)语言——不强制要求类型安全,只要你高兴,可以把任何实例(instance,这个词的含义不要硬抠)赋值给变量。
更确切的说,大多数动态语言中变量只是一个名字,或者(不那么准确)一个引用。一个不错的类比是C#里的object。所有的对象都继承自object(值类型可以通过boxing转换)。
再深入考虑一下动态类型如何映射到CLR上,就会碰到许多有趣的问题。动态类型怎么和一个具有Common Type System的运行时系统结合起来?CLR支持代码验证,那么动态类型语言能够通过CLR的验证吗?"a"到底是什么类型的?有些问题的解答我会在后文中给出。
简明定义:REPL(读入-求值-打印循环)
REPL通常是指一种命令行程序,它读入一行文字,然后把输入送给一个编译器或解释器对语句或表达式进行求值。IronPython就支持REPL:C:\IronPython-1.0\ipy.exe
IronPython 1.0.60816 on .NET 2.0.50727.42
Copyright (c) Microsoft Corporation. All rights reserved.
>>> a = "IronPython has a REPL"
>>> print (a)
IronPython has a REPL
>>>
提示符">>>"表示等待执行下一段Python代码。每个传递给Python解释器的字符串(一行或多行代码)都会执行某些动作(不仅仅是往屏幕上写点东西)。Python中的每一行代码都是一条语句,这种语义在动态语言中十分常见。
并非所有动态语言都有REPL(注:好像Perl就没有)。这或许不是一件坏事。一个好用的IDE也很不错。
简明定义:晚绑定
解释这个概念最简单的办法是看下面这个简单然而强大的语句:o.m()
"o"是某个实例,"m"是一个方法。
在静态语言的世界中,编译器编译这个表达式的时候能够确切地知道o的类型(因为o的类型在定义的时候就已经知道了),然后就能够去o的类型定义中查找名为"m"的方法。如果找不到,一个编译错误诞生了;如果找到了,编译器就会很高兴地生成一条调用"m"的IL代码。整个过程非常简单明了:编译期对"m"进行决议(resolution),通过call指令静态调用"m"。假设"o"是Foo类型的,对应的IL大概是这样:
ldloc.
0
callvirt instance void Foo::m()
callvirt instance void Foo::m()
转向动态语言,事情就变得复杂起来了。首先,这个语句通常必须在运行时才能求值。为什么呢?这儿有几个很好的理由:首先,动态语言中的函数调用决议是基于实例的,而实际的调用被推迟到可能的最后时刻才进行——尽可能地减少编译期规则。第二,编译时编译器并不知道"o"是什么类型,所以包括像"o"是否有一个"m()"方法这样的事情都必须依靠具体的实例才能决定;第三,程序员可能对语言规则进行了一些扩展(不错,有些语言甚至允许你重载'.'运算符!比如Lua就是这样的),而这些扩展必须在决议和调用之前被执行。
那么编译器该怎么处理"o"和"m"呢?生成什么样的代码才符合要求?编译器通常会在生成的代码中调用某个运行时设施(runtime helper)进行决议,再调用另一个运行时设施执行实际的方法调用(也有可能两个步骤通过一个辅助设施完成)。听起来比较别扭,实际意思就是把决议和调用都推迟到代码执行时进行。看下面的例子就明白了(VB.NET,关闭Strict):
Option
Strict
Off
...
Dim o
...
o.m()
...
Dim o
...
o.m()
生成的IL代码:
ldloc.0
ldnull
ldstr "m"
ldc.i4. 0
newarr [mscorlib]System.Object
ldnull
ldnull
ldnull
ldc.i4. 0
call object Microsoft.VisualBasic.CompilerServices.NewLateBinding::LateCall(
object ,
class [mscorlib]System.Type,
string ,
object [],
string [],
class [mscorlib]System.Type,
bool[],
bool)
ldnull
ldstr "m"
ldc.i4. 0
newarr [mscorlib]System.Object
ldnull
ldnull
ldnull
ldc.i4. 0
call object Microsoft.VisualBasic.CompilerServices.NewLateBinding::LateCall(
object ,
class [mscorlib]System.Type,
string ,
object [],
string [],
class [mscorlib]System.Type,
bool[],
bool)
上面加了粗体的部分值得关注:ldloc.0,ldstr "m"和call NewLateBinding::LateCall。LateCall以一个实例(ldloc.0)和一个表示方法名的字符串(ldstr "m")作为参数(当然还有别的)。如果再想想,给你一个实例和一个名字,你能干点什么呢?反射(Reflection),同志们,反射!实际上VB.NET的LateCall最后就是这么干的:对"o"实例反射查找方法"m",然后Invoke()来调用找到的方法"m()"。
好了,简明定义到此结束。下面我们会给出一些技术细节,看看这些东西是如何工作的。
函数调用的晚绑定
我们来看一个假想的函数调用场景和相应的晚绑定:
class
Foo
{
void m(o)
{}
}
// part we're generating code for
o = Foo.ctor()
o.m( " test " )
{
void m(o)
{}
}
// part we're generating code for
o = Foo.ctor()
o.m( " test " )
当我们的编译器(目前只是一个假想)遭遇这两条语句时,必须生成代码将调用请求转交给某个运行时语言设施。前面VB.NET已经展示了一下这方面的例子,但是我们不好在这里给出VB.NET的晚绑定代码(注:原文作者是微软开发人员),所以自力更生一个:
.locals init (
object
V_0)
newobj instance void Foo::.ctor()
stloc. 0
ldloc. 0
ldstr " m "
ldstr " test "
call object [LateBinder]LateBinder::Call( object , string , object )
newobj instance void Foo::.ctor()
stloc. 0
ldloc. 0
ldstr " m "
ldstr " test "
call object [LateBinder]LateBinder::Call( object , string , object )
假想的晚绑定代码:
public
class
LateBinder
{
public static object Call( object o, string name, object arg)
{
return o.GetType().GetMethod(name).Invoke(o, new object [] { arg });
}
// ...
{
public static object Call( object o, string name, object arg)
{
return o.GetType().GetMethod(name).Invoke(o, new object [] { arg });
}
// ...
IL把对m()的调用交给方法LateBinder::call,后者通过反射调用m()。这样就实现了晚绑定。
在上面这个假想实例中,我们忽略了很多问题。例如,静态方法调用(很简单,不用传递this参数);方法有两个或两个以上的参数(一样简单,把参数放到一个临时数组里面,写一个LateBinder::Call的重载版本,第三个参数是object[])。其他的一些问题包括:LateBinder::Call返回一个object,而实际的方法返回值可能是void;可变数目参数的方法(methods with params);调用约定(calling conventions)——动态语言是否支持OO式的虚方法调用;函数调用的性能问题;等等。
静态虚拟机上的动态类型
曾经有人认为CLR不适合动态语言,主要原因是:CIL指令,类型和方法签名(signature)匹配,CLS(Common Language Specification,通用语言规范)和CTS(Common Type System,通用类型系统)这三者本质上都是静态的。对编译器作者而言,语言有哪些类型,而这些类型如何映射到CLR的CTS上去是从一开始就必须做好选择的重要问题。运行时系统提供了很多通用类型,问题在于:下面这些代码:
Dim
o
o.m()
o.m()
或者用IronPython的例子:
a
=
"
hello,world
"
a = 1
a = 1
"o"和"a"的类型是什么?(译者注:我认为这里作者的意思是,"o"和"a"应该映射到CLR的哪个类型上?)当编译器生成IL代码时,必须在元数据中指明变量的类型。你可以在ILDASM的输出中找到这些变量类型声明:在.locals段中声明了局部变量的类型,参数的类型在方法签名中声明,类成员(字段,事件,委托等等)也都是强类型的。
回到"o"和"a"。既然它们都可以是方法的局部变量,我们需要给它们设定一个类型作为声明的一部分,同时还要保证可以把其他任何类型的实例赋值给它们。感谢多态机制,一切对象都继承自System.Object,所以一个System.Object类型的实例可以被赋以任何类型的实例(包括值类型,需要boxing)。研究一下VB.NET和IronPython的输出(JScript.NET也是个不错的例子),你可以自己验证一下。
译者注:我把IronPython生成的PE文件(编译时加上-X:SaveAssemblies选项)用ILDASM打开,找了半天,变量确实都被声明为object,程序被放进__main__::.cctor()里。
方法该怎么处理呢?很容易想到——参数和返回值几乎总是System.Object。看一个IronPython方法:
def
saysomething(a):
print (a)
a = " hello world "
saysomething(a)
print (a)
a = " hello world "
saysomething(a)
ILDASM输出:
.method
public
static
object
saysomething$f0 (
object
a) cil managed
{
ldarg. 0
call void [IronPython]IronPython.Objects.Ops::Print( object )
ldnull
ret
}
{
ldarg. 0
call void [IronPython]IronPython.Objects.Ops::Print( object )
ldnull
ret
}
译者注:原文作者所用的IronPython是0.7.6版的,如果是1.0版生成的IL会更复杂一些。
从IL代码中你可以看到,saysomething是一个静态方法,以一个object为参数并返回一个object;IronPython的print方法也以object为参数。
一切皆为object,有问题吗?
考虑一下我们的动态语言和BCL(Base Class Library)的交互,问题就来了。BCL本质上是强类型的,而我们的动态语言中所有东西都是object类型的,可以想象,在我们的语言中调用相应的强类型BCL方法会有一些麻烦。看看下面这个例子:
a
=
"
hello world
"
Console.WriteLine(a)
Console.WriteLine(a)
Console.WriteLine有以下一些重载:
WriteLine(
string
,
object
,
object
,
object
)
WriteLine( string , object object )
WriteLine( char [])
// ...
WriteLine( string )
WriteLine( object )
// ...
WriteLine( string , object object )
WriteLine( char [])
// ...
WriteLine( string )
WriteLine( object )
// ...
WriteLine总共有19个重载版本,其中有12个只有一个参数。现在"a"是object类型的,应该用哪个重载?感谢上帝,WriteLine有一个重载以object为参数,所以编译器能够顺利的将调用绑定到这个重载上。但是,如果重载中没有提供参数是object类型的版本呢?例如:
Console.SetWindowSize(
int
,
int
)
w = 100
h = 100
Console.SetWindowSize(w, h)
w = 100
h = 100
Console.SetWindowSize(w, h)
"w"和"h"都是object类型的(在本例中,w和h常常保存了经过装箱后的整数,具体细节取决于语言如何处理数字),而运行时规则不允许传递两个object类型的参数给以两个int为参数的方法——无法通过验证(尽管实际上这样的程序在完全受信任(in full trust)的情况下能够运行。用ildasm/asm试试看)。这就导致了一个问题:如果在动态语言中调用BCL方法,如何进行决议并绑定到某个重载版本?这一问题一般是在晚绑定(回忆前文中的LateBinder::Call方法)时用反射来解决,需要写一大堆“魔术代码”来完成反射时的显式转换语义(这一句不是很明白,原文是with a whole bunch of magic code to fill in the gaps in Reflections coercion/casting semantics,我猜作者的意思是,需要生成代码来进行类型转换,不知道对不对)。
还有BCL的回调机制,委托和事件,也需要我们关心一下。现在方法都以object为参数并返回object,假如委托被定义为类型void MyDelegate(string s),如何将这些方法挂到(hook)事件上?在后文中我会解决这个以及其他诸如此类的“动态类型”难题(我知道我又给了张空头支票,但请大家有耐心)。
IL指令是有类型的,然后呢?
在这里我只讨论一个最简单的情形。考虑下面的C#表达式:2 + 4
C#编译器生成如下的IL指令序列(实际上,真正的编译器会在编译时计算这个常量表达式):
ldc.i4.
2
ldc.i4. 4
add
ldc.i4. 4
add
ldc.i4将给定的一个int32值作为一个int32压到运算栈(evaluation stack)上。"add"指令从栈上弹出两个值,相加后将结果压栈。"add"指令能从栈上弹出值的类型是有限制的——简单的说,元素的类型只能是数值(像int64,int32,natural int,float,etc。完全的列表可以在msdn上找到)。Add不能把一个Foo类型和一个Bar类型加在一块,不是吗?
而在我们的动态语言里,所有东西都是object,add指令不能以object为操作数。该怎么解决?办法和前面的方法调用的晚绑定有些类似:把类型转换和加法的实际操作交给语言的运行时设施,一个晚绑定的"Add"方法来完成。"Add"方法看起来是这样的:
public
static
object
Add(
object
o1,
object
o2)
{
if (o1.GetType() == typeof ( double ) && o2.GetType() == typeof ( double ))
return (( double )o1) + (( double )o2);
else if (o1.GetType() == typeof ( int ) && o2.GetType() == typeof ( int ))
return (( int )o1) + (( int )o2);
//...
{
if (o1.GetType() == typeof ( double ) && o2.GetType() == typeof ( double ))
return (( double )o1) + (( double )o2);
else if (o1.GetType() == typeof ( int ) && o2.GetType() == typeof ( int ))
return (( int )o1) + (( int )o2);
//...
Add的两个参数都是object。所以如果我们用ldc.i4指令把整型操作数加载到运算栈上,在调用Add之前最好把操作数装箱。最后,表达式2+4的IL代码:
ldc.i4
2
.
box [mscorlib]System.Int32
ldc.i4 4 .
box [mscorlib]System.Int32
call object [LateBinder]LateBinder::Add( object , object )
box [mscorlib]System.Int32
ldc.i4 4 .
box [mscorlib]System.Int32
call object [LateBinder]LateBinder::Add( object , object )
看起来有点乱,不那么漂亮——首先,我们把所有的ints都装箱,然后执行一个晚绑定调用来做加法,性能如何?事实上,Add方法(如果是非虚的)很有希望被inline掉,所有没有方法调用开销;至于装箱操作——有一些技术来加速这一过程(比如,事先把常用的整数装箱缓存在内存中)。在IronPython中有大量诸如此类的优化技巧,如果你有兴趣可以去研究源代码。
实际上,在动态语言的编译器级别我们就可以进行很多优化来完全去除所有的装箱操作(简单的数据流分析)。
(未完待续)
今天写了一天,腰酸背痛。剩下还有近一半的内容,以后再补上。