Press "Enter" to skip to content

高级Python:元类

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

就像阿特拉斯是天堂的代表一样,元类是类的代表。照片作者:Alexander Nikitenko,来源:Unsplash

本文是高级Python系列的延续(之前有一篇关于Python中函数的文章)。这次,我要介绍元类。由于工程师很少需要实现自定义元类,所以这个主题相当高级。然而,它是每个熟悉Python的开发者都应该了解的最重要的结构和机制之一,主要是因为它实现了面向对象编程范式。

在理解元类和类对象的创建方式后,您将能够继续学习封装、抽象、继承和多态的面向对象编程原则。然后,您将能够通过一些软件工程原则(例如,SOLID)指导的众多设计模式来应用这一切。

现在,让我们从这个看似微不足道的例子开始:

class Person:    passclass Child(Person):    passchild = Child()

当您学习面向对象编程时,最有可能遇到的是一个概念描述了类和对象是什么,它是这样说的:

“类就像模具,对象是由它塑造的饼干”。

这是一个非常直观的解释,并且相当清晰地传递了这个概念。也就是说,我们的例子定义了两个几乎没有功能的模板,但它们确实有效。您可以尝试定义__init__方法、设置一些对象属性,并使其更加可用。

然而,有趣的是在Python中,即使类是一个用于创建对象的“模板”,它本身也是一个对象。在学习Python中的面向对象编程时,每个人都会很快过一遍这个陈述,没有深入思考。在Python中,一切都是对象,那又怎样呢?但是一旦您开始思考这个问题,就会涌现出许多问题,以及有趣的Python细微差别。

在我为您提出这些问题之前,请记住,在Python中,一切都是对象。我是指一切。即使您是新手,这可能是您已经了解到的。下一个例子展示了这一点:

class Person:    passid(Person) # 一些内存位置class Child(Person):    passid(Child)# 一些内存位置# 类对象被创建,您可以实例化对象child = Child()id(child)# 一些内存位置

基于这些例子,这里有一些您应该问自己的问题:

  • 如果类是一个对象,那么它是在何时创建的?
  • 是谁创建类对象的?
  • 如果类是一个对象,那么在实例化对象时如何调用它?

类对象的创建方式

Python被广泛认为是一种解释型语言。这意味着有一个解释器(程序或进程)逐行解析代码并尝试将其转换为机器代码。这与编译型编程语言(如C)相反,在运行之前,编程代码会被转换为机器代码。这是一个非常简化的观点。更准确地说,Python既是编译型的又是解释型的,但这是另一个时间的主题。对我们的例子而言,重要的是,解释器会遍历类定义,并在类代码块完成后创建类对象。从那时起,您就可以通过它来实例化对象了。当然,您必须明确执行此操作,尽管类对象是隐式实例化的。

但是,当解释器完成读取类代码块时,会触发什么“过程”呢?我们可以直接进入细节,但是一张图胜过千言万语:

对象、类和元类彼此之间的关系。图片作者:Ilija Lazarevic。

如果您不了解,Python有type函数可以用于我们的目的。通过以对象为参数调用type,您将获得对象的类型。多么巧妙啊!看一下示例:

class Person:    passclass Child(Person):    passchild = Child()type(child)# Childtype(Child)# type

示例中的type调用很有意义。child的类型是Child。我们使用类对象创建了它。因此,在某种程度上,您可以认为type(child)给出了其“创建者”的名称。而在某种程度上,Child类是其创建者,因为您调用它创建了一个新实例。但是,当您尝试获取类对象type(Child)的“创建者”时会发生什么?您会得到type。总结一下,对象是类的实例,类是类型的实例。现在,您可能想知道类如何成为函数的实例,答案是type既是函数又是类。这是故意保留的,因为它的回溯兼容性。

让人晕头转向的是我们为创建类对象使用的类的名称。它被称为元类。在这里,重要的是要区分面向对象范例的继承和使您能够实践此范例的语言机制。元类提供这个机制。更令人困惑的是,元类能够像常规类一样继承父类。但是这很容易变成“奇妙”的编程,所以让我们不要深入研究。

我们每天都需要处理这些元类吗?答案是否定的。在罕见的情况下,您可能需要定义和使用它们,但大多数情况下,默认行为就足够好了。

让我们继续我们的旅程,这次是一个新的示例:

class Parent:    def __init__(self, name, age):        self.name = name        self.age = ageparent = Parent('John', 35)

像这样的示例应该是您在Python中的首次面向对象编程。您被教导__init__是一个构造函数,您可以在其中设置对象属性的值,然后就可以使用了。然而,这个__init__双下划线方法实际上是它所说的:初始化步骤。您称之为初始化对象的方法,但您最终得到了一个对象实例。没有return,对吧?那么,这是如何可能的?谁返回类的实例呢?

在他们的Python之旅开始时,很少有人知道还有一个隐式调用并且被命名为__new__的方法。这个方法实际上在__init__之前创建一个实例。以下是一个示例:

class Parent:    def __new__(cls, name, age):        print('new is called')        return super().__new__(cls)    def __init__(self, name, age):        print('init is called')        self.name = name        self.age = ageparent = Parent('John', 35)# new is called# init is called

您立即看到的是,__new__返回super().__new__(cls)。这是一个新实例。super()获取Parent的父类,这是一个隐式的object类。这个类被所有Python类继承。而且它本身也是一个对象。这是Python创作者的另一个创新之举!

isinstance(object, object)# True

但是__new____init__是如何绑定的?对于当我们调用Parent('John' ,35)时如何执行对象实例化肯定有更多的东西。再次看一下它。您调用(调用)的是一个类对象,就像一个函数。

Python可调用对象

Python作为一种结构类型语言,可以在类中定义特定的方法来描述协议(一种使用其对象的方式),基于此,该类的所有实例将以预期的方式运行。如果你来自其他编程语言,不要感到害怕。协议在其他语言中类似于接口。然而,这里我们不会明确声明我们正在实现特定接口和因此特定行为。我们只是实现了由协议描述的方法,所有对象都将具有协议的行为。其中一种协议是可调用协议。通过实现双下划线方法__call__ ,你可以使你的对象像函数一样被调用。看下例子:

class Parent:    def __new__(cls, name, age):        print('new is called')        return super().__new__(cls)    def __init__(self, name, age):        print('init is called')        self.name = name        self.age = age    def __call__(self):        print('Parent here!')parent = Parent('John', 35)parent()# Parent here!

通过在类定义中实现__call__,你的类实例变成了可调用的。但是,Parent('John', 35)又如何实现相同的效果呢?如果一个对象的类型定义(类)指定该对象是可调用的,那么类对象类型(元类)也应该指定类对象是可调用的,对吗?双下划线方法__new____init__的调用发生在那里。

此时,是时候开始使用元类了。

Python元类

有至少两种方式可以更改类对象创建的过程。一种是使用类装饰器,另一种是显式指定元类。以下是描述元类的方法。请记住,元类看起来像一个普通类,唯一的异常是它必须继承一个type类。为什么?因为type类包含了我们的代码所需的所有实现。例如:

class MyMeta(type):    def __call__(self, *args, **kwargs):        print(f'{self.__name__} is called'              f' with args={args}, kwargs={kwargs}')class Parent(metaclass=MyMeta):    def __new__(cls, name, age):        print('new is called')        return super().__new__(cls)    def __init__(self, name, age):        print('init is called')        self.name = name        self.age = ageparent = Parent('John', 35)# Parent is called with args=('John', 35), kwargs={}type(parent)# NoneType

在这里,MyMeta是新类对象实例化的驱动力,也指定了如何创建新的类实例。仔细看示例的最后两行。parent没有返回任何结果!但是为什么呢?因为正如你所看到的,MyMeta.__call__仅打印信息并返回了一个空。明确来说,它返回None,它属于NoneType

我们该如何修复这个问题?

class MyMeta(type):    def __call__(cls, *args, **kwargs):        print(f'{cls.__name__} is called'              f' with args={args}, kwargs={kwargs}')        print('metaclass calls __new__')        obj = cls.__new__(cls, *args, **kwargs)        if isinstance(obj, cls):            print('metaclass calls __init__')            cls.__init__(obj, *args, **kwargs)        return objclass Parent(metaclass=MyMeta):    def __new__(cls, name, age):        print('new is called')        return super().__new__(cls)    def __init__(self, name, age):        print('init is called')        self.name = name        self.age = ageparent = Parent('John', 35)# Parent is called with args=('John', 35), kwargs={}# metaclass calls __new__# new is called# metaclass calls __init__# init is calledtype(parent)# Parentstr(parent)# '<__main__.Parent object at 0x103d540a0>'

通过输出,您可以了解在MyMeta.__call__调用时发生了什么。提供的实现只是整个过程的示例。如果您计划自己覆盖元类的部分,请更加小心。有一些边缘情况需要处理。例如,其中一个边缘情况是Parent.__new__可能返回一个不是Parent类的实例的对象。在这种情况下,它将不会被Parent.__init__方法初始化。这是您必须注意的预期行为,初始化一个不是相同类的实例的对象真的没有意义。

结论

这将总结定义类并创建其实例时发生的简要概述。当然,您还可以进一步了解类块解释期间发生的情况。所有这些都发生在元类中。对于我们大多数人来说,幸运的是,我们可能不需要创建和使用特定的元类。但了解一切如何运作是有用的。我想使用适用于使用NoSQL数据库的类似说法,大意是:如果您不确定是否需要使用Python元类,您可能不需要。

参考资料

Leave a Reply

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