写代码这么多年,我最大的毛病不是不会写,而是写完了就不管。前几天翻自己旧项目,盯着某一行变量名看了半分钟,愣是想不起当初为什么加这行。这种感觉比报错还难受,因为报错至少告诉你哪里错了,遗忘却让你连从哪下手都不知道。正好那段时间我在整理乱成一团的下载目录,顺手写了个文件分类脚本,写着写着突然意识到:这种“当时想明白了、过几天就忘”的东西,如果老老实实记录下来,过段时间再回头看,价值其实特别大。于是就有了这个《代码随想录》系列。Day 1,我不打算一上来就聊复杂框架或者算法竞赛,而是选了每个人都会遇到的场景:把下载文件夹里堆了半年的文件,按类型分好类。这个任务看起来小,认真写一遍收获却不小。
1. 为什么 Day 1 要拿“整理下载目录”这种小任务开刀
1.1 这个系列想沉淀什么,不想凑什么热闹
决定写这个系列之前,我给自己定了调:不写随处可见的 API 手册,也不做长篇大论的技术前瞻,只写“我在写代码时脑子里实际发生的事”。比如遇到一个报错,第一反应是什么;改到第三版,为什么放弃了那个看起来很简洁的写法;这些过程本身比最终代码更值得留存。很多人记录技术笔记,只记结论,不记推导,等三个月后再翻笔记,结论还在,但“为什么得出这个结论”已经完全想不起来了,那不就跟白记一样嘛。所以《代码随想录》的核心不是代码仓库,而是围绕代码的思考过程。既然定位是思考过程,Day 1 就不适合选一个特别宏大的主题。越小的例子,越能把每个决策点掰开揉碎讲清楚。
1.2 Day 1 要处理的实际场景
说说我碰到的场景。下载目录里混杂着各种东西:几个 PDF 文档、一堆截图 png、不同的压缩包、安装程序,还有一些扩展名稀奇古怪的文件,连我自己都想不起来是什么时候下载的。手动分类其实十几分钟就做完了,但我那天下意识的想法是“这种重复操作以后肯定还会遇到,为什么不写个脚本”。结果,这一个小脚本花了大约一小时,暴露出来的问题比我最近写的某些功能模块还要多。正好把它作为开篇案例,既真实又有代表性。
任务边界在一开始也很模糊,后来我把它划成三条:只处理下载目录顶层的文件,不递归处理子文件夹;按扩展名把文件分成图片、文档、压缩包、其他四类;移动时不要覆盖任何已有文件。这三条边界看着简单,实际上每一条都对应后面代码里的一个关键设计点。等会讲到代码时你就会发现,凡是边界没想清楚的地方,后面全是坑。
1.3 为什么用 Python 而不是其他办法
有朋友可能会问,这种需求用批处理命令也能做,为什么要用 Python?我的理由有三条。第一,Python 标准库自带 pathlib、shutil 这些模块,处理文件路径和移动操作非常顺手,不需要额外安装依赖。第二,代码读起来接近自然语言,后期维护成本低,哪怕是半年后再看也能一眼明白每个函数在干嘛。第三,我预期这个脚本以后会越改越复杂,比如加上日志、定时执行、按时间归档等功能,Python 在这种小工具的演进上很适合。如果只是为了当场用一次,我可能直接手动整理更快,但考虑到“以后还会遇到同类问题”,写脚本的价值才能体现出来。一句话,选型不是选最炫的,而是选最方便自己长期维护的。
2. 第一版代码:一次性贪快的代价
2.1 那个看起来很正常的第一版
先给大家看看我最初的写法,这段代码极其直白,甚至会让人觉得有点“幼稚”。它做的事情就是遍历下载目录里的所有条目,用split(".")取出扩展名,再根据扩展名拼出目标路径,最后用shutil.move移动文件。
import os import shutil source = "./Downloads" for name in os.listdir(source): ext = name.split(".")[1].lower() if ext in ["jpg", "jpeg", "png"]: dest = "./Downloads/images/" + name elif ext in ["zip", "rar"]: dest = "./Downloads/archives/" + name else: dest = "./Downloads/others/" + name shutil.move("./Downloads/" + name, dest)写这段代码的时候,我心里想的是:这不难嘛,一个循环加两个判断就搞定了。可实际一运行,问题接二连三冒出来。先说第一个最容易发现的毛病:如果文件名里没有点号,split(".")返回的列表长度只有 1,访问[1]会直接抛IndexError: list index out of range。我下载目录里还真有几个完全没有扩展名的文件,脚本一跑上去,第一秒钟就炸了。
2.2 第一次运行就收到三份“见面礼”
第一份见面礼就是刚才说的IndexError。当时我还很天真,心想“补个判断不就行了”,结果第二份见面礼马上来了:目标目录不存在。当脚本试图把文件移动到./Downloads/images/的时候,系统直接抛FileNotFoundError,因为images这个文件夹根本不存在。第三份见面礼更隐蔽——我把脚本里的报错修掉一部分后再次运行,发现有一个同名文件被静默覆盖了。这其实是所有坑里最危险的一个,因为程序没有报错,只是默默把文件换掉了,等你发现的时候,旧文件已经找不回来。
这三个问题,单独拎出来每一个都很简单,但一旦混在一个“看起来能用”的脚本里,就会造成一种假象:代码跑起来了,就以为没问题了。等到真正要用的时候,才发现它只对“想象中”的文件名有效,对“现实中”的文件名处处是雷。
2.3 从“能用”到“敢用”之间,隔着三个自觉
踩完这三个坑之后,我开始认真反思。第一版代码最大的问题不是哪个函数用错了,而是思考方式太“顺拐”:假设所有文件名都规规矩矩地带一个点号,假设目标文件夹都已经存在,假设永远不会出现重名。现实显然不是这样。我给自己总结了三个自觉,后面改版完全围绕它们展开。
第一个自觉是“输入永远比我以为的复杂”。文件名可能是report.pdf,也可能是.env、没有扩展名、photo 2024.png,甚至可能是中文名.带空格.多后缀.v1.zip。任何基于简单规律的解析都可能失效。第二个自觉是“目标位置必须自己准备好”。代码不能假设某个目录已经存在,创建目录这个动作必须写进去。第三个自觉是“任何覆盖都是一种破坏”。移动文件之前,必须先检查目标位置有没有同名文件,有的话就要生成一个新名字,而不是直接顶掉。
3. 改版之前,先把三个基础知识点补扎实
3.1 Path.suffix 到底比 split(".") 强在哪
第一个要补的知识点,是怎么正确提取扩展名。split(".")虽然简单,但它把文件名按点号拆成了多个部分,对report.tar.gz这种复合后缀会得到['report', 'tar', 'gz'],你根本分不清哪个是真正的扩展名。而标准库里的Path.suffix是一个专门为“提取扩展名”设计的方法,它永远只返回最后一个后缀,比如.gz;如果没有后缀,就返回空字符串。我对照过几个典型例子,差异很明显:
| 原文件名 | name.split(".")的结果 | Path(name).suffix的结果 |
|---|---|---|
notes.txt | ['notes', 'txt'] | .txt |
.env | ['', 'env'] | .env |
没有扩展名 | ['没有扩展名'] | '' |
report.tar.gz | ['report', 'tar', 'gz'] | .gz |
你可能注意到了,对.env这种隐藏文件,split(".")会把它当成扩展名是env的普通文件;而Path.suffix同样返回.env。这两种结果其实都不能完美处理点号开头的文件,所以后续代码里需要单独判断,或者干脆把它归入“其他”类别。但至少Path.suffix不会因为文件名没有点号而崩溃,这个底线比什么都重要。
3.2 路径拼接不要用加号,交给 pathlib 处理
第二个要补的知识点是路径拼接。第一版里我写了"./Downloads/" + name,短路径看起来没问题,可一旦路径里带着空格、中文、特殊符号,或者路径很长,字符串拼接的可读性就会变得很差。而且不同操作系统对路径分隔符的处理方式不一样,手动拼"/"在跨平台场景下容易出问题。
改版后我直接用pathlib.Path:
from pathlib import Path source = Path.home() / "Downloads" target_dir = source / "organized" / category这里的/运算符不是为了炫技,而是让路径拼接变成一种安全的构造方式。Path对象会自己处理分隔符、处理相对路径和绝对路径的关系,代码读起来也顺很多。唯一要注意的是,shutil.move这类函数有时候只接受字符串路径,所以传给函数的时候可以包一层str(),或者直接传Path对象,看具体版本。总之,优先用Path对象做路径逻辑,真正需要传给外部函数时再转换,能少踩非常多的坑。
3.3 目录自动创建和重名保护
第三个要补的知识点是安全落地。原先脚本假设目标目录存在,可实际运行时它根本不存在,所以需要调用mkdir自动创建。它的完整写法是:
target_dir.mkdir(parents=True, exist_ok=True)parents=True表示如果父目录也不存在,会一并创建;exist_ok=True表示如果目录已经存在,也不会报错。这两个参数缺一个都不够保险。至于重名保护,我的方案是:先判断目标路径是否存在,如果存在,就把时间戳拼到文件名里,保证新的名字不会冲突。
这里要顺带提一下shutil.move和os.rename的区别。同一目录下用os.rename没问题,但一旦涉及跨目录甚至跨磁盘移动,os.rename可能会报错,因为它本质上是在文件系统层面改名字,跨文件系统时并不总是可用。而shutil.move会先尝试os.rename,不行的时候自动退化成“复制到目标 + 删除原文件”,更符合我们“移动文件”的直觉。
4. 坚持留档:Day 1 的完整脚本与实测记录
4.1 完整代码与模块设计
改版之后,我把脚本整理成了下面这个版本。整体结构分成三块:规则配置、分类函数、组织函数。规则配置放在最上面,以后想新增文件类型,直接往字典里加后缀就行,不用改逻辑代码。
from pathlib import Path import shutil SOURCE = Path.home() / "Downloads" RULES = { "images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"], "docs": [".pdf", ".doc", ".docx", ".txt", ".md", ".ppt", ".pptx"], "archives": [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"], } def classify(ext: str) -> str: for category, extensions in RULES.items(): if ext in extensions: return category return "others" def organize(directory: Path) -> None: for item in directory.iterdir(): if item.is_dir(): continue ext = item.suffix.lower() category = classify(ext) target_dir = directory / "organized" / category target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / item.name if target.exists(): stamp = item.stat().st_mtime_ns target = target_dir / f"{item.stem}_{stamp}{item.suffix.lower()}" shutil.move(str(item), str(target)) print(f"{item.name} -> {target.relative_to(directory)}") if __name__ == "__main__": organize(SOURCE)我稍微解释一下几个设计选择。第一,item.is_dir()这个判断很关键,因为下载目录里可能还有子文件夹,我们这次的任务只处理顶层文件,不递归,所以遇到文件夹就跳过。第二,ext = item.suffix.lower()统一转小写,避免JPG和jpg被当成两种类型。第三,重名时用修改时间戳做后缀,虽然文件名丑了一点,但绝不会冲突。第四,用print实时输出每条移动记录,方便跑完检查哪里出了问题。
4.2 实测运行结果
我在自己的下载目录里放了一批测试文件,然后运行脚本,输出大概是这样的:
旧笔记.txt -> organized/docs/旧笔记.txt image_001.png -> organized/images/image_001.png 资料包.zip -> organized/archives/资料包.zip 未知格式.xyz -> organized/others/未知格式.xyz 子文件夹 -> 已跳过实测下来,目录结构变得清爽多了:根目录被清理干净,整理好的文件按照图片、文档、压缩包、其他四类待在organized文件夹下面。整个过程没有报错,也没有文件丢失。唯一还需要注意的问题是,脚本只处理后下载目录顶层的文件,如果organized目录本身也在下载目录里,二次运行的时候脚本会扫描到已经分类好的文件,把它们再移动成一个更深的organized嵌套结构。所以我后来在代码里加了一行判断,遇到organized目录本身直接跳过,这个小细节特别容易忽略,给同样想抄作业的朋友提个醒。
4.3 这个脚本还能往哪些方向长
Day 1 的脚本只是起点,我脑子里已经列了好几个扩展方向。最实用的是一个dry-run模式,也就是只打印“将要做什么”,但不真正移动文件。当你面对一批重要文件时,先模拟跑一遍,确认没问题再实际执行,会安心很多。实现起来也很简单,加一个布尔参数,如果为真就只打印print(f"[Dry Run] {item.name} -> {target}")。
另外一个方向是支持按时间归档,比如把文件按月份分组,2024-01、2024-02这样,适合下载文件经常带时间属性的场景。再高级一点,可以加一份日志文件,记录每次运行的时间、文件数量、移动了哪些文件,方便追溯。不过这些都是后话,Day 1 先把基础版本跑通再说。
5. 给《代码随想录》定下的三条规矩
5.1 只记录真正跑过的代码
写这个系列的第一天,我就给自己立了一条死规矩:所有贴出来的代码,必须是在自己机器上真实运行过的,不允许出现“理论上应该能跑”的代码。为什么?因为没跑过的代码,你根本不知道注释里应该写什么,也不知道读者照着操作会卡在哪一步。这个小脚本能成为开篇,正是因为我在运行过程中真遇到了IndexError、FileNotFoundError,以及最可怕的静默覆盖。这些经验不跑一遍是编不出来的。以后每一篇我都会遵循这个习惯,宁可代码丑一点,也不放没法验证的内容。
5.2 把“为什么”当成一等公民
第二条规矩是:每个关键决策后面,至少要写一句“为什么”。路径拼接为什么用Path,而不用字符串加法;后缀提取为什么用Path.suffix,而不用split(".");重名保护为什么会用到时间戳。这些问题单独看都很小,但把它们串起来,就能看到一个完整的思考链。我之所以强调这个,是因为自己以前记笔记总是只记结果,结果后来复盘时最缺的不是答案,而是当时选答案的依据。把“为什么”写下来,相当于给未来的自己留了一张地图。
5.3 每篇结尾都留下一个待尝试的想法
第三条规矩比较随性,每篇结尾我都会写一个“明天想试什么”。这样会让系列有连续性,今天没做完的事情,会成为下一篇的起点。比如我这个脚本的下一步,就是加dry-run模式,顺便再写一个简单的版本控制器。你可以把它理解为给自己挖的一个小坑,挖坑不填会难受,填上了就有成就感。Day 1 能把这个整理脚本完整跑通,我已经挺满足了,明天可以试试不移动文件、只预览结果的新功能。