ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python 工匠(one-python-craftsman):高效操作文件的三个建议——pathlib、流式分块读取与文件对象设计

Python 工匠(one-python-craftsman):高效操作文件的三个建议——pathlib、流式分块读取与文件对象设计 技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载本文是开源系列文章「Python 工匠」的第 11 篇完整系列目录见 README.md。文章聚焦于日常最高频的“文件操作”场景围绕三个核心建议展开用pathlib简化路径与文件处理、用生成器分块流式读取大文件节约内存、以及设计接受“类文件对象”的函数以提升可测试性与可组合性。读完本文你将获得一套可直接套用的文件处理代码模板并理解其背后的标准库实现原理与设计取舍。前言在这个世界上人们每天都在用 Python 完成着不同的工作。而文件操作则是大家最常需要解决的任务之一。使用 Python你可以轻松为他人生成精美的报表也可以用短短几行代码快速解析、整理上万份数据文件。当我们编写与文件相关的代码时通常会关注这些事情我的代码是不是足够快我的代码有没有事半功倍的完成任务在这篇文章中我会与你分享与之相关的几个编程建议推荐一个被低估的 Python 标准库模块、演示一个读取大文件的最佳方式、最后再分享我对函数设计的一点思考。注意因为不同操作系统的文件系统大不相同本文的主要编写环境为 Mac OS/Linux 系统其中一些代码可能并不适用于 Windows 系统。建议一使用 pathlib 模块如果你需要在 Python 里进行文件处理那么标准库中的os和os.path兄弟俩一定是你无法避开的两个模块。在这两个模块里有着非常多与文件路径处理、文件读写、文件状态查看相关的工具函数。老牌选手os 与 os.path 的痛点让我用一个例子来展示一下它们的使用场景。有一个目录里装了很多数据文件但是它们的后缀名并不统一既有.txt又有.csv。我们需要把其中以.txt结尾的文件都修改为.csv后缀名。我们可以写出这样一个函数import os import os.path def unify_ext_with_os_path(path): 统一目录下的 .txt 文件名后缀为 .csv for filename in os.listdir(path): basename, ext os.path.splitext(filename) if ext .txt: abs_filepath os.path.join(path, filename) os.rename(abs_filepath, os.path.join(path, f{basename}.csv))让我们看看上面的代码一共用到了哪些与文件处理相关的函数os.listdir(path)列出 path 目录下的所有文件*含文件夹*os.path.splitext(filename)切分文件名里面的基础名称和后缀部分os.path.join(path, filename)组合需要操作的文件名为绝对路径os.rename(...)重命名某个文件上面的函数虽然可以完成需求但说句实话即使在写了很多年 Python 代码后我依然觉得这些函数不光很难记而且最终的成品代码也不怎么讨人喜欢。使用 pathlib 模块改写代码为了让文件处理变得更简单Python 在 3.4 版本引入了一个新的标准库模块pathlib。它基于面向对象思想设计封装了非常多与文件操作相关的功能。如果使用它来改写上面的代码结果会大不相同。from pathlib import Path def unify_ext_with_pathlib(path): for fpath in Path(path).glob(*.txt): fpath.rename(fpath.with_suffix(.csv))和旧代码相比新函数只需要两行代码就完成了工作。而这两行代码主要做了这么几件事首先使用Path(path)将字符串路径转换为Path对象调用.glob(*.txt)对路径下所有内容进行模式匹配并以生成器方式返回结果仍然是Path对象所以我们可以接着做后面的操作使用.with_suffix(.csv)直接获取使用新后缀名的文件全路径调用.rename(target)完成重命名注意第 2 点中glob的返回值它是一个生成器意味着即使目录中文件很多也不会一次性把所有匹配结果加载进内存同时由于每个匹配项都包装成了Path对象后续的with_suffix、rename等操作可以“链式”接续这正是面向对象封装带来的整体统一感。相比os和os.path引入pathlib模块后的代码明显更精简也更有整体统一感。所有文件相关的操作都是一站式完成。pathlib 的更多用法除此之外pathlib 模块还提供了很多有趣的用法。比如使用/运算符来组合文件路径# 旧朋友使用 os.path 模块 import os.path os.path.join(/tmp, foo.txt) /tmp/foo.txt # ✨ 新潮流使用 / 运算符 from pathlib import Path Path(/tmp) / foo.txt PosixPath(/tmp/foo.txt)/运算符对路径片段的“拼接”语义十分直观左侧是父目录右侧是要追加的路径片段返回结果是一个新的Path对象在 POSIX 系统上表现为PosixPath。它比os.path.join更符合人类的目录直觉也天然规避了不同平台上路径分隔符的差异问题。或者使用.read_text()来快速读取文件内容# 标准做法使用 with open(...) 打开文件 with open(foo.txt) as file: ... print(file.read()) ... foo # 使用 pathlib 可以让这件事情变得更简单 from pathlib import Path print(Path(foo.txt).read_text()) foo.read_text()一行即完成了“打开文件 → 读取内容 → 关闭文件”的全部流程省去了手动管理文件描述符的负担。除此之外pathlib 模块还提供了非常多有用的方法比如Path.iterdir()遍历目录、Path.mkdir(parentsTrue)递归创建目录、Path.write_text()写入内容、Path.stat()获取文件状态等强烈建议去官方文档详细了解一下。PEP-519 与 Path 对象的兼容性如果上面这些都不足以让你动心那么我再多给你一个使用 pathlib 的理由[PEP-519] 里定义了一个专门用于“文件路径”的新对象协议这意味着从该 PEP 生效后的 Python 3.6 版本起pathlib 里的 Path 对象可以和以前绝大多数只接受字符串路径的标准库函数兼容使用 p Path(/tmp) # 可以直接对 Path 类型对象 p 进行 join os.path.join(p, foo.txt) /tmp/foo.txt也就是说Path对象实现了 PEP-519 所定义的路径协议凡是遵守该协议的标准库函数都能把它当作路径来用。这让 pathlib 可以与存量代码平滑共存你不必一次性推翻所有os.path用法可以逐步把新代码迁移到 pathlib同时保持与旧代码的互操作性。所以无需犹豫赶紧把 pathlib 模块用起来吧。Hint:如果你使用的是更早的 Python 版本可以尝试安装 pathlib2 模块第三方镜像实现提供了几乎一致的 API。建议二掌握如何流式读取大文件几乎所有人都知道在 Python 里读取文件有一种“标准做法”首先使用with open(fine_name)上下文管理器的方式获得一个文件对象然后使用for循环迭代它逐行获取文件里的内容。下面是一个使用这种“标准做法”的简单示例函数def count_nine(fname): 计算文件里包含多少个数字 9 count 0 with open(fname) as file: for line in file: count line.count(9) return count假如我们有一个文件small_file.txt那么使用这个函数可以轻松计算出 9 的数量。# small_file.txt feiowe9322nasd9233rl aoeijfiowejf8322kaf9a # OUTPUT: 3 print(count_nine(small_file.txt))为什么这种文件读取方式会成为标准这是因为它有两个好处with上下文管理器会自动关闭打开的文件描述符在迭代文件对象时内容是一行一行返回的不会占用太多内存第二个好处的关键在于文件对象本身是可迭代的for line in file通过内部缓冲机制按行产出内容而不是把整个文件一次性读入内存。标准做法的缺点但这套标准做法并非没有缺点。如果被读取的文件里根本就没有任何换行符那么上面的第二个好处就不成立了。当代码执行到for line in file时line 将会变成一个非常巨大的字符串对象消耗掉非常可观的内存。让我们来做个试验有一个5GB大的文件big_file.txt它里面装满了和small_file.txt一样的随机字符串。只不过它存储内容的方式稍有不同所有的文本都被放在了同一行里# FILE: big_file.txt df2if283rkwefh... 剩余 5GB 大小 ...如果我们继续使用前面的count_nine函数去统计这个大文件里9的个数。那么在我的笔记本上这个过程会足足花掉65秒并在执行过程中吃掉机器2GB内存视机器空闲内存的多少这个过程可能会消耗比 2GB 更多的内存。原因不难理解按行迭代的前提是“行”的存在当整个文件只有一行时for line in file就退化为一次性的巨大字符串读取内存与耗时双双失控。使用 read 方法分块读取为了解决这个问题我们需要暂时把这个“标准做法”放到一边使用更底层的file.read()方法。与直接循环迭代文件对象不同每次调用file.read(chunk_size)会直接返回从当前位置往后读取chunk_size大小的文件内容不必等待任何换行符出现。所以如果使用file.read()方法我们的函数可以改写成这样def count_nine_v2(fname): 计算文件里包含多少个数字 9每次读取 8kb count 0 block_size 1024 * 8 with open(fname) as fp: while True: chunk fp.read(block_size) # 当文件没有更多内容时read 调用将会返回空字符串 if not chunk: break count chunk.count(9) return count在新函数中我们使用了一个while循环来读取文件内容每次最多读取 8kb 大小这样可以避免之前需要拼接一个巨大字符串的过程把内存占用降低非常多。这里有两个实现细节值得注意block_size 1024 * 88KB是一个兼顾性能与内存的常见选择块太小会增加调用次数与开销块太大则又回到内存压力的老路上你可以根据文件特性和可用内存调整该值终止条件依赖read的返回值当读到文件末尾时read会返回空字符串据此break跳出循环。利用生成器解耦代码假如我们在讨论的不是 Python而是其他编程语言。那么可以说上面的代码已经很好了。但是如果你认真分析一下count_nine_v2函数你会发现在循环体内部存在着两个独立的逻辑数据生成read 调用与 chunk 判断与数据消费。而这两个独立逻辑被耦合在了一起。正如我在系列文章 《编写地道循环的两个建议》 里所提到的为了提升复用能力我们可以定义一个新的chunked_file_reader生成器函数由它来负责所有与“数据生成”相关的逻辑。这样count_nine_v3里面的主循环就只需要负责计数即可。def chunked_file_reader(fp, block_size1024 * 8): 生成器函数分块读取文件内容 while True: chunk fp.read(block_size) # 当文件没有更多内容时read 调用将会返回空字符串 if not chunk: break yield chunk def count_nine_v3(fname): count 0 with open(fname) as fp: for chunk in chunked_file_reader(fp): count chunk.count(9) return count把“数据生成”抽成独立的生成器函数后chunked_file_reader成为了一件通用工具无论是统计字符、解析 JSON、计算哈希还是做文本预处理任何“分块消费大文件”的需求都可以直接复用它count_nine_v3则回归到“只做消费”的单一职责。这与第 7 篇文章中“按职责拆解循环体内复杂代码块”的思路一脉相承——循环体内容越聚焦代码越容易被复用与测试。iter(callable, sentinel)更进一步的精简进行到这一步代码似乎已经没有优化的空间了但其实不然。iter(iterable)是一个用来构造迭代器的内建函数但它还有一个更少人知道的用法。当我们使用iter(callable, sentinel)的方式调用它时会返回一个特殊的对象迭代它将不断产生可调用对象 callable 的调用结果直到结果为 sentinel 时迭代终止。def chunked_file_reader(file, block_size1024 * 8): 生成器函数分块读取文件内容使用 iter 函数 # 首先使用 partial(fp.read, block_size) 构造一个新的无需参数的函数 # 循环将不断返回 fp.read(block_size) 调用结果直到其为 时终止 for chunk in iter(partial(file.read, block_size), ): yield chunk最终只需要两行代码我们就完成了一个可复用的分块文件读取函数。这段代码用到了两个配合精妙的标准库特性functools.partial(file.read, block_size)把需要传参的read(block_size)调用“冻结”成一个无参可调用对象partial的更多用法可参考系列文章 《让函数返回结果的技巧》那里展示了用partial代替“薄封装函数”的实践iter(callable, sentinel)用哨兵值作为终止信号自动把“不断调用 read 直到返回空串”这个过程变成标准的迭代器完全替代了手写的while Truebreak。那么这个函数在性能方面的表现如何呢和一开始的2GB 内存 / 耗时 65 秒相比使用生成器的版本只需要7MB 内存 / 12 秒就能完成计算。效率提升了接近 4 倍内存占用更是不到原来的 1%。注以上性能数据来自作者在原文中的笔记本实测具体数值会因机器配置、磁盘速度与文件内容而异但“分块读取能显著降低内存峰值”的结论是确定的。建议三设计接受文件对象的函数统计完文件里的 “9” 之后让我们换一个需求。现在我想要统计每个文件里出现了多少个英文元音字母*aeiou*。只要对之前的代码稍作调整很快就可以写出新函数count_vowels。def count_vowels(filename): 统计某个文件中包含元音字母(aeiou)的数量 VOWELS_LETTERS {a, e, i, o, u} count 0 with open(filename, r) as fp: for line in fp: for char in line: if char.lower() in VOWELS_LETTERS: count 1 return count # OUTPUT: 16 print(count_vowels(small_file.txt))和之前“统计 9”的函数相比新函数变得稍微复杂了一些。为了保证程序的正确性我需要为它写一些单元测试。但当我准备写测试时却发现这件事情非常麻烦主要问题点如下函数接收文件路径作为参数所以我们需要传递一个实际存在的文件为了准备测试用例我要么提供几个样板文件要么写一些临时文件而文件是否能被正常打开、读取也成了我们需要测试的边界情况如果你发现你的函数难以编写单元测试那通常意味着你应该改进它的设计。上面的函数应该如何改进呢答案是让函数依赖“文件对象”而不是文件路径。修改后的函数代码如下def count_vowels_v2(fp): 统计某个文件中包含元音字母(aeiou)的数量 VOWELS_LETTERS {a, e, i, o, u} count 0 for line in fp: for char in line: if char.lower() in VOWELS_LETTERS: count 1 return count # 修改函数后打开文件的职责被移交给了上层函数调用者 with open(small_file.txt) as fp: print(count_vowels_v2(fp))这个改动带来的主要变化在于它提升了函数的适用面。因为 Python 是“鸭子类型”的虽然函数需要接受文件对象但其实我们可以把任何实现了文件协议的 “类文件对象file-like object” 传入count_vowels_v2函数中。而 Python 中有着非常多“类文件对象”。比如 io 模块内的StringIO对象就是其中之一。它是一种基于内存的特殊对象拥有和文件对象几乎一致的接口设计。用 StringIO 轻松编写单元测试利用 StringIO我们可以非常方便的为函数编写单元测试。# 注意以下测试函数需要使用 pytest 执行 import pytest from io import StringIO pytest.mark.parametrize( content,vowels_count, [ # 使用 pytest 提供的参数化测试工具定义测试参数列表 # (文件内容, 期待结果) (, 0), (Hello World!, 3), (HELLO WORLD!, 3), (你好世界, 0), ] ) def test_count_vowels_v2(content, vowels_count): # 利用 StringIO 构造类文件对象 file file StringIO(content) assert count_vowels_v2(file) vowels_count使用 pytest 运行测试可以发现函数可以通过所有的用例❯ pytest vowels_counter.py test session starts collected 4 items vowels_counter.py ... [100%] 4 passed in 0.06 seconds 这段测试至少体现了三个收益零文件依赖测试不再需要真实文件系统纯内存构造StringIO测试速度快且互不干扰参数化覆盖边界pytest 的pytest.mark.parametrize把“空内容”“全大写”“含非英文字符”等边界情况列成表格一目了然测试关注点收敛count_vowels_v2只关心“从流中统计元音”不再涉及“文件是否存在、能否打开”这类 IO 边界问题——后者本就该由打开文件的调用方负责。subprocess.PIPE函数的新客户而让编写单元测试变得更简单并非修改函数依赖后的唯一好处。除了 StringIO 外subprocess 模块调用系统命令时用来存储标准输出的PIPE对象也是一种“类文件对象”。这意味着我们可以直接把某个命令的输出传递给count_vowels_v2函数来计算元音字母数import subprocess # 统计 /tmp 下面所有一级子文件名目录名有多少元音字母 p subprocess.Popen([ls, /tmp], stdoutsubprocess.PIPE, encodingutf-8) # p.stdout 是一个流式类文件对象可以直接传入函数 # OUTPUT: 42 print(count_vowels_v2(p.stdout))注意这里的p.stdout甚至是一个流式对象子进程边输出、函数边读取整条数据通路无需落盘。这正是“面向接口编程”的价值——正如系列文章 《写好面向对象代码的原则上》 中对“面向容器接口编程”的讨论依赖抽象文件协议而非具体实现磁盘上的路径会让函数的使用场景自动拓宽。一个折中方案兼容文件路径与文件对象不过这样的改造并非毫无缺点它也会给调用方带来一些不便。假如调用方就是想要使用文件路径那么就必须得自行处理文件的打开操作。有没有办法即拥有“接受文件对象”的灵活性又能让传递文件路径的调用方更方便答案是有而且标准库中就有这样的例子。打开标准库里的xml.etree.ElementTree模块翻开里面的ElementTree.parse方法。你会发现这个方法即可以使用文件对象调用也接受字符串的文件路径。而它实现这一点的手法也非常简单易懂def parse(self, source, parserNone): *source* is a file name or file object, *parser* is an optional parser close_source False # 通过判断 source 是否有 read 属性来判定它是不是类文件对象 # 如果不是那么调用 open 函数打开它并负担起在函数末尾关闭它的责任 if not hasattr(source, read): source open(source, rb) close_source True这段源码的精髓在于一个基于“鸭子类型”的检测hasattr(source, read)。有read方法 → 按文件对象处理直接使用没有 → 认为是路径字符串open打开并把“关闭文件”的责任揽到自己身上close_source True标记会在函数收尾时决定是否close。使用这种基于“鸭子类型”的灵活检测方式count_vowels_v2函数也同样可以被改造得更方便——只需在函数开头加上同样的hasattr判断即可同时服务“传路径”与“传文件对象”两类调用方。仓库实例面向 file-like 对象设计这种“函数依赖类文件对象”的思想在本仓库的系列文章中还有更完整的实战佐证。在 《写好面向对象代码的原则上》 里作者设计了一个HNTopPostsSpider类它的构造器接收任意 file-like 对象def main(): # with open(/tmp/hn_top5.txt) as fp: # crawler HNTopPostsSpider(fp) # crawler.write_to_file() # 因为 HNTopPostsSpider 接收任何 file-like 的对象所以我们可以把 sys.stdout 传进去 # 实现往控制台标准输出打印的功能 crawler HNTopPostsSpider(sys.stdout) crawler.write_to_file()同一个类传入open()得到的文件对象就往文件里写传入sys.stdout就往控制台打印——零改动地切换输出目标。这正是“让依赖指向更抽象的文件对象/协议”带来的可组合性的直观体现与本文建议三的主旨完全同构。正如之前所说将函数参数修改为“文件对象”最大的好处是提高了函数的适用面和可组合性。通过依赖更为抽象的“类文件对象”而非文件路径给函数的使用方式开启了更多可能StringIO、PIPE 以及任何其他满足协议的对象都可以成为函数的客户。总结文件操作是我们日常工作中经常需要接触的领域使用更方便的模块、利用生成器节约内存以及编写适用面更广的函数可以让我们编写出更高效的代码。让我们最后再总结一下吧使用 pathlib 模块可以简化文件和目录相关的操作并让代码更直观PEP-519 定义了表示“文件路径”的标准协议Path 对象实现了这个协议通过定义生成器函数来分块读取大文件可以节约内存使用iter(callable, sentinel)可以在一些特定场景简化代码难以编写测试的代码通常也是需要改进的代码让函数依赖“类文件对象”可以提升函数的适用面和可组合性相关阅读Python 工匠编写地道循环的两个建议生成器解耦循环体、用修饰函数优化循环是本文建议二的思路来源Python 工匠让函数返回结果的技巧partial 构造新函数、生成器代替返回列表Python 工匠写好面向对象代码的原则上面向接口编程与 file-like 对象的实战案例Python 工匠做一个精通规则的玩家上一篇关于语言规则与对象协议Python 工匠在边界处思考测试中边界情况的处理思路README.md全部 16 篇文章的系列索引赞分享技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载相关推荐3大技术突破OpenCore Legacy Patcher让老Mac重获新生的终极方案3大技术突破OpenCore Legacy Patcher让老Mac重获新生的终极方案 当你的MacBook Pro 2015在App Store中看到此更操作系统固件驱动开发CS硕士论文写作教程awesome-thesis科学写作心法、写作障碍破解与4个免费拼写检查工具清单CS硕士论文写作教程awesome thesis科学写作心法、写作障碍破解与4个免费拼写检查工具清单 项目简介什么是 awesome thesis awe上一篇Racket机器学习实战指南如何用函数式编程构建AI应用下一篇Mermaid Live Editor几行代码画出流程图免费在线图表编辑器完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表