Репортаж от страната на Зиг и опасностите, които дебнат там

Така че прекарах известно време, за да се запозная с езика за програмиране 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 и други и не предоставите интерфейси или класове на ниво език?

Въпросът е Zig да е сравнително ниско ниво. Предимство на създаването на интерфейс като този е, че има много гъвкавост в това как се прави. напр. множественото наследяване е лесно за добавяне, ако желаете.

Можете да добавите поле към вашата структура за всеки интерфейс/базов клас, който прилагате, напр.

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

В другия си живот обикновено използвах Julia и 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 (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;
    }
}