先问一个扎心的问题:代码写了不少,循环也算会用,但一遇到嵌套循环就开始拿草稿纸画表,盯着 i 和 j 的变化不敢放手?再不然,到了要定义函数的时候,所有代码全挤在一起,参数怎么传、返回值怎么写,全凭感觉?
这不是你笨。双重循环和函数这两块内容,真正考验的其实不是语法记忆,而是把复杂问题拆成层次结构的思维能力。我陪练过不少从零开始的学员,几乎每个人都会在这两章卡一阵子,但一旦把执行顺序和作用域这两条线理顺,后面看算法、读源码都会顺畅很多。这篇内容围绕第六章“双重循环与函数详解”展开,这里用 Python 来写示例,概念换到别的语言同样适用。咱们把五个关键关卡过一遍:先拆双重循环的执行机制,再用经典案例练手,接着处理循环中断的边界问题,然后彻底搞懂函数的参数与作用域,最后看看循环和函数配合时容易踩的隐藏陷阱。
1. 双重循环的执行逻辑拆解:外层走一步,内层跑一圈
1.1 双重循环到底是怎么转的
双重循环,说穿了就是循环里面再套一个循环。很多人初学时的困惑在于:两个循环到底谁先谁后?其实规则特别简单——外层循环每执行一次,内层循环就要完整地跑完一整轮。
我用最直白的例子演示一下:
for i in range(3): for j in range(3): print(f"i={i}, j={j}")输出结果是:
i=0, j=0 i=0, j=1 i=0, j=2 i=1, j=0 i=1, j=1 i=1, j=2 i=2, j=0 i=2, j=1 i=2, j=2注意看:i=0 的时候,j 从 0 跑到 2,把内层整轮跑完了,外层才轮到 i=1。这就叫“外层走一步,内层跑一圈”。生活里最贴切的类比是时钟:时针是外层循环,分针是内层循环,分针转满六十格,时针才向前挪一格。
这个执行顺序是所有嵌套循环的基础,后面的九九乘法表、矩阵遍历、冒泡排序,全都建立在这个规律上。我刚开始学的时候也觉得抽象,后来养成一个习惯:遇到嵌套循环先在草稿纸上按行模拟一遍,把变量变化列成一张表,慢慢就能在脑子里“跑”代码了。
1.2 用矩阵和表格场景理解内外层的角色
理解了执行顺序,再来看一个更贴近实际的应用场景:遍历二维数据。矩阵可以理解成一张表格,我们要逐一读取里面的所有元素,最自然的方式就是双重循环:外层循环遍历每一行,内层循环遍历当前行的每个元素。
matrix = [ [1, 2, 3], [4, 5, 6], [7, 8, 9] ] for row in matrix: for item in row: print(item, end=" ") print()运行结果就是矩阵原本的样子:
1 2 3 4 5 6 7 8 9这个案例里,外层 row 变量依次是 [1,2,3]、[4,5,6]、[7,8,9] 三行;内层把当前行的三个元素依次取出来打印。end=" "让元素之间用空格隔开,外层的print()负责在每行结束后换行。
我在实际教学中发现,很多学员在这里会问:能不能把内外层反过来写?比如先遍历列再遍历行?可以,但结果就会变成逐列输出:
for j in range(3): # 外层:列 for i in range(3): # 内层:行 print(matrix[i][j], end=" ") print()输出:
1 4 7 2 5 8 3 6 9同一个矩阵,内外层循环的顺序变了,输出的方向完全反转。这就是理解双重循环执行顺序最重要的意义:你不仅要知道怎么写,还要能预测它会输出什么。这种“换角色”的思路,恰恰就是接下来要讲的矩阵转置的雏形。
2. 经典嵌套循环实战:从九九乘法表到冒泡排序
2.1 九九乘法表:下三角、上三角随便改
九九乘法表是练双重循环的经典题目,因为它逼着你思考内外层循环的边界关系。基础版写法:
for i in range(1, 10): for j in range(1, i + 1): print(f"{j}x{i}={i*j}", end="\t") print()运行结果从1x1=1开始,到9x9=81结束,是一个左下三角的乘法表。第 2 行是关键:内层循环的range(1, i + 1)意味着当外层 i=5 时,内层 j 只能从 1 取到 5,而不是取到 9。每一行的列数取决于当前行号,行号越大列越多,自然形成三角形。
如果你想打印右上三角,只需把内层 range 改成从 i 开始到 10:
for i in range(1, 10): for j in range(i, 10): print(f"{i}x{j}={i*j}", end="\t") print()这里想提醒一个新手常犯的错误:很多人以为内层范围写不写 i 都行,直接写range(1, 10)就完事。那样输出的不是三角形,而是完整的长方形,每一行都有九列。代码不报错,结果却完全不对,这种逻辑错误比语法错误更难排查。
2.2 矩阵转置与冒泡排序:同样的循环,不同的处理思路
接下来看两个更实际的算法。
矩阵转置,简单说是把矩阵的行和列互换。前面我们用双重循环按列打印矩阵内容,那就是转置的核心思路。写标准逻辑如下:
matrix = [ [1, 2, 3], [4, 5, 6] ] rows = len(matrix) cols = len(matrix[0]) transposed = [] for j in range(cols): new_row = [] for i in range(rows): new_row.append(matrix[i][j]) transposed.append(new_row) print(transposed) # [[1, 4], [2, 5], [3, 6]]外层循环遍历列下标 j,内层循环遍历行下标 i,把每一列的元素收集起来作为转置后的一行。原矩阵是 2 行 3 列,转置后变成 3 行 2 列。“内外层交换角色”这个思路,比死记代码重要得多,以后处理表格数据、图像像素坐标都会用到。
再说冒泡排序。它也是经典的双重循环,但和矩阵遍历不一样:内外层处理的是同一个数组,内层负责两两比较相邻元素,外层控制重复比较的轮数。
def bubble_sort(arr): n = len(arr) for i in range(n): for j in range(n - 1 - i): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] return arr nums = [64, 34, 25, 12, 22, 11, 90] print(bubble_sort(nums))关键点在内层上界n - 1 - i。每经过一轮外层循环,就有一个最大元素“冒泡”到数组末尾,这个位置已经确定,下一轮不需要再碰它。所以随着 i 增大,内层比较范围越来越小。这个例子很好地体现了嵌套循环里“循环边界不是拍脑袋定的,而是由算法状态推导出来的”这一思想。
我经常提醒学员:做嵌套循环相关的题目,不要急着写代码,先把边界条件在纸上算清楚。边界定了,循环体怎么写反而好办。
3. 循环中断与跳转:break、continue、else 以及退出多层循环的方案
3.1 break 和 continue 的真实生效范围
循环体里的 break 和 continue 只会作用于它所在的那一层循环,这是嵌套循环中最容易出问题的点。
for i in range(3): for j in range(3): if j == 1: break print(f"i={i}, j={j}")这段代码输出什么?很多初学者以为 break 会把内外两层都停掉,其实不会。它只跳出内层循环,外层完全不受影响:
i=0, j=0 i=1, j=0 i=2, j=0每组数据里,j 打印到 0 就被 break 截住,但 i 照常从 0 走到 2。break 只作用于“离它最近的那一层循环”。continue 同理,它只是跳过当前这层循环的剩余语句、进入下一次迭代,嵌套时同样只作用于本层。
这个特性本身不难,难的是多层循环里容易产生“我以为它全跳了”的错觉。我在排查别人代码时见过不少这样的问题:明明想找到一个结果就停止整个搜索,结果 break 写在内层,外层还在继续跑,白白浪费时间,甚至导致结果错误。
3.2 三种退出多层循环的写法对比
既然 break 只跳一层,要真正退出嵌套循环,常见有三种做法。
第一种是标志变量。在外层循环外面定义一个标志位,内层命中条件后置位,内层 break 之后再判断标志,决定是否 break 外层:
found = False for i in range(10): for j in range(10): if i * j == 42: found = True break if found: break print(i, j) # 6 7逻辑直白,适合刚掌握嵌套循环的读者,缺点是多了一行判断,嵌套深了以后看着累赘。
第二种是把循环逻辑封装成函数,用 return 直接返回结果,这是我最推荐的做法:
def find_target(): for i in range(10): for j in range(10): if i * j == 42: return i, j return None result = find_target() print(result) # (6, 7)return 一执行,整个函数立刻结束,不管你在多少层循环里面。这个写法让意图非常清楚:双层循环就是一个“搜索过程”,找到即返回。后面讲函数的时候还会再强调,用 return 提前退出是控制复杂流程的利器。
第三种做法相对冷门,利用 Python 的 for-else 机制。for 循环完整跑完且没有被 break 打断,else 块就会执行;被 break 了,else 块不执行:
for i in range(5): for j in range(5): if i * j == 12: break else: continue # 内层没有找到,继续外层 break # 内层找到了,外层也停逻辑是:内层找到就 break,跳过 else 的 continue 来到外层 break;内层没找到就走 else 里的 continue,外层进入下一轮。这种写法用好了非常优雅,但对新手不太友好。建议先掌握前两种,第三种看得懂就行。
3.3 循环 else 的另一个经典场景
循环 else 在“检查是否全部满足条件”的场景特别好用。比如判断一个数是不是素数:
def is_prime(n): if n < 2: return False for i in range(2, int(n ** 0.5) + 1): if n % i == 0: break else: return True return False for num in range(2, 20): if is_prime(num): print(num, end=" ")当循环完整跑完都没有找到因子,进入 else 返回 True;一旦中途 break,说明找到了因子,不会进入 else,直接返回 False。这个写法省去了标志变量,语句更简洁,前提是记得“else 是在循环没有被 break 的情况下才执行”这个语义。
提示:无论你选择哪种退出多层循环的方式,最重要的是先想清楚“退出之后程序应该处于什么状态”。新手经常只顾着跳出循环,却忘了把结果保存下来,导致跳是跳出来了,数据却没拿到手。
4. 函数从上手到进阶:参数传递、作用域规则与返回值设计
4.1 函数包装的标准:什么时候该把代码提成函数
双重循环帮我们处理了“数据逐层遍历”的问题,可一旦逻辑复杂起来,六十行代码堆在同一个函数里,读起来就会非常痛苦。这时候就该谈函数——把一段逻辑包起来、起个名字、需要的时候反复调用。
很多新手会在两个极端之间摇摆:要么所有代码堆在顶层,主流程写了几百行;要么疯狂定义函数,每个函数只包三行,调用关系绕来绕去。我自己的判断标准很简单:一段代码要在多个地方使用,或者它负责一个独立任务(读取配置、计算金额、格式化日期),就该提取成函数。提取之后,主流程会变成“读配置 -> 算金额 -> 格式化输出”的清晰链条,读代码的人一眼就能看出程序在干什么。
定义一个基础函数并不难:
def calculate_total(price, quantity): total = price * quantity return total真正容易出问题的是参数传递。Python 默认按位置传参,调用时按顺序一一对应。参数一旦多起来,位置传参的可读性就很差。我在实际项目里建议多用关键字参数调用:
calculate_total(price=29.9, quantity=3)这样哪怕参数顺序打乱,也不会传错。定义函数时,默认参数要放在所有位置参数后面,否则直接语法报错:
def calculate_total(price, quantity, discount=0): total = price * quantity if discount: total *= (1 - discount) return total print(calculate_total(29.9, 3, discount=0.2))discount 有默认值 0,调用时可以不传,传了就覆盖默认值。这个机制很常用,但有一个必须记住的坑:默认参数最好只用不可变对象,比如数字、字符串、None,不要用列表或字典当默认值。
很多新手在这里踩过坑:
def add_item(item, items=[]): items.append(item) return items print(add_item("苹果")) # ['苹果'] print(add_item("香蕉")) # ['苹果', '香蕉'],而不是 ['香蕉']原因在于默认值[]在函数定义时就已创建,每次调用不传入新列表,用的都是同一个列表对象。第一次的调用结果被第二次继承,工程里很容易引发诡异 bug。解决办法是默认值设 None,函数内部再创建:
def add_item(item, items=None): if items is None: items = [] items.append(item) return items4.2 参数形式的完整清单:从位置参数到 *args 和 **kwargs
实际开发中,参数个数有时候事先不确定,例如计算多个数的总和、拼接不定长的条件。这时可以用*args接收任意多个位置参数,用**kwargs接收任意多个关键字参数。
def average(*args): if not args: return 0 return sum(args) / len(args) print(average(1, 2, 3)) # 2.0 print(average(1, 2, 3, 4)) # 2.5def config_logger(**kwargs): level = kwargs.get("level", "INFO") path = kwargs.get("path", "./log.txt") print(f"级别: {level}, 路径: {path}") config_logger(level="DEBUG", path="/tmp/app.log")args会打包成一个元组,kwargs会打包成一个字典。它们在包装函数、装饰器等场景中非常有用,可以把收到的参数原样转发给内部函数。我把常用参数形式整理成一张表:
| 参数形式 | 写法示例 | 适用场景 |
|---|---|---|
| 位置参数 | def f(a, b) | 参数顺序天然固定 |
| 默认参数 | def f(a, b=0) | 可选配置项 |
| 关键字参数调用 | f(a=1, b=2) | 参数多、提升可读性 |
| 可变位置参数 | def f(*args) | 参数个数不确定 |
| 可变关键字参数 | def f(**kwargs) | 配置项动态变化 |
4.3 作用域规则:LEGB 和 global 的正确用法
函数内访问变量的顺序有一条规则,缩写叫 LEGB,依次是局部作用域(Local)、嵌套函数外层作用域(Enclosing)、全局作用域(Global)、内置作用域(Built-in)。Python 找到第一个匹配的变量就停止查找。
count = 10 def demo(): count = 20 print(count) demo() # 20 print(count) # 10函数内部的count = 20创建了一个新的局部变量,和全局的 count 互不影响。这就是局部与全局隔离的机制。
但如果想在函数内部修改全局变量,直接赋值是不行的:
count = 10 def update(): count = count + 1 # 报错:局部变量 count 在赋值前被引用 update()因为函数内部一旦出现count =这种赋值语句,Python 就会认为 count 是局部变量,于是前面读取 count 时它还不存在,直接抛出异常。这时才需要 global 声明:
count = 10 def update(): global count count = count + 1 update() print(count) # 11有一点要额外强调:全局变量能让代码短一点,但工程上过度使用 global 会让函数之间隐形耦合,很难定位是哪个函数改动了状态。我更推荐的方式是尽量通过参数传进去、通过返回值接出来,让每个函数成为一个自包含的逻辑块。写测试的时候也舒服,因为同样的输入必然得到同样的输出。
4.4 返回值:return 和 None 的边界设计
每个函数都会返回一个值,没有写 return 的函数默认返回 None。很多新手排查 bug 时发现某个函数打印是对的,但把结果赋给变量后却是 None,就是没写 return 导致的。
def say_hello(): print("你好") result = say_hello() print(result) # Noneprint只是输出内容,没有 return,函数结束时默认返回 None。如果你希望函数“产出”结果,一定记得写 return。
另一种常见错误是只在某个分支里 return:
def classify(num): if num > 0: return "正数" # 漏掉了 num <= 0 的情况,此时返回 None print(classify(-5)) # None正确的做法是函数内每个可能的路径都要有明确返回值,或至少补一个默认 return。这和搜索函数“找不到就返回 None”是同一个道理:return 的边界条件要设计清楚,养成习惯后很多隐蔽 bug 都能提前避免。
5. 循环 + 函数的隐藏陷阱:闭包延迟绑定、推导式优化与调试思路
5.1 循环里定义函数,为什么结果永远是最后一个值
这是把循环和函数放在一起讲时最值得注意的坑,我在实际项目中遇到过不止一次。
funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f())直觉上,第一次调用应该输出 0,第二次输出 1,第三次输出 2。但实际运行结果是:
2 2 2原因在于:lambda 函数体内捕获的不是 i 的值,而是 i 这个变量本身。循环结束后 i 的最终值是 2,所以三个函数再去读取 i,读到的都是 2。这叫闭包变量的延迟绑定。
解决办法很多,最简单的就是给 lambda 设置默认参数,把当前 i 的值“冻结”进去:
funcs = [] for i in range(3): funcs.append(lambda i=i: i) for f in funcs: print(f()) # 0 1 2这里的i=i意思是:第一个 i 是 lambda 的参数名,第二个 i 是循环里当前的 i 值。默认参数在函数定义时就会求值,于是每个函数保存的是自己那一轮的 i 快照,而不是共享同一个变量。同类问题在图形界面按钮回调、异步任务、多线程代码里非常常见。只要在循环体里定义函数,就要想清楚“这个函数之后被调用时,我到底希望它读哪个时刻的哪个变量”。
5.2 双重循环的推导式写法:代码更短,但别短到看不懂
列表推导式可以把双重循环压缩成一行,例如生成所有坐标对:
pairs = [(x, y) for x in range(3) for y in range(3)] print(pairs)它等价于:
pairs = [] for x in range(3): for y in range(3): pairs.append((x, y))执行顺序和普通嵌套循环完全一致:外层循环在前,内层循环在后,所以 x 变化慢、y 变化快。还可以加条件过滤,比如只保留 x 小于 y 的:
pairs = [(x, y) for x in range(4) for y in range(4) if x < y]比写三重嵌套加 append 可读性强不少。但有一个度的问题:推导式里的嵌套层数不建议超过两层。三层以上信息密度太高,别人读起来费劲。实际工程中遇到三层以上的循环逻辑,还是老实用普通 for 循环加函数提取比较稳。
5.3 把循环体抽成函数:复杂逻辑的调试捷径
写完嵌套循环发现输出不对,最快的调试方式不是对着屏幕干瞪眼,而是把内层循环体抽出来变成函数。循环体一旦独立成函数,就可以单独用不同输入值测试,很快定位到底是循环边界的问题还是循环体内部逻辑的问题。
def process_cell(matrix, i, j): # 这里原本是一大段处理逻辑 return matrix[i][j] * 2 for i in range(len(matrix)): for j in range(len(matrix[0])): matrix[i][j] = process_cell(matrix, i, j)抽出来后,你可以直接调用process_cell(matrix, 1, 2)针对某个位置验证处理逻辑,不用为了测试一个点而跑完整套循环。这也是我带新手时最常给的一条建议:循环体一旦超过 5 行,就考虑提取函数。提取之后内层循环变得简短清晰,所有细节都在函数内部,既方便测试,也便于复用。
5.4 性能小提醒:别在循环里反复做重复的事情
最后聊一个性能习惯,虽然初学阶段不太在意,但养成良好习惯会受益很久。
双重循环的时间复杂度是 O(n²),如果内层循环里再做耗资源的操作,比如反复打开文件、反复查数据库、反复拼接大字符串,整体开销会被放大得非常夸张。有些计算不依赖循环变量,应该提到循环之前:
# 不推荐 for i in range(n): for j in range(m): result = expensive_function() print(result) # 更合理 cached = expensive_function() for i in range(n): for j in range(m): print(cached)数据量小的时候无所谓,可一旦 n、m 变成几千几万,差距就是指数级的。写代码时多想一步“哪些东西每轮都是一样、可以提前算好”,后面遇到真正的大数据任务时不会被性能问题卡住。
带学员到这一章时,我常说一句话:双重循环帮你看懂数据在空间里的排布,函数帮你把排布切成模块。两样加起来,代码就开始有结构感了。
最后分享一个我自己的小习惯:遇到任何复杂的嵌套循环,先在纸上把循环变量从第一步到最后一步的变化写一遍,哪怕只写一遍,很多边界问题都会提前暴露。代码交给电脑很快,但思路是要自己先想通的。这一章学完,建议把每一段示例都亲手敲一遍,改一改边界条件,观察输出的变化——动手试过的东西,才真正算你的。