
1. 从一行最常见的代码说起range 到底在干什么几乎每个刚接触 Python 的人都会在教程的某个角落看到这样一行代码for i in range(10): print(i)很多初学者盯着这行代码看了半天心里冒出的问题是range到底是干嘛的它和列表有什么区别为什么不能直接写for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]这背后到底是 Python 在偷懒还是有什么更深的讲究先说结论range是 Python 内置的一个“整数序列生成器”它的核心作用是在你需要一段连续整数的时候不用真的把这一大堆数字一个一个写在代码里。比如你要循环 100 次、要生成 0 到 99 的所有下标、要从 10 倒数到 1这些场景都是range的主场。为什么说它是“序列生成器”而不是“列表”这是整篇文章最关键的一个认知点。列表是实实在在把每个元素都存进内存的数据结构而range更像是“按需出数”的计数工具——它知道自己从哪开始、到哪结束、每一步跳多大但不会提前把整串数字都算出来摆在那儿。用一句可能不太严谨但非常好记的话来概括range就像一个只会报数的机器人你告诉它起点、终点和步长它就一个数一个数地往外蹦绝不会一口气给你倒出一麻袋数字。这篇文章适合谁看如果你是刚学 Python 的零基础新手看完之后能彻底搞懂range的三种用法和所有边界细节如果你已经写过一段时间 Python 但总在range(10, 0, -1)这种倒序写法上犯迷糊这篇文章也能帮你把这块补上。我会把参数、原理、实战场景和踩坑经历全部揉在一起讲争取让你看完就能直接用。1.1 拆解 for i in range(10) 这行代码先来逐词拆解for i in range(10)。for是 Python 的循环关键字表示“对于……中的每一个元素”i是循环变量每次循环会把当前拿到的值赋给它in表示“在……里面”range(10)则是你循环时要去遍历的那个对象。所以这行代码的完整语义是从range(10)生成的序列里从头到尾依次取出每一个整数每次取出的整数都放进变量i然后执行下面缩进的代码块。那range(10)到底生成了什么答案是 0 到 9共 10 个整数。注意没有 10。这个“从 0 开始、不包含结尾数字”的规则是 Python 里最经典也最容易让新手懵圈的设计之一。我见过太多人在range(10)里期待输出 1 到 10结果打印出来 0 到 9当场怀疑自己是不是写错了。这里有个非常有意思的点range(10)只传了一个参数代表的是“生成 10 个数字”而不是“从 0 数到 10”。你可以把它理解成“我要 10 个数从 0 开始挨个给”。所以list(range(10)) # 结果是 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]如果你想要 1 到 10得写range(1, 11)。这个“结束值要比目标值多 1”的规律后面我会在边界那一节详细讲这里你先记住一个口诀range(n)生成的是“0 到 n-1”。1.2 三种调用方式和参数含义range一共有三种调用方式区别在于你给它传几个参数调用方式参数含义生成的序列示例range(stop)只传结束值从 0 开始到 stop-1range(5)生成 0, 1, 2, 3, 4range(start, stop)传起始值和结束值从 start 到 stop-1range(2, 6)生成 2, 3, 4, 5range(start, stop, step)再传步长range(1, 10, 2)生成 1, 3, 5, 7, 9你可以把range的三个参数想成一个“区间计数器”start是起点stop是终点线但不越过它step是每一步跨多大。这里的“步长”不是时间意义上的速度而是“每次数字增加多少”。默认情况下步长是 1所以range(5)相当于range(0, 5, 1)。关键规则就一句话生成的序列从 start 开始按 step 的步长递增或递减但永远不会等于或超过 stop 这个边界。这套规则被称为“左闭右开区间”也就是数学里[start, stop)的写法。Python 里很多操作都是这个套路比如列表切片my_list[2:5]取的是下标 2 到 4同样不包含 5。为什么 Python 要采用左闭右开而不是两边都包含这里有三个非常实际的原因第一计算长度方便。如果步长为正且是 1range(start, stop)的元素个数就是stop - start一眼就能看出来。比如range(3, 8)有多少个元素8 减 3 等于 5马上就算出来了不用再在脑子里加一减一。第二和列表下标体系天然对齐。Python 的索引从 0 开始range(len(my_list))正好能生成 0 到len(my_list)-1的所有下标一个不多一个不少完美匹配列表的可访问下标范围。第三在循环里判断边界条件更自然。当你想让循环变量从某个值连续走到某个值写成for i in range(0, n)时循环体内判断i n永远成立语义非常干净。1.3 step 为负数时到底怎么走热搜词里有“range步长为负数”说明这是很多人卡住的地方。步长为负时range会从start开始往下递减每次减去步长的绝对值直到“跨过”stop为止。举个例子list(range(10, 0, -2)) # 结果是 [10, 8, 6, 4, 2]这里 start 是 10stop 是 0step 是 -2。生成的数字依次是 10、8、6、4、2到 2 之后如果继续减 2 会得到 0但 stop 是 0规则是“不能等于或越过 stop”所以 0 被排除在外序列到 2 就结束了。再看一个更经典的倒序需求想生成 10 到 1 的所有整数怎么写list(range(10, 0, -1)) # 结果是 [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]注意这里 stop 写的是 0 而不是 1因为要保证 1 被包含进来。很多人在这一步翻车他们会写range(10, 1, -1)然后发现结果里没有 1因为这个序列会在碰到 1 之前停下来。我自己早期学 Python 的时候就因为在 LeetCode 上做一道回文数相关的题目需要从数字末尾往前遍历结果边界写错导致漏掉了最前面的那个数字排查了半天才发现是range的 stop 边界问题。那如果 step 是负数但 start 比 stop 小呢比如range(1, 10, -1)这种情况下生成的序列是空的因为从 1 开始递减只会离 10 越来越远永远碰不到 10 这个边界所以 Python 会直接给你一个空序列。这不是报错但确实容易让新手困惑——记住range的step和start到stop的方向必须匹配否则结果为空。2. range 不是列表但又很像列表——理清这层关系就赢了一半讲完基本用法下一步必须搞清楚range和列表的关系。我见过很多初学者把range(5)和[0, 1, 2, 3, 4]当成完全等价的东西直到某天他们对range调用.append()方法被 Python 甩了一个AttributeError才如梦初醒。2.1 type 是 range不是 list直接看证据print(type(range(5))) # class range print(type([0, 1, 2, 3, 4])) # class listrange(5)的类型是range它和列表是两种完全不同的东西。但为什么很多入门教程里会直接用list(range(5))来演示因为range是一个“可迭代对象”你可以从一个range对象里一个接一个地取出元素但你不能像列表那样直接看到全部内容。print(range(5))会输出什么它不会输出[0, 1, 2, 3, 4]而是输出range(0, 5)。这恰恰说明range是一个“懒”对象——它只记录自己的起始值、结束值和步长这三个元数据至于里面有多少个元素、每个元素具体是多少它都是等你真正去遍历的时候才现场算出来的。不过range又确实提供了很多和列表相似的能力这让它用起来很像一个“假的列表”操作range 对象列表索引访问obj[2]支持range(5)[2]返回 2支持负数索引obj[-1]支持range(5)[-1]返回 4支持切片obj[1:3]支持返回新的 range支持返回新的列表长度len(obj)支持len(range(5))返回 5支持成员判断3 in obj支持3 in range(5)返回 True支持追加元素.append()不支持支持修改元素obj[0] 99不支持支持看到这个对比就明白了range能读、能查、能数数但你不能往里面塞新东西也不能改里面的值。它更像一个“只读的计数器”而不是一个“可以随便改动的容器”。2.2 range 的“内存优势”到底有多大既然range不是列表那为什么要用它而不用列表最大的理由是内存。试试看这两行代码big_range range(100000000) # 一亿个数 big_list list(range(100000000))range(100000000)创建出来之后不管这个范围有多大它在内存里占的空间几乎是不变的。为什么呢因为它只存三个值起始值 0、结束值 100000000、步长 1。无论是一百个数、一万个数还是一个亿个数对range来说都只是三个数字而已。而list(range(100000000))就完全不同了。Python 会真的生成一亿个整数对象然后把它们全部放进一个列表里。这一亿个整数对象加起来要占多少内存粗略估算Python 中一个整数对象至少占 28 个字节小整数可能会被缓存复用但大整数不会再加上列表本身需要存储指针数组一亿个元素轻轻松松就是几个 GB 的占用。你的电脑在运行这行代码的时候风扇可能直接起飞甚至内存直接爆掉。用个生活化的比喻range就像一个外卖骑手顾客点 100 份外卖他不用一次性把 100 份都扛在身上而是跑一趟送一份送完再回来取下一份list则像一个中央仓库必须提前把 100 份外卖全部堆在货架上等着随时被取走。前者省空间但需要一个一个取后者占空间但所有东西都是现成的。这也是为什么在绝大多数循环场景里根本不需要把range转成列表。直接for i in range(n)遍历一个一亿次循环Python 每次只生成一个整数用完就丢内存开销极小反过来如果你先建一个包含一亿个整数的列表再遍历光是列表的构建就已经让程序卡住了。2.3 什么时候才需要 list(range(...))那是不是说永远不要用list(range(...))也不是。存在即合理有些场景确实需要把它变成列表。第一种场景是你需要随机访问多个位置并且这个范围不大。比如说你要从 0 到 99 里随机抽 5 个不重复的数字用random.sample(range(100), 5)其实已经够用因为sample内部会处理可迭代对象。但如果你后续要频繁地对这组数字做多次索引、切片、甚至排序把这些数字真正放在列表里会更顺手。不过这种情况下直接把range转成列表也就无所谓了因为数字量小内存不会成为瓶颈。第二种场景是你需要修改序列本身。比如说你要生成一个从 0 到 9 的列表然后把偶数下标的元素全部加 100。这时候你不可能在range上直接改因为你根本没有一个可变的容器。正确做法是nums list(range(10)) for idx in range(0, len(nums), 2): nums[idx] 100 print(nums) # [100, 1, 102, 3, 104, 5, 106, 7, 108, 9]第三种场景是调试时为了看清楚range到底生成了什么。如果你不确定range(0.5)或range(5, 0, -2)的输出直接list()包一层打印出来一目了然。我在实际写代码时就经常用list(range(...))来做快速验证。除此之外绝大多数情况下你应该直接使用range本身不要为了“看起来更像数据”而多此一举地把它转成列表。这不仅省内存还更符合 Python 的惯用写法。2.4 顺带解答一下 Python 2 的 xrange 老故事如果你在网上搜过range的用法可能会碰到xrange这个单词。这里简单交代一下背景避免你在看老教程的时候一头雾水。在 Python 2 时代range(n)会真的生成一个包含 n 个整数的列表也就是说 Python 2 的range就是今天list(range(n))的效果而xrange(n)则返回一个“可迭代对象”行为类似今天 Python 3 的range不会一次性把所有数字都放进内存。所以在 Python 2 里处理超大范围循环时大家都推荐用xrange。后来 Python 3 发布时官方直接把range改造成了当年xrange的样子也就是说现在的range已经是一个“按需生成”的可迭代对象了xrange这个名字也就正式退出了历史舞台。所以在 Python 3 里你只需要认识range不用再纠结xrange。如果你在面试中被问到这个问题知道这段历史会是个加分项。3. 实际开发里 range 最常见的几个用法照着写就行搞清楚了理论接下来看实战。range在真实代码里绝不会只出现在入门教程的打印练习中它在循环、遍历、数据处理、算法实现里都是常客。我挑了五个最高频的使用场景每一个都附上可以直接抄的代码。3.1 计数型循环与延迟执行当你只关心“重复做 N 次某件事”而不关心当前是第几次的时候range就是最简单的工具。比如你要打印 5 条分隔线for _ in range(5): print(- * 50)注意这里的循环变量我写成了_。当循环变量在循环体内完全用不到时Python 惯例就是用下划线来占位这是一种“我虽然必须写个变量接收值但我根本不打算用它”的明确信号。再比如你写一个不断尝试连接数据库的服务最多重试 3 次for attempt in range(3): print(f这是第 {attempt 1} 次尝试) # 模拟连接... break为什么这种场景用range而不是while因为while需要你手动维护一个计数器变量还要自己写累加逻辑和边界判断代码一旦多了就容易出问题。而range把“从几开始、到几结束、每次加几”全部打包封口循环结束后变量也不会留下一个多余的值在外头虽然 Python 的for循环变量在循环结束后确实还存在于作用域里但至少循环控制逻辑是完整的。3.2 按索引遍历列表这是range最经典的用途之一拿到列表的长度用range生成所有下标然后通过下标去访问列表元素。fruits [苹果, 香蕉, 橙子, 葡萄] for i in range(len(fruits)): print(f第 {i} 个水果是 {fruits[i]})你会看到range(len(fruits))这种写法。len(fruits)返回 4range(4)生成 0、1、2、3正好是fruits的所有合法下标。但这里要提醒一句如果你只是想“遍历列表里的每个元素”并不需要下标直接用for fruit in fruits:更符合 Python 的风格。只有当你在循环体内确实需要用到下标 i 时才考虑range(len(...))这条路。Python 的“pythonic”之道是能用迭代器直接遍历就绝不绕圈子。那如果你既需要元素、又需要下标呢更优雅的写法是用enumeratefor i, fruit in enumerate(fruits): print(f第 {i} 个水果是 {fruit})很多人会问既然有enumerate那range(len(...))是不是就完全没用了不完全是这样。range(len(...))的优势在于它更贴近“下标优先”的思维模式在某些需要“根据下标去修改被遍历列表”的场景里更直接。例如把列表中所有小于 0 的数字改成 0nums [3, -1, 5, -2, 8] for i in range(len(nums)): if nums[i] 0: nums[i] 0 print(nums) # [3, 0, 5, 0, 8]这个例子如果用for num in nums遍历你会发现你在循环里修改的是num这个临时变量根本不会影响原列表只有通过下标去赋值才能真正改到列表内部的值。这是 Python 新手最容易犯的错误之一。3.3 分组、分页与均匀取点range最容易被忽视的用法是“带步长地跳过”。当你需要每隔固定步长取元素时range的第三个参数step就派上用场了。假设你有一个长度为 100 的列表你想每 10 个元素一组分别打印每一组data list(range(100)) batch_size 10 for start in range(0, len(data), batch_size): batch data[start:start batch_size] print(batch)这里的range(0, 100, 10)会生成 0、10、20、30、40、50、60、70、80、90正好是每个分组的起始下标。配合切片data[start:start10]完美实现分组逻辑。如果你想做分页功能这个模式直接复制过去改个变量名就行——start就是每页的偏移量batch_size就是每页大小。再比如你要在一个区间里均匀地取 5 个点用于画图for x in range(0, 101, 20): print(x) # 0, 20, 40, 60, 80, 100注意这里的 stop 写成 101 而不是 100因为要确保 100 能被取到。如果写range(0, 100, 20)生成的是 0、20、40、60、80最后一个点 100 就丢了。画图少了端点图形的边界就会偏这种细节在实际科研绘图和数据可视化里非常要命。3.4 列表推导与生成器表达式range和列表推导式、生成器表达式搭配起来能写出非常简洁的代码。列表推导式是 Python 里一种快速创建列表的语法它天然地以可迭代对象为基础而range恰好是最常用的基座。# 生成 1 到 10 的平方列表 squares [x ** 2 for x in range(1, 11)] print(squares) # [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] # 生成 0 到 20 之间的偶数 evens [x for x in range(0, 21, 2)] print(evens) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20] # 生成 1 到 50 之间所有能被 7 整除的数 multiples [x for x in range(1, 51) if x % 7 0] print(multiples) # [7, 14, 21, 28, 35, 42, 49]如果你不想一次性全部生成所有数字可以把方括号改成圆括号成为生成器表达式squares_gen (x ** 2 for x in range(100000000))这样得到的是一个生成器对象它保持和range一样的惰性什么时候需要值什么时候才去算内存占用极小。在数据量很大的场景里这种写法能够避免一次性的内存分配。3.5 算法里的“初始化工具”和排序辅助在很多算法和数据结构实现中range也被用作初始化序列的工具。例如动态规划里经常要建一张二维表最朴素的方式就是dp [[0] * (n 1) for _ in range(m 1)]这里的for _ in range(m 1)就是在重复 m1 次“创建一个长度为 n1 的全零列表”外层range在这里扮演的是“重复 N 次”的计数器角色。再比如你要给一个序列编号之后排序range可以配合sorted使用words [banana, apple, cherry] # 按字母顺序输出原下标 sorted_indices sorted(range(len(words)), keylambda i: words[i]) print(sorted_indices)这里range(len(words))生成了下标序列 0、1、2sorted按words[i]的字面量排序后得到[1, 0, 2]也就是apple、banana、cherry对应的原下标顺序。这种“给下标排序”的技巧在竞赛代码和数据处理里很常见本质上是把range当成了“所有候选下标”的集合。4. 学 range 时最容易踩的坑每个 Python 学习者都会在range上踩几个坑。下面这些是我亲眼见过、也自己踩过的经典问题按出现频率从高到低排列。4.1 边界差 1 魔咒最经典的坑range(1, 10)到底包括不包括 10答案是——不包括。range(1, 10)生成的是 1 到 9。这一条规则我已经在前面反复强调过但为什么大家总是忘我认为根本原因是人类的日常语言和编程语言的边界习惯不一样。我们平时说“从 1 到 10”默认是包含 10 的但 Python 的range遵循的是“左闭右开”天然排除 stop。怎么彻底解决这个问题我的经验是每次写range的时候先在脑子里用一条公式算一遍如果步长为正且是 1那么生成的数字总数 stop - start最后一个数字 stop - 1。比如range(5, 9)数字总数 9 - 5 4最后一个数字 9 - 1 8。这样算几遍之后就会形成肌肉记忆。4.2 step 为零会直接报错这是一个很容易翻车的细节。有人会想“我让step0是不是就永远停在一个数字上不前进”想法很天真但 Python 不这么认为。它直接抛异常range(0, 10, 0) # ValueError: range() arg 3 must not be zero报错信息说得很直白range的第三个参数不能为零。道理也简单步长为零意味着数字永远不变序列永远不会结束这会导致无限循环所以 Python 干脆在创建阶段就拦住。如果你真的需要一个“死循环”应该用while True:而不是琢磨怎么让range罢工。4.3 倒序时 stop 的边界算错想让range生成 5 到 1你可能会很自然地写range(5, 1, -1)但这个写法的结果是 5、4、3、2——没有 1。因为 stop 是 1序列走到 2 之后下一个就是 1正好等于 stop于是戛然而止。那 5 到 1 怎么写答案是range(5, 0, -1)stop 要比目标值再小 1。同理想生成 5 到 0要写range(5, -1, -1)因为 0 是目标值stop 必须比 0 还小也就是 -1。这里我提供一个小技巧能帮你彻底摆脱倒序边界混乱先不要直接写倒序的range而是写一个正序的range再用reversed包一层list(reversed(range(1, 6))) # [5, 4, 3, 2, 1]reversed(range(1, 6))会先把 1、2、3、4、5 按正序生成出来然后从后往前依次输出得到 5、4、3、2、1。这样你只需要思考正序的边界剩下来的交给reversed。注意一点reversed返回的是一个迭代器它同样很“懒”只有你遍历它的时候才会一个接一个地出数。如果你需要看全部内容用list()包一层即可。4.4 试图给 range 追加元素前面提到过range对象没有.append()方法。如果你忘记这一点代码会直接报错r range(5) r.append(6) # AttributeError: range object has no attribute append这个错误消息非常明确但很多新手看到之后的第一反应是“我是不是导入错了模块”不是的。根本原因是range是只读的、不可变的序列它的长度和内容在创建时就已经被三个参数锁死了。你不可能往里面加数字也不可能改掉已有的数字。如果你需要一个可以动态增长的整数序列正确做法是先转成列表r list(range(5)) r.append(6) print(r) # [0, 1, 2, 3, 4, 6]4.5 range 的切片返回的还是 range在 Python 3.3 之后range支持切片操作但这可能是最容易被忽略的一个特性。很多人默认切片就会得到列表于是在range上切了一刀得到的依然是个ranger range(10) r2 r[2:8] print(r2) # range(2, 8) print(type(r2)) # class range也就是说range(10)[2:8]不会给你[2, 3, 4, 5, 6, 7]而是一个新的range(2, 8)。它和原来的range一样依然是懒加载的。如果你需要得到列表还是要手动加工r_list list(range(10))[2:8] print(r_list) # [2, 3, 4, 5, 6, 7]或者这样print(list(range(10)[2:8])) # [2, 3, 4, 5, 6, 7]4.6 传了浮点数range的三个参数都必须是整数不能是浮点数。这是个硬性限制range(1.5) # TypeError: float object cannot be interpreted as an integer range(1, 10, 0.5) # TypeError: float object cannot be interpreted as an integer为什么range不允许浮点数步长因为浮点数在计算机里存在精度问题比如 0.1 在二进制里是一个无限循环小数用浮点数步长很容易累积出意外的误差导致序列长度不确定。Python 官方选择直接禁止从根上规避了这类问题。那如果你确实需要一个小数步长怎么办常见方案有三种一是用numpy.arange它支持浮点步长二是自己写一个while循环控制变量累加三是把步长放大成整数再缩小。我个人的习惯是如果只是画图取点直接用numpy.linspace更可靠因为它能保证取到指定数量的点不会因为浮点误差导致端点丢失。5. 最后分享一个我调试 range 时常用的小技巧写代码这么多年我现在每次不确定range到底生成什么的时候不会去背文档也不会去查资料而是直接在终端里敲一行list(range(起点, 终点, 步长))把它打印出来立刻真相大白。比如我写range(3, 15, 4)但不确定结果就输list(range(3, 15, 4))输出[3, 7, 11]一切清清楚楚。这比在脑子里空想边界规则可靠得多尤其在你写了一个复杂的倒序或者大跨度range的时候眼见为实胜过一切推断。还有一个我自己会用的判断技巧当你需要让某个整数落在特定区间里range的in判断特别有用。比如你要判断score是否在 60 到 100 之间包含两端可以写if score in range(60, 101): print(及格)这里range(60, 101)包含了 60 到 100 的所有整数score in range(60, 101)的语义一眼就能看懂而且由于range支持 O(1) 时间的成员判断它会直接做数学比较而不是逐个遍历这种写法在小范围内性能也没有问题。不过要注意这只适用于整数判断如果score是浮点数in range(...)会返回 False所以浮点区间判断还是老老实实用if 60 score 100:。range这个内置函数看起来简单但它牵涉到“可迭代对象”“惰性求值”“左闭右开区间”这些 Python 的核心概念。把这一个小东西彻底搞懂你对 Python 循环和数据模型的理解会上一个台阶。下次再看到for i in range(...)你就知道这一行代码背后并不只是一个简单的数字列表而是一个精巧的、按需生产的整数序列生成器。