走进 Python 类的内部

这篇文章和大家一起聊一聊 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.age

30

>>> 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'] = False

TypeError: '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 类一文的方法对象一节中也有相关的解释。

__slots__

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__ 一文。

补充

__getattribute__ 和 __getattr__

也许你还有疑问,那函数的 __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

@calltracker

def f():

    return 'f called'

[点击并拖拽以移动]

>>> f.calls

0

>>> f()

'f called'

>>> f.calls

1

想学习更多关于python的知识可以加我QQ:2955637827 

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容

  • Python 面向对象 Python从设计之初就已经是一门面向对象的语言,正因为如此,在Python中创建一个...
    今早上阅读 516评论 0 0
  • Python 面向对象Python从设计之初就已经是一门面向对象的语言,正因为如此,在Python中创建一个类和对...
    顺毛阅读 4,212评论 4 16
  • 一 python特殊属性杏彩平台制作Q> 1279829431【源码链接】dashengba.com 1 总述 属...
    实打实阅读 395评论 0 0
  • 一、Python简介和环境搭建以及pip的安装 4课时实验课主要内容 【Python简介】: Python 是一个...
    _小老虎_阅读 5,737评论 0 10
  • 在程序运行过程中,总会遇到各种各样的错误。 有的错误是程序编写有问题造成的,比如本来应该输出整数结果输出了字符串,...
    jbb_43b0阅读 840评论 0 0