namedtuple简易实现

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

我们可以这样使用:

1
2
3
4
5
from collections import namedtuple

User = 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的实例化行为呢,我们当然会首先想到继承并重写基类的构造方法。比如下面这样:

1
2
3
4
5
6
7
8
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__ 方法相当不常用,但是当继承一个不可变的类型或使用元类时,它将派上用场。稍作修改的代码如下:

1
2
3
4
5
6
7
8
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)

了解了以上知识后,我们开始着手编写代码:

1
2
3
4
5
6
7
8
9
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)

一个基本的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__方法中可接受的参数数量。

那么究竟应该怎么做?如果我们有一个模板,并动态往里面填充我们想要的字段名作为参数,不就实现了这一需求了吗。就像这样:

1
2
3
4
5
6
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代码执行,比如:

1
2
exec('a=1')
print(globals().get('a')) # 1

目前为止,我们能实现如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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__方法。

让我们测试一下:

1
2
3
4
5
User = name_tuple('User', ['id', 'name'])
print(User) # <class '__main__.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:

1
2
3
4
5
6
7
8
9
10
11
class User():
__name = 'private'

@property
def name(self):
return self.__name

if __name__ == '__main__':
u = User()
print(u.name) # private
u.name = 'public' # AttributeError: can't set attribute

把一个方法变成属性,只需要加上@property装饰器就可以了,此时,@property本身又创建了另一个装饰器@name.setter,负责把一个setter方法变成属性赋值,若不定义这一方法,则表示name属性是只读的。

property还有另一种写法:

1
2
3
4
5
6
7
class User():
__name = 'private'

def name(self):
return self.__name

name = property(fget=name)

以上两种property的用法是等价的。理解了这些之后,我们继续实现代码:

1
2
3
for i, v in enumerate(field_names):
rv = itemgetter(i)
class_namespace[v] = property(rv)

itemgetter函数如下:

1
2
3
4
5
def itemgetter(item):
def func(obj):
return obj[item]

return func

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def itemgetter(item):
def func(obj):
return obj[item]

return func


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))
"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的模拟实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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__ = doc

def __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 = fget

def setter(self, fset):
self.fset = fset

def 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) 特性。