测试开发之Python核心笔记(19): 深入理解类的属性

属性作为类的重要组成部分,除了平时常用的读取和设置操作之外,还有很多隐藏的、高级的操作。比如属性的查找顺序、属性的类型检查、限制属性的动态添加等等。这一小节,就让我们深入理解属性的各种高级操作。

19.1 通过字符串操作属性和方法

反射是一个很重要的概念,它可以把字符串映射到实例的属性或者方法,然后可以去执行调用、修改等操作。
Python提供了四个函数,可以通过字符串操作属性和方法:hasattr、getattr、setattr和delattr。但是要注意属性和方法不能是私有的,如果是以“_”开头的属性和方法,那将无法对其进行操作。

看下官方文档说明:

def hasattr(*args, **kwargs): # real signature unknown # hasattr(obj, 'x')  ① 通过"字符串"判断对象的属性或方法是否存在。
    """
    Return whether the object has an attribute with the given name.
    
    This is done by calling getattr(obj, name) and catching AttributeError.
    """
    pass
    
def getattr(object, name, default=None): # known special case of getattr ② 获取object对象的name属性
    """
    getattr(object, name[, default]) -> value  
    
    Get a named attribute from an object; getattr(x, 'y') is equivalent to x.y.  相当于点操作符
    When a default argument is given, it is returned when the attribute doesn't
    exist; without it, an exception is raised in that case.  当name属性不存在时返回default,否则会引发AttributeError
    """
    pass
    
def setattr(x, y, v): # real signature unknown; restored from __doc__  ③ 通过"字符串"动态设置对象的属性或方法。
    """
    Sets the named attribute on the given object to the specified value. 
    
    setattr(x, 'y', v) is equivalent to ``x.y = v''  # 相当于点操作符
    """
    pass
    
def delattr(x, y): # real signature unknown; restored from __doc__  ④ 通过"字符串"删除对象的属性或方法。
    """
    Deletes the named attribute from the given object.
    
    delattr(x, 'y') is equivalent to ``del x.y''  # 删除属性
    """
    pass

了解了这个功能,发现其实效果都是与点操作符一样的。那么这四个函数有什么实际用处呢?答案是:反射。

反射就是用户通过输入字符串,在代码里通过上面四个函数处理这个字符串,把字符串反射成内存对象。对其进行调用,这个应用非常常见。举个例子:

# 反射应用:
class FileControl:
    def run(self):
        while True:
            # 让用户输入上传或下载功能的命令:
            user_input = input('请输入 上传(upload) 或 下载(download) 功能:').strip()
            # 通过用户输入的【字符串】判断方法是否存在,然后调用相应的方法
            if hasattr(self, user_input):
                func = getattr(self, user_input)  # 【获取字符串,转成属性】
                func()  # 【可以调用】
            else:
                print('输入有误!')

    def upload(self):
        print('文件正在上传...')

    def download(self):
        print('文件正在下载...')


file_control_obj = FileControl()
file_control_obj.run()

可以看出来,反射就是把字符串反射成内存对象。

再来看一个反射的函数带参数的例子。

import re


class Role(object):
    def __init__(self, name, weapon, clothes, life=100):
        self.name = name
        self.weapon = weapon
        self.clothes = clothes
        self.life = life

    def got_headshoot(self):
        print("%s got headshot!" % self.name)
        self.life -= 50

    def buy_weapon(self, weapon_name):
        print("%s buy a %s" % (self.name, weapon_name))

    def fight(self, weapon, place):
        print("%s use %s to shoot %s" % (self.name, weapon, place))


role_default = Role("zzz", "AWM", "3-level")

if __name__ == '__main__':
    # complete_command = "buy_weapon('gun')"
    complete_command = "fight('gun',123)"
    command = complete_command.split('(')[0]
    if hasattr(role_default, command):
        command_act = getattr(role_default, command)  # 将字符串能转成函数对象
        parameter_count = command_act.__code__.co_argcount
        if parameter_count == 1:  # 表示没有参数
            command_act()
        else:
            pattern = r"\((.*)\)"  # 贪婪模式,尽量多吃
    		parameter_list = re.findall(pattern, complete_command)[0].split(",")
            command_act(*parameter_list)  # 参数传递给函数对象
    else:
        print('Command not exists!')

语句command_act.__code__.co_argcount用于统计函数所带参数的个数。我们在知道所需参数个数不为0后,通过正则表达式从输入的字符串complete_command 中提前出所需要的参数,并传递给函数。

反射在工作中,到底有啥用?

  • 通过字符串导入模块
temp = "re"
model = __import__(temp)
content = "Life is short, I love Python, Python is the best programming language!"
pattern = model.compile('Python')  # 编译模式串
substitution = pattern.sub('PHP', content)  # 替换
print(substitution)
  • 以字符串的形式使用模块的方法
import re
func = "compile"
content = "Life is short, I love Python, Python is the best programming language!"
pattern = getattr(re, func)('Python')  # 编译模式串
substitution = pattern.sub('PHP', content)  # 替换
print(substitution)

19.2 __slots__魔法

通过使用__slot__限制属性的动态添加功能,在创建大量对象的场合可以减少内存占用

Python用一个字典来保存一个对象的实例属性,通过__dict__可以查看所有的实例属性。Python是动态语言,因此可以在代码运行时,设置任意的新属性。比如,在实例化后,可以给实例动态添加publisher属性:

doc = Document("title", "author", "babababba")
doc.publisher = "出版社"
print(doc.publisher)

如果避免在运行时,设置任意的新属性,可以在类定义时,添加__slots__属性,规定这个类的对象能够包含的属性列表。这样,当给对象添加不在这个列表中的属性时,则会报错。

例如,在上面的类定义中增加添加__slots__属性,只能包含titleauthor__context__这三个属性。这样,在动态添加publisher属性时将会报AttributeError错误。不过, __slot__限制的是实例属性的添加,不限制类属性的添加。

class Document(object):
    __slots__ = ['title', 'author', '__context']
    ......

对象的__dict__字典会占用大量内存,使用__slot__后,就不会存在__dict__了,在创建大量对象的场合就可以减少内存占用了,这也是设计__slot__的初衷。虽然可以减少内存的使用,但是我们也不用滥用它,除非你预计得到这个类会创建很多的实例,否则不要用它。因为当你用它之后,就不会存在实例属性的字典__dict__了,而Python的很多特性都依赖于这个字典实现的,因此可能会导致某些异常的问题。

19.3 @property魔法

@property装饰器广泛应用在类的定义中,可以让调用者写出简短的代码,同时保证对参数进行必要的检查。

  1. 实例方法变成其同名属性

@property可以把一个实例方法变成其同名属性,以支持.号访问。

class Circle: 
  def __init__(self, radius): 
    self.radius = radius 
   
  @property  # area本来是函数,使用@property装饰之后,使用area时可以像属性一样使用了
  def area(self): 
    return 3.14 * self.radius ** 2

if __name__ == "__main__":   
    c = Circle(4) 
    print(c.radius)
    print(c.area) 

area虽然是定义成一个方法的形式,但是加上@property后,可以通过访问c.area得到方法的返回值,而不必像c.area()调用它。

  1. 利用property校验属性的类型

例如,我们在Student类中定义一个age字段,合法值一般为包含0的正整数,但是在python中无正整数的类型,只能自己来校验。方法是对age方法用@age.setter装饰,见下面代码:

class Student:
    def __init__(self, name, age, address):
        self._name = None  # 注意,属性设置为私有的,这样避免在类外部直接对属性进行设置
        self._age = None
        self._address = None

    @property
    def name(self):
        return self._name  # 返回私有属性值

    @name.setter
    def name(self, value):
        if not isinstance(value, str):  # 检查value类型
            raise ValueError("Must be string")
        self._name = value  # 设置私有属性值

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if isinstance(value, int) and value > 0:
            self._age = value
        else:
            raise ValueError("Must be positive number")

    @property
    def address(self):
        return self._address

    @address.setter
    def address(self, value):
        if not isinstance(value, str):
            raise ValueError("Must be string")
        self._address = value


if __name__ == "__main__":
    # n1 = Student(12, 10, "beijing")
    n2 = Student("Jim", -1, "shanghai")

代码中,实例化n2时,传递的年龄是负数,执行代码,将会得到ValueError: Must be positive number异常。注意,要想使用@age.setter,一定要先@property装饰age属性。

上面的这段代码对name、age和address都进行了类似的限制。通过观察发现对name的检查和对address的检查是一样的,但是代码冗余太多。描述符提供了优雅、简洁、健壮和可重用的解决方案,关于描述符我们后面在介绍。

再来一个温度的例子。这个类接收摄氏度,内部自动转成华氏温度,可以通过fahrenheit属性访问到。属性必须标记为_开头,表示私有属性,不应在类外部直接赋值。

class Celsius:
    def __init__(self, temperature=0):
        self._temperature = temperature

    @property
    def fahrenheit(self):  # ②
        return (self._temperature * 1.8) + 32  # ③

    @property
    def temperature(self):
        print("Getting value")
        return self._temperature

    @temperature.setter  # ①
    def temperature(self, value):
        if value < -273:
            raise ValueError("Temperature below -273 is not possible")
        print(f"Setting value to {value}")
        self._temperature = value


if __name__ == "__main__":
    c = Celsius(37)
    print(c.temperature)
    print(c.fahrenheit)
    c.temperature = 10
    c.temperature = -300

19.4 描述符

利用property校验属性,可以看到有很多重复的代码。对于name和address这两个属性的校验代码完全一样。 有没有办法简化代码呢?有。这就是描述符。

在python中,如果一个类定义了__get____set____delete__方法中的一个或者多个,那么这个类的对象被称之为描述符descriptor。

描述符规定了对象的属性在获取、设置和删除时,可以做的额外操作。常见的“额外操作”是类型检查。

描述符的作用是用来代理一个类的属性,需要注意的是描述符不能定义在类的构造函数中,只能定义为类的属性,它只属于类的,不属于实例。

使用过Django中的Models类的同学,应该有印象,Models类字段能够自动校验数据类型,比如age=models.IntegerField(),会校验age设置的数据类型必须为Integer。下面我们看看如何用描述符实现这个功能。看一个例子,通过阅读注释,理解代码。

class StringPropertyDescriptor:  # 这就是一个描述符类
    def __init__(self, init_value=None):
        self.value = init_value

    def __get__(self, instance, cls):  # 属性被访问时调用。instance是使用描述符实例的类的实例(student),cls就是这个类(Student)
        print('call __get__', instance, cls)
        if instance is None:  # 作为类属性访问时,instance是None
            return self  # 返回描述符本身
        else:  # 作为实例属性访问
            return instance.__dict__[self.value]  # 使用实例字典操作

    def __set__(self, instance, value):  # 实例化类或者设置属性时,被调用
        print('call __set__', instance, value)
        if not isinstance(value, str):
            raise ValueError("Must be string")
        instance.__dict__[self.value] = value  # 使用实例字典操作

    def __delete__(self, instance): # 删除实例属性时,被调用
        print('call __delete__', instance)
        del instance.__dict__[self.value]  # 使用实例字典操作

# 在这个类Student中使用描述符StringPropertyDescriptor
class Student:
    name = StringPropertyDescriptor()  # 【提醒!】Student的类属性设置成描述符的实例

    def __init__(self, name):
        self.name = name  # 【重要】类属性name赋值给实例属性name


if __name__ == '__main__':
    student = Student("春和景明")  # 实例化Student类会调用__set__
    student.name = "liuchunming"  # 设置Student类name属性,会调用__set__
    print(Student.name)  # 访问Student类的类属性name,会调用__get__,返回描述符本身。
    print(student.name)  # 访问Student类的实例属性name,会调用Student.name.__get__(),返回"春和景明"
    del student.name  # 删除Student类name属性,会调用__delete__
    # student.name = 1000  # 输出ValueError("Must be string")

如果一个对象定义了 __set__()__delete__(),则它会被视为数据描述符。 仅定义了 __get__()的描述符称为非数据描述符(它们通常被用于方法,但也可以有其他用途)。

描述符类中对实例的属性的操作,都是通过属性字典__dict__进行的,从而实现对属性访问、修改和删除时的额外操作。上一小节,我们介绍过__dict__是一个字典,包含完全独属于实例自己的属性,不包含从父类继承到的属性。

可以直接通过__dict__访问、设置、修改、删除属性,描述符类中通过instance.__dict__[self.value] = value 设置value属性,等效于通过类的对象实例self.value=value设置value属性。 student.name 等价于 StringPropertyDescriptor.__get__(name, student, Student)

上面的例子中__get__() 看上去有点复杂,主要是因为描述符被用作实例属性和类属性的不同。当一个描述符被当作一个类属性访问时,上面的例子中直接返回了描述符对象本身,当然也可以做一些其它操作。

如果给Student类增加一个address属性,也规定它必须是字符串,那么修改Student类,增加代码,相比使用@property简单很多:

class Student:
    name = PropertyDescriptor("初始值")  # 描述符的实例只能作为Student类的’类属性‘
    address = PropertyDescriptor("初始值")

    def __init__(self, name): 
        self.name = name  # 【重要】类属性name赋值给实例属性name
        self.address = address

描述符通常是那些使用到装饰器或元类的大型框架中的一个组件。同时它们的使用也被隐藏在后面。 举个例子,下面是一些更高级的基于描述符的代码,并涉及到一个类装饰器,实现的功能与上面StringPropertyDescriptor描述符一样:

class TypedAssertion:
    """对name进行expected_type类型检查的描述符"""

    def __init__(self, name, expected_type):
        self.name = name
        self.expected_type = expected_type

    def __get__(self, instance, cls):
        if instance is None:
            return self
        else:
            return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError('Expected ' + str(self.expected_type))
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        del instance.__dict__[self.name]


def type_decorator(**kwargs):
    """检查实例属性类型的装饰器"""
    def decorate(cls):  # 对cls类进行装饰
        for name, expected_type in kwargs.items():
            # 给cls类添加属性类name,并给属性设置描述符实例
            setattr(cls, name, TypedAssertion(name, expected_type))  # 参考反射那节
        return cls

    return decorate


if __name__ == '__main__':
    @type_decorator(brand=str, shares=int, price=float)
    class Stock:
        def __init__(self, brand, shares, price):
            self.brand = brand
            self.shares = shares
            self.price = price


    s1 = Stock("nio", "14000", 10.2)
    s2 = Stock("nio", 14000, 10.2)

通过装饰器使用描述符更加优雅。

不过不要迷恋描述符,如果你只是想简单的对某个自定义类的单个属性进行访问控制时,使用@property技术会更加容易。

当程序中有很多重复代码的时候再考虑使用描述符,比如你想在你代码的很多地方使用描述符提供的功能或者将它作为一个函数库特性的时候,采用描述符才是合适的。

关于描述符的Python官方文档请参考:https://docs.python.org/zh-cn/3/howto/descriptor.html

19.5 属性的设置与读取的底层原理

实例对象通过.点操作符进行属性的访问和设置。例如访问实例d的title属性:

d=Document("demo", "chunming", "show you class")
print(d.title)

点号背后的原理是什么呢?

19.5.1 属性访问拦截器__getattribute__

访问类的属性时,其实都会经过当前类的或者父类的__getattribute__方法。这个方法是属性访问拦截器。Python中只要定义的类继承了object的类,就默认存在属性拦截器,只不过是拦截后没有进行任何操作,而是直接返回。如果我们想在访问属性时做一些操作,比如查看权限、打印log日志等,可以在自己定义的类中改写__getattribute__方法来实现相关功能。

比如在Document类中实现一个__getattribute__,目的是在访问属性之前打印一下log。

    def __getattribute__(self, item):
        print("属性拦截器,你正在访问属性{}".format(item))
        return super().__getattribute__(item)

之后通过d.title 访问title属性时,都会打印字符串"属性拦截器,你正在访问属性title"。注意在__getattribute__方法中访问当前实例的属性时,要通过父类的方法super().__getattribute__(item),以便避开无限循环的陷阱。什么是无限循环?请看一个例子:

class D:
    def __init__(self):
        self.test = 20
        self.test2 = 21

    def __getattribute__(self, name):
        if name == 'test':
            return 0.
        else:
            return self.__dict__[name]


d = D()
print(d.test)
print(d.test2)

由于在__ getattribute__内访问 self .__ dict __属性,就会导致递归错误RecursionError: maximum recursion depth exceeded in comparison。正确的办法是,通过父类的__getattribute__方法访问当前实例的属性。因此需要上面的代码将return self.__dict__[name]修改为return super().__getattribute__(name)

还有一种递归错误的场景,

class People:
    def __init__(self, name):
        self.name = name

    def __getattribute__(self, obj):
        if obj.endswith("e"):
            return super().__getattribute__(obj)
        else:
            return self.get_gender()

    def get_gender(self):
        return "性别"


p = People("马克思")
print(p.name)  # name是以e结尾,所以打印出"马克思"
print(p.gender)  

执行p.name时,因为name是以e结尾,所以直接返回name属性。但是执行p.gender时,先调用__getattribute__方法,经过判断后,gender不是以e结尾,返回的是self.get_gender(),即self.get_gender的执行结果,当调用get_gender属性时,又会去调用__getattribute__方法,反反复复,没完没了,形成了递归调用且没有退出机制。

19.5.2 属性访问拦截器__getattr__

Python提供了另外一个方法__getattr__,当访问某一个对象不存在的属性时被调用。

通常当访问某一个对象不存在的属性时,__getattribute__会抛出AttributeError异常。但是,如果对象所属的类定义了__getattr__()方法后, 访问某一个对象不存在的属性时,则返回这个方法的返回值,避免了AttributeError异常。

class Student(object):

    def __init__(self):  # 只定义了name属性,没定义score属性
        self.name = 'Michael'

    def __getattr__(self, attr):  # 当访问不存在的属性时,到这里找,比如找到score,则返回99,找不到则返回None
        print("遇到了一个不存在的属性")
        if attr == 'score':
            return 99


if __name__ == '__main__':
    s = Student()
    print(s.score)  # 99
    print(s.age)  # None
	print(s.name)
	print(s.__dict__)

执行上面的代码将会得到如下输出:

遇到了一个不存在的属性
99
遇到了一个不存在的属性
None
Michael
{
    
    'name': 'Michael'}

可见当使用点号获取实例属性时,如果属性不存在就自动调用__getattr__方法。

这里说的属性不存在说的是,属性name没有在实例instance的__dict__或它构造类的__dict__或者父类的__dict__中。

当属性name可以通过正常机制追溯到时,__getattr__是不会被调用的。比如访问s.name就不会调用__getattr__方法。因为s.__dict__中包含这个属性:

{'name': 'Michael'}

19.5.3 属性设置拦截器__setattr__

与获取属性相对应的是设置属性,Python提供了__setattr__方法,当设置类实例属性时自动调用。

class Dict(dict):
    def __getattr__(self, key):
        try:
            print("调用原本没有定义的属性")
            return self[key]
        except KeyError:
            raise AttributeError(r"'Dict' object has no attribute '%s'" % key)

    def __setattr__(self, key, value):
        print("对实例的属性进行赋值")
        self.__dict__[key] = value  #【重要】不能直接给属性赋值,而通常的做法是使用__dict__属性。


d = Dict(a=1, b=2)  # 从类定义看,其实没有a,b这两个属性
print(d)
print(d['a'])  # 调用__getattr__
print(d.a) # 调用__getattr__
d.a = 100  # 对实例的属性进行赋值的时候调用__setattr__
print(d['a'])

当执行d.a=100时,就会调用__setattr__方法。不能self.[key] = value设置属性,这样会导致无限循环。而操作__dict__字典中的键,不会触发__setattr__方法,可以避免无限循环。

19.6 属性查找顺序

https://www.cnblogs.com/xybaby/p/6270551.html#_label_3
通过前面的介绍,我们知道访问属性时用到了一系列get函数(__get__, __getattr__, __getattribute__) ,先执行哪个后执行哪个,是不是有点晕?现在我们就对属性的查找顺序组一个简单总结。

19.6.1 实例属性查找顺序

当一个实例通过点操作符访问实例属性的时候,例如instance.name,首先无条件调用__getattribute__方法,如果类定义了__getattr__方法,那么在__getattribute__抛出 AttributeError 的时候就会调用到__getattr__

__getattribute__内部的会根据情况获取属性值:

  1. 判断属性是否是数据描述符,是则调用数据描述符方法__get__获得属性,否则,
  2. 判断属性是否是实例属性,是则直接返回实例的属性,也就是通过实例的__dict__中的值,否则,
  3. 判断属性是否是非数据描述符,是则直接返回实例的属性,但是实例没有这个属性时,则调用非数据描述符的__get__方法。

也就是数据描述符优先于实例变量,实例变量优先于非数据描述符。

看一个例子:

class DataDescriptor(object):
    """
    数据描述符
    """

    def __init__(self, init_value):
        self.value = init_value

    def __get__(self, instance, typ):
        return 'DataDescriptor __get__'

    def __set__(self, instance, value):
        print('DataDescriptor __set__')
        self.value = value


class NonDataDescriptor(object):
    """
    非数据描述符
    """

    def __init__(self, init_value):
        self.value = init_value

    def __get__(self, instance, typ):
        return 'NonDataDescriptor __get__'


class Base(object):
    dd_base = DataDescriptor(0)
    ndd_base = NonDataDescriptor(0)


class Derive(Base):
    dd_derive = DataDescriptor(0)
    ndd_derive = NonDataDescriptor(0)
    ndd_derive2 = NonDataDescriptor(0)
    same_name_attr = '单纯类属性(不是描述符)'

    def __init__(self):
        self.not_des_attr = 'I am not descriptor attr'
        self.same_name_attr = '实例属性'

    def __getattr__(self, key):
        return '__getattr__ with key %s' % key

    def change_attr(self):
        """dd_base和ndd_derive即是实例变量,又是类变量了"""
        self.__dict__['dd_base'] = 'dd_base now in object dict '
        self.__dict__['ndd_derive'] = 'ndd_derive now in object dict '


if __name__ == '__main__':
    b = Base()
    d = Derive()
    # 1)same_name_attr和not_des_attr不是描述符,same_name_attr既是实例属性又是类属性,但实例属性优先。not_des_attr是实例属性则直接返回实例属性值。
    assert d.same_name_attr == '实例属性'
    assert d.not_des_attr == 'I am not descriptor attr'

    # 2)访问不存在的属性no_exists_key是调用__getattr__。
    assert d.no_exists_key == '__getattr__ with key no_exists_key'

    # 3)dd_base是数据描述符,其他是非数据描述符,而且他们都不是实例属性,所以访问他们的时候直接调对应的__get__方法。
    assert d.dd_base == "DataDescriptor __get__"
    assert d.ndd_derive == 'NonDataDescriptor __get__'
    assert d.ndd_base == 'NonDataDescriptor __get__'

    d.change_attr()  # 调用change_attr方法之后,给实例增加两个属性
    # 5) dd_base作为数据描述符,dd_base且变成了实例属性,则优先调用数据描述符的__get__方法
    assert d.dd_base == Base.dd_base  # 父类
    # 6) ndd_derive是非数据描述符,ndd_derive是实例属性,优先返回实例的__dict__['ndd_derive']
    assert d.ndd_derive == 'ndd_derive now in object dict '

从这个例子可以看出,访问一个实例的属性obj.attr 的顺序如下:

  1. 如果attr不是描述符,实例属性优先级高于类属性,就像上面代码中的same_name_attr
  2. 如果attr是描述符,但是只是类的属性,不是实例的属性, 则不管是数据描述符还是非数据描述符,都调用描述符的__get__方法;
  3. 如果attr是数据描述符,且是实例属性,则优先调用数据描述符的__get__方法
  4. 如果attr是非数据描述符,且是实例属性,则优先返回实例的属性,不调用非数据描述符的__get__方法

记住一句话,访问属性的优先级是:数据描述符优先于实例变量,实例变量优先于非数据描述符。

19.6.2 类属性查找顺序

类的也是对象,类是元类(metaclass)的实例,所以类属性的查找顺序基本同实例属性的查找顺序。

  1. 如果属性是一个描述符(不管是数据描述符还是非数据描述符),都调用其__get__方法
  2. 如果属性不是描述符,则直接返回类或者父类的__dict__['attr']

就想上面的例子中,这个部分:

# 3)dd_base是数据描述符,其他是非数据描述符,而且他们都不是实例属性,所以访问他们的时候直接调对应的__get__方法。
    assert d.dd_base == "DataDescriptor __get__"
    assert d.ndd_derive == 'NonDataDescriptor __get__'
    assert d.ndd_base == 'NonDataDescriptor __get__'

猜你喜欢

转载自blog.csdn.net/liuchunming033/article/details/107913092