说实话,学Python的第一道坎,往往不是语法有多难,而是你第一次意识到“代码可以帮人干重复的活”的那个瞬间。很多人卡在for循环和range语句上,不是因为看不懂,而是因为没人讲清楚“这个i到底是个什么东西”“range(5)为什么从0开始到4就结束了”。这篇文章专门写给正在从零开始学Python的朋友,把for循环和range从底层逻辑到实战技巧一次性讲透,也适合那些学了一半但总觉得循环这块“差点意思”的同学查漏补缺。我会把执行过程、内存变化、常见坑、嵌套场景都拆开揉碎了讲,保证你看完能直接上手写。
1. 先搞明白一件事:for循环到底在“循环”什么
1.1 没有循环之前,我们是怎么写重复代码的
想象一个场景:老师布置了一道题,让你把1到5的平方打印出来。没有学过循环的人,第一反应是老老实实写五行print:
print(1 * 1) print(2 * 2) print(3 * 3) print(4 * 4) print(5 * 5)写5个还好,如果让你打印1到100的平方呢?复制粘贴100行?代码还没写完,手先抽筋了。这时候最自然的想法是:能不能有一个东西,自动把一个数从1变到2、再从2变到3,每变一次我就算一次平方?
这就是循环存在的意义。循环的本质,是把“重复的动作”和“每次变化的数据”绑定在一起。而Python里最常用的循环就是for循环,它最擅长的事情就是“从一个序列里挨个取东西”。
1.2 for循环的标准写法,每个部分分别是什么
先看一个最简单的例子:
for i in [1, 2, 3, 4, 5]: print(i * i)这一小段代码,拆开来看就三个部分:
for:Python的关键字,告诉解释器“我要开始循环了”。i:循环变量。每次循环开始,Python会从后面的序列里取出一个元素,临时存到i里面。你可以给它起任何名字,i、x、item都行,但初学者最好固定用i,等熟悉了再换其他词。in [1, 2, 3, 4, 5]:in后面跟的是一个可迭代对象,这里是一个列表。循环会依次访问列表里的每一个元素。- 缩进的代码块:也就是第二行的
print(i * i)。这才是循环真正要重复执行的部分。
执行流程用大白话说就是:Python先看这个列表里有5个元素,于是决定循环5次。第一次循环,把1放到i里,执行缩进的print(1 * 1);第二次循环,把2放到i里,执行print(2 * 2)……直到列表里的元素被取完,循环自动结束。
这里有个新手最容易忽略的细节:列表里装的不一定是数字。你可以让for循环遍历字符串、元组、集合,甚至字典。比如:
for ch in "hello": print(ch)这段代码会依次输出h、e、l、l、o。字符串本质上就是一个字符序列,遍历它就是在遍历每个字符。
1.3 缩进为什么这么重要
Python不靠大括号{}来区分代码块,靠的是缩进。这个设计让代码看起来清爽,但也坑了不少新手。
for i in range(3): print(i) print("循环结束")注意看,第二个print没有缩进,它就不属于循环体。这段代码会输出:
0 1 2 循环结束而如果你写成这样:
for i in range(3): print(i) print("循环结束")输出就变成了:
0 循环结束 1 循环结束 2 循环结束因为print("循环结束")被缩进了,它就成了循环体的一部分,每循环一次都要执行一次。这一个小小的缩进差异,会让程序行为完全不同——这也是很多零基础同学对着报错或者“输出结果不对”挠头半天的原因。我的建议是:如果你的编辑器没有自动缩进功能,建议用4个空格作为缩进单位,不要用Tab键,Python 3中混用空格和Tab会直接报错TabError。
提示:观察缩进时,把代码块想象成一个“团队”。缩进越深,说明这个代码越“属于”上一层的团队。
2. range到底是什么:被人误解最深的Python内置函数
2.1 为什么range和for循环总是成对出现
在Python里,最经典的合作关系就是for和range。比如上一节我用for i in [1, 2, 3, 4, 5]来遍历,但如果我要遍历1到1000,不可能真的手写一个1000个元素的列表。这时候就需要range出场了。
for i in range(1000): print(i)range(1000)一眼看去像是一个函数,它会生成一个“从0开始,到999结束”的数字序列。很多人第一次看到输出结果时都会有同一个疑问:为什么不是从1到1000?为什么到999就停了?
这里的关键在于range的一个设计原则:左闭右开(区间包含左边,不包含右边)。也就是说,range(5)包含0、1、2、3、4这五个数,但不包含5。这个设计起初看着别扭,实际上有两个重要的好处:
- 与编程中“从0计数”的习惯一致,数组和列表的下标正是从0开始的。
- 计算个数很方便:
range(5)包含5个数,range(2, 10)就包含10 - 2 = 8个数。
理解了左闭右开,再看上面那个“为什么不是1到1000”的问题,答案就浮现了:因为你想让代码执行1000次,而Python从0开始计数,所以0到999正好是1000次。
2.2 range的三种调用方式与步长
range最常用的是这三种写法:
| 写法 | 含义 | 示例 | 结果 |
|---|---|---|---|
range(stop) | 从0开始,到stop-1结束 | range(4) | 0, 1, 2, 3 |
range(start, stop) | 从start开始,到stop-1结束 | range(2, 6) | 2, 3, 4, 5 |
range(start, stop, step) | 从start开始,每次增加step | range(1, 10, 2) | 1, 3, 5, 7, 9 |
第三种写法的step叫步长,也就是“每隔几个取一个”。它可以是负数,负数时表示倒着数:
for i in range(5, 0, -1): print(i)输出结果是5、4、3、2、1。注意,由于左闭右开,这里的stop是0,但0不会被输出,所以正好输出1到5的倒序。如果你想输出5、4、3、2、1、0,就得写range(5, -1, -1)。
这里分享一个我当年学的时候自己踩过的坑:步长为负数时,起始值必须大于结束值,否则循环体一次都不会执行。比如range(1, 5, -1),从1开始要减到5,永远不可能达到,所以循环直接跳过,不产生任何输出。这个行为不会报错,只会让你怀疑自己代码哪里写错了,排查起来还挺隐蔽的。
2.3 range对象和列表的区别:一个关于内存的故事
很多时候,你的Python老师会写一句list(range(5)),然后告诉你这是把range转成列表。但为什么要转?不转不行吗?
这里涉及到一个非常重要的概念:range返回的不是一个列表,而是一个“range对象”。它像一个“生成器”的亲戚——不会在内存里真的创建一长串数字,而是在每次循环的时候“现场算”出下一个数给我。
我打个比方:列表是一栋把所有房间都建好的楼,你要先花大量材料和空间,才能拥有这些房间;而range是一个“房产中介”,你说“给我一套5个房间的房”,他不会真的马上盖好,而是带你一间一间看,看完一个再带你看下一个。
用代码来感受一下区别:
import sys big_list = list(range(10000000)) # 一千万个元素的列表 big_range = range(10000000) # 一个range对象 print(sys.getsizeof(big_list)) # 列表占用的内存,单位是字节 print(sys.getsizeof(big_range)) # range对象占用的内存在我的电脑上,big_list占用了大约80MB的内存(不同电脑数值不一样但量级类似),而big_range只占48字节。差了百万倍。所以直接用range来循环,内存开销极小,这也是为什么for循环配合range能轻松遍历超大范围数字的原因。
不过要注意,range对象本身不能直接像列表一样按下标访问吗?其实可以,Python的range对象支持下标访问,比如range(10)[3]返回3,这也是它和普通迭代器的一个区别。但你不能对它做“切片后赋值”这类列表操作。
3. 循环里的常用搭档:enumerate、zip和列表推导式
3.1 enumerate:同时拿“下标”和“元素”的聪明办法
很多新手在写for循环时,会遇到一个尴尬场景:我想遍历一个列表,既需要知道当前元素的值,又需要知道它在列表中的位置(下标)。初学者的第一反应是:那我用下标来遍历吧。
fruits = ["apple", "banana", "cherry"] for i in range(len(fruits)): print(i, fruits[i])这当然能跑,但是啰嗦。Python提供了一个更优雅的工具:enumerate。
fruits = ["apple", "banana", "cherry"] for index, fruit in enumerate(fruits): print(index, fruit)注意这里的写法:for index, fruit in enumerate(fruits),enumerate会从列表里同时取出下标和元素,Python用“并行解包”的方式把它们分别赋给index和fruit。输出结果和上面的写法一样,但代码清晰了很多。
enumerate还有一个可选的起始参数:
for index, fruit in enumerate(fruits, start=1): print(index, fruit)这样输出的下标就从1开始,而不是0。这个功能在做“给用户显示第几条数据”的时候特别实用,相当于帮你省了一行index = index + 1。
3.2 zip:让多个列表在同一个循环里“并排走”
另一个常见需求是把两个列表的对应元素同时取出来。比如我有两个列表,一个是学生的名字,一个是它们对应的成绩,我想把它们配对打印。
names = ["小明", "小红", "小刚"] scores = [90, 85, 95] for name, score in zip(names, scores): print(name, score)这里的zip会把两个列表“像拉链一样”合并成一个个配对:第一个配第一,第二个配第二。循环时,name拿到名字,score拿到对应的成绩。
这里有个值得注意的行为:如果两个列表长度不一致,zip只会配对到较短列表结束为止,多出来的元素会被悄悄忽略。比如names有3个元素,scores只有2个,那么循环只执行2次,第三个名字“小刚”就不会输出。这句话看起来像废话,但很多人调试时遇到数据莫名其妙少了,根本没往zip长度上想。如果你希望以较长列表为准,可以用itertools.zip_longest,它会用fillvalue补齐缺失的那个元素,默认补None。
3.3 列表推导式:把for循环压缩成一行
学完循环,你马上会遇到一种看起来不像循环的循环——列表推导式。比如我想生成1到10的平方组成的列表。用常规写法是:
squares = [] for i in range(1, 11): squares.append(i * i)用列表推导式可以写成:
squares = [i * i for i in range(1, 11)]有没有觉得后者读起来就像“我要一个列表,里面是每个i的平方,i来自1到10”。它在功能上等价于前面那个四行的写法,但在可读性和性能上往往更好。
列表推导式还可以加if条件。比如我只要偶数平方:
even_squares = [i * i for i in range(1, 11) if i % 2 == 0]这就是"每个i的平方,i来自1到10,但要满足i是偶数"。方便是方便,但初学者不要太迷恋推导式——如果逻辑复杂,嵌套三个for再加两个if,写出来还不如老老实实写循环。我的经验是:列表推导式适合“单层循环+简单条件”的场景,一旦超过这个复杂度,别犹豫,换回普通循环。
4. 控制循环的节奏:break、continue与for-else
4.1 break:发现目标,立刻退出
有时候你不想让循环老老实实跑完所有的次数。比如在1到100中找第一个能被7整除的数。理论上你可以从头算到尾,但找到一个就够了吧?这时候你得用break:
for i in range(1, 101): if i % 7 == 0: print("找到了:", i) break循环执行到break这一行时,会立刻跳出整个循环,不管后面的数还有多少。这个行为有点像你在书里找一句话,找到之后就把书合上,不会继续往后翻。
break最常见的应用场景是“搜索”和“匹配”——找到目标就提前结束,既省时间,也让逻辑更清晰。不过要学会分辨什么时候该用break,什么时候不该用。比如你要统计1到100中所有能被7整除的数有多少个,那就不能break,你需要的全部数据都在后面呢。
4.2 continue:跳过这一轮,继续下一轮
continue和break名字很像,但行为完全不同。break是终止整个循环,而continue是跳过当前这一次迭代,继续执行下一次。
举一个简单例子:我想打印1到10中的偶数。可以这样写:
for i in range(1, 11): if i % 2 != 0: continue print(i)循环跑到i = 3时,if条件成立,于是执行continue——它告诉Python“别再往下执行了,赶紧进入下一次循环”。所以当i是奇数时,print(i)不会执行;只有偶数才能被打印出来。输出结果是2、4、6、8、10。
初学者容易在这里困惑:continue后面的代码还能不能执行?答案是:一旦执行了continue,本循环体内continue之后的所有代码都会被跳过。所以如果你把print(i)写在continue前面,那所有数都会被打印,就没意义了。写代码时,注意continue的位置和缩进。
4.3 for-else:循环“正常结束”别的语言都没有的彩蛋
这是Python里一个非常独特、但也最容易被忽略的语法:for...else...。很多人第一次看到它时一脸懵,else不是应该配if吗?怎么还能配for?
其实for-else的语义是:如果循环正常执行完(没有碰到break),就执行else分支。如果循环中途被break打断了,else分支就不执行。
最经典的用法是判断一个数是不是素数。很多人一开始这么写:
num = 17 is_prime = True for i in range(2, num): if num % i == 0: is_prime = False break print(is_prime)用for-else可以写得更加优雅:
num = 17 for i in range(2, num): if num % i == 0: print("不是素数") break else: print("是素数")逻辑非常清晰:如果2到16之间某个数能整除17,就说明不是素数,break退出;如果整个循环跑完都没有遇到能整除的数,说明17是素数,这时候走else分支打印“是素数”。
这个语法我第一次知道时觉得特别神奇,但也很实用。需要特别留神的是:else必须和for对齐,而不是和if对齐。如果代码看起来像上面示例但缩进错了,就会出现“每次循环都打印是素数”的诡异结果。
5. 零基础最容易踩的坑:差一错误、遍历时修改列表、变量污染
5.1 差一错误:为什么你总觉得循环次数少了一次或多了一次
我在教学和解答问题时,最常碰到的循环bug就是差一错误(off-by-one error)。这类错误通常和range的左闭右开特性有关。举几个典型例子:
range(100)循环100次,最后一次是99,不是100。- 想遍历列表的下标0到
len(lst)-1,正确写法是range(len(lst)),不是range(len(lst) - 1)。 - 想输出从5到1的递减序列,正确写法是
range(5, 0, -1),不是range(5, 1, -1)(后者会少输出1)。
最有效的调试方法是:在循环里临时打印出当前的下标或值,确认边界表现符合预期。比如:
lst = ["a", "b", "c", "d"] for i in range(len(lst)): print(i, lst[i])看到输出里的下标是0、1、2、3,你就能直观感受到“最后一个元素的下标是len-1”。这种“打印大法”虽然看起来土,但确实是排查循环问题的核心手段。
5.2 在遍历列表时删除元素:你以为的“跳过”其实是“错位”
这个坑坑了无数人,包括当年的我。需求很简单:有一个数字列表,我想把里面所有偶数删掉。于是写下了:
numbers = [1, 2, 3, 4, 5, 6] for num in numbers: if num % 2 == 0: numbers.remove(num) print(numbers)你期望的输出是[1, 3, 5],但实际输出很可能是[1, 3, 5]吗?不一定。某些情况下输出没问题,但换个列表就出问题了。试着跑一下numbers = [1, 2, 4, 3, 6],你能看到答案总是不对。
问题出在哪里?因为remove会改变列表的长度和元素位置,而for循环在内部是通过下标来迭代的。一边迭代一边删除,就像你一边数台阶一边拆台阶,数着数着就乱了。当循环指向第2个元素时,你删掉了它,下一个元素自动顶上来,循环继续向后走,就跳过了这个“顶上来”的元素。
推荐的做法有两种。第一种是遍历副本:
numbers = [1, 2, 4, 3, 6] for num in numbers[:]: if num % 2 == 0: numbers.remove(num)numbers[:]创建了列表的一个副本,遍历副本的同时修改原列表,就不会出现错位问题。
第二种是使用列表推导式直接生成新列表:
numbers = [1, 2, 4, 3, 6] numbers = [num for num in numbers if num % 2 != 0]这种方法更符合Python的风格,也几乎不可能出错。我的建议是:新手优先掌握列表推导式的写法,它比遍历+删除省心得多。
5.3 嵌套循环中的变量污染与命名冲突
再深入一点,说说嵌套循环。很多新手写九九乘法表时会这样写:
for i in range(1, 10): for i in range(1, 10): print(i, "*", i, "=", i * i)注意这两个循环都用了i作为循环变量。虽然Python允许这么做(内层循环会覆盖外层的i),但内层循环结束后,外层的i已经变成了内层最后的值,外层下一次迭代时i就无法更新到预期值了。
这是个隐藏极深的坑:代码不报错,但结果完全错误。解决办法很简单:外层用i,内层用j,更深的嵌套用k、l,或者用有业务含义的名字。嵌套越深,变量名越要有区分度。
我还想提一个稍微进阶的坑:在循环里定义函数或者lambda表达式时,它们会“记住”循环变量的引用,而不是值。比如:
funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 输出 2 2 2很多人预期输出0、1、2,结果输出全是2。原因就是lambda捕获的是i本身,循环结束后i的值停在2,所有函数访问到的都是同一个i。如果你确实需要保存当前值,可以用默认参数lambda i=i: i或使用functools.partial。这个坑属于进阶内容,但提前知道能省很多弯路。
6. 实战练习:九九乘法表和素数查找,两个必练的循环项目
6.1 用嵌套循环写出九九乘法表
九九乘法表是练手嵌套循环的经典项目。需求很简单:输出一个9行9列的乘法表,每个格子形如a * b = ab,且只输出a <= b的部分。
先给出一个最直观的写法:
for i in range(1, 10): for j in range(1, 10): if j <= i: print(f"{j} * {i} = {i * j}", end="\t") print()这段代码能正确输出吗?能。但有些新手不理解为什么要套一个if j <= i。其实换一种写法更贴近“只打印上三角”的思路:内层循环的范围可以由外层变量决定:
for i in range(1, 10): for j in range(1, i + 1): print(f"{j} * {i} = {i * j}", end="\t") print()内层循环从1遍历到i,自然就实现了“每一行只打印不超过当前行号的内容”。这里用到end="\t"让print不换行、用制表符分隔;而内层循环结束后,单独一个print()表示换行。
这是我个人特别推荐新手手敲一遍的项目,因为它同时用到了range的多种写法、嵌套循环、字符串格式化和print换行控制,可以说是“一个小项目练完一整章知识点”。
6.2 找出1到100内的所有素数并求和
这个需求在网络上被问到很多次:找出1到100以内所有素数,并计算它们的总和。
说到素数判断,自然要用到之前讲的for-else结构:
total = 0 for num in range(2, 101): for i in range(2, num): if num % i == 0: break else: total += num print(num, end=" ") print("\n总和:", total)逐行解释一下:
range(2, 101):从2开始循环到100。这里从2开始是因为1既不是素数也不是合数,没必要参与。- 内层
range(2, num):遍历从2到num-1的所有整数,检查num能否被它们整除。 - 如果找到一个能整除的数,
break跳出内层循环,此时for-else的else不会执行。 - 如果内层循环完整跑完都没找到能整除的数,说明
num是素数,执行else分支,累加总数并打印。
这个程序里有一个小优化点:判断素数时,内层循环的右边界其实不需要到num - 1,到int(num ** 0.5) + 1就足够了,因为如果一个数n是合数,它必定有一个不大于√n的因子。对于求1到100这种小范围,写不写优化都不影响运行速度,但在大范围场景下差别巨大。
6.3 我的实操心得:从死记硬背到画出循环执行过程
最后说说我自己当年学循环的一个小方法。初学for时,我总是搞不清“循环到哪一步了”“为什么这里是99不是100”,后来我养成了一个习惯:在每个循环的第一行加一个print输出当前循环变量的值。比如:
for i in range(10): print(f"当前i的值是:{i}")虽然这会让程序多输出很多调试信息,但它能让你直观地“看见”循环的执行过程。当你亲眼看到i从0到9一个个蹦出来,左闭右开这个概念就不再是死记硬背的规则,而是你亲身体验过的运行规律。等代码写熟之后,再把调试用的print删掉就行。
还有一个建议是:不要怕写嵌套循环,但每层嵌套都要当场验证。我会先只写外层循环,print(i)看外层对不对;再写内层,print(i, j)看双层组合对不对。这种“一层一层验证”的思路,比一次性写完三四个嵌套再调试要省力得多,也能帮你在出现问题时快速定位到底是哪一层出了岔子。
从最简单的for i in range(10),到enumerate、zip、列表推导式,再到break、continue、for-else,说实话,Python的循环并不比别的语言复杂,但它的表达力很强、细节也很多。这篇文章没有太多需要背的东西,真正重要的是你打开编辑器,亲手把每个例子敲一遍,把输出和你预想的对照一下。跑通了再来回想一遍我提到的那些坑,你会发现自己对for循环和range的理解,已经不知不觉超过很多人了。