Репортаж от страната на Зиг и опасностите, които дебнат там
Така че прекарах известно време, за да се запозная с езика за програмиране 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:
- Ако не предоставяте разпределител на функция, тогава тя също няма да разпределя нищо. Това означава, че в повечето случаи ще върне цели стойности, а не указатели.
- Ако не използвате някаква функция за достъп, но всъщност получавате поле на структура, тогава обикновено бихте искали указател към това поле.
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;
}
}