来源: 2.4 Mutable Data
译者:飞龙
协议: CC BY-NC-SA 4.0
我们已经看到了抽象在帮助我们应对大型系统的复杂性时如何至关重要。有效的程序整合也需要一些组织原则,指导我们构思程序的概要设计。特别地,我们需要一些策略来帮助我们构建大型系统,使之模块化。也就是说,它们可以“自然”划分为可以分离开发和维护的各个相关部分。
我们用于创建模块化程序的强大工具之一,是引入可能会随时间改变的新类型数据。这样,单个数据可以表示独立于其他程序演化的东西。对象行为的改变可能会由它的历史影响,就像世界中的实体那样。向数据添加状态是这一章最终目标:面向对象编程的要素。
我们目前引入的原生数据类型 -- 数值、布尔值、元组、范围和字符串 -- 都是不可变类型的对象。虽然名称的绑定可以在执行过程中修改为环境中不同的值,但是这些值本身不会改变。这一章中,我们会介绍一组可变数据类型。可变对象可以在程序执行期间改变。
2.4.1 局部状态我们第一个可变对象的例子就是局部状态。这个状态会在程序执行期间改变。
为了展示函数的局部状态是什么东西,让我们对从银行取钱的情况进行建模。我们会通过创建叫做 withdraw 的函数来实现它,它将要取出的金额作为参数。如果账户中有足够的钱来取出, withdraw 应该返回取钱之后的余额。否则, withdraw 应该返回消息 'Insufficient funds' 。例如,如果我们以账户中的 $100 开始,我们希望通过调用 withdraw 来得到下面的序列:
>>> withdraw(25) 75 >>> withdraw(25) 50 >>> withdraw(60) 'Insufficient funds' >>> withdraw(15) 35观察表达式 withdraw(25) ,求值了两次,产生了不同的值。这是一种用户定义函数的新行为:它是非纯函数。调用函数不仅仅返回一个值,同时具有以一些方式修改函数的副作用,使带有相同参数的下次调用返回不同的结果。我们所有用户定义的函数,到目前为止都是纯函数,除非他们调用了非纯的内建函数。它们仍旧是纯函数,因为它们并不允许修改任何在局部环境帧之外的东西。
为了使 withdraw 有意义,它必须由一个初始账户余额创建。 make_withdraw 函数是个高阶函数,接受起始余额作为参数, withdraw 函数是它的返回值。
>>> withdraw = make_withdraw(100)make_withdraw 的实现需要新类型的语句: nonlocal 语句。当我们调用 make_withdraw 时,我们将名称 balance 绑定到初始值上。之后我们定义并返回了局部函数, withdraw ,它在调用时更新并返回 balance 的值。
>>> def make_withdraw(balance): """Return a withdraw function that draws down balance with each call.""" def withdraw(amount): nonlocal balance # Declare the name "balance" nonlocal if amount > balance: return 'Insufficient funds' balance = balance - amount # Re-bind the existing balance name return balance return withdraw这个实现的新奇部分是 nonlocal 语句,无论什么时候我们修改了名称 balance 的绑定,绑定都会在 balance 所绑定的第一个帧中修改。回忆一下,在没有 nonlocal 语句的情况下,赋值语句总是会在环境的第一个帧中绑定名称。 nonlocal 语句表明,名称出现在环境中不是第一个(局部)帧,或者最后一个(全局)帧的其它地方。
我们可以将这些修改使用环境图示来可视化。下面的环境图示展示了每个调用的效果,以上面的定义开始。我们省略了函数值中的代码,以及不在我们讨论中的表达式树。

我们的定义语句拥有平常的效果:它创建了新的用户定义函数,并且将名称 make_withdraw 在全局帧中绑定到那个函数上。
下面,我们使用初始的余额参数 20 来调用 make_withdraw 。
>>> wd = make_withdraw(20)这个赋值语句将名称 wd 绑定到全局帧中的返回函数上:

所返回的函数,(内部)叫做 withdraw ,和定义所在位置即 make_withdraw 的局部环境相关联。名称 balance 在这个局部环境中绑定。在例子的剩余部分中, balance 名称只有这一个绑定,这非常重要。
下面,我们求出以总数 5 调用 withdraw 的表达式的值:
>>> wd(5) 15名称 wd 绑定到了 withdraw 函数上,所以 withdraw 的函数体在新的环境中求值,新的环境扩展自 withdraw 定义所在的环境。跟踪 withdraw 求值的效果展示了 python 中 nonlocal 语句的效果。

withdraw 的赋值语句通常在 withdraw 的局部帧中为 balance 创建新的绑定。由于 nonlocal 语句,赋值运算找到了 balance 定义位置的第一帧,并在那里重新绑定名称。如果 balance 之前没有绑定到值上,那么 nonlocal 语句会产生错误。
通过修改 balance 绑定的行为,我们也修改了 withdraw 函数。下次 withdraw 调用的时候,名称 balance 会求值为 15 而不是 20 。
当我们第二次调用 wd 时,
>>> wd(3) 12我们发现绑定到 balance 的值的修改可在两个调用之间积累。

这里,第二次调用 withdraw 会创建第二个局部帧,像之前一样,但是, withdraw 的两个帧都扩展自 make_withdraw 的环境,它们都包含 balance 的绑定。所以,它们共享特定的名称绑定,调用 withdraw 具有改变环境的副作用,并且会由之后的 withdraw 调用继承。
实践指南。通过引入 nonlocal 语句,我们发现了赋值语句的双重作用。它们修改局部绑定,或者修改非局部绑定。实际上,赋值语句已经有了两个作用:创建新的绑定,或者重新绑定现有名称。Python 赋值的许多作用使赋值语句的执行效果变得模糊。作为一个程序员,你应该用文档清晰记录你的代码,使赋值的效果可被其它人理解。
2.4.2 非局部赋值的好处非局部赋值是将程序作为独立和自主的对象观察的重要步骤,对象彼此交互,但是各自管理各自的内部状态。
特别地,非局部赋值提供了在函数的局部范围中维护一些状态的能力,这些状态会在函数之后的调用中演化。和特定 withdraw 函数相关的 balance 在所有该函数的调用中共享。但是, withdraw 实例中的 balance 绑定对程序的其余部分不可见。只有 withdraw 关联到了 make_withdraw 的帧, withdraw 在那里被定义。如果 make_withdraw 再次调用,它会创建单独的帧,带有单独的 balance 绑定。
我们可以继续以我们的例子来展示这个观点。 make_withdraw 的第二个调用返回了第二个 withdraw 函数,它关联到了另一个环境上。
>>> wd2 = make_withdraw(7)第二个 withdraw 函数绑定到了全局帧的名称 wd2 上。我们使用星号来省略了表示这个绑定的线。现在,我们看到实际上有两个 balance 的绑定。名称 wd 仍旧绑定到余额为 12 的 withdraw 函数上,而 wd2 绑定到了余额为 7 的新的 withdraw 函数上。

最后,我们调用绑定到 wd2 上的第二个 withdraw 函数:
>>> wd2(6) 1这个调用修改了非局部名称 balance 的绑定,但是不影响在全局帧中绑定到名称 wd 的第一个 withdraw 。

这样, withdraw 的每个实例都维护它自己的余额状态,但是这个状态对程序中其它函数不可见。在更高层面上观察这个情况,我们创建了银行账户的抽象,它管理自己的内部状态,但以一种方式对真实世界的账户进行建模:它基于自己的历史提取请求来随时间变化。
2.4.3 非局部赋值的代价我们扩展了我们的计算环境模型,用于解释非局部赋值的效果。但是,非局部复制与我们思考名称和值的方式有一些细微差异。
之前,我们的值并没有改变,仅仅是我们的名称和绑定发生了变化。当两个名称 a 和 b 绑定到 4 上时,它们绑定到了相同的 4 还是不同的 4 并不重要。我们说,只有一个 4 对象,并且它永不会改变。
但是,带有状态的函数不是这样的。当两个名称 wd 和 wd2 都绑定到 withdraw 函数时,它们绑定到相同函数还是函数的两个不同实例,就很重要了。考虑下面的例子,它与我们之前分析的那个正好相反:
>>> wd = make_withdraw(12) >>> wd2 = wd >>> wd2(1) 11 >>> wd(1) 10这里,通过 wd2 调用函数会修改名称为 wd 的函数的值,因为两个名称都指向相同的函数。这些语句执行之后的环境图示展示了这个现象:

两个名称指向同一个值在世界上不常见,但我们程序中就是这样。但是,由于值会随时间改变,我们必须非常仔细来理解其它名称上的变化效果,它们可能指向这些值。
正确分析带有非局部赋值代码的关键是,记住只有函数调用可以创建新的帧。赋值语句始终改变现有帧中的绑定。这里,除非 make_withdraw 调用了两次, balance 还是只有一个绑定。
变与不变。这些细微差别出现的原因是,通过引入修改非局部环境的非纯函数,我们改变了表达式的本质。只含有纯函数的表达式是引用透明(referentially transparent)的。如果我们将它的子表达式换成子表达式的值,它的值不会改变。
重新绑定的操作违反了引用透明的条件,因为它们不仅仅返回一个值。它们修改了环境。当我们引入任意重绑定的时候,我们就会遇到一个棘手的认识论问题:它对于两个相同的值意味着什么。在我们的计算环境模型中,两个分别定义的函数并不是相同的,因为其中一个的改变并不影响另一个。
通常,只要我们不会修改数据对象,我们就可以将复合数据对象看做其部分的总和。例如,有理数可以通过提供分子和分母来确定。但是这个观点在变化出现时不再成立了,其中复合数据对象拥有一个“身份”,不同于组成它的各个部分。即使我们通过取钱来修改了余额,某个银行账户还是“相同”的银行账户。相反,我们可以让两个银行账户碰巧具有相同的余额,但它们是不同的对象。
尽管它引入了新的困难,非局部赋值是个创建模块化编程的强大工具,程序的不同部分,对应不同的环境帧,可以在程序执行中独立演化。而且,使用带有局部状态的函数,我们就能实现可变数据类型。在这一节的剩余部分,我们介绍了一些最实用的 Python 内建数据类型,以及使用带有非局部赋值的函数,来实现这些数据类型的一些方法。
2.4.4 列表list 是 Python 中最使用和灵活的洗了类型。列表类似于元组,但是它是可变的。方法调用和赋值语句都可以修改列表的内容。
我们可以通过一个展示(极大简化的)扑克牌历史的例子,来介绍许多列表编辑操作。例子中的注释描述了每个方法的效果。
扑克牌发明于中国,大概在 9 世纪。早期的牌组中有三个花色,它们对应钱的三个面额。
>>> chinese_suits = ['coin', 'string', 'myriad'] # A list literal >>> suits = chinese_suits # Two names refer to the same list扑克牌传到欧洲(也可能通过埃及)之后,西班牙的牌组(oro)中之只保留了硬币的花色。
>>> suits.pop() # Removes and returns the final element 'myriad' >>> suits.remove('string') # Removes the first element that equals the argument然后又添加了三个新的花色(它们的设计和名称随时间而演化),
>>> suits.append('cup') # Add an element to the end >>> suits.extend(['sword', 'club']) # Add all elements of a list to the end意大利人把剑叫做“黑桃”:
>>> suits[2] = 'spade' # Replace an element下面是传统的意大利牌组:
>>> suits ['coin', 'cup', 'spade', 'club']我们现在在美国使用的法式变体修改了前两个:
>>> suits[0:2] = ['heart', 'diamond'] # Replace a slice >>> suits ['heart', 'diamond', 'spade', 'club']也存在用于插入、排序和反转列表的操作。所有这些修改操作都改变了列表的值,它们并不创建新的列表对象。
共享和身份。由于我们修改了一个列表,而不是创建新的列表,绑定到名称 chinese_suits 上的对象也改变了,因为它与绑定到 suits 上的对象是相同的列表对象。
>>> chinese_suits # This name co-refers with "suits" to the same list ['heart', 'diamond', 'spade', 'club']列表可以使用 list 构造函数来复制。其中一个的改变不会影响另一个,除非它们共享相同的结构。
>>> nest = list(suits) # Bind "nest" to a second list with the same elements >>> nest[0] = suits # Create a nested list在最后的赋值之后,我们只剩下下面的环境,其中列表使用盒子和指针的符号来表示:

根据这个环境,修改由 suites 指向的列表会影响 nest 第一个元素的嵌套列表,但是不会影响其他元素:
>>> suits.insert(2, 'Joker') # Insert an element at index 2, shifting the rest >>> nest [['heart', 'diamond', 'Joker', 'spade', 'club'], 'diamond', 'spade', 'club']与之类似,在 next 的第一个元素上撤销这个修改也会影响到 suit 。
由于这个 pop 方法的调用,我们返回到了上面描述的环境。
由于两个列表具有相同内容,但是实际上是不同的列表,我们需要一种手段来测试两个对象是否相同。Python 引入了两个比较运算符,叫做 is 和 is not ,测试了两个表达式实际上是否求值为同一个对象。如果两个对象的当前值相等,并且一个对象的改变始终会影响另一个,那么两个对象是同一个对象。身份是个比相等性更强的条件。
译者注:两个对象当且仅当在内存中的位置相同时为同一个对象。CPython 的实现直接比较对象的地址来确定。
>>> suits is nest[0] True >>> suits is ['heart', 'diamond', 'spade', 'club'] False >>> suits == ['heart', 'diamond', 'spade', 'club'] True最后的两个比较展示了 is 和 == 的区别,前者检查身份,而后者检查内容的相等性。
列表推导式。列表推导式使用扩展语法来创建列表,与生成器表达式的语法相似。
例如, unicodedata 模块跟踪了 Unicode 字母表中每个字符的官方名称。我们可以查找与名称对应的字符,包含这些卡牌花色的字符。
>>> from unicodedata import lookup >>> [lookup('WHITE ' + s.upper() + ' SUIT') for s in suits] ['', '', '', '']列表推导式使用序列的接口约定增强了数据处理的范式,因为列表是一种序列数据类型。
扩展阅读。Dive Into Python 3 的 推导式 一章包含了一些示例,展示了如何使用 Python 浏览计算机的文件系统。这一章介绍了 os 模块,它可以列出目录的内容。这个材料并不是这门课的一部分,但是推荐给任何想要增加 Python 知识和技巧的人。
实现。列表是序列,就像元组一样。Python 语言并不提供给我们列表实现的直接方法,只提供序列抽象,和我们在这一节介绍的可变方法。为了克服这一语言层面的抽象界限,我们可以开发列表的函数式实现,再次使用递归表示。这一节也有第二个目的:加深我们对调度函数的理解。
我们会将列表实现为函数,它将一个递归列表作为自己的局部状态。列表需要有一个身份,就像任何可变值那样。特别地,我们不能使用 None 来表示任何空的可变列表,因为两个空列表并不是相同的值(例如,向一个列表添加元素并不会添加到另一个),但是 None is None 。另一方面,两个不同的函数足以区分两个两个空列表,它们都将 empty_rlist 作为局部状态。
我们的可变列表是个调度函数,就像我们偶对的函数式实现也是个调度函数。它检查输入“信息”是否为已知信息,并且对每个不同的输入执行相应的操作。我们的可变列表可响应五个不同的信息。前两个实现了序列抽象的行为。接下来的两个添加或删除列表的第一个元素。最后的信息返回整个列表内容的字符串表示。
>>> def make_mutable_rlist(): """Return a functional implementation of a mutable recursive list.""" contents = empty_rlist def dispatch(message, value=None): nonlocal contents