Python 2 到 Python 3 迁移实战:评估、工具链到兼容层清理全攻略

发布时间:2026/9/8 6:46:26
Python 2 到 Python 3 迁移实战:评估、工具链到兼容层清理全攻略 2020 年 1 月 1 日Python 2 官方停止维护。对还在生产环境里跑着 Python 2 的团队来说这不是一个可以视而不见的消息而是一份进入倒计时的技术债清单。我当时接手的一个服务代码量大概十几万行混合着 Flask 接口、Celery 任务、一堆内部工具脚本以及更早同事留下的“能用就行”的模块。更麻烦的是代码里到处是print语句、iteritems()、unicode前缀还有各种依赖了 Python 2 隐式行为的逻辑。这篇文章不是翻译官方迁移文档也不是列一张“Python 2 和 Python 3 有什么区别”的新手对比表。它是从实际迁移项目里摸爬滚打出来的经验总结覆盖了我从评估、选型、自动化工具、手工修改到最终清理兼容层的完整过程。如果你正准备做同样的事或者只是想把一个老项目从 Python 2 的泥潭里拉出来这篇文章能帮你少走不少弯路。1. 迁移前先做对三件事盘点、评估与策略选择很多人拿到这类任务的第一反应是“直接跑一遍 2to3然后修报错”。这个想法不能说完全错误但风险极高。一个没有经过盘点和评估的迁移就像在不知道墙体结构的情况下拆承重墙表面看着没事等装修完才发现裂缝。Python 2 到 Python 3 的迁移表面上改的是语法实际上改的是代码对语言运行时行为的依赖。所以迁移之前我花了整整一周时间做三件事。1.1 代码盘点全量扫描与依赖梳理第一步是搞清楚自己到底有多少代码、有多少依赖。我用cloc统计了代码行数又用了pip list结合pip freeze输出了所有第三方包清单然后把代码里import的模块名全部提取出来和已安装的包列表做了一次差集校验。这一步能快速发现哪些模块是内部自研的、哪些是第三方库、哪些是标准库但版本有差异。这个环节最容易忽略的是“隐式依赖”——不是通过import引用的而是通过命令行调用的、通过动态加载的、或者通过配置文件指定的。我那次就发现一个调度系统直接调用了某个 Python 2 时代的脚本路径写死参数格式也是老式的这类东西 2to3 根本扫不到。建议把所有.py文件里出现的字符串常量也扫一遍搜一下是否包含python2、python2.7之类的路径引用。盘点完成后我整理了一张表格按模块分为三类核心业务代码、工具脚本、临时脚本。核心业务代码是迁移的主力工具脚本要看是否还有人用临时脚本基本可以直接弃用。这一步的价值在于有些“代码”根本不需要迁移删掉反而是最经济的方案。1.2 依赖可用性核查第三方库是否支持 Python 3第二个关键是第三方库的 Python 3 兼容性。Python 2 时代有大量库在 Python 3 初期完全没有替代品这也是很多人一直停留在 Python 2 的原因。我这次盘下来比较幸运的是绝大部分库都有了 Python 3 版本但有两个库的替换工作比想象中复杂得多。一个是最早使用的MySQLdb这个库在 Python 3 下没法直接用需要换成PyMySQL或者mysqlclient。从MySQLdb切到PyMySQLAPI 整体上是兼容的但连接参数、事务处理、异常类上都有细微差别。另一个是内部团队自己封装的一个消息队列客户端底层的 socket 处理用了大量str/bytes不一致的操作这个直接导致了迁移后期一连串的编码问题。核查依赖时建议不要只看“能否 import”这种最基本的问题要主动测试库的关键调用路径。特别是那些 Python 2 时代特有、Python 3 下只是“不报错但行为变了”的 API这种隐蔽坑最危险。1.3 迁移策略一步到位还是渐进式过渡迁移策略上业内比较常见的有三种方案直接一次性全部迁移到 Python 3先迁移到 Python 2/3 双兼容代码再择机移除兼容层或者新老服务并行新代码用 Python 3老代码留着慢慢改。我这次选择的是第二种——先写成双兼容再逐步过渡到纯 Python 3。原因很直接整个系统十几个服务之间存在相互调用不可能一次性全部切换。双兼容阶段可以让我按服务逐个迁移、逐个验证风险小很多。如果你面对的是一个小项目或者没有“多个服务协同”的问题方案一会更快一步到位短痛。如果你面对的是一堆已经没人维护的“孤儿代码”方案三可能更理性——新模块用 Python 3 写老模块不去碰它。总之策略不能凭感觉拍板要基于第一步的盘点和第二步的依赖核查结果做决策。2. 先用 2to3 跑一遍再靠自动化工具兜底策略确定之后我开始动手。当你决定做双兼容时直接使用2to3反而会“帮倒忙”因为它会把代码直接转换成 Python 3 专属语法破坏双兼容性。正确的做法是先在自己的代码里加上__future__导入让 Python 2 模拟 Python 3 的部分行为再用专门的工具做兼容层适配。2.1 双兼容阶段的基石__future__导入__future__不是某个第三方库而是 Python 2 内置的一种“未来特性预览机制”。它能让 Python 2 解释器提前启用 Python 3 里的语法和语义。在双兼容代码中我几乎在所有 Python 2 文件的顶部都加了这样几行from __future__ import ( absolute_import, division, print_function, unicode_literals, )这四行分别解决的问题是absolute_importPython 2 中import x会先搜索当前目录Python 3 改为绝对导入优先这行能让 Python 2 的行为向 Python 3 靠拢。division让/在 Python 2 中也执行真实除法浮点数结果不再做整数截断。print_function让print从语句变为函数支持print(a, b, sep,)的写法。unicode_literals让代码里直接书写的字符串字面量在 Python 2 中也按 Unicode 处理。这个操作看似简单但很多项目没有做导致后面修 bug 修到怀疑人生。unicode_literals尤其关键它几乎决定了你后面在处理中文、编码等场景时是噩梦还是顺水推舟。2.2 自动化工具选型futurize 和 pasteurize加了__future__之后我用futurize工具对代码做了一次批量改动。futurize有两个模式futurize --stage1和futurize --stage2。stage1做的是安全的基础转换比如把print语句转成函数、把except Exception, e改成except Exception as estage2做更激进的转换比如给 dict 的iteritems()加兼容包装给字符串加b前缀等。实际用下来的感受是futurize能解决大约 60% 的机械性问题但它不是一个可以无脑跑完就交差的工具。转换出来的代码有时会显得很啰嗦比如它可能给某些方法加上了future.utils的包装函数这些包装函数的性能损耗虽然不大但代码可读性明显下降。所以我的原则是futurize负责“找问题”人工负责“确认问题并写出更优雅的兼容代码”。另外一个要注意的是pasteurize它是future库提供的反向工具把 Python 3 代码转成兼容 Python 2 的代码。如果你有一些 Python 3 的新模块需要在 Python 2 环境下运行pasteurize会有用。但在整体从 2 到 3 的迁移里它的使用场景比较窄。2.3 自动化工具的边界哪些改不了工具始终是工具它理解了语法但理解不了语义。我遇到过几个典型的“工具改不了”的问题第一个是依赖隐式行为的代码。Python 2 的字典是无序的实际上在 CPython 实现里有一定顺序但不是语言规范有些人依赖这种“看似有规律”的顺序做逻辑判断。Python 3.7 之后字典按插入顺序排序如果原有代码碰巧利用了字典在某种实现下的乱序特征语义就会变。第二个是包含 C 扩展的模块。如果项目里有自己编译的.so文件那么futurize完全无能为力需要重新编译而且必须用 Python 3 的头文件重新编译。第三个是类型转换中的隐式行为。Python 2 中1 1不会报错而是按类型名的字典序比较Python 3 中这会直接抛出TypeError。这类代码 2to3 能识别一部分但很多时候需要人去看场景。所以我的判断是自动化工具适合做“第一遍清洗”真正的深度迁移仍然是一件高度依赖经验的事。3. 手写迁移中最容易翻车的十几类代码差异这个部分是最值钱的经验也是我在实际项目中反复踩坑的地方。下面这十几类差异几乎每一类都能对应到我迁移过程中排查过的问题。我按“危险程度从高到低”排序吃透这些差异你的迁移工作量至少能减半。3.1 print从语句到函数不只是加括号那么简单print从语句变成函数是所有人都知道的差异但真正迁移时没有那么简单。比如这样的代码# Python 2 写法 print result:, value,末尾的逗号在 Python 2 里表示不换行在 Python 3 里要写成# Python 3 写法 print(result:, value, end )还有更隐蔽的# Python 2 里这句能跑但输出内容可能和你预期不同 print sys.stderr, error occurredPython 3 要写成print(error occurred, filesys.stderr)print函数化之后print(a, b)和print(a, b)在 Python 2 中被解析成打印元组(a, b)而不是打印两个值。这种差异在日志代码里非常常见。我的建议是迁移日志相关的代码时统一封装一个自定义的log函数或直接用logging模块不要留裸print。不仅是为了兼容也为了后续日志格式的统一管理。3.2 除法语义/和//的天壤之别这是最容易造成“不报错但结果错”的一类差异。Python 2 中3 / 2的结果是1因为两个整数相除会做整数除法直接截断小数Python 3 中3 / 2的结果是1.53 // 2才是1。这类问题可怕就可怕在它不报错程序正常运行但算出来的数据全错了。我遇到过计算分页数的代码在 Python 2 时代没问题迁移到 Python 3 后total_pages total_items / page_size的结果从整数变成了浮点数然后循环里直接用它做range()参数抛出了一个TypeError。如果你加上了from __future__ import divisionPython 2 的行为就和 Python 3 一致了这样能提前暴露问题。在迁移阶段建议全局搜索所有包含/的行人工确认是否有依赖整数除法的逻辑。搜索时也要小心注释里的日期比如2019/12/31这种。3.3 字符串和字节迁移中最深的坑这是整个迁移过程中最消耗精力的一部分。Python 2 中的str本质上是字节序列unicode类型才是真正意义上的 Unicode 字符串Python 3 中str是 Unicode 字符串bytes则用来处理二进制数据。实际迁移中常遇到的问题包括# Python 2 中字符串和字节可以混用下面这个能跑 s 中文 b s.encode(utf-8) c b.decode(utf-8) print s u测试Python 3 中str和bytes绝不能直接拼接。一旦代码里有a bb这种操作会立即抛出TypeError: can only concatenate str (not bytes) to str。我遇到过一种更隐蔽的情况网络编程里的socket.recv()返回的是bytesPython 2 时代代码习惯了直接和字符串常量比较比如if data OK。迁移到 Python 3 后这个比较永远不会成立因为bOK ! OK。这种 bug 在单元测试里很难发现因为单测的输入数据可能恰好是str类型和线上数据不一样。处理策略上我建议在项目里建立一条硬性规则所有“从外部获取的数据”文件读取、网络请求、数据库查询必须在入口处明确声明期望的数据类型并做一次显式转换。不要让bytes和str在代码里“随缘”混着用。3.4 异常语法as、raise和traceback异常处理的语法差异也很麻烦。Python 2 支持两种写法# Python 2 旧式写法 try: do_something() except ValueError, e: handle(e) # Python 2 也可以这样写 try: do_something() except ValueError as e: handle(e)Python 3 只支持as写法。这一点2to3和futurize都能正确转换但要注意的是raise语句# Python 2 中这条语句会重新抛出异常并附带新的描述信息 raise ValueError, some message # Python 3 写法 raise ValueError(some message)Python 2 中raise ValueError, message的第二个参数是“异常参数”Python 3 中要使用构造函数。这个差异在代码库里出现概率不低但大部分工具也能处理。更麻烦的是traceback相关的代码。Python 2 中sys.exc_info()[0]或者traceback.format_exc()的行为在 Python 3 中基本一致但如果你在except块里做了变量清理然后再次raisePython 3 的异常链语义更严格有时会报During handling of the above exception, another exception occurred。3.5 字典方法iteritems、keys、values的变化Python 2 中字典有iteritems()、itervalues()、iterkeys()它们返回迭代器items()、values()、keys()则返回列表。Python 3 去掉了iter*系列方法items()、values()、keys()返回的是“视图对象”。“视图对象”和列表的区别在于视图是动态的如果在遍历后修改字典视图内容也会变。并且视图可以高效地做集合运算比如求交集、并集但如果你像操作列表那样索引它会报错。我在迁移时遇到一个实际问题原来的代码用了d.keys()[0]取第一个键。Python 2 中keys()返回列表索引没问题Python 3 中keys()返回视图不支持索引。正确的 Python 3 写法是next(iter(d))。这类差异虽然会报错但修复起来相对直观。难的是那些“不报错但行为变了”的情况比如for key in d.keys()在 Python 2 中遍历的是键的列表副本如果你在循环里修改了字典不会影响迭代Python 3 中遍历的是视图循环中修改字典会直接抛RuntimeError: dictionary changed size during iteration。3.6range与xrange迭代器 vs 列表Python 2 中range()返回一个列表xrange()返回一个迭代器。Python 3 中只有range()行为等同于 Python 2 的xrange()。如果代码里有range(1000000)这种代码Python 2 会在内存里生成一个包含一百万元素的列表Python 3 只是生成一个范围对象内存占用从几十 MB 降到了几个字节算是迁移的“福利”。但如果你依赖了range()返回列表的特性比如lst range(10) lst[5] 100Python 2 能跑Python 3 直接报TypeError: range object does not support item assignment。这种代码必须改成list(range(10))。还有更常见的场景len(range(5))两种版本都能跑range(5).index(3)也都能跑但还是建议把range和“真正需要列表”的场景分清楚。futurize不会帮你改这种逻辑因为它不知道你是不是需要列表。3.7map、filter、zip和sort的行为变化Python 2 中map、filter、zip返回列表Python 3 中返回迭代器。这和使用range一样既有“福利”也有“坑”。典型的问题出现在这样的代码# 这段代码在 Python 2 里没问题 filtered filter(lambda x: x 0, nums) print filtered[0]Python 3 中filter返回的是迭代器迭代器不支持索引需要改成list(filter(...))[0]。sort相关的差异更隐蔽。Python 2 的sort支持cmp参数用来定义自定义比较函数# Python 2 写法 items.sort(cmplambda x, y: cmp(x[1], y[1]))Python 3 移除了cmp参数只支持key参数。大部分简单的按某个字段排序都可以用keyoperator.itemgetter(1)代替。但如果是复杂的多级比较逻辑就需要用functools.cmp_to_key包装一下。from functools import cmp_to_key def custom_cmp(x, y): # 自定义比较逻辑 if x[0] ! y[0]: return -1 if x[0] y[0] else 1 return -1 if x[1] y[1] else 1 items.sort(keycmp_to_key(custom_cmp))3.8 类与继承新旧式类、super、metaclassPython 2 中有“经典类”和“新式类”之分。经典类不继承object新式类继承object。Python 3 中只有新式类经典类已经被移除。如果你的 Python 2 代码里还有经典类迁移后最大的问题是super()不可用以及多重继承的方法解析顺序MRO不同。所以我迁移时会先全局搜一遍“没有继承object的类定义”全部补上。super()的用法差异也需要处理# Python 2 写法 class Child(Parent): def __init__(self, name): super(Child, self).__init__(name) # Python 3 写法 class Child(Parent): def __init__(self, name): super().__init__(name)futurize能处理一部分super转换但涉及多重继承时还是要人工确认。我自己遇到的坑是super(Child, self)中的Child如果改名了或类结构变化了2to3 会转换出错。metaclass的写法在 Python 2 和 Python 3 中完全不同# Python 2 写法 class MyMeta(type): pass class MyClass(object): __metaclass__ MyMeta # Python 3 写法 class MyClass(metaclassMyMeta): pass如果你项目里很少用到元类这个可以最后处理。但如果用到了建议迁移时优先改因为元类的问题会引发非常难排查的TypeError。3.9 容易被忽略的细节has_key、raw_input、标准库命名剩下这些小差异单独看都不严重但堆积起来也耗费不少时间dict.has_key(key)在 Python 3 中已移除统一用key in dict。raw_input()在 Python 3 中改名为input()且 Python 2 的input()会被移除。xrange在 Python 3 中不存在。long类型在 Python 3 中和int合并。StringIO在 Python 3 中需要从io模块导入from io import StringIO。urllib2拆分成了urllib.request、urllib.error等多个子模块。ConfigParser改名为configparserQueue改名为queueTkinter改名为tkintercPickle并入pickle模块。iteritems()统一用items()。next()在 Python 2 中是通过迭代器的.next()方法调用的Python 3 中改为内置函数next(iterator)。我不建议靠记忆力处理这些差异更好的办法是在项目里引入一个“兼容层模块”比如compat.py把和 Python 版本相关的导入统一收拢然后在业务代码里通过这个模块引用。这样将来移除兼容层时只需要改一个文件。4. 双兼容阶段怎么验证不能只测“能跑”还要测“行为一致”很多人迁移完之后只关心“程序能不能跑”这远远不够。Python 2 到 Python 3 的迁移最大的风险不是崩溃而是“静默错误”——程序不报错但输出结果和之前不一样。所以在双兼容阶段我花了大量时间做行为一致性验证。4.1 构造“差异报告”用同一份输入跑两个版本我的做法是准备了一批固定的测试输入包括各种边界情况和历史真实数据分别在 Python 2 和 Python 3 环境里跑一遍然后把输出做 diff。这个做法听起来简单但实际操作时需要考虑两个环境之间的“输出格式”差异比如字典打印的顺序在 Python 3.7 之后是插入序在 Python 2.7 里则是哈希序。所以 diff 不能直接做文本对比要先对输出做一次 canonicalization规范化比如把字典按 key 排序后再转成字符串。如果你觉得为每个模块都建立“对比测试”太重可以只针对核心业务模块做。我自己是把验收标准定为核心的交易数据计算模块必须输出完全一致的统计结果非核心的数据展示模块允许部分格式差异。4.2 单元测试老测试是最大资产但需要调整项目里原有的测试在 Python 2 时代积累下来是迁移时最宝贵的资产。但有个问题老测试本身就是用 Python 2 的习惯写的里面可能也有对 Python 2 隐式行为的依赖。比如某些测试断言的是str(exception)的内容而 Python 2 和 Python 3 对异常转成字符串的格式不一样。我迁移测试代码时遵循这样一个原则测试要测试的是业务逻辑不是测试语言行为。所以凡是关于字符串格式、异常消息文本、dict 顺序、编码提示之类的断言都要放宽标准。真正有价值的断言是“在给定输入下核心输出值的正确性”。如果项目覆盖率本来就低那么迁移时可以先不急着补测试把从 Python 2 到 Python 3 的迁移视为一次重构迁移完成之后再补核心链路的测试。补测试时优先给“最容易因版本差异出问题”的模块加罩比如编码处理、数据计算、时间日期处理。4.3 编码问题的专项测试编码问题是迁移后最容易爆的雷我专门做了一套编码专项测试。测试内容包括读取包含中文的 CSV、JSON、TXT 文件。把包含中文的数据写入数据库再读出来。通过 socket 发送和接收包含非 ASCII 数据的消息。处理包含 emoji 的文本。每一条都分别在 Python 2 和 Python 3 环境下验证看结果是否一致。实际上我在做这一步时发现了不少问题而且有一个问题在线上跑了一个月才偶然暴露。那是一个日志系统Python 2 时代日志文件默认以locale编码写入中文内容按 GBK 写入迁移到 Python 3 后代码里没有显式指定encodingPython 3 默认以 UTF-8 写入。新老日志文件混在一起后日志查看工具读取时出现了乱码需要去翻历史文档才能判断哪些日志是老的、哪些是新的。这个问题的教训是任何文件读写的代码迁移时都必须显式指定encoding参数绝对不要依赖系统默认编码。5. 迁移过程中的坑与完整排查链路迁移过程不可能一帆风顺。下面分享几个我实际踩过的坑重点讲完整的排查链路而不是直接给答案这样你下次遇到类似问题能有思路去“破案”。5.1 排查案例一字典无序导致的随机性数据不一致这个问题发生在一个数据分析模块。迁移后的 Python 3 服务每次跑出来的结果都“大体一致但偶尔有一两个字段对不上”。开始我怀疑是浮点计算精度问题但排查后发现不是因为差异的字段是字符串类型的标签。排查链路是这样的先在两个环境里各跑 20 次收集所有不一致的情况发现并不是“随机”的而是每次只有特定几个 key 的顺序不同。然后我打印了代码里所有中间层的字典 key 顺序对比之后发现Python 2 下字典顺序是“哈希序”而 Python 3 下是“插入序”。代码里有一段逻辑是遍历字典把 key 拼进一个字符串再把字符串做哈希存储。修复方案很简单在遍历字典之前先按 key 排序。但反思一下为什么这个 bug 能逃过测试因为我在 4.1 节提到的“canonicalization”只在最终输出上做了 dict 排序没有对中间过程的字符串做规范化。而且这种“拼 key 的哈希值”去重的逻辑本质上是依赖字典的迭代顺序这类代码本来就属于“不该存在的编码方式”。5.2 排查案例二socket收发的bytes和str混用问题现象服务上线后在处理图片上传时报错错误信息是TypeError: a bytes-like object is required, not str。排查时需要先理解这个报错说明代码在某个需要bytes参数的地方传入了str。我先用traceback模块定位到具体行号发现是在调用hashlib.md5()时参数直接用了从 HTTP 请求里拿到的字符串。在 Python 2 时代hashlib.md5()能接受str参数因为str本身就是字节Python 3 中对str参数直接报错。这个问题的修复很简单把参数 encode 成bytes就行。但真正的教训在于整个模块的其他代码都是拿“字符串”在传参它们能工作是因为底层库帮你做了编码转换。这种隐式转换在 Python 3 中大多被移除了所以迁移后一旦出现“字符串/字节不匹配”的错误不能只改报错那一行而要把整个调用的“类型传递链”梳理清楚。后来我给自己定了一个规则凡是跨模块传输的数据函数签名里必须写明类型注解哪怕是简单的注释# type: bytes。没有类型标记的函数在迁移时就是一颗定时炸弹。5.3 排查案例三列表推导式作用域泄漏这个问题比较老但我在自己的代码里确实遇到过。Python 2 中列表推导式里的循环变量会“泄漏”到外部作用域# Python 2 x outer squares [x ** 2 for x in range(5)] print x # 输出 4Python 3 中列表推导式的作用域被隔离了# Python 3 x outer squares [x ** 2 for x in range(5)] print(x) # 输出 outer如果原有代码里“碰巧”依赖了泄漏出的循环变量迁移后变量值就会变化。这种问题在排查时非常隐蔽因为代码逻辑没有报错但行为不同。我遇到的情况是某个模块在列表推导式之后使用了一个叫i的变量Python 2 中i恰好是列表推导式循环后的最后一个值Python 3 中i变成了之前的默认值。最终的结果是列表的索引错位。这个问题的排查链路是对比两个环境下的变量值发现差异不是计算逻辑而是作用域规则。修复方式是把“依赖变量”改成“在推导式内显式获取”比如last_i [x for x in range(5)][-1]。6. 何时、以及如何清理兼容层当你所有的服务都成功切换到 Python 3 环境并且稳定运行了一段时间之后接下来的工作就是清理兼容层。很多人觉得“反正都能跑清理不清理无所谓”但兼容层会持续积累“技术债利息”每次读代码都要想一下这个函数是兼容层还是原生 API每次跑测试都要多花一点时间而且兼容层的存在往往会让人不敢放心使用 Python 3 的新语法特性。6.1 清理时机不是越早越好我的建议是至少等待一个完整的发布周期确认线上没有任何回滚需求后再清理。如果团队小、发布快这个周期可以是一个月如果发布频率低可以等一个季度。关键不是看日历而是看系统稳定性。我在清理之前特意检查了线上日志确认没有因为“Python 3 行为不同”导致的告警才决定动手。6.2 清理步骤从“删除兼容模块”开始我最先清理的是compat.py这个模块。用grep全局搜索一下把所有from compat import *或者import compat的引用全部打开逐一确认在纯 Python 3 环境下的等价写法。比如# 兼容层 try: from urllib.request import urlopen except ImportError: from urllib2 import urlopen # Python 3 环境直接写 from urllib.request import urlopen把兼容层的try/except去掉后代码清爽了很多。紧接着清理__future__导入。这些导入在 Python 3 环境下没有任何作用但删掉它们需要保证代码里不再依赖 Python 2 的语义。我用的检查方法是删掉from __future__ import division后全局搜索/符号确认除法逻辑没变删掉unicode_literals后检查所有包含中文的字符串变量确认不会产生编码错误。最后清理的是依赖版本。比如future、six这两个库在纯 Python 3 项目中已经没有存在的必要。把requirements.txt里的相关项删掉然后重新跑全量测试确认没有遗留引用。6.3 清理后还能做什么清理兼容层之后代码终于可以享受 Python 3 的现代语法了。我个人认为迁移后值得优先做的几件事把print全面替换成logging让日志更规范。使用 f-string 替换掉%格式化和format()。引入类型注解type hints为后续使用 mypy 做静态检查铺路。检查能不能用pathlib替换os.path操作。有条件的话用async/await重写部分 I/O 密集的代码块。这些优化不一定要在迁移的同一批次做完但迁移是一个天然的“重构窗口”趁热打铁比之后再改要划算得多。7. 迁移工具链与开发环境的一些补充经验前面讲的主要是代码层面的迁移实际工作中工具链和开发环境的迁移同样是项目成败的一部分。7.1 虚拟环境分离是王道我强烈建议你在同一台机器上保留 Python 2 和 Python 3 两套虚拟环境互不干扰。Python 2 项目的虚拟环境专门用来跑“旧版本对照”Python 3 的用来跑迁移后的新代码。不要把两套环境的包混装在同一个解释器里。用virtualenv或 Python 3 自带的venv都可以。7.2 CI/CD 的适配如果项目原来有 CI 配置迁移后需要在 CI 里增加一个 Python 3 的构建任务。在双兼容阶段我同时跑了两个 job一个 Python 2.7 环境跑老测试一个 Python 3.8 环境跑新测试。任何一次提交都要求两个环境同时通过。这个策略非常有价值因为很多迁移问题是在后续迭代中“重新引入”的——新代码里有人写了iteritems()在 Python 3 的 CI 里会直接报错但 Python 2 的 CI 能通过两者一对照就很容易发现问题。7.3 编辑器配置和代码检查工具迁移期间我给团队统一配置了flake8加上python3相关的规则并启用了flake8-future-import插件专门检查是否缺少__future__导入。另外还用了pylint的 Python 3 兼容性检查项这些工具能在很大程度避免“人为引入旧语法”的问题。8. 迁移完成之后回头看这几条经验如果只说三句话总结这次迁移我会说第一迁移最重要的不是“改语法”而是“对齐语义”。同样的代码在 Python 2 和 Python 3 下表现不同才是隐藏的重症。第二自动化工具是第一步不是最后一步。futurize和2to3的价值在于快速定位真正的质量保障靠的是系统性的行为验证。第三不要试图一次性把所有代码都挪过去。按模块、按服务分批次推进每个批次都要有明确的“对比验证”环节这种做法在团队协作中尤其重要。独立开发者可以相对灵活但如果你在带团队渐进式迁移几乎是唯一靠谱的方案。我个人的体会是Python 2 到 Python 3 的迁移本质上是一次“语言运行时时序”的大升级。这个过程中最宝贵的资产不是工具而是测试。测试覆盖越充分迁移的风险越低。如果你现在还在维护 Python 2 的老项目哪怕暂时没有迁移计划我也建议你先把关键模块的核心测试补上。等到真正需要迁移的那一天你会发现这些测试比任何工具都值钱。