
前几天有个学Python的朋友在群里发了一个报错IndexError: list index out of range他对着屏幕看了半小时最后发现是range(start, stop)的参数顺序搞反了把stop填成了起始值。这不是段子这是很多刚入门的人的真实状态。range()、pass语句、运算符这三样东西几乎出现在每一段入门代码里但恰恰因为它们太基础很少有人认真讲清它们的边界和坑。这篇文章不是语法手册的复读而是从我实际带人和写脚本的经验出发把这几个点拆开揉碎讲清楚每个细节背后的原因以及我踩过的那些坑。适合学Python两三个月、一直在追教程但总被小bug卡住的入门者也适合已经写过一段时间代码、想回头把基础补牢的人。看完之后你会发现很多让你加班到深夜的Bug根子上都是这三样东西没吃透。1. 先弄明白range、pass、运算符到底各自负责什么1.1 三个语法点为什么总纠缠在一起很多教程会把range()、pass、运算符放在“入门语法”的同一章里但不会解释它们为什么会凑到一起。我的理解是它们共同构成了循环、判断、函数体这三块最基本的代码骨架。range()负责生成一个可迭代的整数序列最常见的用法是配合for循环控制循环次数或者作为列表下标。pass负责占位它本身没有任何执行效果但在Python的语法结构中某些地方必须有一条语句占住位置否则就报IndentationError。运算符则负责把数据组合起来做计算从加减乘除到逻辑判断、赋值、索引访问全是运算符的事。这三者经常同时出现在同一段代码里。比如一个最简单的遍历lst [a, b, c] for i in range(len(lst)): if lst[i] b: pass else: print(lst[i] * (i 1))这里range生成了下标序列if/else用了比较运算符和乘号pass占住了空的if分支。看不懂这段代码的人往往并不是不懂单个语法而是不理解三者在代码里各自扮演的角色。所以我把它们放在一起讲因为它们本来就是一起工作的。1.2 一个比喻数据流、占位符和加工器我经常跟新人打一个比方range()像是一条传送带按规则吐出编号运算符像加工设备对经过的数据做各种变换pass像是一个写着“暂时不装设备”的工位你先把流水线画好之后随时可以回来补上具体逻辑。写代码最怕的是脑子里没有画面。你写for i in range(10)脑子里应该出现“一条传送带连续吐出0到9这10个数字”的画面而不是“这是一个函数调用”。你写x a b * 2应该先看到乘法先执行再执行加法最后把结果赋给x。你写except Exception: pass应该意识到这里有一块空地你的程序从异常中穿过去当什么都没发生。有了这个画面许多问题其实根本不需要记。例如为什么range(5)不包含5因为传送带吐出的是“从0开始逐个加1直到达到5之前”的数字。开区间设计是编程里非常常见的约定切片lst[0:5]也是取前5个元素。把它看成“左闭右开”的区间比死记“结束值不包含”要容易得多。2. range()的七个隐藏细节参数顺序、惰性、切片与内存2.1 三参数模型与默认行为range()的完整形态是range(start, stop, step)但三个参数都有默认值和省略规则。start省略时默认从0开始step省略时默认为1stop是唯一必须指定的参数。这个“唯一必须指定”本身就是一个容易踩的坑新手经常写range(0, 10, 2)没问题但一省略参数就乱了。print(list(range(5))) # [0, 1, 2, 3, 4] print(list(range(1, 5))) # [1, 2, 3, 4] print(list(range(1, 10, 2))) # [1, 3, 5, 7, 9]第二个容易被忽略的地方是step的值正负影响方向。step2会跳过奇数step-1则从start向下递减直到小于等于stop为止。print(list(range(5, 0, -1))) # [5, 4, 3, 2, 1] print(list(range(0, 5, -1))) # []因为方向不对结果是空注意step0会直接抛ValueError: range() arg 3 must not be zero。这个报错信息很直白但我见过不少人把0当默认值填进去以为步长为0表示“步长为1”。这里的核心是步长本质是每次加上的数0加多少次都不前进所以必须禁止。我在实际教学中最喜欢让新手做一个小练习用range生成100以内所有能被3整除的数。答案可以是list(range(0, 100, 3))也可以是自己用条件判断筛出来。两种写法的对比能让他们瞬间理解step并不是一个“只看数字大小”的参数而是参与了真实计算。2.2 range对象不是列表是“按需出数”的序列Python 2里的range()返回的是列表Python 3里返回的是range对象。这一点变化非常重要但很多教程只是一笔带过。它带来的直观区别是打印range(10)不会看到[0, 1, 2, ...]只会看到range(0, 10)。为什么要改为了省内存。如果你需要遍历1亿个数字Python 2会在内存里建一个包含1亿个整数的列表而Python 3的range(100000000)只保存start0、stop100000000、step1这三个值然后边遍历边计算下一个数。这就像办了一张健身房年卡办卡时并不需要把整栋楼的器材都搬回家你每次去的时候按需使用就行了。但这并不意味着range对象什么操作都不支持。它属于序列类型所以它支持len()、支持下标索引、支持成员判断、支持切片r range(10) print(len(r)) # 10 print(r[3]) # 3 print(3 in r) # True print(r[2:5]) # range(2, 5)注意切片返回的仍然是range对象不是list。如果你需要真正的列表用list()显式转换。很多新手直接用r[2:5]去和其他列表做加法结果报TypeError: unsupported operand type(s) for 到处找原因其实就是没转成列表。2.3 range能重复使用迭代器不能range对象是可以反复迭代的这一点和生成器、迭代器有本质区别。迭代器像一根一次性吸管吸完一口就没了range像一盘磁带任何时候重新播放都能从头再来。r range(3) print(list(r)) # [0, 1, 2] print(list(r)) # [0, 1, 2]没事可以再来一次如果换成iter(range(3))得到的迭代器第二次遍历就不会有输出了。这个细节在写代码时非常实用。比如你需要在函数里对同一个range做两次统计千万不要把参数预先转换成迭代器直接用range对象传进去就好。我还见过有人把range的结果转成list存起来仅仅是为了“以防万一要遍历两次”。绝大多数情况下这是多余的。只有当你需要对序列做修改比如替换某个位置的元素才需要转成真正的列表因为range对象是不可变序列不支持元素赋值。r range(3) r[0] 9 # TypeError: range object does not support item assignment记住一个判断标准只读遍历用range需要修改数据用list(range(...))。2.4 用range遍历列表时最容易翻车的地方range(len(lst))是Python里非常经典的一种遍历写法但它带来一个隐患如果你在循环里直接修改列表的长度索引就会越界。最典型的就是边遍历边删除元素的反例lst [1, 2, 3, 4, 5] for i in range(len(lst)): if lst[i] % 2 0: lst.pop(i)这段代码执行到一半就会报IndexError: list index out of range因为lst.pop(i)让列表长度变短了但range(len(lst))在循环开始前早就把长度计算好了。这个坑我见过不下十次网上相关的提问贴更是堆成山。正确的做法有几种。第一种是用列表推导式生成新列表不修改原列表lst [1, 2, 3, 4, 5] lst [x for x in lst if x % 2 ! 0]第二种是反向遍历从后往前删除这样即使删除当前元素也不会影响前面还没访问到的索引lst [1, 2, 3, 4, 5] for i in range(len(lst) - 1, -1, -1): if lst[i] % 2 0: lst.pop(i)这个例子同时用到了range的负步长、列表索引和取余运算符。如果你能自己把这段代码写对、讲明白说明这几个基础点真正串起来了。3. pass语句的真实身份占位、兜底与设计意图3.1 语法上必须有一条语句但逻辑上你还没写Python用缩进表示代码块而代码块不能为空。你写一个函数的函数体如果不写任何内容直接按回车再缩进下一行解释器会报IndentationError: expected an indented block。def parse_config(): # 这里空着会报错 passpass就是用来解决这个问题的它是一条不执行任何操作的语句专门用来占位。它甚至不是函数没有返回值运行起来什么都不会发生。从语义上讲pass传递了一个重要信息“这里我故意留空之后会补上。”它相当于你在代码里画了一个圈告诉自己和别人这个位置我先占住不然后面的骨架搭不起来。3.2 适合用pass的三种常见场景第一种是定义空类。有些场景需要一个“标记类”或者“类型标识”暂时不需要任何方法和属性class UserProfile: pass第二种是还没想好实现逻辑的函数。你在写一个大程序时先把接口定义出来函数体用pass占位。这样整个项目的模块结构可以先跑通具体逻辑再一个个补def load_config(path): pass def save_result(data): pass第三种是异常处理里暂时不处理某个分支。比如你明确知道某个NotImplementedError不是致命问题先用pass兜住后面再补日志或恢复逻辑try: parse_special_data() except NotImplementedError: pass这里要注意pass和函数的return None不是一回事。一个没有显式返回值的函数本来就会返回None但函数体仍然不能为空所以即使你不关心函数返回值也需要在函数体里写pass或者写一个文档字符串。3.3 滥用pass的后果以及替代方案except Exception: pass是新手最爱写但也最容易埋雷的写法。它把所有异常都吞掉了程序表面上继续跑实际上错误早就发生了只是没人知道。try: result external_request() except Exception: pass # 异常被吞后续用result时才发现变量没定义这种代码在我见过的真实事故里出现过好几次。服务一直“正常运行”但核心功能早就坏了因为异常被pass吞得一干二净。如果确实想暂时忽略异常至少应该用logging记录一下import logging try: result external_request() except Exception: logging.exception(request failed, will continue)另一个替代方案是raise NotImplementedError。当你写一个抽象接口、明确要求子类必须实现时用raise NotImplementedError比pass更准确class BaseParser: def parse(self, data): raise NotImplementedError这里的区别是pass表示“现在不做事但代码合法”raise NotImplementedError表示“这个地方不应该被调用如果被调用就要立刻报错”。两者的设计意图完全不同。写框架或者抽象基类时用后者能帮你尽早发现问题。4. 核心运算符一网打尽分类、优先级、结合方向4.1 七大类运算符全景表Python里的运算符比大多数人以为的要多而且不同类别之间的优先级很容易混淆。我把常用的分成七大类列一个表类别常见运算符说明简单例子算术运算符 - * / // % **四则运算、整除、取余、幂7 // 2 3比较运算符 ! 比较两个值3 2赋值运算符 - * /等把值绑定到名字x 1逻辑运算符and or not布尔逻辑True and False位运算符 | ^ ~ 二进制位操作5 3成员运算符in not in判断是否属于容器1 in [1,2]身份运算符is is not判断是不是同一个对象a is None下标运算和 slicing 实际上也属于运算符体系比如lst[0]中的方括号本质上是对“取下标”这个操作的调用。三元表达式x if cond else y也可以看作一种运算符结构它优先级比or低比赋值高。4.2 优先级不是靠背的但有几个位置必须背Python的执行顺序遵循优先级表从高到低大概是这样幂运算**一元正负号x -x乘法、除法、整除、取余* / // %加法、减法 -移位 位与位异或^位或|比较运算、成员运算、身份运算逻辑非not逻辑与and逻辑或or赋值运算这个表不需要死记因为平时写代码可以加括号。真正容易出问题的是两个地方第一个是幂运算和负号。-3 ** 2的结果是-9不是9。因为幂运算的优先级比负号更高计算机会先算出3 ** 2等于9再取负号为-9。如果你想表达负三的平方必须写(-3) ** 2。第二个是位运算符和算术运算符的优先级关系。比如5 3 1大多数人容易算成(5 3) 1 2但实际结果是5 4 4因为的优先级比高。这个坑在写权限判断、色彩数据解析这类位运算场景时特别容易出现。4.3 比较运算符可以连写这是一个宝藏特性Python允许把比较运算符连起来写表达“在区间内”的判断score 75 if 60 score 90: print(及格但不够优秀)这个60 score 90在其它语言里是不合法的很多从C、Java转过来的人一开始不适应。但Python把它处理成60 score and score 90而且score只会被计算一次。这不光是写起来舒服读起来也特别符合自然语言习惯。反例是新手容易把链式比较理解错。比如5 3 2确实为True但5 3 True这种写法会相当迷惑它等价于5 3 and 3 True后者是False因为3和True并不相等。链式比较适合写数学意义上的区间不适合写多个独立条件。多个独立条件请用and加括号明确表达你的意图。4.4 and 和 or 返回的不是布尔值而是操作数这是Python里最反直觉的运算符行为之一。and和or虽然用于逻辑判断但它们的返回值并不一定是True或False而是决定整个表达式结果的最后一个操作数。print(1 and 2) # 2 print(0 or abc) # abc print( or []) # []规则是这样的or从左到右求值遇到第一个真值就返回那个真值如果全是假值返回最后一个假值。and刚好相反遇到第一个假值就返回那个假值如果全是真值返回最后一个真值。这个特性让很多老手写默认值name input_name or default当input_name为空字符串时or会继续向右返回default。但这里埋了一个坑如果input_name本身是合法的空字符串也会被当成“假值”处理结果你可能得不到想要的值。所以判断默认值是否适合用or关键要看这个“假值”是否对你没有意义。用None或空值的情况适合用空字符串的情况就要小心必要时写if name is None。5. 运算符实战排雷除法、短路、身份判断与海象表达式5.1 整除和取余的符号问题C语言程序员最容易栽//是向下取整的整除和C语言或Java里那种“直接截断向零取整”并不一样。这个区别在负数场景下特别明显print(-7 // 3) # -3而不是 -2 print(7 // -3) # -3而不是 -2为什么会这样因为//的语义是“求不大于结果的最大整数”。-7 / 3约等于-2.33不大于-2.33的最大整数是-3所以结果是-3。C语言的整数除法则是向零取整也就是把-2.33中的小数部分丢掉得到-2。取余运算%的符号也有对应规则。在Python里余数的符号和除数一致print(-7 % 3) # 2因为 -7 (-3) * 3 2 print(7 % -3) # -2因为 7 (-3) * (-3) - 2这个规则在做循环索引、周期计算时很关键。比如你要把下标做环形移动(i - 1) % n在Python里总是能得到一个非负下标假设n为正但在C语言里可能会得到负数这是很多人没意识到的问题。5.2 浮点数比较别用 用近似比较浮点数在二进制里不能精确表示所以0.1 0.2 0.3的结果是False。这不是Python的Bug而是IEEE 754浮点数存储方式本身的特征几乎所有编程语言都有这个问题。print(0.1 0.2) # 0.30000000000000004处理方法是使用math.isclose()import math print(math.isclose(0.1 0.2, 0.3)) # True需要注意的是isclose()默认的容差与绝对误差、相对误差有关在数值极小时要留意。如果你做的是金融计算或金额相关逻辑建议直接用Decimal不要用二进制浮点数。这不是“较真”而是真实项目里出过很多次的精度事故。5.3 is 和 的区别以及一个小整数缓存陷阱比较的是值is比较的是身份即两个变量是否指向同一个对象。对大多数场景你应该用。a [1, 2, 3] b [1, 2, 3] print(a b) # True值相同 print(a is b) # False不是同一个对象但Python对一些小整数做了缓存导致is在某些情况下会返回Truex 256 y 256 print(x is y) # True因为小整数被缓存 x 257 y 257 print(x is y) # False不同对象这个坑的教训很简单永远不要用is去判断整数或字符串是否相等。你可以用is None判断空值因为None是单例对象用is True判断布尔值虽然技术上可行但也不推荐直接写if a True或者更地道的if a就好了。5.4 下标运算符、负索引和切片边界下标运算符[]的优先级非常高它出现在几乎所有复杂表达式里。列表索引有两个容易出错的地方一是负索引二是切片边界。负索引是从末尾开始数的lst[-1]是最后一个元素lst[-2]是倒数第二个。切片的lst[start:stop]同样是左闭右开区间lst[1:3]包含下标1和2不包含3。lst [0, 1, 2, 3, 4, 5] print(lst[-1]) # 5 print(lst[1:3]) # [1, 2] print(lst[:2]) # [0, 1]start省略表示从0开始 print(lst[3:]) # [3, 4, 5]stop省略表示到末尾 print(lst[::2]) # [0, 2, 4]通过步长切片IndexError: list index out of range最常见的原因有两个访问了不存在的正下标比如长度为3的列表访问lst[3]以及循环中列表长度动态变化导致下标越界这部分在2.4里详细说过。出现这个报错时第一反应应该是打印len(lst)看看长度再用条件判断保护边界。5.5 海象运算符 : 的用途与陷阱:是Python 3.8引入的“海象运算符”作用是“赋值并返回结果”。它最常见的用途是在表达式里给变量赋值同时把这个结果用在判断里data [1, 2, 3] if (n : len(data)) 2: print(f数据量是{n})没有海象运算符时你需要先算一遍len(data)赋给n再拿n去判断要写两行。用了之后一行就能同时完成赋值和条件判断。海象运算符在while循环里读文件时特别好用while (line : file.readline()) ! : process(line)这里的坑是赋值表达式必须加括号因为:的优先级比比较运算符低。直接写while line : file.readline() ! 会把结果赋成布尔值而不是整行文本。我自己第一次写就踩过这个结果line被赋成了True或False后面处理逻辑全乱了。海象运算符虽然方便但别到处滥用它更适合消除重复计算的场景而不是让代码变得花哨。6. 两个落地案例邻接矩阵构建与“李白打酒”循环6.1 构建邻接矩阵时最常见的列表复制坑热词里有人搜“python构建邻接矩阵”。这个场景其实特别适合把range、运算符和切片一起练一遍。最直接的写法是n 4 adj [[0 for _ in range(n)] for _ in range(n)]这里用了两次range(n)外层循环生成n行内层循环生成每行的n个0。为什么这样写因为每一行都是一个独立的新列表你修改adj[0][1]不会影响其它行。新手更爱写的是这种“看起来更短”的版本adj [[0] * n] * n这个写法非常危险。[0] * n确实能生成一个包含n个0的列表但* n复制的是这个列表的引用而不是复制内部的值。也就是说矩阵里的每一行其实都指向同一个列表对象。当你执行adj[0][1] 1你会发现所有行的第2个元素都变成了1。因为本质上一共有n个变量名但都指着同一个列表。这就是运算符使用不当引发的问题*用在列表上做的是重复而不是数值乘法。判断一个矩阵构建方式是否安全最简单的方法是把n改成3执行一次赋值然后打印整个矩阵。如果你发现一改改一排说明引用的复制关系出了问题。用列表推导式或显式for循环生成新列表才是稳妥的做法。6.2 一道经典递推题从0.875斗酒开始“李白打酒”是一个经典编程练习题大意是诗人提着酒壶遇到酒店酒量翻倍遇到花喝一斗最后一次喝完壶中酒。这道题用正向思维比较绕用反向递推却非常清晰。题目常见的顺序是店、花、店、花、店、花一共六次。正向是从初始酒量开始逆向则从最终酒量0倒推。逆向遇到一次花相当于把喝掉的一斗加回去逆向遇到一次店相当于把翻倍过程反过来即酒量减半。steps [店, 花, 店, 花, 店, 花] wine 0 for i in range(6): step steps[5 - i] if step 花: wine 1 elif step 店: wine / 2 print(wine) # 0.875这里range(6)控制循环次数steps[5 - i]通过下标倒序取元素和/是赋值运算符是比较运算符。整道题没有用到任何高级语法却把本节讲的知识全串起来了。如果你觉得steps[5 - i]不够直观可以用reversed(steps)for step in reversed(steps): if step 花: wine 1 elif step 店: wine / 2输出结果一样。这个例子说明清晰的代码往往不是因为用了多少高级功能而是把基础类型和运算符用对了。6.3 用range替代while做次数受控的循环很多从其它语言转过来的人习惯用while实现次数循环比如i 0 while i 10: do_something() i 1这段代码在Python里不算错但不够Pythonic。更地道的写法是for _ in range(10): do_something()好处有几点不需要手动初始化i不需要手动增加i也就不用担心最后忘写i 1导致死循环。range天然表达了“执行10次”的意图代码更短也更容易读。如果你确实需要知道当前是第几次循环可以把计数器变量留着for i in range(10): print(f第{i 1}次)但如果你只是重复执行某个操作而不关心次数用_作为循环变量名是惯例告诉读者“这个变量我不准备用”。_本身是一个普通变量名但它承担了“占位”的语义和pass有异曲同工之处。7. 高频报错速查与我的排查习惯7.1 与range、pass、运算符相关的高频报错表我把实际开发中遇到的、和这几个主题相关的高频报错整理成一张表方便你遇到问题时直接对着看报错信息可能原因处理思路IndexError: list index out of range访问了不存在的下标循环过程中列表长度变化先打印len(lst)确认范围避免边遍历边删除ValueError: range() arg 3 must not be zerorange的步长参数被写成0检查第三个参数确保不为0TypeError: range object does not support item assignment试图修改range对象的元素用list(range(...))转成列表再修改IndentationError: expected an indented block函数、类、分支体为空用pass占位或补上实际代码TypeError: unsupported operand type(s) for 不同类型做运算如字符串和整数相加先做类型转换保证类型一致ZeroDivisionError: division by zero除数或%的右值为0判断分母或使用默认值保护这张表不全面但覆盖面已经足够应付大多数入门日常了。关键是看到报错后不要急着改代码先读一遍报错信息的名字它通常已经把问题类型告诉你了。7.2 我的三步排查法范围、类型、优先级我排查这类基础问题的顺序是固定的。第一步看范围。凡涉及索引、循环、切片先检查边界值。从经验来看“差一错误”是最常见的问题比如想取前5个元素却写了range(1, 5)导致第一个元素被漏掉。用开区间思维看一遍往往马上就能发现问题。第二步看类型。range对象、列表、迭代器在打印时长得不太一样。range(0, 5)不是[0, 1, 2, 3, 4]如果代码把它当列表用很容易报类型错误。此时在调试器里打印type(x)确认它是range、list还是iterator。第三步看优先级。当一行表达式里有算术、比较、逻辑运算混在一起时不要相信自己的心算。加括号把意图明确写出来既防读错也防别人将来改错。比如if (a 1) and (b % 2 0): pass这个写法虽然多了两个括号但读代码的人不会产生任何歧义也不会误以为a 1 and b会先做运算。7.3 我长期写Python的一些小习惯最后分享几个自己一直用的习惯都是踩坑踩出来的。第一个习惯是能用for _ in range(n)就尽量不用while写“执行n次”的循环少一个计数器变量就少一个出错机会。第二个习惯是遇到IndexError永远不要先怀疑是Python的问题先检查自己的索引和容器长度。打印len()是成本最低的调试手段别嫌低级我到现在遇到复杂列表操作时也照样先加一个print(len(...))。第三个习惯是pass尽量少用在except分支里。如果真的确定某个异常可以忽略至少给它加一行日志。否则半年后线上出了诡异问题翻代码时看到一行孤零零的pass那种心情真的很煎熬。第四个习惯是复杂运算符表达式一律加括号不为别人就为三个月后的自己。三个月后的你会忘记当时的优先级心算过程但括号还在那儿一看就懂。这些习惯没有一条是高深技巧全是写代码时随手能做的选择。但就是这些选择决定了一个人的代码是让别人看得舒服还是让三个月后的自己骂骂咧咧。range、pass、运算符我在课程和项目里反复讲在真实代码里反复用。它们从来不惊艳但几乎所有业务逻辑都长在它们上面。把基础里的每个细节摸透比追一百个花哨库要值得多。