Работа с файловой системой

2025-05-03 3855 19

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

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

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

Построение пути к файлу или директории

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

К сожалению, операционные системы не ограничиваются использованием только валидных с точки зрения UTF-8 символов в именах файлов и директорий. В Unix-подобных системах любое произвольное сочетание байтов (за исключением некоторых, например нулевого байта \0) может считаться допустимым именем файла. В Windows ситуация аналогичная: почти любая строка “широких” символов (UTF-16) считается допустимой, за исключением определённых символов вроде :, *, ?, " и т.д.

Также важно учитывать, что формат путей различается между операционными системами. В Unix-системах разделителем директорий является символ /, тогда как в Windows используется обратный слэш \. Эти различия могут вызвать ошибки при написании кроссплатформенного кода, если не учитывать особенности платформ.

Чтобы упростить работу с путями и скрыть платформенные различия, многие языки программирования обычно предлагают абстракции. Например, в Rust есть отдельный тип Path. В Zig подход немного другой: здесь нет специального типа для путей, но есть модуль std.fs.path, предоставляющий набор функций для построения, разбора и анализа путей. Это позволяет писать кроссплатформенный код без лишней сложности.

Модуль std.fs.path позволяет:

Рассмотрим некоторые полезные функции из 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, как и в случае с директориями, есть два основных способа создать файл:

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

В результате вызова любого из этих методов вы получите файловый дескриптор (или “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. Все они позволяют изменить положение курсора, но делают это по-разному:

Выбор подходящего метода зависит от того, куда именно вы хотите переместить курсор: в начало, конец или просто сместить его вперёд или назад. Для того чтобы сместить указатель в начало файла мы просто используем позицию 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 позволяет писать переносимый код без необходимости беспокоиться о различиях между операционными системами.

#directory #file #zig #zigbook

0%