Работа с файловой системой
Файловый ввод-вывод — одна из ключевых возможностей любого языка программирования. Сегодня практически каждое приложение так или иначе работает с файловой системой: читает данные с диска, сохраняет на диск результаты работы или обращается к системным файлам.
В этой главе мы начнём с работы с директориями. Сначала рассмотрим, как создавать директории на диске, включая вложенные. Затем подробно разберём, как получить список содержимого: научимся перебирать как файлы, так и поддиректории. При необходимости рассмотрим, как выполнять обход рекурсивно — проходя через всё дерево вложенных папок.
После того как мы разберёмся с работой с директориями, перейдём к следующему важному этапу — взаимодействию с файлами. Мы научимся создавать и открывать файлы, читать из них данные, записывать информацию, а также правильно закрывать файлы по завершении работы.
Построение пути к файлу или директории
Работа с файлами и директориями начинается с построения пути — относительного или абсолютного — к нужному ресурсу. Это может быть путь к файлу, каталогу или любому другому объекту файловой системы. Во всех популярных операционных системах путь к файлу или директории это строка, в формате специфичном для каждой операционной системы.
К сожалению, операционные системы не ограничиваются использованием только валидных с точки зрения UTF-8 символов в именах файлов и директорий. В Unix-подобных системах любое произвольное сочетание байтов (за исключением некоторых, например нулевого байта \0) может считаться допустимым именем файла. В Windows ситуация аналогичная: почти любая строка “широких” символов (UTF-16) считается допустимой, за исключением определённых символов вроде :
, *
, ?
, "
и т.д.
Также важно учитывать, что формат путей различается между операционными системами. В Unix-системах разделителем директорий является символ /
, тогда как в Windows используется обратный слэш \
. Эти различия могут вызвать ошибки при написании кроссплатформенного кода, если не учитывать особенности платформ.
Чтобы упростить работу с путями и скрыть платформенные различия, многие языки программирования обычно предлагают абстракции. Например, в Rust есть отдельный тип Path
. В Zig подход немного другой: здесь нет специального типа для путей, но есть модуль std.fs.path
, предоставляющий набор функций для построения, разбора и анализа путей. Это позволяет писать кроссплатформенный код без лишней сложности.
Модуль std.fs.path
позволяет:
- объединять части пути (например, join);
- разбирать путь на компоненты (basename, dirname, extension);
- определять тип разделителя для текущей платформы;
- нормализовать или проверять путь;
Рассмотрим некоторые полезные функции из std.fs.path
, которые помогут нам безопасно и удобно работать с путями в разных операционных системах.
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
// Пример объединения путей
const path = try std.fs.path.join(allocator, &[_][]const u8{
"home",
"user",
"documents",
"file.txt",
});
defer allocator.free(path);
std.debug.print("Joined path: {s}\n", .{path});
// Выведет на Unix: "home/user/documents/file.txt"
// Выведет на Windows: "home\\user\\documents\\file.txt"
// Поучить директорию файла
const dir = std.fs.path.dirname(path) orelse "";
// Получить имя файла
const file = std.fs.path.basename(path);
// Получить расширение файла
const ext = std.fs.path.extension(path);
std.debug.print("Directory: {s}\n", .{dir}); // Выведет: "home/user/documents"
std.debug.print("File: {s}\n", .{file}); // Выведет: "file.txt"
std.debug.print("Extension: {s}\n", .{ext}); // Выведет: ".txt"
// Проверка пути на абсолютность
const path1 = "/home/user/documents/file.txt";
const path2 = "home/user/documents/file.txt";
const is_absolute1 = std.fs.path.isAbsolute(path1);
const is_absolute2 = std.fs.path.isAbsolute(path2);
std.debug.print("Is path1 absolute: {}\n", .{is_absolute1}); // Выведет: true
std.debug.print("Is path2 absolute: {}\n", .{is_absolute2}); // Выведет: false
// Пример нормализации пути
const normalized_path = try std.fs.path.resolve(allocator, &[_][]const u8{
"home",
"user",
"..",
"user",
"documents",
".",
"file.txt",
});
defer allocator.free(normalized_path);
std.debug.print("Normalized path: {s}\n", .{normalized_path});
// Выведет: "home/user/documents/file.txt"
}
Как мы видим модуль std.fs.path
содержит много полезных функций для работы с путями файловой системы и если вы будете использовать функции из данного модуля, то ваш код будет успешно работать на различных платформах.
Работа с директориями
Довольно часто при разработке приложений возникает необходимость работать с директориями на диске. Наиболее распространённые сценарии включают:
- создание временных папок для промежуточных данных;
- создание директорий для выгрузки файлов (например, при архивировании);
- обход содержимого директорий, чтобы обработать каждый файл — например, для построения полнотекстового индекса по документам;
В Zig для работы с директориями можно использовать как относительные, так и абсолютные пути. Относительные пути часто удобнее, особенно если ваша программа работает в известной структуре каталогов или запускается из определённой директории. Однако, чтобы использовать относительные пути правильно, необходимо сначала узнать, где именно находится ваша текущая рабочая директория (current working directory).
Прежде чем перейти к созданию, удалению и обходу директорий, начнём с того, как получить текущую рабочую директорию в Zig. Для того, чтобы получить текущую рабочую директорию в Zig необходимо использовать функцию std.fs.cwd()
:
const std = @import("std");
pub fn main() !void {
const dir_path1 = try std.fs.cwd().realpathAlloc(std.heap.page_allocator, ".");
defer std.heap.page_allocator.free(dir_path1);
std.debug.print("Текущия директория: {s}\n", .{dir_path1});
const parent = try std.fs.cwd().openDir("..", .{});
try parent.setAsCwd();
const dir_path2 = try std.fs.cwd().realpathAlloc(std.heap.page_allocator, ".");
defer std.heap.page_allocator.free(dir_path2);
std.debug.print("Текущия директория: {s}\n", .{dir_path2});
}
Если запустить код на моей машине, то он выведет следующее:
$ zig build run
Текущия директория: /Users/roman/Projects/zig/simple
Текущия директория: /Users/roman/Projects/zig
Как видно из примера, текущую рабочую директорию, которую возвращает вызов std.fs.cwd()
, можно изменить с помощью метода setAsCwd
, вызываемого для другой директории.
В нашем случае мы сначала открываем родительскую директорию, используя метод openDir
с относительным путём “..”. После этого вызываем setAsCwd
на полученном файловом дескрипторе директории, тем самым устанавливая её в качестве новой текущей рабочей директории.
С этого момента все последующие вызовы std.fs.cwd()
будут возвращать уже родительскую директорию. Давайте теперь рассмотрим как нам сделать базовые операции создания, удаления и переименования директорий.
Для того чтобы создать новую директорию, мы можем использовать три различных метода — makeDir
, makeDirAbsolute
и makePath
. Первый метод, makeDir
, вызывается у файлового дескриптора директории, например, полученного через std.fs.cwd()
, и создаёт директорию только в том случае, если она ещё не существует. Если директория уже существует, метод вернёт ошибку.
В отличие от makeDir
, функция makeDirAbsolute
позволяет создать директорию, используя абсолютный путь, и не требует предварительного открытия дескриптора какой-либо директории.
Метод makePath
также вызывается на файловом дескрипторе директории и создаёт не только саму директорию, но и все её родительские директории, если они отсутствуют. По сути, этот метод позволяет рекурсивно создать всю структуру пути до нужной директории за один вызов.
При использовании методов makeDir
и makePath
необязательно ограничиваться относительными путями — вы также можете передавать абсолютные пути. Давайте рассмотрим пример создания директории:
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
const cwd = std.fs.cwd();
const path = try std.fs.path.join(allocator, &[_][]const u8{ "dest", "subfolder", "data" });
defer allocator.free(path);
try cwd.makeDir("dest");
const dir_path1 = try std.fs.cwd().realpathAlloc(std.heap.page_allocator, "dest");
defer std.heap.page_allocator.free(dir_path1);
std.debug.print("Директория1: {s}\n", .{dir_path1});
try cwd.makePath(path);
const dir_path2 = try std.fs.cwd().realpathAlloc(std.heap.page_allocator, path);
defer std.heap.page_allocator.free(dir_path2);
std.debug.print("Директория2: {s}\n", .{dir_path2});
}
Если мы запустим наш пример, то на диске будет создана вложенная папка dest/subfolder/data
в папке нашего проекта. Причем если запустить наш пример повторно, то мы увидим ошибку о том, что директория dest
уже существует:
$ zig build run
Директория1: /Users/roman/Projects/zig/simple/dest
Директория2: /Users/roman/Projects/zig/simple/dest/subfolder/data
$ l dest/subfolder/data
total 0
drwxr-xr-x@ 2 roman staff 64B May 3 16:40 .
drwxr-xr-x@ 3 roman staff 96B May 3 16:40 ..
$ zig build run
error: PathAlreadyExists
/Users/roman/.zvm/0.14.0/lib/std/posix.zig:2933:19: 0x1000d8c9b in mkdiratZ (simple)
...
Это происходит потому, что функция makeDir
возвращает ошибку, если директория уже существует. Чтобы программа работала корректно при повторном запуске, у нас есть два варианта: обрабатывать эту ошибку или удалять созданную директорию при завершении работы программы.
Давайте реализуем второй подход — удаление директории. В Zig для этого доступны три метода: deleteDir
, deleteDirAbsolute
и deleteTree
. Эти функции являются симметричными по отношению к методам создания директорий и позволяют удалять как отдельные директории, так и целые деревья каталогов.
Метод deleteDir
вызывается на файловом дескрипторе директории, например, полученном через std.fs.cwd()
и вернет вам ошибку DirNotEmpty
, если вы попытаетесь удалить непустую директорию. В отличие от него, deleteDirAbsolute
можно вызвать напрямую, передав абсолютный путь к удаляемой директории — без необходимости открытия дескриптора. Но он также вернет вам ошибку, если директория окажется не пустой.
Для рекурсивного удаления директорий вместе с их содержимым следует использовать метод deleteTree
. Давайте добавим вызов deleteTree
в наш код:
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
const cwd = std.fs.cwd();
const path = try std.fs.path.join(allocator, &[_][]const u8{ "dest", "subfolder", "data" });
defer allocator.free(path);
try cwd.makeDir("dest");
const dir_path1 = try std.fs.cwd().realpathAlloc(std.heap.page_allocator, "dest");
defer std.heap.page_allocator.free(dir_path1);
std.debug.print("Директория1: {s}\n", .{dir_path1});
try cwd.makePath(path);
const dir_path2 = try std.fs.cwd().realpathAlloc(std.heap.page_allocator, path);
defer std.heap.page_allocator.free(dir_path2);
std.debug.print("Директория2: {s}\n", .{dir_path2});
try std.fs.cwd().deleteTree("dest");
}
Мы добавили удаление ранее созданных директорий, и теперь повторный запуск нашей программы больше не приводит к ошибке.
Хотя создание и удаление директорий — полезные операции, на практике куда чаще возникает необходимость рекурсивно получить все файлы в директории и её поддиректориях, чтобы затем как-то их обработать.
Для обхода содержимого директории в Zig доступны два метода: iterate
и walk
. Метод iterate
позволяет пройтись только по элементам текущей директории, не заходя в поддиректории. Метод walk
, напротив, выполняет рекурсивный обход, заходя во все вложенные папки и возвращая информацию о каждом файле и директории.
На практике чаще всего используется именно метод walk
, так как он позволяет обрабатывать сразу всё содержимое дерева директорий без необходимости вручную писать рекурсивную логику.
Давайте рассмотрим пример, в котором мы итерируемся по директории /tmp
:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var iterable = try std.fs.cwd().openDir("/tmp", .{});
defer iterable.close();
var walker = try iterable.walk(allocator);
defer walker.deinit();
while (try walker.next()) |entry| {
std.debug.print("Path: {s} ({s})\n", .{ entry.path, @tagName(entry.kind) });
}
}
Запустив пример на моей машине, я получил следующий вывод:
$ zig build run
Path: powerlog (directory)
Path: com.apple.launchd.8AIMA8gdDy (directory)
Path: com.apple.launchd.8AIMA8gdDy/Listeners (unix_domain_socket)
Path: TemporaryDirectory.rKd52q (directory)
Path: TemporaryDirectory.ljzjjb (directory)
Path: 30A0A2F4-CFAD-48B0-B590-4DEF623993BE-vpndownloader (file)
В этом примере мы открываем директорию /tmp
и с помощью метода walk
рекурсивно итерируемся по всем файлам и поддиректориям, содержащимся в ней. Для каждого элемента — будь то файл или директория — мы выводим его путь и тип.
Метод walk
удобен тем, что автоматически обходит всю структуру каталогов, начиная с указанной папки, избавляя нас от необходимости вручную реализовывать рекурсивный обход.
Важно помнить об одном критичном моменте, который часто упускают: после завершения обхода необходимо явно закрыть итератор, полученный через walk
. Это освобождает ресурсы, связанные с файловой системой, и предотвращает потенциальные утечки.
Работа с файлами
Теперь давайте рассмотрим, как в Zig работать с файлами: создавать их, открывать, читать, записывать данные и удалять. Начнём с самого первого шага — создания и открытия файла.
В Zig, как и в случае с директориями, есть два основных способа создать файл:
- Использовать функцию
createFileAbsolute
, передав ей абсолютный путь к файлу. - Вызвать метод
createFile
на файловом дескрипторе директории, полученном, например, черезstd.fs.cwd()
.
В обоих случаях, помимо указания пути к файлу, необходимо передать параметры, определяющие режим доступа. Эти параметры позволяют настроить, как файл будет использоваться — например, будет ли он открыт только для записи, нужно ли создавать его заново, если он уже существует, и так далее.
В результате вызова любого из этих методов вы получите файловый дескриптор (или “handle”) — объект, через который можно взаимодействовать с файлом: читать из него или записывать в него данные.
Важно помнить, что после завершения работы с файлом его необходимо явно закрыть с помощью метода close
. Это освобождает системные ресурсы, связанные с файловым доступом, и предотвращает утечки памяти или зависания дескрипторов, которые могут привести к ошибкам в работе программы. Итак давайте взглянем на код создания файла:
const std = @import("std");
pub fn main() !void {
var cwd = std.fs.cwd();
var f = try cwd.createFile("test.txt", .{});
defer f.close();
try f.writeAll("Hello, world!");
}
$ zig build run
$ cat test.txt
Hello, world!%
Если мы запустим наш пример, то в директории проекта появится файл test.txt
с содержимым Hello, world!
. Однако, если перед этим вручную создать файл test.txt
с текстом Good bye, Alice!
, а затем снова запустить программу, мы увидим, что файл был перезаписан — его содержимое заменилось на Hello, world!
.
Это происходит потому, что по умолчанию метод createFile
, если файл уже существует, открывает его и устанавливает его размер в 0. В результате всё предыдущее содержимое файла удаляется.
Если мы хотим, чтобы Zig создавал файл, только если его ещё нет, а в случае существующего файла просто открывал его на запись без стирания содержимого, нам нужно воспользоваться вторым параметром метода createFile
и передать опцию .truncate = false
.
Давайте изменим наш пример, чтобы избежать нежелательной потери данных:
const std = @import("std");
pub fn main() !void {
var cwd = std.fs.cwd();
var f = try cwd.createFile("test.txt", .{ .truncate = false });
defer f.close();
try f.writeAll("Hello, world!");
}
Если мы теперь запустим нашу программу, то получим странное содержимое файла:
$ zig build run
$ cat test.txt
Hello, world!ce!
Как мы видим, содержимое файла не было полностью удалено, однако текст, который мы пытались записать, оказался записан поверх уже существующего текста, а не добавлен в конец файла. Это привело к тому, что исходное содержимое файла было частично испорчено.
Причина такого поведения в том, что при открытии файла на запись курсор записи по умолчанию выставлен в начало файла, а не в конец. Поэтому новая запись перезаписывает существующие данные с самого начала.
Чтобы избежать этого и записывать данные в конец файла, необходимо вручную сместить позицию курсора записи. Для этого можно использовать метод seekTo
, установив курсор в конец файла перед записью нового содержимого. Мы рассмотрим чуть позднее как использовать курсор при работе с файлами, а пока давайте изменим наш пример и исправим это поведение:
const std = @import("std");
pub fn main() !void {
var cwd = std.fs.cwd();
var f = try cwd.createFile("test.txt", .{ .truncate = false });
defer f.close();
try f.seekTo(try f.getEndPos());
try f.writeAll("Hello, world!");
}
Теперь если мы запустим нашу программу она должна отработать нормально:
$ zig build run
cat test.txt
Good bye, Alice!
Hello, world!
Давайте теперь рассмотрим как открывать существующие файлы. Для того чтобы открыть существующий файл вам необходимо использовать один из двух подходов - функцию openFileAbsolute
или метод openFile
у файлового дескриптора директории. При открытии файла, вам необходимо указать во втором параметре флаг, в каком режиме вы открываете файл. Всего поддерживается 3 режима открытия файла - read_only
, write_only
и read_write
. Давайте рассмотрим как открыть созданный нами файл и прочитать его содержимое:
const std = @import("std");
pub fn main() !void {
var cwd = std.fs.cwd();
var f = try cwd.openFile("test.txt", .{ .mode = .read_only });
defer f.close();
var buf: [1024]u8 = undefined;
const n = try f.read(&buf);
std.debug.print("{s}\n", .{buf[0..n]});
}
Если мы запустим нашу программу, то мы успешно прочитаем содержимое файла:
$ zig build run
Good bye, Alice!
Hello, world!
Давайте теперь рассмотрим как нам копировать и удалять файлы. Для копирования файла можно использовать два варианта - функцию copyFileAbsolute
и метод copyFile
у файлового дескриптора директории. Мы рассмотрим только второй вариант, если вам необходимо использовать первый, вы можете ознакомится с ним в документации к стандартной библиотеки. Для удаления файла также можно использовать два способа - функцию deleteFileAbsolute
и метод deleteFile
у файлового дескриптора директории. Давайте рассмотрим как копировать и удалить созданный нами файл:
const std = @import("std");
pub fn main() !void {
var cwd = std.fs.cwd();
var f = try cwd.openFile("test.txt", .{ .mode = .read_only });
defer f.close();
var buf: [1024]u8 = undefined;
const n = try f.read(&buf);
std.debug.print("{s}\n", .{buf[0..n]});
try cwd.copyFile("test.txt", cwd, "test_copy.txt", .{});
try cwd.deleteFile("test.txt");
}
Если мы запустим нашу программу, то мы успешно скопируем и удалим оригинальный файл:
$ l .
total 24
drwxr-xr-x 8 roman staff 256B May 4 08:45 .
drwxr-xr-x 7 roman staff 224B Apr 26 16:29 ..
drwxr-xr-x@ 6 roman staff 192B Apr 26 01:15 .zig-cache
-rw-r--r--@ 1 roman staff 803B May 2 14:04 build.zig
-rw-r--r--@ 1 roman staff 2.2K May 2 14:02 build.zig.zon
drwxr-xr-x 3 roman staff 96B May 3 16:39 src
-rw-r--r--@ 1 roman staff 30B May 4 08:45 test_copy.txt
drwxr-xr-x@ 3 roman staff 96B Apr 26 01:16 zig-out
При копировании файла с помощью метода copyFile
, мы передаём несколько параметров: имя исходного файла, файловый дескриптор директории назначения, новое имя файла в целевой директории, а также параметры копирования, которые определяют поведение в случае, если файл с таким именем уже существует.
Если в целевой директории уже есть файл с указанным именем, он будет перезаписан. Поэтому при использовании этого метода следует быть особенно внимательным, чтобы не потерять важные данные из-за непреднамеренной перезаписи.
Осталось разобраться, как работает метод seekTo
и что такое курсор (или позиция) при работе с файлом.
Когда вы работаете с файлом — читаете из него или записываете в него данные — у файла существует курсор, который указывает на текущую позицию внутри файла. Этот курсор определяет, откуда именно будут читаться или куда будут записываться данные.
Например, если вы вызываете метод read
, который считывает данные из файла в буфер, курсор автоматически смещается вперёд на количество прочитанных байт. В результате следующий вызов read
начнёт чтение с того места, где закончился предыдущий. Аналогично, при записи данные будут вставлены в позицию, на которую указывает курсор, с последующим его сдвигом.
Метод seekTo
позволяет вручную изменить положение курсора. Это может быть полезно, если вы хотите начать чтение или запись не с текущей позиции, а, скажем, с начала, конца или произвольной точки в файле.
Когда вы открываете или создаёте файл, курсор автоматически устанавливается в начало файла. Однако это не всегда удобно — например, если вы хотите добавить данные в конец файла. В таких случаях необходимо вручную переместить курсор в нужную позицию.
Для этого в Zig доступны три метода: seekTo
, seekFromEnd
и seekBy
. Все они позволяют изменить положение курсора, но делают это по-разному:
- seekTo перемещает курсор на позицию, заданную относительно начала файла.
- seekFromEnd — как видно из названия — рассчитывает позицию от конца файла.
- seekBy сдвигает курсор на заданное количество байт относительно текущей позиции.
Выбор подходящего метода зависит от того, куда именно вы хотите переместить курсор: в начало, конец или просто сместить его вперёд или назад. Для того чтобы сместить указатель в начало файла мы просто используем позицию 0, а вот чтобы используя метод seekTo
сместить указатель в конец файла вам нужно узнать на какой позиции курсора заканчивается текущий файл. Для этого вы может использовать метод getEndPos
как это было сделано в нашем примере.
Пишем grep утилиту
Давайте применим знания, полученные в этой и предыдущих главах, на практике и реализуем что-то более интересное.
Как я уже упоминал, в мире Unix принято создавать небольшие утилиты, каждая из которых хорошо выполняет одну конкретную задачу. Это философия разработки, в основе которой лежит принцип “делай одно, но делай хорошо”.
В качестве примера давайте попробуем реализовать простую версию утилиты grep
, которая будет искать заданную строку в указанном файле. Наша программа будет принимать два аргумента: строку для поиска и путь к файлу, в котором нужно выполнить поиск. Затем она построчно прочитает содержимое файла и выведет только те строки, в которых встречается искомая подстрока.
Таким образом, мы сможем применить сразу несколько ранее изученных тем: работу с аргументами командной строки, файловым вводом-выводом, чтением из файла и базовой обработкой строк.
const std = @import("std");
const fs = std.fs;
const io = std.io;
const mem = std.mem;
const process = std.process;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Получаем аргументы командной строки
const args = try process.argsAlloc(allocator);
defer process.argsFree(allocator, args);
// Проверяем, что передано достаточно аргументов
if (args.len < 3) {
std.debug.print("Использование: {s} <текст> <файл>\n", .{args[0]});
process.exit(1);
}
const search_text = args[1];
const file_path = args[2];
// Открываем файл
const file = fs.cwd().openFile(file_path, .{}) catch |err| {
std.debug.print("Ошибка открытия файла '{s}': {}\n", .{ file_path, err });
process.exit(1);
};
defer file.close();
// Читаем файл построчно
var buffered_reader = io.bufferedReader(file.reader());
var in_stream = buffered_reader.reader();
var buffer = std.ArrayList(u8).init(allocator);
defer buffer.deinit();
var line_number: usize = 1;
while (true) {
in_stream.streamUntilDelimiter(buffer.writer(), '\n', null) catch |err| switch (err) {
error.EndOfStream => break,
else => return err,
};
if (mem.indexOf(u8, buffer.items, search_text) != null) {
std.debug.print("{d}: {s}\n", .{ line_number, buffer.items });
}
line_number += 1;
buffer.clearRetainingCapacity();
}
}
Давайте запустим нашу программу и убедимся что она работает:
$ cat test.txt
Good bye, Alice!
Hello, world!
Test message here
World is mine
Hello from zig world
$ zig build run -- world test.txt
2: Hello, world!
7: Hello from zig world
$ zig build run -- nothing test.txt
Как мы видим, наша программа работает корректно и выводит строки, содержащие заданную подстроку. Давайте разберёмся, как она устроена.
Сначала мы получаем аргументы командной строки с помощью функции process.argsAlloc
. Ранее мы её не рассматривали, но главное её отличие от argsWithAllocator
заключается в том, что она возвращает не итератор, а срез всех аргументов сразу, что может быть удобнее для простых случаев.
Далее мы проверяем, что было передано нужное количество параметров, и открываем указанный файл для чтения. Для этого мы используем буферизованный читатель, что позволяет значительно сократить количество прямых обращений к диску и повышает производительность при чтении больших файлов.
Затем мы построчно читаем файл с диска, используя функцию streamUntilDelimiter
, и для каждой строки проверяем, содержит ли она искомый текст. Для этой проверки используется функция mem.indexOf
, которая возвращает индекс первого вхождения подстроки в строку или null
, если подстрока не найдена.
Интересный момент заключается в том, что в качестве буфера для считываемого текста мы используем std.ArrayList(u8)
. Это позволяет динамически расширять буфер, если длина строки превышает его изначальный размер, а также эффективно очищать его после чтения каждой строки.
В целом наша программа работает корректно, однако есть один интересный аспект, который стоит обсудить. Когда мы пишем программу на Zig и используем функции из стандартной библиотеки, мы, как правило, предполагаем, что они реализованы максимально эффективно и оптимально. К сожалению, это не всегда так. Поэтому бывает полезно заглянуть в реализацию конкретной функции или метода, чтобы понять, как именно они работают и не вносят ли ненужных издержек.
В нашем примере проблема с производительностью возникает в методе streamUntilDelimiter
и на это даже заведен тикет https://github.com/ziglang/zig/issues/17985. Давайте рассмотрим как она устроена:
pub fn streamUntilDelimiter(
self: Self,
writer: anytype,
delimiter: u8,
optional_max_size: ?usize,
) anyerror!void {
if (optional_max_size) |max_size| {
for (0..max_size) |_| {
const byte: u8 = try self.readByte();
if (byte == delimiter) return;
try writer.writeByte(byte);
}
return error.StreamTooLong;
} else {
while (true) {
const byte: u8 = try self.readByte();
if (byte == delimiter) return;
try writer.writeByte(byte);
}
// Can not throw `error.StreamTooLong` since there are no boundary.
}
}
Как мы видим, наша функция streamUntilDelimiter
зависит от функции readByte
. Давайте взглянем на реализацию этой функции:
pub fn readByte(self: Self) anyerror!u8 {
var result: [1]u8 = undefined;
const amt_read = try self.read(result[0..]);
if (amt_read < 1) return error.EndOfStream;
return result[0];
}
Эта функция достаточно универсальна и будет работать с любым читателем, реализующим метод read
для соответствующего интерфейса. Однако такая универсальность часто приводит к потере производительности. Если мы заранее знаем, что используем буферизованный читатель и у нас уже есть доступ к буферу (что в большинстве случаев так и есть), мы можем использовать более эффективные реализации streamUntilDelimiter
, специально оптимизированные под этот случай.
fn streamUntilDelimiter(buffered: anytype, writer: anytype, delimiter: u8) !void {
while (true) {
const start = buffered.start;
if (std.mem.indexOfScalar(u8, buffered.buf[start..buffered.end], delimiter)) |pos| {
// we found the delimiter
try writer.writeAll(buffered.buf[start..start+pos]);
// skip the delimiter
buffered.start += pos + 1;
return;
} else {
// we didn't find the delimiter, add everything to the output writer...
try writer.writeAll(buffered.buf[start..buffered.end]);
// ... and refill the buffer
const n = try buffered.unbuffered_reader.read(buffered.buf[0..]);
if (n == 0) {
return error.EndOfStream;
}
buffered.start = 0;
buffered.end = n;
}
}
}
Этот пример будет работать примерно в четыре раза быстрее, чем стандартная функция. Я взял его из статьи автора тикета, которую вы можете найти по ссылке: https://www.openmymind.net/Performance-of-reading-a-file-line-by-line-in-Zig/.
Заключение
В этой главе мы познакомились с основными возможностями работы с файловой системой. Конечно, мы не охватили всё — многие аспекты остались за пределами обзора. Мы не рассматривали создание символических ссылок, изменение прав доступа к файлам, блокировку файлов и другие продвинутые возможности. Всё это вы можете изучить самостоятельно, обратившись к документации стандартной библиотеки.
Также мы изучили пример практической реализации программы grep, которая демонстрирует, как эффективно использовать модули стандартной библиотеки Zig для работы с файловой системой (std.fs
), вводом-выводом (std.io
) и строками (std.mem
).
Навыки работы с файловой системой, полученные в этой главе, являются фундаментальными для разработки практически любых приложений, от простых утилит командной строки до сложных серверных систем. Поддержка кросс-платформенной работы через абстракции в std.fs.path
позволяет писать переносимый код без необходимости беспокоиться о различиях между операционными системами.