Python | 对于变量作用域和闭包(Closure)的理解

Python中的变量作用域,这个主题来源于最近一个实际的需求,对于任意深度的字典进行解析。
初步一看,这个问题很明了,递归解析,直到最后一层结束。实际也确实是这么做的,但是在实现上却遇到了一些问题,所以正好就函数变量的作用域谈一下自己的理解,顺便再学习一下闭包。

问题引入

我们的问题是这样的,有两个家庭,一对异性恋TOM和JANE组成的家庭和一只单身狗DOG一个人组成的家庭。他们都有姓名和年龄的属性,现在我们需要对于他们的年龄和姓名进行解析。故事的主人公们长这样:

to_be_parsed_dict = {
    "Couple": {
        "Husband": {"name": "Tom", "age": 15},
        "Wife": {"name": "Jane", "age": 43}
    },
    "Single": {"name": "Dog", "age": 34}
}

任意深度的字典的解析


  • 第一个需求:解析这两个家庭的所有人的姓名组成列表

毫无疑问,递归,因为需求比较简单,所以就找到最后一层字典中有"name"结束即可。

def get_name(src_dict, result_list):
    for key in src_dict:
        if src_dict.__contains__("name"):
            result_list.append(src_dict["name"])
            break
        else:
            get_name(src_dict[key], result_list)
    return result_list

解析一下故事的主人公们,得到了三位主角的名字,emmmm,一切看起来不错。

>>> result_list = []
>>> get_name(to_be_parsed_dict, result_list)
['Tom', 'Jane', 'Dog']

  • 第二个需求:看一下这两个家庭的人加起来超没超过100岁

看起来和第一个需求完全一样,那我们直接重复一下代码,毕竟实现的时候copy yourself是最爽的(您的好友“维护”已经加入游戏)。

def get_age(src_dict, total_age):
    for key in src_dict:
        if src_dict.__contains__("age"):
            total_age += src_dict["age"]
            break
        else:
            get_age(src_dict[key], total_age)
    return total_age

完全一样的思路,代码都几乎一样,应该可以安心拍胸脯了,作为一个负责的码农,还是假装测试一下吧。

>>> total_age = 0
>>> get_age(to_be_parsed_dict, total_age)
0

emmmm,肯定不是代码出问题了,肯定是操作系统有点问题,重启试试。
结果还是一样。

为什么?
好像要谈到函数变量的作用域的理解了。
第一个需求和第二个需求其唯一的差别就在于:第一个需求传了可变类型list,第二个需求传了不可变类型int。问题不在于变量类型本身,而是因为我们在二个函数中用到了total_age += src_dict["age"],这个句法其本质是total_age = total_age + src_dict["age"],这就相当于在函数的内部重新分配了变量,变量的作用域变成了函数内有效,自然就无法对于函数外部作用域的变量产生任何影响了。所以结果输出的还是函数外部在定义的时候的赋值0。
而在第一个函数中,写法是result_list.append(src_dict["name"]),并没有在函数的内部重新非配变量。如果我们在函数的内部加上result_list = [],那么其结果显然是无法正常工作的。

为了解决这个问题,我们可以引入全局变量,更新后的代码如下:

def get_age(src_dict):
    global total_age
    for key in src_dict:
        if src_dict.__contains__("age"):
            total_age += src_dict["age"]
            break
        else:
            get_age(src_dict[key])
    return total_age

这里在命令行里面并没有跑起来,不过通过了脚本内的测试(要在这里挖坑了,也许命令行里不能定义全局变量)

闭包

既然函数的变量是有作用域的,那么函数中函数的变量的作用域又怎么说呢?


  • 第三个需求:我想要将这两个家庭的人的参数解析两遍

对于姓名的解析,我们可以将第一次解析得到的列表作为参数传递函数的第二次调用,对于年龄的解析,由于是全局变量,所以直接执行两次函数即可。
但是这样的做法是不安全的,服务端对于客户端产生了依赖,这破坏了服务端的封装,比如说不小心在函数的某处改变了全局变量的值,那么重复解析两次得到的结果是不准确的。
如此,便到了闭包出场的时候,直接上代码。

def name_getter():
    result_list = []
    def get_name(src_dict):
        for key in src_dict:
            if src_dict.__contains__("name"):
                result_list.append(src_dict["name"])
                return result_list
            else:
                get_name(src_dict[key])
        return result_list
    return get_name


def age_getter():
    total_age = 0
    def get_age(src_dict):
        nonlocal total_age
        for key in src_dict:
            if src_dict.__contains__("age"):
                total_age += src_dict["age"]
                break
            else:
                get_age(src_dict[key])
        return total_age
    return get_age

这两个函数的核心是在实际的执行函数层外包了一层函数,用来维持数据的状态。
需要特别注意age_getternonlocal total_age这个声明,如同在第二个需求中讨论的,为了避免get_age函数中重新声明变量造成外部声明的变量失效,需要用nonlocal关键字来声明。相对于gobal声明来说,有效控制了变量名的有效范围,是有助于保持数据不被污染的。
这两个函数的实际执行像这样。

>>> re_get_name = name_getter()
>>> re_get_name(to_be_parsed_dict)
['Tom', 'Jane', 'Dog']
>>> re_get_name(to_be_parsed_dict)
['Tom', 'Jane', 'Dog', 'Tom', 'Jane', 'Dog']

>>> re_get_age = age_getter()
>>> re_get_age(to_be_parsed_dict)
92
>>> re_get_age(to_be_parsed_dict)
184

为什么函数的上次执行结果可以保存。
一方面,因为外层函数返回的是内层函数,变量的初始化在外层函数的初始化过程中完成,以后每次调用外层函数,实质是调用了内层函数,所以类似totoal_age=0的赋值只会被执行一次,不会被覆盖。
另一方面,外层函数一直存在(一个python对象),所以数据可以保持。


  • 对于闭包的一点理解

从目前我写的代码的角度来看,闭包并不是非常有用的特性,所以对于概念本身也谈不上特别熟悉,但我认为闭包从本质来说还是函数变量作用域的问题。

从功能的角度来看。
第一点,闭包有效的减少了参数的数量,在实现第三个需求的时候,实际看到的只需要传递一个参数就可以实现解析了。
第二点,闭包可以维持内部状态,这也是为什么可以连续解析两次的原因。

从形式的角度来看。
这个写法非常非常像装饰器的写法,二者之间必然存在联系。不过现在我也说不出来联系在哪里。

从实用的角度来看。
这个找了资料,原文在这里 Python深入04 闭包
闭包有效的减少了函数所需定义的参数数目。这对于并行运算来说有重要的意义。在并行运算的环境下,我们可以让每台电脑负责一个函数,然后将一台电脑的输出和下一台电脑的输入串联起来。最终,我们像流水线一样工作,从串联的电脑集群一端输入数据,从另一端输出数据。这样的情境最适合只有一个参数输入的函数。闭包就可以实现这一目的。

并行运算正称为一个热点。这也是函数式编程又热起来的一个重要原因。函数式编程早在1950年代就已经存在,但应用并不广泛。然而,我们上面描述的流水线式的工作并行集群过程,正适合函数式编程。由于函数式编程这一天然优势,越来越多的语言也开始加入对函数式编程范式的支持。

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

推荐阅读更多精彩内容