Python函数陷阱复盘:默认参数、闭包与作用域实战解析

Python函数陷阱复盘:默认参数、闭包与作用域实战解析 前几天把一份《Python 测验3函数》翻出来重做了一遍。说实话当时做这套题的时候我心里是有点膨胀的觉得函数不就是def加return嘛结果对答案的时候傻眼了有三四道题是我以为稳对、实际全错的。后来我没有急着去看标准答案而是把每道错题都丢进解释器里跑一遍逐个拆解背后的运行机制。为什么单独把函数拎出来出一个测验因为函数是几乎所有 Python 项目的“最小组织单元”你可以不写类但不能不写函数。函数题表面上考语法实际考的是对命名空间、对象引用、调用栈这些底层概念的掌握程度而这些恰恰是写业务代码时最容易想当然的地方。这篇文章就是我重做这套函数测验的完整复盘。包含具体题目、错误输出、底层原理、正确写法还会把考试题延伸成真实项目里的函数设计经验。适合刚刚学完 Python 基础、想检验自己函数掌握程度的读者也适合给学员出题、备课的朋友参考。每道题我都会给出在解释器里可以直接跑的代码建议你边看边敲。1. 这套函数测验到底在考什么1.1 为什么“函数”值得单独出一次测验很多初学者觉得函数是 Python 里最好糊弄过去的知识点定义、传参、返回值好像半天就能看完。但真到写项目时函数写得好不好直接决定了一个模块能不能测试、能不能复用、别人能不能看懂。函数题和其他语法题最大的区别在于它考的是“边界情况”和“运行机制”。比如默认参数会不会被共享、局部变量和全局变量怎么绑定、闭包捕获的到底是什么、return和print搞混了会有什么后果。这些坑在一两行的小 demo 里根本看不出来一旦放进真实代码里就是线上事故级别的 bug。所以我复盘这套测验时给自己定了三条标准第一结果对不对第二能不能用自己的话讲清楚“为什么是这个结果”第三能不能根据这个知识点写出更健壮的代码。只有三条都过了才算真正吃透一道题。1.2 我按什么顺序重新梳理这份测验原题的题目顺序比较跳跃一会儿考参数、一会儿考作用域。我复盘时没有按原顺序走而是按“运行时行为”重新归了类第一类关于“函数定义时发生了什么”。典型考点是默认参数、函数对象属性。第二类关于“函数调用时名字怎么查找”。典型考点是作用域、闭包、全局变量。第三类关于“函数返回什么、不返回什么”。典型考点是return、None、多返回值。第四类关于“参数怎么收集、怎么展开”。典型考点是*args、**kwargs、解包。按这个顺序走下来你会发现一个很有意思的事实函数题里的大多数错误都不是“语法不会写”而是“不理解 Python 在背后替你做了什么”。下面我按这个分类把错题一道一道拆开。2. 第一类错题可变默认参数引发的连锁反应2.1 原题两次调用同一个函数结果居然“记住了”上次的数据题目是这样的def add_item(item, items[]): items.append(item) return items print(add_item(1)) print(add_item(2)) print(add_item(3, [0])) print(add_item(4))请写出输出结果。我当时毫不犹豫地写了[1] [2] [0, 3] [4]理由是每次调用add_item时items如果没传就创建一个新的空列表[]然后往里面添加元素。传给add_item(3, [0])那次我重新传了一个列表所以应该是[0, 3]。看起来逻辑没毛病但实际运行结果让我当场愣住[1] [1, 2] [0, 3] [1, 2, 4]第二次调用输出[1, 2]第四次调用输出[1, 2, 4]。也就是说第二次和第四次调用时函数“记得”第一次调用添加的1。这个现象背后藏着一个非常核心的机制Python 函数的默认参数值只会在函数定义时被求值一次而不是每次调用时都求值。2.2 为什么默认列表会被“共享”定义时求值机制当解释器执行到def add_item(item, items[]):这一行时它会创建一个函数对象然后把items[]这个空列表对象保存到函数对象的某个属性里。这个属性就在add_item.__defaults__里。之后每次调用add_item时如果没有显式传入itemsPython 就会直接从__defaults__里取同一个列表对象而不是新建一个。你可以亲自验证一下print(add_item.__defaults__) # 输出: ([1, 2, 4],)这个元组里装的还是那个列表对象而且已经被改得面目全非了。这里最关键的理解点是默认参数在“定义时”就固定下来了它不是一个每次调用都会重新执行的“初始值表达式”。这和你习惯的很多语言不太一样。比如在某些语言里默认参数每次调用都会重新赋值但在 Python 里默认参数是函数对象的一部分。2.3 正确写法None 哨兵值而不是可变对象标准修正方案是用None作为默认值然后在函数体内判断并重新创建列表def add_item(item, itemsNone): if items is None: items [] items.append(item) return items这样每次不传items时函数体内都会新创建一个列表对象彻底避开“共享”问题。为什么大家都推荐写itemsNone而不是items[]因为None是不可变对象不存在被修改的问题在函数体内重新赋值也不会影响其他调用。而且这个写法语义清晰看到None你就知道这里是在用“哨兵值”表示“未提供该参数”比一个空列表要直观得多。2.4 延伸考点值传递还是引用传递其实是“对象引用传递”做完这道题之后我顺手复盘了另一个相关知识点Python 函数的参数到底是值传递还是引用传递答案是两者都不是准确说法是“对象引用传递”。看这个例子def change_first(nums): nums[0] 99 lst [1, 2, 3] change_first(lst) print(lst) # [99, 2, 3]列表被修改了。初学者会以为这是“引用传递”。但再看这个def reassign(nums): nums [100, 200, 300] lst [1, 2, 3] reassign(lst) print(lst) # [1, 2, 3]外部列表没有被改成新列表。如果真的是引用传递nums [...]应该会改变外部变量指向。但实际情况是传入函数的是对象的引用但这个引用本身是按值传递的。也就是说函数内的局部变量nums和外部变量lst指向同一个对象但它们是两个不同的名字对nums重新赋值只是让nums指向了新对象外部lst没有任何影响。我的理解方式是把参数想象成“门牌号复印件”。你拿到的是对象在内存里的地址门牌号可以通过这个地址找到对象并修改它但如果你在函数里改写“门牌号复印件”并不会改变对方手里的原件。这道题给我最大的收获是以后写函数时如果参数是可变对象先问自己三个问题——我是不是要在函数内部修改它外部调用者是否希望我修改它如果不想修改是不是应该先拷贝一份3. 第二类错题作用域、闭包与那些“变了却没变”的变量3.1 局部变量遮蔽 UnboundLocalError为什么全局变量在函数里不能用第二道让我印象深刻的题是这样的count 0 def inc(): count 1 return count print(inc())我当时觉得很简单count是全局变量函数里直接count 1然后返回输出1。结果一跑直接报错UnboundLocalError: local variable count referenced before assignment这个报错不少写过一段时间 Python 的人都遇到过。关键在于count 1这个赋值语句让 Python 把count当成了局部变量。既然它是局部变量而函数第一行还没有给count赋值就直接使用它自然就报了“引用在赋值之前”的错误。这个规则的底层逻辑是 Python 的“命名空间绑定”机制在一个函数体内只要某个名字出现在赋值语句左侧Python 就从那一刻起把这个函数里所有对该名字的引用都绑定到局部变量上。这不是运行时才决定的编译函数体的时候就已经决定了。如果你真的想修改全局变量需要显式声明count 0 def inc(): global count count 1 return count print(inc()) # 1global count的意思就是告诉 Python 解释器函数内部出现的count不要再当作局部变量请直接去全局命名空间里找它。3.2 闭包里的一层变量nonlocal 到底解决了什么问题全局变量之后又碰到了一种更隐蔽的情况。考虑这个计数器def make_counter(): count 0 def counter(): count 1 return count return counter c make_counter() print(c())这段代码同样会报UnboundLocalError。原因和上面的global问题一模一样只不过这次count不在全局作用域而在外层函数make_counter的局部作用域里。要想在内层函数中修改外层函数的局部变量用global是不行的因为count不是全局变量。这里需要用nonlocaldef make_counter(): count 0 def counter(): nonlocal count count 1 return count return counter c make_counter() print(c()) # 1 print(c()) # 2nonlocal会向上查找“最近的、既不是全局也不是局部的变量”并允许你修改它。接下来要问一个关键问题闭包捕获的到底是变量的值还是变量本身答案是变量本身准确说是“变量所在的单元格”。counter函数并不保存count当前的值而是保存着一个指向count的引用外层每次修改count内层都能感知到。你可以用__closure__来验证c make_counter() print(c.__closure__) # (cell at 0x...: int object at 0x...,)这个元组里的cell对象就是闭包保存变量的“容器”。3.3 经典陷阱循环里造 lambda为什么结果全是同一个值考察闭包时几乎必考的另一道题是循环和lambda的组合funcs [lambda: i * i for i in range(3)] for f in funcs: print(f())我当时想i依次是 0、1、2所以输出应该是0 1 4。实际输出却是4 4 4原因还是闭包捕获的是变量本身不是循环每次迭代时的快照。循环结束后i的最终值是2而所有lambda函数共享同一个小循环作用域里的i所以每次调用f()拿到的都是 2。解决方式有两种。第一种是用默认参数绑定当前值funcs [lambda ii: i * i for i in range(3)] for f in funcs: print(f())默认参数在函数定义时求值所以ii把当前循环变量值“固化”到了默认参数里。第二种是用一个额外的工厂函数def make_square(i): return lambda: i * i funcs [make_square(i) for i in range(3)]make_square每次调用都会创建新的作用域i是它的局部变量闭包捕获的是每个作用域里独立的i互不干扰。3.4 调试闭包的小工具inspect 和closure如果你在项目里遇到“闭包表现诡异”建议先用inspect模块看看函数的闭包信息。比如import inspect def outer(x): def inner(): return x return inner f outer(42) print(inspect.getclosurevars(f)) # ClosureVars(nonlocals{x: 42}, globals{}, builtins{}, unboundset())getclosurevars会清清楚楚告诉你这个函数依赖哪些非局部变量以及它们的当前值。排查闭包问题时比盲目加print高效得多。我还习惯用一个小小的判断口诀问自己“这个内层函数想用的是创建时的值还是以后随时可能变的变量”如果不希望它随后续变化而改变就要么在创建时通过默认参数“拍照”要么不要让变量暴露在外层作用域。4. 第三类错题return 与 print 的纠缠以及没有显式返回的函数4.1 原题print(hello) 之后result 到底是什么再来一道很经典但很容易错的题def hello(): print(hello) result hello() print(result)输出是什么我当时写的是hello hello想得很天真hello()打印一次 hello返回值赋给result再把result打印一次所以是两行 hello。但实际输出是hello None原因就一句话print是输出到终端return才是把值交给调用者没有return的函数返回值永远是None。这是初学者最容易犯的混淆。很多人一开始写函数时喜欢在函数内部用print来“返回”结果结果后面想拿这个结果做进一步运算时发现拿到的是None最后到处找 bug。一个非常实用的判断标准如果函数只是向终端展示信息用print如果函数要产出数据、供其他代码继续处理用return。两者职责完全不同。4.2 多返回值其实是返回一个元组这道题还算简单但接下来的这题更绕def minmax(nums): return min(nums), max(nums) result minmax([3, 1, 4, 1, 5]) print(result) print(type(result))输出是(1, 5) class tuple从语法上看一个return后面跟着两个用逗号分隔的表达式好像“返回了多个值”。但 Python 里并没有“返回多个值”这回事实际是把它们打包成了一个元组对象返回。调用方接收到的永远只有一个对象只不过这个对象是元组。接收时可以解包low, high minmax([3, 1, 4, 1, 5]) print(low, high) # 1 5这个解包过程也是元组解包本质上和a, b (1, 5)没有区别。如果返回的数据项比较多比如三个以上我建议不要再裸着返回元组而是用具名元组或者数据类这样读代码的人能一眼看懂每个字段的含义from collections import namedtuple Range namedtuple(Range, [low, high]) def minmax(nums): return Range(min(nums), max(nums)) r minmax([3, 1, 4, 1, 5]) print(r.low, r.high) # 1 54.3 return 除了返回结果还能提前退出函数复盘到这里我发现测验里很多题只关注“返回值是什么”但很少人关注return的另一个作用提前终止函数执行。比如这段代码def check_score(score): if score 0: return invalid if score 100: return invalid if score 60: return pass return fail如果score 0函数在第一个return处就结束了后面所有逻辑都不会执行。这种“守卫式”写法可以显著减少嵌套层级让代码更平、更清晰。对比一种反面写法def check_score(score): if score 0: if score 100: if score 60: return pass else: return fail else: return invalid else: return invalid两层以上的if嵌套只要再叠加一个循环或异常处理代码基本就没法读了。所以我在实际项目里写函数时会刻意把“异常分支”和“边界情况”前置用多个return提前退出把正常的主流程留在最后。4.4 给返回值加上“说明书”注解与 docstring测验题不会考函数注解但真实项目会要求你写明白函数返回什么。Python 支持在函数签名上加“类型注解”def parse_score(text: str) - int | None: try: return int(text) except ValueError: return None- int | None明确告诉调用者这个函数返回整数或者None。配合mypy、pyright这些静态检查工具可以在运行之前就发现很多“以为返回 int结果拿了 None”的隐患。在此基础上再加一个简短的 docstring说明参数含义、返回内容、可能抛出的异常。不需要长篇大论几行就够def parse_score(text: str) - int | None: 把字符串转成整数分数。 参数: text: 要解析的字符串。 返回: 解析成功返回分数失败返回 None。 我见过太多“完全没注释”的函数过三个月再看签名上是一堆缩写和魔法数字根本不敢改。写 docstring 和类型注解的成本很低收益却很高尤其是别人需要调用你写的函数时。5. 第四类错题*args、**kwargs 与解包的一场误会5.1 当时的错题星号到底什么时候“收集”什么时候“展开”有一道题我错得特别丢人def collect(*args, **kwargs): print(args) print(kwargs) collect(1, 2, x3) collect(*[1, 2], **{x: 3})我以为两次调用的结果不一样第一次是“接收多个普通参数”第二次是“传入列表和字典”所以输出应该不同。但实际两次输出完全一致(1, 2) {x: 3} (1, 2) {x: 3}原因要分两层理解。第一层在函数定义里*args的作用是“把调用方传入的多余位置参数收集成一个元组”**kwargs的作用是“把多余的关键字参数收集成一个字典”。这是“收集”。第二层在函数调用里*和**的作用正好反过来。*[1, 2]把列表“展开”成位置参数传入等价于collect(1, 2)**{x: 3}把字典“展开”成关键字参数传入等价于collect(x3)。这是“展开”。“收集”和“展开”都叫解包unpacking但用在定义时和调用时效果完全不同。我的记法是在定义处星号负责“把碎的收成整的”在调用处星号负责“把整的拆成碎的”。5.2 解包操作符的三种使用场景整理一下*和**在 Python 3 里有三种主要用法在函数定义中收集参数def f(a, b, *rest, **options): ...在函数调用中展开参数data [1, 2] f(*data) config {x: 10} f(**config)在列表或字典字面量中展开first [1, 2] second [3, 4] combined [*first, *second] # [1, 2, 3, 4] d1 {a: 1} d2 {b: 2} merged {**d1, **d2} # {a: 1, b: 2}第三种用法在合并列表、合并字典的场景里非常实用代码可读性远高于循环update的写法。5.3 装饰器里的参数转发为什么必须写 *args, **kwargs测验题只考了基础语法但*args, **kwargs在真实项目里最常出现在“装饰器”中。比如写一个给函数计时的装饰器import time def timer(func): def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) print(f耗时: {time.perf_counter() - start:.6f} 秒) return result return wrapper为什么wrapper必须用*args, **kwargs因为装饰器不知道它装饰的函数有哪些参数可能是add(a, b)也可能是connect(host, port, timeout3)。用*args, **kwargs把任意参数原封不动地接过来再用展开的方式原封不动地传给被装饰函数这样这个装饰器才能通用于所有函数。这就是“参数转发”的经典写法。如果把它写死比如def wrapper(a, b):这个装饰器就只能装饰接收两个位置参数的函数泛化能力就丢了。5.4 项目里的取舍*args 不能当“万能垃圾桶”这里要泼一盆冷水*args和**kwargs很方便但在业务代码里不能滥用。原因有三个。第一可读性变差。函数签名是def process(*args, **kwargs)调用方根本不知道应该传什么只能去看文档或源码。第二IDE 和类型检查工具失效。没有具体参数名自动补全和静态检查基本帮不上忙。第三错误延迟暴露。参数名写错、个数不对往往要等到运行到很深的逻辑时才报错定位成本很高。我自己的原则是自己写的业务函数尽量用明确的参数名和类型注解避免直接用*args, **kwargs。装饰器、框架回调、通用分发器、日志封装这类“需要转发参数”的代码才放心大胆地用*args, **kwargs。如果函数已经需要*args才能接住各种情况说明参数设计可能有问题先去检查调用方是否太随意。6. 调试复盘拿到报错信息后我是怎么一步步定位问题的6.1 三种最常见的函数类报错长什么样重做这套测验的过程中我故意把每道错误答案都跑了一遍目的就是让报错信息“长记性”。下面是函数题里最常见的三类报错报错类型典型触发原因典型排查方向NameError: name xxx is not defined函数名或变量名拼写错误、未导入模块检查拼写、检查 importTypeError: f() missing 1 required positional argument调用时少传了必填位置参数对照函数签名检查调用参数UnboundLocalError: local variable xxx referenced before assignment函数内先使用了后被赋值的同名变量检查是否有赋值语句意外把它变成局部变量考虑 global / nonlocal初学者遇到TypeError最容易慌因为报错信息很长还带一串路径。其实TypeError的错误消息开头就告诉了你原因比如TypeError: add() missing 1 required positional argument: b这句话已经直白地告诉你调用add时少传了一个名为b的位置参数。直接对着函数签名去数参数个数即可。6.2 traceback 阅读顺序从最后往前看有一次我调一个三层调用链时盯着 traceback 看了半天没头绪。后来养成了一个习惯绝不从头读永远从最后一行开始读。看这个例子def f1(): return f2() def f2(): x 1 / 0 return x f1()报错是这样的Traceback (most recent call last): File demo.py, line 11, in module f1() File demo.py, line 2, in f1 return f2() File demo.py, line 5, in f2 x 1 / 0 ZeroDivisionError: division by zero从下往上看最后一行是错误类型ZeroDivisionError和原因division by zero倒数第二行告诉你发生在f2函数的第 5 行再往上是f1的第 2 行说明f1调用了f2最上面是模块层说明整个调用起点。所以读 traceback 的顺序是先看最后一行“死于何处”再顺着往上“谁调用了它”从最顶上找到“最初的调用者”。如果错误消息本身已经够直白通常只需要看最后两行就够定位了。6.3 插桩调试与 pdb两种高效的定位方式遇到逻辑错误不报错但结果不对时需要更主动的手段。最直接的是插桩也就是在关键位置加print看中间值def calc(a, b): print(a , a, b , b) result a * b print(result , result) return result这个方法土但在小函数里非常高效。唯一要注意的是调试完记得删掉print或者用logging代替否则代码里到处是调试输出。如果问题比较复杂print来回插太麻烦可以直接用内置调试器。在函数里加一行def mysterious(a, b): breakpoint() ...运行到这一行时程序会进入交互式调试器你可以输入p a查看变量、输入n单步执行、输入s进入函数内部、输入c继续运行。不需要安装任何第三方库Python 3.7 之后内置了breakpoint()非常方便。6.4 用 doctest 把错题变成测试用例这是我自己复盘时最受用的一招。Python 的doctest允许你把函数调用的“预期输出”直接写在 docstring 里然后自动验证。比如把刚才那道add_item的修正版写成def add_item(item, itemsNone): 把 item 添加到列表中未提供列表时返回新列表。 add_item(1) [1] add_item(2) [2] add_item(3, [0]) [0, 3] if items is None: items [] items.append(item) return items if __name__ __main__: import doctest doctest.testmod()doctest会执行后面的代码然后和下一行的预期输出比对。如果输出不一致会明确告诉你哪里不一样。这个方法特别适合把测验错题改造成“可回归的测试用例”以后每次复习直接跑一遍所有 docstring 里的示例哪些知识点又搞混了立刻能暴露出来。等于把一次性的测验变成了长期有效的自检脚本。7. 从测验题到真实项目函数设计中的几个坏味道7.1 参数列表越来越长从位置参数到配置对象测验题里的函数大多只有两三个参数但真实项目里函数参数膨胀的速度远超想象。我最常见到的一个版本是这样的def create_user(name, age, email, phone, city, country, is_adminFalse, is_activeTrue, ...):十几个参数排在那里调用的时候一大串谁看谁头疼。这种“长参数列表”本身就是一个信号函数可能承担了太多职责或者参数之间有关联关系应该被组织成对象。一个更干净的做法是用数据类from dataclasses import dataclass dataclass class UserProfile: name: str age: int email: str phone: str city: str country: str def create_user(profile: UserProfile, is_admin: bool False, is_active: bool True): ...调用时profile UserProfile(name张三, age18, email..., phone..., city..., country...) create_user(profile)这样参数被组织成了“数据对象”函数签名从十几个参数变成“一个对象加少量选项参数”清晰程度直接上升一个档次。不过也要注意不要把一切参数都塞进对象里。我的判断标准是如果多个参数总是同时出现、同时变化、描述同一个实体就应该打包成对象如果只是几个互不相关的选项仍作为独立参数更直观。7.2 副作用与纯函数把可测试的边界画清楚函数维护成本高很多时候不是因为逻辑难而是因为它把“计算”和“操作外部环境”混在了一起。所谓纯函数就是满足两个条件的函数输入相同输出必然相同。不修改外部状态不执行 I/O 操作。纯函数有一个巨大的优势可预测、可测试。你不需要准备复杂的运行环境随便传什么参数都能跑直接对比返回值即可。反观不纯的函数比如在函数里直接改数据库、直接写文件、直接改全局变量测试起来就要费很大力气去 mock 外部依赖。所以我做项目时的做法是把业务逻辑尽量拆成纯函数把 I/O 操作尽量放到函数边界上。比如先写一个calculate_total_price(items)的纯函数再写一个save_order(order)专门负责持久化。前者不需要数据库也能单测后者只需要 mock 一个文件路径就能测。7.3 命名与类型提示三个月后还能读懂的“自己写的代码”测验题里的函数名都很短比如f、g、foo考试时可以这样写项目里不行。函数名本身应该是一个动词短语能描述“这个函数做了什么”。比如不好def data(x):好def parse_score(text: str) - int | None:看到parse_score你大概能猜出这个函数负责解析分数看到data你什么都不知道只能点进源码去看。在函数签名上类型注解的价值前面已经提过。这里再补充一点注解不只是给人类看的也是给工具看的。mypy和pyright会在运行前帮你发现“把str当int用”之类的低级错误。我自己写新函数时会强迫自己遵守一个简单流程先想清楚参数和返回值再写签名和 docstring最后写函数体。这样写出来的函数即使隔了几个月再回来看也能很快进入状态。7.4 一个实际案例坏味道函数 vs 整洁函数最后用一段对比来展示“从测验到实战”的差别。先看一个从真实代码里抽出来的“坏味道函数”def process(order, discount0, tax0.05): if discount 0 or discount 1: return invalid discount if tax 0 or tax 1: return invalid tax subtotal sum(item[price] * item[qty] for item in order[items]) discounted subtotal * (1 - discount) if discount else subtotal total discounted * (1 tax) return round(total, 2)这个函数的问题在于计算逻辑和校验逻辑混在一起返回值既有正常金额又有错误字符串类型不统一后续很难维护。拆开后的版本from dataclasses import dataclass dataclass class Order: items: list def calculate_subtotal(order: Order) - float: return sum(item[price] * item[qty] for item in order.items) def apply_discount(subtotal: float, discount: float) - float: return subtotal * (1 - discount) def apply_tax(subtotal_after_discount: float, tax: float) - float: return subtotal_after_discount * (1 tax) def calculate_total(order: Order, discount: float 0, tax: float 0.05) - float: subtotal calculate_subtotal(order) subtotal apply_discount(subtotal, discount) return round(apply_tax(subtotal, tax), 2)校验逻辑单独放一层计算逻辑拆成可以单独测试的纯函数每个函数职责单一、命名清楚。虽然代码行数变多了但维护成本显著降低。最后再分享一个我自己的习惯每次做完函数相关的小测验我都会把错题改造成一个独立脚本放进一个叫questions/的目录里然后用doctest把题目里的“预期输出”写成测试用例。下次再复习的时候直接跑一遍全套测试哪些知识点又生疏了一眼就能看出来。这个方法比反复翻答案有用得多。函数这个东西背语法手册没有用真正能检验理解程度的就是你面对一个“想不到的结果”时愿不愿意一层一层往底层挖。测验只不过是把这些“想不到的结果”集中摆到你面前而已。挖得越深后面的坑就越少。