作为一名 Python 开发人员,我们每天都使用 Python 来完成不同的任务。而文件操作是最常见的任务之一。使用 Python,你可以轻松为他人生成精美的报告,只需几行代码即可快速解析和组织数以万计的数据文件。 当我们编写文件相关的代码时,我们通常会关注这些事情:我的代码是否足够快?我的代码是否事半功倍?在这篇文章中,我将向大家推荐一个被低估的 Python 标准库模块,演示一种读取大文件的最佳方式,最后分享我对函数设计的一些想法。 使用 os or os.path 模块如果你需要在 Python 中做文件处理,标准库中的os和os.path模块必须是你无法避免的两个模块。在这两个模块中,有很多与文件路径处理、文件读写、文件状态查看相关的工具功能。 让我用一个例子来说明它们是如何使用的。假设一个目录下安装了很多数据文件,但它们的后缀不统一,则.csv和.txt文件都有。我们需要将所有.txt文件更改为.csv后缀。 可以快速编写如下函数: import osimport os.pathdef unify_ext_with_os_path(path): 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, 'test.csv')) 我们看看上面代码中用到了哪些文件处理相关的函数:
使用 pathlib 模块为了使文件处理更容易,Python 在 3.4 版本中引入了一个新的标准库模块:pathlib它基于面向对象的思想设计,封装了很多与文件操作相关的功能。如果你用它来重写上面的代码,结果会很不一样。 使用pathlib模块后的代码:
与旧代码相比,新功能只需要两行代码即可完成工作。而这两行代码主要做了以下几件事:
与os和相比os.path,引入pathlib模块后的代码明显简单,整体统一感更强。所有与文件相关的操作都一站式完成。 除此之外,该pathlib模块还提供了许多有趣的用途。例如,使用/运算符组合文件路径: >>> import os.path >>> os.path.join('/tmp', 'test.txt') '/tmp/test.txt' >>> from pathlib import Path >>> Path('/tmp ') / 'test.txt' PosixPath('/tmp/test.txt') 或用于.read_text()快速读取文件内容:
PEP-519为“文件路径”定义了一个新的对象协议,这意味着从 PEP 生效开始,从 Python 3.6 开始,Path 对象pathlib可以与大多数只接受字符串路径的标准库函数兼容: >>> p = Path('/tmp') >>> os.path.join(p, 'test.txt') '/tmp/test.txt' 流式传输大文件几乎每个人都知道在 Python 中读取文件有一个“标准做法”:首先使用with open(fine_name)上下文管理器获取文件对象,然后for通过循环对其进行迭代,逐行获取文件的内容。例如:
为什么这种读取文件的方式会成为标准?这是因为它有两个好处:
但这种标准做法并非没有缺点。如果正在读取的文件根本不包含任何换行符,那么上面的第二个好处不成立。当代码for line in file执行时,line 会变成一个非常大的字符串对象,消耗非常可观的内存。 分块阅读为了解决这个问题,我们需要暂时把这个“标准做法”放在一边,使用更底层的file.read()方法。每次调用都不会在循环中遍历文件对象,而是file.read(chunk_size)直接返回chunk_size从当前位置读取的大小的文件内容,而无需等待任何换行符出现。 所以新的代码片段看起来像: def count_nine_v2(fname): '''计数总共9s,每次读取8kb ''' count = 0 block_size = 1024 * 8 with open(fname) as fp: while True: chunk = fp.read(block_size) # If no more content if not chunk: break count += chunk.count('9') return count 在新函数中,我们使用while循环读取文件的内容,每次读取最大大小为8kb,这样可以避免之前拼接一个巨大的字符串的过程,并且减少了很多内存使用。 将代码与生成器解耦假设我们不是在谈论 Python,而是在谈论其他编程语言。那么可以说上面的代码已经不错了。但是如果你分析count_nine_v2函数,你会发现循环体内有两个独立的逻辑:数据生成(读取调用和块判断)和数据消耗。并且这两个独立的逻辑耦合在一起。 为了提高可重用性,我们可以定义一个新的chunked_file_reader生成器函数,它负责所有与“数据生成”相关的逻辑。这样,count_nine_v3里面的主循环只需要负责计数即可。
至此,代码似乎已经没有优化的余地了,其实不然。iter(iterable)是用于构造迭代器的内置函数,但它也有一个鲜为人知的用法。 当我们使用iter(callable, sentinel)method调用它的时候,它会返回一个特殊的对象,并且会不断的生成callable对象callable的callable结果,直到结果为sentinel,迭代终止。 def chunked_file_reader(file, block_size=1024 * 8): '''Generator:使用iter分块读取文件 ''' # 使用partial(fp.read, block_size)构造一个新的func # 读取并返回fp.read( block_size) until '' for chunk in iter(partial(file.read, block_size), ''): yield chunk 最后,只用了两行代码,我们就完成了一个可复用的分块文件读取功能。 文件对象设计假设我们要计算每个文件中出现了多少个英语元音 (aeiou)。只需对以前的代码进行一些调整,就可以立即编写新函数count_vowels。
为了保证程序的正确性,我们需要为它编写一些单元测试。但是当我准备写一个测试的时候,我发现它很麻烦。主要问题如下:
一般来说,如果你发现你的函数难以编写单元测试,那通常意味着你应该改进它的设计。上面的功能应该如何改进?答案是:使函数依赖于“文件对象”而不是文件路径。 让我们尝试一下: def count_vowels_v2(fp): '''Count (aeiou)s ''' 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 countwith open('test.txt') as fp: print(count_vowels_v2(fp)) 主要的变化是提高了函数的适用性。因为 Python 是“鸭子类型的”,虽然函数需要接受一个文件对象,但我们实际上可以将任何实现文件协议的“类文件对象”传递给count_vowels_v2函数。 而且 Python 有很多“类文件对象”。例如,io模块中的 StringIO 对象就是其中之一。它是一种特殊的基于内存的对象,其界面设计与文件对象几乎相同。 使用 StringIO,我们可以非常方便地为函数编写单元测试。
将函数参数更改为“文件对象”的最大好处是提高了函数的适用性和可组合性。 通过依赖更抽象的“类文件对象”而不是文件路径,它为如何使用函数开辟了更多可能性。StringIO、PIPE 和任何其他满足协议的对象都可以是函数的客户端。 结论文件操作是我们日常工作中经常需要接触的领域。使用更方便的模块,使用生成器节省内存,编写更多适用的函数,可以让我们编写更高效的代码。 |
|