Flask 框架作者希望看到的 Python,flaskpython,未经许可,禁止转载!英文


本文由 编橙之家 - 艾凌风 翻译。未经许可,禁止转载!
英文出处:Armin Ronacher。欢迎加入翻译组。

编橙之家注:本文作者 Armin Ronacher 是知名 Python 框架 Flask 的开发者。文章写于 2014 年 8 月。

我不是Python 3的粉,也不喜欢这门语言的发展方向,这都不是什么秘密了。这也导致了最近几个月,铺天盖地邮件询问我,我到底想要Python怎样。所以我觉得我应该公开分享一下我的想法,给将来的程序语言设计者提供一些灵感。:)

显然Python作为一门编程程序,并不完美。然而,让我感到沮丧的是,这门语言大多数问题都与解释器的细节有关,很少是语言本身。然而这些解释器的细节,正在变成语言的一部分,这也是为什么这一点是很重要的。

我希望能带你们一起遨游,从解释器中古怪的槽(slots)开始,到python语言设计中最大的错误结束。如果反响比较好的话,以后还会有更多类似的文章。

大体上讲,这些文章将会探索解释器中的设计决策以及这些决策是如何影响解释器和语言的。我相信从程序语言设计的角度来看本文,会比将其作为对python将来如何发展的建议会更有趣。

 

语言vs实现

完成本文的初版后,我又添加了这段文字,因为我觉的我们在很大程度上忽视了,Python作为一种语言,CPython作为一种解释器,他们之间并没有开发者认为的那样分得开。

虽说有语言规范,但是大多数情况下,它只是规定了解释器要做什么,甚至连这些都没有规定。

在这种特定的情况下,解释器的这种晦涩的实现细节,改变或是影响了语言的设计,并且迫使Python的其他实现方式要去适应它。比如我猜想PyPy并不知道什么是槽(slots),但是仍然要操作它,就好像槽(slots)是解释器的一部分一样。

 

槽(Slots)

目前为止,我对专门语言最大的意见就是愚蠢的槽(slots)系统。我指的不是__slot__,而是特殊方法的内部类型槽。这些槽(slots)是语言的“特性”,这点在很大程度上是错误的,因为你很少需要去关心它。也就是说,我认为,槽(slots)存在的这一事实,是这门语言最大的问题。

那么槽(slots)是什么东西呢?slot是解释器内部实现时产生的副作用。每个Python程序员都知道所谓的“魔术方法”,比如__add__。这些方法以两个下划线开头,后面是这种特殊方法的名称,然后又是两个下划线。正如每位开发者所知,a+b就相当于a.__add__(b)

不幸的是,这是一个谎言

Python实际上并不是这样工作的。现如今的Python内部完全不是这样工作的。这里我们大致讲解解释器是如何工作的:

  1. 当一个类型被创建后,解释器会寻找类里面所有的描述符,同时还会寻找类似__add__这样的特殊方法。
  2. 对于每一个特殊方法,解释器找到它,把描述符的引用放在类型对象的一个预定义的槽(slots)中。

例如,特殊方法add对应两个内部槽(slots),tp_as_number->nb_add 和 tp_as_sequence->sq_concat.

  1. 当解释器想求a+b的值时,它会进行类似于TYPE_OF(a)->tp_as_number->nb_add(a, b)的调用(实际上比这复杂的多,因为__add__实际上有多个槽)

因此,表面上看a+b类似于type(a).__add__(a, b),但是即使这样还是不准确的,这点你可以从槽(slots)的处理中看出来。你自己就可以轻易的证明这件事,通过在元类里实现
__getattribute__并尝试连接一个自定义的__add__。你会发现,它永远不会被调用。

我认为槽(slots)系统简直荒唐透顶。对于一些特定的类型(比如整型),它是一种优化,但是对于其他的类型则是毫无意义的。

为了证明这一点,请看这种完全无意义的类型(x.py):

 

Python
class A(object):
    def __add__(self, other):
        return 42

因为我们有一个__add__方法,解释器会在槽(slots)中建立它。速度有多快呢?我们做a+b的时候,会用到槽(slots),下面是执行所用时间:

 

Python
$ python3 -mtimeit -s 'from x import A; a = A(); b = A()' 'a + b'
1000000 loops, best of 3: 0.256 usec per loop

然而如果我们使用a.__add__(b),则会绕过槽(slots)系统。解释器会去查看实例字典(在那里它什么也找不到),然后取查看类型字典,它将找到这个方法。下面是执行所需的时间

Python
$ python3 -mtimeit -s 'from x import A; a = A(); b = A()' 'a.__add__(b)'
10000000 loops, best of 3: 0.158 usec per loop

你敢信吗:不使用槽(slots)的版本实际上更快。这是什么魔力?这一现象的原因我不是很明白,但是这种情况已经持续了很久很久了。实际上,对运算符来讲旧式的类(旧式类不含有槽(slots))比新式的类速度要快很多,并且有更多的特性。

更多的特性?没错,因为旧式的类可以这样做(Python 2.7):

Python
>>> original = 42
>>> class FooProxy:
...  def __getattr__(self, x):
...   return getattr(original, x)
...
>>> proxy = FooProxy()
>>> proxy
42
>>> 1 + proxy
43
>>> proxy + 1
43

是的。对于一个复杂的类型系统而言,如今我们拥有的特性比Python2还少。因为上面的那段代码,是不能新式类中使用的。

Python
>>> import sys
>>> class OldStyleClass:
...  pass
...
>>> class NewStyleClass(object):
...  pass
...
>>> sys.getsizeof(OldStyleClass)
104
>>> sys.getsizeof(NewStyleClass)
904

如果考虑到旧式的类是如此的轻量级,情况实际上更坏。

 

槽(slots)是哪来的?

这就提出了一个问题:为什么会存在槽(slots)。据我所知,槽(slots)的存在仅仅是一个历史遗留问题。当Python的解释器最初发明之时,像字符串等内建类型,被实现为全局变量,并且静态分配了结构体,用来存放一个类型所需的全部的特殊方法

这都是在__add__出现之前。如果你看一下1990年的Python,你可以看到当时的对象是如何创建的。

比如说,整数看上去是这样的:

Python
static number_methods int_as_number = {
    intadd, /*tp_add*/
    intsub, /*tp_subtract*/
    intmul, /*tp_multiply*/
    intdiv, /*tp_divide*/
    intrem, /*tp_remainder*/
    intpow, /*tp_power*/
    intneg, /*tp_negate*/
    intpos, /*tp_plus*/
};

Python
typeobject Inttype = {
    OB_HEAD_INIT(&Typetype)
    0,
    "int",
    sizeof(intobject),
    0,
    free,       /*tp_dealloc*/
    intprint,   /*tp_print*/
    0,          /*tp_getattr*/
    0,          /*tp_setattr*/
    intcompare, /*tp_compare*/
    intrepr,    /*tp_repr*/
    &int_as_number, /*tp_as_number*/
    0,          /*tp_as_sequence*/
    0,          /*tp_as_mapping*/
};

如你所见,即使在曾经发布的第一版Python中,tp_as_number就存在了。不幸的是,好像某天代码仓库因为一些修改而崩溃了,所以在一些非常老的Python版本中,很多重要的东西都丢失了(比如解释器),所以我们需要稍微往将来看看,看看对象是如何被实现的。到1993年,这是解释器的加法指令回调的样子:

Python
static object *
add(v, w)
    object *v, *w;
{
    if (v->ob_type->tp_as_sequence != NULL)
        return (*v->ob_type->tp_as_sequence->sq_concat)(v, w);
    else if (v->ob_type->tp_as_number != NULL) {
        object *x;
        if (coerce(&v, &w) != 0)
            return NULL;
        x = (*v->ob_type->tp_as_number->nb_add)(v, w);
        DECREF(v);
        DECREF(w);
        return x;
    }
    err_setstr(TypeError, "bad operand type(s) for +");
    return NULL;
}

那么__add__等是何时实现的呢?就我看来,他们出现于1.1版。我想方设法搞到了Python 1.1,并耍了一些手段在OS X 10.9上对其进行了编译:

Python
$ ./python -v
Python 1.1 (Aug 16 2014)
Copyright 1991-1994 Stichting Mathematisch Centrum, Amsterdam

当然,它经常会崩溃而且不是所有功能都能运行,但是它能让你看到Python从前的样子。比如说,C和Python在类型实现的时候有着巨大的不同:

Python
$ ./python test.py
Traceback (innermost last):
  File "test.py", line 1, in ?
    print dir(1 + 1)
TypeError: dir() argument must have __dict__ attribute

如你所见,没有对内建类型(比如整型)进行检查。实际上,当add可以用于自定义类的时候,它是自定义类的完整特性:

Python
>>> (1).__add__(2)
Traceback (innermost last):
  File "<stdin>", line 1, in ?
TypeError: attribute-less object

所以,这一传统一种保留到今天的Python中。Python中,类型的整体设计一直没有改变,但是补丁却打了很多很多年。

 

现代PyObject

如今,很多人会认为,C解释器中Python对象的实现,和实际Python代码中的Python对象实现,它们的差别非常微小。在 Python 2.7 中,最大的不同应该是在 Python 中默认的 __repr__ 会报告 Python 中的类型实现为class,而 C 中的类型实现为type。事实上,repr中的不同表示了一个类型是静态分配(类型)还是是在堆上动态分配。它并不会产生什么实际差异,而且在Python3中这些都已经不存在了。特殊方法被复制到槽,反之亦然。在很大程度上,Python和C类的差别貌似已经消失了。

然而,不幸的是,它们仍然非常的不同。让我们来看一看。

正如每一份Python程序员所知的那样,Python的类是开放的。你可以查看类里存放的全部内容,分离(detach)或是重连(reattach)类中的方法,即使类的声明已经结束。这一动态特性在解释器类中是不能使用的。为什么会这样呢?

对于你为什么不能连接一个方法到字典类(比方说)这件事而言,其本身并没有什么技术上的限制。解释器不允许你这样做的原因实际上和程序员没什么关系,内建类型不在堆(heap)中.为了理解它的广泛影响,你需要明白Python是如何启动解释器的。

 

该死的解释器

在Python中,解释器的启动是一个消耗很大的过程。每当你启动一个可执行的Python文件,你调用了一个庞大的工具,做了全部的事情。此外,它会引导内部类型,它会启动导入的工具,它会导入需要的模块,和操作系统一起处理信号,接受命令行参数,启动内部状态等。当这些工作终于都完成之后,它会执行你的代码然后停止。Python这么做已经25年之久了。

用伪代码展示上面过程,是这样的

Python
/* called once */
bootstrap()

/* these three could be called in a loop if you prefer */
initialize()
rv = run_code()
finalize()

/* called once */
shutdown()

这样做的问题是,Python的解释器有一大堆的全局状态。实际上,你只能有一个解释器。一个更好的设计是启动解释器然后在上面运行一些东西:

Python
interpreter *iptr = make_interpreter();
interpreter_run_code(iptr):
finalize_interpreter(iptr);

实际上这也是其他一些动态语言如何工作的。例如,这是lua的工作方式,javascript引擎的工作方式,等等。这样做明显的好处是你可以有两个解释器。多么新颖的概念啊。

谁会需要多个解释器呢?你可能会感到惊讶。甚至是Python也需要,或是至少认为多个解释器是有用的。比如说,如果存在多个解释器,那么一个嵌入在Python中的应用程序可以单独的运行一些东西。(比如说,想象一些在mod_python中实现的web应用,它们想要独立的运行)。所以,在Python中,有一些子解释器。它们在解释器中工作,但是因为有太多的全局状态。全局状态中最大也是最容易引起冲突的一个是:全局解释器锁。Python已经选定了这种单一解释器的理念,所以子解释器之间有很多共享的数据。因为这些数据被共享,所以也就需要一个锁,所以这个所在实际的解释器中。哪些数据被共享了呢?

如果你看一下我上面贴过的代码,你可以看到那些无所事事的结构体。实际上这些结构体是作为全局变量的。实际上,解释器把这些类型结构体直接暴露给Python代码。通过OB_HEAD_INIT(&Typetype)宏来启动,这个宏给予结构体必要的数据头,以至于解释器可以用它工作。比如说,这里有一个该类型的引用计数(refcount)。

现在你可以看到将要发生什么了。这些对象被子解释器所共享。所以,想象你可以在你的Python代码中改变这一对象。两个完全独立的Python代码,彼此间没有任何的关系,他们的状态都会改变。想象如果这是在JavaScript中,Faceboox选项卡将可以改变内建数组类型而且Google选项卡可以马上看到这一事件所带来的影响。

这一1990年左右的设计方案,它带来的影响至今我们仍然可以感受的到。

从好的方面来看,这种不可变的内建类型,已经越来越被社区当做一个好的特性所接受。可变的内建类型所带来的问题,已经被其他语言所证明,这不是我们所想要的。

但仍然有很多是我们想要的。

 

什么是虚函数表(VTable)?

那些来自C语言的Python变量类型,大多是不可变的。那还有其他的什么东西是不同的呢?

另外一个巨大的不同点仍然和Python类的开放特点有关。Python中的类有它们的“虚”方法。虽然这里并没有“真”的C++式的虚函数表,所有的方法都保存在类字典里而且有一种查询算法,但是归结起来其实几乎是一样的。这样做的结果很明显。当你创建一个子类并重载一个方法的时候,在这个过程中很有可能另外一个方法会间接的被改变,因为它进行了调用。

另外一个好的例子是集合。很多集合都有很方便的方法。例如Python中的字典有两个方法来检索字典中的对象:__getitem__()get()。当你在Python实现一个类的时候,你通常会通过诸如返回self.__getitem__(key)另一个类来实现它

解释器对于类型的实现是不同的。其原因仍然是因为slots和字典间的不同。比如说你想要在解释器里实现一个字典。你的目标是重用代码,所以你要从get调用__getitem__。你要如何着手去做呢?

C里面的Python方法,仅仅是一个具有特殊标识的C函数。这就是第一个问题了。这个函数首要目的是处理Python层的参数并把他们转换为你可以在C层使用的东西。至少,你需要从一个Python元组或字典(args and kwargs)中把单独的参数取出,放入局部变量。因此一个
普通的模式是,dict__getitem__在内部仅仅解析参数,然后使用实际参数调用dict_do_getitem。你应该能料到接下来会怎样,dict__getitem__dict_get 都会调用dict_get,它是一个内部静态函数。你不能够重载它。

There really is no good way around this. The reason for this is related to the slot system. There is no good way from the interpreter internally issue a call through the vtable without going crazy. The reason for this is related to the global interpreter lock.when you are a dictionary your API contract to the outside world is that your operations are atomic. That contract completely goes out of the window when your internal call goes through a vtable. Why? Because that call might now go through Python code which needs to manage the global interpreter lock itself or you will run into massive problems.
这里真的没有什么好办法了。这和槽(slots)系统有关。没有什么好办法让解释器正常的通过虚函数表来进行内部调用。这都是因为这个全局解释器锁。当作为一个字典的时候,字典通过API与外界联系。当内部调用遍历虚函数表的时候,这一联系就完全消失了。
为什么?因为这一调用现在也需要通过Python代码,这需要它去处理全局解释器锁,不然就会产生大量的错误。

Imagine the pain of a dictionary subclass overriding an internal dict_get which would kick off a lazy import. You throw all your guarantees out of the window. Then again, maybe we should have done that a long time ago.
想象一个字典的子类重载一个内部dict_get的痛苦。你无法做出任何的保证。再说一次,也许我们很早以前就应该这样做了。

 

给将来的参考

近些年来,有一个明显的趋势就是要把Python变的更加复杂。我希望能够看到与之截然相反的趋势。

我希望看到一个内部解释器的设计可以基于一些相互独立工作的解释器,拥有局部基类型,更像JavaScript的工作方式。这将马上打开基于消息传递的嵌入和并发的大门。CPU不会变的更快了:)

与其把槽和字典看做是虚函数表,我们不如用字典做实验。Objective-C 作为一门语言,完全建立在消息机制上,这使得它的调用非常的快。它的调用,就我所知,比Python在最好的情况下,表现的还要快。字符串在Python里是被限制的,使得它的比较非常的快。我跟你赌,它一点都不慢,即使稍微慢那么一点点,它也会是一个更简单的系统,也就更容易优化。

你应该看一下Python的代码库,处理槽(slots)统需要多少额外的逻辑呢?多到不可思议。

我深信槽(slot)系统是个坏点子,在很久之前就该丢弃了。移除它甚至可能会对PyPy有所助益,因为我敢肯定他们需要故意限制解释器来使其像CPython一样,来获得兼容性。

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

任选一种支付方式

评论关闭