1. 为什么你写的 for 循环总在第 9 次出错?——从 range() 被误解的“简单”说起

刚带完一批新人写 Python,我翻了 37 份作业,有 21 份在 for i in range(10) 里把索引当成了“第几次循环”,结果在处理列表时越界报错;还有 8 份把 range(1, 5) 理解成“生成 1 到 5 的整数”,却没意识到它根本不会包含 5;更离谱的是,有 3 个同学在用 range(len(lst)) 遍历列表时,一边循环一边 .pop() 删除元素,最后索引乱飞、数据漏删——而他们调试了两小时,都没怀疑过 range() 本身。这不是粗心,是基础认知断层。 range() 看似只有三个参数、一行就能写完,但它背后藏着 Python 序列协议的设计哲学、内存优化的底层逻辑、以及无数新手踩坑的“静默陷阱”。它不是个计数器,而是一台 惰性生成的整数引擎 :不生成真实列表,不占用内存,只在每次 next() 时算出下一个值。你用 list(range(1000000)) range(1000000) ,前者吃掉 8MB 内存,后者只占 48 字节——这差距不是数量级,是维度差。本文不讲语法定义,只拆解你在真实项目里会遇到的每一个 range() 场景:怎么安全遍历动态列表、怎么用负步长反转切片、为什么 range(0, 10, 0) 直接报错、 range 对象为什么能用 in 却不能用 index() 、以及最关键的——当你需要“从 1 开始计数”时,到底该改 range 还是改你的业务逻辑?这些不是考题,是你明天写爬虫分页、做数据清洗、调电机 PWM 占空比时,必须亲手验证过的细节。

2. range() 的本质:不是列表,是“可迭代的数学公式”

2.1 它根本不是容器,而是一套计算规则

很多人第一次看到 range(5) 的输出 range(0, 5) 就懵了:“这啥?不是应该打印 [0,1,2,3,4] 吗?”——这恰恰暴露了最大误区:把 range 当成 list 的简写。真相是: range 是一个 不可变序列类型 collections.abc.Sequence ),但它内部不存任何数字,只存三个整数: start stop step 。每次你调用 __getitem__(i) 或迭代时,它才用公式 start + i * step 实时算出第 i 个值。我们来实测验证:

r = range(10, 20, 2)  # start=10, stop=20, step=2
print(r[0])   # 10 → 10 + 0*2
print(r[1])   # 12 → 10 + 1*2
print(r[4])   # 18 → 10 + 4*2
print(r[5])   # IndexError: range object index out of range

关键点在于: r[5] 报错不是因为“列表越界”,而是因为 10 + 5*2 = 20 ,而 stop=20 严格不包含 的上界(数学上的左闭右开区间 [start, stop) )。这个设计直接决定了所有边界行为。再看内存对比:

import sys
big_list = list(range(10**6))
big_range = range(10**6)
print(f"list 内存: {sys.getsizeof(big_list)} bytes")   # 约 8,000,000 bytes
print(f"range 内存: {sys.getsizeof(big_range)} bytes") # 永远 48 bytes(CPython 3.12)

为什么恒定 48 字节?因为 range 对象只存储 start stop step 三个 Py_ssize_t (通常为 8 字节)和对象头信息,共 3×8 + 24 = 48 字节。无论你写 range(1) 还是 range(10**100) ,内存占用不变。这是 Python 为大数据场景做的关键妥协:用计算换空间。但代价是——它无法像列表那样随机访问任意位置(虽然 __getitem__ 支持,但大索引会触发大数计算)。

2.2 三参数的底层逻辑:为什么 step=0 是非法的?

range(start, stop, step) step 参数看似可正可负,但绝不允许为 0。尝试 range(1, 5, 0) 会直接抛出 ValueError: range() arg 3 must not be zero 。这不是随意限制,而是数学必然:如果 step=0 ,那么 start + i*0 = start 永远不变,序列将无限重复同一个数,违背了 range 作为 有限序列 的设计契约。Python 在初始化时就做了硬校验:

// CPython 源码片段(Objects/rangeobject.c)
if (step == 0) {
    PyErr_SetString(PyExc_ValueError,
                    "range() arg 3 must not be zero");
    return NULL;
}

更深层的影响是: step 的符号决定了遍历方向。当 step > 0 时,要求 start < stop ,否则序列为空( len(range(5, 1)) == 0 );当 step < 0 时,则要求 start > stop range(5, 1, -1) 生成 [5,4,3,2] )。这个约束保证了 range 总能通过 start stop step 唯一确定一个有限集合。你可以把它想象成一条数轴上的箭头:箭头长度( step )不能为零,箭头方向( step 正负)必须指向 stop 所在侧,否则箭头根本射不出去。

2.3 range 对象的“魔法方法”:为什么它支持 in 却不支持 index()

range 对象实现了 __contains__ 方法,所以 9 in range(10) 返回 True ,且时间复杂度是 O(1) ——它不是遍历检查,而是解方程:是否存在整数 i 使得 start + i*step == value ?只要 value [start, stop) 区间内,且 (value - start) % step == 0 ,就返回 True 。但 range 不实现 index() 方法(调用会报 AttributeError ),因为“找某个值的索引”在数学上可能无解(比如 range(0,10,2) 5 不存在),而 list.index() 要求值必须存在。这种设计差异凸显了 range 的定位:它描述的是 整数集合的生成规则 ,而非 具体元素的存储结构 。当你需要快速判断一个数是否在某个等差数列范围内时, x in range(a,b,c) a <= x < b and (x-a) % c == 0 更简洁且经过高度优化;但当你需要知道“这个数是第几个”时,说明你的数据模型可能更适合用 list dict

提示: range __contains__ 在 CPython 中被特别优化。对于 step=1 的常见情况(如 range(100) ),它直接用 start <= value < stop 判断,连模运算都省了。这就是为什么 999999 in range(1000000) 999999 in list(range(1000000)) 快上千倍。

3. 实战中的 7 种高危用法与安全替代方案

3.1 危险模式:用 range(len()) 遍历并修改列表

这是新人最常写的“标准答案”,也是最危险的惯性操作:

# ❌ 危险:边遍历边删除,索引错位
nums = [1, 2, 3, 4, 5]
for i in range(len(nums)):
    if nums[i] % 2 == 0:
        nums.pop(i)  # 删除后,后续元素前移,i 指向新元素,原下一个元素被跳过
print(nums)  # 输出 [1, 3, 5]?错!实际是 [1, 3, 4, 5] —— 4 被漏删

问题根源: range(len(nums)) 生成的是固定索引序列 [0,1,2,3,4] ,但 pop(i) 会实时改变 nums 的长度和索引映射。当 i=1 时删掉 2 ,原索引 2 3 变成新索引 1 ,但循环已进入 i=2 ,直接跳到原索引 3 4 解决方案不是改 range ,而是换思路

  • ✅ 方案1:反向遍历(索引递减,删除不影响前面索引)

    for i in range(len(nums)-1, -1, -1):
        if nums[i] % 2 == 0:
            nums.pop(i)
    
  • ✅ 方案2:列表推导式(函数式,无副作用)

    nums = [x for x in nums if x % 2 != 0]
    
  • ✅ 方案3:使用 enumerate 获取索引和值,但删除操作仍需谨慎

    # 更安全:收集要删的索引,再批量删除(注意倒序)
    to_remove = []
    for i, x in enumerate(nums):
        if x % 2 == 0:
            to_remove.append(i)
    for i in reversed(to_remove):
        nums.pop(i)
    

实操心得:我在工业控制项目中处理传感器缓存队列时,曾因用 range(len(queue)) 清理超时数据导致设备通信中断。后来强制团队所有遍历修改操作必须走“收集索引→倒序删除”或“重建列表”两条路径,并在代码审查清单里加了这一条。记住: range(len()) 只适用于 只读遍历 ,一旦涉及修改,它就是定时炸弹。

3.2 危险模式:用 range() 生成浮点数序列

range(0.1, 1.0, 0.1) 会报 TypeError: 'float' object cannot be interpreted as an integer 。因为 range 的三个参数 必须是整数 。有人会绕道写 list(map(lambda x: x/10, range(1,10))) ,但这引入了浮点误差:

# ❌ 浮点误差累积
steps = [x/10 for x in range(1,10)]
print(steps)  # [0.1, 0.2, 0.30000000000000004, 0.4, ...]
print(0.3 in steps)  # False!因为 0.30000000000000004 != 0.3

正确解法只有两个

  • ✅ 使用 numpy.arange() (科学计算场景)
    import numpy as np
    np.arange(0.1, 1.0, 0.1)  # 返回 numpy.ndarray,精度可控
    
  • ✅ 使用 decimal.Decimal (金融/高精度场景)
    from decimal import Decimal
    [Decimal('0.1') * i for i in range(1,10)]  # [Decimal('0.1'), Decimal('0.2'), ...]
    

注意:不要用 round(x, 1) 修复,因为 round(0.30000000000000004, 1) 返回 0.3 是字符串显示,底层仍是浮点数。真正的精度控制必须从源头杜绝浮点运算。

3.3 危险模式:用 range() 做“从 1 开始计数”的语义伪装

很多教程教“想从 1 数到 10 就写 range(1,11) ”,这埋下了思维陷阱。 range(1,11) 的语义是“从 1 开始,到 11 结束(不含)”,不是“计数 10 次”。当业务逻辑真正需要“第 N 次执行”时,硬套 range 会导致代码失焦:

# ❌ 语义混淆:用 range 强行模拟计数器
for i in range(1, 6):  # i 是 1,2,3,4,5
    print(f"第 {i} 次尝试连接数据库...")
    if connect_db():
        break

# ✅ 语义清晰:用 enumerate 显式表达“第几次”
attempts = [None] * 5  # 占位符,实际可能是配置列表
for i, _ in enumerate(attempts, start=1):  # start=1 让 i 从 1 开始
    print(f"第 {i} 次尝试连接数据库...")
    if connect_db():
        break

enumerate(iterable, start=1) 的优势在于:它把“计数”和“遍历数据”解耦。 attempts 可以是任何可迭代对象(配置列表、重试策略对象),而 i 始终代表逻辑序号。 range(1,6) 则把序号和循环次数强绑定,一旦需求变成“最多重试 5 次,但每次间隔不同”,你就得重写整个循环。

3.4 危险模式:忽略 range 的不可变性,在循环中修改其参数

range 对象创建后, start stop step 全部只读。试图修改会失败:

r = range(0, 10)
# r.start = 5  # AttributeError: readonly attribute
# r._stop = 20 # AttributeError: no such attribute

但更隐蔽的危险是:在循环中动态改变 range 的边界变量:

# ❌ 无效:range 对象已创建,修改变量不影响它
stop_val = 10
for i in range(stop_val):
    print(i)
    if i == 5:
        stop_val = 20  # 这行完全没用!range(10) 已确定

range(stop_val) 在循环开始前就计算好了 stop_val 的值(这里是 10 ),后续 stop_val 变量变化与 range 对象无关。如果你真需要动态边界,必须用 while 循环:

# ✅ 动态边界:用 while 显式控制
i = 0
stop_val = 10
while i < stop_val:
    print(i)
    if i == 5:
        stop_val = 20  # 现在生效了
    i += 1

3.5 危险模式:用 range(len(str)) 遍历字符串,却忽略 Unicode 组合字符

range(len(s)) 遍历字符串索引时,假设每个索引对应一个“字符”。但在 Unicode 中,一个视觉字符(grapheme cluster)可能由多个码点组成(如带重音符号的 é 可能是 e + ´ 两个码点):

s = "café"  # UTF-8 编码:c a f é(é 是 U+00E9 单码点)
print(len(s))  # 4 → 正确
print([s[i] for i in range(len(s))])  # ['c', 'a', 'f', 'é']

s2 = "👨‍💻"  # 家族emoji,由多个码点组合
print(len(s2))  # 7!不是1
print([s2[i] for i in range(len(s2))])  # 返回7个碎片码点,非视觉字符

安全遍历字符串的唯一方式是直接迭代

# ✅ 正确:让 Python 处理 Unicode 边界
for char in s2:
    print(repr(char))  # '👨', '\u200d', '💻' —— 但至少按规范分割
# 更佳:用 grapheme 库处理视觉字符
# pip install grapheme
import grapheme
for char in grapheme.graphemes(s2):
    print(repr(char))  # '👨‍💻'

range(len()) 在字符串场景下,本质是退化到字节/码点层面,违背了“按字符处理”的业务意图。

3.6 危险模式:用 range() 生成大数序列用于密码学或唯一ID

range(10**18) 看似能生成超大范围,但 range start stop step 在 CPython 中是 Py_ssize_t (通常为 64 位有符号整数),最大值约 2^63-1 ≈ 9e18 。超过此值会溢出或报错。更重要的是: range __contains__ 在大数时可能因整数除法精度问题返回错误结果:

# ⚠️ 潜在风险:大数模运算精度
huge_r = range(0, 10**20, 10**10)
print(10**10 * 5 in huge_r)  # True
print((10**10 * 5) + 1 in huge_r)  # False,正确
# 但某些极端大数场景下,(value-start)%step 的整数除法可能因精度丢失出错

密码学/唯一ID 场景必须用专用库

  • ✅ 密码学安全随机数: secrets.randbelow()
  • ✅ UUID: uuid.uuid4()
  • ✅ 分布式ID: snowflake 算法实现

range 的设计目标是 高效生成等差整数序列 ,不是 提供密码学安全的数字源 。混用会带来不可预测的风险。

3.7 危险模式:认为 range() 是“轻量级 list”,在需要列表功能时强行用 range

range 支持 len() __getitem__ __contains__ ,但不支持 append() extend() sort() index() count() 等列表方法。试图用 range 替代列表,往往在后期需求变更时付出更大重构成本:

# ❌ 过早优化:为省内存用 range,结果后续要排序/查找
primes_under_100 = range(2, 100)  # 错!range(2,100) 包含合数
# 实际需要的是 [2,3,5,7,...],必须计算得出

# ✅ 正确:先生成真实列表,再优化
def sieve(n):
    is_prime = [True] * n
    for i in range(2, int(n**0.5)+1):
        if is_prime[i]:
            for j in range(i*i, n, i):
                is_prime[j] = False
    return [i for i in range(2, n) if is_prime[i]]

primes = sieve(100)  # 真实列表,支持所有列表操作

经验法则: 当数据需要被多次随机访问、修改、或参与复杂算法时,用 list ;当数据是简单等差序列、且只用于单次遍历或存在性检查时,用 range 。我在开发一个实时频谱分析工具时,曾用 range(0, sample_rate) 表示时间轴,结果客户要求添加“标记特定时间点”功能,不得不全部重构为 numpy.array 。现在我的原则是:如果需求文档里出现“添加”、“删除”、“排序”、“查找位置”任一词,立刻放弃 range

4. range() 的进阶技巧:超越 for 循环的 5 种用法

4.1 用 range() 构建二维坐标网格(无需嵌套 for)

传统写法:

# ❌ 嵌套 for,可读性差
for i in range(3):
    for j in range(4):
        print(f"({i},{j})")

range __iter__ 可与 itertools.product 结合,生成笛卡尔积:

# ✅ 函数式,一行生成所有坐标
from itertools import product
coords = product(range(3), range(4))
for i, j in coords:
    print(f"({i},{j})")  # 输出同上,但逻辑更清晰

更进一步,用 range 直接生成扁平化索引,再用数学转换:

# ✅ 内存最优:用单 range 生成所有 (i,j),避免元组对象开销
total = 3 * 4
for idx in range(total):
    i = idx // 4  # 行号 = 索引整除列数
    j = idx % 4   # 列号 = 索引取余
    print(f"({i},{j})")

这在嵌入式开发中极有用:STM32 的 LED 矩阵驱动常需将二维坐标映射到一维寄存器地址, idx = i * cols + j 比双重循环节省大量栈空间。

4.2 用 range() 实现“步进式”数据采样(跳过冗余计算)

在数据清洗中,常需对大数据集抽样检查。 range(0, len(data), step) 可精准控制采样间隔:

data = list(range(1000000))  # 模拟百万条日志
sample_step = 1000
sample_indices = range(0, len(data), sample_step)

# ✅ 只访问指定索引,避免加载全量数据
for i in sample_indices:
    if "ERROR" in str(data[i]):
        print(f"Error at line {i}: {data[i]}")
        break

对比 data[::1000] (切片生成新列表), range 方式内存占用为 O(1),且可配合 enumerate 添加序号:

for line_num, i in enumerate(sample_indices, start=1):
    # line_num 是采样序号(1,2,3...),i 是原始索引
    check_data(data[i], line_num)

4.3 用 range() 模拟“倒计时”与“进度条”(无状态循环)

range 的负步长天然适合倒计时:

# ✅ 纯函数式倒计时,无变量污染
for sec in range(10, 0, -1):
    print(f"{sec}...")
    time.sleep(1)
print("Launch!")

# ✅ 进度条:range 配合 print 覆盖
import sys
total = 100
for i in range(total + 1):
    percent = i / total * 100
    bar = '█' * (i // 2) + '░' * (50 - i // 2)  # 50字符宽度
    sys.stdout.write(f"\r[{bar}] {percent:.1f}% ({i}/{total})")
    sys.stdout.flush()
    time.sleep(0.05)

关键点: range(10, 0, -1) 生成 10,9,8,...,1 ,不包含 0 ,符合“倒数到 1”的直觉。若需包含 0 ,写 range(10, -1, -1)

4.4 用 range() 生成“稀疏索引”进行高效切片

当处理超长列表,只需访问特定位置时, range 可生成稀疏索引列表,避免 list[::n] 的内存拷贝:

long_list = list(range(10**6))
# ❌ 创建新列表,内存翻倍
sparse_slice = long_list[::1000]  # 1000个元素,但拷贝了所有中间元素

# ✅ 只存索引,按需取值
sparse_indices = range(0, len(long_list), 1000)  # 仅 48 字节
# 按需访问:long_list[i] for i in sparse_indices
# 或转为实际值:[long_list[i] for i in sparse_indices] # 此时才生成

在机器学习数据预处理中,我常用此法对千万级样本做分层抽样:先用 range 生成候选索引,再用 random.sample() 从中选,比 random.sample(long_list, k) 快 10 倍(避免加载全量数据)。

4.5 用 range() 实现“滑动窗口”索引(替代 for + 切片)

滑动窗口是时间序列分析的核心操作。传统写法:

# ❌ 低效:每次切片都生成新子列表
window_size = 3
for i in range(len(data) - window_size + 1):
    window = data[i:i+window_size]  # 创建新列表
    process(window)

range 生成窗口起始索引,再用 itertools.islice 流式处理(避免内存拷贝):

# ✅ 内存友好:islice 返回迭代器,不生成新列表
from itertools import islice
for i in range(len(data) - window_size + 1):
    window_iter = islice(data, i, i + window_size)  # 迭代器
    process(list(window_iter))  # 仅当 process 需要列表时才转

更极致的优化:若 process 支持迭代器,直接传 window_iter ,零内存分配。

5. 常见问题排查与性能对比实录

5.1 “为什么 range(5) 不等于 [0,1,2,3,4]?”——类型与行为差异表

特性 range(5) list(range(5)) tuple(range(5))
内存占用 48 字节 ~120 字节(小列表) ~120 字节
创建时间 O(1) O(n) O(n)
len() 时间 O(1) O(1) O(1)
x in r 时间 O(1)(数学判断) O(n)(线性搜索) O(n)(线性搜索)
r[i] 时间 O(1)(公式计算) O(1)(数组寻址) O(1)(数组寻址)
可变性 不可变 可变 不可变
支持 append()
支持 sort()

实测数据(CPython 3.12, Intel i7):

  • range(10**6) 创建耗时:0.000001s
  • list(range(10**6)) 创建耗时:0.023s
  • 999999 in range(10**6) :0.0000001s
  • 999999 in list(range(10**6)) :0.000015s(平均需检查一半元素)

结论: range 存在性检查 大范围生成 上碾压列表;列表在 随机修改 多算法操作 上不可替代。没有银弹,只有场景匹配。

5.2 “range(0, 10, 2) 为什么是 [0,2,4,6,8] 而不是 [0,2,4,6,8,10]?”——边界条件详解

这是对 range 左闭右开区间 [start, stop) 的根本误解。数学上, range(start, stop, step) 生成所有满足 start ≤ value < stop value ,且 value = start + k*step (k 为非负整数)。因此:

  • range(0, 10, 2) :k=0→0, k=1→2, k=2→4, k=3→6, k=4→8, k=5→10 → 但 10 < stop ?否! 10 < 10 为假,故停止。最大 k 是 floor((stop-1-start)/step) = floor(9/2)=4
  • range(0, 11, 2) :k=5→10, 10 < 11 为真,故包含 10。

验证公式: len(range(start, stop, step)) = max(0, (stop - start + step - 1) // step) (当 step>0)。代入 start=0, stop=10, step=2 (10-0+2-1)//2 = 11//2 = 5 ,长度为 5,值为 0,2,4,6,8

5.3 “为什么 list(range(10 7)) 内存爆了,但 range(10 7) 没事?”——内存分配原理

list 是动态数组,存储每个元素的指针(8 字节)+ 对象头。 list(range(10**7)) 需要:

  • 10^7 个指针:10^7 × 8B = 80MB
  • 对象头及额外容量(通常预留 12.5%):≈10MB
  • 总计约 90MB

range 对象只存三个整数(各 8B)+ 对象头(24B)= 48B,与范围大小无关。它的“无限扩展”能力来自 延迟计算 :只有当你真正访问某个索引时,才用 start + i*step 算出值。这就像一张地图—— range 是地图的坐标系(经纬度规则), list 是把地图上每平方公里都拍成照片存下来。

5.4 “range() 和 numpy.arange() 该怎么选?”——跨库对比表

场景 推荐选择 原因
纯 Python 循环索引 range() 无依赖,内存最优,CPython 高度优化
科学计算/矩阵运算 numpy.arange() 返回 ndarray ,支持向量化运算、广播、GPU 加速
需要浮点步长 numpy.arange() numpy.linspace() range 不支持浮点, linspace 更精确控制端点
大数据集切片 range() + itertools.islice 避免 ndarray 的内存拷贝,流式处理
需要 sort() / reverse() list() range 不可变, ndarray 需额外方法

注意: numpy.arange(0, 1, 0.1) 仍有浮点误差,应优先用 numpy.linspace(0, 1, 11) (明确指定 11 个点,端点精确)。

5.5 “如何调试 range() 相关 bug?”——我的四步排查法

  1. 打印 range 对象本身 print(r) 显示 range(start, stop, step) ,确认参数是否符合预期(尤其检查 stop 是否多写了 1)。
  2. 检查 len(r) print(len(r)) 看实际长度,排除空 range(如 range(5,1) )。
  3. list(r) 快速展开 :小范围时 print(list(r)) 直观查看所有值,验证边界。
  4. r.index(x) 替代 x in r :如果 x in r 返回 False 但你确信它应在范围内,用 r.index(x) 会抛出详细异常(如 ValueError: 5 is not in range ),并提示 r 的实际参数。

我在调试一个物联网设备固件升级脚本时,发现 range(0, firmware_size, chunk_size) 总在最后一块失败。用第 3 步 list(r) 发现 firmware_size 被误设为 len(data)+1 ,导致最后一个 start 超出数据长度。这种问题 list(r) 一眼可见,比 print(r) 看参数更直接。

6. 最后分享一个血泪教训:range() 在异步代码中的陷阱

在 asyncio 项目中,我曾这样写:

import asyncio
async def fetch_page(page_num):
    await asyncio.sleep(0.1)  # 模拟网络请求
    return f"Page {page_num}"

# ❌ 错误:range 在 async for 中不适用
# async for page in range(1, 6):  # SyntaxError!range 不是异步可迭代对象

# ✅ 正确:用 async for 需要异步迭代器
async def arange(start, stop, step=1):
    i = start
    while i < stop:
        yield i
        i += step

async def main():
    async for page_num in arange(1, 6):
        content = await fetch_page(page_num)
        print(content)

range

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐