Репортаж из страны Зига и опасностей, которые там таятся

Поэтому я потратил некоторое время на ознакомление с языком программирования Zig.

Поскольку мы все еще не в версии 1.0, на дороге обязательно будут некоторые неровности. Итак, это я документирую некоторые из проблем, с которыми я столкнулся.

Когда я получу адрес?

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

Одна вещь, в отношении которой я изначально чувствовал себя очень неуверенно, заключалась в том, когда я буду искать адрес. Если вы посмотрите на пример ниже, мы просто получим значение для переменных gpa и dir:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    var allocator: *std.mem.Allocator = &gpa.allocator;
    
    const dir: Dir = std.fs.cwd();
}

However the allocator is a pointer. How do you know when to get a pointer? Here are some simple rules to keep in mind:

  1. Если вы не предоставляете распределителя функции, то она также ничего не будет выделять. Это означает, что в большинстве случаев он будет возвращать целые значения, а не указатели.
  2. Если вы не используете какую-либо функцию доступа, а фактически получаете поле структуры, то обычно вам нужен указатель на это поле.

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

std.fs.cwd() напр. не является частью объекта, поэтому все, что возвращается, не может быть полем, скажем, std.fs, поэтому вы должны получить целое значение структуры для работы, а не использовать указатель.

Интерфейсы в Zig

Возможно, стоит упомянуть в качестве примечания о том, как интерфейсы и наследование работают в Zig. Интерфейсы и наследование формально не являются частью языка. Вместо этого это делается так же, как C, где одна из переменных-членов структуры в основном представляет суперкласс/базовый класс.

Например. вы можете думать о std.mem.Allocator как о базовом классе или интерфейсе std.heap.GeneralPurposeAllocator. Функциональность базового класса доступна через член .allocator. Он содержит указатели на функции, принимающие std.mem.Allocator в качестве аргумента.

Но на самом деле это зависит от окружающей структуры. Вот упрощенная и отредактированная версия того, как работает функция alloc в структуре `std.mem.Allocator:

fn alloc(allocator: *Allocator, ...) ![*]u8 {
    var self: GeneralPurposeAllocator = undefined;
    self = @fieldParentPtr(GeneralPurposeAllocator, 
                           "allocator", 
                            allocator);
    // code ...
}

Эта функция на самом деле сначала задается как указатель функции на поле std.mem.Allocator. Каждый конкретный тип распределителя будет заполнять эти поля указателя функций функциями, характерными для этого конкретного распределителя.

Итак, эта функция использует @fieldParentPtr для получения указателя на окружающую структуру GeneralPurposeAllocator. Но если allocator является копией объекта-распределителя внутри структуры GeneralPurposeAllocator, а не внутри нее, то это взорвется.

@fieldParentPtr — это функция, которая запускается во время компиляции для вычисления базового указателя для структуры. Это можно сделать, потому что компилятор, естественно, знает, какое смещение памяти находится в поле allocator внутри поля GeneralPurposeAllocator. Однако компилятор не может знать, действительно ли объект allocator находится внутри такой структуры, только то, что если это так, он может дать вам базовый указатель. Если это объяснение не имеет смысла, то я советую вам прочитать это более подробное объяснение интерфейсов Zig.

Вы спросите, зачем все это усложнение? Почему бы просто не сделать, как C++, Go и другие, и предоставить интерфейсы или классы на уровне языка?

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

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

Чтение файлов

В моей другой жизни я обычно использовал Джулию и Python, где один простой вызов open для открытия файла. У Zig здесь несколько своеобразный интерфейс. Это имеет некоторый смысл, но я не уверен, чем это было вдохновлено.

В Zig, когда вы хотите открыть файл, вам обычно приходится начинать с каталога. Затем вы просите этот каталог открыть относительно него файл.

Если вы хотите узнать, какой у вас текущий рабочий каталог в соответствии с Zig, вы можете сделать это с помощью этих строк кода:

const path = try fs.realpathAlloc(allocator, ".");
defer allocator.free(path);
try stdout.print("{}\n", .{path});

Вам не обязательно освобождать path, но это хорошая привычка.

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

const fs = std.fs;
const dir: fs.Dir = fs.cwd();

Из этого рабочего каталога мы открываем файл foobar.txt в текущем каталоге:

const file: fs.File = try dir.openFile(
    "foobar.txt",
    .{ .read = true },
);
defer file.close();

Второй аргумент .{ .read = true } нуждается в пояснении для новичков в Zig. Здесь мы фактически предоставляем структуру, в которой мы устанавливаем для поля read значение true. Структура определяется примерно так:

pub const OpenFlags = struct {
    read: bool = true,
    write: bool = false,
    lock: Lock = .None,
};

Обратите внимание, что я удалил некоторые поля для ясности.

Просто написав точки ., мы полагаемся на то, что компилятор Zig автоматически определит тип. Если бы мы не хотели этого делать, мы могли бы вместо этого написать:

var flags = fs.File.OpenFlags{};
flags.read = true;

const file: File = try dir.openFile(
    "foobar.txt",
    flags,
);

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

Интерфейс читателя

Как мы говорили с распределителями, использование базового интерфейса часто требует получения поля. Сам объект file не предлагает общих функций чтения. Мы получаем это, вызывая reader().

const io = std.io;
const reader: io.Reader = file.reader();

Если вы читаете старые примеры кода Zig, вы увидите, что вместо этого используется file.inStream(), но это устарело, поэтому забудьте, что вы когда-либо видели его.

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

В то время как Loop Gotcha

Наконец мы добрались до той части, которая вдохновила меня на написание этой истории. Я хотел читать по одной строке через интерфейс io.Reader.

Несколько функций позволяют это сделать:

  • readUntilDelimiterArrayList
  • readUntilDelimiterAlloc
  • readUntilDelimiterOrEof

Я выбираю последнее, так как это кажется разумным, чтобы не выделять память для строки снова и снова, а вместо этого повторно использовать буфер:

var buffer: [500]u8 = undefined;
while (try reader.readUntilDelimiterOrEof(buffer[0..], '\n')) |line| {
    try stdout.print("Line: {}\n", .{line});
}

Однако правильно использовать это оказалось непросто. Причину можно найти в сигнатуре функции:

fn readUntilDelimiterOrEof(self: Self, buf: []u8, delimiter: u8) !?[]u8

! означает, что он возвращает либо набор ошибок, либо значение. Однако ? означает, что это значение может быть 8-битным целочисленным массивом без знака ([]u8) или указателем null. Иметь как null, так и потенциально код ошибки - головная боль, и, честно говоря, я думаю, что они допустили ошибку в дизайне интерфейса.

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

Однако работа с необязательным значением и ошибкой, установленной в цикле while в Zig, не является чем-то, для чего он был разработан. Эта строка означает, что мы возвращаем необязательное значение или выходим из функции с ошибкой:

try reader.readUntilDelimiterOrEof(buffer[0..], '\n')

Внешний вид while настроен так, что вы можете сделать это:

while (next()) |item| {
   stuff(item);
}

Здесь цикл продолжается, пока элемент, возвращенный из next(), не является null. Это дает желаемое поведение в нашем случае.

Но что, если мы решим, хотим ли мы на самом деле обрабатывать потенциальный код ошибки, возвращаемый локально? Тогда у нас может возникнуть соблазн подумать, что мы могли бы написать:

while (reader.readUntilDelimiterOrEof(buffer[0..], '\n')) |line| {
    try stdout.print("Line: {}\n", .{line});
} else |err| {
    try stdout.print("Error: {}\n", .{err});
}

Но НЕ делайте это! Вы получите бесконечный цикл. Если вы посмотрите в руководство, это выглядит заманчиво, потому что добавление else к циклу while позволяет вам отлавливать ошибки в цикле while.

За исключением того, что эта конструкция не работает с опциями. Таким образом, в этом случае, когда line становится null, цикл фактически не завершается, что означает, что он продолжается бесконечно.

Таким образом, нет простого способа решить обе проблемы одновременно. Ошибку лучше распространять за пределы функции. В качестве альтернативы мы создаем бесконечный цикл while, из которого намеренно выходим, когда это необходимо. Реализация фактически использует эту тактику:

pub fn readUntilDelimiterOrEof(self: Self, buf: []u8, delimiter: u8) !?[]u8 {
    var index: usize = 0;
    while (true) {
        const byte = self.readByte() catch |err| switch (err) {
            error.EndOfStream => {
                if (index == 0) {
                    return null;
                } else {
                    return buf[0..index];
                }
            },
            else => |e| return e,
        };

        if (byte == delimiter) return buf[0..index];
        if (index >= buf.len) return error.StreamTooLong;

        buf[index] = byte;
        index += 1;
    }
}