LECTURE 07

序列与容器

一个名字终于可以装下一堆值。列表、切片、for 循环、列表推导式——以及「复制」这件事从此开始变得危险。

教材:Composing Programs §2.3 Sequences 对应作业:Lab 03 / Project Cats

0. 本讲导读

到上一讲为止,你手里的值都是一个一个的:一个数、一个布尔值、一个字符串、一个函数。写 count_partitions 这种树递归时,你能算出「有多少种分法」,但没法把那些分法本身留下来——因为你没有任何办法让一个名字同时指向多个值。

这个限制比看上去严重。想一想「求这组数的平均值」:你不知道这组数有几个,所以没法写 def average(a, b, c);想一想「把这句话里每个词都翻译一遍」:句子的长度是运行时才知道的。凡是数据的个数在写代码时还不确定的问题,用目前的工具都写不出来。

本讲补上的是容器(container):一个值,内部装着别的值。有了它,「一组数据」本身成了可以命名、可以传参、可以返回的一个值,而「数据有几个」变成了运行时才回答的问题。CS 61A 的下半场——数据抽象、树、链表、Scheme 里的表——全部建立在这一讲上。

但容器带来的不只是方便。列表是可变(mutable)的,这意味着从今天起,「两个名字指向同一个值」这件事第一次会产生可观察的后果:改了一边,另一边跟着变。本讲的切片、浅拷贝、is 与 ==,讲的都是同一个问题的不同侧面——到底有几个对象,谁在指着谁。这个问题下一讲(可变性与数据抽象)会正面展开,本讲先把它摆到台面上。

往回看:本讲的 sum_list_rec 用的还是第 5、6 讲的递归骨架,只不过「更小的问题」从「更小的数」变成了「更短的列表」。往前看:Lab 03 的主题就是 Sequences 与 Recursion,而项目 Cats 从头到尾都在切列表、切字符串。

核心结论
  • 容器(container)是一组值的集合;序列(sequence)是有顺序的容器。Python 里的 str、list、range 都是序列,本讲讲的索引、切片、in、len、for 对它们基本通用。
  • 索引从 0 开始;lst[i] 只是 getitem(lst, i) 的语法糖,它和函数调用一样是个表达式,求值成一个值。
  • 凡是写着「end」的地方(range 的终点、切片的终点)都不含终点。range(a, b) 的长度是 b - a。
  • 切片会造出一个新列表,而且是浅拷贝(shallow copy):新列表里的每个格子仍然指向原来那些对象。所以外层改动互不影响,内层嵌套的列表却是共享的。
  • == 问「内容一样吗」,is 问「是同一个对象吗」。a == b 为真而 a is b 为假,是列表的常态。
  • append / extend / insert / remove 这些方法就地修改列表并返回 None。写 lst = lst.append(4) 会让 lst 变成 None——这是本讲最高频的 bug。
  • 空容器是 falsy,非空容器是 truthy。所以判断「列表空不空」直接写 if not lst:。

1. 容器、序列与列表基础

两个定义,先分清

容器(container):一个装着若干个值的值。
序列(sequence):有顺序的容器——每个元素有确定的位置,你可以问「第 0 个是谁」。

「有顺序」这三个字是关键。下一讲会遇到集合(set)和字典(dict),它们是容器但不是序列:你不能问一个 set 的「第 0 个元素」,因为它根本没有第 0 个。本讲的所有语法——索引、切片、for 的遍历顺序——都依赖「有顺序」这个前提。

Python 里最常见的三个序列类型:

类型例子元素是什么能改吗
str(字符串)'hello'单个字符(本身也是长度为 1 的字符串)不能
list(列表)[1, 'abc', True]任意值,可以混着放能
rangerange(5)一串连续整数不能

本讲讲列表的语法时,请随时记着:其中很大一部分对字符串同样成立。len、索引、切片、in、for 遍历,字符串全都支持。真正的分水岭只有一条——字符串不能改。

造一个列表

用方括号把若干个表达式逗号分隔起来,就是一个列表字面量(list literal):

>>> lst = [1, 'abc', True]   # can mix and match data types
>>> len(lst)
3

第一行发生的事,用第 1 讲的求值规则拆开是这样的:

1 求值右边的表达式 [1, 'abc', True]。方括号里的三个子表达式从左到右各自求值,得到 1、'abc'、True 三个值。
2 Python 在内存里新建一个列表对象,让它的第 0、1、2 个格子分别指向刚才那三个值。
3 把名字 lst 绑定到这个列表对象上——注意,绑定的不是「三个值」,而是「那一个列表」。

第 3 步值得多想一秒。lst 这个名字在当前帧里只占一格,它指向的是一个整体。列表内部装着三个指向元素的引用,但那是列表的事,不是环境的事。这个区分在讲别名时会变得性命攸关。

len(lst) 返回元素个数 3。注意 len 数的是顶层的格子数,不管格子里装的是什么——哪怕某个格子里装的是另一个有 100 个元素的列表,它也只算 1 个。

List Basics 幻灯片
一张图里塞进了列表的四件事:元素类型可以不一致(1、'abc'、True 混装);len 数顶层元素个数;下标从 0 开始;lst[i] 和 getitem(lst, i) 是同一件事的两种写法。最后一行 lst[1] = 'def' 是本课程第一次出现的「改动已有对象」——它没有造新列表,而是把原列表 1 号格子里的东西换掉了。

索引:方括号是函数调用的语法糖

>>> lst[0]      # zero-indexed
1
>>> from operator import getitem
>>> getitem(lst, 2)     # equivalent to square bracket notation
True

幻灯片特意把 getitem 拿出来讲,不是为了让你以后写 getitem(lst, 2)——没人这么写。它想说的是一件观念上的事:

核心结论

lst[0] 不是什么新语法,它就是一个普通的调用表达式,只是穿了件方括号的外衣。所以第 1 讲那套求值规则完全适用:先求值 lst 得到列表对象,再求值 0 得到整数,然后把 getitem 施加到这两个实参上,返回一个值。

推论:方括号里可以放任意表达式,只要它求值成整数。lst[i + 1]、lst[len(lst) - 1]、lst[f(x)] 都合法,因为它们都是「先求值成一个整数,再传给 getitem」。

为什么从 0 开始?把下标理解成「离开头有多远」就顺了:第 0 个元素离开头 0 步,第 1 个离开头 1 步。长度为 3 的列表,合法下标是 0、1、2;最后一个元素的下标是 len(lst) - 1。

下标越界会得到一个非常明确的错误:

>>> lst = [1, 'abc', True]
>>> lst[3]
Traceback (most recent call last):
  ...
IndexError: list index out of range

Python 还支持负数下标,从右边数起:lst[-1] 是最后一个,lst[-2] 是倒数第二个。等价关系是 lst[-k] 就是 lst[len(lst) - k]。这个特性在本讲最后的凯撒密码练习里会被用得很巧妙。

表达式(lst = ['a', 'b', 'c'])结果说明
lst[0]'a'第一个
lst[2]'c'最后一个,下标 = len(lst) - 1
lst[-1]'c'倒数第一,等价于 lst[2]
lst[-3]'a'倒数第三,等价于 lst[0]
lst[3]IndexError越界
lst[-4]IndexError反方向也会越界
常见误区

下标必须是整数,写成字符串或浮点数都会炸,而且报错信息完全不同:

>>> lst['0']
Traceback (most recent call last):
  ...
TypeError: list indices must be integers or slices, not str
>>> lst[1.0]
Traceback (most recent call last):
  ...
TypeError: list indices must be integers or slices, not float

看到 TypeError: list indices must be...,说明你算下标的那个表达式类型不对。最常见的来源是用了 / 而不是 //:lst[len(lst) / 2] 里 / 的结果永远是 float,哪怕它是 2.0。改成 lst[len(lst) // 2] 就好了。

下标赋值:本课程第一次「改动一个值」

>>> lst = [1, 'abc', True]
>>> lst[1] = 'def'      # mutation: reassignment
>>> lst
[1, 'def', True]

这两行值得停下来。lst[1] = 'def' 是一条赋值语句,但它跟你见过的所有赋值都不一样:

x = 5lst[1] = 'def'
改的是什么当前帧里名字 x 的绑定列表对象内部第 1 个格子的内容
环境图上哪里变了帧里多一行 / 改一行帧里一个字都没变,变的是堆上的那个列表
别的名字看得见吗看不见(别的名字有自己的绑定)看得见——只要它也指着这个列表
术语重新绑定(rebinding)变异(mutation)

「帧里一个字都没变」是理解可变性的钥匙。画个图看看 lst[1] = 'def' 前后:

执行前
  Global frame
    lst ──┐
          ▼
        列表对象 @id1
        ┌─────┬───────┬──────┐
        │  0  │   1   │  2   │
        ├─────┼───────┼──────┤
        │  1  │ 'abc' │ True │
        └─────┴───────┴──────┘

执行 lst[1] = 'def' 之后
  Global frame
    lst ──┐          ← 绑定没动,还是指向 @id1
          ▼
        列表对象 @id1  ← 还是同一个对象,只是 1 号格换了内容
        ┌─────┬───────┬──────┐
        │  0  │   1   │  2   │
        ├─────┼───────┼──────┤
        │  1  │ 'def' │ True │
        └─────┴───────┴──────┘

字符串则完全不给你这个机会:

>>> s = 'hello'
>>> s[0]
'h'
>>> s[0] = 'j'
Traceback (most recent call last):
  ...
TypeError: 'str' object does not support item assignment

这条报错信息要记住——它是「你想改一个不可变对象」的标准信号。想要「改过的字符串」,只能造一个新的:s = 'j' + s[1:]。本讲末尾的凯撒密码正是因为这个限制,才要先把字符攒进一个列表、最后再 join 成字符串。

2. for 循环与 range

用 while 遍历列表有什么不好

有了列表和 len,你其实已经能用第 2 讲的 while 遍历它了:

lst = ['a', 'b', 'c']
i = 0
while i < len(lst):
    print(lst[i])
    i += 1

这段代码能跑,但四行里有三行跟「打印每个元素」这件事毫无关系:i = 0 是初始化、i < len(lst) 是边界、i += 1 是更新。三行样板代码,每一行都是一个能出错的地方——i 从 1 开始就漏掉第一个,条件写成 <= 就 IndexError,忘了 i += 1 就死循环。

for 循环把这三行全部收进语法里:

for <var> in <iterable>:
    <suite>
>>> lst = ['a', 'b', 'c']
>>> for elem in lst:
...     print(elem)
...
a
b
c

执行 for 循环时到底发生了什么

for 是一条复合语句,和 if、while 一样由 header 加缩进的 suite 组成。它的执行过程是:

1 求值 header 里 in 后面的表达式(这里是 lst),得到一个可迭代对象(iterable)。这一步只做一次,不是每轮都做。
2 依次取出它的每个元素。对每个元素:把 <var> 这个名字绑定到该元素上(在当前帧里,跟普通赋值一样),然后执行一遍 suite。
3 元素取完,循环结束,继续执行 for 后面的语句。

第 2 步里「跟普通赋值一样」这句话有两个后果,都很重要。

后果一:for 不新建帧。 和 while 一样,循环变量就是当前帧里的一个普通名字。所以循环结束之后它还在,值是最后一次绑定的那个:

>>> for elem in ['a', 'b', 'c']:
...     pass
...
>>> elem
'c'

后果二:改循环变量不会影响原列表。 这是初学者最常摔的一跤:

>>> lst = [1, 2, 3]
>>> for elem in lst:
...     elem = elem * 2
...
>>> lst
[1, 2, 3]
第 2 轮开始时(elem 被绑定到列表的 1 号元素)
  Global frame
    lst  ──┐
    elem ──┼──┐
           ▼  │
        列表 @id1
        ┌───┬───┬───┐
        │ 1 │ 2 │ 3 │
        └───┴─▲─┴───┘
              └───────┘   elem 和列表的 1 号格 指向同一个值 2

执行 elem = elem * 2 之后
  Global frame
    lst  ──┐
    elem ──── 4      ← 只是把 elem 改指到新算出来的 4
           ▼
        列表 @id1
        ┌───┬───┬───┐
        │ 1 │ 2 │ 3 │   ← 列表一个字没变
        └───┴───┴───┘

道理和 x = 5 一模一样:elem = ... 改的是名字的绑定,而列表的格子是另一处存放引用的地方。要真的改列表,必须走下标赋值 lst[i] = ...——也就是说你需要下标,而 for elem in lst 恰恰不给你下标。这就引出了 range。

range:一串按需生成的整数

range(end)               # 从 0 开始
range(start, end)        # 从 start 开始
range(start, end, step)  # 步长为 step
注意

end 是不含的(exclusive)。 所以 range(start, end) 的长度是 end - start,不是 end - start + 1。range(5) 给出 0、1、2、3、4 —— 五个数,没有 5。

这个约定不是为了折磨你。正因为 end 不含,range(len(lst)) 才恰好是「所有合法下标」:长度为 3 的列表,下标是 0、1、2,而 range(3) 正好给这三个。如果 end 含在内,这里就得永远写 range(len(lst) - 1),反而更容易错。

写法产生的整数长度
range(5)0, 1, 2, 3, 45
range(2, 5)2, 3, 43(= 5 − 2)
range(2, 10, 3)2, 5, 83
range(5, 0, -1)5, 4, 3, 2, 15
range(5, 5)(什么都没有)0
range(5, 2)(什么都没有,步长为正却要往回走)0

后两行值得留意:range 为空时,for 循环体一次都不执行,而且不会报错。程序「什么也没干就结束了」的时候,先怀疑 range 是不是空的。

还要说清一件事:range(1000000) 不会在内存里造一百万个整数。range 对象只记住 start、end、step 三个数,用到第几个才算第几个。所以它在交互式解释器里直接显示自己是这样:

>>> range(5)
range(0, 5)
>>> list(range(5))
[0, 1, 2, 3, 4]

想看到里面的数,得用 list(...) 把它铺开。(这和后面 map / filter 的显示是同一回事。)

按下标遍历

>>> lst = ['a', 'b', 'c']
>>> for i in range(len(lst)):
...     print(i)
...
0
1
2

这就是「需要下标时」的标准写法。回到刚才那个「把每个元素翻倍」的需求,现在能写对了:

>>> lst = [1, 2, 3]
>>> for i in range(len(lst)):
...     lst[i] = lst[i] * 2
...
>>> lst
[2, 4, 6]

区别只在于赋值语句的左边:elem = ... 改名字,lst[i] = ... 改列表。

直觉

什么时候用 for x in lst,什么时候用 for i in range(len(lst))?

  • 只需要「每个元素的值」——用前者。它更短、更不容易错。
  • 需要下标本身(要改列表、要比较相邻元素 lst[i] 和 lst[i+1]、要同时遍历两个等长列表)——用后者。

不要因为「看起来更专业」而无脑写 range(len(lst))。多出来的那个 i 是多出来的一个出错机会。

序列解包:一次绑定多个名字

如果被遍历的每个元素本身是一个序列,可以在 for 的变量位置写多个名字,一次拆开:

>>> lst = [[1, 2], [3, 4], [5, 6]]
>>> for x, y in lst:     # sequence unpacking
...     print(x, y)
...
1 2
3 4
5 6

每一轮做的事是:取出一个元素(比如 [1, 2]),然后同时把 x 绑到它的第 0 个、y 绑到第 1 个。这叫序列解包(sequence unpacking)。它要求每个元素的长度恰好等于名字的个数,否则报错:

>>> for x, y in [[1, 2], [3, 4, 5]]:
...     print(x, y)
...
1 2
Traceback (most recent call last):
  ...
ValueError: too many values to unpack (expected 2)

注意它是跑到第二轮才炸的——第一轮已经打印出 1 2。这是循环里所有类型错误的共同特征:前面几轮的副作用已经发生了。

enumerate 把「值」和「下标」一起给你,正好配合解包使用:

>>> lst = ['a', 'b', 'c']
>>> for i, elem in enumerate(lst):    # sequence unpacking
...     print(i, elem)
...
0 a
1 b
2 c

enumerate(lst) 概念上产生 (0, 'a')、(1, 'b')、(2, 'c') 这一串成对的东西,解包后 i 拿下标、elem 拿值。这是「既要值又要下标」时最干净的写法——比 for i in range(len(lst)) 再 lst[i] 好读,也少一次索引。

常见误区

enumerate 给出的顺序是 (下标, 值),不是 (值, 下标)。写反了不会报错,只会安静地给你一个错误答案——这比报错难查得多:

>>> lst = [10, 20, 30]
>>> total = 0
>>> for elem, i in enumerate(lst):   # 写反了!
...     total += elem
...
>>> total
3

你以为在加 10 + 20 + 30 = 60,实际加的是 0 + 1 + 2 = 3,因为 elem 拿到的是下标。调试口诀:结果小得离谱、或者恰好等于「0 + 1 + … + (n−1)」,就去看 enumerate 的两个名字是不是反了。

下划线:一个不打算用的名字

>>> for _ in range(3):    # _ is convention for an unused variable
...     print('Go bears!')
...
Go bears!
Go bears!
Go bears!

_ 在 Python 里就是一个普通的合法名字,没有任何特殊语法含义。用它纯粹是一种约定:告诉读代码的人「这个循环我只关心重复三次,根本不在乎当前是第几轮」。你写成 for i in range(3) 完全等价,只是读者会花半秒钟去找 i 在哪用了,然后发现没用。

3. 成员判断:in 比你想的浅

<value> in <sequence> 是一个表达式,求值成 True 或 False,问的是「这个值是不是该序列的某一个元素」。

>>> lst = [1, 2, [3, 4]]
>>> 2 in lst
True
>>> 3 in lst
False
>>> [3, 4] in lst
True
>>> len(lst)
3

中间那行是整段的重点:3 in lst 是 False,尽管 3 明明「在里面」。原因是 lst 只有三个元素:1、2、[3, 4]。3 不是其中任何一个,它是第三个元素的元素。

核心结论

in 只看一层。它逐个取出序列的顶层元素,拿每个去和左边的值做 == 比较,有一个相等就返回 True。它不会递归下钻到嵌套的列表里。

len 也是同样的「只看一层」:len([1, 2, [3, 4]]) 是 3,不是 4。这两个函数的口径是一致的——顶层有几个格子,就是几个元素。

既然比较用的是 ==,那 [3, 4] in lst 为真就顺理成章:lst[2] == [3, 4] 成立(两个列表内容相同),所以 in 认账。注意它不要求是同一个对象——右边那个 [3, 4] 是刚刚新建的列表,和 lst 里装的根本不是一个东西,但 == 为真就够了。这一点在下一节 is vs == 里会再提。

字符串上的 in 不一样

字符串是唯一的例外:对 str 来说,in 判断的是子串(substring),不是「某个字符」:

>>> 'b' in 'abc'
True
>>> 'bc' in 'abc'      # 子串也算
True
>>> 'ac' in 'abc'      # 必须连续
False
>>> 'bc' in ['a', 'b', 'c']    # 列表就不吃这一套
False
表达式结果为什么
2 in [1, 2, [3, 4]]True2 是顶层元素
3 in [1, 2, [3, 4]]False3 藏在嵌套列表里,in 不下钻
[3, 4] in [1, 2, [3, 4]]True顶层第 2 个元素与它 ==
[3] in [1, 2, [3, 4]]False[3] == [3, 4] 不成立
'bc' in 'abc'True字符串特例:查连续子串
'bc' in ['a','b','c']False列表里没有名为 'bc' 的元素
2 in range(5)Truerange 也是序列

另外,in 在 for 语句里的那个 in 跟这个 in 是两个完全不同的东西:前者是 for 语法的一部分(关键字),后者是一个运算符。看到 in 先看它前面是不是 for。

4. 切片:取出一段,顺手复制一份

索引一次只能拿一个元素。想拿「第 1 到第 3 个」,用切片(slicing):

>>> lst = ['a', 'b', 'c', 'd', 'e']
>>> lst[1:3]      # end is exclusive
['b', 'c']
>>> lst[1:]       # default end is len(lst)
['b', 'c', 'd', 'e']
>>> lst[:2]       # default start is 0
['a', 'b']
>>> lst[::2]      # default step is 1
['a', 'c', 'e']

语法是 lst[start:end:step],三个部分各有默认值:

省略的部分默认值记法
start0「从头开始」
endlen(lst)「一直到尾」
step1「一个不落」

和 range 一样,end 不含。所以 lst[1:3] 拿的是下标 1 和 2,两个元素,长度恰好是 3 - 1 = 2。

直觉

把下标想成元素之间的「刀口」,而不是元素本身:

   'a'   'b'   'c'   'd'   'e'
  0     1     2     3     4     5

刀口 0 在最左边,刀口 5 在最右边。lst[1:3] 就是「在刀口 1 和刀口 3 处各切一刀,取中间那段」——中间夹着 'b' 和 'c'。这个模型让「end 不含」变得自然:刀口 3 左边的东西才被取走。它同时解释了为什么 lst[2:2] 是空列表(同一刀口切两下,中间什么都没有),以及为什么 lst[:2] + lst[2:] 恰好拼回原列表。

切片永远不越界

这是切片和索引最大的行为差异,也是它好用的原因之一:

>>> lst = [1, 2]
>>> lst[5]
Traceback (most recent call last):
  ...
IndexError: list index out of range
>>> lst[1:5]      # 切片不报错,能拿多少拿多少
[2]
>>> lst[5:9]
[]

切片超出范围只会给你一个更短(甚至为空)的列表,永远不会 IndexError。 这个性质对写递归极其友好:lst[1:] 哪怕在 lst 只有一个元素、甚至是空列表时,也安安静静地返回 [],不需要你先判断长度。第 9 节的 sum_list_rec 就靠这一点。

切片赋值:这是变异

>>> lst = ['a', 'b', 'c', 'd', 'e']
>>> lst[1:3] = ['go', 'bears']    # slicing assignment mutates
>>> lst
['a', 'go', 'bears', 'd', 'e']

把切片写在赋值号左边,效果是「把这一段整个换成右边那些元素」。它改的是原列表,跟 lst[1] = 'def' 一样属于变异。

有意思的是,新旧长度不必相等——列表会自己伸缩:

>>> z = [1, 2, 3, 4, 5]
>>> z[1:3] = [9]          # 两个换一个,列表变短
>>> z
[1, 9, 4, 5]
>>> z = [1, 2, 3]
>>> z[1:2] = [7, 8, 9]    # 一个换三个,列表变长
>>> z
[1, 7, 8, 9, 3]

切片取值:造出一个新列表

切片写在赋值号右边时,行为完全不同——它不碰原列表,而是新建一个:

>>> lst
['a', 'go', 'bears', 'd', 'e']
>>> temp = lst[1:3]       # slicing creates a (shallow) copy
>>> temp[1] = 'stanford'
>>> lst
['a', 'go', 'bears', 'd', 'e']
>>> temp
['go', 'stanford']
Slicing (2 of 2) 幻灯片
同一个 lst[1:3] 出现在赋值号两侧,做的是截然不同的两件事:在左边(第 1 行)是变异,把原列表那一段换掉;在右边(第 4 行)是取值并复制,造出一个独立的新列表 temp,所以后面改 temp[1] 时 lst 纹丝不动。最下面两条 Tip 是整门课都会反复用到的惯用法。
temp = lst[1:3] 之后
  Global frame
    lst  ──▶ 列表 @A ['a', 'go', 'bears', 'd', 'e']
                       │      │
                       │      │      (只是内容上巧合相同的引用)
    temp ──▶ 列表 @B ['go', 'bears']
                       ▲
                       └── @B 是一个全新的对象,@A 和 @B 是两个列表

temp[1] = 'stanford' 之后
    lst  ──▶ 列表 @A ['a', 'go', 'bears', 'd', 'e']   ← 没动
    temp ──▶ 列表 @B ['go', 'stanford']               ← 只有 @B 的 1 号格变了

由此得到两条一定要背下来的惯用法(幻灯片上的 Tip 1 和 Tip 2):

写法作用典型用途
lst[:]整个列表的(浅)拷贝函数里不想弄脏调用方传进来的列表
lst[::-1]倒序的(浅)拷贝反转,且不破坏原列表
lst[1:]去掉第一个元素的拷贝列表上的递归:「首元素 + 其余」
lst[:-1]去掉最后一个元素的拷贝从尾部递归

lst[::-1] 之所以能反转,是因为 step 为 -1 表示从右往左走,而 start / end 省略时会自动取「最右」和「最左之外」。这是个惯用法,记住形状即可,不必每次现推。

常见误区

误区一:以为 lst[:] 和 lst 是一回事。 它们内容相同(== 为真),但是两个对象(is 为假)。这正是它作为「拷贝」的价值所在。

误区二:以为 lst[::-1] 会把原列表反转。 它不会,它返回一个新列表;原列表原封不动。真想就地反转,用 lst.reverse()——但那个方法返回 None,别拿它的返回值。

误区三:字符串上做切片赋值。 字符串不可变,s[1:3] = 'xy' 会报 TypeError: 'str' object does not support item assignment。但字符串取值切片完全没问题:'hello'[1:3] 得到 'el'。

5. 嵌套列表与浅拷贝:copy 到底复制了什么

列表的元素可以是任意值——包括另一个列表。这叫嵌套列表(nested list),是后面「树」「矩阵」「链表」的物质基础。

访问嵌套元素就是连着写两次索引:

>>> lst = [1, 2, [3, 4]]
>>> lst[2]
[3, 4]
>>> lst[2][0]
3

lst[2][0] 依然只是普通的表达式求值:先算 lst[2] 得到那个内层列表,再对结果取 [0]。和 f(x)(y) 是完全同一个道理——上一步的结果就是下一步的操作对象。

浅拷贝的真面目

现在把切片拷贝和嵌套列表放到一起,本讲最关键的一段就出现了:

>>> lst = [1, 2, [3, 4]]
>>> copy = lst[:]      # careful, this is a shallow copy!
>>> lst[1] = 'go'
>>> lst
[1, 'go', [3, 4]]
>>> copy
[1, 2, [3, 4]]
>>> lst[2][0] = 'bears'
>>> lst
[1, 'go', ['bears', 4]]
>>> copy
[1, 2, ['bears', 4]]
Nested Lists 幻灯片
同一份代码里两次修改,结果却不对称:lst[1] = 'go' 之后 copy 毫无变化,而 lst[2][0] = 'bears' 之后 copy 却跟着变了。区别在于第一次改的是「外层列表的格子」(两个列表各有各的格子),第二次改的是「内层那个列表本身」(两个外层列表共用同一个内层列表)。

这个不对称让所有人第一次都看不懂。画出来就一目了然了:

① lst = [1, 2, [3, 4]]

  lst ──▶ 列表 @A
          ┌───┬───┬─────┐
          │ 1 │ 2 │  ·──┼──▶ 列表 @C  ┌───┬───┐
          └───┴───┴─────┘             │ 3 │ 4 │
                                      └───┴───┘

② copy = lst[:]     ← 新建列表 @B,把 @A 每个格子里的「引用」原样抄一份

  lst  ──▶ 列表 @A
           ┌───┬───┬─────┐
           │ 1 │ 2 │  ·──┼──┐
           └───┴───┴─────┘  │
                            ├──▶ 列表 @C  ┌───┬───┐
  copy ──▶ 列表 @B          │             │ 3 │ 4 │
           ┌───┬───┬─────┐  │             └───┴───┘
           │ 1 │ 2 │  ·──┼──┘
           └───┴───┴─────┘

   关键:@A 和 @B 是两个不同的列表对象,
        但它们的 2 号格 指向的是「同一个」 @C。

③ lst[1] = 'go'    ← 只改 @A 的 1 号格

  lst  ──▶ @A [1, 'go', ·──▶@C]
  copy ──▶ @B [1,  2 ,  ·──▶@C]     ← @B 完全不受影响

④ lst[2][0] = 'bears'
   先算 lst[2] → 拿到 @C 本身;再把 @C 的 0 号格改成 'bears'

  lst  ──▶ @A [1, 'go', ·──┐
                           ├──▶ @C ['bears', 4]   ← 被改的是这里
  copy ──▶ @B [1,  2 ,  ·──┘

   于是 copy 显示为 [1, 2, ['bears', 4]] —— 它并没有「被改」,
   它只是一直指着那个 @C,而 @C 变了。
核心结论

浅拷贝(shallow copy)复制的是「格子里的引用」,不是「引用指向的对象」。所以:

  • 拷贝出来的是一个新的外层列表——改外层格子(lst[i] = ...)互不影响。
  • 但内层的嵌套对象还是同一个——通过任一条路径去变异它(lst[i][j] = ...、lst[i].append(...)),另一条路径都看得见。

判断口诀:看赋值号左边有几层方括号。一层(lst[1] = ...)动的是拷贝自己的格子;两层及以上(lst[2][0] = ...)动的是共享的那个内层对象。

验证「共享」这件事,可以直接问 Python:

>>> lst = [1, 2, [3, 4]]
>>> copy = lst[:]
>>> lst is copy
False
>>> lst[2] is copy[2]
True

外层不是同一个,内层是同一个。这两行把「浅拷贝」四个字彻底说清了。

注意

「浅」不是缺陷,只是一个需要知道的事实。要真正独立的副本(深拷贝),得自己递归地拷:

def deep_copy(lst):
    result = []
    for elem in lst:
        if type(elem) == list:
            result.append(deep_copy(elem))
        else:
            result.append(elem)
    return result

(标准库里有 copy.deepcopy,但 61A 不用它。这段代码本身是一个漂亮的递归练习:base case 是「元素不是列表,直接放进去」,递归步是「元素是列表,就拷它的拷贝」。)

常见误区

用乘法造二维列表,会造出一排别名。 这是浅拷贝陷阱最出名的形态:

>>> grid = [[0, 0, 0]] * 3
>>> grid
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
>>> grid[0][0] = 'X'
>>> grid
[['X', 0, 0], ['X', 0, 0], ['X', 0, 0]]

* 3 只是把同一个引用重复放了三次,三行其实是同一个列表。改一行就是改三行。正确写法是让每一行都是新建的:

>>> grid = [[0, 0, 0] for _ in range(3)]
>>> grid[0][0] = 'X'
>>> grid
[['X', 0, 0], [0, 0, 0], [0, 0, 0]]

因为列表推导式每一轮都重新求值一次 [0, 0, 0],每轮造一个新列表。(列表推导式见第 7 节。)

6. 身份与相等:is 和 == 问的不是同一个问题

>>> a = [1, 2, 3]
>>> b = [1, 2, 3]
>>> a == b
True
>>> a is b
False
Identity vs. Equality 幻灯片
四行代码就把「相等」和「同一」分了家:两条列表字面量各自新建了一个对象,内容完全一样所以 == 为真;但它们是内存里两个独立的东西,所以 is 为假。整门课后面讲别名、讲可变数据、讲环境图,用的都是 is 这个口径。
==(equality,相等)is(identity,同一)
问的问题内容一样吗?是同一个对象吗?
怎么判断逐个元素比较(嵌套的会递归比)比较内存地址(id())
[1,2] == [1,2]True—
[1,2] is [1,2]—False
关系a is b 为真 ⟹ a == b 为真;反过来不成立

为什么每写一次 [1, 2, 3] 就得到一个新对象?因为列表可变。如果 Python 偷懒让两个字面量共用一个列表,那 a.append(4) 就会莫名其妙地把 b 也改了——没有人能接受这种事。所以每一次求值列表字面量,都必然新建一个对象。

那么什么时候 is 才为真?只有当两个名字确实指着同一个对象时:

>>> a = [1, 2, 3]
>>> b = a            # 只是给同一个列表起了第二个名字
>>> a is b
True
>>> b.append(4)
>>> a
[1, 2, 3, 4]
>>> c = a[:]         # 切片造新对象
>>> a is c
False
>>> a == c
True

b = a 这行没有造任何列表,它只是让 b 和 a 指向同一处。这种「一个对象两个名字」的情况叫别名(aliasing),是下一讲的主角。is 就是检测别名的工具。

b = a  之后(别名)          c = a[:] 之后(拷贝)

  a ──┐                        a ──┐
      ├──▶ 列表 @X                 ├──▶ 列表 @X  [1,2,3,4]
  b ──┘                        b ──┘
                               c ────▶ 列表 @Y  [1,2,3,4]

  a is b → True                a is c → False
  改 @X,a 和 b 都看得见        a == c → True(内容碰巧一样)
                               改 @Y,a 和 b 都看不见
常见误区

拿 is 去比较数字或字符串,会得到「有时对有时错」的诡异结果。

>>> x = 1000
>>> y = int('1000')
>>> x is y
False
>>> x == y
True
>>> s1 = 'hello'
>>> s2 = ''.join(['h', 'e', 'l', 'l', 'o'])
>>> s1 is s2
False
>>> s1 == s2
True

但如果你把 y = int('1000') 换成 y = 1000,x is y 很可能变成 True——因为 CPython 会缓存小整数、也会把同一段代码里的相同字面量合并成一个常量。这些都是实现细节,不是语言保证,换个 Python 版本、换个执行方式(脚本 vs 交互式)结果就可能不同,绝对不能依赖。(较新的 Python 看到 256 is 256 这类写法还会警告 SyntaxWarning: "is" with a literal. Did you mean "=="?)

规矩:比较「值相不相等」永远用 ==;只有在问「是不是同一个对象」时才用 is。 唯一常见的例外是 x is None——判断 None 惯例上用 is,因为 None 全程序只有一个。

直觉

拿双胞胎打比方:两个长得一模一样的人,== 说「是的,一样」;is 说「不,这是两个人」。而一个人有小名和大名,is 才会说「是的,同一个人」。

7. 列表推导式:把「造一个新列表」写成一个表达式

「对列表里每个元素做点什么,得到一个新列表」这个模式出现的频率高到离谱。用 for 循环写出来永远是同一副骨架:

squares = []
for x in range(5):
    squares.append(x ** 2)

列表推导式(list comprehension)把这三行压成一个表达式:

[<map_expression> for <var> in <iterable>]
>>> [x for x in range(5)]
[0, 1, 2, 3, 4]
>>> [x ** 2 for x in range(5)]
[0, 1, 4, 9, 16]

求值过程

1 求值 <iterable>(这里是 range(5)),只做一次。
2 新建一个空列表,准备装结果。
3 依次取出每个元素,把 <var> 绑到它,然后求值 <map_expression>,把得到的值 append 进结果列表。
4 整个方括号表达式的值就是那个新列表。

注意第 3 步:<map_expression> 写在最前面,但它是最后才被求值的——每一轮都要先有 x 才能算 x ** 2。读推导式的正确顺序是先读中间的 for,再回头读开头的表达式。

核心结论

列表推导式是一个表达式,不是语句。这意味着它能出现在任何需要值的地方:函数实参里、return 后面、另一个推导式里面。而 for 循环是语句,只能单独占一行。这就是推导式真正的价值——它让「造一个列表」成为一件可以内联的事。

另一个后果:推导式的结果总是一个新列表,原列表不会被改动。

加过滤条件

>>> [x ** 2 for x in range(5) if x % 2 == 0]
[0, 4, 16]

在最后加 if <condition>,含义是:条件为假的元素直接跳过,连算都不算。所以结果列表比原来短——这里 5 个变成了 3 个。手动展开一遍:

逐步推演
xx % 2 == 0算 x ** 2 吗结果列表
0True算,得 0[0]
1False跳过[0]
2True算,得 4[0, 4]
3False跳过[0, 4]
4True算,得 16[0, 4, 16]

另一个 if:条件表达式

>>> [x ** 2 if x % 2 == 0 else x + 1 for x in range(5)]
[0, 2, 4, 4, 16]
List Comprehension (1 of 2) 幻灯片
最后两行是全讲最容易混的一对。倒数第二行的 if 在 for 后面,是过滤器,结果只剩 3 个元素;最后一行的 if ... else ... 在 for 前面,是条件表达式(属于 map 表达式的一部分),每个元素都保留、只是算法不同,所以结果仍有 5 个元素。位置决定语义。
过滤 if条件表达式 if ... else
位置在 for ... in ... 之后在 for 之前(属于 map 表达式)
有没有 else不能有必须有
结果长度可能变短和原序列一样长
作用决定「要不要这个元素」决定「这个元素算成什么」

逐个元素算一遍 [x ** 2 if x % 2 == 0 else x + 1 for x in range(5)]:

逐步推演
x偶数?用哪个分支值
0是x ** 20
1否x + 12
2是x ** 24
3否x + 14
4是x ** 216

结果 [0, 2, 4, 4, 16]。注意里面出现了两个 4,分别来自 2 ** 2 和 3 + 1——它们走的是不同的分支。

<a> if <cond> else <b> 这个条件表达式(conditional expression)不是推导式专有的语法,它在任何地方都能用:y = 1 if x > 0 else -1。它和 if 语句的区别是老问题——表达式求值成一个值,语句只产生效果。

常见误区

把两个 if 的位置搞混,会得到语法错误或者错误答案。

>>> [x for x in range(5) if x % 2 == 0 else 0]
  File "<stdin>", line 1
    [x for x in range(5) if x % 2 == 0 else 0]
                                       ^^^^
SyntaxError: invalid syntax

过滤 if 后面不允许跟 else——想想也对,「不满足条件时改成 0」根本不是「过滤」。要那个效果,就得把整个 if ... else ... 挪到前面去:[x if x % 2 == 0 else 0 for x in range(5)],得到 [0, 0, 2, 0, 4]。

什么时候不该用

推导式还能用在字典、集合等其它容器上(下一讲会见到)。但幻灯片提醒的那句话值得认真对待:可读性 vs 简洁性。

下面这种就已经过头了:

[y for x in lst for y in x if y > 0 and y % 3 != 1]

它是合法的,但没人能一眼看懂。真需要这种复杂度时,要么拆成多行写,要么老老实实用 for 循环。推导式是为了让代码更好读才存在的;如果它让代码更难读,那就用错地方了。

8. 列表方法:增、删,以及那个要命的 None

方法(method)是「绑在某个对象上的函数」,用点号调用:lst.append(7)。现在只需要知道它的行为,机制(面向对象)在后面的课里讲。

添加元素

>>> lst = [1, 2, 3]
>>> lst.append(7)
>>> lst
[1, 2, 3, 7]
>>> lst.extend([4, 5])
>>> lst
[1, 2, 3, 7, 4, 5]
>>> lst.insert(1, 'a')
>>> lst
[1, 'a', 2, 3, 7, 4, 5]
>>> [1, 2, 3] + [4, 5]
[1, 2, 3, 4, 5]
写法做什么改原列表吗返回什么
lst.append(x)把 x 作为一个元素加到末尾改None
lst.extend(s)把 s 的每个元素逐个加到末尾改None
lst.insert(i, x)在下标 i 处插入 x,后面的整体右移改None
a + b拼接不改一个新列表

append 和 extend 的差别是必考点,看清楚:

>>> a = [1, 2]
>>> a.append([3, 4])
>>> a
[1, 2, [3, 4]]        # 长度 3,末尾多了一个「列表元素」
>>> b = [1, 2]
>>> b.extend([3, 4])
>>> b
[1, 2, 3, 4]          # 长度 4,多了两个元素

一句话:append 加的是「一个东西」,extend 加的是「一批东西」。 lst.extend(s) 的效果等价于 for x in s: lst.append(x)。

顺带一个后果:extend 的参数必须可迭代,否则报错;而 append 什么都收:

>>> [1, 2].extend(3)
Traceback (most recent call last):
  ...
TypeError: 'int' object is not iterable

删除元素

>>> lst = [1, 'a', 2, 3, 7, 4, 5]
>>> lst.remove('b')
Traceback (most recent call last):
  ...
ValueError: list.remove(x): x not in list
>>> lst.remove('a')
>>> lst
[1, 2, 3, 7, 4, 5]
>>> lst.pop()
5
>>> lst
[1, 2, 3, 7, 4]
>>> lst.pop(2)
3
>>> lst
[1, 2, 7, 4]
写法按什么删返回什么失败时
lst.remove(x)按值,只删第一个匹配的NoneValueError: list.remove(x): x not in list
lst.pop()删最后一个被删掉的那个元素空列表时 IndexError: pop from empty list
lst.pop(i)按下标删被删掉的那个元素越界时 IndexError: pop index out of range

pop 是这一组里唯一有返回值的方法——它既改列表又给你东西,所以 x = lst.pop() 是完全正常的写法。而 x = lst.append(7) 就是个 bug。

常见误区

这是本讲最高频的 bug,没有之一:把变异方法的返回值拿去用。

>>> lst = [1, 2, 3]
>>> lst = lst.append(4)     # ✗ 想「更新 lst」,实际把它变成了 None
>>> lst
>>> print(lst)
None
>>> lst.append(5)
Traceback (most recent call last):
  ...
AttributeError: 'NoneType' object has no attribute 'append'

注意第 3 行:交互式解释器什么都不显示(因为值是 None),这一点很迷惑人——很多人以为「没输出说明成功了」。真正的爆炸推迟到下一次用 lst 时才发生,报错信息是 AttributeError: 'NoneType' object has no attribute ...。

看到 'NoneType' object has no attribute,第一反应就该是:我是不是把某个返回 None 的方法的结果赋给了名字。

正确写法是只调用,不赋值:

>>> lst = [1, 2, 3]
>>> lst.append(4)        # ✓ 它直接改了 lst 指向的那个列表
>>> lst
[1, 2, 3, 4]

同样的坑还有 lst = lst.sort()、lst = lst.reverse()、lst = lst.extend(...)。想要「排好序的新列表」用 sorted(lst)(函数,有返回值),想要「就地排序」用 lst.sort()(方法,返回 None)。

常见误区

一边遍历一边删,会漏掉元素。

>>> v = [1, 2, 3, 4]
>>> for e in v:
...     if e % 2 == 0:
...         v.remove(e)
...
>>> v
[1, 3]

这次碰巧对了,换一组数据就露馅:

>>> v = [2, 4, 6]
>>> for e in v:
...     if e % 2 == 0:
...         v.remove(e)
...
>>> v
[4]

原因:for 是按下标往前推进的。删掉下标 0 的 2 之后,列表变成 [4, 6],但循环下一轮要取的是下标 1,也就是 6——4 被整个跳过了。删掉 6 之后列表只剩 [4],下标 2 已越界,循环结束。

规矩:绝不在遍历一个列表的同时增删它。 要过滤,就造一个新列表:v = [e for e in v if e % 2 != 0]。(同理,在循环里 append 也可能造成永不终止的循环。)

9. 序列的真假性与一批常用函数

空容器是 falsy

第 2 讲的 falsy 名单里有一项叫「空容器」,现在可以坐实了:

>>> not []      # empty list is falsy
True
>>> not [1]
False
>>> not ''
True
>>> not [0]     # 里面装着 falsy 的东西,但列表本身非空
False

最后一行要看清:[0] 是非空列表,所以它 truthy,尽管它唯一的元素是 falsy 的 0。容器的真假性只取决于「空不空」,与内容毫无关系。

直接后果是判断空列表的惯用写法:

写法评价
if not lst:推荐。最短,最惯用
if len(lst) == 0:可以,写递归的 base case 时反而更点题
if lst == []:不推荐。多造了一个空列表对象,还只对列表有效
if lst is []:错的。永远是 False——右边那个 [] 是刚新建的对象,不可能和 lst 同一

list() 与 sum()

>>> list('abc')      # convert things into lists
['a', 'b', 'c']
>>> sum([1, 2, 3])
6
>>> sum([1, 2, 3], 10)      # optional start
16

list(x) 把任何可迭代对象铺开成列表。对字符串它按字符拆;对 range 它把整数全算出来;对已有列表它做一次浅拷贝(list(lst) 和 lst[:] 效果一样)。

sum 的第二个参数是起始值,默认 0。所以 sum([1,2,3], 10) 算的是 10 + 1 + 2 + 3 = 16。这个参数在「累加时要有个初值」的场合有用。

all() 与 any()

>>> is_even = [x % 2 == 0 for x in range(5)]
>>> is_even
[True, False, True, False, True]
>>> all(is_even)
False
>>> all([])
True
>>> any(is_even)
True
>>> any([])
False

all(s):s 里每一个元素都 truthy 吗?any(s):至少有一个吗?两者判断的都是真假性,不是「等于 True」,所以 all([1, 'hi', [0]]) 也是 True。

注意

all([]) 是 True,any([]) 是 False。 这不是随手定的。

all 问「有没有反例」——空集合里找不出反例,所以为真(数学上叫空真,vacuous truth:「这个空盒子里所有的球都是红的」这句话没法被证伪)。any 问「有没有正例」——空集合里当然一个也找不出,所以为假。

记法:all 从 True 出发,遇到假就翻;any 从 False 出发,遇到真就翻。空序列谁也遇不到,就停在出发点。

max() 与 min(),以及 key

>>> max([1, 2, 3])
3
>>> min([1, 2, 3])
1
>>> max(['apple', 'pear', 'banana'])      # alphabetically highest
'pear'
>>> max(['apple', 'pear', 'banana'], key=lambda s: len(s))
'banana'

字符串之间的大小是字典序比较:先比第一个字符,相同再比第二个……'pear' 的 'p' 排在 'apple' 的 'a' 和 'banana' 的 'b' 之后,所以它最大。

key 参数是本讲和高阶函数的接口。它的含义是:不要直接比较元素本身,先把每个元素喂给 key,比较返回的结果;但最终返回的仍是原元素。

逐步推演

max(['apple', 'pear', 'banana'], key=lambda s: len(s))

元素key(元素)当前最大
'apple'5'apple'(key = 5)
'pear'4'apple'(4 < 5,不换)
'banana'6'banana'(6 > 5,换)

返回的是 'banana' 这个字符串本身,而不是它的 key 值 6。这一点非常关键:key 只影响「谁赢」,不影响「返回什么」。

注意 key=lambda s: len(s) 里传的是函数本身,不是调用结果。写成 key=len(s) 会直接报 NameError: name 's' is not defined——这里根本没有叫 s 的名字。其实这个例子写成 key=len 更干净,len 本身就是一个函数。

map / filter / reduce

这三个是第 3 讲高阶函数在序列上的具体化身,也是「用函数处理一整个序列」的经典三件套。

>>> map(lambda x: x * x, [1, 2, 3])
<map object ...>
>>> list(map(lambda x: x * x, [1, 2, 3]))
[1, 4, 9]
>>> filter(lambda x: x > 2, [1, 2, 3, 4, 5])
<filter object ...>
>>> list(filter(lambda x: x > 2, [1, 2, 3, 4, 5]))
[3, 4, 5]
>>> from functools import reduce
>>> reduce(lambda x, y: x + y, [1, 2, 3, 4, 5])
15
>>> reduce(lambda x, y: x + y, [1, 2, 3, 4, 5], 10)   # optional start
25
Misc. Functions with Lists (4 of 4) 幻灯片
注意 map 和 filter 直接求值时显示的是 <map object ...> / <filter object ...> 而不是列表——它们返回的是「按需产生元素」的惰性对象,必须用 list(...) 包一层才能看到内容。reduce 不在内置函数里,要 from functools import reduce;它接受一个两参数函数,把序列一路折叠成单个值。
函数参数干什么等价的列表推导式
map(f, s)f 收一个参数对每个元素施加 f[f(x) for x in s]
filter(pred, s)pred 收一个参数、返回真假留下让 pred 为真的元素[x for x in s if pred(x)]
reduce(f, s)f 收两个参数两两折叠成一个值(没有等价的推导式)

reduce 的展开过程值得写清楚,因为它和前两个不是一类东西:

逐步推演

reduce(lambda x, y: x + y, [1, 2, 3, 4, 5]) 没有起始值时,先拿第一个元素当初始的累积值,然后逐个折进去:

累积值 = 1                (直接取第一个元素)
累积值 = f(1, 2)  = 3     (折进 2)
累积值 = f(3, 3)  = 6     (折进 3)
累积值 = f(6, 4)  = 10    (折进 4)
累积值 = f(10, 5) = 15    (折进 5)
                  → 15

给了起始值 10 之后,初始的累积值变成 10,第一个元素也要被折进去:

累积值 = 10
累积值 = f(10, 1) = 11
累积值 = f(11, 2) = 13
累积值 = f(13, 3) = 16
累积值 = f(16, 4) = 20
累积值 = f(20, 5) = 25
                  → 25
常见误区

误区一:忘了 list(...)。 直接把 map(...) 的结果当列表用,会得到看不懂的输出,或者 TypeError:

>>> len(map(lambda x: x * x, [1, 2, 3]))
Traceback (most recent call last):
  ...
TypeError: object of type 'map' has no len()

误区二:map / filter 对象只能用一次。 它是惰性的,元素取完就没了:

>>> m = map(lambda x: x * x, [1, 2, 3])
>>> list(m)
[1, 4, 9]
>>> list(m)      # 第二次就空了
[]

误区三:reduce 在空序列上没有起始值会炸。

>>> from functools import reduce
>>> reduce(lambda x, y: x + y, [])
Traceback (most recent call last):
  ...
TypeError: reduce() of empty iterable with no initial value

因为它连「第一个元素」都拿不到。给个起始值就没事:reduce(lambda x, y: x + y, [], 0) 返回 0。

10. 随堂练习一:sum_list,一个问题的两种解法

随堂代码 07.py 的第一题,要求把同一个功能用迭代写一遍、再用递归写一遍。这不是凑数——它是本讲最有价值的一次对照实验:同一个「遍历序列」的需求,迭代靠下标推进,递归靠切片缩短。

def sum_list_iter(lst: list) -> int:
    """
    Given a list of integers, return the sum of
    all the elements iteratively (don't just use `sum`).

    >>> sum_list_iter([])
    0
    >>> sum_list_iter([1, 2, 3])
    6
    >>> sum_list_iter([4, 6434, 16, 20, 46, 7])
    6527
    """

顺便认识一下签名里的 lst: list 和 -> int,它们叫类型注解(type hint):写给人看的说明,Python 运行时完全不检查。你传个字符串进去它照样跑(然后在别的地方炸)。

迭代版

思路是累加器(accumulator)模式,第 2 讲的老朋友:设一个变量存「到目前为止的和」,扫一遍列表,每见到一个元素就加进去。

def sum_list_iter(lst):
    total = 0
    for elem in lst:
        total += elem
    return total

三个决定,每一个都值得说明为什么:

1 total = 0 而不是 total = lst[0]。 后者在空列表上会 IndexError,而 doctest 第一条就是 sum_list_iter([])。而且 0 是加法的单位元——从它出发,空列表自然地得到 0。
2 用 for elem in lst 而不是 for i in range(len(lst))。 我们只要值,不要下标,所以用短的那个。
3 return 在循环外面。 缩进进循环里的话,第一轮就返回了——那是本课程最经典的缩进 bug。

追踪 sum_list_iter([1, 2, 3]):

逐步推演
轮次elem进入时 totaltotal += elem 后
———0(初始化)
1101
2213
3336
列表取完,循环结束,return 6

空列表时:循环体一次都不执行,直接 return total,得到初始值 0。边界情况不需要任何特殊处理——这正是「初值取单位元」的好处。

while 版本,以及官方参考解答里的一个坑

题目鼓励你「用另一种循环再写一遍」。while 版本长这样:

def sum_list_iter(lst):
    total = 0
    i = 0
    while i < len(lst):
        total += lst[i]
        i += 1          # ← 这一行不能少
    return total
注意

课程发布的参考解答 07-sol.py 里,那段被注释为 ALTERNATE SOLUTION 的 while 版本漏掉了 i += 1:

# ALTERNATE SOLUTION:
total = 0
i = 0
while i < len(lst):
    total += lst[i]
return total

这段代码在非空列表上会死循环(i 永远是 0,条件永远为真,total 一路加 lst[0])。它之所以没被 doctest 抓到,是因为它写在第一个 return total 后面,永远执行不到——属于死代码。

这里如实指出来,不是挑刺,而是因为它恰好演示了两个真实教训:(a) return 后面的代码不会执行,所以它错了也没人知道;(b) while 循环忘记更新变量就是死循环。 for 之所以更安全,正是因为它把「更新」这一步收进了语法里,不给你忘的机会。

递归版

递归的关键一步永远是同一句话:怎么把这个问题变成一个「同构但更小」的问题?

之前几讲里,「更小」意味着更小的数字(n - 1、n // 10)。在列表上,「更小」意味着更短的列表。而第 4 节已经给了现成的工具:lst[1:] 是「去掉第一个元素之后的那截」,而且它在任何长度上都不会越界。

于是分解方式自己就冒出来了:

列表的和 = 第一个元素 + 剩下那截的和

def sum_list_rec(lst):
    if len(lst) == 0:
        return 0
    else:
        return lst[0] + sum_list_rec(lst[1:])
1 base case:空列表。 每次递归调用列表都短一个,所以一定会走到空。空列表的和是 0——注意这跟迭代版的初值是同一个 0,来自同一个理由。
2 递归步:lst[0] + sum_list_rec(lst[1:])。 直接把上面那句中文翻译成代码。
3 「更小」是真的更小吗? len(lst[1:]) == len(lst) - 1,严格递减,且下界是 0,正好撞上 base case。终止性有保证。

现在真的展开一遍 sum_list_rec([1, 2, 3]),一层一层下到底再回代:

逐步推演
sum_list_rec([1, 2, 3])
  len([1,2,3]) == 0 ?  否
  → 1 + sum_list_rec([2, 3])
        │
        │  sum_list_rec([2, 3])
        │    len([2,3]) == 0 ?  否
        │    → 2 + sum_list_rec([3])
        │          │
        │          │  sum_list_rec([3])
        │          │    len([3]) == 0 ?  否
        │          │    → 3 + sum_list_rec([])
        │          │          │
        │          │          │  sum_list_rec([])
        │          │          │    len([]) == 0 ?  是  ← base case
        │          │          │    → return 0
        │          │          ▼
        │          │    → 3 + 0 = 3
        │          ▼
        │    → 2 + 3 = 5
        ▼
  → 1 + 5 = 6

递归的两个方向看得清清楚楚:下行时列表越切越短 [1,2,3] → [2,3] → [3] → [],回代时加法从最里层往外一层层算完 0 → 3 → 5 → 6。

注意每一层的加法都卡在那里等:1 + ??? 里的 ??? 不算出来,这一层就没法返回。这就是为什么要有四个函数帧同时存在。

对应的帧长这样(每次调用都新建一帧,parent 都是 Global,因为 sum_list_rec 定义在全局):

Global frame
    sum_list_rec ──▶ func sum_list_rec(lst) [parent=Global]

f1: sum_list_rec   [parent=Global]
      lst   ──▶ [1, 2, 3]
      正在算: 1 + sum_list_rec([2, 3])      ← 挂起,等 f2

f2: sum_list_rec   [parent=Global]
      lst   ──▶ [2, 3]        ← 注意:这是切片造出来的「新列表」
      正在算: 2 + sum_list_rec([3])         ← 挂起,等 f3

f3: sum_list_rec   [parent=Global]
      lst   ──▶ [3]
      正在算: 3 + sum_list_rec([])          ← 挂起,等 f4

f4: sum_list_rec   [parent=Global]
      lst   ──▶ []
      len(lst) == 0 成立 → 返回 0           ← 第一个真正返回的帧

回代:f4 返回 0 → f3 得 3+0=3 → f2 得 2+3=5 → f1 得 1+5=6
直觉

「首元素 + 其余」这个分解方式,是列表递归的标准骨架,往后会反复出现:

def f(lst):
    if not lst:          # 或 len(lst) == 0
        return <空列表时的答案>
    else:
        return <用 lst[0] 和 f(lst[1:]) 的结果拼出答案>

换个「拼」法就是另一道题:lst[0] * f(lst[1:]) 求乘积(base case 返回 1);max(lst[0], f(lst[1:])) 求最大值(base case 得单独处理单元素,因为「空列表的最大值」不存在);f(lst[1:]) + [lst[0]] 就是反转。学到 Scheme 的时候你会发现,这个骨架在那边有个名字,叫 car 和 cdr。

常见误区

误区一:写成 sum_list_rec(lst[1]),少了冒号。 那传进去的是一个整数而不是列表,下一层 len(2) 立刻报错:

TypeError: object of type 'int' has no len()

误区二:base case 写成 len(lst) == 1,返回 lst[0]。 对非空列表算得没错,但 sum_list_rec([]) 根本走不到 base case——长度 0 不等于 1,于是进 else 分支去取 lst[0]:

>>> sum_list_rec([])
Traceback (most recent call last):
  ...
IndexError: list index out of range

doctest 第一条就是空列表,一测就现原形。选 base case 的准则:它必须是递归一定会到达的那个最小情况,而不是「看起来最简单」的那个。 长度每次减 1 且下界是 0,所以「长度为 0」才是必经之地;「长度为 1」在从空列表出发时会被直接跳过。

误区三:忘了 return。 写成 lst[0] + sum_list_rec(lst[1:]) 单独一行,函数返回 None,上一层做 1 + None 就炸:

TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
注意

递归版有个迭代版没有的代价:每一层 lst[1:] 都新建一个列表。长度为 n 的列表会造出 n 个新列表,总共复制约 n²/2 个元素引用。迭代版是 Θ(n) 时间、Θ(1) 额外空间;这个递归版是 Θ(n²) 时间、Θ(n²) 空间。

在 61A 的题目规模下无所谓,但要知道「用切片做递归」是有代价的。想避开它,可以改成传下标:def helper(i): return 0 if i == len(lst) else lst[i] + helper(i + 1)——这样不切片,只递归。

11. 随堂练习二:凯撒密码 decode 与 encode

第二题把本讲的东西全用上了:字符串遍历、index 查位置、模运算绕圈、列表 append 攒结果、join 拼回字符串——外加一个漂亮的负数索引小技巧。

题目要什么

ALPHABET = 'abcdefghijklmnopqrstuvwxyz'

凯撒密码(Caesar cipher),移位 3:加密时每个字母在字母表上右移 3 位(a → d,b → e,……,z 绕回到 c),解密就是反过来左移 3 位。空格保持不变,输入只含小写字母和空格。

>>> encode('hello')
'khoor'
>>> decode('khoor')
'hello'
>>> decode('pdb wkh irufh eh zlwk brx')
'may the force be with you'
>>> 'go bears' == decode(encode('go bears'))
True

先解决「怎么表示一个字母的位置」

字母本身没法直接做算术('a' + 3 会报 TypeError: can only concatenate str (not "int") to str)。所以要先把字母换成数字,算完再换回来。ALPHABET 这个字符串就是那张对照表:

>>> ALPHABET.index('d')     # 字母 → 位置
3
>>> ALPHABET[3]            # 位置 → 字母
'd'

index 返回第一次出现的下标。ALPHABET 里每个字母只出现一次,所以这个「第一次」就是唯一一次,映射是一一对应的。(找不到时报 ValueError: substring not found,本题保证输入合法,不会遇到。)

于是整个流程固定为三步:字母 → 位置 → 移位后的位置 → 字母。

另一个约束:字符串不能改

直觉上你想写 result = result + new_char 一路拼。这能跑,但 starter code 给的骨架不是这样——它先建一个列表 decoded_chars = [],把字符一个个 append 进去,最后:

return "".join(decoded_chars)

sep.join(iterable) 把可迭代对象里的字符串用 sep 连起来。','.join(['a','b','c']) 得到 'a,b,c';这里 sep 是空字符串 "",所以就是原样粘在一起。

为什么绕这一圈?因为字符串不可变——result = result + c 每次都要造一个全新的字符串并把已有内容整个复制过去,n 个字符总共复制 Θ(n²) 次。而列表可变,append 是直接往末尾塞,最后 join 一次成型,总共 Θ(n)。「用可变的列表攒中间结果,最后转成不可变的字符串」是标准套路,Cats 项目里会反复用到。

encode:模运算处理绕圈

def encode(message):
    encoded_chars = []

    for char in message:
        if char == " ":
            encoded_chars.append(" ")
        else:
            original_index = ALPHABET.index(char)
            encoded_index = (original_index + 3) % len(ALPHABET)
            encoded_chars.append(ALPHABET[encoded_index])

    return "".join(encoded_chars)
1 for char in message——直接遍历字符串,每轮 char 是一个长度为 1 的字符串。不需要 range(len(message)),因为我们不关心位置。
2 空格单独处理。 必须先判,否则 ALPHABET.index(' ') 会 ValueError——空格不在字母表里。
3 (original_index + 3) % 26。 加 3 之后可能超出 25,取模把它拉回 0–25 的范围。% 在这里的作用是「绕圈」:它把一条直线接成了一个环。
4 len(ALPHABET) 而不是硬写 26。 这样换个字母表也不用改代码。
逐步推演

encode('hello'),逐字符走:

charoriginal_index+3% 26新字母encoded_chars
'h'71010'k'['k']
'e'477'h'['k','h']
'l'111414'o'['k','h','o']
'l'111414'o'['k','h','o','o']
'o'141717'r'['k','h','o','o','r']

"".join(['k','h','o','o','r']) → 'khoor',与 doctest 一致。

再看绕圈那一档:encode('z')。ALPHABET.index('z') 是 25,25 + 3 = 28,28 % 26 = 2,ALPHABET[2] 是 'c'。如果没有 % 26,ALPHABET[28] 会直接 IndexError: string index out of range。

decode:为什么可以不写 %

def decode(message):
    decoded_chars = []

    for char in message:
        if char == " ":
            decoded_chars.append(" ")
        else:
            encoded_index = ALPHABET.index(char)
            # This is fine even for encoded_index < 3
            # because Python supports negative indexing
            decoded_index = encoded_index - 3
            decoded_chars.append(ALPHABET[decoded_index])

    return "".join(decoded_chars)

结构和 encode 一模一样,只是 +3 换成 -3。但注意——这里没有取模,却依然正确。这是本题最巧的一处。

逐步推演

问题出在前三个字母:'a'(0)、'b'(1)、'c'(2)减 3 之后是负数。看 decode('a'):

encoded_index = ALPHABET.index('a') = 0
decoded_index = 0 - 3 = -3
ALPHABET[-3]  = ?

  ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
  正下标:   0  1  2  ...        23 24 25
  负下标:                      -3 -2 -1
                                 x  y  z

ALPHABET[-3] = 'x'

而 encode('x') 确实等于 'a'(23 + 3 = 26,26 % 26 = 0,ALPHABET[0] 是 'a')。所以 decode('a') 应该得到 'x' ——结果正确。

为什么这么巧?因为 Python 的负数索引规则是 lst[-k] == lst[len(lst) - k],而 -3 + 26 = 23 恰恰就是 (0 - 3) % 26 的值。负数索引在这里天然地帮你做完了取模。

但这只在下溢不超过一圈时成立。如果移位量是 30,0 - 30 = -30,而 ALPHABET[-30] 会直接 IndexError: string index out of range。写 % len(ALPHABET) 永远是安全的;靠负数索引是「知道这题移 3 位」才敢用的捷径。

完整验证一条:decode('pdb wkh irufh eh zlwk brx')。取前三个字符和那个空格:

char是空格?encoded_index-3ALPHABET[...]
'p'否1512'm'
'd'否30'a'
'b'否1−2'y'(负索引绕回)
' '是——直接 append ' '
'w'否2219't'

攒出 ['m','a','y',' ','t', ...],join 之后开头是 'may t...',正是 'may the force be with you'。

最后一条 doctest 'go bears' == decode(encode('go bears')) 检验的是互逆性:先 encode 得到 'jr ehduv',再 decode 应当原样回来。这种「往返测试(round-trip test)」是检验一对互逆函数的最佳手段——它不需要你手算出中间结果就能查出错。

常见误区

误区一:忘了处理空格。 直接对空格调 index:

>>> ALPHABET.index(' ')
Traceback (most recent call last):
  ...
ValueError: substring not found

误区二:decode 里也照抄 % len(ALPHABET)。 这其实没错——(0 - 3) % 26 在 Python 里就是 23,因为 Python 的 % 对负数取模结果符号跟除数走,永远非负。(这一点和 C、Java 不同,那些语言 -3 % 26 得到 -3。)所以写取模反而更稳妥。

>>> (0 - 3) % 26
23
>>> -3 % 26
23

误区三:用字符串拼接却忘了赋值。 如果你不用列表而用字符串攒结果,写成 result + new_char 单独一行是无效的——字符串不可变,加法只是算出一个新串然后扔掉。必须写 result = result + new_char。这也是 starter code 用列表 + append 的原因之一:append 是变异,不需要重新赋值,少一个出错点。

直觉

注意 encode 和 decode 的代码只差一个符号。看到这种情况,可以抽出一个更一般的函数:

def shift(message, k):
    chars = []
    for char in message:
        if char == " ":
            chars.append(" ")
        else:
            i = ALPHABET.index(char)
            chars.append(ALPHABET[(i + k) % len(ALPHABET)])
    return "".join(chars)

def encode(message):
    return shift(message, 3)

def decode(message):
    return shift(message, -3)

这就是第 3 讲高阶函数那一课的母题在这里的回响:把变化的部分(移位量)提成参数,重复的部分(流程)只写一次。

本讲小结

序列操作速查

写法含义改原对象吗返回什么
len(s)顶层元素个数否int
s[i]取第 i 个(负数从右数)否那个元素;越界 IndexError
lst[i] = x换掉第 i 个是(语句,无值)
s[a:b:c]切片,b 不含否新的同类型对象(浅拷贝)
lst[a:b] = t整段替换,长度可变是(语句,无值)
x in s顶层是否有元素 == x(字符串是查子串)否bool
a + b拼接否新列表 / 新字符串
lst.append(x)末尾加一个元素是None
lst.extend(s)末尾加 s 的每个元素是None
lst.insert(i, x)在 i 处插入是None
lst.remove(x)按值删第一个匹配是None;没有则 ValueError
lst.pop() / lst.pop(i)删末尾 / 删下标 i是被删的元素
sorted(lst)排序否新列表
lst.sort()就地排序是None

函数速查

函数作用要点
list(s)转成列表对列表相当于浅拷贝
range(a, b, c)整数序列b 不含;惰性,用 list() 才看得到内容
enumerate(s)产生 (下标, 值)顺序是下标在前
sum(s, start=0)求和第二参数是起始值
all(s) / any(s)全真 / 有真all([]) 为 True,any([]) 为 False
max(s, key=f)最大值按 f(x) 比较,但返回原元素
map(f, s)逐个施加 f惰性,只能消费一次
filter(pred, s)筛选惰性,只能消费一次
reduce(f, s, init)折叠要 from functools import reduce;f 收两个参数
sep.join(s)用 sep 连接字符串s 的元素必须都是字符串

四个必须分清的对子

对子区别
== vs is内容一样 vs 同一个对象。列表比较一律用 ==
lst[i] = x vs elem = x改列表内部 vs 改名字绑定。for 里改循环变量对列表无效
左边的切片 vs 右边的切片lst[1:3] = ... 变异原列表;t = lst[1:3] 造新列表
append vs extend加一个东西 vs 加一批东西

三条最容易踩的线

  1. lst = lst.append(x) —— 变异方法返回 None,这行把列表弄丢了。下次用它就是 AttributeError: 'NoneType' object has no attribute ...。
  2. 浅拷贝只拷一层 —— copy = lst[:] 之后 lst[2] is copy[2] 仍为 True。[[0]*3]*3 造出来的三行是同一行。
  3. 边遍历边增删 —— 元素会被跳过或永远循环。要过滤就用列表推导式造新列表。

与前后讲的接口

第 5、6 讲的递归骨架在本讲换了一副皮:「更小的问题」从更小的数变成了更短的列表,lst[1:] 就是这一讲的 n - 1。第 3 讲的高阶函数在本讲有了具体用武之地:map、filter、max(key=...) 全都靠「把函数当参数」。

下一讲会把本讲最后按下不表的那件事挑明:可变性。别名到底怎么产生、函数参数传的是什么、可变默认参数为什么是陷阱,以及怎么用数据抽象把这些危险关进笼子里。本讲的浅拷贝、is、变异方法,是那一讲的全部前置知识。

动手练习

练习 1:Python 会显示什么

逐行写出输出(没有输出就写「无」):

>>> lst = [1, [2, 3], 4]
>>> len(lst)
>>> 2 in lst
>>> [2, 3] in lst
>>> lst[1][1]
>>> lst[1:]
>>> lst[3:]
>>> lst.append([5])
>>> lst
看答案
>>> len(lst)
3
>>> 2 in lst
False
>>> [2, 3] in lst
True
>>> lst[1][1]
3
>>> lst[1:]
[[2, 3], 4]
>>> lst[3:]
[]
>>> lst.append([5])
(无输出,返回 None)
>>> lst
[1, [2, 3], 4, [5]]

逐条说明:

  • len 只数顶层,[2, 3] 算一个元素,所以是 3 不是 4。
  • 2 in lst 为 False:in 不下钻,2 藏在嵌套列表里。这是全讲最容易错的一问。
  • [2, 3] in lst 为 True:顶层 1 号元素与它 ==(不要求是同一个对象)。
  • lst[3:] 起点超出范围,但切片不越界,给空列表;换成 lst[3] 就是 IndexError 了。
  • append 返回 None,交互式解释器对 None 什么都不打印。它加的是一个元素 [5],所以列表末尾多了一个嵌套列表,长度变成 4 而不是 5。

练习 2:别名与拷贝

下面这段执行完,a、b、c 各是什么?

>>> a = [1, 2, [3, 4]]
>>> b = a
>>> c = a[:]
>>> b.append(5)
>>> c[0] = 'x'
>>> a[2].append(6)
看答案
>>> a
[1, 2, [3, 4, 6], 5]
>>> b
[1, 2, [3, 4, 6], 5]
>>> c
['x', 2, [3, 4, 6]]
初始
  a ──┐
      ├──▶ @A [1, 2, ·──▶ @C [3, 4]]
  b ──┘        (b = a 是别名,没有新建对象)
  c ────▶ @B [1, 2, ·──▶ @C]     (c = a[:] 新建 @B,但 2 号格还是指 @C)

b.append(5)  → 改的是 @A         → a、b 都看到 5;c 看不到
c[0] = 'x'   → 改的是 @B 的 0 号格 → 只有 c 变
a[2].append(6) → 先算 a[2] 拿到 @C,再改 @C
               → @A 和 @B 的 2 号格都指着 @C,所以 a、b、c 全都看到 6

三条修改覆盖了三种情况:改别名共享的外层对象(b 和 a 一起变)、改拷贝自己的格子(只有 c 变)、改被共享的内层对象(三个全变)。看懂这题,浅拷贝就算过关了。

练习 3:两个 if 的位置

写出下面三个表达式的值:

>>> [x * 2 for x in [1, 2, 3, 4] if x > 2]
>>> [x * 2 if x > 2 else 0 for x in [1, 2, 3, 4]]
>>> [x * 2 for x in [1, 2, 3, 4] if x > 2 if x % 2 == 0]
看答案
[6, 8]
[0, 0, 6, 8]
[8]

第一个是过滤:只有 3 和 4 通过,各乘 2 得 [6, 8],长度从 4 变成 2。

第二个是条件表达式:四个元素一个不少,只是 1 和 2 走 else 分支变成 0,得 [0, 0, 6, 8],长度还是 4。「结果长度变没变」是区分这两种 if 最快的办法。

第三个:过滤条件可以连写多个,效果是「与」。要同时满足 > 2 和「偶数」的只有 4,得 [8]。写成 if x > 2 and x % 2 == 0 完全等价,而且更好读。

练习 4:写代码

实现 count_occurrences(lst, x),返回 x 在 lst 里出现的次数。要求写两版:一版迭代,一版递归,都不许用 lst.count。

>>> count_occurrences([1, 2, 1, 3, 1], 1)
3
>>> count_occurrences([], 5)
0
>>> count_occurrences([[1], [1], 2], [1])
2
看答案
def count_occurrences_iter(lst, x):
    count = 0
    for elem in lst:
        if elem == x:
            count += 1
    return count


def count_occurrences_rec(lst, x):
    if len(lst) == 0:
        return 0
    elif lst[0] == x:
        return 1 + count_occurrences_rec(lst[1:], x)
    else:
        return count_occurrences_rec(lst[1:], x)

迭代版就是累加器骨架,初值取 0(计数的单位元)。递归版是「首元素 + 其余」骨架:base case 是空列表(数出来是 0),递归步看首元素算不算数,算就 1 +,不算就直接把结果传上去。

两个要点:

  • 用 == 而不是 is。 第三条 doctest 就是为此设计的:[[1], [1], 2] 里的两个 [1] 和传进来的 [1] 是三个不同的对象,用 is 会得到 0,用 == 才得到 2。
  • 递归的两个分支都要 return。 else 那支很容易漏写 return,一漏就返回 None,上层做 1 + None 报 TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'。

追踪 count_occurrences_rec([1, 2, 1], 1):

count([1,2,1], 1)   lst[0]=1 == 1  → 1 + count([2,1], 1)
  count([2,1], 1)   lst[0]=2 != 1  →     count([1], 1)
    count([1], 1)   lst[0]=1 == 1  → 1 + count([], 1)
      count([], 1)  空             → 0
    回代:1 + 0 = 1
  回代:      1
回代:1 + 1 = 2

回代结果 2,与 [1, 2, 1] 里 1 出现两次相符。顺带注意中间那层:lst[0] 是 2,不等于 1,它什么都不加,直接把下层的结果原样返回上去——这一层是「透明」的。

练习 5:这段代码错在哪

下面这个函数想「把列表里所有奇数删掉」,但它有 bug。指出 bug、说清为什么,并给出两种改法。

def remove_odds(lst):
    for elem in lst:
        if elem % 2 == 1:
            lst.remove(elem)
    return lst
看答案

bug:一边遍历一边删。 实测:

>>> remove_odds([1, 3, 5])
[3]
>>> remove_odds([1, 3, 2, 5])
[3, 2]

为什么:for 在内部是按下标 0、1、2…… 往前推进的。以 [1, 3, 5] 为例:

循环取的下标此刻列表取到的 elem动作动作后列表
0[1, 3, 5]1删 1[3, 5]
1[3, 5]5(3 被跳过了!)删 5[3]
2[3]下标 2 已越界循环结束[3]

删掉一个元素后,后面所有元素整体左移一格,而循环的下标还在往右走——于是每删一个就漏检一个。

改法一:造新列表(推荐)。

def remove_odds(lst):
    return [elem for elem in lst if elem % 2 == 0]

干净、无副作用。缺点是它不改原列表,调用方必须接住返回值。

改法二:真要就地改,就遍历一份拷贝。

def remove_odds(lst):
    for elem in lst[:]:      # 遍历拷贝,删的是原列表
        if elem % 2 == 1:
            lst.remove(elem)
    return lst

lst[:] 是一个独立的新列表,遍历它时原列表怎么删都不影响推进。这正是第 4 节 Tip 1 的实际用途。

顺带一个隐藏问题:即使修好了,lst.remove(elem) 删的是第一个等于 elem 的元素,不一定是当前这个。列表里有重复值时会出乱子。这也是「改法一」更值得推荐的原因——它压根不需要考虑这些。