[Python编程思想与技巧-01]我所理解的Python元模型

[Python编程思想与技巧-01]我所理解的Python元模型
虽然Python作为AI领域的第一语言但是作为一个C#的深度使用者对于Python这门编程语言确实有太多值得吐槽的地方。但是我觉得Python在设计上有一个绝对的亮点也是我最喜欢的地方那就是基于元类的元模型在这篇文章中我会聊聊我对Python元模型理解。1. 元宇宙类比我们以前两年风光无限现今基本无人提及的元宇宙来类比。从“创世”的角度来讲元宇宙定义了宇宙现实世界的法则宇宙是由元数据构建的一个实例。“元”和“实例”并非决定概念元宇宙也是宇宙元宇宙的法则由更高层次的“元元宇宙”来定义元宇宙就是这个元元宇宙的实例。以此类推不断抽象直至尽头在那里我们找不到法则的来源于是有人将其视为“造物主”老子将其称为“道”我们不妨将这个宇宙称为创世宇宙。我们找不到创世宇宙的元于是我们它自己作为自己的元这样便形成一个自洽的闭环。既然我们将宇宙视为其元宇宙的实例那么创世宇宙的实例也包含它自己。元可以比实例更简单所谓大道至简“道生一、一生二、二生三、三生万物”即为这个道理。元也可以比实例更复杂它可以包含纷繁复杂的法则我们只取其部分法则来构建实例。看过《完美世界》都知道“下界八域”作为作为囚犯的放逐之地是一个以“上界”为元宇宙构建的“降维宇宙”由于法则不全在下界修炼的境界只能达到“尊者境”不能成神。在Python的“元宇宙”也是如此我们使用元为实例定义规则并作为创建实例的工厂所以类为实例的元。我们可以使用元类创建类所以常规类是元类的实例元类是常规类的元。从这个意义上将元类叫类元可能更贴切但是元类除了表达“类的元”外还体现了元类也是类的概念。既然元类是类自然也可以有自己的元类。所以类即元元亦是类正如“元宇宙也是宇宙宇宙亦可作为元宇宙”。Python的创世宇宙是type我们的类由它构建所以type是类的元类。既然type是元类所以type也具有类的属性。由于处在创世宇宙这个超然的位置type可以视为type的实例。2. 元类的定义在默认情况下我们使用一个类去构建实例的时候背后的流程是将类和参数含任意添加的关键字参数传入类的__new__方法创建一个基础对象将这个基础对象和参数传入类的__init__方法进行初始化返回这个经过初始化的对象所以针对如下定义的Foobar类两种创建实例foobar1和foobar2的方式是等效的。这里强调以下虽然就定义看起来__new__方法像一个类方法但是它本质上时一个静态方法。在它之上既没有标准classmethod装饰器调用的时候第一个参数也也必须指定。fromtyping_extensionsimportSelffromtypingimportAnyclassFoobar:def__new__(cls,*args:Any,**kwargs:Any)-Self:returnsuper().__new__(cls)def__init__(self,foo:int,bar:int)-None:self.foofoo self.barbardef__eq__(self,value:object)-bool:ifnotisinstance(value,Foobar):returnNotImplementedreturnself.foovalue.fooandself.barvalue.bar foobar1Foobar(foo111,bar222)foobar2Foobar.__new__(Foobar,foo111,bar222)Foobar.__init__(foobar2,foo111,bar222)assertfoobar1foobar2与之类似元类作为一个类它也可以定义__new__和__init__方法。类的这两个方法是为了初始化它的实例元类的这两个方法的使命也是如此只是元类的实例是以它为元类的类罢了。所以当Python解释器遇到某个具有元类的class定义时它也会采用类似的方式来创建这个类采用固定的参数调用元类的__new__方法构建一个类对象参数依次为当前元类类名以字典表示的类成员定义类时指定的关键字参数采用固定的参数调用元类的__init__方法对上面创建的基础类对象进行初始化self为类对象其余参数包括类名以字典表示的类成员定义类时指定的关键字参数以如下这个元类Meta为例。我们在定义的__new__和__init__会打印出所有的输出参数。在__new__方法中我们将关键字参数添加到作为类成员的namespaces字典中最后调用基类type的__new__方法将基础类对象创建出来。在__init__方法中我们定义了一个作为字符串输出的函数repr并将其作为类的__repr__方法。fromtypingimportAnyclassMeta(type):def__new__(cls,name:str,bases:tuple[type,...],namespaces:dict[str,Any],/,**kwds:Any):print(f __new__ cls:{cls}name:{name}bases:{bases}namespaces:{namespaces}kwds:{kwds})forkey,valueinkwds.items():namespaces[key]valuereturnsuper().__new__(cls,name,bases,namespaces)def__init__(self,name:str,bases:tuple[type,...],dict:dict[str,Any],/,**kwds:Any)-None:print(f __init__ cls:{self}name:{name}bases:{bases}dict:{dict}kwds:{kwds})defrepr(self)-str:express, .join(f{key}{value!r}forkey,valueinkwds.items())returnf({express.strip()})setattr(self,__repr__,repr)classFoo:...classBar(Foo,metaclassMeta,x-1,y-1):...print(Bar())我们定义的Meta作为类Bar的元类Foo是Bar的基类。在定义了Bar时我们还指定了两个关键字参数根据Meta的__new__方法的定义它们会作为类型的字段成员。我们创建了Bar对象并将其作为参数调用print方法。程序执行后会产生如下的输出__new__ cls: class __main__.Meta name: Bar bases: (class __main__.Foo,) namespaces: {__module__: __main__, __qualname__: Bar, __firstlineno__: 33, __static_attributes__: ()} kwds: {x: -1, y: -1} __init__ cls: class __main__.Bar name: Bar bases: (class __main__.Foo,) dict: {__module__: __main__, __qualname__: Bar, __firstlineno__: 33, __static_attributes__: (), x: -1, y: -1} kwds: {x: -1, y: -1} (x-1, y-1)3. 实例是由元类创建的在上面我们通过大量的篇幅旨在说明类实例创建的流程先调用__new__构建基础对象再将此对象交付给__init__方法进行初始化。现在我要说的是这只是表象并非本质真正的事实是某个类的实例是由元类创建的。既然我们在说“利用元类对象构建”这一动作实际上已经将元类视为实例构建的工厂函数。在Python的世界里万物皆对象。函数自然也不例外函数是函数类的一个实例函数或者可执行对象Callable类是一个拥有自己定于或者从基类继承__call__方法的类调用某个函数本质上就是调用函数对象的__call__方法。当以函数的形式调用元类构建实例意味着实例是通过定义在元类中的__call__方法创建的,如下这个演示实例充分说明了这一点fromtypingimportAnyclassFoo:...classMeta(type):def__call__(self,*args:Any,**kwds:Any)-Any:assertselfisBarasserttype(self)isMetaassertargs(111,222)assertkwds{c:333,d:444}returnFoo()classBar(metaclassTypeMeta):def__init__(self,a:str,b:str,**kwargs)-None:self.xa self.yb self.kwargskwargsassertisinstance(Bar(111,222,c333,d444),Foo)作为一个方法它的第一个参数永远是方法调用的主体对象类方法的调用主体是类实例方法的调用主体是类的实例从__call__方法中的断言可以看出调用此方法的主体对象为Bar这个类对象type(self)返回的类自然就是定义这个类的元类Meta了。Bar(111, 222, c333, d444)注入的位置参数和关键字参数分别以元组和字段的形式赋值给args和kwds。那么为什么对于普通类比如Foo我们调用Foo()能返回一个Foo对象呢其实上述的规则依然适用虽然Foo没有指定一个具体的元类意味着type会作为兜底的元类。Foo()返回的实例其实是由定义在type中的__call__方法创建的该方法采用如下的方式构建对象。classtype:def__call__(self,*args:Any,**kwds:Any)-Any:instanceself.__new__(self,*args,**kwds)# type: ignoreself.__init__(instance,*args,**kwds)returninstance这个方法采用如下的方式创建对象先调用类的__new__构建一个基础对象然后将该对象作为参数调用类的__init__方法进行初始化最终返回此对象。我们也不用考虑什么类有没有定义__new__和__init__因为由终极基类object兜底定义了无参的__new__会开辟一块内存作为创建的空对象;定义了的无参__init__方法什么也没做。4. 终极元类type上面我们从实例构建的角度简单说明了定义在__call__方法的逻辑这个方法以及type类远没有这么简单。4.1 type.__new__方法我们首先来看看定义在type类中的__new__方法的逻辑这个方法用于构建要给类对象上面我们自定义元类Meta的__new__方法最终就是调用这个方法(return super().__new__(cls))。如下所示的是该方法的签名classtype:def__new__(cls:type[Self],name:str,bases:tuple[type,...],namespace:dict[str,Any],/,**kwds:Any)-Self:...五个参数分别是cls构建类的元类name用于命名最终构建出来的类bases作为构建类型的基类namespaces类型成员kwds额外关键字参数。如果cls参数为type这个类对象构建出来的就是一个常规的类型它以name参数命名以bases作为基类并且具有namespaces定义的类型成员。kwds参数指定的关键字参数没有意义如下的演示代码体现了这一点classFoo:x-1clstype.__new__(type,Bar,(Foo,),{y:-1},)assertcls.__name__Barassertcls.__bases__(Foo,)asserttype(cls)istypebarcls()assertbar.x-1assertbar.y-1如果cls参数指定为一个自定义的元类就有点意思了。因为元类和type的__new__方法都可以创建类型,因此会产生冲突很明显后者具有更高的优先级毕竟我们显式调用了__new__方法所以自定义元类的__new_方法和__init__都不会生效。下面的演示程序证明了这一点。classBaz:...log[]classMeta(type):def__new__(cls,name:str,bases,namespaces,/,**kwds):log.append(fMeta.__new__ is called)returnBazdef__init__(self,*args,**kwargs):log.append(fMeta.__init__ is caled)self.z-1classFoo:x-1clstype.__new__(Meta,Bar,(Foo,),{y:-1})assertlen(log)0assertcls.__name__Barassertcls.__bases__(Foo,)asserttype(cls)isMeta barcls()assertbar.x-1assertbar.y-1assertnothasattr(bar,z)虽然元类定义的__new_方法和__init__不会生效但是它指定的元类确实被设置成生成类型的元类assert type(cls) is Meta。如果我们在元类中重写了__call__方法按照上面介绍的规则当我们利用生成的类进行实例化时背后调用的就是这个方法这一点体现在如下的演示程序中。classBaz:...classMeta(type):def__call__(self,*args,**kwargs):returnBaz()classFoo:x-1clstype.__new__(Meta,Bar,(Foo,),{y:-1})assertisinstance(cls(),Baz)4.2 type函数作为终极元类的type也是类上面我们说了当我们将类作为可执行的函数时背后调用的元类的__call__方法。由于type的元类就是它自己所以我们调用type函数本质上就是调用type的__call__方法。我们知道type函数的逻辑是这样的如果传入单一对象它会返回对象的类。具体来说指定的是一个常规对象返回的是该对象的类如果指定的是一个类返回的是元类如果没有显式指定元类返回的自然就是兜底的元类type。这一个规则在如下的演示代码中得到了很好的体现classMeta(type):passclassFoobar(metaclassMeta):passasserttype(Foobar())isFoobarasserttype(Foobar)isMetaasserttype(type)istype如果不满足单一参数的要求type函数最终会创建一个类并要求输入参数依次为类名、基类、类成员、关键字前面加上type刚好与__new__方法参数一致classBase():foo-1clstype(Foobar,(Base,),{bar:-1})assertcls.__name__Foobarassertcls.__bases__(Base,)assertcls.bar-1#type: ignoreinstancecls()assertinstance.foo-1assertinstance.bar-1#type: ignore所以type的__call__会根据参数格式决定返回指定对象的类还是创建一个类所以如下的定义更接近真实实现。classtype:def__call__(self,*args,**kwargs):# 单一参数返回参数的类型iflen(args)1andnotkwargs:objargs[0]returngetattr(obj,__class__,type(obj))# 多参数创建一个新的类instanceself.__new__(self,*args,**kwargs)self.__init__(instance,*args,**kwargs)returninstance5. 重新梳理类的生成和实例化我现在对类的生成与基于类的实例化做一个总结对于一个采用class关键字编写的类代码片段Python解释器会采用如下的方式来构建这个类提取元类如果没有显式指定type类即为元类并将它和类的定义信息类名、基类、为类定义的所有成员和关键字参数作为参数调用元类的__new__方法对于自定义的元类如果它或者它自定义的基类重写了__new__方法原则上它可以返回任何一个类对象如果没有显式指定元类或者指定的自定义元类包括它自定义的基类没有重写__new__方法那么type类的__new__方法会被调用。按照上面的逻辑它会严格我们定义的形式生成并返回对应的类对象将__new__方法返回的类对象以及类的定义信息作为参数调用元类的__init__方法如果指定的自定义元类或者它自定义的基类重写了__init__方法如果没有采用slots模式原则上它可以对传入的类对象做任意的加工一旦类对象时基于slots模式创建的由于类的内存布局已经固定将不能对类成员进行添加和删除如果没有显式指定元类或者指定的自定义元类包括它自定义的基类没有重写__init__方法那么type类的__init__方法会被调用此方法为空方法没有任何操作当我们将类对象作为工厂函数进行实例化的时候调用的是元类的__call__方法其中第一个参数为作为工厂函数的类对象如果指定的自定义元类或者它自定义的基类重写了__call__方法原则上它可以返回任何一个对象如果没有显式指定元类或者指定的自定义元类包括它自定义的基类没有重写__call__方法那么type类的__call__方法会被调用此时反映的就是默认的实例化流程调用类的__new__方法构建一个基础对象如果类或者它自定义的基类重写了__new__方法原则让它可以返回任何一个对象否则从object类继承的__new__方法被用于创建这个基础对象将__new__方法构建的基础对象连同指定的参数传入类的__init__方法如果类或者它自定义的基类重写了__init__方法原则上可以对提供的基础对象作任何加工否则从object的继承的__init__方法会被调用但它什么都不会做6. 元类的实例方法由于元类的实例是以它为元类的类所以对于由它创建的类来说定义在元类的实例方法就是它的类方法。以如下这个PointMeta元类为例parse是它的实例方法。但是对于将它作为元类的Point来说parse成了它的类方法。classPointMeta(type):def__new__(cls,name,bases,namespace,**kwds):namespace[x]0namespace[y]0returnsuper().__new__(cls,name,bases,namespace)defparse(cls,s):x_str,y_strs.split(,)pointcls()point.xint(x_str)point.yint(y_str)returnpointclassPoint(metaclassPointMeta):passpPoint.parse(1,2)assertp.x1assertp.y27. 如何确定实例的类型有没有想过当我们调用isinstance或者type函数时它们如何确定指定对象所述的类。有人说不是每个对象都有一个__class__字段吗没错但是这个字段由来源于何处呢不论什么编程语言一个对象总是对应于一块连续或者多块不连续的内存空间对象提供的所有信息皆来源于此对象所属的类自然也不例外。所以了解了对象的内存布局你就了解了对象的一切信息。对于一个常规的对象不是类对象类对象的布局要复杂得多其内存布局取决于它是否采用slots模式。7.1 非 slots 模式动态字典布局默认这是Python最常用的模式其核心是灵活性内存采用如下结构进行布局PyObject Header包含引用计数和指向类型对象的指针。__dict__指针指向一个真实的 Python 字典对象。__weakref__指针用于支持弱引用。动态性是这种布局的最大优势。由于属性attribute不存储在实例本身而是存储在__dict__指针指向的字典里我们可以随时添加任意成员。但带来的问题是内存开销大由于字典为了减少冲突会预留空间每个实例都要额外维护一个哈希表对象。每次定位数据成员进行的哈希也会影响访问速度较慢。7.2 slots 模式这种模式采用紧凑型数组布局。当我们定义了__slots__ (a, b)Python会采用如下的结构布局对象内存PyObject Header引用计数和类型指针。固定偏移量的属性位在内存中直接为a和b预留位置存储的是指向具体对象的指针。没有__dict__除非你在slots里显式加上’dict’。这是静态编译语言采用的内存布局方式这样导致实例只能拥有slots中定义的成员尝试添加新属性会抛出AttributeError。虽然丧失了动态性但是因极其紧凑内存布局去掉了整个字典对象的开销。在拥有数百万个小对象时内存占用通常能降低40%-70%。属性访问变成了基地址 固定偏移量的直接内存寻址不需要哈希计算能力也得到提升。在回到标题的问题如何确定实例的类型很简单不论采用何种内存布局它都有一个PyObject Header其中包含执行类对象的指针这就是判断对象类型的依据。那么在上述的实例化流程中这个指针是由谁写入的呢Python中针对某个类的实例化总是会调用object的__new__方法它会根据指定的类型计算对象所需的内存大小并分配一段大小匹配的内存然后将类对象的地址写入PyObject Header中的类型指针。有人可能会问刚才不是说非slots模式下对象内存是动态分配的吗话虽没错但这里的动态性指定是__dict__指向的字典这里的内存仅包含PyObject Header和额外两个指针而已。classobject:def__new__(cls)-Self