当前位置: 首页 > 科技观察

围观高手如何用Python处理文件?

时间:2023-03-14 19:05:57 科技观察

在这个世界上,人们每天都在使用Python从事不同的工作。文件操作是每个人都需要解决的最常见的任务之一。使用Python,您可以轻松为他人生成精美的报告,只需几行代码即可快速解析和整理数万个数据文件。当我们编写与文件相关的代码时,我们通常会关注这些事情:我的代码是否足够快?我的代码是不是事半功倍?在这篇文章中,我将与大家分享一些与之相关的编程建议。我会向你推荐一个被低估的Python标准库模块,演示读取大文件的最佳方式,最后分享我对功能设计的思考。接下来,让我们进入第一次“模块安利”时间。注意:由于不同操作系统的文件系统差异较大,本文主要写作环境为MacOS/Linux系统,部分代码可能不适用于Windows系统。建议一:使用pathlib模块如果你需要在Python中进行文件处理,那么标准库中的os和os.path兄弟一定是你绕不开的两个模块。在这两个模块中,有很多与文件路径处理、文件读写、文件状态查看相关的工具函数。让我用一个例子来展示他们的使用场景。有一个目录,里面有很多数据文件,但是后缀不统一,.txt和.csv都有。我们需要修改所有以.txt结尾的文件为.csv后缀。我们可以写这样一个函数:importosimportos.pathdefunify_ext_with_os_path(path):"""统一目录下的.txt文件名后缀为.csv"""forfilenameinos.listdir(path):basename,ext=os.path.splitext(filename)ifext=='.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):拆分文件名Basename和suffix部分os.path.join(path,filename):将要操作的文件名与绝对路径组合os.rename(...):重命名文件。老实说,即使写了很多年的Python代码,我仍然觉得:这些函数不仅难记,而且最终的产品代码也不是很讨喜。使用pathlib模块重写代码为了使文件处理更容易,Python在3.4版本中引入了一个新的标准库模块:pathlib。它是基于面向对象的思想设计的,封装了很多与文件操作相关的函数。如果用它来重写上面的代码,结果会大不相同。使用pathlib模块后的代码:frompathlibimportPathdefunify_ext_with_pathlib(path):forfpathinPath(path).glob('*.txt'):fpath.rename(fpath.with_suffix('.csv'))与旧代码相比,新函数只需要两行代码就搞定了。而这两行代码主要做了以下事情:首先使用Path(path)将字符串路径转换为Path对象调用.glob('*.txt')对路径下的所有内容进行模式匹配,并使用generator方法返回,结果还是一个Path对象,所以我们可以使用.with_suffix('.csv')直接获取新后缀文件的完整路径。调用.rename(target)完成相对于os和os的重命名。path,引入pathlib模块后,代码明显更精简,更统一。所有与文件相关的操作都是一站式完成的。其他用法除此之外,pathlib模块还提供了许多有趣的用法。例如,使用/运算符组合文件路径:#😑老朋友:使用os.path模块>>>importos.path>>>os.path.join('/tmp','foo.txt')'。read_text()快速读取文件内容:#标准做法,使用withopen(...)打开文件>>>withopen('foo.txt')asfile:...print(file.read())。..foo#使用pathlib可以让这个更简单>>>frompathlibimportPath>>>print(Path('foo.txt').read_text())foo除了我在文章中介绍的那些之外,pathlib模块还提供了这么多好用的方法,强烈建议多去官方文档了解一下。如果以上还不足以打动你,那我再给你一个使用pathlib的理由:PEP-519定义了一个新的对象协议,专用于“文件路径”,这意味着PEP生效后,从Python3.6开始,pathlib中的Path对象可以兼容之前大部分只接受字符串路径的标准库函数:>>>p=Path('/tmp')#可以直接执行Path对象pjoin>>>os.path.join(p,'foo.txt')'/tmp/foo.txt'因此,请毫不犹豫地使用pathlib模块。提示:如果您使用的是较早的Python版本,请尝试安装pathlib2模块。建议二:掌握如何流式读取大文件几乎所有人都知道Python读取文件有一个“标准做法”:先使用withopen(fine_name)上下文管理器获取一个文件对象,然后使用for循环遍历它逐行获取文件的内容。下面是一个使用此“标准做法”的简单示例函数:defcount_nine(fname):"""计算文件中有多少位'9'"""count=0withopen(fname)asfile:forlineinfile:count+=line。count('9')returncount如果我们有一个文件small_file.txt,那么使用这个函数可以很容易地计算出9的个数。#small_file.txtfeowe9322nasd9233rlaoeijfiowejf8322kaf9a#OUTPUT:3print(count_nine('small_file.txt'))为什么这种文件读取方式会成为标准?这是因为它有两个优点:with上下文管理器会自动关闭打开的文件描述符,当遍历文件对象时,内容是逐行返回的,不会占用太多内存。标准做法的缺点,但这种标准做法并非没有缺点。如果正在读取的文件根本没有任何换行符,那么上面的第二个好处将不成立。当代码执行到forlineinfile时,line会变成一个非常巨大的字符串对象,非常消耗内存。我们来做个实验:有一个5GB的文件big_file.txt,里面填充了和small_file.txt一样的随机字符串。只是它存储内容的方式略有不同,所有的文本都放在同一行:#FILE:big_file.txtdf2if283rkwefh......如果我们继续使用前面的count_nine函数来统计这个大文件中9的数量。所以在我的笔记本电脑上,这个过程将花费整整65秒,并在执行期间占用2GB的机器内存[注1]。使用read方法分块读取要解决这个问题,我们需要暂时搁置这个“标准做法”,使用更底层的file.read()方法。不同于直接循环迭代文件对象,每次调用file.read(chunk_size)都会直接返回从当前位置向后读取的chunk_size大小的文件内容,而不需要等待任何换行符的出现。所以,如果我们使用file.read()方法,我们的函数可以改写如下:defcount_nine_v2(fname):"""计算文件中包含多少个数字'9',每次读取8kb"""count=0block_size=1024*8withopen(fname)asfp:whileTrue:chunk=fp.read(block_size)#当文件没有更多内容时,read调用将返回一个空字符串''ifnotchunk:breakcount+=chunk.count('9')returncount在新函数中,我们使用while循环读取文件内容,每次最大大小为8kb,可以避免之前拼接一个巨大字符串的过程,大大减少了内存占用。使用生成器解耦代码假设我们不是在谈论Python,而是在谈论其他一些编程语言。那么可以说上面的代码已经好了。但是仔细分析count_nine_v2这个函数就会发现,在循环体内,有两个独立的逻辑:数据生成(read调用和chunk判断)和数据消费。而这两个独立的逻辑是耦合在一起的。正如我在《编写地道循环》中提到的,为了提高可重用性,我们可以定义一个新的chunked_file_reader生成器函数,它负责所有与“数据生成”相关的逻辑。这样count_nine_v3中的主循环只需要负责计数即可。defchunked_file_reader(fp,block_size=1024*8):"""Generatorfunction:readfilecontentinchunks"""whileTrue:chunk=fp.read(block_size)#当文件中没有更多内容时,读取调用将返回一个空字符串''ifnotchunk:breakyieldchunkdefcount_nine_v3(fname):count=0withopen(fname)asfp:forchunkinchunked_file_reader(fp):count+=chunk.count('9')returncount到这里,代码似乎没有优化的余地了,但事实并非如此。iter(iterable)是一个用于构造迭代器的内置函数,但它还有一个鲜为人知的用法。当我们使用iter(callable,sentinel)调用它时,会返回一个特殊的对象,迭代它会不断产生可调用对象callable的调用结果,直到结果为setinel,迭代结束。defchunked_file_reader(file,block_size=1024*8):"""生成器函数:分块读取文件内容,使用iter函数"""#首先使用partial(fp.read,block_size)构造一个无参数的新函数#循环会一直返回fp.read(block_size)调用的结果,直到''结束forchunkiniter(partial(file.read,block_size),''):yieldchunk最后只需要两行代码,我们完成了一个Reusablechunked文件读取功能。那么,这个函数在性能上表现如何呢?使用生成器的版本只需要7MB内存/12秒即可完成计算,而原来的2GB内存/65秒。效率提升近4倍,内存占用不到原来的1%。建议三:设计一个接受文件对象的函数数完文件中的“9”之后,我们来改一下需求。现在,我想统计每个文件中出现了多少个英文元音字母(aeiou)。通过对之前的代码进行一些调整,可以立即编写新函数count_vowels。defcount_vowels(filename):"""计算文件中元音(aeiou)的数量"""VOWELS_LETTERS={'a','e','i','o','u'}count=0withopen(filename,'r')asfp:forlineinfp:forcharinline:ifchar.lower()inVOWELS_LETTERS:count+=1returncount#OUTPUT:16print(count_vowels('small_file.txt'))与之前的“统计9”函数相比,新函数得到了一个稍微复杂一点。为了保证程序的正确性,我需要为它写一些单元测试。但是当我准备写测试的时候,发现很麻烦。主要问题如下:该函数接收文件路径作为参数,因此我们需要传递一个实际文件。为了准备测试用例,我要么提供几个样板文件,要么写一些临时文件,文件是否能正常打开和读取,就成了我们需要测试的一个boundarycase。如果你发现你的函数很难编写单元测试,通常意味着你应该改进它的设计。上面的功能应该如何改进呢?答案是:使函数依赖于“文件对象”而不是文件路径。修改后的函数代码如下:defcount_vowels_v2(fp):"""统计一个文件中元音字母(aeiou)的个数"""VOWELS_LETTERS={'a','e','i','o','u'}count=0forlineinfp:forcharinline:ifchar.lower()inVOWELS_LETTERS:count+=1returncount#修改函数后,打开文件的责任交给上层函数调用者withopen('small_file.txt')asfp:print(count_vowels_v2(fp))这个改动带来的主要变化是提高了函数的适用性。因为Python是“duck-typed”的,虽然函数需要接受一个文件对象,但实际上我们可以将任何实现文件协议的“类文件对象”传递给count_vowels_v2函数。并且在Python中有大量的“类文件对象”。例如io模块中的StringIO对象就是其中之一。它是一种特殊的基于内存的对象,其接口设计与文件对象几乎相同。使用StringIO,我们可以非常方便的编写函数的单元测试。#注意:以下测试函数需要使用pytest执行importpytestfromioimportStringIO@pytest.mark.parametrize("content,vowels_count",[#使用pytest提供的参数化测试工具定义测试参数列表#(文件内容,预期结果)('',0),('HelloWorld!',3),('HELLOWORLD!',3),('Hello,world',0),])deftest_count_vowels_v2(content,vowels_count):#使用StringIO构造afile-likeobject"file"file=StringIO(content)assertcount_vowels_v2(file)==vowels_count使用pytest运行测试发现该函数可以通过所有用例:?pytestvowels_counter.py======testsessionstarts======collected4itemsvowels_counter.py。..[100%]======4passedin0.06seconds======使编写单元测试更容易并不是修改功能依赖项的唯一好处。除了StringIO,subprocess模块??调用系统命令时用来存储标准输出的PIPE对象也是一个“类文件对象”。也就是说我们可以直接将某个命令的输出传递给count_vowels_v2函数来统计元音的个数:importsubprocess#统计/tmp下所有一级子文件名(目录名)有多少个元音p=subprocess.Popen(['ls','/tmp'],stdout=subprocess.PIPE,encoding='utf-8')#p.stdout是一个类流文件对象,可以直接传入函数#OUTPUT:42print(count_vowels_v2(p.stdout))之前说过,将函数参数改为“文件对象”最大的好处是提高了函数的适用性和可组合性。通过依赖更抽象的“类文件对象”而不是文件路径,它为函数的使用方式开辟了更多可能性。StringIO、PIPE和任何其他符合协议的对象都可以是函数的客户端。但是,这样的改造也不是没有缺点,也会给调用者带来一些不便。如果调用者只是想使用文件路径,那么它必须自己处理文件打开操作。如何编写一个与两者兼容的函数?有没有办法既有“接受文件对象”的灵活性,又能更方便调用者传递文件路径?答案是:是的,标准库中有这方面的例子。打开标准库中的xml.etree.ElementTree模块,打开里面的ElementTree.parse方法。您会注意到可以使用文件对象或文件路径作为字符串来调用此方法。而它实现的方式也很简单易懂:defparse(self,source,parser=None):"""*source*isafilenameorfileobject,*parser*isanoptionalparser"""close_source=False#通过判断是否是source有“read”属性判断是否为“类文件对象”#如果不是,则调用open函数打开并负责在函数末尾关闭ifnothasattr(source,"read"):source=open(source,"rb")close_source=True利用这种基于“鸭子打字”的灵活检测方式,count_vowels_v2函数也可以改造得更加方便,这里不再赘述。总结一下我们在日常工作中经常需要接触的文件操作领域,使用更方便的模块,使用生成器节省内存,编写应用范围更广的函数,可以让我们写出更高效的代码。总结一下:使用pathlib模块可以简化文件和目录相关的操作,让代码更直观PEP-519定义了一个表示“文件路径”的标准协议,Path对象通过定义一个生成器函数来实现这个协议读取大文件inchunks可以节省内存。使用iter(callable,sentinel)可以在某些特定场景下简化代码。难以编写和测试的代码通常是需要改进的代码。让函数依赖“类文件对象”,提高函数的适用性和可组合性