能把Python学到第三阶段的人,我觉得已经算过了第一道坎。这道坎筛掉的是那些"收藏了100个教程、一个都没看完"的人,留下的是真的会在每天下班后打开编辑器、跟缩进搏斗、被报错刷屏还想继续的人。
这一篇笔记覆盖的内容,大致对应百天计划里第30天到第60天的区间——正是从"会写脚本"到"能写程序"的分水岭。前30天你学会了语法、流程控制、函数调用,能写一些几十行的玩具代码;后30天你需要面对的是数据结构的选择、异常处理、模块组织、以及初步的面向对象设计。换句话说,前30天学的是"怎么让代码跑起来",这个阶段学的是"怎么让代码不容易跑挂、别人能看懂、自己能维护"。
我先摆一个观点:很多人在这个阶段放弃,不是因为内容变难了,而是因为不知道学这些到底有什么用。`list`和`tuple`的区别背了十遍,转头写爬虫还是全用`list`;装饰器看了三天教程,自己写代码的时候根本想不到哪里能用。如果你也处在这个状态,这篇笔记能帮你把这些知识点跟真实场景串起来。
1. 从"会写"到"会选":数据结构才是性能的分水岭
很多初学者有个错觉,觉得数据结构是面试才会考的东西,平时写代码"能跑就行"。但当你开始处理稍微真实一点的数据——比如从接口拉回10万条记录去重、在两层循环里频繁查找某个值——跑得慢和跑得快之间的差距,就不是一星半点了。
1.1 列表、元组、集合、字典:用对场景比死记特性重要
先看一个最典型的例子:去重。新手第一反应通常是:
unique_items = [] for item in raw_items: if item not in unique_items: unique_items.append(item)功能没错,但如果`raw_items`有10万条,这个循环的时间复杂度是O(n²)。我在自己机器上跑过,处理10万条整数用了将近40秒。同样的事用`set`做:
unique_items = list(set(raw_items))10毫秒以内搞定。差距接近4000倍。这不是夸张,是真实跑出来的数据。为什么会差这么多?因为列表的`in`判断是逐个遍历,而集合底层是哈希表,查找的平均时间复杂度是O(1)。
再比如需要频繁按下标访问元素,用`list`;需要按唯一键快速找到对应值,用`dict`;需要维护一个只读数据避免被意外修改,用`tuple`;需要判断元素是否存在但不需要顺序,用`set`。选型逻辑就这么简单——不是看哪个"高级",是看你的数据访问模式是什么。
1.2 千万别忽略的哈希代价与内存取舍
不过得说句公道话,哈希结构也不是没有代价。字典和集合的内存占用比列表大得多,因为它们要额外存储哈希值、维护哈希表的空闲槽位。如果你的数据量小、访问次数少,列表完全够用,用字典反而是浪费。
还有一个隐藏的坑:不可哈希类型。初学者把`list`塞进`set`或者当字典的键,直接报`TypeError: unhashable type: 'list'`。我当时一直没搞懂为什么列表不能当键,后来用了一个很土的类比才明白:哈希表每次查找都要拿键去算一个固定地址,这就要求这个键在存进去之后永远不能变;而列表是可变的,你把它存进去之后随时可以改里面的元素,一变哈希值就对不上了,当然不能当键。所以只有不可变类型(`str`、`int`、`tuple`)才能当字典的键。
1.3 推导式可以写,但别写出"一行天书"
这个阶段还有一个绕不开的宠儿:生成器表达式和推导式。我见过不少人被所谓"Pythonic"洗脑,非要把所有循环都压缩成一行:
result = {k: v for k, v in raw.items() if v is not None and v > 0}这行代码其实还行,因为逻辑还算短。但有些同学会把三重循环加条件全塞进一个列表推导式,写完自己第二天都看不懂,那就本末倒置了。我的建议是:推导式最多两层循环加一个条件,超过这个复杂度就老实写普通循环。可读性是代码的第一属性,这个阶段比"显得很厉害"重要得多。
另外一个值得养成的习惯是优先用生成器而不是列表推导式处理大数据——生成器是惰性的,不会一次性把所有结果都存在内存里。处理几百万行日志的时候,用生成器表达式配合`sum()`、`any()`这类函数,内存占用天差地别。
2. 函数进阶:作用域、闭包、装饰器,从"会调用"到"能设计"
函数是Python里最核心的抽象工具,但大部分初学者把函数当成"把重复代码包起来"的容器。到了这个阶段,你需要换一个视角:函数是程序里最小的设计单元,怎么定义参数、怎么隔离变量、怎么复用行为,直接决定了一个程序的骨架是否清晰。
2.1 作用域规则:为什么你的全局变量在函数里不生效
被作用域坑过的人应该不在少数。最经典的场景:
count = 0 def increment(): count += 1一运行就报`UnboundLocalError`。新手的第一反应是"Python是不是有毛病"。其实规则很简单:函数内部对变量赋值时,Python默认把它当作局部变量,所以`count += 1`里的`count`被认为是局部变量,但还没赋值就拿来用了,自然报错。
解决办法是明确声明`global count`,或者更好一点——别用全局变量,把`count`作为参数传入、返回值带出:
def increment(count): return count + 1你可能会觉得这有什么大不了的,但全局变量一旦多起来,函数的副作用就会失控。今天你在这个函数里改一下全局状态,明天另一个函数读到的就是被你改过的值,排查起来异常痛苦。规则很简单:能用参数传递就用参数传递,全局变量越少越好。
2.2 闭包不神秘:就是"带着记忆的函数"
闭包这个概念,很多教材讲得云里雾里。我理解闭包的方式很简单:它就是一个函数,外加它定义时所在作用域的记忆。内层函数引用了外层函数的变量,而这个变量不会因为外层函数执行完就消失,因为它被内层函数"捕获"了。
最常见的应用是计数器:
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c1 = make_counter() c2 = make_counter() print(c1()) # 1 print(c1()) # 2 print(c2()) # 1注意这里的`nonlocal`关键字,它声明`count`不是内层函数的局部变量,而是来自外层作用域。如果少了这一行,又会遇到跟全局变量类似的报错。`c1`和`c2`是互不干扰的,因为每次调用`make_counter()`都会创建一个新的`count`变量,这个变量分别被各自的`counter`函数记住。
闭包真正的价值在于:你可以用它把"状态"和"行为"绑定在一起,而不需要定义一个类。在处理回调函数、事件处理器这类场景时,闭包写起来比类更轻巧。
2.3 装饰器:不改变函数代码,却给函数"加buff"
学装饰器之前我建议你先想明白一件事:Python里的函数是一等公民,也就是说函数能像变量一样被传递、被返回、被赋值。装饰器本质就是一个接收函数作为参数、返回一个新函数的函数。
最简单的装饰器:
def log_calls(func): def wrapper(*args, **kwargs): print(f"calling {func.__name__}") return func(*args, **kwargs) return wrapper @log_calls def add(a, b): return a + b`@log_calls`语法糖等价于`add = log_calls(add)`。每次调用`add`,实际上先经过`wrapper`,它会先打印日志再执行原函数。
装饰器的实用场景太多了:日志记录、性能计时、权限校验、输入参数校验、缓存结果。我自己的习惯是在写内部工具脚本时,至少备一个通用的计时装饰器,把每个关键函数的耗时打出来,优化的时候一眼就能看出瓶颈在哪。
不过有两个细节容易踩坑:一是装饰器会改变函数的元信息(`name`、`doc`),需要加`functools.wraps`来保留;二是装饰器本身就是函数,如果你需要在装饰器里传参数(比如指定日志级别),需要再套一层。这些当前阶段能理解原理就行,用到的时候再深挖。
2.4 参数设计的顺序与默认值陷阱
函数参数这块有个特别经典的坑:默认值如果是可变对象,它会跨调用共享。
def append_item(item, items=[]): items.append(item) return items第一次调用`append_item(1)`返回`[1]`,第二次调用`append_item(2)`返回`[1, 2]`——你没有传`items`,但上次调用留下的数据还在。原因是默认值在函数定义时就创建了,之后的每次不传参调用用的都是同一个列表。正确写法是:
def append_item(item, items=None): if items is None: items = [] items.append(item) return items这个坑我在实际项目里见过不止一次,而且都是写了很久代码的人犯的,因为他们下意识觉得"每次调用都是新的"。记住:默认值只会在定义时求值一次,永远不要在默认值里放可变对象。
3. 模块与包:把你的代码从"一个文件"变成"一个项目"
到了这个阶段,你的代码量开始上来了,越来越难塞进一个文件。学会组织代码,是"脚本作者"和"程序员"之间的一条隐形分界线。
3.1 模块化不是"把代码拆开",是为了让依赖关系清晰
最简单的理解:一个`.py`文件就是一个模块,一个带`init.py`的文件夹就是一个包。模块化有两个直接的好处:一是命名空间隔离,不同模块里可以有同名函数互不干扰;二是复用,写好的模块拷到另一个项目里就能用。
但我觉得更重要的是第三点:模块之间的依赖关系,就是程序的架构雏形。如果你发现模块A要import模块B,模块B又要import模块A,那你就要警惕了——这通常是设计出了问题,要么是有公共的东西应该抽出来放到C里,要么是两个模块的职责边界划分得不清楚。
3.2 import的搜索路径:为什么有时候会import到同名文件
新手常遇到一个诡异场景:自己明明改了某个模块的代码,但程序跑起来用的还是旧逻辑。排查了半天,发现自己项目的某个目录下有个`utils.py`,而`site-packages`里也有个很出名的`utils.py`(其实是某个第三方库的依赖),Python的import机制优先找到了本地的那个,结果版本错乱。
理解Python的搜索顺序能省很多事:先找内置模块,再找`sys.path`里的路径,而`sys.path`的第一条往往是当前脚本所在的目录。所以在项目里给模块起名时,尽量别跟标准库或知名第三方库重名,比如别叫`utils.py`、`common.py`这类烂大街的名字,否则总有一天会撞车。
3.3 必备技能:虚拟环境与依赖管理
我有很长一段时间不理解虚拟环境的必要性,直到有一次同时维护两个爬虫项目,一个用旧版`requests`,一个用了新版,结果新版项目里有些接口签名变了,旧项目的代码跑不起来了。从那之后我养成了习惯:每个项目建一个独立的虚拟环境,绝不共用。
推荐的工具按顺序是:`venv`(Python自带的,够用) → `pipenv`(管理依赖更省心) → `poetry`(依赖解析更严格)。新手建议从`venv`开始,手动操作一遍流程能帮你理解原理,后面再换工具也容易。
附上最基础的流程:
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install requests pip freeze > requirements.txt这个`requirements.txt`就是项目的依赖清单,换电脑或者部署的时候执行`pip install -r requirements.txt`,环境就能一键还原。另外建议把`venv/`目录加到`.gitignore`里,别把它提交到版本库。
3.4 从绝对导入到相对导入:包内模块互相引用的问题
当你开始使用包(`package`)组织项目时,会遇到一个很实际的问题:同一个包里的两个模块,要怎么互相引用?
# package/module_a.py from package import module_b # 绝对导入 # 或者 from . import module_b # 相对导入如果直接在项目根目录运行脚本,两种方式都行。但如果你把这个包当作一个模块来用(`python -m package.main`),相对导入可能会报`ImportError`。这个坑我踩了很久才搞明白:相对导入是相对于当前模块的`package`属性来解析的,如果你的文件被当作顶层脚本直接运行,`package`是空的,相对导入就会失败。
所以我的经验法则很简单:写库代码或者包内部引用,用相对导入,因为它能让包在项目里移动位置而不需要改代码;但入口文件(比如`main.py`)始终用绝对导入。
4. 面向对象:从"用类"到"设计类"的关键一步
Python不是强制面向对象的语言,你可以全程写函数式代码,但到了这个阶段,应该学会什么时候用面向对象来组织代码,以及怎么设计出不那么糟糕的类。
4.1 先别急着写类:面向对象是组织方式,不是目的
我见过不少初学者学完类就开始什么都往里塞——明明两三行函数就能解决的事,非要定义类。这其实走偏了。面向对象适合的场景有共通性:你需要把一组相关的数据(属性)和操作这些数据的方法绑定在一起,并且可能有多种不同的行为实现。
比如一个日志处理器:你要记录不同等级的消息、输出到不同目标(文件、控制台、远程服务)。每种输出方式本质上是"处理一条日志消息",但实现不同,这就是抽象基类加多个子类的经典场景。
反过来,如果只是两个函数共享一些全局配置,用函数加模块级常量就够了,强行用类反而增加了阅读负担。
4.2 属性和方法:公开、私有只是约定
Python没有真正的私有成员,以双下划线开头的属性只是被"名称改写"(name mangling)了,目的是防止子类意外覆盖,而不是防小人。
class Account: def __init__(self, balance): self.__balance = balance def get_balance(self): return self.__balance在类外部访问`account.__balance`会报`AttributeError`,但如果你真的想访问,`account._Account__balance`照样能取到。所以一定要理解:Python的私有性是"君子协定",不是真正的访问控制。规范的做法是:用单个下划线`_balance`表示"这是内部属性,外部调用者请勿直接访问",这完全是自觉;双下划线主要用来防止命名冲突,不要指望它做安全隔离。
4.3 继承与组合:别为了复用而继承
新手刚学继承时特别容易产生一个冲动:把两个类的公共方法抽到一个父类里,让它们继承。这个思路不完全错,但如果子类和父类之间不是"is-a"关系,强用继承会把代码搞得很拧巴。
我踩过的一个例子:有个`Pizza`类和一个`Order`类,它们都要计算总价、都要格式化输出,我当时图省事让`Order`继承了`Pizza`。虽然代码少写了几行,但后面需求一改(`Order`还要处理配送费,而`Pizza`不需要),继承体系马上就崩了。后来改成组合——`Order`类里持有`Pizza`的实例,需要`Pizza`的功能时调用它的方法——需求变更处理起来轻松太多。
一个比较稳妥的判断标准:如果子类需要重写父类的大部分方法才能满足需求,那大概率不应该继承,用组合更合适。
4.4 特殊方法与运算符重载:让自定义类型用起来像内置类型
`init`大家都熟,但Python类型系统的优雅之处在于各种特殊方法:`str`控制`print`时的表现,`len`支持`len()`,`getitem`支持`obj[key]`下标访问,`iter`让它能直接被for循环遍历。
写一个简单示例:
class Point: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): return Point(self.x + other.x, self.y + other.y) def __str__(self): return f"Point({self.x}, {self.y})"这样`p1 + p2`不再是报错,而是返回一个新的`Point`对象,`print(p1)`输出也清晰友好。核心价值是让自定义类型跟内置类型在用法上保持一致,调用方不用关心对象内部到底是什么结构。
不过要控制自己别过度设计:不是所有类都需要实现全套特殊方法,按实际需求实现最关键的一两个就行。
5. 异常处理:不是"try一下不报错",是"设计程序在异常下如何降级"
很多教材教异常处理就一句话:"用try-except包住可能出错的代码"。听起来很简单,但实践中只有真正经历过"静默吞异常→线上出事故→排查到崩溃"这个过程的人,才懂异常处理设计的重要性。
5.1 异常也是一个对象:捕获的本质是"类型匹配"
Python里的异常都是一个类的实例,捕获的本质是判断这个异常的类型。所以`except`的子句是从上到下匹配的,一旦匹配成功,后面的`except`不会再执行。
很多新手因此掉进一个坑:把`Exception`写在所有具体异常前面,结果后面的具体异常分支永远走不到。所以写异常处理的顺序必须是从具体到宽泛。另外,尽量不要用裸的`except:`捕获所有东西,因为你很难知道到底发生了什么错误。
我从实际维护中学到的经验是:捕获异常时用`as e`把异常对象保存下来,然后用`logging`记录下来。录日志这段非常重要——如果不记录下来,异常被吞掉之后你根本不知道程序里曾经发生过这件事,只能等用户来反馈问题,那就太被动了。
5.2 什么时候该捕获,什么时候该让它"爆炸"
这是我看了很多糟糕代码之后特别想强调的一点:不是所有异常都应该被捕获。如果某个异常表示程序的状态已经不可信(比如数据库连接失败、配置文件解析错误),那让程序立刻崩溃输出完整回溯,其实比"假装没看见继续往下跑"要好得多。继续跑下去的结果往往是产生了更诡异的数据损坏,到时候排查成本翻倍。
反过来,如果是外部输入导致的预期内异常(比如用户传入了一个格式不对的日期字符串),那就应该捕获并给一个友好的提示。判断标准就一条:这个异常之后,程序还能以正确状态继续执行吗?能,就捕获;不确定,就让它抛出去。
5.3 try-finally与with:资源管理的正确姿势
除了异常本身,`try-finally`和上下文管理器也是这个阶段必须掌握的。最常见的场景是文件操作:
try: f = open("data.txt", "r") data = f.read() finally: f.close()但是Python更推荐的做法是`with`:
with open("data.txt", "r") as f: data = f.read()`with`语句在代码块执行完(无论是否发生异常)都会自动调用`f.close()`,这本质上是因为文件对象实现了`enter`和`exit`两个特殊方法。以后你在处理锁、网络连接、临时目录时,优先思考:这个资源对象有没有实现上下文管理器?有就用`with`,没有就用`try-finally`保证释放。这是防止资源泄漏最有效的手段。
5.4 自定义异常:让你的错误信息能被调用方"分类处理"
当项目规模变大,你可能会遇到一种情况:业务代码里到处是`if`判断然后抛`ValueError`,但调用方根本分不清这个`ValueError`是"输入格式问题"还是"业务规则不允许"。这时候就该自定义异常了。
class InsufficientBalanceError(Exception): pass class Account: def withdraw(self, amount): if amount > self._balance: raise InsufficientBalanceError("余额不足")自定义异常的好处是:调用方可以只捕获你定义的异常类型,做对应的降级处理,而其他系统异常会正常冒泡。设计异常类本身不用写任何逻辑,`pass`就够了,它作为一个"标签"把错误的类别标识清楚。异常类的命名要用`Error`结尾,这是贯穿Python社区的习惯。
6. 文件与数据持久化:从读写文本到处理真实数据
到了百天计划的中段,你已经绕不开文件操作了——读配置、存日志、导出数据,所有程序最终都要跟磁盘打交道。这里面有很多看似简单、实际上很考验细节的地方。
6.1 编码问题是最大的隐性坑
我至今还记得第一次用Python读一个csv文件,里面中文全变成了乱码,折腾了一下午。原因很简单:文件写入时的编码和读取时的编码不一致。
现代Python的默认编码是`utf-8`,但在Windows上,默认编码可能是`gbk`(取决于系统区域设置)。所以读写文件时,强烈建议显式指定编码:
with open("data.txt", "r", encoding="utf-8") as f: content = f.read()如果还是乱码,可以用`codecs`模块或者直接尝试`gbk`、`utf-8-sig`(`utf-8-sig`会自动处理带BOM的文件)。处理完乱码问题后,我的建议是:项目中所有文件读写统一指定`encoding="utf-8"`,别让它跟随系统默认值走。
6.2 逐行读大文件,别一次性把所有内容塞进内存
新手读文件喜欢一步到位:
with open("big.log", "r", encoding="utf-8") as f: lines = f.readlines() # 一次性把整个文件读入内存文件小没问题,但一旦日志文件上GB,`readlines()`会直接把内存撑爆。正确姿势是迭代读取:
with open("big.log", "r", encoding="utf-8") as f: for line in f: process(line)因为文件对象本身就是可迭代的,内部做了缓冲,一次只读一行到内存。这个改动极其廉价,但能处理的数据规模完全不同。
6.3 结构化数据:JSON和CSV的正确使用方式
处理结构化数据是这个阶段必备的技能。JSON读写用内置`json`模块:
import json data = {"name": "张三", "scores": [90, 85, 92]} with open("data.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) with open("data.json", "r", encoding="utf-8") as f: loaded = json.load(f)注意两点:一是`ensure_ascii=False`,否则中文会被转成`\uXXXX`转义序列,文件里完全看不出原始内容;二是`indent=2`让输出可读,方便调试和人工检查。
CSV文件则强烈建议用`csv`模块而不是自己拼字符串——CSV的字段里如果包含逗号、换行、引号,自己拼字符串必炸。
import csv with open("data.csv", "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["name", "age", "city"]) writer.writerow(["李四", 25, "北京"]) with open("data.csv", "r", encoding="utf-8") as f: reader = csv.reader(f) for row in reader: print(row)`newline=""`这个参数是为了避免Windows下出现空行,不加的话写出来的CSV行与行之间会有多余换行,几乎每个从Windows入门的人都会遇到。
6.4 一个小工具:用pathlib替代os.path
如果你还在用`os.path.join`拼接路径,我建议试试`pathlib`。它的操作体验更接近人的直觉:
from pathlib import Path base_dir = Path("data") file_path = base_dir / "sub" / "a.txt" # 用/拼接路径 file_path.mkdir(parents=True, exist_ok=True)`/`运算符直接拼接路径,可读性比`os.path.join("data", "sub", "a.txt")`好不少。此外`Path`对象自带了读写文件的方法,`file_path.read_text(encoding="utf-8")`和`file_path.write_text(...)`,非常适合快速处理小型文本文件。
我在中大型项目里现在基本只用`pathlib`,`os.path`真的退居二线了。
7. 实战练兵:把前面所有的知识缝起来
只看不练永远学不会写程序。这一节我用一个贴近真实工作的小项目——统计服务器日志里的状态码分布——把前面讲的东西串起来过一遍。这个项目虽然小,信息密度却很高:会用到文件迭代、字典计数、异常处理、集合去重、排序输出,几乎覆盖了前面所有章节的知识点。
7.1 需求与设计:一份日志文件,一个统计脚本
假设我们的日志文件`access.log`里每一行是类似这样的内容:
2025-06-01 10:23:45 INFO 192.168.1.10 GET /api/users 200 32ms 2025-06-01 10:23:47 WARNING 192.168.1.11 GET /api/login 401 12ms目标:统计每个状态码出现的次数,按次数从高到低输出,同时找出出现过但出现次数少于10次的状态码(可能暗示偶发异常)。
设计思路很简单:读文件 → 按空格切分取状态码那一列 → 用字典计数 → 排序输出。但为了更贴近真实场景,我会刻意让代码"出错"——比如某行日志格式不完整,导致切分后取不到状态码。
7.2 一步步实现,并记录踩坑
先写第一版,在这个版本里我会故意留下一个隐患:假设所有行的格式都正常。
from collections import Counter status_counts = Counter() with open("access.log", "r", encoding="utf-8") as f: for line in f: parts = line.split() status = parts[4] # 第5列是状态码 status_counts[status] += 1 for status, count in status_counts.most_common(): print(f"{status}: {count}")`Counter`是字典的一个子类,专门用来计数,`most_common()`直接返回排序好的列表。单独看这段代码没有任何问题,但一旦日志中有异常行,比如时间格式变了、多了一个空格、某行是空行,`parts[4]`就会越界或者取错值。
真实环境里的日志不会像文档里那么规整,处理的时候一定要对不完整、格式异常的行有容忍能力——这就是异常处理知识的用武之地。我改成这样:
from collections import Counter import re status_counts = Counter() error_lines = 0 status_pattern = re.compile(r'"(\d{3})"') with open("access.log", "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: parts = line.split() status = parts[4] if not status.isdigit(): raise ValueError(f"非状态码字段: {status}") status_counts[status] += 1 except (IndexError, ValueError) as e: error_lines += 1 print(f"跳过异常行: {e} | 原文: {line[:80]}") print(f"\n状态码分布:") for status, count in status_counts.most_common(): print(f" {status}: {count}") print(f"\n异常行数量: {error_lines}")这里用`try-except`捕获了两种预期的异常:下标越界和字段非数字。这样即使日志中有脏数据,脚本也能跑完,同时把异常情况打印出来,方便后续定位数据源的问题。
7.3 只看次数不够,加上"耗时中位数"才算真正有用
统计完状态码,如果我们只知道"有500个500",不知道这些500的请求到底慢了还是快了,还是不完整。进一步完善:对于每个状态码,计算耗时那一列的中位数,看看到底是哪种状态码拖慢了整体响应。
from collections import defaultdict import statistics status_durations = defaultdict(list) with open("access.log", "r", encoding="utf-8") as f: for line in f: parts = line.split() if len(parts) < 6: continue status = parts[4] try: duration = int(parts[5].replace("ms", "")) except ValueError: continue status_durations[status].append(duration) print(f"状态码 | 请求次数 | 耗时中位数(ms)") print("------|----------|---------------") for status, durations in sorted(status_durations.items()): print(f"{status:>6} | {len(durations):>8} | {statistics.median(durations):>12}")这个版本用了`defaultdict`——它跟普通字典的区别是,访问不存在的键时不会报`KeyError`,而是自动创建一个默认值(这里是空列表)。在统计分组数据时非常省事,少写很多`if status not in dict: dict[status] = []`的样板代码。
这个脚本虽然只有三四十行,但写下来你会同时用到:`Counter`、`defaultdict`、`statistics.median`、异常处理、文件迭代、列表解析。比单纯背语法效率高太多了。
8. 阶段总结与下一步规划:学到这里,你已经可以开始"接小活"了
写到这,这一篇的核心内容已经全部过完。对我来说,这个阶段的本质变化不是"会写的语法更多了",而是"拿到需求之后,知道从哪开始设计了"。从前拿到需求第一反应是"这个功能能不能用Python实现",现在会先想"这个数据怎么组织、程序分几个模块、哪些地方可能出错需要加防御"。
8.1 如果只能记住五件事
第一,数据结构的选择要基于数据访问模式:查找频繁用`set`/`dict`,顺序访问用`list`,只读数据用`tuple`。第二,函数默认值永远不要用可变类型,这是无数线上事故的起点。第三,装饰器是"给函数加行为"的工具,但别为了用装饰器而用装饰器。第四,异常捕获要具体、要记录、要判断是否应该继续执行,吞异常等于埋雷。第五,`with`和上下文管理器是资源管理的正确姿势,永远优先于手动`close`。
这些规则在后面的Web开发、爬虫、自动化脚本里会一遍一遍地出现。
8.2 接下来的学习方向
百天计划后面的内容会走向两个大的方向:一是数据处理和爬虫(你会更频繁地用到requests、pandas这些第三方库),二是Web开发(你会接触到Flask或类似的框架,理解什么是路由、什么是请求-响应循环)。这两个方向都会要求你具备我们今天练的这些基本功:组织模块、处理异常、操作文件。
我的建议是:在进入下个阶段之前,先自己独立写一个稍微完整的小工具——比如一个命令行版的待办事项管理器,或者一个能够定时备份某个目录文件的脚本。别急着学框架,先把"用Python解决一个实际问题"的流程完整走一遍。这一步的收获,比再多看十篇教程都大。
8.3 最后说点实在的
很多人在这个阶段会产生一种错觉:感觉自己什么都学了,又什么都记不住。这太正常了。我写这个系列的笔记也是因为这个原因——记不住没关系,但你要知道"有这么个东西、它能解决什么问题",等到真的遇到了,翻一下笔记、查一下文档,马上就能重新捡起来。编程的学习从来不是线性的,更像是在脑袋里织一张网,第一次学是在拉线,第二次、第三次碰到是在把线打结,网越织越密,知识才真正变成你的。