Модуль 8 · Исключения и файлы

Справочник по Python · модуль 8 · исключения и файлы

Глава 8.11

8.11

Произвольный доступ:
seek() и tell()

До сих пор файл читался только от начала к концу. seek() позволяет перейти к любой позиции, а tell() — узнать текущую. В текстовом режиме у обоих есть жёсткие ограничения, и понять их проще всего через разницу между символами и байтами.

Категория
Файлы
Уровень
Средний → продвинутый
Связанные темы
Кодировки, байты, режимы открытия
Зачем это знать
Работать с частями файла, не читая его целиком

§1Что даёт произвольный доступ

Глава 8.4 разделила два способа работы с файлом. Все предыдущие главы использовали последовательный: данные шли подряд, от начала к концу. Произвольный доступ снимает это ограничение.

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

Последняя строка — самая частая на практике: именно ради неё в главах 8.6 и 8.8 встречался вызов seek(0).

§2tell(): где мы сейчас

Метод возвращает текущую позицию — то место, откуда пойдёт следующее чтение или запись.

main.pyфайл из двадцати символов ASCII
with open("demo.txt", "w", encoding="utf-8") as file:
    file.write("0123456789ABCDEFGHIJ")

with open("demo.txt", encoding="utf-8") as file:
    print(file.tell())
    file.read(3)
    print(file.tell())
Выводпозиция сдвинулась на три
0
3

Здесь всё совпало с интуицией только потому, что символы латинские. С кириллицей картина изменится — §7.

§3seek(): переход к позиции

seek(offset) перемещает позицию на указанное число байтов от начала файла.

main.pyчитаем с одиннадцатого символа
with open("demo.txt", encoding="utf-8") as file:
    file.seek(10)
    print(file.read(5))
    print(file.tell())
Выводначало файла пропущено
ABCDE
15

Метод возвращает новую позицию, поэтому print(file.seek(10)) напечатал бы 10. Часто этот результат просто игнорируют.

§4Параметр whence

Второй аргумент задаёт точку отсчёта.

whenceОтсчёт отПример
0 (по умолчанию)начала файлаseek(10) — на десятый байт
1текущей позицииseek(5, 1) — на пять байт вперёд
2конца файлаseek(-10, 2) — за десять байт до конца

Смещение может быть отрицательным — для whence=1 это движение назад, для whence=2 иначе и не бывает.

§5Ограничения текстового режима

Таблица выше описывает двоичный режим. В текстовом работает только часть возможностей.

main.pyпопытка сместиться от текущей позиции
with open("demo.txt", encoding="utf-8") as file:
    file.read(3)
    file.seek(5, 1)
Ошибкав текстовом режиме так нельзя
io.UnsupportedOperation: can't do nonzero cur-relative seeks
ВызовТекстовый режимДвоичный режим
seek(n)можноможно
seek(0, 1)можноможно
seek(n, 1)ошибкаможно
seek(0, 2)можноможно
seek(-n, 2)ошибкаможно

Разрешены только два безопасных случая: переход к позиции, полученной от tell(), и перемещение в конец файла нулевым смещением.

§6Почему так

Ограничение выглядит произвольным, но у него простая причина — кодировка. В UTF-8 символы занимают разное число байтов, и сдвиг «на пять байт вперёд» может попасть в середину символа.

Схемачто случится при сдвиге на нечётное число
"привет"  в UTF-8:

  п      р      и      в      е      т
d0 bf  d1 80  d0 b8  d0 b2  d0 b5  d1 82
      ↑
      seek(1) — середина символа, декодировать нечем

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

§7Позиция в байтах, чтение в символах

Отсюда же главное недоразумение темы: read() считает символы, а tell() — байты.

main.pyтри кириллических символа
with open("demo.txt", "w", encoding="utf-8") as file:
    file.write("привет мир")

with open("demo.txt", encoding="utf-8") as file:
    file.read(3)
    print(file.tell())
Выводпрочитано три символа, позиция шесть
6
Схемасимволы сверху, байты снизу
read(3) прочитал три символа п р и в е т м и р 0 2 4 6 8 10 12 13 15 17 19 позиция в байтах tell() → 6
  • прочитано: три символа
  • ещё не прочитано

Верхний ряд — символы, нижняя линейка — байты. Кириллическая буква в UTF-8 занимает два байта, пробел — один, поэтому обе шкалы расходятся с самого начала. read() отсчитывает деления по верхней шкале, tell() и seek() — по нижней.

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

§8Правило работы с текстом

Единственный надёжный способ вернуться к месту в текстовом файле — сохранить позицию, полученную от tell().

main.pyзакладка в файле
with open("demo.txt", encoding="utf-8") as file:
    file.read(3)
    bookmark = file.tell()

    file.seek(0)
    file.seek(bookmark)

    print(file.read())
Выводчтение продолжилось с закладки
вет мир

Значение tell() в текстовом режиме — непрозрачное число: его не нужно понимать, складывать или уменьшать. Его только сохраняют и передают обратно в seek().

§9Двоичный режим без ограничений

В режиме "rb" доступны все формы seek().

main.pyтри вида перехода
with open("demo.txt", "w", encoding="utf-8") as file:
    file.write("0123456789ABCDEFGHIJ")

with open("demo.txt", "rb") as file:
    file.seek(10)
    print(file.read(5))

    file.seek(-4, 2)
    print(file.read())
Выводот начала и от конца
b'ABCDE'
b'GHIJ'

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

§10Размер файла через seek

Переход в конец с нулевым смещением возвращает размер файла в байтах — и это работает в обоих режимах.

main.pyузнаём размер, не читая содержимое
with open("demo.txt", "rb") as file:
    size = file.seek(0, 2)

print(size)
Выводдвадцать байтов
20

Приём удобен, когда файл уже открыт. Если он ещё не открыт, проще os.path.getsize(path) — функция из стандартной библиотеки.

§11Запись по позиции

seek() действует и на запись. Но запись не «вставляет» данные, а перезаписывает то, что было на этом месте.

main.pyдва символа поверх существующих
with open("demo.txt", "w", encoding="utf-8") as file:
    file.write("0123456789")

with open("demo.txt", "r+", encoding="utf-8") as file:
    file.seek(3)
    file.write("XX")

print(open("demo.txt", encoding="utf-8").read())
Выводцифры 3 и 4 заменены
012XX56789

Режим "r+" открывает файл на чтение и запись, не стирая содержимое, — в отличие от "w+" из главы 8.6. Вставить данные в середину без сдвига остального нельзя: для этого файл переписывают целиком.

§12Режим a игнорирует seek

Отдельная ловушка: в режиме дозаписи любая запись идёт в конец, куда бы ни поставили позицию.

main.pyseek(0) перед записью
with open("demo.txt", "a", encoding="utf-8") as file:
    file.seek(0)
    file.write("!")

print(open("demo.txt", encoding="utf-8").read())
Выводвосклицательный знак оказался в конце
012XX56789!

Это не ошибка и не недоработка: режим "a" по определению означает «всегда в конец». Ошибки не будет — просто данные окажутся не там, где ожидалось.

§13Где это применяется

ЗадачаКак используется
перечитать файлseek(0) и повторное чтение
прочитать конец большого журналаseek(-n, 2) в двоичном режиме
записи фиксированной длиныseek(номер * длина)
заголовок двоичного форматапрочитать первые байты и перейти к данным
обновить поле на местеseek() и write() в режиме "r+"

В обычной работе с текстом произвольный доступ нужен редко: файл либо читают целиком, либо построчно. Его область — двоичные форматы и структуры с известным устройством.

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

Что написаноЧто произойдётКак правильно
seek(5, 1) в текстовом режимеUnsupportedOperationоткрыть в "rb" или сохранить tell()
seek(-10, 2) в текстовом режимето же исключениедвоичный режим
считать tell() числом символовдля кириллицы значение вдвое большеэто байты, и в тексте — непрозрачные
вычислять позицию арифметикой в текстепопадание в середину символатолько значения из tell()
seek() в режиме "a" перед записьюданные всё равно уйдут в конецрежим "r+"
ждать вставки от write() после seek()существующие данные будут перезаписаныпереписать файл целиком
seek() без повторного чтенияничего не изменится: метод только двигает позициюпосле seek() вызвать read()

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

В каких единицах работает seek()?

В байтах — в обоих режимах. При этом read() в текстовом режиме считает символы, и совпадать эти числа не обязаны.

Почему seek(5, 1) запрещён в текстовом режиме?

Произвольное смещение может попасть в середину многобайтового символа, а проверить это заранее Python не может.

Какие формы seek() разрешены в текстовом режиме?

Переход к абсолютной позиции, seek(0, 1) и seek(0, 2). Остальное — только в двоичном.

Как надёжно вернуться к месту в текстовом файле?

Сохранить значение tell() и передать его в seek(). Вычислять позицию самому нельзя.

Что делает write() после seek()?

Перезаписывает данные с этой позиции, а не вставляет. Существующие символы будут заменены.

Почему seek() не работает в режиме "a"?

Режим дозаписи всегда пишет в конец файла независимо от позиции. Ошибки при этом нет.

§16Шпаргалка

В началоfile.seek(0)
К позицииfile.seek(offset)
Текущая позицияpos = file.tell()
Вернутьсяfile.seek(pos)
Размер файлаfile.seek(0, 2)
От концаfile.seek(-n, 2) — только "rb"
Правка на местеopen(path, "r+")

Итог главы

seek() переводит позицию в файле, tell() её возвращает, и оба работают в байтах. В текстовом режиме разрешены только абсолютный переход и нулевые смещения — потому что произвольный сдвиг может разорвать многобайтовый символ. Значение tell() для текста стоит считать непрозрачным: его сохраняют и возвращают в seek(), но не вычисляют. Запись после перехода перезаписывает данные, а в режиме "a" позиция вовсе игнорируется.

Глава 8.11 · Произвольный доступ: seek() и tell() · Справочник по Python Автор: Макаров Максим Николаевич