文件、函数与装饰器:写出可复用的 Python 代码
2026/7/25 14:18:49 网站建设 项目流程

文件、函数与装饰器:写出可复用的 Python 代码

一、背景引入

在《Python 实战》入门篇里,我们写下的脚本大多是「一次性」的:读一个写死的文件、跑一遍就结束。但真正在生产环境里跑的代码,核心诉求从来不是「能跑」,而是「能复用、能维护、能交给别人改」。

我在带团队做内部工具时,见到过太多这样的代码:配置读取没有指定编码,换台机器就UnicodeDecodeError;函数默认参数写成def f(x, l=[]),结果多次调用之间悄悄串了数据;给函数加日志、加计时,直接把逻辑改得面目全非。这些问题的根,都指向三个 Python 里最朴素也最容易被轻视的概念——文件、函数、装饰器。

这一篇我不讲语法书上的定义,而是在真实云服务器上一行一行跑、抓真实回显,把那些「看书觉得懂、一写就出错」的地方全部复现出来。读完后你会发现,所谓「可复用的代码」,无非是把这些基础打牢之后的自然结果。

二、环境说明(真实回显)

本文所有代码都在华为云 Flexus X 实例(Ubuntu 24.04,Python 3.12.3)上真实执行。我先连上服务器、确认环境、建好实验目录/root/lab-b

$uname-a;echo---;python3 --version;echo---;mkdir-p/root/lab-b&&echolab-b-ready Linux ecs-3d3b-00046.8.0-106-generic#106-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 6 07:58:08 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux--- Python3.12.3 --- lab-b-ready

可以看到内核是 6.8.0-106,Python 3.12.3,实验目录已就绪。下面每一节都会给出「目的 → 代码 → 真实回显 → 讲解」四段式结构,代码与回显均来自服务器真实输出,未做任何编造。

三、分节实操

3.1 文件操作:open 模式、encoding 乱码、with 上下文、合并实战

3.1.1 open 的常用模式对比

很多人分不清r / w / a / r+的差异,尤其是w清空原文件这一点,踩过一次的都印象深刻。

# b3_file_modes.py(节选)withopen("modes_demo.txt","w",encoding="utf-8")asf:f.write("第一行\n第二行\n")print("== 1) r 只读模式 ==")withopen("modes_demo.txt","r",encoding="utf-8")asf:print(repr(f.read()))print("== 3) w 写模式会清空原内容 ==")withopen("modes_demo.txt","w",encoding="utf-8")asf:f.write("被 w 覆盖后的唯一一行\n")

真实回显:

== 1) r 只读模式 == '第一行\n第二行\n' == 2) a 追加模式(文件末尾追加)== 第一行 第二行 第三行(追加) == 3) w 写模式会清空原内容 == 被 w 覆盖后的唯一一行 == 4) r+ 读写模式(不截断)== 读到的: '被 w 覆盖后的唯一一行\n' 再次读: 被 w 覆盖后的唯一一行 追加在指针末尾 == 5) 读取不存在文件 == FileNotFoundError : [Errno 2] No such file or directory: 'not_exist.txt' == 6) 当前目录文件 == ['b3_file_modes.py', 'modes_demo.txt']

讲解:r只读、a追加到末尾、w覆盖清空、r+读写且不截断(注意读完指针在末尾,再write是追加而非从头)。读不存在的文件会抛FileNotFoundError,这正是我们做健壮 IO 时要用try/except兜住的那类错误。

3.1.2 encoding 不一致导致乱码 / UnicodeDecodeError 复现

这是最经典的「在我机器上好好的」问题。我故意用GBK写一个含中文的文件,再用默认的UTF-8去读,复现乱码异常:

# b3_encoding.py(节选)text="你好,世界!中文编码测试"withopen("gbk_file.txt","w",encoding="gbk")asf:f.write(text)print("已用 GBK 写入,磁盘字节预览:",open("gbk_file.txt","rb").read()[:20],"...")try:withopen("gbk_file.txt","r",encoding="utf-8")asf:content=f.read()print("读到了:",content)exceptUnicodeDecodeErrorase:print("捕获到异常:",type(e).__name__)print("异常信息:",e)

真实回显:

== 1) 用 GBK 编码写入中文 == 已用 GBK 写入,磁盘字节预览: b'\xc4\xe3\xba\xc3\xa3\xac\xca\xc0\xbd\xe7\xa3\xa1\xd6\xd0\xce\xc4\xb1\xe0\xc2\xeb' ... == 2) 用默认的 UTF-8 去读这个 GBK 文件 == 捕获到异常: UnicodeDecodeError 异常信息: 'utf-8' codec can't decode byte 0xc4 in position 0: invalid continuation byte == 3) 正确做法:按写入时的编码读取 == 正确读取: 你好,世界!中文编码测试 == 4) 用 errors 容错读取(替换策略)== errors=replace 结果: '��ã����磡���ı������'

讲解:第 29 行b'\xc4\xe3...'是「你好,世界」的 GBK 字节;用 UTF-8 解码时,首字节0xc4不是合法的 UTF-8 续接字节,于是抛出UnicodeDecodeError,并精确指出出错位置(position 0)。结论:写文件时用什么encoding,读就必须用同一个。若实在无法预知编码,可加errors="replace"兜底(如第 39 行),但替换出来的��已是丢失的信息,只能算「不崩」而非「正确」。

3.1.3 with 上下文 + 实战:合并多份日志 / CSV

with的价值是「无论是否异常,文件句柄都会被关闭」。我制造 3 个日志分片,再合并;并演示 CSV 按列头去重合并:

# b3_with_merge.py(节选)withopen("logs/merged.log","w",encoding="utf-8")asout:forpathinsorted(glob.glob("logs/*.log")):withopen(path,"r",encoding="utf-8")assrc:out.write(f"# ----{os.path.basename(path)}----\n")out.write(src.read())

真实回显(节选):

== 4) 合并所有日志(保持 with 安全打开)== 合并后行数: 1 # ---- app.1.log ---- 2 2026-07-24 10:00:01 INFO 服务启动 3 2026-07-24 10:00:02 INFO 收到请求 4 # ---- app.2.log ---- 5 2026-07-24 10:01:00 WARN 慢查询 6 2026-07-24 10:01:30 INFO 处理完成 7 # ---- app.3.log ---- 8 2026-07-24 10:02:00 ERROR 连接超时 == 5) 合并多个 CSV(按列头去重)== 合并 CSV 结果: name,score Alice,90 Bob,85 Bob,85 Carol,92

讲解:嵌套with让每个源文件读完后立即关闭,目标文件最后才关,安全且清晰。CSV 合并里我只对「列头」做了去重(header_seen哨兵),所以数据行中的Bob,85在两份里各出现一次就都保留了——这正好提醒你:去重是有业务语义的,先想清楚是去重表头还是去重主键,再写代码。

3.2 函数:参数形态与可变默认参数的坑

3.2.1 位置 / 关键字 / 默认 / 可变参数
# b3_func_args.py(节选)defgreet(name,age):returnf"{name}今年{age}岁"print(greet("张三",18))# 位置print(greet(age=20,name="李四"))# 关键字deftotal(*args):print("args 类型:",type(args),"内容:",args)returnsum(args)print("求和:",total(1,2,3,4,5))defshow_profile(**kwargs):print("kwargs 类型:",type(kwargs))show_profile(name="王五",city="北京",vip=True)

真实回显:

== 1) 位置参数 vs 关键字参数 == 张三 今年 18 岁 李四 今年 20 岁 == 3) *args 收集位置参数为元组 == args 类型: <class 'tuple'> 内容: (1, 2, 3, 4, 5) 求和: 15 == 4) **kwargs 收集关键字参数为字典 == kwargs 类型: <class 'dict'> name = 王五 city = 北京 vip = True == 6) 仅限关键字参数(* 之后)== 6 报错: f() takes 2 positional arguments but 3 were given == 7) 返回多个值(本质是元组)== 商: 3 余: 2 返回值类型: <class 'tuple'>

讲解:*args把多余位置参数收成元组,**kwargs把关键字参数收成字典。*之后的参数(def f(a,b,*,c))只能用关键字传入,强制调用方写清c=3,可读性更好。所谓「返回多个值」,底层就是一个元组,所以q, r = divmod2(...)本质是一次元组解包。

3.2.2 可变默认参数的经典坑(真实复现)

这是我见过最高频的面试题兼生产 bug。先看错误写法:

# b3_mutable_default.pydefadd_item(item,bag=[]):bag.append(item)returnbagprint("第一次调用 add_item('苹果'):",add_item("苹果"))print("第二次调用 add_item('香蕉'):",add_item("香蕉"))print("第三次调用 add_item('樱桃'):",add_item("樱桃"))print("默认列表的 id(始终相同):",id(add_item.__defaults__[0]))

真实回显:

== 1) 复现坑:默认列表被多次调用共享 == 第一次调用 add_item('苹果'): ['苹果'] 第二次调用 add_item('香蕉'): ['苹果', '香蕉'] 第三次调用 add_item('樱桃'): ['苹果', '香蕉', '樱桃'] 问题:每次调用都没有'清空',因为共用同一个默认列表对象 默认列表的 id(始终相同): 127184020361792

为什么?默认参数bag=[]在函数定义时就被创建并绑定,之后每次调用如果没有传bag,用的都是同一个列表对象(id 始终相同)。正确写法是用None作哨兵:

defadd_item_ok(item,bag=None):ifbagisNone:bag=[]bag.append(item)returnbag

真实回显(正确版):

== 2) 正确写法:用 None 作哨兵 == 第一次: ['苹果'] 第二次: ['香蕉'] 第三次: ['樱桃']

3.3 高阶函数:map / filter / sorted(key) / lambda / 函数对象

高阶函数就是「接收或返回函数」的函数。它们把「做什么」和「怎么做」拆开,是函数式复用的起点。

# b3_higher_order.py(节选)students=[{"name":"Alice","score":90},{"name":"Bob","score":75},{"name":"Carol","score":99}]by_score=sorted(students,key=lambdas:s["score"],reverse=True)defmake_adder(n):defadder(x):returnx+nreturnadder# 注意:没有括号,返回的是函数对象add5=make_adder(5)print("add5(10) 调用结果:",add5(10))

真实回显:

== 1) map:把函数作用到每个元素 == 平方: [1, 4, 9, 16, 25] == 3) sorted + key:按自定义规则排序 == 按分数降序: Carol 99 Alice 90 Bob 75 == 5) 函数对象 vs 函数调用(一个括号的差别)== add5 的类型: <class 'function'> add5(10) 调用结果: 15 add5 本身(未调用): <function make_adder.<locals>.adder at 0x7dda7d7a9300> 对比误把调用当对象: make_adder(5) 返回的是函数,不是数字 -> 调用 make_adder(5)(10) = 15

讲解:lambda只是匿名函数,和def完全等价(第 4 节两者输出一致可证)。sorted(key=...)把排序规则抽象成一个函数,非常解耦。make_adder返回的是「函数对象」而非结果,add5因此成了一个「参数被冻结」的专用函数——这是后面装饰器的核心思想:函数可以像数据一样被传递和返回

3.4 装饰器:从函数嵌套推导出计时器、带参装饰器、functools.wraps

装饰器本质就是「接收一个函数、返回一个函数」的高阶函数。我先用函数嵌套引出,再逐步加料。

# b3_decorator.py(节选,初级版未用 wraps)deftimer(func):defwrapper(*args,**kwargs):t0=time.perf_counter()result=func(*args,**kwargs)print(f"[计时]{func.__name__}耗时{time.perf_counter()-t0:.6f}s")returnresultreturnwrapper@timerdefslow_add(a,b):time.sleep(0.2)returna+b

真实回显(含 wraps 前后对比):

== 2) 计时装饰器 @timer(初级版,未用 wraps)== [计时] slow_add 耗时 0.200078s slow_add(3,4) = 7 没有 wraps 时,slow_add.__name__ = wrapper (原函数名被覆盖) == 3) 用 functools.wraps 保留元信息 == [计时] fast_mul 耗时 0.100080s fast_mul(3,4) = 12 有 wraps 时,fast_mul.__name__ = fast_mul fast_mul.__doc__ = None == 4) 带参数的装饰器(三层嵌套)== greet('小明') = ['你好, 小明!', '你好, 小明!', '你好, 小明!'] == 5) 装饰器叠加顺序(从下往上包裹)== [计时] hello 耗时 0.000002s hello() = ['Hi', 'Hi']

讲解:@timer等价于slow_add = timer(slow_add)wrapper包裹了原函数,所以原函数名__name__被覆盖成了wrapper——这会让日志、调试栈变得难读。用@functools.wraps(func)装饰wrapper即可把原函数的元信息(名字、文档)拷贝回来(第 165 行恢复到fast_mul)。带参装饰器如@repeat(times=3)是多包一层:最外层收参数,中间层收函数,最内层才是真正的wrapper。多个装饰器叠加时,执行顺序从下往上包裹(离函数最近的先包)。

3.5 实战:电商购物车(函数式 + 装饰器实现满减)

把前面所有概念串起来:购物车用字典表示,加购是普通函数,结算用装饰器叠加「满减优惠」,可任意组合。

# b3_cart.py(节选)defcalc_subtotal():returnsum(price*qtyforprice,qtyincart.values())deffull_reduction(threshold,minus):defdecorator(func):@functools.wraps(func)defwrapper(*args,**kwargs):subtotal=calc_subtotal()discount=minusifsubtotal>=thresholdelse0print(f"优惠规则:满{threshold}{minus}-> 本次优惠{discount}")returnfunc(*args,**kwargs)-discountreturnwrapperreturndecorator@full_reduction(threshold=200,minus=30)defcheckout():returncalc_subtotal()

真实回显:

== 购物车结算演示 == 加购: 机械键盘 x1 @ 199 加购: 鼠标 x1 @ 89 小计: 288 优惠规则:满 200 减 30 -> 本次优惠 30 最终应付(满200减30): 258 == 换一个不满 200 的购物车 == 加购: 数据线 x2 @ 25 加购: 贴膜 x1 @ 15 小计: 65 优惠规则:满 200 减 30 -> 本次优惠 0 走满200减30: 65 优惠规则:满 100 减 10 -> 本次优惠 0 走满100减10: 65 == 新增 VIP 95 折装饰器叠加 == 加购: 显示器 x1 @ 999 小计: 999 应用 VIP 95 折 优惠规则:满 200 减 30 -> 本次优惠 30 VIP 最终应付: 920.55

讲解:满 288 触发「满 200 减 30」→ 实付 258;而不满 200 时优惠为 0,分文不减。VIP 场景把vip_discountfull_reduction叠加(注意从下往上:@full_reduction先算减 30 得 969,再@vip_discount乘 0.95 ≈ 920.55)。优惠逻辑与结算逻辑彻底解耦——要加新活动,只需再写一个装饰器,无需改动checkout本体,这就是可复用性的直接收益。

四、踩坑清单

序号现象正确做法
1w模式覆盖文件原内容被清空且无提示确认要覆盖才用w;追加用a
2读写编码不一致UnicodeDecodeError: byte 0xc4 ...写读用同一encoding;优先 UTF-8
3忘记关文件句柄泄漏、写入可能未落盘一律用with open(...) as f
4可变默认参数l=[]多次调用共享同一对象,数据串号None作哨兵,函数内再初始化
5add5add5()拿到函数对象而非结果区分「函数对象」与「函数调用」一个括号
6装饰器没加functools.wraps原函数__name__/__doc__丢失@functools.wraps(func)装饰 wrapper
7装饰器叠加顺序误解优惠计算顺序与预期相反记住「从下往上包裹」,先写的后执行

五、总结

这一篇我们从一个朴素目标出发——写出可复用的 Python 代码,然后逐层落地:

  • 文件r/w/a/r+的差异、encoding必须读写一致、用with兜底资源释放,以及把多文件合并写成可维护的脚本;
  • 函数:四种参数形态、多返回值本质是元组、以及那个几乎必踩的「可变默认参数」坑;
  • 高阶函数map/filter/sorted(key)lambda把行为参数化,并理解「函数也是对象」;
  • 装饰器:从函数嵌套推导出@timer,认识functools.wraps的必要性、带参装饰器的三层结构,并在购物车实战中用装饰器把「优惠规则」与「结算」彻底解耦。

你会发现,所谓「可复用」,并不是什么高深技巧,而是把每一次副作用(IO、状态)都关进清晰的边界里,把每一个变化点(参数、规则)都抽象成可传递的函数。下一篇我们会进入面向对象与异常处理,把「状态」这件事交给类来管理。


本文实验均在华为云 Flexus X 实例(Ubuntu 24.04, Python 3.12.3)上真实执行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询