
2. 元组比列表更“克制”的容器类型2.1 为什么列表都学了还要专门学元组很多教程会把元组轻描淡写地带过去告诉你“元组就是不可变的列表”然后就没下文了。如果你只是这么理解那后面写代码会遇到一个很尴尬的情况你完全想不到什么时候该用元组于是所有数据结构一律用列表代码也能跑但总感觉不够“地道”。我自己的理解是元组和列表的关系很像“常量”和“变量”的关系。列表允许你随意增删改适合存放长度可能会变化、内容需要动态调整的集合而元组一旦创建就不允许修改它更像是一个“定型打包”的结构。举个生活化的类比列表就像一个可扩展的收纳箱你可以随时往里面塞东西或掏出来而元组就像一封已经封好口的信件内容写死了寄出去就不能改了它保证的是“这份信息在传递过程中绝对不会被篡改”。正因为不可变元组有三大好处安全数据传给别人时不用担心被意外修改尤其是多线程、多人协作时这个保证非常有价值。性能元组的底层结构比列表更紧凑创建和访问速度更快内存开销更小。可哈希元组可以作为字典的键而列表不行。这一点在做缓存、映射、去重时非常好用。2.2 动手创建元组这四个细节别踩坑创建元组很简单圆括号一包就行t1 (1, 2, 3) t2 (hello, world) print(t1) # (1, 2, 3) print(t2) # (hello, world)但有几个细节是新手最容易翻车的我一个个说第一单个元素的元组必须加逗号。这是坑中之坑t (5) # 这其实是一个整数不是元组 print(type(t)) # class int t2 (5,) # 这才是元组 print(type(t2)) # class tuple运行之后你会发现t (5)的类型是 int不带逗号时圆括号只是改变运算优先级的符号而已。写的时候一定记得单元素元组后面要跟一个逗号。第二元组表面不可变但内嵌可变对象可以变。这句话看着绕实际非常重要t (1, [2, 3], 4) t[1].append(99) print(t) # (1, [2, 3, 99])元组本身不能增加、删除、修改元素但如果元组里的某个元素恰好是一个列表那这个列表的内部是可以被修改的。这是因为元组保存的只是列表的引用引用没变但引用指向的内容可以变化。这就是“浅层不可变深层可能可变”的性质理解了这一点就不会在调试时奇怪了。第三元组也支持切片和拼接。它和列表一样拥有索引、切片、in 判断、len() 等操作唯一的区别是修改类的方法append、extend、remove 等它一概没有。我用得比较多的操作是t (10, 20, 30, 40, 50) print(t[1:3]) # (20, 30) print(30 in t) # True print(len(t)) # 5 t2 t (60, 70) print(t2) # (10, 20, 30, 40, 50, 60, 70)2.3 解包这门手艺是元组最惊艳的地方元组最让我觉得“回本”的功能就是解包unpacking。它本质上就是把元组里各个位置的元素一次性赋给多个变量point (3, 5) x, y point print(x) # 3 print(y) # 5这有什么实际价值呢最经典的用法有两个。一个是交换两个变量。在其他很多语言里交换两个变量必须借助一个临时变量temp a a b b temp但是在 Python 里用元组解包一句话就搞定了a, b b, a这行代码右侧其实先构成了一个元组(b, a)然后解包赋值给左侧的a, b。我当年第一次看到这个写法的时候觉得真是优雅而且实测下来性能也完全不差。另一个是函数返回多个结果。当你需要返回不止一个值时不用像 C 语言那样传指针或定义一个结构体直接返回元组就行def get_user_info(): name 张三 age 28 email zhangsanexample.com return name, age, email # 自动打包成元组 name, age, email get_user_info()这里return name, age, email看起来返回了三个值实际上是返回了一个元组(name, age, email)。而调用方用name, age, email ...一行解包非常舒服。再进阶一点还有星号解包的用法。当元组长度不确定时可以把多余的部分统一收集成一个列表first, *middle, last (1, 2, 3, 4, 5) print(first) # 1 print(middle) # [2, 3, 4] print(last) # 52.4 元组 vs 列表什么时候该选谁我在写代码时有一条经验法则如果这个数据集合创建之后不需要修改优先选元组除非你有明确的变长需求。比如坐标点、日期年、月、日、配置项、数据库查询的一行结果这些天然就是“定型打包”的数据用元组再合适不过。另外当你需要把一组值作为字典的键时元组是唯一的容器选择location (39.9042, 116.4074) # 北京的经纬度 cache {} cache[location] 北京市 # 列表就不能这么干从性能角度看直接用timeit测一下会发现元组的创建和访问都比列表稍微快一点。虽然差距不是质变但在循环量很大的场景下能省一点是一点。2.5 进阶namedtuple 把“魔法数字”变成字段从 Python 2.6 开始标准库提供了一个非常实用的工厂函数namedtuple它可以把元组中的每个位置起一个名字。用大白话说它让元组不再只是(name, age, email)这种需要靠位置记忆的结构而是像对象一样能用属性名访问from collections import namedtuple User namedtuple(User, [name, age, email]) u User(李四, 30, lisiexample.com) print(u.name) print(u.age) print(u.email)它的好处是代码可读性大幅提升同时仍然保留了元组的不可变特性和解包能力。如果你需要一个“轻量级的数据封装对象”又不想写一整个 classnamedtuple 几乎是最优解。3. OS 模块操作系统的 Python 遥控器3.1 从 getcwd 开始先搞清“当前在哪”学 OS 模块我建议从getcwd()入手。这个函数返回当前工作目录的绝对路径。很多人写脚本时会遇到一个经典问题“我的脚本明明在某个文件夹里为什么读文件报错说找不到”大概率就是当前工作目录跟你以为的不一样。import os print(os.getcwd()) # 比如 /Users/yourname/projects你可能以为在 PyCharm 里把脚本放在一个目录下当前目录就是脚本所在目录其实不是。默认的当前工作目录往往取决于你启动 Python 的位置。要稳妥地处理文件路径最好的方式是用脚本文件所在的目录作为基准而不是依赖当前工作目录import os base_dir os.path.dirname(os.path.abspath(__file__)) print(base_dir)用__file__拿到脚本路径再用os.path.abspath()转成绝对路径再用os.path.dirname()取出目录。这样无论你在哪运行脚本文件都找得准。3.2 路径处理的核心组合join、split、splitext路径处理的头号选手是os.path.join()。不同操作系统使用的路径分隔符不一样Windows 是反斜杠Linux 和 macOS 是正斜杠。直接拼接字符串会踩到平台的坑但用os.path.join()就能自动适配folder /Users/me/data filename report.csv full_path os.path.join(folder, filename) print(full_path) # /Users/me/data/report.csv对应的反向操作是os.path.split()它把一个完整路径拆成“目录 文件名”的元组path /Users/me/data/report.csv dirname, basename os.path.split(path) print(dirname) # /Users/me/data print(basename) # report.csv注意os.path.split()返回的正是元组看到没有这就是为什么元组和 OS 模块放在同一天学一个路径拆分的结果天然就是一个二元组。另外还有一个高频函数os.path.splitext()单独把扩展名拆出来root, ext os.path.splitext(/Users/me/data/report.csv) print(root) # /Users/me/data/report print(ext) # .csv3.3 判断文件与目录的存在、类型和大小在做文件操作之前先判断路径是文件还是目录、存不存在这是最基本的防御性编程。常用的函数就那么几个import os path /Users/me/data/report.csv print(os.path.exists(path)) # True print(os.path.isfile(path)) # True print(os.path.isdir(path)) # False print(os.path.getsize(path)) # 文件大小单位字节有个细节我想特别提醒os.path.exists()对符号链接软链接的处理是跟随链接的也就是说链接指向的目标存在才返回 True。而os.path.islink()是判断路径本身是否为符号链接。如果你的脚本要处理大量软链接这两个函数要配合着用否则会出现“我明明看到一个链接代码却说它不存在”的情况。3.4 目录操作listdir、scandir、makedirs最基础的目录列举函数是os.listdir()它返回的是目录下的文件名列表import os entries os.listdir(/Users/me/data) print(entries) # [report.csv, notes.txt, images]但这里有个容易让人懵的点listdir()返回的只是“名字”不是完整路径。如果你想拿到完整的路径需要自己join一下entries os.listdir(/Users/me/data) full_paths [os.path.join(/Users/me/data, e) for e in entries]这种写法非常常见尤其在遍历目录做批量处理的时候。os.scandir()是 listdir 的升级版效率更高而且返回的是DirEntry对象能直接拿到类型、大小等信息entries os.scandir(/Users/me/data) for entry in entries: if entry.is_file(): print(f文件: {entry.name}, 大小: {entry.stat().st_size})创建目录时要注意的是os.makedirs()和os.mkdir()的区别。mkdir只能创建单层目录如果父目录不存在就直接报错makedirs可以递归创建多层目录配合exist_okTrue再也不用担心目录已存在的报错os.makedirs(/Users/me/data/2024/01, exist_okTrue)3.5 文件操作rename、remove、walk文件重命名和删除是批量脚本里最常见的操作os.rename(old_name.csv, new_name.csv) os.remove(need_to_delete.csv)os.remove()只能删文件删目录需要用os.rmdir()但它只允许删空目录。如果需要递归删除非空目录树就得用shutil.rmtree()这是 OS 模块的最佳搭档。压轴好戏是os.walk()。它递归遍历目录树每次返回一个三元组(root, dirs, files)又是一个元组root是当前目录路径dirs是当前目录下的子目录列表files是当前目录下的文件列表。for root, dirs, files in os.walk(/Users/me/data): for f in files: full_path os.path.join(root, f) print(full_path)这个函数在做整个目录树的批量扫描时极其好用比如找出所有大于 100MB 的文件、统计某类扩展名的文件数量、按日期清理日志等。4. 实战用元组和 OS 模块做批量文件重命名工具4.1 工具的功能定位学完元组和 OS 模块如果不马上拿来做点什么很快就会忘。我在这里分享一个自己写过的批量重命名脚本它是一个非常典型的案例把这两天的知识点全部串起来了。场景是这样的我有一个存放拍摄照片的目录里面文件名都是类似IMG_20240101_123456.jpg这种光看名字没什么规律我想把它们统一成photos_年-月-日_月日时分秒.jpg这种带清晰时间的格式同时加上序号方便按顺序浏览。功能需求拆解下来有四个递归遍历目标目录收集所有指定扩展名的文件从原文件名里解析出拍摄时间拼接新的文件名并防止重名覆盖打印日志让用户看到改名过程4.2 完整代码与逐段讲解import os import re from collections import namedtuple # 定义文件信息结构 FileInfo namedtuple(FileInfo, [path, name, ext]) def collect_files(target_dir, extensions): 递归收集指定扩展名的所有文件。 返回FileInfo 元组列表 result [] for root, dirs, files in os.walk(target_dir): for filename in files: name, ext os.path.splitext(filename) ext_lower ext.lower() if ext_lower in extensions: full_path os.path.join(root, filename) result.append(FileInfo(full_path, name, ext_lower)) return result def parse_time_from_name(name): 从文件名中解析时间。 假设格式IMG_20240101_123456 返回格式化时间字符串 pattern rIMG_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2}) match re.search(pattern, name) if not match: return None year, month, day, hour, minute, second match.groups() return f{year}-{month}-{day}_{hour}{minute}{second} def build_new_filename(old_name, ext, index): 构造新文件名序号_日期_时间.扩展名 time_part parse_time_from_name(old_name) if time_part is None: time_part unknown_time return f{index:04d}_{time_part}{ext} def batch_rename(target_dir, extensions): 批量重命名主函数 file_infos collect_files(target_dir, extensions) if not file_infos: print(没有找到符合条件的文件) return # 按目录分组每组内按文件名排序 grouped {} for info in file_infos: dir_path os.path.dirname(info.path) grouped.setdefault(dir_path, []).append(info) for dir_path, infos in grouped.items(): infos.sort(keylambda x: x.name) for idx, info in enumerate(infos, start1): new_name build_new_filename(info.name, info.ext, idx) new_path os.path.join(dir_path, new_name) # 避免覆盖已存在的文件 if os.path.exists(new_path) and new_path ! info.path: print(f跳过 {info.name} - {new_name} 已存在同名文件) continue os.rename(info.path, new_path) print(f重命名: {info.name} - {new_name}) if __name__ __main__: batch_rename(/Users/me/photos, {.jpg, .jpeg, .png})4.3 代码要点解析这个脚本里有几个地方值得单独拎出来说说。第一namedtuple 让文件信息一目了然。FileInfo(path, name, ext)比直接用(path, name, ext)这种裸元组清晰太多了。访问info.path比info[0]可读性高一个档次而且还保留了解包能力比如for info in file_infos里我可以随时取字段。第二os.walk返回的root, dirs, files本身就是三元组解包。如果你忘了元组解包的语法这段代码就理解不了。而os.path.splitext(filename)返回的(name, ext)又是一次二元组解包。整个脚本里元组的应用场景一个接一个非常自然。第三分组按目录处理。我用元组解包dir_path, infos in grouped.items()遍历字典每个目录下的文件从 1 开始编号。这样不管你有多少个子目录每个目录的序号都是独立的更符合日常使用预期。第四重命名前做了防覆盖保护。新文件名可能跟已有文件同名如果直接os.rename()会把原有文件直接覆盖掉数据就丢了。所以我在执行前用os.path.exists()检查一下碰到同名就跳过。4.4 运行效果假设/Users/me/photos里有如下文件IMG_20240101_091530.jpg IMG_20240101_101122.png IMG_20231231_232001.jpg运行脚本后输出重命名: IMG_20231231_232001.jpg - 0001_2023-12-31_232001.jpg 重命名: IMG_20240101_091530.jpg - 0002_2024-01-01_091530.jpg 重命名: IMG_20240101_101122.png - 0003_2024-01-01_101122.png文件被整齐地按时间排了序再配合文件管理器按文件名排序整个目录的浏览体验会清爽很多。4.5 一个可以继续扩展的思路这个脚本的思路完全可以迁移到其他场景比如批量清理日志遍历目录删除超过 30 天且大于 100MB 的.log文件按扩展名归档把所有.pdf移动到Documents/PDF目录按年份再分一层图片批量压缩配合 PIL 库遍历所有.jpg并压缩后输出到新目录统计目录占用用os.scandir()遍历每个目录计算各子目录的文件大小总和这些场景本质上都是同一个套路用os.walk或os.scandir遍历用os.path处理路径用元组或者 namedtuple 组织数据最后执行os.rename、os.remove、os.makedirs等操作。5. 常见问题与避坑清单5.1 元组相关的高频坑问题一忘了单元素元组的逗号导致类型错误。这是所有 Python 初学者早晚都会踩的坑。排查方式很简单print(type(value))一看便知。问题二试图修改元组内嵌列表时以为元组“坏掉了”。其实这只是因为你对可变对象的理解不够。记住元组的不可变性守护的是一层引用不是深层对象。问题三解包时变量数量不匹配。比如a, b (1, 2, 3)会抛出ValueError: too many values to unpack。如果确实需要接收多余的项用星号解包a, b, *rest (1, 2, 3)。5.2 OS 模块相关的高频坑问题一os.listdir()返回的不是完整路径。这一点最容易让新手懵。拿到元素后要自己拼接os.path.join()才能得到完整路径否则你在后续判断文件类型、读取文件时都会原地爆炸。问题二os.getcwd()不等于脚本所在目录。这个我前面已经强调过。获取脚本目录一定要用os.path.dirname(os.path.abspath(__file__))而不是默认的当前工作目录。问题三Windows 下的路径分隔符问题。直接写/Users/me/data这种绝对路径在 Windows 下往往不存在。推荐始终用os.path.join()拼接路径程序才能跨平台运行。问题四os.remove()删除目录直接报错。删除文件要用os.remove()删除空目录用os.rmdir()删除非空目录用shutil.rmtree()。搞混了会有 PermissionError 或 IsADirectoryError。问题五文件正在被占用时重命名或删除会报错。这在 Windows 上尤其常见。如果脚本需要处理大量文件建议在操作前加 try/except 捕获异常打日志并跳过。5.3 一份速查表需求推荐函数说明当前工作目录os.getcwd()返回绝对路径拼接路径os.path.join()自动适配系统分隔符拆分目录和文件名os.path.split()返回二元组拆分扩展名os.path.splitext()返回二元组判断是否存在os.path.exists()软链接会跟随目标判断是文件还是目录os.path.isfile() / isdir()记得先判断 exists列出目录内容os.listdir()/os.scandir()后者性能更好递归遍历目录树os.walk()返回三元组创建单层目录os.mkdir()父目录不存在报错递归创建目录os.makedirs(exist_okTrue)推荐使用重命名文件os.rename()会覆盖同名文件需谨慎删除文件os.remove()只适用于文件删除空目录os.rmdir()只适用于空目录递归删除目录shutil.rmtree()危险操作慎用6. 我的实操心得与扩展建议学完元组和 OS 模块这一天最大的感受是这两块内容单看都不难但把它们组合在一起爆发出来的生产力非常高。元组帮你把数据结构整理得井井有条OS 模块帮你在文件系统层面操作自如两者结合你就能写出各种各样的自动化脚本从文件整理到备份清理都能轻松搞定。我实际做项目时已经养成了几个习惯。第一凡是创建后不打算修改的容器优先写成元组既安全又省内存。第二凡是涉及文件路径的代码一定会先用os.path.abspath或__file__把路径固定好避免运行时因为当前目录不一致而出错。第三遍历大量文件时优先用os.scandir()而不是os.listdir()性能差距在小文件多的目录里特别明显。最后再分享一个小技巧如果你在新项目里想用更现代、更优雅的路径处理方式可以了解一下pathlib模块。它把路径处理封装成了面向对象的方式例如Path(/users/me/data) / report.csv就能直接得到拼接后的路径。但即便如此我依然建议你先把 OS 模块的用法吃透原因很简单大量现存代码、网上教程、第三方库接口还在使用 os 家族的写法你看不懂os.walk、os.path.join就会错过很多高质量资料。把元组和 OS 模块的基础打牢你之后的自动化脚本之路会顺畅很多。