说实话,刚带新人的时候我特别怕听到一句话:“我想学 Python,但是不知道从哪下手。”这话背后往往藏着一个误会——很多人把 Python 当成一门要去“学完”的课程,而不是一个拿来“用”的工具。我自己最早接触 Python,是因为实在受够了手工整理一批几十个表格文件的重复劳动,被逼着写了个几十行的脚本,跑通那一刻才明白这玩意儿真正的价值在哪。所以这篇东西不打算按语法书的顺序铺开,而是按一个从业者的实际使用路径来讲:Python 是什么、它擅长解决什么问题、哪些人适合投入时间、以及从环境搭建到写出能跑在生产里的代码,中间那些真正会绊倒你的细节。不管你是零基础想找一条不绕弯的入门路线,还是已经能写脚本但总觉得代码“不稳”、想补齐底层认知,下面这些内容都能直接拿去用。
1. 先想清楚:Python 到底解决的是哪一类问题
1.1 它是一门“把零件串起来”的语言
Python 的核心定位,业界有个说法叫“胶水语言”,我觉得这个比喻很准。它本身不是性能最强的,单看纯计算速度,跟编译型语言差着一大截;但它的生态里几乎每个领域都有成熟可靠的现成组件,你要做的事情往往不是从零造轮子,而是把这些组件按业务逻辑粘在一起。举个生活化的例子:编译型语言像是自己从和面开始做一顿饭,Python 更像是去一个品类极全的菜市场,你负责的是搭配和火候。
这个特性决定了它的强项场景:数据清洗与转换、批处理脚本、爬取与聚合、接口服务、自动化运维、原型验证、机器学习实验。反过来说,如果你要做的是高频交易撮合引擎、图形渲染底层、实时音视频编解码这类对每纳秒都敏感的活儿,Python 通常只出现在验证阶段,最终实现会换语言。认清这一点很重要,因为它直接决定你学习时该往哪个方向使劲——把时间花在啃算法常数优化上,对绝大多数 Python 使用者来说是错配。
还有一个经常被忽略的点:Python 的动态类型让它写起来极快,但也意味着很多错误要到运行时才暴露。这不是缺点,是取舍。它换来了开发速度,代价是你必须用工程手段(类型注解、单元测试、静态检查)把风险补回来。理解了这层取舍,你就不会一边享受写脚本的快感,一边抱怨“为什么线上才报错”。
1.2 三条典型路线,选一条先走通
我带过的人里,走得最顺的往往是一开始就锁定单一目标的那批。Python 的学习路线大致可以分成三条,每条的重点完全不同,混着学最容易半途而废。
| 路线 | 典型任务 | 必须掌握 | 可以先放放 |
|---|---|---|---|
| 脚本自动化 | 批量重命名、定时抓取、文件整理、报表生成 | 文件与路径操作、异常处理、日志、定时调度 | 类继承、装饰器进阶、异步 |
| 数据处理 | 表格清洗、指标统计、图表输出、简单建模 | 数组与表格操作、分组聚合、缺失值处理、可视化 | Web 框架、并发编程 |
| 接口服务 | 提供 API、对接前端、任务队列 | Web 框架、数据库操作、请求校验、部署与日志 | 科学计算库、爬虫技巧 |
我个人的建议是:先用脚本自动化路线把“能跑通一件真实的事”这个闭环走完,哪怕只是把一堆 CSV 按日期归档。原因很实在——脚本路线的反馈周期最短,你写完立刻能看到结果,这种即时正反馈是撑过入门期最关键的东西。等到你能独立写出一个带日志、带异常处理、能重复运行的脚本,再去碰数据处理或者 Web 框架,会发现那些概念并不陌生,因为它们解决的是同一批工程问题。
顺带说一句关于“要不要学算法和数据结构”的困惑。我的看法是:基础的数据结构概念(列表、字典、集合的查找复杂度差异)必须懂,这直接影响你写出 O(n²) 还是 O(n)。但不必一上来就刷题,那是另一条赛道。真到了性能瓶颈,你会自然而然地回去补。
1.3 版本选择这件事,别纠结太久
新手常见的一个卡点是:到底装哪个版本。答案很干脆——选当前主流稳定版里较新的那个,别用太老的,也别追最新的尝鲜版。太老的版本(比如很多老教程里的 3.6、3.7)缺了新语法特性,跑别人的代码容易报错;太新的版本生态适配可能还没跟上,装第三方库时容易撞见编译失败。
判断标准很朴素:看你依赖的关键库是否已经提供对应版本的预编译包。如果装库时频繁出现要本地编译、报缺编译器的情况,说明这个组合还偏新,退一档更省心。这个经验是我踩过好几次坑换来的——曾经为了用新语法特性升级解释器,结果一个关键依赖没有预编译包,在环境上折腾了整整一个下午,最后还是退回原版本。
2. 环境搭建:地基没打好,后面全是坑
2.1 解释器、虚拟环境、包管理器,三者关系一次讲透
这三样东西的关系,用一句话概括:解释器负责执行,虚拟环境负责隔离,包管理器负责往隔离区里装东西。很多人卡在环境问题上,本质是没分清这三者的边界。
解释器就是你装的那个 Python 本体,全局只有一份(或者几份不同版本)。包管理器负责从公共仓库下载库并安装。问题在于:如果所有项目共用一个全局环境,A 项目要某库的 1.x 版本,B 项目要 2.x 版本,冲突就不可避免了。虚拟环境解决的就是这个——每个项目一个独立目录,里面有自己的一套库,互不干扰。
生活类比:解释器是厨房,虚拟环境是给每个菜系单独配的一套刀具和调料,包管理器是采购员。你不能让川菜和粤菜共用同一罐已经调好的酱。
# 创建虚拟环境(在项目根目录执行) python -m venv .venv # 激活(Windows) .venv\Scripts\activate # 激活(macOS / Linux) source .venv/bin/activate # 确认当前用的是虚拟环境里的解释器 python -c "import sys; print(sys.executable)"最后那行命令非常关键,我建议你养成习惯:每次装库之前先确认 sys.executable 指向的是项目里的 .venv。我见过太多“明明装了却 import 不到”的情况,九成是因为装到了全局环境,而运行时用的是另一个。
注意:虚拟环境目录不要提交到版本库。它的体积大、路径与本机绑定,换台机器就失效。正确做法是把依赖清单提交上去,别人拿到后一条命令重建。
2.2 一份可以直接抄的项目骨架
我的习惯是把目录结构定死,这样换项目不用重新想。下面这个骨架适用于绝大多数脚本类和中小型服务类项目:
project/ ├── .venv/ # 虚拟环境,不提交 ├── src/ │ └── app/ │ ├── __init__.py │ ├── config.py # 配置集中管理 │ ├── fetcher.py # 数据获取 │ ├── transform.py # 数据清洗转换 │ ├── storage.py # 落库/落盘 │ └── main.py # 入口,只做编排 ├── tests/ │ └── test_transform.py ├── logs/ # 运行日志,不提交 ├── data/ # 输入输出数据,不提交 ├── requirements.txt └── README.md这个结构的好处是职责边界清晰:main.py只负责串联流程,不写具体逻辑;每个模块只干一件事,写测试时可以直接对准一个模块。我早期所有代码都塞一个文件里,几百行之后改一处就心惊胆战,后来强制拆分,维护成本立刻降下来。
配置这块我特别想说一句:不要把参数硬编码在代码里。连接地址、超时时间、重试次数、输出路径,全部集中到config.py或者一个配置文件里。原因很实际——同一份代码在你本机、测试环境、正式环境跑,差异往往就在这几个值上。硬编码意味着每次都要改代码,改代码就意味着可能引入新问题。
# config.py 示例 from dataclasses import dataclass @dataclass(frozen=True) class Settings: base_url: str = "https://example.internal/api/items" timeout: float = 8.0 # 单次请求超时,秒 max_retries: int = 3 # 最大重试次数 page_size: int = 50 # 每页条数 db_path: str = "data/records.db" log_level: str = "INFO" settings = Settings()用dataclass加frozen=True的好处是配置不可变,避免某个模块偷偷改了全局配置导致另一个模块行为异常——这种 bug 排查起来极其痛苦,因为改的地方和执行的地方隔着好几个文件。
依赖清单这块,requirements.txt够用但有个坑:只写库名不锁版本,过两个月别人重建环境,装到的是最新版,行为可能已经变了。我的做法是关键依赖锁大版本,比如requests>=2.28,<3.0,既能拿到安全更新,又不会因为跨大版本升级被破坏性变更打到。
2.3 装库慢或者失败,先分清是网络问题还是编译问题
装库失败大体分两类,处理方式完全不同。
第一类是下载超时、连接被重置,表现是卡在下载进度上。这种情况一般调大超时时间、或者换一个延迟更低的包索引地址就能解决。配置可以写到用户级配置文件里,一次配置长期有效:
# pip.conf(Linux/macOS 放在 ~/.config/pip/pip.conf) [global] index-url = https://pypi.org/simple timeout = 60 retries = 5第二类是编译失败,报错里会出现error: Microsoft Visual C++ ... required或者gcc: command not found这类字样。这说明这个库没有提供当前平台的预编译包,需要本地编译。处理顺序是:先看看能不能升级包管理器本身(新版通常对预编译包支持更好);再确认解释器版本是不是太新,回退一档往往就有预编译包了;实在不行才去装编译工具链,但这条路成本高,不到万不得已别选。
提示:遇到装库问题,先看报错最后 20 行。前面几百行通常是编译日志噪音,真正的结论在末尾。
3. 语法核心:决定你代码质量的那几个细节
3.1 可变对象与默认参数,一个能绊倒无数人的坑
Python 里最容易出错、也最容易被面试官拿来考的点,就是可变对象的默认参数。
# 错误写法 def add_item(item, bucket=[]): bucket.append(item) return bucket print(add_item("a")) # ['a'] print(add_item("b")) # ['a', 'b'] <- 期望是 ['b']原因在于:默认参数的值在函数定义时求值一次,之后所有调用共享同一个对象。列表是可变对象,所以每次 append 都在改同一个列表。
正确写法是用None做哨兵:
def add_item(item, bucket=None): if bucket is None: bucket = [] bucket.append(item) return bucket这个坑的变体还有不少:dict、set、自定义的可变实例,只要是可变默认参数,都有同样的问题。判断标准很简单——默认值是不是不可变对象(数字、字符串、元组、None),不是的话就换成哨兵。
顺着这条线还得说浅拷贝和深拷贝。赋值只是给同一个对象贴第二个标签,copy.copy()只复制最外层,嵌套里的列表还是共享的,copy.deepcopy()才是彻底复制。我见过一个数据处理脚本,用浅拷贝复制配置字典,结果改子项时把原始配置也改了,排查了半天。经验是:只要是嵌套结构需要在两个地方独立修改,直接上深拷贝,性能开销在绝大多数场景下完全可以接受。
3.2 列表推导式与生成器:内存视角下的取舍
列表推导式写起来爽,但很多人不知道它和生成器表达式的本质差别。
squares_list = [x * x for x in range(10_000_000)] # 立刻分配约 80MB+,全部驻留内存 squares_gen = (x * x for x in range(10_000_000)) # 几乎不占内存,边算边给列表推导式会一次性把所有结果算出来放进内存,生成器表达式只保留计算规则,取一个算一个。数据量小的时候无所谓,一旦到了百万级,列表推导式可能直接把内存吃满。
实际经验:输入规模不确定、或者只需要遍历一次的场景,优先用生成器。比如读一个大文件逐行处理、从接口分页拉数据、做累加统计,这些都是生成器的典型用武之地。反过来,如果结果需要反复访问、需要随机索引、需要算长度,那就老老实实用列表。
这里还有个隐藏坑:生成器只能遍历一次。遍历完就空了,再迭代什么也拿不到。我踩过一次,把生成器传给两个函数分别处理,第二个函数拿到的永远是空结果。排查时打印长度是 0,第一反应是数据源有问题,折腾很久才反应过来是迭代器耗尽了。所以我的做法是:接口边界处不返回生成器,除非函数名里明确写了 iter 之类的字样,避免调用方误判。
另外说一个性能上的实操心得:collections里的Counter、defaultdict、deque,在处理计数、分组、队列的时候比自己手写快得多也稳得多。计数器场景用Counter代替手写字典循环,代码量能少一半,还不容易写出边界错误。这些标准库工具的真正价值不在“知道有这个类”,而在“遇到问题的第一反应是去找标准库有没有现成的”。
3.3 异常处理:写 try 容易,写对难
新手写异常处理的典型写法是except Exception: pass,也就是什么都不做。这是最危险的一种写法,因为它把所有错误都吞掉了,包括你本来应该发现的问题。
我的原则有三条。第一,捕获尽量具体。需要处理的是网络超时就捕TimeoutError,需要处理的是键不存在就捕KeyError,不要一上来就捕Exception。第二,捕获了就要有动作——记日志、重试、转成业务异常、或者给出兜底值,总之不能什么都不做。第三,不要在 try 里塞太多行,try 块越大,出问题时越难定位是哪一句炸的。
import logging def safe_read(path): try: with open(path, encoding="utf-8") as f: return f.read() except FileNotFoundError: logging.warning("文件不存在,返回空内容:%s", path) return "" except UnicodeDecodeError: logging.error("编码解析失败,请确认文件编码:%s", path) raise这个例子里,FileNotFoundError给出了兜底返回值,因为“文件不存在”在业务上可能是可接受的;UnicodeDecodeError直接抛出去,因为这是配置或数据本身有问题,静默跳过只会让问题延后爆发。区分这两种情况的能力,比记住语法本身重要得多。
和异常处理绑在一起的是上下文管理器,也就是with语句。它的作用是保证资源一定被释放,即使中间抛了异常。写文件、开连接、拿锁,全部用with。自己实现的话,用contextlib.contextmanager装饰器几行就能搞定,比写一个完整的类清爽得多。
from contextlib import contextmanager import time @contextmanager def timer(label): start = time.perf_counter() try: yield finally: elapsed = time.perf_counter() - start print(f"[{label}] 耗时 {elapsed:.3f}s")这个计时器我用得极多,套在任何一个函数块外面就能知道耗时,而且即使里面抛异常也能正常打印。finally是关键,它保证清理逻辑一定执行。
3.4 作用域与闭包:那些看起来反直觉的行为
Python 的作用域规则是 LEGB——局部、外层函数、全局、内置。绝大多数时候不用想,但有两种情况会让人困惑。
第一种是在函数内部给全局变量赋值。直接写count += 1会报UnboundLocalError,因为 Python 看到函数里有赋值语句,就把这个名字当成局部变量了,而局部变量在赋值之前引用是非法的。解决办法是显式声明global,但更好的办法是根本不用全局变量,改成传参和返回值。全局状态是维护噩梦的源头,尤其是在多函数协作的场景下。
第二种是循环变量捕获。在闭包或延迟执行里引用循环变量,最终拿到的是循环结束时的最后一个值,而不是当时那一次的值。解决办法是用默认参数固定住,或者用functools.partial。这个坑在写回调、绑定事件处理的时候尤其常见:
# 有问题的写法 handlers = [] for i in range(3): handlers.append(lambda: i) print([h() for h in handlers]) # [2, 2, 2] # 正确写法:用默认参数固定当前值 handlers = [] for i in range(3): handlers.append(lambda i=i: i) print([h() for h in handlers]) # [0, 1, 2]原理不复杂,但不知道的话就会被结果搞懵。我记得第一次遇到时反复确认代码逻辑,最后才意识到是延迟绑定。这类知识点属于“不知道就永远想不通,知道了就再也不会错”的类型,值得专门花时间过一遍。
4. 库与工具链:别人已经造好的轮子
4.1 标准库先吃透,第三方按需引入
我见过不少人的习惯是遇到问题先搜第三方库,标准库反而不熟。这其实走了弯路——Python 的标准库覆盖面相当广,很多需求根本不需要额外依赖。
路径操作用pathlib,比字符串拼接路径稳得多,跨平台也不会有分隔符问题。日期时间用datetime,注意区分“带时区”和“不带时区”两种对象,混用是经典坑。JSON 序列化用内置json,注意中文默认会被转义成 Unicode 编码,加个ensure_ascii=False才是人类可读的。日志用logging,不要用print——这是我反复强调的一条,因为print没有级别、没有时间戳、没法重定向到文件、上线前还得一个个删。
import logging from pathlib import Path logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", handlers=[ logging.FileHandler("logs/app.log", encoding="utf-8"), logging.StreamHandler(), ], ) data_dir = Path("data") / "raw" data_dir.mkdir(parents=True, exist_ok=True) for f in data_dir.glob("*.csv"): logging.info("待处理文件:%s,大小 %.1f KB", f.name, f.stat().st_size / 1024)这段代码里有几个值得说的点:mkdir(parents=True, exist_ok=True)保证多级目录能建、重复执行不报错,这是脚本可重复运行的基础;glob支持通配符,比手写遍历清爽;日志格式里带时间戳和模块名,出问题时能快速定位来源。
第三方库的引入原则我总结成一句话:只有当标准库明显不趁手,或者第三方实现明显更成熟时才引入。每引入一个依赖,你就多了一个版本兼容、安全更新、安装失败的潜在来源。我现在启动新项目,依赖清单通常控制在五六个以内。
4.2 数据处理与可视化的组合套路
做表格类数据处理,绕不开数组与表格这两个层级的工具。数组层适合做整列整块的数值运算,表格层适合做带列名、带索引、带混合类型的操作。理解这个分层很重要,因为它决定了你什么时候该用哪个。
我的经验是:读取和写出用表格层,重计算下推到数组层。比如要算一列数值的移动平均,用表格层的滚动窗口方法写起来一行;但如果是复杂的逐元素变换,转成数组再算会快不少。这种混用在数据脚本里非常常见,不是“二选一”而是“各取所长”。
可视化这块,关键不在画图函数怎么调,而在图表设计。我踩过的坑包括:纵轴不从零开始导致差异被夸大、类别太多导致标签堆叠、配色区分度不够导致黑白打印后完全看不出区别。这些不是技术问题,是表达问题,但恰恰是决定图表有没有用的关键。
提示:出图之前先问自己一句——这张图想让读者看到哪一个结论?如果答不上来,说明还没想清楚要画什么。
还有一点:中间结果要落盘。数据处理的流程往往分好几步,每步都重算一遍既慢又难排查。我的做法是在关键节点把中间结果存成文件,加个开关控制是否重算。这样一旦后面出问题,可以直接从中间结果复现,不用从头跑。
4.3 代码质量工具:从“能跑”到“敢改”
代码能跑只是及格线。真正让我从“写完不敢动”变成“随时敢重构”的,是几个自动化工具。
格式化工具负责统一缩进、引号、换行,团队协作时能避免大量无意义的格式改动。静态检查工具负责在你运行之前找出未使用的变量、可疑的写法、潜在的类型错误。类型注解本身不改变运行行为,但它配合静态检查,能把一大类低级错误拦截在执行之前。
from typing import Sequence def average(values: Sequence[float]) -> float: if not values: raise ValueError("序列为空,无法计算平均值") return sum(values) / len(values)加上注解之后,编辑器能直接提示“你传了个字符串列表进来”,不用等到运行时才报类型错误。这个收益在项目变大之后会指数级放大。我现在的习惯是新写的函数一律带注解,老代码则是改到哪补到哪,不强求一次性全量改造。
单元测试这块,不用追求覆盖率数字。我的实践是只测三类东西:纯计算逻辑、边界条件、曾经出过 bug 的地方。网络请求、数据库操作这类外部依赖,抽一层接口出来打桩,测试就只跑纯逻辑部分,速度快、结果稳。
5. 完整实操:从抓取到报表的一条流水线
5.1 需求拆解:先把流程画在纸上
假设要做一个定时任务:从某个内部接口分页拉取记录,清洗后存进本地数据库,最后生成一份按类别汇总的文本报表。这个需求足够典型,涉及网络、异常、并发、存储、输出五个环节。
拆解顺序是:入口 → 获取 → 清洗 → 存储 → 输出,每一环都能独立测试。我习惯先把这几个模块的空函数签名写出来,跑通主干流程,再逐个填实现。这个顺序的好处是,你随时有一个能跑的骨架,而不是写到最后才发现模块之间接口对不上。
关于参数,有几个需要算一下。单次请求超时设 8 秒,是因为实测接口 P99 在 2 秒上下,留四倍余量;最大重试 3 次,是指数退避的话总等待约 1+2+4=7 秒,加上三次请求本身耗时,最坏情况 30 秒左右返回,这个时长对定时任务来说可接受,再长就可能和下一轮调度撞上。每页 50 条,是基于单页响应体大小估算的——太小则请求次数多,太大则单次失败重传成本高,50 是一个平衡点。
5.2 关键代码:带退避的重试装饰器
网络请求最需要的就是重试,但随便重试会放大故障。我用的方案是指数退避加随机抖动:
import random import time import logging from functools import wraps def retry(times=3, base_delay=1.0, max_delay=8.0, exceptions=(Exception,)): """指数退避重试:1s, 2s, 4s,上限 8s,每次加 20% 随机抖动避免同步重试""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): last_exc = None for attempt in range(1, times + 1): try: return func(*args, **kwargs) except exceptions as exc: last_exc = exc if attempt == times: break delay = min(base_delay * (2 ** (attempt - 1)), max_delay) delay += random.uniform(0, delay * 0.2) logging.warning("第 %d 次调用失败:%s,%.2fs 后重试", attempt, exc, delay) time.sleep(delay) logging.error("重试 %d 次后仍失败", times) raise last_exc return wrapper return decorator为什么加随机抖动?因为如果一批任务同时失败、同时重试,会在同一时刻对接口发起相同数量的请求,等于把一次故障放大成一次脉冲。加了抖动之后重试时间点被打散,压力就摊平了。这个技巧在有并发调用的场景里几乎是标配。
配合重试的还有连接复用。频繁建连接的开销不小,用一个会话对象复用底层连接池,能把整体耗时降下来一大截。会话对象还能统一带上公共请求头,避免每个请求重复写。
import requests @retry(times=3, base_delay=1.0, max_delay=8.0, exceptions=(requests.Timeout, requests.ConnectionError)) def fetch_page(session, url, page, size): resp = session.get(url, params={"page": page, "size": size}, timeout=8.0) resp.raise_for_status() payload = resp.json() return payload.get("items", [])这里有个容易忽略的点:重试只针对可恢复的错误。超时、连接重置可以重试;而 4xx 这类客户端错误重试多少次都是同样的结果,raise_for_status()抛出的异常如果不在重试白名单里,就直接往外冒,这是对的。我见过不加区分的全量重试,对着一堆 400 错误重试三轮,白白浪费一分钟。
5.3 清洗、落库与报表输出
清洗环节的原则是幂等:同一条数据不管处理几次,结果都一样。这样重跑才不会产生重复记录。落库用主键冲突替换的方式实现幂等:
import sqlite3 DDL = """ CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY, category TEXT NOT NULL, amount REAL NOT NULL, created_at TEXT NOT NULL ); """ def save_all(conn, rows): sql = ("INSERT INTO records (id, category, amount, created_at) " "VALUES (?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET " "category=excluded.category, amount=excluded.amount") conn.executemany(sql, rows) conn.commit()executemany批量提交比逐条插入快一个数量级,这个差异在几千条以上就非常明显了。背后原因是逐条执行每次都要走一遍事务开销,批量提交只走一次。顺手提一句,sqlite3的默认隔离行为在不同版本之间有过调整,如果发现数据没写进去,先检查是不是漏了commit()——这几乎是新手最常见的插入失败原因。
报表输出我选纯文本或 Markdown 表格,理由是它不依赖任何额外运行环境,发给谁都能打开。汇总逻辑用Counter一行搞定:
from collections import Counter def build_report(rows): by_category = Counter() for r in rows: by_category[r["category"]] += 1 total = sum(by_category.values()) lines = ["| 类别 | 条数 | 占比 |", "| --- | --- | --- |"] for cat, cnt in by_category.most_common(): lines.append(f"| {cat} | {cnt} | {cnt / total:.1%} |") return "\n".join(lines)整个流程跑下来,我用一份约两万条的样例数据测过,端到端耗时在十几秒,其中网络请求占七成以上,本地处理只占很小一部分。这个比例说明一件事:优化要对着瓶颈去。如果当时花时间去优化清洗逻辑,收益几乎为零;把并发请求数从 1 提到 4(注意不要超过接口承受能力),总耗时能砍掉一大半。
6. 常见问题与排查实录
6.1 环境与依赖类问题速查
这类问题占了新手提问的一大半,而且往往有明确的现象对应关系。
| 现象 | 最可能原因 | 处理办法 |
|---|---|---|
ModuleNotFoundError | 装到了别的环境,或虚拟环境没激活 | 打印sys.executable核对路径,重装 |
| 导入成功但版本号不对 | 存在同名文件遮蔽(如json.py) | 检查当前目录有无同名脚本,改名 |
| 装库卡在下载 | 网络超时 | 调大 timeout、换索引地址 |
| 装库报编译错误 | 无预编译包 | 升级包管理器或回退解释器版本 |
| 换台机器就跑不起来 | 依赖未锁版本 | 补requirements.txt并锁大版本 |
“同名文件遮蔽”这条我特别想强调。你写了个叫random.py的测试文件,之后在同目录运行任何脚本,import random拿到的都是你的文件,然后就是一堆莫名其妙的属性错误。这个坑隐蔽性极高,因为报错信息不会告诉你“你覆盖了标准库”。排查这类问题的第一反应应该是:当前运行的目录里有没有和报错模块同名的文件。
6.2 运行期逻辑错误速查
逻辑错误不会报错,只会给出错误结果,这类最难查。
浮点数精度是典型。0.1 + 0.2不等于0.3,这是二进制表示导致的,不是 bug。涉及金额计算时,正确做法是用整数分位存储,或者用定点小数类型。比较浮点数时永远不要用等号,用差值小于某个极小值来判断。
编码问题也是高频。读文件不加encoding参数时,用的是系统默认编码,在不同平台上结果不同,中文内容就可能乱码。读写文本文件一律显式指定编码,这一条能省掉无数麻烦。写文件时如果希望中文不被转义,记得关掉 ASCII 转义。
还有一类是“数据看起来对但统计不对”。我的排查套路是:先打印总条数,再看去重后的条数,两者不一致说明有重复;随机抽几条打印全字段,看有没有空值或异常值;最后按类别分组计数,看分布是否符合预期。这套流程能覆盖绝大多数数据问题。
6.3 性能与内存问题
脚本跑得慢或者内存暴涨,处理顺序建议是:先定位,再优化。
定位用内置的性能分析工具,能输出每个函数的调用次数和累计耗时,一眼就能看出热点在哪。我见过太多人凭直觉优化,结果改的是只占 3% 耗时的部分,白忙一场。
内存暴涨最常见的原因是“一次性把数据全部读进内存”。几十万行的表格听起来不多,但如果每行是嵌套结构,实际占用会翻好几倍。解决办法是分块处理,一块一块读、一块一块处理、一块一块写。这个改动通常只需要改几行代码,收益却是决定性的。
还有一个隐蔽的原因是循环里不断拼字符串。字符串不可变,每次拼接都会创建新对象,数据量大时开销惊人。正确做法是把片段放进列表,最后一次性连接。这个细节在生成报表时特别值得注意。
7. 几个只有踩过才懂的经验
类型注解别嫌麻烦。我现在写任何超过二十行的函数都会加,因为它让编辑器能在你按下运行键之前就告诉你参数传错了。这个反馈环的缩短,对效率的提升比任何技巧都实在。
别迷信“优雅”。我早期特别喜欢把逻辑压缩成一行嵌套的推导式,觉得写得短就是水平高。后来发现三个月后自己都读不懂。可读性永远排在简洁前面,拆成三行、每行都能说出在干什么,才是好代码。
日志级别要分清楚。调试信息用 DEBUG,正常流程用 INFO,可以恢复的异常用 WARNING,影响功能用 ERROR。全用 INFO 的结果就是日志里全是噪音,真出事时关键信息被淹没。这个习惯养成之后,线上排查的时间能缩短一大半。
最后一条,也是我觉得最重要的一条:先让代码跑通,再让它变好看。很多人卡在第一步,是因为一边写一边纠结架构设计、设计模式、抽象层次,结果一个能跑的东西都没出来。正确的节奏是先写一个能出结果的粗糙版本,跑通之后再重构。能跑的烂代码,永远比设计精美的空项目有价值。