【个人笔记】简单谈谈Python中的下标访问与几个魔法方法

341 阅读6分钟

103358144_p0.png

提出问题

前几天同学在改项目源码的时候,习惯性地写下了一条代码片段:

tempUser = User.objects.get(email=email)
auth.authenticate(username=tempUser['username'], password=password)

接着便立即得到了如下的报错:TypeError: 'User' object is not subscriptable

几经尝试后,我们将代码中的tempUser['username'](下标访问)修改为tempUser.username(点运算符访问),才终于解决了报错。

当然,这个报错还是引起了之前长期写JavaScript的我的兴趣。众所周知,在JavaScript中,只要对象的某个属性的属性名是合法的标识符,使用下标访问和点运算符访问的效果是完全一样的。但通过上面这个例子,容易发现在Python中却并非如此。

那么问题就来了:Python中下标访问与点运算符访问之间到底有何区别?

先上结论

这里先给出之前问题的结论。不同于JavaScript,在Python中,自定义的类实例化出来的对象并不原生支持通过下标访问属性。下标访问和点操作符访问在Python中是两个意义完全不同的操作。

下面给出我自己关于这个问题的理解。

事实上,无论是在Python还是JavaScript中,都存在着"对象属性"(property/attribute)和"映射表"(map/hash/dictionary)两种不同的概念。 在习惯上,大多数程序员会认为.表示"获取/修改对象属性",[]表示"设置映射表的键值映射"

在JavaScript中两者的差别在大多数情况下似乎不是非常明显,.[]被并称为"属性访问器"(property accessors)。

例如,o.prop = 2023(为对象设置一个名为prop的属性)和o['prop']=2023(为映射表增加一条'prop'->2023的键-值映射)从最终的执行效果上来看并没有什么差别。即便我们设置的映射键并不是一个合法的所谓标识符/属性名(例如字符串'2020'),当我们用Object.getOwnPropertyNames来查询这个映射表的所有自有属性时,仍然能查得这个字符串。

但在Python中,却对这两个概念进行了明确的划分。一个简单的例子就是如果我们在Python中定义一个字典对象d = dict(),如果我们企图使用.运算符来设置字典中的映射(例如d.prop = 2023),则会得到报错AttributeError: 'dict' object has no attribute 'prop'。我们必须写作d['prop'] = 2023才能使得代码正常执行。同样地,当要根据键值查询字典对象中的映射值时,我们必须仍然使用[]来访问,使用.依旧会得到一个AttributeError。

于是乎,我们很自然地又抛出了一个新问题:在Python中,我们有没有办法,使得我们自定义类实例化出来的对象,也能像JavaScript中的对象一样支持通过.[]两种运算符来进行属性访问?

实现一个自定义类

实际上这是非常简单的,这里我先直接上代码,再做一些说明。

class AccessObject:
    def __init__(self, **kwargs):
        for (key, value) in kwargs.items():
            setattr(self, key, value)

    def __getitem__(self, key):
        return getattr(self, key)

    def __setitem__(self, key, value):
        setattr(self, key, value)

obj = AccessObject(name='Alice', age=25)

# 输出Alice 25
print(obj['name'], obj['age']) 
# 输出结果与用下标访问一样
print(obj.name, obj.age)

obj['name'] = 'Bob'
# 输出Bob Bob
print(obj['name'], obj.name)

obj.name = 'Biden'
# 输出Biden Biden
print(obj['name'], obj.name)

# 没查询到结果会触发getattr的报错
# AttributeError: 'AccessObject' object has no attribute 'birthday'
print(obj['birthday'])

我给出的代码中使用了Python中的两个魔法方法__getitem__、__setitem__,以及Python两个自带的函数getattr和setattr。

__getitem__和__setitem__的功能是重要却又显而易见的,它们分别负责拦截下标访问的取值和赋值操作。

setattr函数的作用也很好理解,它接收传入的字符串key作为对象新设置(或重新赋值)的自有属性的属性名,并为其赋值。

值得一提的是,setattr接收的属性名也可以是一个内容为非法标识符的字符串。这主要是因为Python中给对象设置的自有属性属性都是挂在对象一个属性名为__dict__的字典上,字典作为Python中的映射表容器,其键值当然可以是一般的非标识符字符串(这与js真是殊途同归啊😂)。当程序员通过.运算符或者getattr来查询属性时,Python首先会检查__dict__有没有相应的记录,如果没有找到,则按对象继承链向上查找。

至于getattr函数,它与.运算符的主要区别在于:其一,其第三个参数用于指定当沿对象的继承链查询结束后仍未得到结果时函数的默认返回值,而不抛出错误(虽然本例中没有启用这个特性);其二,getattr也能够接收非合法标识符的字符串作为要查询的属性名,具有更好的兼容性。

必须指出的是,__getitem__和__setitem__方法本身理论上允许接收的key(也就是下标访问符号[]内填的内容)可以是任意的一个对象,而不仅仅局限于字符串。

到这里本文的主要内容就结束了。

题外话

说起Python中常见的魔法方法,一个容易与前文介绍的__getitem__和__setitem__方法弄混的便是__getattr__和__setattr__,这里我就在本文中一并介绍了。

前一组方法用于拦截关于下标访问的操作,而后后一组方法用于拦截关于普通的访问属性操作。

  • __getattr__方法在尝试通过.运算符或getattr函数访问一个属性而该属性不存在时被调用,并且会阻止Python抛出默认的AttributeError报错。换句话说,这个方法是"获取属性的最后一道防线"——只有当通过常规方式无法获取属性时,Python才会尝试调用它。

  • __setattr__方法在尝试通过.运算符或setattr函数为一个属性赋值(无论该属性是否已存在)时被调用。

下面通过一个具体的例子进一步说明:

class MyClass:
    a = 1
    
    def __init__(self, **kwargs):
        for (key, value) in kwargs.items():
            self.__dict__[key] = value
    
    def __getattr__(self, key):
        print('can\'t find property {}'.format(key))
        return None
    
    def __setattr__(self, key, value):
        print('set property {}={}'.format(key, value))
        self.__dict__[key] = value
        return value
        
obj = MyClass(b = 2)

# 沿继承链在MyClass.__dict__找到属性a,正确输出1
print(obj.a)
# 在obj.__dict__找到属性b,正确输出2
print(obj.b)
# 沿继承链没有查找到属性c,触发__getattr__方法
print(obj.c)

# 为对象继承的属性重新赋值(实际上会为对象自身创建新的自有属性),会触发__setattr__
obj.a = 4
# 为对象已有的自有属性赋值,会触发__setattr__
obj.b = 5
# 为对象设置新的属性,会触发__setattr__
obj.c = 6

需要注意的是,如果在__setattr__方法内部为当前对象的任何属性赋值,都需要使用object.__setattr__(self, key, value)或者直接操作对象的__dict__字典,而不能直接使用self.key = value或者setattr(self, key, value),否则会造成无穷递归。