$ sudo teach IT

Зачем программе файлы и даты

Программа редко живёт одним запуском. Ей нужно сохранить результат работы на диск, чтобы прочитать его позже: список задач, настройки, журнал событий. И почти в любом журнале рядом с событием стоит время, когда оно произошло. Сегодня разберём, как Swift работает с файлами на диске и как превращать момент времени в понятную человеку строку.

FileManager и временные файлы

За работу с файловой системой отвечает тип FileManager. У него есть общий экземпляр FileManager.default, через который можно создавать, читать, перемещать и удалять файлы и папки.

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

let folder = FileManager.default.temporaryDirectory
let fileURL = folder.appendingPathComponent("report.txt")
print(fileURL.path)

Здесь temporaryDirectory возвращает URL — адрес папки. Метод appendingPathComponent достраивает к нему имя файла, не трогая исходный URL (он тоже структура, копируется как значение).

Записать текст в файл и прочитать его обратно можно двумя методами String:

let text = "Первая строка\nВторая строка"
try text.write(to: fileURL, atomically: true, encoding: .utf8)

let loaded = try String(contentsOf: fileURL, encoding: .utf8)
print(loaded)

Оба метода помечены throws: запись и чтение могут провалиться (нет прав, диск полон, файла не существует), поэтому вызывать их нужно через try внутри do/catch или похожей конструкции. Параметр atomically: true значит, что Swift сначала пишет во временный файл рядом и только потом подменяет им целевой — если что-то пойдёт не так посреди записи, старое содержимое не испортится.

Когда временный файл больше не нужен, его стоит убрать за собой:

try? FileManager.default.removeItem(at: fileURL)

try? превращает ошибку в nil — если файла и так уже нет, программа не упадёт.

Date — точка на числовой прямой

Тип Date хранит момент времени как число секунд относительно 1 января 1970 года (это называется Unix-эпоха). Внутри Date нет ни часового пояса, ни языка — это просто точка на числовой прямой, и создать её можно явно:

let launch = Date(timeIntervalSince1970: 1_700_000_000)
print(launch.timeIntervalSince1970)

Сравнивать даты можно как обычные значения: <, >, ==. Разницу между двумя моментами возвращает timeIntervalSince(_:) в виде количества секунд:

let start = Date(timeIntervalSince1970: 1_700_000_000)
let end = Date(timeIntervalSince1970: 1_700_003_600)
let seconds = end.timeIntervalSince(start)
print(seconds) // 3600.0

Чтобы достать из даты год, месяц, час и так далее, используют Calendar — он знает, как устроен календарь (сколько дней в месяце, что такое високосный год) и в каком часовом поясе считать:

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!

let parts = calendar.dateComponents([.year, .month, .day, .hour], from: launch)
print(parts.year!, parts.month!, parts.day!, parts.hour!)

dateComponents возвращает структуру DateComponents, где каждое поле — опционал: если вы не запросили какую-то часть в первом параметре, соответствующее свойство будет nil.

formatted() и ловушка с локалью

Показывать пользователю секунды с 1970 года бессмысленно — нужна читаемая строка вроде «14 ноября 2023». Для этого у Date есть метод formatted():

let now = Date(timeIntervalSince1970: 1_700_000_000)
print(now.formatted(date: .long, time: .shortened))

Это самый короткий способ, но у него есть важная особенность: без явных настроек formatted() берёт язык и часовой пояс из системных настроек устройства, на котором выполняется код — это называется текущая локаль, Locale.current. Один и тот же Date превратится в разные строки на телефоне с русским языком и на сервере, настроенном на английский. Для интерфейса это как раз то, что нужно — пользователь видит дату на своём языке. А вот для журналов, тестов и всего, что должно выглядеть одинаково независимо от того, где выполняется код, такая зависимость от окружения — источник трудноуловимых ошибок.

Решение — задавать локаль и часовой пояс явно через Date.FormatStyle:

let style = Date.FormatStyle(
    locale: Locale(identifier: "en_US_POSIX"),
    timeZone: TimeZone(identifier: "UTC")!
).year().month(.twoDigits).day(.twoDigits)

print(now.formatted(style))

en_US_POSIX — специальная локаль, которая не меняется от настроек устройства и не подстраивается под региональные соглашения, поэтому её обычно берут именно для машинного, а не человеческого формата. Когда нужен полный контроль над видом строки (например «14.11.2023 22:13» без вариантов), проще и надёжнее собрать её самим из компонентов календаря, как в примере с Calendar выше, и оформить через String(format:).

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

  • Писать файл не во временную папку, а по произвольному пути — в песочнице и во многих реальных окружениях диск доступен только для чтения, кроме отведённой директории.
  • Забывать удалить временный файл после использования — он остаётся лежать на диске до следующей перезагрузки системы.
  • Полагаться на formatted() без явной локали там, где важен одинаковый результат: в логах, в тестах, в данных для другой программы.
  • Путать Date с готовой строкой: Date — это момент времени, строку из него нужно получить отдельным шагом форматирования.
  • Не оборачивать try text.write(...) и try String(contentsOf:...) в обработку ошибок — при отсутствии прав или битом пути программа просто упадёт.

Резюме

  • FileManager.default.temporaryDirectory — единственная папка, куда точно можно писать.
  • String.write(to:atomically:encoding:) и String(contentsOf:encoding:) — запись и чтение текста, оба варианта бросают ошибку.
  • Date хранит момент времени как число секунд от 1970 года, без языка и часового пояса.
  • Calendar достаёт из даты год, месяц, день и другие компоненты с учётом заданного часового пояса.
  • formatted() удобен для интерфейса, но зависит от локали устройства — для предсказуемого результата задавайте локаль и часовой пояс явно.

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

3 вопроса

Журнал событий на диске

Реализуйте функцию makeEventLog(events:). Она принимает массив событий, каждое из которых — кортеж с именем name и меткой времени timestamp (число секунд от 1 января 1970 года, тип Double).

Для каждого события нужно:

  1. получить Date из timestamp через Date(timeIntervalSince1970:);
  2. достать из даты год, месяц, день, час и минуту с помощью Calendar с явно заданным часовым поясом UTC (TimeZone(identifier: "UTC")!);
  3. собрать строку вида «день.месяц.год час:минута», где день, месяц, год, час и минута дополнены нулями слева до двух (год — до четырёх) цифр, например «14.11.2023 22:13»;
  4. сформировать строку события как «имя - штамп», например «Старт - 14.11.2023 22:13».

Все строки событий нужно соединить символом перевода строки \n (для пустого массива событий результат — пустая строка), записать во временный файл через FileManager.default.temporaryDirectory, тут же прочитать файл обратно и вернуть прочитанное содержимое как результат функции. В конце обязательно удалите созданный временный файл — не оставляйте его на диске. Если запись или чтение вернут ошибку, функция должна вернуть пустую строку и всё равно попытаться удалить файл.