namedtuple简易实现

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

我们可以这样使用:

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

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)

一个基本的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)    # <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:

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还有另一种写法:

class User():
    __name = 'private'

    def name(self):
        return self.__name

    name = 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 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的模拟实现:

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

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

推荐阅读更多精彩内容