Python Artisan: три совета по эффективному управлению файлами

Python

предисловие

Это 11-я статья из серии "Мастер Python".[Посмотреть все статьи цикла]

В этом мире люди используют Python для решения разных задач каждый день. А манипуляции с файлами — одна из самых распространенных задач, которую необходимо решать каждому. Используя Python, вы можете легко создавать красивые отчеты для других, а также быстро анализировать и систематизировать десятки тысяч файлов данных всего несколькими строками кода.

Когда мы пишем код, связанный с файлами, мы обычно сосредотачиваемся на следующих вещах:Достаточно ли быстр мой код? Делает ли мой код больше с меньшими затратами?В этой статье я поделюсь с вами несколькими советами по программированию, связанными с этим. Я порекомендую вам недооцененный модуль стандартной библиотеки Python, продемонстрирую оптимальный способ чтения больших файлов и, наконец, поделюсь некоторыми своими мыслями о дизайне функций.

Теперь давайте введем время первого «Модуля Amway».

**Примечание: **Поскольку файловые системы разных операционных систем очень разные, основной средой написания этой статьи является система Mac OS/Linux, часть кода может быть неприменима к системе Windows.

Рекомендация 1: Используйте модуль pathlib

Если вам нужно сделать обработку файлов на Python, то стандартная библиотека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): список всех файлов * (включая папки) в каталоге пути *
  • os.path.splitext(filename): Разделите базовое имя и часть суффикса имени файла.
  • os.path.join(path, filename): имя файла комбинации, с которой необходимо работать, представляет собой абсолютный путь
  • os.rename(...): переименовать файл

Хотя приведенная выше функция может выполнить требования, честно говоря, даже после написания кода на Python в течение многих лет я все еще чувствую, что:Мало того, что эти функции трудно запомнить, так ещё и окончательный код не очень приятен.

Перепишите свой код, используя модуль 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)преобразовать строковый путь вPathобъект
  2. передача.glob('*.txt')Шаблон сопоставляет все по пути и возвращает его как генератор, результат все ещеPathобъект, поэтому мы можем выполнить следующие операции
  3. использовать.with_suffix('.csv')Получить полный путь к файлу с новым суффиксом напрямую
  4. передача.rename(target)полное переименование

в сравнении сosа такжеos.path, импорт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')

или использовать.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

В дополнение к тем, которые я представил в статье, модуль pathlib также предоставляет множество полезных методов, настоятельно рекомендуется перейти наофициальная документацияУзнать больше.

Если ничего из вышеперечисленного недостаточно, чтобы произвести на вас впечатление, то я дам вам еще одну причину использовать pathlib:PEP-519Он определяет новый объектный протокол специально для «путей к файлам», что означает, что начиная с версии Python 3.6 после вступления в силу PEP объект Path в pathlib можно сравнить с большинством предыдущих стандартных библиотек, которые принимали только строковые пути.Функция совместима. использовать:

>>> p = Path('/tmp')
# 可以直接对 Path 类型对象 p 进行 join
>>> os.path.join(p, 'foo.txt')
'/tmp/foo.txt'

Поэтому не стесняйтесь использовать модуль pathlib.

Hint:Если вы используете более раннюю версию Python, попробуйте установитьpathlib2модуль .

Предложение 2: освойте потоковую передачу больших файлов

Как почти все знают, существует «стандартная практика» чтения файлов в 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, то с помощью этой функции можно легко вычислить количество девяток.

# small_file.txt
feiowe9322nasd9233rl
aoeijfiowejf8322kaf9a

# OUTPUT: 3
print(count_nine('small_file.txt'))

Почему этот способ чтения файлов становится стандартом? Это потому, что у него есть два преимущества:

  1. withМенеджер контекста автоматически закрывает дескрипторы открытых файлов.
  2. При переборе файлового объекта содержимое возвращается построчно и не занимает слишком много памяти.

Недостатки стандартной практики

Но эта стандартная практика не лишена недостатков. Если читаемый файл вообще не содержит символов новой строки, то второе вышеприведенное преимущество не выполняется.Когда код выполняется дляfor line in file, строка станет очень большим строковым объектом, потребляющим очень значительный объем памяти.

Проведем эксперимент: есть5GBбольшой файлbig_file.txt, который заполнен иsmall_file.txtта же случайная строка. Просто способ хранения контента немного отличается, и весь текст помещается в одну строку:

# FILE: big_file.txt
df2if283rkwefh... <剩余 5GB 大小> ...

Если мы продолжим использовать предыдущийcount_nineфункция для подсчета в этом большом файле9количество. Тогда на моем ноутбуке этот процесс займет много денег65секунд, и ест машину во время выполнения2GBОЗУ[Примечание 1].

Используйте метод чтения для чтения кусками

Чтобы исправить это, нам нужно пока отложить эту «стандартную практику» и использовать более низкоуровневый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Прочитайте содержимое файла в цикле и каждый раз считывайте максимальный размер 8 КБ, что может избежать процесса объединения огромной строки раньше и значительно сократить использование памяти.

Развязка кода с генераторами

Предположим, речь идет не о Python, а о других языках программирования. Тогда можно сказать, что приведенный выше код уже хорош. Но если внимательно проанализироватьcount_nine_v2вы обнаружите, что внутри тела цикла есть две независимые логики:Генерация данных (чтение вызова и оценка фрагмента)а такжепотребление данных. И эти две независимые логики связаны вместе.

как я«Написание туннельных циклов»Как упоминалось в, чтобы улучшить возможность повторного использования, мы можем определить новыйchunked_file_readerФункция генератора, отвечающая за всю логику, связанную с «генерацией данных». такcount_nine_v3Основной цикл внутри должен отвечать только за подсчет.

def chunked_file_reader(fp, block_size=1024 * 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

На данный момент кажется, что в коде нет места для оптимизации, но это не так.iter(iterable)— это встроенная функция для создания итераторов, но она также имеет менее известное применение. когда мы используемiter(callable, sentinel)Когда он вызывается способом callable, он возвращает специальный объект, и итерация будет непрерывно генерировать результат вызова вызываемого объекта callable до тех пор, пока результат не станет setinel, и итерация завершится.

def chunked_file_reader(file, block_size=1024 * 8):
    """生成器函数:分块读取文件内容,使用 iter 函数
    """
    # 首先使用 partial(fp.read, block_size) 构造一个新的无需参数的函数
    # 循环将不断返回 fp.read(block_size) 调用结果,直到其为 '' 时终止
    for chunk in iter(partial(file.read, block_size), ''):
        yield chunk

Наконец, всего с двумя строками кода мы завершили повторно используемую функцию чтения фрагментированных файлов. Итак, как эта функция работает с точки зрения производительности?

И начало2 ГБ оперативной памяти / 65 секундНапротив, версия с использованием генератора требует только7 МБ ОЗУ / 12 секунддля завершения расчета. Эффективность увеличена почти в 4 раза, а использование памяти составляет менее 1% от исходного.

Рекомендация 3: Разработайте функции, которые принимают файловые объекты

Посчитав «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» новая функция немного сложнее. Чтобы убедиться в корректности программы, мне нужно написать для нее несколько модульных тестов. Но когда я собрался написать тест, то обнаружил, что это очень хлопотно, основные проблемы заключаются в следующем:

  1. Функция принимает путь к файлу в качестве параметра, поэтому нам нужно передать фактический файл
  2. Для подготовки тестовых случаев я либо предоставляю несколько шаблонных файлов, либо пишу несколько временных файлов.
  3. Возможность нормального открытия и чтения файла стала граничным условием, которое нам нужно проверить.

**Если вам сложно писать модульные тесты для вашей функции, это обычно означает, что вам следует улучшить ее дизайн. **Как следует улучшить вышеуказанную функцию? ответ:Заставьте функции зависеть от «файловых объектов», а не от путей к файлам..

Модифицированный код функции выглядит следующим образом:

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 является «уткообразным», хотя функция должна принимать файловый объект, мы фактически можем передать любой «файлоподобный объект», который реализует файловый протокол.count_vowels_v2в функции.

А в Python много «файлоподобных объектов». Например, в модуле ioStringIOОбъект является одним из них. Это специальный объект на основе памяти с почти таким же дизайном интерфейса, что и файловый объект.

Используя 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 модуль подпроцесса используется для хранения стандартного вывода при вызове системных команд.PIPEОбъект также является своего рода «файлоподобным объектом». Это означает, что мы можем напрямую передать вывод команды вcount_vowels_v2функция подсчета гласных:

import subprocess

# 统计 /tmp 下面所有一级子文件名(目录名)有多少元音字母
p = subprocess.Popen(['ls', '/tmp'], stdout=subprocess.PIPE, encoding='utf-8')

# p.stdout 是一个流式类文件对象,可以直接传入函数
# OUTPUT: 42
print(count_vowels_v2(p.stdout))

Как упоминалось ранее, самым большим преимуществом изменения параметра функции на «файловый объект» является улучшение работы функции.Применимая поверхностьа такжеКомбинация. Полагаясь на более абстрактные «файлоподобные объекты», а не на пути к файлам, он открывает больше возможностей для использования функций.StringIO, PIPE и любые другие объекты, удовлетворяющие протоколу, могут быть клиентами функций.

Однако такая модификация не лишена недостатков, а также может доставлять некоторые неудобства звонящему. Если вызывающая сторона просто хочет использовать путь к файлу, она должна сама обрабатывать операцию открытия файла.

Как писать функции, совместимые с обоими

Есть ли способ обеспечить гибкость «принятия файлового объекта», делая его более удобным для вызывающего абонента, передающего путь к файлу? ответ:Да и в стандартной библиотеке есть такие примеры.

Откройте стандартную библиотекуxml.etree.ElementTreeмодуль, откройте внутреннюю частьElementTree.parseметод. Вы обнаружите, что этот метод можно либо вызывать с файловым объектом, либо он принимает строку путей к файлам. И способ, которым это делается, очень прост и понятен:

def parse(self, source, parser=None):
    """*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

Используя этот гибкий метод обнаружения, основанный на «утином типировании»,count_vowels_v2Функции также можно модифицировать для более удобного использования, поэтому я не буду их здесь повторять.

Суммировать

Файловые операции — это области, которых нам часто приходится касаться в нашей повседневной работе.Использование более удобных модулей, использование генераторов для экономии памяти и написание функций с более широкой применимостью могут позволить нам писать более эффективный код.

Давайте завершим это в конце:

  • Использование модуля pathlib упрощает операции с файлами и каталогами и делает код более интуитивно понятным.
  • PEP-519Определяет стандартный протокол для представления «путей к файлам», и объекты Path реализуют этот протокол.
  • Память можно сэкономить, определив функции генератора для чтения больших файлов кусками.
  • использоватьiter(callable, sentinel)Код может быть упрощен в некоторых конкретных сценариях
  • Код, который трудно писать тесты, и часто код, который нуждается в улучшении
  • Зависимость функций от «файлоподобных объектов» может улучшить применимость и компоновку функций.

Прочитав статью, у вас есть что угодно? Пожалуйста, оставьте сообщение или вПроблемы проекта GitHubскажи мне.

приложение

  • Источник титульного изображения: Фото Девона Дивайна на Unsplash
  • Еще серия адресов статей:GitHub.com/leather/one-…

Другие статьи цикла:

аннотация

  1. В зависимости от объема свободной памяти на компьютере этот процесс может потреблять более 2 ГБ памяти.