The History of Python

2009年4月28日星期二

增加对用户自定义类的支持

英文原文链接:http://python-history.blogspot.com/2009/02/adding-support-for-user-defined-classes.html

原文作者:Guido van Rossum

信不信由你,Python在CWI开发了一年之后才增加了类(class),当然这仍在首次公开发布Python之前了。要知道如何添加类,有必要先知道一些Python的实现细节。

Python是用C语言实现的,属于一个典型的基于堆栈的字节码解释器也可以称作“虚拟机”外加一些也是用C实现的基本类型。虽然Python中到处都是对象(“object”),然而C语言中并不直接支持对象而是通过结构(structure)和函数指针(function pointer)来实现。Python虚拟机对每个对象类型定义了几十个必须或者可选支持的操作(例如,“get attribute”、“add”和“call”)。于是一个对象类型就表示为一个含有一系列函数指针的静态分配结构,每个函数指针对应一个标准操作。这些函数指针通常初始化为静态函数。但是对可选操作,类型对象选择不做对应实现时可以将其函数指针指向空地址。调用该操作时虚拟机或者产生一个运行时错误,或者在某些情况下提供一个该操作的缺省实现。一个类型的结构还包含各种数据域,其中之一是对该类型特有方法list的引用,表示为一个结构数组,该数组包含string(作为函数名)和函数指针(对应函数实现)。Python特有的自省就源于一个对象可以在运行时如同访问其它对象一样访问自身类型的结构。

Python实现的重要一个特点就是完全以C为中心。实际上所有的标准操作和方法(method)都是用C语言的函数现的。最初字节码解释器只能支持纯Python的函数或者C实现的函数和方法。我记得是同事Siebren van der Zee第一个建议Python应当允许和C++类似的类定义以便在Python代码中定义对象。

在实现用户定义对象时我遵守了设计尽量简单原则;方案是使用一种新的内置(built-in)对象来表示该对象,它存储类引用作为指向为该类所有实例共享“类对象”的指针,配合一个“实例字典”来保存实例的变量。

这个实现方案中,实例字典包括每个单独对象的实例变量而类对象则包括该类所有实例的共享部分--特别是方法。在实现类对象时我再次运用设计尽量简单原则;一个类拥有的方法存储在一个字典中,以函数名为key。于是我配套了一个类字典。为了支持继承,类对象还需要另外保存表示基类对象的引用。当时我对类所知甚少,却也知道C++刚刚支持的多重继承。我想既然要支持继承,最好也支持一个简单版本的多重继承。于是每个类对象可以有一个或多个基类。

在这个实现中对象操作的机理十分简单。对实例和类变量的操作只是简单的对应到其字典对象上。例如,给实例变量赋值就是更新它的局部实例字典。同样的,在实例对象中查找变量的值只是简单检查它的实例字典中对应变量。如果没有找到该变量,事情会变得复杂一点。这时会在类字典中进行查找,然后遍历类的基类类字典进行查找。

查找类对象及基类属性(attribute)的过程通常都是和定位方法一致的。如前所述,方法存储在类对象字典里为该类所有实例共享。因此,调用一个方法时,通常在具体实例对象的实例字典里面是找不到对应入口。相反,你需要先在类字典里面查找,然后顺序在其基类的类字典里查找,一旦找到就立即停止查找过程。对每个基类来说也递归的实现同样的查找策略。这个策略通常称为深度优先,从左至右,也是Python原来大多数版本采用的缺省方法解析顺序(MRO:method resolution order)。新近的Python版本采用了一个更复杂的MRO,后面的博文会讨论到。

实现类时尽量简单是我的目标之一。因此Python定位方法时不执行复杂的错误检查或者一致性检查。例如,如果某个类重载了基类的某个方法,并不进行检查以确保重新定义的方法和原基类实现有相同的参数个数或者一致的调用方式。前述的方法解析算法只是简单的返回第一个找到的结果,然后不论有什么参数,都在调用该结果时提供给它。

其它一些特性也在设计中引入。例如,虽然类字典最初设计时是为了存储方法的,也没有理由说一定不能存储其他类型的对象。因此,如果某个integer或者string对象存储在类字典中,它就成为一个类变量--它不存储在每个实例变量中,而是存在类中为所有该类实例共享。

类的实现不仅简单,还提供了令人惊奇的灵活性。例如,具体实现不仅使得类成为“一类公民”从而在运行时很容易进行自省,还使得动态进行类的修改成为可能。例如类对象已经生成之后,通过简单的增加或者修改类字典就可以修改类的方法!(*) Python的动态本质使得修改立刻对所有该类及其子类的的实例生效。同样,单个对象可以通过动态的增加、修改和删除实例变量来进行修改。(这个我后来才知道的特性使得Python实现的对象比Smalltalk中的更灵活,后者局限在对象创建时指定的属性范围内)。

类语法开发

设计了用户定义类和实例的运行时表示之后,我的下一个任务是设计类定义的语法,特别是类包含的方法定义。设计时一条主要约束原则是我不希望给方法添加与函数不同的语法。重构语法和字节码解释器来处理如此相似的不同事物看起来是个艰巨任务。然而,即使在我成功的保持了语法一致之后,仍然需要找到处理实例变量的合适途径。开始,我想模拟C++中处理实例变量的隐含方式。例如,C++中,类定义代码如下:


class A {
public:
int x;
void spam(int y) {
printf("%d %d\n", x, y);
}
};


在类中,实例变量x被声明,在方法中,对x的引用到实例变量是通过隐含方式的。例如在spam() 方法中,变量x既不是函数参数,也不是局部变量,又在类中声明为实例变量的情况下,很简单x就是该实例变量。虽然我也想提供类似的方法,很快就发现对于没有变量声明的语言不存在这样的方法,因为无法简洁的区分实例变量和局部变量。

虽然理论上得到一个实例变量的值很容易。Python已有的变量名查找顺序是:局部、全局和内置。每个都表示为变量名和值映射的字典。因此,查找任一变量名就是一系列的字典搜索,直到找到第一个匹配成功。例如,当运行一个包含局部变量p和全局变量q的函数时,语句“print p, q”首先查找p,并在搜索顺序的第一个字典(包含局部变量)找到。接下来,在第一个字典中查找q,找不到,然后在第二个字典(全局变量)中继续查找,并找到q。

把当前对象实例字典放在方法运行时搜索顺序的最前面是很容易实现的。这时,假设一个对象的实例方法包含实例变量x和局部变量y,语句“print x, y”将会在实例字典找到x(搜索路径的第一个字典),在局部变量字典找到y(第二个字典)。

这种策略的问题是给实例变量赋值时就完蛋了。Python赋值时并不在多个字典中查找变量名,而是简单的对搜索顺序中的一个字典进行添加或修改变量,原来情况下这通常是局部变量。结果就是变量总是缺省创建在局部(local scope)范围。(当然,这儿也要说明的是,对每个函数中的每个变量可以用全局声明“global declaration”方式来重载顺序,使全局在最前。)

不改变赋值方法的简单策略之前,把实例字典置于搜索顺序第一项使得在方法中无法给局部变量赋值。例如,如下一个方法


def spam(y):
x = 1
y = 2


对x和y的赋值语句将会改写x的值,创建一个新的y并遮蔽局部变量y。交换实例变量和局部变量在搜索中的顺序,也只是将问题转进到另外一个方面而已,又无法给实例变量赋值了。

如果修改赋值的语义,对已存在实例变量名的赋值时修改实例变量,对不存在实例变量的情况时生成局部变量,也不是一个正确策略,因为这样会带来一个自举问题:实例变量如何被初始化呢。一个可能的解决方案是对实例变量进行清晰的声明,就像全局变量那样,但是我实在不愿意增加增加这样的特性,因为已经在完全不需要变量声明的路上走的太远了。而且,对表示全局变量的额外处理只是一个特殊情况,这种处理也表明在代码中使用全局变量时应谨慎。然而对实例变量的额外处理则几乎在类里面随处可见。另一个思路是实例变量有变量名上的区别。例如,以特殊符号开头(Ruby用@开头),或者采用前缀后缀等特殊的命名规范。这些都不能让我满意(直到现在我也没有改变过看法)。

最后,我决定放弃采用隐含方式表示实例变量的想法。C++这样的语言允许你用this->foo来明确表示foo是实例变量(当存在另一个局部变量foo时)。因此,我决定用明确声明方式作为指定实例变量的唯一方式。另外,与其用一个特定关键字来表示当前对象(“this”),我更愿意用方法的第一个参数“this”(或者其它等价变量名)。实例变量就可以总是通过这个参数的属性来引用了。
有了清晰引用,就不用为方法设计特殊语法了,你也不用担心变量名查找时的会变得很复杂。相反,只需要函数的第一个参数对应实例(通常用“self”),就可以成为一个方法了。如下代码:


def spam(self,y):
print self.x, y


我在Modula-3中学过类似做法,之前Modula-3中已经提供了import和异常处理的语法。Modula-3没有类,但是它允许你创建记录类型(record type)时包含完整类型函数指针成员并就近初始化,并且增加了语法糖,当x是当前记录类型的一个变量,m是一个该类型的函数指针,用函数 f初始化时,调用x.m(args)和f(x, args)是等价的。这正好可以应用到典型的对象和方法实现,使得实例变量等价于第一个参数的属性。

Python类语法的剩余细节也遵从这个规则或者其他实现引入的约束。出于简化的目的,我假定类语句由一系列方法定义组成,方法定义的语法和函数定义相同除了依照惯例要求第一个参数名为“self”。另外,与其给特殊的类方法(例如构造函数和析构函数)设计新的语法,我决定这个特征可由用户通过实现有特定命名约定的方法来完成,例如__init__,__del__等。这个命名规则从C语言中借鉴而来,C语言中由下划线开头的标识符保留给编译器使用,并通常有特殊用途(例如,C预编译器的__FILE__宏)。

这样,我设想的类定义代码就是下面这个样子了:


class A:
def __init__(self,x):
self.x = x
def spam(self,y):
print self.x, y


如同往常,我希望能尽量多的重复利用早期已有代码。以前一个函数定义就是一条可执行语句,简单的在当前命名空间设定一个变量,变量值就是函数对象(变量名就是函数名)。因此,与其设计完全不同的新方法来处理类,对我来说不如简单的把类的内容(class body)当做在一个新的命名空间运行的一系列语句。这个命名空间的字典将会被用以初始化类字典并创建一个类对象。实质就是把类内容用一个特殊方法当做匿名函数,并将执行后的局部变量组成的字典当做结果返回。这个字典传递给一个辅助函数(helper function)来生成类对象。最终,这个类对象存为相应命名空间下的一个变量。用户在知晓任何有效的Python语句都可以出现在类中后常感到惊奇。其实这个特点确实只是我想保持语法尽量简单的同时又不愿人工干涉来约束潜在可能的自然结果了。

最后一点细节是关于类实例化(实例创建)的语法。许多语言,例如C++和Java,采用特定的运算符“new”来生成新的类实例。C++中这么做或许讲得过去,毕竟类名在其解析器中有特殊地位,在Python中情况就不同了。我分析后很快得出结论,既然Python的解析器不在意调用的对象是何种类型,让类对象本身可以被调用是恰当的,是“最简”方案,也是不需引入新语法的好点子。这儿我可能有所超前--现在,厂函数(factory function)是创建是通常实例创建的优先方式,我所做的正是简单的把每个类的实例创建丢给它自己的厂。

特殊方法

如上节中已经简单提到的那样,我的目标之一是保持类的实现简单化。大多数面向对象编程中都有专为类设计的特殊的运算符和方法。例如,C++中,定义构造函数和析构函数有特殊的语法,与一般普通的函数和方法不同。

我显然不愿意为对象的特殊操作引入新的语法。相反,我解决这个问题的方法是简单的将特殊操作映射到一套已预定义好的“特殊方法(special method)”名,例如__init__和__del__。通过定义这些名称的方法,用户就可以提供对象的构建函数和析构函数所需代码了。

我还将这个设计应用到允许用户自定义类重定义Python运算符行为上来。前面已经提到,Python用C语言实现,利用函数指针表来实现各种内置对象能力(例如“get attribute”、“and”和“call”)。为了允许用户自定义类也拥有这些能力,我将这些函数指针映射到特别方法名上,如__getattr__、__add__和__call__。在实现C语言新建Python对象时已经存在一个从这些名字到function pointer表之间的直接对应。

__________
(*) 后来,new-style class需要控制对__dict__的改动;你仍然可以动态修改类,但是必须通过属性赋值方式,而不是直接操作__dict__了。

没有评论: