分享

Python—文件操作的最佳实践

 网摘文苑 2022-12-05 发布于新疆
Python—文件操作的最佳实践

作为一名 Python 开发人员,我们每天都使用 Python 来完成不同的任务。而文件操作是最常见的任务之一。使用 Python,你可以轻松为他人生成精美的报告,只需几行代码即可快速解析和组织数以万计的数据文件。

当我们编写文件相关的代码时,我们通常会关注这些事情:我的代码是否足够快?我的代码是否事半功倍?在这篇文章中,我将向大家推荐一个被低估的 Python 标准库模块,演示一种读取大文件的最佳方式,最后分享我对函数设计的一些想法。

使用 os or os.path 模块

如果你需要在 Python 中做文件处理,标准库中的osos.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'))

我们看看上面代码中用到了哪些文件处理相关的函数:

  • os.listdir(path): 列出路径目录下的所有文件
  • os.path.splitext(filename): 分割文件名的基名和后缀部分
  • os.path.join(path, filename): 需要操作的组合的文件名是绝对路径
  • os.rename(...): 重命名文件

使用 pathlib 模块

为了使文件处理更容易,Python 在 3.4 版本中引入了一个新的标准库模块:pathlib它基于面向对象的思想设计,封装了很多与文件操作相关的功能。如果你用它来重写上面的代码,结果会很不一样。

使用pathlib模块后的代码:

from pathlib import Path def unify_ext_with_pathlib(path):     for fpath in Path(path).glob('*.txt'):         fpath.rename(fpath.with_suffix('.csv'))

与旧代码相比,新功能只需要两行代码即可完成工作。而这两行代码主要做了以下几件事:

  1. 首先使用将字符串路径转换为对象Path(path)
  2. 调用.glob('*.txt’)模式匹配路径下的所有内容并作为生成器返回,结果仍然是一个Path对象,所以我们可以继续做下面的操作
  3. .with_suffix('.csv’)直接获取新后缀文件的全路径
  4. 调用.rename(target)重命名文件

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()快速读取文件内容:

>>> with open('test.txt') as file:... print(file.read()) ... test >>> from pathlib import Path >>> print(Path('test.txt') .read_text()) test

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通过循环对其进行迭代,逐行获取文件的内容。例如:

def count_nine(fname):     count = 0     with open(fname) as file:         for line in file:             count += line.count('9')     return count

为什么这种读取文件的方式会成为标准?这是因为它有两个好处:

  1. with:上下文管理器自动关闭打开的文件描述符
  2. 遍历文件对象时,逐行返回内容,不会占用太多内存

但这种标准做法并非没有缺点。如果正在读取的文件根本不包含任何换行符,那么上面的第二个好处不成立。当代码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里面的主循环只需要负责计数即可。

def chunked_file_reader(fp, block_size=1024 * 8):     '''generator:分块读取文件    '''     while True:         chunk = fp.read(block_size)         # 如果没有内容        if not chunk:             break         yield chunk def count_nine_v3( fname):     count = 0     with open(fname) as fp:         for chunked_file_reader(fp):             count += chunk.count('9')     return count

至此,代码似乎已经没有优化的余地了,其实不然。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(filename):     '''count (aeiou)s     '''     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('test.txt'))

为了保证程序的正确性,我们需要为它编写一些单元测试。但是当我准备写一个测试的时候,我发现它很麻烦。主要问题如下:

  1. 该函数接受文件路径作为参数,所以我们需要传递一个实际的文件
  2. 为了准备测试用例,我们需要提供一些样板文件或编写一些临时文件
  3. 文件能否正常打开和读取成为我们需要测试的边界条件

一般来说,如果你发现你的函数难以编写单元测试,那通常意味着你应该改进它的设计。上面的功能应该如何改进?答案是:使函数依赖于“文件对象”而不是文件路径

让我们尝试一下:

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,我们可以非常方便地为函数编写单元测试。

import pytest from io import StringIO @pytest.mark.parametrize(     'content,vowels_count', [         # 定义测试用例        # (content, expected_output)         ('', 0),         ('test!', 3),         ('test!', 3),         ('test', 0),     ] ) def test_count_vowels_v2(content, vowels_count):     # 使用StringIO构造“文件”     file = StringIO(content)     assert count_vowels_v2(file) == vowels_count

将函数参数更改为“文件对象”的最大好处是提高了函数的适用性和可组合性。

通过依赖更抽象的“类文件对象”而不是文件路径,它为如何使用函数开辟了更多可能性。StringIO、PIPE 和任何其他满足协议的对象都可以是函数的客户端。

结论

文件操作是我们日常工作中经常需要接触的领域。使用更方便的模块,使用生成器节省内存,编写更多适用的函数,可以让我们编写更高效的代码。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约