這篇文章和大家一起聊聊Python 3.8 中類別和物件背後的一些概念和實作原理,主要嘗試解釋Python 類別和物件屬性的存儲,函數和方法,描述器,物件記憶體佔用的最佳化支持,以及繼承與屬性查找等相關問題。
讓我們從一個簡單的範例開始:
class Employee: outsource = False def __init__(self, department, name): self.department = department self.name = name @property def inservice(self): return self.department is not None def __repr__(self): return f"<Employee: {self.department}-{self.name}>"employee = Employee('IT', 'bobo')复制代码
employee
物件是Employee
類別的一個實例,它有兩個屬性department
和name
,其值屬於該實例。 outsource
是類別屬性,擁有者是類,該類別的所有實例物件共用此屬性值,這跟其他物件導向語言一致。
更改類別變數會影響到該類別的所有實例物件:
>>> e1 = Employee('IT', 'bobo')>>> e2 = Employee('HR', 'cici')>>> e1.outsource, e2.outsource (False, False)>>> Employee.outsource = True>>> e1.outsource, e2.outsource>>> (True, True)复制代码
這僅限於從類別更改,當我們從實例更改類別變數時:
>>> e1 = Employee('IT', 'bobo')>>> e2 = Employee('HR', 'cici')>>> e1.outsource, e2.outsource (False, False)>>> e1.outsource = True>>> e1.outsource, e2.outsource (True, False)复制代码
是的,當你試圖從實例物件修改類別變數時,Python 不會更改該類別的類別變數值,而是建立一個同名的實例屬性,這是非常正確且安全的。在搜尋屬性值時,實例變數會優先於類別變量,這將在繼承與屬性尋找一節中詳細解釋。
值得特別注意的是,當類別變數的類型是可變類型時,你是從實例物件中更改的它們的:
>>> class S:... L = [1, 2] ...>>> s1, s2 = S(), S()>>> s1.L, s2.L ([1, 2], [1, 2])>>> t1.L.append(3)>>> t1.L, s2.L ([1, 2, 3], [1, 2, 3])复制代码
好的實踐方式是應盡量的避免這樣的設計。
本小節我們一起來看看 Python 中的類別屬性、方法及實例屬性是如何關聯儲存的。
在Python 中,所有實例屬性都儲存在__dict__
字典中,這就是一個常規的dict
,對於實例屬性的維護即是從該字典中取得和修改,它對開發者是完全開放的。
>>> e = Employee('IT', 'bobo')>>> e.__dict__ {'department': 'IT', 'name': 'bobo'}>>> type(e.__dict__)dict>>> e.name is e.__dict__['name']True>>> e.__dict__['department'] = 'HR'>>> e.department'HR'复制代码
正因為實例屬性是採用字典來存儲,所以任何時候我們都可以方便的給對象添加或刪除字段:
>>> e.age = 30 # 并没有定义 age 属性>>> e.age30>>> e.__dict__ {'department': 'IT', 'name': 'bobo', 'age': 30}>>> del e.age>>> e.__dict__ {'department': 'IT', 'name': 'd'}复制代码
我們也可以從字典中實例化一個對象,或透過保存實例的__dict__
來恢復實例。
>>> def new_employee_from(d):... instance = object.__new__(Employee)... instance.__dict__.update(d)... return instance ...>>> e1 = new_employee_from({'department': 'IT', 'name': 'bobo'})>>> e1 <Employee: IT-bobo>>>> state = e1.__dict__.copy()>>> del e1>>> e2 = new_employee_from(state)>>> e2>>> <Employee: IT-bobo>复制代码
因為__dict__
的完全開放,所以我們可以在其中加上任何hashable 的immutable key,例如數字:
>>> e.__dict__[1] = 1>>> e.__dict__ {'department': 'IT', 'name': 'bobo', 1: 1}复制代码
這些非字串的欄位是我們無法透過實例物件存取的,為了確保不會出現這樣的情況,除非必要的情況下,一般最好不要直接對__dict__
進行寫入操作,甚至不要直接操作__dict__
。
所以有個說法是 Python is a "consenting adults language"。
這種動態的實作使得我們的程式碼非常靈活,很多時候非常的便利,但這也付出了儲存和效能上的開銷。所以 Python 也提供了另一個機制(__slots__
)來放棄使用 __dict__
,以節約內存,提高性能,詳見 __slots__ 一節。
同樣的,類別屬性也在儲存在類別的__dict__
字典中:
>>> Employee.__dict__ mappingproxy({'__module__': '__main__', 'outsource': True, '__init__': <function __main__.Employee.__init__(self, department, name)>, 'inservice': <property at 0x108419ea0>, '__repr__': <function __main__.Employee.__repr__(self)>, '__str__': <function __main__.Employee.__str__(self)>, '__dict__': <attribute '__dict__' of 'Employee' objects>, '__weakref__': <attribute '__weakref__' of 'Employee' objects>, '__doc__': None}>>> type(Employee.__dict__) mappingproxy复制代码
與實例字典的「開放」不同,類別屬性使用的字典是一個MappingProxyType
對象,它是一個不能setattr
的字典。這意味著它對開發者是唯讀的,其目的正是為了保證類別屬性的鍵都是字串,以簡化和加快新型類別屬性的查找和 __mro__
的搜尋邏輯。
>>> Employee.__dict__['outsource'] = FalseTypeError: 'mappingproxy' object does not support item assignment复制代码
因為所有的方法都歸屬於一個類,所以它們也儲存在類別的字典中,從上面的例子中可以看到已有的__init__
和__repr__
方法。我們可以再增加幾個來驗證:
class Employee: # ... @staticmethod def soo(): pass @classmethod def coo(cls): pass def foo(self): pass复制代码
>>> Employee.__dict__ mappingproxy({'__module__': '__main__', 'outsource': False, '__init__': <function __main__.Employee.__init__(self, department, name)>, '__repr__': <function __main__.Employee.__repr__(self)>, 'inservice': <property at 0x108419ea0>, 'soo': <staticmethod at 0x1066ce588>, 'coo': <classmethod at 0x1066ce828>, 'foo': <function __main__.Employee.foo(self)>, '__dict__': <attribute '__dict__' of 'Employee' objects>, '__weakref__': <attribute '__weakref__' of 'Employee' objects>, '__doc__': None})复制代码
目前為止,我們已經知道,所有的屬性和方法都儲存在兩個__dict__
#字典中,現在我們來看看Python 是如何進行屬性查找的。
Python 3 中,所有類別都隱含的繼承自object
,所以總是會有一個繼承關係,而且Python 是支援多重繼承的:
>>> class A:... pass...>>> class B:... pass...>>> class C(B):... pass...>>> class D(A, C):... pass...>>> D.mro() [<class '__main__.D'>, <class '__main__.A'>, <class '__main__.C'>, <class '__main__.B'>, <class 'object'>]复制代码
#mro()
是一個特殊的方法,它會傳回類別的線性解析順序。
屬性存取的預設行為是從物件的字典中取得、設定或刪除屬性,例如對於 e.f
的查找簡單描述是:
e.f
的查找顺序会从e.__dict__['f']
开始,然后是type(e).__dict__['f']
,接下来依次查找type(e)
的基类(__mro__
顺序,不包括元类)。 如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重载默认行为并转而发起调用描述器方法。这具体发生在优先级链的哪个环节则要根据所定义的描述器方法及其被调用的方式来决定。
所以,要理解查找的顺序,你必须要先了解描述器协议。
简单总结,有两种描述器类型:数据描述器和和非数据描述器。
如果一个对象除了定义
__get__()
之外还定义了__set__()
或__delete__()
,则它会被视为数据描述器。仅定义了__get__()
的描述器称为非数据描述器(它们通常被用于方法,但也可以有其他用途)
由于函数只实现 __get__
,所以它们是非数据描述器。
Python 的对象属性查找顺序如下:
请记住,无论你的类有多少个继承级别,该类对象的实例字典总是存储了所有的实例变量,这也是 super
的意义之一。
下面我们尝试用伪代码来描述查找顺序:
def get_attribute(obj, name): class_definition = obj.__class__ descriptor = None for cls in class_definition.mro(): if name in cls.__dict__: descriptor = cls.__dict__[name] break if hasattr(descriptor, '__set__'): return descriptor, 'data descriptor' if name in obj.__dict__: return obj.__dict__[name], 'instance attribute' if descriptor is not None: return descriptor, 'non-data descriptor' else: raise AttributeError复制代码
>>> e = Employee('IT', 'bobo')>>> get_attribute(e, 'outsource') (False, 'non-data descriptor')>>> e.outsource = True>>> get_attribute(e, 'outsource') (True, 'instance attribute')>>> get_attribute(e, 'name') ('bobo', 'instance attribute')>>> get_attribute(e, 'inservice') (<property at 0x10c966d10>, 'data descriptor')>>> get_attribute(e, 'foo') (<function __main__.Employee.foo(self)>, 'non-data descriptor')复制代码
由于这样的优先级顺序,所以实例是不能重载类的数据描述器属性的,比如 property
属性:
>>> class Manager(Employee):... def __init__(self, *arg):... self.inservice = True... super().__init__(*arg) ...>>> m = Manager("HR", "cici") AttributeError: can't set attribute复制代码
上面讲到,在查找属性时,如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重载默认行为并转而发起描述器方法调用。
描述器的作用就是绑定对象属性,我们假设 a
是一个实现了描述器协议的对象,对 e.a
发起描述器调用有以下几种情况:
e.__get__(a)
,不常用e.a
会被转换为调用: type(e).__dict__['a'].__get__(e, type(e))
E.a
会被转换为调用: E.__dict__['a'].__get__(None, E)
在继承关系中进行绑定时,会根据以上情况和 __mro__
顺序来发起链式调用。
我们知道方法是属于特定类的函数,唯一的不同(如果可以算是不同的话)是方法的第一个参数往往是为类或实例对象保留的,在 Python 中,我们约定为 cls
或 self
, 当然你也可以取任何名字如 this
(只是最好不要这样做)。
上一节我们知道,函数实现了 __get__()
方法的对象,所以它们是非数据描述器。在 Python 访问(调用)方法支持中正是通过调用 __get__()
将调用的函数绑定成方法的。
在纯 Python 中,它的工作方式如下(示例来自描述器使用指南):
class Function: def __get__(self, obj, objtype=None): if obj is None: return self return types.MethodType(self, obj) # 将函数绑定为方法复制代码
在 Python 2 中,有两种方法: unbound method 和 bound method,在 Python 3 中只有后者。
bound method 与它们绑定的类或实例数据相关联:
>>> Employee.coo <bound method Employee.coo of <class '__main__.Employee'>> >>> Employee.foo<function __main__.Employee.foo(self)> >>> e = Employee('IT', 'bobo') >>> e.foo<bound method Employee.foo of <Employee: IT-bobo>>复制代码
我们可以从方法来访问实例与类:
>>> e.foo.__self__ <Employee: IT-bobo>>>> e.foo.__self__.__class__ __main__.Employee复制代码
借助描述符协议,我们可以在类的外部作用域手动绑定一个函数到方法,以访问类或实例中的数据,我将以这个示例来解释当你的对象访问(调用)类字典中存储的函数时将其绑定成方法(执行)的过程:
现有以下函数:
>>> def f1(self):... if isinstance(self, type):... return self.outsource... return self.name ...>>> bound_f1 = f1.__get__(e, Employee) # or bound_f1 = f1.__get__(e)>>> bound_f1 <bound method f1 of <Employee: IT-bobo>>>>> bound_f1.__self__ <Employee: IT-bobo>>>> bound_f1()'bobo'复制代码
总结一下:当我们调用 e.foo()
时,首先从 Employee.__dict__['foo']
中得到 foo
函数,在调用该函数的 foo
方法 foo.__get__(e)
将其转换成方法,然后执行 foo()
获得结果。这就完成了 e.foo()
-> f(e)
的过程。
如果你对我的解释感到疑惑,我建议你可以阅读官方的描述器使用指南以进一步了解描述器协议,在该文的函数和方法和静态方法和类方法一节中详细了解函数绑定为方法的过程。同时在 Python 类一文的方法对象一节中也有相关的解释。
Python 的对象属性值都是采用字典存储的,当我们处理数成千上万甚至更多的实例时,内存消耗可能是一个问题,因为字典哈希表的实现,总是为每个实例创建了大量的内存。所以 Python 提供了一种 __slots__ 的方式来禁用实例使用 __dict__
,以优化此问题。
通过 __slots__
来指定属性后,会将属性的存储从实例的 __dict__
改为类的 __dict__
中:
class Test: __slots__ = ('a', 'b') def __init__(self, a, b): self.a = a self.b = b复制代码
>>> t = Test(1, 2)>>> t.__dict__ AttributeError: 'Test' object has no attribute '__dict__'>>> Test.__dict__ mappingproxy({'__module__': '__main__', '__slots__': ('a', 'b'), '__init__': <function __main__.Test.__init__(self, a, b)>, 'a': <member 'a' of 'Test' objects>, 'b': <member 'b' of 'Test' objects>, '__doc__': None})复制代码
关于 __slots__ 我之前专门写过一篇文章分享过,感兴趣的同学请移步理解 Python 类属性 __slots__ 一文。
也许你还有疑问,那函数的 __get__
方法是怎么被调用的呢,这中间过程是什么样的?
在 Python 中 一切皆对象,所有对象都有一个默认的方法 __getattribute__(self, name)
。
该方法会在我们使用 .
访问 obj
的属性时会自动调用,为了防止递归调用,它总是实现为从基类 object
中获取 object.__getattribute__(self, name)
, 该方法大部分情况下会默认从 self
的 __dict__
字典中查找 name
(除了特殊方法的查找)。
话外:如果该类还实现了
__getattr__
,则只有__getattribute__
显式地调用或是引发了AttributeError
异常后才会被调用。__getattr__
由开发者自己实现,应当返回属性值或引发AttributeError
异常。
而描述器正是由 __getattribute__()
方法调用,其大致逻辑为:
def __getattribute__(self, key): v = object.__getattribute__(self, key) if hasattr(v, '__get__'): return v.__get__(self) return v复制代码
请注意:重写
__getattribute__()
会阻止描述器的自动调用。
函数也是 Python function
对象,所以一样,它也具有任意属性,这有时候是有用的,比如实现一个简单的函数调用跟踪装饰器:
def calltracker(func): @wraps(func) def wrapper(*args, **kwargs): wrapper.calls += 1 return func(*args, **kwargs) wrapper.calls = 0 return wrapper@calltrackerdef f(): return 'f called'复制代码
>>> f.calls0>>> f()'f called'>>> f.calls1复制代码
相关免费学习推荐:python视频教程
以上是一起深入 Python 類別的內部的詳細內容。更多資訊請關注PHP中文網其他相關文章!