SICP1——Python中的函数机制

一、使用函数构建抽象

1.1 基本元素

程序必须为人类阅读而编写,并且仅仅碰巧可以让机器执行

编程语言都应该具有基本的元素,即表达式和语句,数据和函数是这两种基本元素的代表。有了基本元素之后,还要有合适的方式将他们进行组合以完成简单到复杂的构造。最后,可以对内容进行抽象,已完成复杂到简单的指代。

首先要区分代码中的语句表达式,这两者分别负责执行某个操作或者计算某个值。最简单的语句就是赋值语句了,赋值语句的执行作用就是负责将某个值和某个名字相关联,即“名称被绑定到了值上”,并将这种绑定存放在环境中。而表达式则会计算出一个结果,最简单的表达式就是一个字面量或者一个环境中已有的名字。最常见以及最重要的表达式是调用表达式。表达式是递归的,就是说通过调用运算可以将表达式构造成更大的表达式。解释器会以递归的方式计算复杂表达式:深入到子表达中,直到遇到字面量或者变量名称,进行计算并向上返回。注意不同于表达式,语句不返回任何值。最后,解释器负责关联起这些东西:存储对象与名字之间的关联、计算表达式、执行语句。

通过这些简单的基本元素,我们了解了编程语言是如何提供在本章开头时提到的基本能力的:(1)数值是内建数据,算数运算是函数。(2)嵌套提供了组合操作的手段。(3)名称到值的绑定提供了有限的抽象手段。但是这还远远不够,一个编程语言还需要更加强大的抽象技巧,即定义函数。定义函数可以将一个名称绑定到一个操作序列上,然后就可以将其作为一个整体来引用。

函数中的一个重要概念是纯函数,纯函数具有一个特性:调用它们时除了返回一个值之外没有其它效果。而非纯函数除了返回一个值之外,会产生副作用,即改变解释器或计算机的一些状态。一个普遍的副作用就是在返回值之外生成额外的输出,比如打印一些内容到屏幕上。函数的可接受参数的描述叫做签名

在有了定义函数的能力后,实际上我们也有了划分局部环境的能力,因为通常函数自身具有局部环境。那么,什么是环境?之前我们提到,环境可以简单的看做一块内存,用于将名字绑定到值(要注意值并不属于环境帧),这种概念实际上是环境的“帧”。不同粒度的环境帧会构成帧序列,或者说是帧“链”。赋值语句和导入语句会再最上面的环境帧中新增绑定。而函数定义语句同样会新增绑定————将函数名称绑定到函数自身。此时函数名会出现在两个地方,环境帧与函数自身中。考虑到不同的名字可能会绑定到同一个函数体,因此这种重复是有意义的。注意,通常环境中的函数名上绑定的是函数地址,函数地址处存放有函数具体代码。

如果对Python的实现有所了解,可以知道,运行时环境和静态编译出的字节码对象其实是分别存放的,前者为PyFrameObject,后者为PyCodeObject,而PyFrameObject中会保存指向PyCodeObject的指针以及当前执行的字节码在PyCodeObject中的偏移。

定义了函数的目的是为了使用,使用的方式是通过调用,调用步骤会先求出参数表达式,但是对计算好的实参上调用具名函数。这个调用就会产生一个局部帧并将该帧链入当前的环境帧链上,在新的局部帧上,将实参绑定到函数的形式参数上,然后在当前帧的开头以及全局帧的末尾求出函数体。对于局部环境中的名字对应的值为,最先发现该名称的帧中绑定到这个名称的值(这句话实际上有着很严重的误导性,考虑一下,名字搜索时真的会沿着帧链一层层的搜索吗?后面会详细说明这一点。)。更具体地说,局部帧中维护了指向其前驱的指针(通常是更大的局部帧或者全局帧),通过这种形式,帧序列表现为链表。

函数自身拥有局部环境使得,函数的形参名称可以是任意的————局部名字的作用域被限制在定义它的函数的函数体中。当一个名称不能再被访问时,它就离开了作用域。

1.2 高阶函数与作用域

觉得已经掌握了吗?考虑这段代码(可以参考我的另一篇文章)。

# 代码片1.2.1
# 例子 0
a = 1
def f():
	return a
print(f())

# 例子 1
a = 1
def f():
	return a
def g():
	a = 2
	return f()
print(g()) # output: 1

# 例子2
def g():
    def f():
        return a
    a=2
    return f()
a=1
print(g()) # output:2

总的来说,函数给了我们更强大的抽象能力,我们可以将操作序列抽象到函数背后而不去了解其实现,只需要确定其“合约”(参考《程序员修炼之道》)符合我们的要求即可。这种抽象可以称为函数式抽象。函数提供了我们使用一个名字指代一段操作的能力,因此,我们要遵守 DRY 原则(同样参考《程序员修炼之道》),当你在复制粘贴的时候,你脑子中的“DRY警报”应该响起并提示你:“应该抽象了”。

函数的强大还不止于此,因为一个函数可以作为另一个函数的返回值或者参数,这种方式允许我们以层次化的方式对复杂的逻辑进行多次抽象。另外,函数内部还可以嵌套的定义函数。在函数内使用def语句定义函数,此时函数名绑定在函数的局部帧中。回想一下,在代码片1.2.1的例子0中,直接执行f,输出 1 ,没有任何问题。调用函数,会创建新的栈帧,当需要查找名字的时候在局部环境中找不到,然后便会找前一个帧,找到a,并输出。那么例子 1 中按理说,进入到f后,局部执行环境中没有a,会向上找,然后再g的局部环境中会找到a,然后应该输出2呀?这个问题的原因在于,在Python中,一个名字绑定在程序正文的某个位置是否起作用,是由该名字在文本中的位置唯一决定的,而不是运行时动态决定的。因此Python的作用域是静态作用域,也称为词法作用域

名字空间就是与作用域对应的动态的东西,作用域可以认为就是一段代码的范围,作用域在Python运行时就会转化为名字空间。因此对于例子1中的 f 函数,由于其定义在全局空间内而不是嵌套定义,因此其作用域规则为LGB规则。而在例子2中我们使用了嵌套定义函数,内层定义的函数与其“直接外围作用域”被捆绑到一起,因此即使我们把代码中函数g的返回值改为return f,然后执行g的返回值,结果依然不变,这就是所谓的闭包。

对于词法作用域:

  • 局部函数的名称并不影响定义所在函数外部的名称,因为局部函数的名称绑定到了定义处的当前局部环境中,而不是全局环境。
  • 局部函数可以访问外层函数的环境。这是因为局部函数的函数体的求值环境扩展于定义处的求值环境。

这种方式使得内部函数会额外绑定一些信息,即定义处的直接外围局部环境的数据。因此,带有词法作用域的编程语言的一个重要特性就是,嵌套定义函数在它们返回时仍旧持有所关联的环境。前面我们讨论过纯函数,但是闭包可能对导致以相同参数多次调用闭包却得到不同的结果,那闭包是纯函数吗?它们仍旧是纯函数,因为它们并不允许修改任何在局部环境帧之外的东西。

最后,再多说一点所谓的“最内嵌套作用域规则”。看一下代码片1.2.2,预测一下结果是怎样的?运行的话会提示函数g中的第一个print语句出错“local variable ‘a’ referenced before assignment”。怎么f中运行没问题,到了g中就有问题了?

# 代码片1.2.2
a = 1
def f():
    print(a)

def g():
    # global a
    print(a)
    a=2
    print(a)

f()
g()

要回答这个原因,就需要再逐字逐句的看一下作用域规则:由一个赋值语句引起的名字在这个赋值语句所在的作用域内是可见的。在g中的a=2这一句话对于其所在的整个作用域都是有影响的,因此第一个print知道自己的局部空间中有a这个变量,但是a却是在自己的后面定义的,所以会上面的报错。

如果有兴趣可以使用dis.dis看一下下面的代码中,函数f和函数g生成的字节码就会发现,二者对于变量a的查找使用了不同的字节码指令,前者是LOAD_GLOBAL,而后者是LOAD_FAST。这就意味着通过作用域的静态信息,函数知道a是局部空间中的变量,因此直接使用LOAD_FAST,但是只有在运行时才会发现在该条语句执行失败,这意味着对于该变量,python在编译时就已经知道了名字应该到哪里去搜索。

因此我们此时对nonlocalglobal关键字的理解就更深入一层了,可以知道这两个关键字的本质是控制名字引用使用的字节码指令。我们可以做实验看一下是不是这样的,把代码片1.2.2中的放到一个单独的文件中,使用compile函数编译出code对象,然后执行import dis;dis.dis(code.co_consts[3])来查看函数g的代码编译出的字节码。可以发现对变量a的查找指令从LOAD_GLOBAL变成了LOAD_FAST,后者会查找代码函数栈帧的静态变量区,而前者会查找栈帧对象的global和buildin字典中依次查询。

我们发现,python中的函数具有十分灵活的使用方式,这是因为,python将函数视为“一等公民”。通常,编程语言会限制操作计算元素的途径。带有最少限制的元素被称为具有一等地位。一些一等元素的“权利和特权”是:

  1. 它们可以绑定到名称。
  2. 它们可以作为参数向函数传递。
  3. 它们可以作为函数的返回值返回。
  4. 它们可以包含在数据结构中。

二、python虚拟机中的函数机制

写这一章之前,几个问题很是困扰了我。

第一是,上面代码片1.2.2中函数g体现的行为是,运行时直接在快速区进行查找,查找失败就是失败了,那么沿着作用域链向上查找的这种行为是在哪里出现的。是在编译字节码的时候出现的吗?如果是的话,可以说通函数f中对a的查找直接使用了LOAD_GLOBAL,而不是从局部开始查找。但是又可以看到LOAD_NAME中是有LGB规则的,具体见《python源码剖析》P167,这到底是咋回事?而且注意到LOAD_GLOBAL字节码中也有GB规则。目前的猜测是对于函数的静态信息使用快速查找,其他的情况使用LGB规则。那又有一个问题,LOAD_NAME中是只可以发现LGB规则的,LEGB中的E又是怎么实现的?

第二是就是函数的调用栈与函数的作用域链的关系是什么,这两个概念我似乎有些混淆。我想了下面这个代码帮助清晰的展示这个问题。

import sys
from functools import wraps

def show_frame(func):
    @wraps(func)
    def wrapped(*args, **kwargs):
        # print current frame chain
        f = sys._getframe()
        print("\n>"+"="*20, func.__name__)
        while f:
            print(f.f_code.co_name, end=" -> ")
            f = f.f_back
        print()
        print("="*20+"<\n")
        return func(*args, **kwargs)
    return wrapped

a = 1

@show_frame
def g(n):
    if n < 3: # 3是随便选的,只是为了让该函数递归的调用多次
        g(n+1)
    else:
        print(a)

def f():
    a = 2
    g(1)

f()

运行f之后可以发现,g函数在打印出变量a的值的时候,环境栈帧中已经压了三个g的不同参数的调用。然而根据我的理解,函数中对于a的查找过程应该是这个调用链的深度无关的,应该是在local作用域中找不到该变量的绑定之后,直接到global作用域中去查找,而不是穿过栈帧链中的三个g函数的调用到全局环境中找到变量a,那么名字空间是怎么实现的呢?

后面咱们就带着这几个问题来看函数对象。

1. 函数对象

typedef struct {
    PyObject_HEAD
    PyObject *func_code;    /* 函数代码编译后的 PyCodeObject 对象 */
    PyObject *func_globals; /* global名字空间 */
    PyObject *func_defaults;    /* 默认参数(NULL or tuple) */
    PyObject *func_closure; /* NULL or a tuple of cell objects */
    PyObject *func_doc;     /* The __doc__ attribute, 是PyStringObject */
    PyObject *func_name;    /* The __name__ attribute, 是PyStringObject  */
    PyObject *func_dict;    /* The __dict__ attribute, a dict or NULL */
    PyObject *func_weakreflist; /* List of weak references */
    PyObject *func_module;  /* The __module__ attribute, can be anything */
} PyFunctionObject;

其中函数的代码段会编译为一个PyCodeObject对象,这个对象是对 python 源码的静态反映,但是不是说这个对象里面就是一行行的代码而别无他物。这个对象会保存可以从代码中获得的静态信息,比如常量表co_const、符号表co_names以及编译好的字节码序列co_code。关于这个对象可以参考前面讲PyCodeObject的文章,这里简单的复习一下:字节码对象是代码编译的结果,是静态分析得到的信息,常量表和符号表都是元组,存放了这块Code Block内出现过的常量与名字,没有任何的绑定信息,因此名字空间肯定不在这里。而PyFunctionObject是一个函数运行时产生的动态对象,是在执行def子句的时候创建的。这个对象通过保存PyCodeObject来获得函数的静态信息。除此之外,PyFunctionObject还会保存函数执行时的动态信息,比如func_globalsfunc_closure。因为global作用域中的符号和值的对应关系一定是运行时才能知道,因此这部分必须要运行时动态创建。因此,一段python函数,其PyCodeObject是唯一的,而PyFunctionObject对象可能有多个,每次调用都会生成一个,并保存指向那个唯一的PyCodeObject的指针。

在上面看到func_globalsfunc_closure时,心中一抖!(哎?这个好像和名字空间有关?)的确,这两个字段与名字空间是有关系的,但不是真正的名字空间,具体原因是在这里简单提一句:名字空间是存放在Frame中的,这两个字段是负责给Frame传递内容的“信使”,而不是真正的负责人。

在函数定义的时候,会将当前的global放到函数的global中,然后在函数被调用的时候,函数对象中的global又会用于新的栈帧的global的初始化。思考一下,函数是允许嵌套的,那函数自身的名字应该是在global中的,是什么时候放进去的呢?定义函数是通过MAKE_FUNCTION指令做的,然后这个函数对象会在栈顶,然后要通过STORE_NAME把函数名和函数对象放到当前的local环境中,而全局函数的localglobal是一个字典对象,因此STORE_NAME把当前函数名放到local环境的同事也就放到了global环境中,再通过函数对象对global环境的传递功能,函数内部就也可以使用自己的名字了。那如果是非全局函数呢?内部嵌套函数的名字的确在其自身的LGB环境中都找不到,这个就涉及到闭包了,比较复杂,我也还有点乱,就不在这里写了。

第二个问题差不多可以解答了,LGB名字空间都是存在于每一个Frame内的,不用沿着栈帧往回搜索。函数在定义的时候会打包当前的global空间,等到被执行的时候在传递给新的帧。而buildin空间应该是共享的,local空间在新建的栈帧中应该为空。但是函数的局部变量怎么办?这个是放在frame的静态堆栈中,因为局部变量以及位置参数是一开始就可以确定个数的,因此是可以静态处理的。看python的源码,在ceval.c文件中,LOAD_FASTSTORE_FAST就是处理这种“快速局部变量”,他们会操作frame栈帧中localplus指向的部分。也就是说,frame的栈一部分是用于计算的,一部分是用来存储数据的,二者虽然形式上是一段连续的内存,一衣带水,但是永远是互不相见,“白天不懂夜的黑”的。

第二个问题解决了,那第一个问题呢?LGB规则什么时候发生的?LEGB规则又是什么时候发生的?

首先这个问题我现在没有明确的答案,不过我的猜测是,在全局空间内,LGB规则体现在LOAD_NAME字节码指令中,在函数的局部栈帧内,L规则被LOAD_FAST取代,不会使用LGB规则查找名字,而是静态的在localplus栈的指定位置读取,而GB规则还在,体现在LOAD_GLOBAL中。至于LEGB呢,要更细的说一下。

E是闭包,在python中闭包就要用到嵌套函数。嵌套函数的静态得到的code对象中,co_cellvarsco_freevars是和闭包相关的,前者保存嵌套的作用域中使用的变量名,后者保存使用到的外层作用域中的变量名。而在frame对象中,和闭包有关的属性是f_localsplus。没错,又是这个老铁。这段内存实际上属于四个部分,运行时栈、局部变量(包括位置参数)、cell对象和free对象。

和局部变量自身直接存在localplus中不同,cell变量是以PyCellObject对象的形式存在localplus中的,cell对象被指就是一个指针的封装,静态分析的时候只能知道这里有一个变量用到了外面的变量,但是不知道具体值是什么,就将一个cell放到静态数据区(localplus)的指定位置,等到外层的变量所在行运行的时候再通过STORE_DEREF指令,让cell对象指向外层的值。

在内层嵌套函数的def语句执行的时候,会将通过LOAD_CLOSURE指令将外层的cel对象取出,封装到内层函数的function对象中。此时在内层函数完成定义时的状态时,外层的函数是运行时,拥有自己的栈帧,该栈帧的local空间中有一个函数对象,函数对象中的func_closure属性指向了当前栈帧中的其他变量。

而当内层函数执行的时候,通过PyEval_EvalCodeEx函数,会将cell对象逐个拷贝到localplus中的指定位置(即freevars)。这就是闭包的基本原理。

因此,对于全局函数,LGB规则的L体现在localplus静态区中,而对于内层嵌套函数,LEGB规则的LE都体现在localplus静态区中。这也就可以解答我们在本节开始提出的第一个问题。


参考

轻松7步,导出Y结合子
Python进阶之路:operator模块
SICP 第一章
Python源码剖析 第十一章
python中的函数式编程
Python函数式编程指南(4):生成器
Python源码剖析笔记6-函数机制
深入理解python之函数系统
python开启尾递归优化
深入理解python之函数系统
Python 中的作用域准则

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
"SICP中文版"是指计算机科学经典教材《Structure and Interpretation of Computer Programs》(计算机程序的构造和解释)的中文翻译PDF版。这本教材由麻省理工学院的Harold Abelson和Gerald Jay Sussman等人编写,是计算机科学领域一本重要的教材。 "SICP中文版PDF"提供了更方便的学习方式。无论是学生、程序员还是计算机科学爱好者,都可以在任何时候通过电子设备访问和学习这本教材。使用PDF格式的好处是可以在不同的平台上都能打开和阅读,而不受限于特定的操作系统或设备。 通过"SICP中文版PDF",读者可以学习计算机科学的基本原理和概念,如过程、数据抽象、递归、高阶函数、并发等。这本教材以Scheme语言为示例,帮助读者理解计算机程序的结构、设计和解释。通过逐步的案例和练习,读者可以锻炼解决问题和编写高质量代码的能力。 "SICP中文版PDF"也提供了沟通和讨论的平台。读者可以通过在线社群或论坛,与其他人分享学习心得、解答疑问和参与讨论。这为读者提供了一个学习交流的机会,促进了学习者之间的互动和共同成长。 总之,"SICP中文版PDF"是一本经典的计算机科学教材的中文翻译版本,使得更多的读者可以方便地学习和掌握其的知识。无论是对于计算机科学专业的学生还是对计算机科学感兴趣的人,这本教材都是一本很好的参考书,并提供了丰富的实例和练习,让读者深入理解计算机程序的核心概念和设计原则。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值