如果你是跟着这个系列一路读过来的,应该已经装了 Python 环境,也写了不止一个“能跑”的脚本。这个系列的目标一直很明确:帮你把 Python 的入门地基打扎实,但不停留在“能写出.py文件”的层面。第一篇讲了环境和最基础的语法,第二篇讲了函数和常用数据结构。到了第三篇,也就是这篇,我想聊点让代码真正“长大”的东西——模块化、异常处理、面向对象,还有你迟早要面对的第三方库。
为什么要在这个节点谈这些?因为很多自学的人学到列表推导式就停下来了,觉得“基础语法我都会了”,结果一到真实项目就懵:代码全写在同一个文件里,一千多行,想改个功能连下钻都找不到位置;程序一报错直接傻眼,根本不知道那些红色信息在说什么;看到别人代码里有class,手忙脚乱去查文档,越看越抽象。这篇就是来解决这几个问题的。它适合已经掌握基础语法、想继续往初级到中级过渡的读者,也适合学了很久但总觉得代码风格“不像正规军”的老朋友。学完这一篇,你手里的 Python 就不再是玩具了,它能帮你处理更真实、更复杂的任务。
1. 模块:代码一长,最先要学的不是炫技,是拆文件
1.1 一个 py 文件跑到底,到底哪里不行
我记得第一次写超过五百行的脚本,是在一个数据处理的需求里。当时所有函数、所有循环、所有 print 调试全堆在一个文件里,前一百行定义变量,中间三百行处理逻辑,最后一百行输出结果。功能倒是实现了,但第二天需求一变,我去改其中一段逻辑的时候,光是找到那一段就花了十分钟,而且因为变量作用域纠缠不清,改完一个地方,另一个地方莫名报错。
这个痛点是所有初级学习者必须正视的:写代码不只是让程序跑起来,还得让未来的自己能读懂、能维护。模块化就是解决这个问题的第一步。说白了,就是把不同职责的代码,拆到不同的 .py 文件里面,按需导入。这么干的好处有两个,一个是每个文件小且职责清晰,查问题的时候去对应文件里找就行;另一个是代码可以复用,一个写好的工具函数可以同时被多个脚本调用,不用复制粘贴。
在 Python 里,你可以把任何一个 .py 文件当作模块。假设你有一个 daily_tools.py:
# daily_tools.py def add(a, b): return a + b def mul(a, b): return a * b然后在另一个文件 main.py 里,你可以这样导入并使用:
# main.py import daily_tools print(daily_tools.add(2, 3)) from daily_tools import mul print(mul(4, 5))这里的 import 机制有几件事值得展开。第一,import daily_tools会把整个模块里的代码执行一遍,然后把模块名 daily_tools 作为命名空间提供给你。所以调用里面的函数要带前缀:daily_tools.add。第二,from daily_tools import mul是直接把 mul 这个名字导入当前命名空间,好处是调用时不用带前缀,坏处是有可能和你自己的变量名冲突。第三,from daily_tools import *这种写法虽然存在,但我在实际开发里强烈建议你别用。它会一次性导入模块里所有不以_开头的内容,看着省事,实际上会让你的代码变得完全不可追踪,别人根本不知道某个函数是从哪来的。
1.2 import 的路径逻辑与if __name__ == "__main__"
模块化之后,新手碰撞的第一个问题是:我把模块放到了别的文件夹里,怎么 import 不到?这就要理解 Python 找模块的规则了。Python 内部维护了一个叫sys.path的列表,你 import 一个模块的时候,它会按顺序在这个列表里找。列表的第一项通常是当前脚本所在的目录(在交互式环境里是空字符串,代表当前目录),所以你的模块文件只要和主脚本在同一个目录,直接 import 就能成功。
要是模块在子目录里呢?在 Python 里,存放模块的文件夹被称作“包”。一个文件夹被当作包,需要包含一个__init__.py文件。这个文件的含义是:告诉 Python,这个目录不是一个普通文件夹,而是一个可以导入的包。后面版本里如果你的目录结构合理,不写这个文件在某些情况下也可以(命名空间包),但对初学者来说,就记住一点:建包时一定放一个空的__init__.py,免得踩到一些奇怪的导入问题。
比如说你的项目结构长这样:
project/ main.py utils/ __init__.py file_helper.py那么在 main.py 里应该这样导入:
from utils.file_helper import read_config接着是if __name__ == "__main__"这一行。很多新手会说:我写不写都能跑啊,这行到底有啥用?关键在于理解__name__这个内置变量的行为。当你直接运行一个脚本时,Python 会把它的__name__设置为字符串"__main__"。当你导入一个模块时,这个被导入模块的__name__就是它的模块名,比如daily_tools。
所以,如果你把测试代码写在if __name__ == "__main__"里面,直接运行这个模块时测试代码会执行,被其他模块导入时测试代码不会执行。这个机制在调试工具函数时特别好用,你既可以单独验证这个文件的功能,又不会污染其它导入方。
1.3 pip 和虚拟环境:给不同项目装好“隔离舱”
模块化解决了单机内代码的组织问题,但真实项目里几乎不可能只用标准库,你一定会用到第三方库。这就绕不开 pip 和虚拟环境。
先说 pip,它是 Python 的包管理工具,用来安装、卸载、升级第三方包。命令核心就几个:
pip install numpy # 安装 pip uninstall numpy # 卸载 pip list # 查看已安装的包 pip install numpy==1.26.0 # 安装指定版本在国内网络环境下,直接装包经常遇到超时或速度极慢,因为默认源是国外服务器。解决办法是换用国内镜像源。国内比较常用的几个源包括清华 TUNA 镜像、阿里云镜像、腾讯镜像。清华源的地址是https://pypi.tuna.tsinghua.edu.cn/simple。用的时候有两种方式,一种是单次指定:
pip install numpy -i https://pypi.tuna.tsinghua.edu.cn/simple另一种是设置全局默认源,这样以后都不用再带-i参数了:
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple虚拟环境这一点,新手的感受可能还不深,但等你同时负责两个项目的时候就懂了。一个项目基于 Django 3 开发,另一个项目已经迁到了 Django 5,如果你把第三方包一股脑全装在全局环境里,升级这个必然破坏那个,最后项目很可能直接跑不起来。虚拟环境就是给每个项目建立一套独立的包运行空间。用 Python 内置的 venv 创建:
python -m venv venv这条命令会在当前目录生成一个名为 venv 的文件夹。然后激活它,Windows 上执行venv\Scripts\activate,macOS 或 Linux 上执行source venv/bin/activate。激活之后,你用 pip 安装的任何包都会进到这个虚拟环境里,不会污染全局环境。不干了或者切换项目,执行deactivate退出即可。
2. 异常处理:从“一报错就慌”到“让报错听你的”
2.1 错误和异常,其实是两码事
很多初学者见到屏幕上一片红字就紧张,然后跑过来问:“这个报错什么问题啊?”但我也见过不少人在改了一个晚上的报错之后,对 Python 产生了新的理解——报错不可怕,可怕的是报错信息看不懂、也不知道接下来该怎么办。
先厘清一个基础概念。在 Python 里,语法错误和异常是两种不同的东西。语法错误是解释器在“看”代码阶段就发现的问题,比如你少写了个冒号,多写了个右括号,这属于代码不符合语法规则,解释器根本没法编译,直接抛 SyntaxError。而异常是在程序“运行”过程中发生的问题,语法上没问题,但运行时环境出了问题。比如用户输入了一个无法转换成数字的内容,比如你要打开一个压根不存在的文件,这些都属于异常。
初学者要做的第一步,就是学会区分常见的异常类型。我把初级阶段最常遇到的几个列出来,背下来不吃亏:
| 异常类型 | 出现示例 | 含义 |
|---|---|---|
| ZeroDivisionError | 1 / 0 | 除数为零 |
| ValueError | int("abc") | 类型正确,但值不适合转换 |
| TypeError | "1" + 1 | 类型不匹配 |
| IndexError | lst[5],列表只有 3 个元素 | 索引越界 |
| KeyError | d["name"],字典里没有这个键 | 键不存在 |
| FileNotFoundError | open("not_exist.txt") | 文件不存在 |
当你认识这些异常之后,看到报错的末行就不会一头雾水了。Python 的报错信息里,最后一行基本就是异常类型和一段简短描述,前面的部分是不正常的“追踪栈”,展示了异常是在哪个文件的第几行冒出来的,顺着追踪栈从下往上看,通常就是出问题的根因。
2.2 try、except、else、finally 的完整攻击姿势
异常处理的核心语法是try/except。最基本的写法是:
try: num = int(input("请输入一个数字: ")) result = 10 / num print(result) except ValueError: print("输入的不是数字") except ZeroDivisionError: print("不能除以 0")这段代码的意思是:先执行 try 块里的代码,如果中途抛出了异常,就跳过 try 里剩下的部分,跳到对应的 except 块里执行处理逻辑。上面这个例子分别捕获了ValueError和ZeroDivisionError两种异常类型。
这里有一个新手容易轻视的关键点:except 后面的异常类型要尽量精确,一定要避免裸写一个except:。裸捕获会让程序把所有可能的异常都吞掉,包括键盘中断、内存错误、一切你想都想不到的问题,造成的后果是程序在捕获所有异常后继续执行,掩盖了真正的 bug,到后面排查问题的时候,你只能看到一个莫名其妙的后续结果,完全找不到根源。我见过一个线上脚本就是这么坏的,因为裸 except 把所有异常吞了下去,导致日志里一根线索都没有,花了整整一下午才查出是网络请求超时。
try/except 还可以搭配 else 和 finally 一起使用,构成完整结构:
try: num = int(input("请输入一个数字: ")) result = 10 / num except ValueError: print("输入的不是数字") except ZeroDivisionError: print("不能除以 0") else: print(f"结果是 {result}") finally: print("无论如何,这一步都会执行")else块里的代码只有一种情况会执行:try 块没有抛出任何异常。它的作用是区分“异常发生之后”和“一切正常”两条逻辑路径,让代码逻辑更清晰。finally更直白,无论成功还是失败,它都会执行,通常用于释放资源。比如操作完文件后要关闭文件,无论是读了一半报错还是顺利读完,都得把文件句柄关掉,把关闭操作放进finally里就最稳妥。
2.3 初级最容易踩的两个异常处理的坑
第一个坑是“吞异常”。写法是:
try: data = read_file() except: pass看起来程序不报错了,安全运行了,实际上这是最危险的状态。文件读取失败后,data变量根本没定义,后续代码用到data时直接抛 NameError,到时候系统报的是另一个错误,你看半天都不知道问题其实出在最开始的读取环节。正确做法是捕获异常后至少打印日志,或者把错误信息抛出去,让它暴露出来。哪怕只是except OSError as e: print(f"读取失败: {e}"),也好过pass一百倍。
第二个坑是“捕获范围过大”。比如你写except Exception:,这样任何异常都能捕获掉。有时候你会猜想:反正我就是要它在任何错误时都溜达过去,省事。但这个写法会掩盖一些你不想忽略的 bug。例如上面那个除法例子,如果除了 ValueError、ZeroDivisionError 之外,还因为变量拼写错误导致 NameError,这个异常也被 Exception 捕获了,流程直接走 except 分支,最后输出一个让你摸不着头脑的错误提示。合理做法是尽量捕获可能出现的精确异常类型,把意料之外的异常老老实实暴露出来。
3. 类与对象:初学 Python 的第一个“分水岭”
3.1 用“人”的视角来理解对象
类与对象是很多 Python 初级学习者绕不过的一个坎。说实话,这不是 Python 的语法难,而是编程思维要转一个弯。前面你写的函数,本质是“做一件事的操作”;而类和对象,本质是“把一个数据以及操作这些数据的方法,封装成一个整体”。
想理解它,生活里最简单的一个类比就是“人”。你可以想象有一个模板叫 Student,它描述了“学生”应该具有哪些属性和行为。属性就是数据,比如名字、年龄、学号;行为就是方法,比如自我介绍。每个具体的学生,比如小明、小红,都是这个模板创造出来的“实例”。在 Python 里,我们把模板叫“类”,把实例叫“对象”。
来看一段最简单的类代码:
class Student: def __init__(self, name, age): self.name = name self.age = age def introduce(self): print(f"我叫 {self.name},今年 {self.age} 岁") s1 = Student("小明", 18) s1.introduce()class关键字用来定义类,__init__是一个特殊方法,它在创建实例的时候自动调用。名字、年龄这些实例的“个性”数据,都通过参数传入,然后赋值给 self。这里的self到底是什么?你可以理解成“这个实例自己”。当你调用s1.introduce()时,Python 会自动把 s1 作为第一个参数传给 introduce,所以self.name就是“小明这个实例的名字”。所有的实例方法,第一个参数都必须是 self,这是 Python 内部约定,也是新手最容易忘的。
3.2 类变量、实例变量和 self 的“坑”
在类里面,变量有两种常见的存放位置。一种是在__init__里通过 self.xxx = yyy 赋值的,叫实例变量,它的特点是每个实例都有自己的副本。另一种是直接在类底部定义的,叫类变量,它的特点是所有实例共享同一个数据。
class Student: school = "第一中学" # 类变量 def __init__(self, name): self.name = name # 实例变量 s1 = Student("小明") s2 = Student("小红") print(s1.school) # 第一中学 print(Student.school) # 第一中学这里有一个隐藏很深的坑:如果你写s1.school = "第二中学",它并不会修改类变量 school,而是给 s1 这个实例创建了一个新的实例变量 school,以后访问 s1.school 得到的是新值,访问 s2.school 和 Student.school 仍然还是老值。这种“看似修改共享数据实则创建了局部副本”的行为,在代码初学阶段偶尔会让人困惑,但只要记住访问顺序就能理清:访问一个属性时,Python 会先找实例变量,找不到再找类变量,再找不到就报 AttributeError。
3.3 继承、多态,初级阶段掌握到什么程度
关于继承,初级学习者最容易有两种极端。一种是完全不知道继承是干嘛的,看到父类、子类就发怵;另一种是刚学完就到处滥用继承,把所有类都套上一堆父子关系。我的建议是,初级阶段的继承,你只要掌握一个最典型的场景就够了:子类想复用父类的代码,并且可以在父类基础上扩展自己的行为。
class Animal: def __init__(self, name): self.name = name def speak(self): print("...") class Dog(Animal): def speak(self): print(f"{self.name} 汪汪叫") class Cat(Animal): def speak(self): print(f"{self.name} 喵喵叫") d = Dog("旺财") c = Cat("咪咪") d.speak() c.speak()子类 Dog 和 Cat 继承父类 Animal 的__init__,然后各自重写了speak方法。这就是多态的一个雏形:同样一个 speak 接口,不同的对象有不同的实现。
对于初级阶段的写代码,我觉得掌握到这里就够了。更加深入的封装原则、多继承、抽象基类、魔法方法这些东西,等你在真实项目里遇到抽象需求,再回头学效率更高。不要一上来就整花活,类本身解决的核心问题是“让数据和操作内聚”,这个观念比语法细节重要得多。
4. 标准库与第三方库:Python 的“能力弹药库”
4.1 先把这五个内置库吃透,日常效率翻倍
Python 的特点之一就是“自带电池”,解释器安装后就已经有一大批标准库可以使用。其中有几个是初级学习者应该优先掌握的,几乎在任何一个脚本里都用得上。
第一个是os,它负责操作系统层面的交互。我最常用的是路径处理,比如os.path.join用来拼接路径,它比直接写字符串拼接更安全,因为它会自动处理不同系统间的路径分隔符。还有os.getcwd()获取当前工作目录,os.path.exists()判断文件是否存在,os.makedirs()递归创建目录。
第二个是json,做数据存储和网络接口交互时躲不开。json.dumps()能把 Python 字典转成 JSON 字符串,json.loads()能把 JSON 字符串转回 Python 字典。很多小项目不搞数据库,直接就用一个 JSON 文件当配置文件,存、取、改都非常方便。
第三个是datetime,专门处理时间和日期。最常用的是datetime.datetime.now()获取当前时间,以及datetime.timedelta做时间运算。比如要生成昨天的时间戳,datetime.datetime.now() - datetime.timedelta(days=1)一行搞定。
第四个是random,生成随机数。random.randint(a, b)生成 a 到 b 之间的随机整数,random.choice(sequence)从序列里随机选一个元素。抽奖、生成验证码、随机打乱列表时都用得上。
第五个是collections,它里面有几个非常好用的内置容器。比如Counter可以一行统计一个列表里每个元素出现的次数,defaultdict可以在访问不存在的键时自动创建一个默认值,省去一堆 if 判断。这些容器读起来很不像“标准库”,一开始学容易漏掉,但它们确实能减少大量样板代码。
4.2 安装第三方库时最常遇见的三个问题
第三方库是 Python 生态的精华,但也是新手最容易卡住的地方。结合我见过的大量求助帖,安装第三方库主要就三类问题。
第一类是网络超时,尤其是在国内直接装包,频繁报 timed out。解决办法就是用刚才提到的镜像源,如果清华源还是不够快,可以换阿里云镜像https://mirrors.aliyun.com/pypi/simple/,或者设置更长的超时时间:
pip install numpy --timeout=120第二类是版本兼容问题。有时候你明明已经安装了包,但代码里 import 报错,一种常见原因是装到的版本和你 Python 版本不匹配。比如有些老包只支持到 Python 3.8,在 3.12 上装出来后 import 直接报 DLL load failed。解决办法是在官方文档或包的元数据里找它支持的 Python 版本范围,然后安装一个兼容的版本。这类问题还有个变种:系统里同时装了 Python 3.7 和 Python 3.11,pip 装到了旧版本里,新的 Python 里怎么都导入不到。排查方法就是看当前 Python 和 pip 是否对得上,直接用python -m pip install 包名来保证装进当前 Python 环境。
第三类是只在某个项目里需要这个库,但你又不想污染全局环境。这时候就该回过头用虚拟环境了,在虚拟环境里单独装,哪怕装坏了,直接删掉 venv 文件夹重建一个就行,完全不伤全局。
4.3 初级之后的“方向预览”
到这一步,很多读者应该已经能写出结构清晰、带异常处理和简单类封装的项目了。这时候会有一种“想干点大项目”的冲动。我建议可以往下面几个方向试一试,都是新手接触真实项目的经典入口:
- 爬虫方向:学会用
requests发起 HTTP 请求,用BeautifulSoup或lxml解析网页结构,抓取静态页面数据。初级先别碰反爬太厉害的网站,拿新闻网站练手就够了。 - 数据分析与可视化方向:用
pandas处理表格数据,用matplotlib画折线图、柱状图。这个方向非常容易出成就感,数据一进一出一张漂亮的图就出来了。 - 自动化办公方向:用
openpyxl读写 Excel 文件,用python-docx操作 Word 文档,批量处理报表、合同是很多办公场景的刚需。 - 简单 Web 方向:用
Flask或FastAPI写一个本地网页应用,把脚本能力包装成带接口的服务。
这些方向我都建议在自己机器上立刻动手试一个,Python 这东西光看是不行的,动手跑一个小项目,比看十篇教程都有用。
5. 初级时期最容易踩中的几颗“雷”
5.1 可变对象当默认参数,函数越调越“失忆”
这个坑几乎每个 Python 开发者都踩过,因为它不报错,只在逻辑上悄悄出错,是最阴险的一类。
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2],咦,第二次调用怎么把第一次的结果也带上了?问题出在 Python 的函数默认值只会在函数定义时计算一次,之后每次调用都复用同一个列表对象。所以第一次调用 append 进去的内容,第二次调用依然存在。解决办法是默认值一律用None,在函数内部再初始化为空列表:
def add_item(item, items=None): if items is None: items = [] items.append(item) return items这个坑给我们的启示是:在 Python 里,所有可变数据类型(列表、字典、集合)都不适合做函数默认参数。如果你在写代码时遇到默认参数是一个可变容器,心里就要拉响警报。
5.2 is 和 ==,别傻傻分不清
is判断的是“两个变量是否指向同一个对象”,==判断的是“两个对象的值是否相等”。这两个概念初学者特别容易混。看这一段:
a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True,值相等 print(a is b) # False,是两个不同的列表对象那两个变量指向同一个对象的情况是存在的,比如c = a,这时c is a就是 True,因为它们引用同一个列表。问题来了,如果你误用 is 去比较两个值相等的字符串或列表,结果很可能不如预期。尤其是小整数和短字符串,Python 解释器为了性能会做一些缓存,比如:
x = 256 y = 256 print(x is y) # True,因为小整数被缓存了 x = 257 y = 257 print(x is y) # False,超出了缓存范围这种不一致的结果太容易误导人了。我自己的规范是:判断值相等一律用==,判断“是否为 None”用is,判断“是否为同一个对象”也是用is。除此之外,别用 is 去比较普通值。
5.3 类型转换的边界,比想象中更严格
热搜里一直有人搜 Python 类型转换,说明这是高频需求。基础用法很简单:int()、float()、str()、list()、tuple()。但实际上转换并不是万能的,它有非常严格的边界条件。
int("123")能得到 123,但int("123.45")会抛 ValueError,int("12a")也会抛 ValueError。float("3.14")能得到 3.14,但float("abc")一样会报错。所以写转换代码的时候,最好先做验证,或者放在 try/except 里捕获异常:
def safe_int(value, default=0): try: return int(value) except (ValueError, TypeError): return default另一个典型场景是字符串和列表的互转。list("hello")得到['h', 'e', 'l', 'l', 'o'],把这个列表用''.join(lst)可以变回原来的字符串,但很多人会记混 split 和 join 的参数,split 接收的是分隔符字符串,join 接收的是可迭代对象。写多了之后你自然就记住了,但一开始建议多敲几遍,把问题暴露在练习阶段。
5.4 深浅拷贝:看似复制了,其实只是“贴了个标签”
列表的复制存在一个非常隐蔽的问题:直接赋值并不复制,只是让多个变量引用同一个列表。
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4],a 也被改了这其实不是 bug,而是 Python 的引用语义。如果你真想复制一份独立的数据,可以用a.copy()或者a[:]。但这里还藏着一个更深的坑:浅拷贝。看这段:
a = [1, 2, [3, 4]] b = a.copy() b[2][0] = 99 print(a) # [1, 2, [99, 4]]a.copy()做的是浅拷贝,意思是它复制了外层列表,但内层那个[3, 4]列表仍然和 a 共享同一个对象。所以要复制包含嵌套容器的数据,必须使用深拷贝:
import copy a = [1, 2, [3, 4]] b = copy.deepcopy(a) b[2][0] = 99 print(a) # [1, 2, [3, 4]],a 保持不变我自己在写代码时的经验是:如果数据结构是扁平的一层列表/字典,用浅拷贝没问题;一旦嵌套层数超过一层,直接上 deepcopy,别在“我这种情况会不会受影响”上纠结,省下的思考时间远比多花的那点性能值钱。
5.5 别再用 print 调试了,试试最简单有效的排查方式
写到最后,分享一个同样重要但很少被初级教程提到的习惯:别再用满屏幕的 print 来调试代码。print 当然不是不能用,但它有两个问题:一是你调试完后要手动删掉,删漏了就会污染输出结果;二是 print 太多的时候,根本分不清哪一条是哪个阶段打印出来的。
更靠谱的替代方案,是使用 Python 内置的logging模块,它可以用日志级别区分信息、警告和错误,还能方便地输出到文件里:
import logging logging.basicConfig(level=logging.DEBUG) logging.debug("调试信息,用来追踪变量") logging.info("普通提示") logging.warning("这里可能有潜在问题") logging.error("程序出错了")你会发现,用日志级别代替 print 之后,代码的可读性反而提高了:正常运行时只显示必要的信息,排查问题时再把日志级别调到 DEBUG,就能看到所有细节,调完也不用大面积删代码,改一个配置就行。
说实话,这个系列写到第三篇,“初级”这个标签已经快要装不下你了。模块化让代码不再是一团乱麻,异常处理让程序即使出错也是可控的,类与对象让你能用更抽象的眼光看待数据和行为,标准库与第三方库更是一扇通往真实世界的大门。下一步怎么走?回到你手头正在折腾的那个项目,把这篇提到的东西用起来:拆模块、补异常、试着写个类,让今天的代码比昨天的更好一点,就够了。