namedtuple简易实现

在python中,namedtuple创建一个和tuple类似的对象,可以使用名称来访问元素的数据对象,通常用来增强代码的可读性, 在访问一些tuple类型的数据时尤其好用。
我们可以这样使用:

from collections import namedtupleUser = namedtuple('User', ['id', 'name']) u = User(1, 'aa') print(u.name) # aa

那么,namedtuple是如何实现的呢。
见名知意,通过namedtuple的名字,我们可以推测,namedtuple继承了tuple,并使我们定义的字段名和tuple下标建立某种联系,使得通过字段名来访问数据成为可能。
显然,我们无法预知用户传入的字段名是什么。比如上面的例子User = namedtuple('User', ['id', 'name'])字段名id和name,下次有可能需要新增一个age字段。这就要求我们要动态地创建类,在python中就需要通过元类来实现。
如何修改tuple的实例化行为呢,我们当然会首先想到继承并重写基类的构造方法。比如下面这样:
class MyTuple(tuple): def __init__(self, iterable): newiter = [i for i in iterable if i != 3] tuple.__init__(newiter)if __name__ == '__main__': mytuple = MyTuple([1,2,3,4,5]) print(mytuple)

运行代码,我们将看到打印结果为(1, 2, 3, 4, 5)。这是因为,想要修改python内置不可变类型的实例化行为,需要我们实现__new__方法。__new__ 方法相当不常用,但是当继承一个不可变的类型或使用元类时,它将派上用场。稍作修改的代码如下:
class MyTuple(tuple): def __new__(cls, id, name): newiter = [i for i in iterable if i != 3] return super(MyTuple, cls).__new__(cls, newiter)if __name__ == '__main__': mytuple = MyTuple([1,2,3,4,5]) print(mytuple)

这次,程序运行的结果就会是我们期望的(1, 2, 4, 5)
了解了以上知识后,我们开始着手编写代码:
class User(tuple):def __new__(cls, id, name): iterable = (id, name) return super(User, cls).__new__(cls, iterable)if __name__ == '__main__': user = User(1, 3) print(user)

【namedtuple简易实现】一个基本的User类实现如上,它继承tuple并重写了__new__方法,根据我们传入的参数包装成一个可迭代对象,最后调用父类的__new__方法。但它还是有个严重的问题:不能够动态接收参数。这里我们传的是id和name作为字段名,下一次我们可能希望传入id、name、age作字段名。有人可能会想到用*args*args虽然能解决以上问题,但又会产生新的问题:无法对参数数量进行限制。我们最终定义的函数应该像这样:def name_tuple(cls_name, field_names)。它接收两个参数cls_name为生成类的类名,我们最终希望通过obj.字段名的方式去获取tuple中的元素,所以还需要传入第二个参数:field_names,field_names为一系列字段名,可以是一个可迭代对象,或是一个字符串。我们希望根据field_names中字段的数量,去动态控制__new__方法中可接受的参数数量。
那么究竟应该怎么做?如果我们有一个模板,并动态往里面填充我们想要的字段名作为参数,不就实现了这一需求了吗。就像这样:
class_template = """ def __new__(_cls, {arg_list}): return _tuple_new(_cls, ({arg_list}))' """ class_template.format(arg_list='id, name') print(class_template)

最后生成的是个字符串,并不是我们需要的__new__方法,如何将这一串字符串转成方法呢?
众所周知,Python 是一门动态语言,在 Python 中,exec()能够动态地执行复杂的Python代码,它能够接收一个字符串,并将其作为Python代码执行,比如:
exec('a=1') print(globals().get('a')) # 1

目前为止,我们能实现如下代码:
def name_tuple(cls_name, field_names): if isinstance(field_names, str): field_names = field_names.replace(',', ' ').split() field_names = list(map(str, field_names)) arg_list = repr(field_names).replace("'", "")[1:-1] tuple_new = tuple.__new__ namespace = {'_tuple_new': tuple_new, '__name__': f'namedtuple_{cls_name}'} template = f'def __new__(_cls, {arg_list}): return _tuple_new(_cls, ({arg_list}))' exec(template, namespace) __new__ = namespace['__new__']class_namespace = { '__new__': __new__ }return type(cls_name, (tuple,), class_namespace)

大概解释一下上述代码。首先对传入的field_names进行处理,若传入的是字符串,则用split将其分割为列表,否则直接通过list(map(str, field_names))将它转为列表。之后将field_names进行处理,生成传入模板作为参数的字符串。
之后定义了namespace和template变量,并将它们作为参数传入exec。
exec能接收三个参数:
  • object:必选参数,表示需要被指定的Python代码。它必须是字符串或code对象。如果object是一个字符串,该字符串会先被解析为一组Python语句,然后在执行(除非发生语法错误)。如果object是一个code对象,那么它只是被简单的执行。
  • globals:可选参数,表示全局命名空间(存放全局变量),如果被提供,则必须是一个字典对象。
  • locals:可选参数,表示当前局部命名空间(存放局部变量),如果被提供,可以是任何映射对象。如果该参数被忽略,那么它将会取与globals相同的值。
  • 如果globals与locals都被忽略,那么它们将取exec()函数被调用环境下的全局命名空间和局部命名空间。
执行后产生的__new__方法可以通过namespace['__new__']获取。
最后一句return type(cls_name, (tuple,), class_namespace)非常关键,它表示生成一个名为cls_name的类,且继承自tuple。第三个参数class_namespace是一个包含属性的字典,我们在其中添加了之前生成的__new__方法。
让我们测试一下:
User = name_tuple('User', ['id', 'name']) print(User)# u = User(1,'aa') print(u)# (1, 'aa') print(u.name)# AttributeError: 'User' object has no attribute 'name'

可以发现最后一句报错了,因为我们并没有在class_namespace字典中添加名为name的属性。
现在要考虑的是如何添加这些键值对,属性名我们很容易拿到,接下来要做的就是获取值;此外,不仅要获取,而且还要和tuple一致,保证这些属性是只读,不可变的(immutable)。
通过property可以实现上述操作。通常,我们会这么使用property:
class User(): __name = 'private'@property def name(self): return self.__nameif __name__ == '__main__': u = User() print(u.name)# private u.name = 'public'# AttributeError: can't set attribute

把一个方法变成属性,只需要加上@property装饰器就可以了,此时,@property本身又创建了另一个装饰器@name.setter,负责把一个setter方法变成属性赋值,若不定义这一方法,则表示name属性是只读的。
property还有另一种写法:
class User(): __name = 'private'def name(self): return self.__namename = property(fget=name)

以上两种property的用法是等价的。理解了这些之后,我们继续实现代码:
for i, v in enumerate(field_names): rv = itemgetter(i) class_namespace[v] = property(rv)

itemgetter函数如下:
def itemgetter(item): def func(obj): return obj[item]return func

完整代码:
def itemgetter(item): def func(obj): return obj[item]return funcdef name_tuple(cls_name, field_names): if isinstance(field_names, str): field_names = field_names.replace(',', ' ').split() field_names = list(map(str, field_names)) "a simple implementation of python's namedtuple" arg_list = repr(field_names).replace("'", "")[1:-1] tuple_new = tuple.__new__ namespace = {'_tuple_new': tuple_new, '__name__': f'namedtuple_{cls_name}'} template = f'def __new__(_cls, {arg_list}): return _tuple_new(_cls, ({arg_list}))' exec(template, namespace) __new__ = namespace['__new__']class_namespace = { '__new__': __new__ }for i, v in enumerate(field_names): rv = itemgetter(i) class_namespace[v] = property(rv)return type(cls_name, (tuple,), class_namespace)

至此一个简易版本的namedtuple已经实现。关于namedtuple的官方完整实现可以参考它的源码。
扩展 1.元类: 陌生的 metaclass
2.exec: 官方文档
3.描述符: 描述符是一种特殊的对象,这种对象实现了 __get____set____delete__ 这三个特殊方法中任意的一个
其中,实现了 __get__ 以及 __set__ / __delete__ 的是 Data descriptors ,而只实现了 __get__ 的是Non-Data descriptor 。这两者有什么区别呢?
我们调用一个属性,顺序如下:
  1. 如果attr出现在类的__dict__中,且attr是一个Data descriptor,那么调用__get__
  2. 如果attr出现在实例的__dict__中, 那么直接返回
  3. 如果attr出现在类的__dict__中:
    3.1 如果是Non-Data descriptor, 那么调用其__get__方法
    3.2 返回cls.__dict__['attr']
  4. 若有__getattr__方法则调用
  5. 否则抛出AttributeError
更多与描述符相关的内容可以参考官方文档
4.property 一种property的模拟实现:
class Property(object): def __init__(self, fget=None, fset=None, fdel=None, doc=None): self.fget = fget self.fset = fset self.fdel = fdel if doc is None and fget is not None: doc = fget.__doc__ self.__doc__ = docdef __get__(self, obj, objtype=None): if obj is None: return self if self.fget is None: raise AttributeError("unreadable attribute") return self.fget(obj)def __set__(self, obj, value): if self.fset is None: raise AttributeError("can't set attribute") self.fset(obj, value)def __delete__(self, obj): if self.fdel is None: raise AttributeError("can't delete attribute") self.fdel(obj)def getter(self, fget): self.fget = fgetdef setter(self, fset): self.fset = fsetdef deleter(self, fdel): self.fdel = fdel

在之前的例子中,我们用@property装饰器装饰了name方法,我们的 name就变成了一个 property 对象的实例,它也是一个描述符,当一个变量成为一个描述符后,它将改变正常的调用逻辑,现在当我们 u.name='public' 的时候,因为我们的name是一个 Data descriptors ,那么不管我们的实例字典中是否有 name 的存在,我们都会触发其 __set__ 方法,由于在我们初始化该变量时,没有为其传入 fset 的方法,因此,我们 __set__ 方法在运行过程中将会抛出 AttributeError("can't set attribute") 的异常。我们在简易实现namedtuple时使用了property,这保证了它将遵循了 tuple 的 不可变 (immutable) 特性。

    推荐阅读