$ sudo teach IT
Модуль 7 · Инструменты Python · Урок 7.5

Файлы и with

Как сохранять данные на диске между запусками программы, читать и записывать файлы правильно, и почему конструкция with — это стандарт, а не просто удобство

Теория~35 минутНовичокopen()withpathlib@contextmanager

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

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

Открываем файл: функция open()

Представьте, что файл на диске — это папка в шкафу с документами: прежде чем что-то в ней прочитать или дописать, папку сначала нужно достать и открыть. С файлом в Python то же самое — сначала его нужно открыть, и Python выдаст вам "ручку", через которую вы дальше с ним работаете.

За открытие отвечает готовая функция open(). Она принимает путь к файлу и возвращает файловый объект. У неё три параметра, с которыми вы будете работать постоянно: file — путь, mode — режим (что вы собираетесь делать), encoding — кодировка текста.

f = open("notes.txt", "r", encoding="utf-8")
  • open("notes.txt", "r", encoding="utf-8") — открывает файл notes.txt из той же папки, что и программа. "r" — режим "только чтение" (о режимах дальше). encoding="utf-8" задаёт кодировку текста.
  • f = ... — результат, файловый объект, сохраняется в переменную f. Дальше вы обращаетесь к файлу через неё.

Всегда указывайте encoding="utf-8". Без этого параметра Python берёт кодировку операционной системы — на Windows это часто не utf-8. Одна и та же программа может тогда читать один и тот же файл по-разному на разных компьютерах.

Режимы открытия файла

Открыть файл можно с разными намерениями: только прочитать и не трогать, стереть старое и записать заново, или дописать пару строк в конец. Чтобы Python понял, чего вы хотите, вторым аргументом open() передаётся строка-режим — официально она называется режимом доступа (mode).

Режим Что делает Если файла нет
"r" Читать. Режим по умолчанию, если mode не указать FileNotFoundError
"w" Писать. Стирает всё старое содержимое Создаёт новый файл
"a" Добавлять в конец (append), старое не трогает Создаёт новый файл
"x" Создать новый файл, если уже есть — ошибка Создаёт новый файл

Главное про "w": он очищает файл в момент открытия, ещё до того, как вы что-то запишете. Чтобы добавить данные, не стирая старые — используйте "a".

Читаем содержимое файла

Допустим, на диске уже лежит файл notes.txt:

Купить молоко Позвонить маме Сделать домашку

У файлового объекта есть три готовых метода для чтения — они отличаются тем, сколько текста возвращают за один вызов.

read() читает файл целиком:

f = open("notes.txt", "r", encoding="utf-8")
content = f.read()
print(content)
f.close()
  • f.read() — возвращает одну строку со всем текстом файла, включая переносы строк внутри неё.
  • f.close() — закрывает файл. Почему это важно и как не забывать об этом — чуть ниже.

readline() читает по одной строке за вызов, каждый раз следующую:

f = open("notes.txt", "r", encoding="utf-8")
first_line = f.readline()   # "Купить молоко\n"
second_line = f.readline()  # "Позвонить маме\n"
f.close()
  • Символ \n на конце — это перенос строки, который реально есть в файле. Если вызвать readline() после последней строки — вернётся пустая строка "", это знак, что файл закончился.

readlines() сразу возвращает список строк:

f = open("notes.txt", "r", encoding="utf-8")
lines = f.readlines()
f.close()
print(lines)
# ["Купить молоко\n", "Позвонить маме\n", "Сделать домашку"]
  • Каждый элемент списка — одна строка файла. У всех, кроме последней, на конце есть \n — чтобы его убрать, к элементу применяют уже знакомый strip().

Записываем в файл

Для записи файл открывают в режиме "w" (перезаписать) или "a" (дописать в конец). Метод для записи называется write() — он принимает строку и дописывает её в файл.

f = open("output.txt", "w", encoding="utf-8")
f.write("Привет, файл!\n")
f.write("Вторая строка\n")
f.close()
  • open(..., "w", ...) — если файла не было, он создастся; если был — старое содержимое исчезнет.
  • f.write("Привет, файл!\n") — записывает строку. В отличие от print(), write() не добавляет перенос строки сам — символ \n нужно писать вручную.
  • Второй f.write(...) дописывает вторую строку сразу за первой.

Если строки уже лежат в списке — их можно записать одним вызовом через writelines():

lines = ["Первая строка\n", "Вторая строка\n"]

f = open("output.txt", "w", encoding="utf-8")
f.writelines(lines)
f.close()
  • f.writelines(lines) — записывает элементы списка подряд, без пропусков между ними. Переносы строк, как и в write(), нужно закладывать заранее самому.

close() и почему вместо него используют with

Открытый файл занимает системный ресурс — что-то вроде выданного библиотекой абонемента. Пока файл не закрыт, записанные данные могут остаться в промежуточном буфере и не попасть на диск. Проблема в том, что close() нужно вызвать после всей работы с файлом — а если между open() и close() случится ошибка, строка с close() просто не выполнится:

f = open("data.txt", "w", encoding="utf-8")
f.write(str(1 / 0))  # ZeroDivisionError!
f.close()             # эта строка никогда не выполнится
  • f.write(str(1 / 0)) — деление на ноль ломает программу прямо здесь.
  • f.close() — до него выполнение не дойдёт. Файл останется открытым.

Для этого в Python есть конструкция with — официально менеджер контекста (context manager): открытие и закрытие ресурса Python берёт на себя и гарантирует закрытие в любом случае, даже при ошибке внутри блока.

with open("notes.txt", "r", encoding="utf-8") as f:
    content = f.read()
    print(content)
# здесь блок with уже закончился — файл закрыт автоматически
  • with open(...) as f: — открывает файл так же, как open(), но результат сохраняется через as, а не через =. Двоеточие означает, что дальше — блок с отступом, как у if или for.
  • Как только отступ заканчивается — блок завершён, и Python сам закрывает файл. Отдельно вызывать f.close() уже не нужно.

Как это устроено. Объекты, с которыми работает with, умеют выполнять два действия — одно при входе в блок, второе при выходе, каким бы он ни был. У файла эти действия уже встроены. Дальше в уроке вы напишете такой же "вход и выход" сами, одной функцией, без класса.

Используйте with open() всегда, когда работаете с файлами.

for по файлу: читаем построчно

Файловый объект можно перебирать циклом for точно так же, как строки и списки — только на каждом шаге вы получаете очередную строку файла.

with open("notes.txt", "r", encoding="utf-8") as f:
    for line in f:
        print(line.strip())
  • for line in f: — на каждом шаге line получает следующую строку файла, включая \n на конце.
  • line.strip() — убирает лишние пробельные символы по краям, включая \n, чтобы вывод не дублировал переносы.

readlines() сразу загружает весь файл в список в памяти — для файла на несколько гигабайт это может туда не поместиться. for line in f читает по одной строке за раз, поэтому в памяти всегда лежит только текущая строка.

pathlib: удобная работа с путями

Строка "notes.txt" в open() — это путь к файлу. Как только нужно проверить, существует ли файл, или собрать путь из папки и имени — работать с путями как с обычным текстом неудобно: на Windows и на Mac/Linux разные разделители между папками. Для этого в стандартной библиотеке есть модуль pathlib, а в нём класс Path, представляющий путь как отдельный объект.

from pathlib import Path

p = Path("notes.txt")
print(p.exists())

content = p.read_text(encoding="utf-8")
p.write_text("Новое содержимое", encoding="utf-8")
  • p = Path("notes.txt") — создаёт объект пути. Файл при этом ещё не открывается, мы просто описали, где он должен быть.
  • p.exists() — возвращает True, если по этому пути что-то есть на диске, и False, если нет. Удобно проверить перед чтением.
  • p.read_text(encoding="utf-8") — открывает файл, читает целиком и сам закрывает — то же самое, что with open(p, "r", encoding="utf-8") as f: f.read(), только в одну строку.
  • p.write_text(...) — так же, но для записи: открывает, пишет, закрывает.

Path ещё умеет собирать путь из частей через / и разбирать его: p.name — имя файла с расширением, p.stem — без расширения, p.suffix — только расширение, p.parent — путь до папки. Объект Path можно передать прямо в обычный open() вместо строки.

Обрабатываем ошибки при работе с файлами

С файлами связано многое, что программа не контролирует: файла может не быть по пути, у пользователя может не хватать прав. Открывать файл "вслепую" — плохая идея. Два исключения встречаются чаще всего:

FileNotFoundError
Файла нет по указанному пути. Режимы "r" и "r+" выдают эту ошибку, если файла не существует.
PermissionError
Нет прав на чтение или запись: системный файл, чужой файл, файл занят другой программой.

Обе ситуации ловятся уже знакомой конструкцией try/except:

try:
    with open("notes.txt", "r", encoding="utf-8") as f:
        content = f.read()
        print(content)
except FileNotFoundError:
    print("Файл не найден. Проверьте путь.")
except PermissionError:
    print("Нет прав на чтение этого файла.")
  • with open(...) as f: — если файла не существует, ошибка выбросится прямо здесь, и код внутри with не выполнится.
  • except FileNotFoundError: и except PermissionError: — отдельные ветки под каждую ошибку, со своим понятным сообщением вместо падения программы.

OSError — общий класс для всех ошибок файловой системы, а FileNotFoundError и PermissionError — его частные случаи. Чтобы не перечислять каждую ошибку отдельно, можно поймать сразу except OSError:.

Свой контекстный менеджер: @contextmanager

До сих пор вы использовали with только с готовым open(). Но того же удобства иногда хочется и для своей задачи — например, замерить время выполнения куска кода. В стандартной библиотеке для этого есть декоратор contextmanager из модуля contextlib: он превращает функцию с yield (вы уже видели такую конструкцию в генераторах) в свой собственный менеджер контекста.

Правило одно:

  • Код до yield выполняется при входе в блок with.
  • Код после yield выполняется при выходе из блока with.
import time
from contextlib import contextmanager

@contextmanager
def timer():
    start = time.time()
    yield
    elapsed = time.time() - start
    print(f"Выполнено за {elapsed:.4f} сек")

with timer():
    total = sum(range(1_000_000))
    print(total)
  • @contextmanager над def timer(): — помечает функцию как заготовку менеджера контекста. Без этой строки функция с yield в with не сработает.
  • start = time.time() — выполняется при входе в блок: запоминаем текущее время.
  • yield — момент, где управление передаётся телу блока with. Всё внутри with timer(): выполняется как будто "на месте" этой строки.
  • elapsed = ... и print(...) — выполняются уже после того, как тело блока закончилось: считаем прошедшее время и печатаем его с четырьмя знаками после запятой.
  • with timer(): — используем менеджер точно так же, как раньше with open(...). Слово as не нужно, потому что yield здесь без значения.

Если внутри блока может случиться ошибка, а код после yield обязан выполниться в любом случае — yield оборачивают в знакомый try/finally:

from contextlib import contextmanager

@contextmanager
def managed_resource(name):
    print(f"Открываем: {name}")
    try:
        yield name
    finally:
        print(f"Закрываем: {name}")

with managed_resource("база данных") as res:
    print(f"Работаем с {res}")
  • try: yield name — теперь yield передаёт значение name дальше, оно попадёт в переменную после as.
  • finally: print(...) — выполнится всегда, даже если внутри блока with случится исключение. Так гарантируется освобождение ресурса.

Частые ошибки

Открыли на запись "w" вместо "a" и потеряли данные

open("log.txt", "w", ...) вместо "a" стирает всё, что было в файле, ещё до первого write(). Для дозаписи в конец — только "a".

Забыли \n в write()

Несколько подряд f.write("текст") без \n на конце сольются в одну строку в файле. В отличие от print(), write() перенос строки сам не ставит.

Забыли close() без with

Без close() данные могут не сохраниться на диск, а файл останется занятым. Решение простое: используйте with open(...) as f: вместо ручного открытия и закрытия.

Функция с @contextmanager без yield

Функция, помеченная @contextmanager, обязана содержать ровно один yield — именно он отмечает границу между входом и выходом. Без него использовать функцию в with не получится.

Что важно запомнить

  • open(file, mode, encoding) открывает файл. Всегда указывайте encoding="utf-8".
  • Режим "r" — чтение, "w" — запись с очисткой файла, "a" — добавление в конец, "x" — создать новый (ошибка, если уже есть).
  • read() — весь файл одной строкой, readline() — одна строка за вызов, readlines() — список всех строк.
  • write() записывает строку, writelines() — список строк. Символ \n в обоих случаях добавляется вручную.
  • with open(...) as f: гарантированно закрывает файл при выходе из блока, даже при ошибке — используйте вместо ручного close().
  • for line in f: — самый эффективный способ читать файл строка за строкой, без загрузки всего файла в память.
  • pathlib.Path представляет путь как объект: exists(), read_text(), write_text(), деление / для сборки пути, name/stem/suffix/parent для разбора.
  • FileNotFoundError и PermissionError — частые исключения при работе с файлами, оба ловятся через try/except.
  • @contextmanager из contextlib превращает функцию с yield в собственный менеджер контекста: код до yield — вход в блок with, код после — выход из него.

Проверьте себя

7 вопросов

Контекстный менеджер через @contextmanager

Premium

Запись и чтение файла

Напишите две функции:

  • save_to_file(filename, content) — записывает строку content в файл (режим "w", кодировка utf-8), затем печатает Сохранено!
  • read_from_file(filename) — читает файл (режим "r", кодировка utf-8) и печатает его содержимое

В обеих функциях используйте with open().

Append и обработка ошибок

Premium