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年代就已经存在,但应用并不广泛。然而,我们上面描述的流水线式的工作并行集群过程,正适合函数式编程。由于函数式编程这一天然优势,越来越多的语言也开始加入对函数式编程范式的支持。

你可能感兴趣的:(Python | 对于变量作用域和闭包(Closure)的理解)