🐍 第3章 列表、循环与自定义函数
📄 一句话概括

函数把一段逻辑包起来:参数是入口,return 是出口。 没有 return 的函数返回 None,不是「什么都不返回」。 传列表进函数,函数改了它,外面看得见;传数字进去改,外面看不见。 这是可变与不可变的第一次正面遭遇。

📝 本章和前面几章的关系

前两篇的代码都是一路写到底的,改一处要翻半天。 教材第 3 章最后两节是【创建自定义函数】和【再谈列表】。 这两节合在一起讲,因为「传给函数之后原数据变没变」这个问题,答案全在列表身上。 这一篇是第 3 章的收尾,也是第 4 章【可变与不可变】的引子。 那一条讲透了,后面所有的踩坑都能提前躲开。

一、参数是入口,return 是出口

def area(w, h): return w * h,四个词:def 说「我要定义」,area 是名字,括号里是入口, return 是出口。 调用 area(3, 4) 得 12,area(2.5, 4) 得 10.0。 同一个函数,参数类型换了照样跑——这是 Python 不给参数标类型的直接后果。 没有 return 的函数返回 None。不是「什么都不返回」,是「返回了一个叫 None 的东西」。 这两句话差别很大:前者你以为不能用,后者你能拿去判断 if x is None。

return 一旦执行,函数立刻结束。 写在 return 后面的代码永远跑不到——这不是错误,但常常是 bug 的来源,尤其是从别的语言转过来、 习惯了「最后一行是返回值」的人。

二、默认值和关键字参数

def greet(name, punct='。')——参数后面写个等号,就是默认值。不传就用默认的,传了就用你给的。 规矩只有一条:带默认值的参数必须写在后面。 def f(a=1, b) 会直接报语法错。 为什么?因为调用时是按位置对应的。如果第一个参数能省、第二个不能省,解释器没法判断你省的是哪一个。

调用时写上参数名,就变成关键字参数:greet('小王', punct='!')。 好处是顺序不重要,而且看一眼就知道这个值给的是谁。 参数一多,这个好处就明显了。draw(x=10, y=20, w=100, h=50) 比 draw(10, 20, 100, 50) 好读十倍。 还有一个坑要提前说:默认值只在定义时算一次。 写 def f(a=[]) 会让这个空列表被所有调用共享,第二次调用时它已经不是空的了。 要可变默认值,写 a=None 再在函数里判断。

三、看着像返回三个值,其实是一个元组

return lo, hi, avg 看着像返回三个值。演示里 type() 一打,是 tuple。 Python 没有「多返回值」这个功能,它是「一个元组 + 自动打包」。 左边用三个变量接,叫解包:lo, hi, avg = stats(...)。 个数对不上会报 ValueError,不是安静地给一半。

所以接收端也可以只用一个变量接:t = stats(...),然后 t[0]、t[1]。 两种写法都合法,前者可读性好,后者在「待会儿要整个传下去」时更方便。

四、局部变量出不去

演示第四段:外面 counter = 0,函数里写 counter = 1,函数跑完外面还是 0。 因为函数里赋值就是造一个新的局部变量,跟外面那个同名变量没有关系。名字一样,位置不一样。 真要改外面的,得在函数里写 global counter。但更好的做法是让它 return 出来,外面接住再赋值。

为什么?因为 global 让函数的副作用变得看不见——你读函数体时不知道它偷偷改了外面什么。 能 return 就别 global。 顺带一个相关的东西:函数里可以「读」外面的变量,不用声明。只有「写」的时候才需要 global。 这条不对称经常让人困惑。

五、传列表进去,会被改掉

演示第五段是关键对照。 add_item(box, '扳手') 里写 box.append(item),函数跑完,外面的 box 也多了扳手。 换成 box = box + [item],外面就看不见了。差别在哪?append 改的是传进来那个列表本身; box + [...] 造了一个新列表,赋给局部名字,跟外面没关系。 这是「可变对象」的脾气:传的是标签,不是箱子。 函数拿到的是同一个列表的另一个名字, 改它就是改原来的。

数字、字符串、元组没有这个问题,因为它们不给改。x = x + 1 永远是造一个新的,原来那个动不了。 想让函数不改原数据,进去先复制一份:box = list(box) 或 box = box.copy()。 多写一行,省掉一整类难查的 bug。

六、再谈列表:排序的 key 和浅拷贝

演示最后一段用了 [('螺丝', 3), ('扳手', 12), ('电钻', 1)] 这种「名字配数量」的列表。 要按数量排,写 sorted(items, key=lambda t: t[1])。 key 就是「按什么排」,给一个函数,从每个元素里取出一列来比。 不用自己写比较逻辑,也不用把数据先拆成两个列表。这是 Python 排序最省事的地方。

想倒序加 reverse=True;想按名字排就 key=lambda t: t[0]。规则换一下就行,排序的骨架不动。 第二个坑是浅拷贝。a[:]、a.copy()、list(a) 都只复制最外面一层。 里面如果还装着列表,那些内层列表还是同一批。 演示里 nested = [[1, 2, 99], [3, 4]],浅拷贝之后改内层,两个都变。 要连里层一起复制,得用 copy.deepcopy。

什么时候需要深拷贝? 数据结构里套着可变对象,而且你要改里层。 只有一层的话,浅拷贝就够了——本书绝大部分场景都属于这种。

七、动手 Demo:函数:把一段逻辑包起来

先写最小的函数,再看默认值和关键字参数怎么用。 然后揭穿「返回三个值」的真相:它其实是一个元组。 最后两个对照最关键:局部变量出不去,但传进去的列表改得动。

print('① 最小的函数')
def area(w, h):
    return w * h
print('  area(3, 4)   =', area(3, 4))
print('  area(2.5, 4) =', area(2.5, 4))
def nothing():
    pass
print('  没有 return 的函数返回:', nothing())

print('② 默认值、关键字参数')
def greet(name, word='你好', punct='。'):
    return '%s,%s%s' % (word, name, punct)
print("  greet('小王')            =", greet('小王'))
print("  greet('小王', '早上好')   =", greet('小王', '早上好'))
print("  greet('小王', punct='!') =", greet('小王', punct='!'))
print("  greet(punct='?', name='小李') =", greet(punct='?', name='小李'))

print('③ 一次返回多个值')
def stats(nums):
    return min(nums), max(nums), sum(nums) / len(nums)
r = stats([9, 22, 14, 20])
print('  返回', r)
print('  类型', type(r).__name__)
lo, hi, avg = stats([9, 22, 14, 20])
print('  拆开 lo=%s hi=%s avg=%.2f' % (lo, hi, avg))

print('④ 局部变量出不去')
counter = 0
def bump():
    counter = 1
    return counter
print('  函数返回', bump())
print('  函数跑完,外面 counter 还是', counter)

print('⑤ 传进去的列表会被改掉')
def add_item(box, item):
    box.append(item)
def add_item2(box, item):
    box = box + [item]
    return box
box = ['螺丝']
add_item(box, '扳手')
print('  传列表:add_item(box, \'扳手\') → box =', box)
box2 = ['螺丝']
new = add_item2(box2, '扳手')
print('  改成 box = box + [item] → box2 =', box2, ' 返回的', new)

print('⑥ 再谈列表:排序与浅拷贝')
items = [('螺丝', 3), ('扳手', 12), ('电钻', 1)]
print('  原顺序        ', items)
print('  按数量排       ', sorted(items, key=lambda t: t[1]))
print('  按名字排(反)  ', sorted(items, key=lambda t: t[0], reverse=True))
nested = [[1, 2], [3, 4]]
shallow = nested[:]
shallow[0].append(99)
print('  浅拷贝后改里层 nested =', nested, ' shallow =', shallow)
import copy
deep = copy.deepcopy(nested)
deep[0].append(100)
print('  深拷贝后改里层 nested =', nested, ' deep =', deep)

实跑输出:

--------------------------------------------------------------------
函数:把一段逻辑包起来
--------------------------------------------------------------------

  ① 最小的函数
  --------------------------------------------------------------------
    def area(w, h):
        return w * h
    area(3, 4)   = 12
    area(2.5, 4) = 10.0
  参数是入口,return 是出口。没有 return 的函数返回 None,不是「什么都不返回」。

  ② 默认值、关键字参数
  --------------------------------------------------------------------
    greet('小王')                = 你好,小王。
    greet('小王', '早上好')       = 早上好,小王。
    greet('小王', punct='!')     = 你好,小王!
  带默认值的参数要写在后面。调用时写参数名(关键字参数)可以跳着给,顺序就不重要了。

  ③ 一次返回多个值
  --------------------------------------------------------------------
    返回 (9, 22, 14.5)
    拆开 lo=9 hi=22 avg=14.50
    类型 tuple
  看着像返回三个值,其实返回的是一个元组。左边用三个变量接,叫解包。

  ④ 局部变量出不去
  --------------------------------------------------------------------
    外面 counter = 0
    函数里 counter = 1
    函数跑完,外面 counter 还是 0
  函数里赋值就是造一个新局部变量,跟外面同名的那个没关系。
  真要改外面的,得写 global counter——但更好的做法是让它 return 出来。

  ⑤ 传进去的列表会被改掉
  --------------------------------------------------------------------
    传列表:add_item(box, '扳手') → box = ['螺丝', '扳手']
    改成 box = box + [item] → box2 = ['螺丝']
  append 改的是传进来那个列表本身,所以外面看得见。
  写 box = box + [...] 是造了个新列表,外面就看不见。想让函数不改原数据,进去先 copy 一份。

  ⑥ 再谈列表:排序与浅拷贝
  --------------------------------------------------------------------
    原顺序            [('螺丝', 3), ('扳手', 12), ('电钻', 1)]
    按数量排           [('电钻', 1), ('螺丝', 3), ('扳手', 12)]
    按名字排(反)      [('螺丝', 3), ('电钻', 1), ('扳手', 12)]
    浅拷贝后再改里层    nested = [[1, 2, 99], [3, 4]]
  key 就是「按什么排」,给个函数取出一列即可,不用自己写比较。
  浅拷贝只复制最外面一层。里层还是同一批列表,改里层两个都变。
  要连里层一起复制,得用 copy.deepcopy。

第一段那个 pass 是「先占个位」的写法,函数体不能空着,至少要有这一行。 它返回 None,不是「什么都没有」。 第二段最后一行调换了参数顺序,照样跑对——因为写的是参数名。参数一多,这个好处就很明显。 第三段是本篇一个反直觉点:看着返回三个值,type 一打是 tuple。 Python 没有多返回值,它是把三个值打包成一个元组再给你,左边解包拆开。 第四段和第五段是一组对照。局部变量改不了外面,但列表改得动。 差别在「可变」两个字:数字和字符串不给改,列表给改。 第六段那个 add_item 和 add_item2 的对比最值得记住:append 改的是传进来那个列表, box + [...] 造的是新列表。想让函数不动原数据,进去先 copy 一份。 浅拷贝那两行要连起来看:nested 和 shallow 的外层是两个列表,但里层还是同一批。改里层两边都变。 deepcopy 才是彻底断干净。

30 秒实验

  1. 把 def greet(name, word='你好', punct='。') 改成 def greet(word='你好', name, punct='。'), 看报的是什么错。
  2. 把演示第五段的 add_item(box, '扳手') 改成连续调两次,看 box 里有几个扳手。
  3. 把演示第六段的 sorted(items, key=lambda t: t[1]) 改成 sorted(items, key=lambda t: -t[1]), 看是不是也能倒序(提示:数字前面加负号就等于反着比)。

常见误区

  1. 「函数没有 return 就什么都不返回。」它返回 None。 None 是一个值,能判断、能赋值、能打印。写 x = f() 之后 x 就是 None, 不是「没有 x」。
  2. 「参数传进函数就是复制了一份。」数字和字符串是这样,列表不是。 列表传的是标签,函数改它,外面看得见。
  3. 「a.copy() 复制得很干净。」它只复制最外面一层。 里面套着列表的话,那些内层还是同一批,改里层两个都变。要彻底断干净用 copy.deepcopy。
自测三问(点开对答案) 1. 函数里写 `counter = 1`,为什么外面的 `counter` 没变? —— 因为函数里的赋值造了一个新的局部变量,跟外面那个同名变量没有关系。名字一样,位置不一样。 要改外面的得写 `global counter`,但更好的做法是 `return` 出来让外面接。 2. `add_item(box, '扳手')` 里写 `box.append(item)` 和写 `box = box + [item]`, 对调用方有什么不一样? —— `append` 改的是传进来那个列表本身,调用方的 `box` 也变了; `box + [item]` 造了一个新列表赋给局部名字,调用方的 `box` 一点没动。**前者是改,后者是换。** 3. `a = [[1, 2], [3, 4]]`,`b = a.copy()`,然后 `b[0].append(9)`,`a` 会变吗? —— 会。`copy()` 只复制最外面一层,`b[0]` 和 `a[0]` 还是同一个列表。 要连里层一起复制得用 `copy.deepcopy(a)`。 判断标准很简单:结构里套着可变对象、而你要改里层,就得深拷贝。

第 3 章到此结束。下一篇进第 4 章:让程序去动硬盘上的文件,先从路径说起。

相关笔记