Press "Enter" to skip to content

高级Python 点运算符

使Python中的面向对象范式成为可能的运算符

点运算符是Python面向对象范式的支柱之一。图片由Madeline Pere在Unsplash上拍摄

这次,我将写一些看似琐碎的事情。那就是“点运算符”。大多数人在不知道或不考虑幕后发生了什么的情况下已经多次使用了这个运算符。而与我上次谈到的“元类”概念相比,这个概念在日常任务中更加实用一些。开个玩笑,你每次在Python中使用它进行更多于“Hello World”的操作时,实际上都在使用它。这正是我认为你可能想深入了解的原因,而我想成为你的导游。让我们开始这个旅程吧!

我将从一个简单的问题开始:“什么是“点运算符?”

下面是一个例子:

hello = 'Hello world!'print(hello.upper())# HELLO WORLD!

嗯,这当然是一个“Hello World”的例子,但我几乎无法想象有人会以这种方式开始教你Python。无论如何,“点运算符”是hello.upper()中的“.”部分。让我们尝试给出一个更详细的例子:

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"嘿!我是{self.name}")p = Person('John')p.shout()# 嘿!我是John.p.num_of_persons# 0p.name# 'John'

有几个地方你会用到“点运算符”。为了更好地看到整体情况,让我们总结一下你在两种情况下使用它的方式:

  • 用它来访问对象或类的属性,
  • 用它来访问在类定义中定义的函数。

显然,我们的例子中都有这些,这看起来很直观,也是我们所预期的。但其中还有更多!仔细看一下这个例子:

p.shout# <bound method Person.shout of <__main__.Person object at 0x1037d3a60>>id(p.shout)# 4363645248Person.shout# <function __main__.Person.shout(self)>id(Person.shout)# 4364388816

不知何故,p.shout并不引用与Person.shout相同的函数,虽然你可能会期望它引用相同的函数,对吧?而且p.shout甚至不是一个函数!在我们开始讨论发生了什么之前,让我们先看下一个例子:

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"嘿!我是{self.name}。")p = Person('John')vars(p)# {'name': 'John'}def shout_v2(self):    print("嘿,最近怎么样?")p.shout_v2 = shout_v2vars(p)# {'name': 'John', 'shout_v2': <function __main__.shout_v2(self)>}p.shout()# 嘿!我是John.p.shout_v2()# TypeError: shout_v2()缺少1个必需的位置参数:'self'

对于不了解vars函数的人来说,它返回一个保存实例属性的字典。如果你运行vars(Person),你会得到一个略有不同的响应,但你会明白。它将同时包含属性及其值以及保存类函数定义的变量。显然,一个作为类的实例的对象和类对象本身之间存在区别,因此对于这两者,vars函数的响应也会有所不同。

现在,创建对象后,额外定义一个函数是完全有效的。这就是代码行p.shout_v2 = shout_v2。这确实在实例字典中引入了另一个键值对。表面上一切都好像很好,我们将能够顺利运行,就好像shout_v2是在类定义中指定的一样。但唉!真的有问题。我们不能像调用shout方法那样调用它。

敏锐的读者现在应该已经注意到我如何谨慎地使用函数和方法这两个术语。毕竟,Python打印它们的方式也有所不同。看看之前的例子。shout是一个方法,shout_v2是一个函数。至少从对象p的角度来看是这样的。如果从Person类的角度来看,shout是一个函数,shout_v2不存在。它只在对象的字典(命名空间)中定义了。所以,如果你真的要依赖面向对象的范式和封装、继承、抽象和多态等机制,你不会在对象中定义函数,就像我们示例中的p一样。你会确保在类定义(主体)中定义函数。

那为什么这两者不同,为什么会出现错误?嗯,最快的答案是因为“点操作符”如何工作。更长的答案是背后有一个机制在为你进行(属性)名称解析。这个机制由__getattribute____getattr__幽灵方法组成。

获取属性

一开始,这可能听起来很不直观,而且似乎有些复杂,但请耐心听我解释。在Python中,当你尝试访问对象的属性时,有两种可能发生的情况:属性存在或不存在。简单来说。在这两种情况下,__getattribute__都会被调用,或者为了让你更容易理解,可以说它总是被调用。此方法:

  • 返回计算得到的属性值,
  • 显式地调用__getattr__,或者
  • 引发AttributeError,在这种情况下,默认调用__getattr__

如果你想截取解析属性名称的机制,这就是你偷窃的地方。你只需要小心,因为很容易陷入无限循环或搞乱整个名称解析机制,特别是在面向对象继承的情况下。它并不像看起来那么简单。

如果你想处理对象字典中没有属性的情况,你可以直接实现__getattr__方法。当__getattribute__无法访问属性名称时,它会被调用。如果这个方法找不到属性或不能处理缺失的属性,它也会引发一个AttributeError异常。以下是如何运用这些的示例:

class Person:
    num_of_persons = 0
    def __init__(self, name):
        self.name = name
    def shout(self):
        print(f"Hey! I'm {self.name}.")
    def __getattribute__(self, name):
        print(f'getting the attribute name: {name}')
        return super().__getattribute__(name)
    def __getattr__(self, name):
        print(f'this attribute doesn\'t exist: {name}')
        raise AttributeError()

p = Person('John')
p.name  # getting the attribute name: name  # 'John'
p.name1 # getting the attribute name: name1 # this attribute doesn't exist: name1
# ... 异常堆栈
# AttributeError:

在实现__getattribute__时,调用super().__getattribute__(...)非常重要。而原因正如我之前写的,Python的默认实现中有很多事情正在发生。这正是“点操作符”得到魔力的地方。好吧,至少有一半的魔力在那里。另一部分在于在解释类定义后,如何创建类对象。

类函数

我在这里使用的术语是有目的的。类只包含函数,我们在最早的一个例子中就看到了这一点:

p.shout# <bound method Person.shout of <__main__.Person object at 0x1037d3a60>>Person.shout# <function __main__.Person.shout(self)>

从对象的角度来看,这些被称为方法。将类的函数转化为对象的方法的过程被称为绑定(bounding),结果就是你在前面的例子中看到的,一个绑定方法。它是如何被绑定的,绑定到什么?嗯,一旦你拥有了一个类的实例并开始调用它的方法,实际上你是将对象引用传递给它的每个方法。还记得self参数吗?那么,这是如何发生的,由谁负责呢?

嗯,第一部分发生在类体解析时。这个过程中有很多事情发生,比如定义类的命名空间,向其中添加属性值,定义(类)函数,并将它们绑定到名称。现在,当这些函数被定义时,它们以一种方式被包装起来。在概念上称为描述器的对象。这个描述器使得我们之前看到的类函数的识别和行为发生了改变。我会确保写一篇关于描述器的单独博文,但现在,请知道这个对象是一个实现了一组预定义的dunder方法的类的实例。这也被称为协议。一旦实现了这些,就可以说该类的对象遵循特定的协议,并且按预期的方式运行。数据描述器和非数据描述器之间有区别。前者实现__get____set__和/或__delete__ dunder方法。后者仅实现__get__方法。无论如何,类中的每个函数最终都会被包装在所谓的非数据描述器中。

通过使用”点运算符”来启动属性查找时,会调用__getattribute__方法,整个名称解析过程开始。此过程在成功解析时停止,并且大致如下:

  1. 返回具有所需名称的数据描述器(类级别),或
  2. 返回具有所需名称的实例属性(实例级别),或
  3. 返回具有所需名称的非数据描述器(类级别),或
  4. 返回具有所需名称的类属性(类级别),或
  5. 抛出AttributeError,从而调用__getattr__方法。

我的初始想法是让你参考官方文档,了解这个机制是如何实现的,至少提供一个Python的模板,供学习使用,但我已决定帮助你完成这一部分。不过,我强烈建议你去阅读整个官方文档页面。

因此,在下一个代码片段中,我会在注释中放一些描述,以便更容易阅读和理解代码。以下是代码:

def object_getattribute(obj, name):    "模拟Objects/object.c中的PyObject_GenericGetAttr()"    # 为后续使用创建普通对象。    null = object()    """    obj是从我们自定义类实例化的对象。这里我们尝试找到它所实例化的类的名称。    """    objtype = type(obj)     """    name代表类函数、实例属性或任何类属性的名称。在这里,我们尝试找到它并保留对它的引用。MRO是Method Resolution Order(方法解析顺序),与类继承有关。在此时并不重要。让我们说这个机制通过所有父类优化地找到了name。    """    cls_var = find_name_in_mro(objtype, name, null)    """    这里我们检查此类属性是否是实现了__get__方法的对象。如果是,它就是一个非数据描述器。这对于进一步的步骤很重要。    """    descr_get = getattr(type(cls_var), '__get__', null)    """    现在,要么我们的类属性引用了一个描述器,这种情况下我们测试看它是否是一个数据描述器,并返回对描述器的__get__方法的引用,要么我们进入下一个if代码块。    """    if descr_get is not null:        if (hasattr(type(cls_var), '__set__')            or hasattr(type(cls_var), '__delete__')):            return descr_get(cls_var, obj, objtype)  # 数据描述器    """    在name不引用数据描述器的情况下,我们检查看它是否引用了对象的字典中的变量,如果是,则返回其值。    """    if hasattr(obj, '__dict__') and name in vars(obj):        return vars(obj)[name]  # 实例变量    """    在name不引用对象的字典中的变量的情况下,我们尝试看它是否引用了非数据描述器并返回对它的引用。    """    if descr_get is not null:        return descr_get(cls_var, obj, objtype)  # 非数据描述器    """    如果name未引用以上任何内容,我们尝试看它是否引用了类属性并返回其值。    """    if cls_var is not null:        return cls_var                                  # 类变量    """    如果名称解析失败,我们抛出一个AttributeError异常,并调用__getattr__。    """    raise AttributeError(name)

请记住,这个实现是为了记录和描述在__getattribute__方法中实现的逻辑而使用Python编写的。实际上,它是用C实现的。光看它,你就可以想象最好不要尝试重新实现整个功能。最好的方法是尝试自己解析一部分,然后像上面的例子中那样回退到CPython实现,使用return super().__getattribute__(name)

这里重要的是,每个类函数(也是一个对象)都被包装在一个非数据描述符(也是一个function类对象)中,这意味着这个包装对象定义了__get__特殊方法。这个特殊方法的作用是返回一个新的可调用对象(类似于一个新的函数),其中第一个参数是我们正在执行“点运算符”操作的对象的引用。我说要把它看作一个新函数,因为它是可调用的。本质上,它是另一个叫做MethodType的对象。看看下面的例子:

type(p.shout)# 获取属性名称:shout# methodtype(Person.shout)# 函数

一个有趣的事情就是这个function类。这个类恰恰是定义了__get__方法的包装对象。然而,一旦我们尝试通过“点运算符”访问它作为方法shout__getattribute__会在列表中迭代,并在第三种情况(返回非数据描述符)停止。这个__get__方法包含额外的逻辑,它接受对象的引用,并使用引用到function和对象创建MethodType

这是官方文档的模拟:

class Function:    ...    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)

忽略类名的不同。我使用function而不是Function,这样更容易理解,但从现在开始我将使用Function的名称,以符合官方文档的解释。

不管怎样,光看这个模拟,可能足以理解这个function类如何适应这个局面,但是让我添加几行丢失的代码,可能会更清楚一些。在这个例子中,我将添加两个更多的类函数,即:

class Function:    ...    def __init__(self, fun, *args, **kwargs):        ...        self.fun = fun    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)    def __call__(self, *args, **kwargs):        ...        return self.fun(*args, **kwargs)

我为什么添加这些函数?现在你可以很容易地想象出Function对象在这个方法绑定的整个场景中起到的作用。这个新的Function对象将原始函数存储为属性。这个对象也可以调用,这意味着我们可以像调用函数一样调用它。在这种情况下,它的工作方式与它包装的函数一样。记住,Python中的一切都是对象,甚至函数也是如此。而MethodType会将Function对象与我们调用的对象的引用(在我们的例子中是shout)一起“包装”起来。

MethodType如何做到这一点?它保持这些引用并实现了可调用协议。下面是MethodType类的官方文档模拟:

class MethodType:    def __init__(self, func, obj):        self.__func__ = func        self.__self__ = obj    def __call__(self, *args, **kwargs):        func = self.__func__        obj = self.__self__        return func(obj, *args, **kwargs)

同样出于简洁起见,func最后引用我们的初始类函数(shout),obj引用实例(p),然后我们有了传递的参数和关键字参数。在shout声明中的self最终引用这个‘obj’, 在我们的例子中实际上是p

最后,应该清楚为什么我们区分函数和方法以及函数如何通过使用“点运算符”在通过对象访问时绑定。如果您仔细思考一下,我们可以以以下方式调用类函数:

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"嘿!我是{self.name}。")        p = Person('约翰')Person.shout(p)# 嘿!我是约翰。

然而,这确实不是建议的方式,而且很丑陋。通常,在代码中您不必这样做。

所以,在我总结之前,我想讨论一些属性解析的示例,以便更容易理解“点运算符”的工作原理。让我们使用前面的示例来理解“点运算符”。

p.name"""1. 使用p和"name"参数调用__getattribute__。2. objtype是Person。3. descr_get为空,因为Person类在其字典(命名空间)中没有"name"。4. 由于根本没有descr_get,我们跳过第一个if块。5. "name"在对象的字典中存在,所以我们得到了值。"""p.shout('嘿')"""在进入名称解析步骤之前,请记住Person.shout是函数类的一个实例。实际上,它被包装在其中。而且这个对象是可调用的,所以可以使用Person.shout(...)调用它。从开发者的角度来看,一切都像在类体中定义的那样运行。但在后台,绝对不是这样的。1. 使用p和"shout"参数调用__getattribute__。2. objtype是Person。3. Person.shout实际上被包装起来,是一个非数据描述符。所以这个包装器确实实现了__get__方法,并且被descr_get引用。4. 包装器对象是一个非数据描述符,所以跳过第一个if块。5. "shout"在对象的字典中不存在,因为它是类定义的一部分。第二个if块被跳过。6. "shout"是一个非数据描述符,其__get__方法从第三个if代码块返回。现在,我们尝试访问p.shout('嘿'),但我们实际上得到的是p.shout.__get__方法。这个方法返回一个MethodType对象。因此,p.shout(...)是有效的,但最终调用的是MethodType类的实例。这个对象本质上是对`Function`包装器的一个包装,它保存了对`Function`包装器和我们的对象p的引用。最终,当您调用p.shout('嘿')时,实际上被调用的是具有p对象和'嘿'作为位置参数之一的`Function`包装器。

结论

如果一口气阅读本文并不容易,不要担心!“点运算符”背后的整个机制并不是那么容易理解。至少有两个原因,一个是__getattribute__如何进行名称解析,另一个是类函数在类体解释时如何被包装。因此,请确保多次阅读本文并尝试使用示例进行实验。实验真正促使我开始了一系列名为“高级Python”的文章。

还有一件事!如果你喜欢我解释事物的方式,并且在Python世界的高级领域中有一些你想了解的东西,请大声说出来!

高级Python系列的以前文章:

高级Python:函数

读完标题后,你可能会问自己类似于“Python中的函数是一种高级技术…”

towardsdatascience.com

高级Python:元类

Python类对象的简要介绍及其创建方式

towardsdatascience.com

参考资料

Leave a Reply

Your email address will not be published. Required fields are marked *