Nauka Ziga

Witamy w Nauce Ziga, wprowadzeniu do języka programowania Zig. Ten przewodnik ma na celu ułatwienie korzystania z Ziga. Zakłada on wcześniejsze doświadczenie w programowaniu, choć nie w żadnym konkretnym języku.

Zig jest intensywnie rozwijany i zarówno język Zig, jak i jego standardowa biblioteka stale ewoluują. Niniejszy przewodnik dotyczy najnowszej wersji rozwojowej Ziga. Może się jednak zdarzyć, że część kodu nie będzie zsynchronizowana. Jeśli pobrałeś najnowszą wersję Ziga i masz problemy z uruchomieniem kodu, zgłoś ten problem (w języku angielskim).

Tłumaczenia

Spis treści

  1. Instalacja Ziga
  2. Przegląd języka - część 1
  3. Przegląd języka - część 2
  4. Przewodnik po stylach
  5. Wskaźniki
  6. Pamięć stosu
  7. Pamięć sterty i alokatory
  8. Generyczność (polimorfizm parametryczny)
  9. Kodowanie w Zigu
  10. Wnioski

Instalacja Ziga

Strona pobierania Zig zawiera prekompilowane pliki binarne dla popularnych platform. Na tej stronie znajdziesz pliki binarne dla najnowszej wersji rozwojowej, a także dla głównych wydań. Najnowsza wersja, której dotyczy niniejszy przewodnik, znajduje się na górze strony.

Dla mojego komputera będę pobierał zig-macos-aarch64-0.12.0-dev.2777+2176a73d6.tar.xz. Być może korzystasz z innej platformy lub nowszej wersji. Po rozwinięciu archiwum powinieneś mieć plik binarny zig (oprócz innych rzeczy), który będziesz chciał aliasować lub dodać do swojej ścieżki; w zależności od tego, do czego jesteś przyzwyczajony.

Teraz powinieneś być w stanie uruchomić zig zen i zig version, aby przetestować swoją konfigurację.

Przegląd języka – część 1

Zig jest silnie typowanym językiem kompilowanym. Obsługuje generyki, ma potężne możliwości metaprogramowania w czasie kompilacji i nie zawiera garbage collectora. Wiele osób uważa Ziga za nowoczesną alternatywę dla C. Jako alternatywa ma składnię języka podobną do C. Mówimy o instrukcjach zakończonych średnikiem i blokach ograniczonych nawiasami klamrowymi.

Oto jak wygląda kod Zig:

const std = @import("std");

// Ten kod nie skompiluje się, jeśli `main` nie jest `pub` (publiczny)
pub fn main() void {
    const user = User{
        .power = 9001,
        .name = "Goku",
    };

    std.debug.print("{s}'s power is {d}", .{user.name, user.power});
}

pub const User = struct {
    power: u64,
    name: []const u8,
};

Jeśli zapiszesz powyższe jako learning.zig i uruchomisz zig run learning.zig, powinieneś zobaczyć: Goku's power is 9001.

Jest to prosty przykład, gdzie możesz podążać za kodem, nawet jeśli pierwszy raz widzisz Ziga. Mimo to, przejrzymy go linijka po linijce.

Zobacz sekcję dotyczącą instalacji Ziga, aby szybko rozpocząć pracę.

Importowanie

Bardzo niewiele programów jest napisanych jako pojedynczy plik bez standardowej biblioteki lub bibliotek zewnętrznych. Nasz pierwszy program nie jest wyjątkiem i wykorzystuje standardową bibliotekę Ziga do wypisania naszych danych wyjściowych. System importu Ziga jest prosty i opiera się na funkcji @import i słowie kluczowym pub (aby kod był dostępny poza bieżącym plikiem).

Funkcje zaczynające się od @ są funkcjami wbudowanymi. Są one dostarczane przez kompilator, a nie przez bibliotekę standardową.

Importujemy moduł określając jego nazwę. Standardowa biblioteka Ziga jest dostępna przy użyciu nazwy "std". Aby zaimportować określony plik, używamy jego ścieżki względem pliku wykonującego import. Na przykład, jeśli przenieśliśmy strukturę User do jej własnego pliku, powiedzmy models/user.zig:

// models/user.zig
pub const User = struct {
    power: u64,
    name: []const u8,
};

Następnie zaimportujemy go za pośrednictwem:

// main.zig
const User = @import("models/user.zig").User;

Jeśli nasza struktura User nie została oznaczona jako pub, otrzymamy następujący błąd: 'User' is not marked 'pub'.

models/user.zig może eksportować więcej niż jedną rzecz. Na przykład, możemy również wyeksportować stałą:

// models/user.zig
pub const MAX_POWER = 100_000;

pub const User = struct {
    power: u64,
    name: []const u8,
};

W takim przypadku moglibyśmy zaimportować oba:

const user = @import("models/user.zig");
const User = user.User;
const MAX_POWER = user.MAX_POWER;

W tym momencie możesz mieć więcej pytań niż odpowiedzi. Czym jest user w powyższym fragmencie? Jeszcze tego nie widzieliśmy, ale co jeśli użyjemy var zamiast const? A może zastanawiasz się, jak korzystać z bibliotek stron trzecich. To wszystko są dobre pytania, ale aby na nie odpowiedzieć, musimy najpierw dowiedzieć się więcej o Zigu. Na razie będziemy musieli zadowolić się tym, czego się nauczyliśmy: jak importować standardową bibliotekę Ziga, jak importować inne pliki i jak eksportować definicje.

Komentarze

Następna linia naszego przykładu Zig jest komentarzem:

// Ten kod nie skompiluje się, jeśli `main` nie jest `pub` (publiczny)

Zig nie posiada wieloliniowych komentarzy, tak jak /* ... */ w C.

Istnieje eksperymentalne wsparcie dla automatycznego generowania dokumentów na podstawie komentarzy. Jeśli widziałeś dokumentację biblioteki standardowej Zig, to widziałeś to w akcji. //! jest znany jako komentarz dokumentu najwyższego poziomu i może być umieszczony na początku pliku. Komentarz z potrójnym ukośnikiem (///), znany jako komentarz dokumentu, może być umieszczony w określonych miejscach, na przykład przed deklaracją. Próba użycia któregokolwiek typu komentarza dokumentu w niewłaściwym miejscu spowoduje błąd kompilatora.

Funkcje

Następny wiersz kodu jest początkiem naszej głównej funkcji:

pub fn main() void

Każdy plik wykonywalny potrzebuje funkcji o nazwie main: jest to punkt wejścia do programu. Gdybyśmy zmienili nazwę main na coś innego, na przykład doIt, i spróbowali uruchomić zig run learning.zig, otrzymalibyśmy błąd informujący, że 'learning' has no member named 'main'.

Pomijając specjalną rolę main jako punktu wejścia naszego programu, jest to naprawdę podstawowa funkcja: nie przyjmuje żadnych parametrów i nic nie zwraca, czyli void. Poniższy przykład jest nieco bardziej interesujący:

const std = @import("std");

pub fn main() void {
    const sum = add(8999, 2);
    std.debug.print("8999 + 2 = {d}\n", .{sum});
}

fn add(a: i64, b: i64) i64 {
    return a + b;
}

Programiści C i C++ zauważą, że Zig nie wymaga wcześniejszej deklaracji, tj. add jest wywoływany przed jego zdefiniowaniem.

Kolejną rzeczą, na którą należy zwrócić uwagę, jest typ i64: 64-bitowa liczba całkowita ze znakiem. Inne typy liczbowe to: u8, i8, u16, i16, u32, i32, u47, i47, u64, i64, f32 i f64. Włączenie u47 i i47 nie jest testem, aby upewnić się, że nadal nie śpisz; Zig obsługuje liczby całkowite o dowolnej szerokości bitowej. Chociaż prawdopodobnie nie będziesz ich często używać, mogą się przydać. Jednym z często używanych typów jest usize, który jest liczbą całkowitą bez znaku o rozmiarze wskaźnika i ogólnie typem reprezentującym długość/rozmiar czegoś.

Oprócz f32 i f64, Zig obsługuje również typy zmiennoprzecinkowe f16, f80 i f128.

Chociaż nie ma dobrego powodu, aby to robić, jeśli zmienimy implementację add na:

fn add(a: i64, b: i64) i64 {
    a += b;
    return a;
}

Otrzymamy błąd na a += b;: cannot assign to constant. Jest to ważna lekcja, do której wrócimy bardziej szczegółowo później: parametry funkcji są stałymi.

Ze względu na lepszą czytelność, nie ma przeciążania funkcji (ta sama funkcja zdefiniowana z różnymi typami parametrów i/lub liczbą parametrów). Na razie to wszystko, co musimy wiedzieć o funkcjach.

Struktury (struct)

Następną linią kodu jest utworzenie typu User, który jest zdefiniowany na końcu naszego snippetu. Definicja User to:

pub const User = struct {
    power: u64,
    name: []const u8,
};

Ponieważ nasz program jest pojedynczym plikiem, a zatem User jest używany tylko w pliku, w którym jest zdefiniowany, nie musieliśmy go robić pub. Ale wtedy nie zobaczylibyśmy, jak wyeksponować deklarację innym plikom.

Pola struct są zakończone przecinkiem i mogą mieć wartość domyślną::

pub const User = struct {
    power: u64 = 0,
    name: []const u8,
};

Kiedy tworzymy strukturę, każde pole musi być ustawione. Na przykład w oryginalnej definicji, w której power nie miało wartości domyślnej, wystąpiłby następujący błąd: missing struct field: power.

const user = User{.name = "Goku"};

Jednak z naszą domyślną wartością, powyższe kompiluje się dobrze.

Struktury mogą mieć metody, mogą zawierać deklaracje (w tym inne struktury), a nawet mogą zawierać zero pól, w którym to momencie działają bardziej jak przestrzeń nazw.

pub const User = struct {
    power: u64 = 0,
    name: []const u8,

    pub const SUPER_POWER = 9000;

    pub fn diagnose(user: User) void {
        if (user.power >= SUPER_POWER) {
            std.debug.print("it's over {d}!!!", .{SUPER_POWER});
        }
    }
};

Metody to zwykłe funkcje, które można wywołać za pomocą składni kropki. Oba te sposoby działają:

// wywołaj diagnose na userze
user.diagnose();

// Powyższe jest cukrem składniowym dla:
User.diagnose(user);

Przez większość czasu będziesz używać składni kropki, ale metody jako cukier składniowy nad zwykłymi funkcjami mogą się przydać.

Instrukcja if jest pierwszym przepływem sterowania, który widzieliśmy. To całkiem proste, prawda? Zbadamy to bardziej szczegółowo w następnej części.

diagnose jest zdefiniowana w naszym typie User i akceptuje User jako pierwszy parametr. W związku z tym możemy wywołać ją za pomocą składni kropki. Ale funkcje wewnątrz struktury nie muszą podążać za tym wzorcem. Jednym z typowych przykładów jest funkcja init inicjująca naszą strukturę:

pub const User = struct {
    power: u64 = 0,
    name: []const u8,

    pub fn init(name: []const u8, power: u64) User {
        return User{
            .name = name,
            .power = power,
        };
    }
}

Użycie init jest jedynie konwencją i w niektórych przypadkach open lub inna nazwa może mieć więcej sensu. Jeśli jesteś podobny do mnie i nie jesteś programistą C++, składnia inicjalizacji pól, .$field = $value, może być nieco dziwna, ale szybko się do niej przyzwyczaisz.

Kiedy utworzyliśmy "Goku", zadeklarowaliśmy zmienną user jako const:

const user = User{
    .power = 9001,
    .name = "Goku",
};

Oznacza to, że nie możemy modyfikować user. Aby zmodyfikować zmienną, należy ją zadeklarować za pomocą var. Być może zauważyłeś również, że typ user jest wnioskowany na podstawie tego, co jest do niego przypisane. Moglibyśmy być jawni:

const user: User = User{
    .power = 9001,
    .name = "Goku",
};

Takie użycie jest jednak dość nietypowe. Jednym z miejsc, w których jest to bardziej powszechne, jest zwracanie struktury z funkcji. Tutaj typ można wywnioskować z typu zwracanego przez funkcję. Nasza funkcja init prawdopodobnie zostałaby napisana w ten sposób:

pub fn init(name: []const u8, power: u64) User {
    // zamiast zwracać User{...}
    return .{
        .name = name,
        .power = power,
    };
}

Jak przypadku większości rzeczy, które do tej pory zbadaliśmy, w przyszłości powrócimy do struktur, gdy będziemy mówić o innych częściach języka. Ale w przeważającej części są one proste.

Tablice (arrays) i wycinki (slices)

Moglibyśmy pominąć ostatnią linię naszego kodu, ale biorąc pod uwagę, że nasz mały fragment zawiera dwa łańcuchy, "Goku" i "{s}'s power is {d}\n", prawdopodobnie jesteś ciekawy łańcuchów w Zigu. Aby lepiej zrozumieć łańcuchy, najpierw zbadajmy tablice i wycinki.

Tablice mają stały rozmiar i długość znaną w czasie kompilacji. Długość jest częścią typu, więc tablica 4 liczb całkowitych ze znakiem, [4]i32, jest innego typu niż tablica 5 liczb całkowitych ze znakiem, [5]i32.

Długość tablicy można wywnioskować z inicjalizacji. W poniższym kodzie wszystkie trzy zmienne są typu [5]i32:

const a = [5]i32{1, 2, 3, 4, 5};

// widzieliśmy już tę składnię .{...} ze strukturami
// działa to również z tablicami
const b: [5]i32 = .{1, 2, 3, 4, 5};

// użyj _, aby pozwolić kompilatorowi wywnioskować długość
const c = [_]i32{1, 2, 3, 4, 5};

Z drugiej strony, wycinek jest wskaźnikiem do tablicy o określonej długości. Długość jest znana w czasie wykonywania. Wskaźniki omówimy w późniejszej części, ale można myśleć o wycinku jako o widoku tablicy.

Jeśli jesteś zaznajomiony z Go, być może zauważyłeś, że wycinki w Zigu są nieco inne: nie mają pojemności, a jedynie wskaźnik i długość.

Biorąc pod uwagę następujące,

const a = [_]i32{1, 2, 3, 4, 5};
const b = a[1..4];

Chciałbym móc powiedzieć, że b jest wycinkiem o długości 3 i wskaźnikiem do a. Ale ponieważ "pokroiliśmy" naszą tablicę przy użyciu wartości znanych w czasie kompilacji, tj. 1 i 4, nasza długość, 3, jest również znana w czasie kompilacji. Zig rozgryzł to wszystko i dlatego b nie jest wycinkiem, ale raczej wskaźnikiem do tablicy liczb całkowitych o długości 3. Konkretnie, jego typ to *const [3]i32. Tak więc ta demonstracja wycinka została udaremniona przez spryt Ziga.

W prawdziwym kodzie prawdopodobnie będziesz używał wycinków częściej niż tablic. Na dobre i na złe, programy mają tendencję do posiadania większej ilości informacji w czasie wykonania (runtime) niż w czasie kompilacji (compile time). W tym małym przykładzie musimy jednak oszukać kompilator, aby uzyskać to, czego chcemy:

const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];

b jest teraz prawidłowym wycinkiem, a konkretnie jego typem jest []const i32. Można zauważyć, że długość wycinka nie jest częścią typu, ponieważ długość jest właściwością czasu wykonania, a typy są zawsze w pełni znane w czasie kompilacji. Podczas tworzenia wycinka możemy pominąć górną granicę, aby utworzyć wycinek do końca tego, co kroimy (tablicy lub wycinka), np. const c = b[2...];.

Gdybyśmy zrobili const end: usize = 4 bez inkrementacji, to 1...end stałoby się znaną w czasie kompilacji długością dla b, a tym samym utworzyłoby wskaźnik do tablicy, a nie wycinek. Uważam, że jest to trochę mylące, ale nie jest to coś, co pojawia się zbyt często i nie jest zbyt trudne do opanowania. Chciałbym pominąć to w tym momencie, ale nie mogłem znaleźć uczciwego sposobu na uniknięcie tego szczegółu.

Nauka Ziga nauczyła mnie, że typy są bardzo opisowe. To nie tylko liczba całkowita lub logiczna, czy nawet tablica 32-bitowych liczb całkowitych ze znakiem. Typy zawierają również inne ważne informacje. Rozmawialiśmy o tym, że długość jest częścią typu tablicy, a wiele przykładów pokazało, że stałość jest również jego częścią. Na przykład, w naszym ostatnim przykładzie, typem b jest []const i32. Można to zobaczyć na przykładzie poniższego kodu:

const std = @import("std");

pub fn main() void {
    const a = [_]i32{1, 2, 3, 4, 5};
    var end: usize = 4;
    end += 1;
    const b = a[1..end];
    std.debug.print("{any}", .{@TypeOf(b)});
}

Gdybyśmy próbowali wpisać do b, np. b[2] = 5; otrzymalibyśmy błąd kompilacji: cannot assign to constant. Jest to spowodowane typem b.

Aby rozwiązać ten problem, można pokusić się o wprowadzenie następującej zmiany:

// zamień const na var
var b = a[1..end];

ale otrzymasz ten sam błąd, dlaczego? Jako podpowiedź, jaki jest typ b, lub bardziej ogólnie, czym jest b? Wycinek jest długością i wskaźnikiem do [części] tablicy. Typ wycinka jest zawsze pochodną tego, co jest wycinane. Niezależnie od tego, czy b jest zadeklarowana jako stała, czy nie, jest to wycinek [5]const i32, więc b musi być typu []const i32. Jeśli chcemy mieć możliwość zapisu do b, musimy zmienić a z const na var.

const std = @import("std");

pub fn main() void {
    var a = [_]i32{1, 2, 3, 4, 5};
    var end: usize = 3;
    end += 1;
    const b = a[1..end];
    b[2] = 99;
}

Działa to, ponieważ nasz wycinek nie jest już []const i32, ale raczej []i32. Można się zastanawiać, dlaczego to działa, skoro b wciąż jest stałą. Ale stałość b odnosi się do samego b, a nie do danych, na które b wskazuje. Cóż, nie jestem pewien, czy to świetne wyjaśnienie, ale dla mnie ten kod podkreśla różnicę:

const std = @import("std");

pub fn main() void {
    var a = [_]i32{1, 2, 3, 4, 5};
    var end: usize = 3;
    end += 1;
    const b = a[1..end];
    b = b[1..];
}

To się nie skompiluje; jak mówi nam kompilator, cannot assign to constant. Ale jeśli zrobilibyśmy var b = a[1..end];, kod zadziałałby, ponieważ samo b nie jest już stałą.

Więcej o tablicach i wycinkach dowiemy się przyglądając się innym aspektom języka, z których łańcuchy nie są najmniej ważnym.

Łańcuchy (strings)

Chciałbym móc powiedzieć, że Zig ma typ łańcuch i że jest niesamowity. Niestety tak nie jest. W najprostszym ujęciu, łańcuchy Ziga są sekwencjami (tj. tablicami lub wycinkami) bajtów (u8). Widzieliśmy to w definicji pola name: name: []const u8,.

Zgodnie z konwencją, i tylko zgodnie z konwencją, takie łańcuchy powinny zawierać tylko wartości UTF-8, ponieważ kod źródłowy Ziga jest sam w sobie zakodowany w UTF-8. Ale nie jest to egzekwowane i tak naprawdę nie ma różnicy między []const u8, który reprezentuje łańcuch ASCII lub UTF-8, a []const u8, który reprezentuje dowolne dane binarne. Jak mogłoby być inaczej, są tego samego typu.

Z tego, czego nauczyliśmy się o tablicach i wycinkach, można się domyślić, że []const u8 jest wycinkiem do stałej tablicy bajtów (gdzie bajt jest 8-bitową liczbą całkowitą bez znaku). Ale nigdzie w naszym kodzie nie wycięliśmy tablicy, ani nawet nie mieliśmy tablicy, prawda? Wszystko, co zrobiliśmy, to przypisanie "Goku" do user.name. Jak to zadziałało?

Literały łańcuchowe, te które widzisz w kodzie źródłowym, mają znaną długość w czasie kompilacji. Kompilator wie, że "Goku" ma długość 4. Można by więc pomyśleć, że "Goku" najlepiej reprezentuje tablica, coś w rodzaju [4]const u8. Ale literały łańcuchowe mają kilka specjalnych właściwości. Są one przechowywane w specjalnym miejscu w pliku binarnym i deduplikowane. Tak więc zmienna do literału łańcuchowego będzie wskaźnikiem do tej specjalnej lokalizacji. Oznacza to, że typ "Goku" jest bliższy *const [4]u8, wskaźnikowi do stałej tablicy 4 bajtów.

To nie wszystko. Literały łańcuchowe są zakończone zerem. Oznacza to, że zawsze mają \0 na końcu. Łańcuchy zakończone zerem są ważne podczas interakcji z C. W pamięci, "Goku" wyglądałoby tak: {'G', 'o', 'k', 'u', 0}, więc można by pomyśleć, że typem jest *const [5]u8. Byłoby to jednak w najlepszym przypadku niejednoznaczne, a w gorszym niebezpieczne (można by nadpisać terminator zerowy). Zamiast tego, Zig ma odrębną składnię do reprezentowania tablic zakończonych zerem. "Goku" ma typ: *const [4:0]u8, wskaźnik do zakończonej zerem tablicy 4 bajtów. Mówiąc o łańcuchach, skupiamy się na tablicach bajtów zakończonych znakiem null (ponieważ w ten sposób łańcuchy są zwykle reprezentowane w C), składnia jest bardziej ogólna: [LENGTH:SENTINEL], gdzie "SENTINEL" to specjalna wartość znajdująca się na końcu tablicy. Tak więc, chociaż nie mogę wymyślić, dlaczego byłoby to potrzebne, poniższe jest całkowicie poprawne:

const std = @import("std");

pub fn main() void {
    // tablica 3 wartości logicznych z false jako wartością wartownika
    const a = [3:false]bool{false, true, false};

    // Ta linia jest bardziej zaawansowana i nie zostanie wyjaśniona!
    std.debug.print("{any}\n", .{std.mem.asBytes(&a).*});
}

Co daje wynik: { 0, 1, 0, 0}.

Waham się, czy dołączyć ten przykład, ponieważ ostatnia linia jest dość zaawansowana i nie zamierzam jej wyjaśniać. Z drugiej strony, jest to działający przykład, który możesz uruchomić i pobawić się nim, aby lepiej zbadać trochę z tego, co omówiliśmy do tej pory, jeśli masz taką ochotę.

Jeśli udało mi się to wyjaśnić w zadowalający sposób, prawdopodobnie nadal jest jedna rzecz, której nie jesteś pewien. Jeśli "Goku" jest *const [4:0]u8, jak to się stało, że mogliśmy przypisać go do name, które jest []const u8? Odpowiedź jest prosta: Zig wymusi typ za ciebie. Zrobi to między kilkoma różnymi typami, ale jest to najbardziej oczywiste w przypadku łańcuchów. Oznacza to, że jeśli funkcja ma parametr []const u8 lub struktura ma pole []const u8, można użyć literałów łańcuchowych. Ponieważ łańcuchy zakończone nullem są tablicami, a tablice mają znaną długość, ta koercja jest tania, tj. nie wymaga iteracji przez łańcuch w celu znalezienia zakończenia nullem.

Tak więc, mówiąc o łańcuchach, zwykle mamy na myśli []const u8. W razie potrzeby wyraźnie podajemy łańcuch zakończony zerem, który może zostać automatycznie przekształcony w []const u8. Należy jednak pamiętać, że []const u8 jest również używany do reprezentowania dowolnych danych binarnych i jako taki, Zig nie ma pojęcia łańcucha, które mają języki programowania wyższego poziomu. Co więcej, biblioteka standardowa Ziga ma tylko bardzo podstawowy moduł unicode.

Oczywiście w prawdziwym programie większość łańcuchów (i bardziej ogólnie, tablic) nie jest znana w czasie kompilacji. Klasycznym przykładem są dane wprowadzane przez użytkownika, które nie są znane podczas kompilacji programu. Jest to coś, do czego będziemy musieli powrócić, mówiąc o pamięci. Ale krótka odpowiedź jest taka, że dla takich danych, które mają nieznaną wartość w czasie kompilacji, a tym samym nieznaną długość, będziemy dynamicznie alokować pamięć w czasie wykonywania. Nasze zmienne łańcuchowe, wciąż typu []const u8, będą wycinkami wskazującymi na tę dynamicznie przydzielaną pamięć.

comptime i anytype

W naszej ostatniej niezbadanej linii kodu dzieje się o wiele więcej niż na pierwszy rzut oka:

std.debug.print("{s}'s power is {d}\n", .{user.name, user.power});

Prześledzimy go tylko pobieżnie, ale stanowi on okazję do podkreślenia niektórych z bardziej zaawansowanych funkcji Ziga. Są to rzeczy, o których powinieneś przynajmniej wiedzieć, nawet jeśli ich nie opanowałeś.

Pierwszą z nich jest koncepcja wykonywania w czasie kompilacji, czyli comptime. Jest to rdzeń możliwości metaprogramowania Ziga i, jak sama nazwa wskazuje, obraca się wokół uruchamiania kodu w czasie kompilacji, a nie w czasie wykonywania. W tym przewodniku tylko zbadamy po łebkach co jest możliwe z comptime, ale jest to coś, co stale istnieje.

Być może zastanawiasz się, co takiego jest w powyższej linii, że wymaga ona wykonania w czasie kompilacji. Definicja funkcji print wymaga, aby nasz pierwszy parametr, format łańcucha, był znany w czasie kompilacji:

// zwróć uwagę na "comptime" przed zmienną "fmt"
pub fn print(comptime fmt: []const u8, args: anytype) void {

Powodem tego jest to, że print wykonuje dodatkowe sprawdzenia w czasie kompilacji, których nie można uzyskać w większości innych języków. Jakiego rodzaju sprawdzenia? Cóż, powiedzmy, że zmieniłeś format na "it's over {d}\n", ale zachowałeś dwa argumenty. Otrzymasz błąd czasu kompilacji: unused argument in 'it's over {d}'. Będą również sprawdzenia typu: zmień format łańcuchowy na "{s}'s power is {s}\n", a otrzymasz invalid format string 's' for type 'u64'. Te sprawdzenia nie byłyby możliwe do wykonania w czasie kompilacji, gdyby format łańcuchowy nie był znany w czasie kompilacji. Stąd wymóg wartości znanej w czasie kompilacji.

Jedynym miejscem, w którym comptime natychmiast wpłynie na twoje kodowanie, są domyślne typy dla literałów całkowitych i zmiennoprzecinkowych, specjalne comptime_int i comptime_float. Ten wiersz kodu jest nieprawidłowy: var i = 0;. Otrzymasz błąd kompilacji: variable of type 'comptime_int' must be const or comptime. Kod comptime może działać tylko z danymi, które są znane w czasie kompilacji, a dla liczb całkowitych i zmiennoprzecinkowych takie dane są identyfikowane przez specjalne typy comptime_int i comptime_float. Wartość tego typu może być użyta w czasie wykonywania kompilacji. Prawdopodobnie jednak nie będziesz spędzać większości czasu na pisaniu kodu do wykonania w czasie kompilacji, więc nie jest to szczególnie przydatna wartość domyślna. To, co musisz zrobić, to nadać zmiennym jawny typ:

var i: usize = 0;
var j: f64 = 0;

Zauważ, że ten błąd wystąpił tylko dlatego, że użyliśmy var. Gdybyśmy użyli const, nie mielibyśmy błędu, ponieważ cała istota błędu polega na tym, że comptime_int musi być const.

W przyszłej części przyjrzymy się nieco bliżej comptime podczas eksploracji generyczności.

Inną szczególną rzeczą w naszej linii kodu jest dziwne .{user.name, user.power}, które, jak wiemy z powyższej definicji print, odwzorowuje na zmienną typu anytype. Typ ten nie powinien być mylony z czymś takim jak Object w Javie lub any w Go (znany jako interface{}). Zamiast tego, w czasie kompilacji, Zig utworzy wersję funkcji print specjalnie dla wszystkich typów, które zostały do niej przekazane.

Nasuwa się pytanie: co do niej przekazujemy? Notację .{...} widzieliśmy już wcześniej, gdy pozwalaliśmy kompilatorowi wnioskować o typie naszej struktury. Tu jest podobnie: tworzy literał anonimowej struktury. Rozważmy ten kod:

pub fn main() void {
    std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});
}

który wypisuje:

struct{comptime year: comptime_int = 2023, comptime month: comptime_int = 8}

Tutaj nadaliśmy naszej anonimowej strukturze nazwy pól, year i month. W naszym oryginalnym kodzie tego nie zrobiliśmy. W takim przypadku nazwy pól są generowane automatycznie jako "0", "1", "2" itd. Chociaż oba są przykładami literału anonimowej struktury, ta bez nazw pól jest często nazywana krotką. Funkcja print oczekuje krotki i używa pozycji porządkowej w formacie łańcuchowym, aby uzyskać odpowiedni argument.

Zig nie ma przeciążania funkcji i nie ma funkcji vardiadic (funkcji ze zmienną liczbą argumentów). Posiada jednak kompilator zdolny do tworzenia wyspecjalizowanych funkcji w oparciu o przekazane typy, w tym typy wywnioskowane i utworzone przez sam kompilator.

Przegląd języka - część 2

Ta część jest kontynuacją poprzedniej: zapoznanie się z językiem. Zbadamy przepływ sterowania Ziga i inne typy oprócz struktur. Wraz z pierwszą częścią omówimy większość składni języka, co pozwoli nam zająć się większą częścią języka i biblioteki standardowej.

Przepływ sterowania

Przepływ sterowania Ziga jest prawdopodobnie Ci znany, ale musimy go jeszcze zbadać z dodatkowymi synergiami z aspektami języka. Zaczniemy od szybkiego przeglądu przepływu sterowania i wrócimy do omawiania funkcji, które wywołują specjalne zachowanie przepływu sterowania.

Zauważysz, że zamiast operatorów logicznych && i ||, używamy and i or. Podobnie jak w większości języków, and i or kontrolują przepływ wykonania: robią krótkie spięcie. Prawa strona and nie jest obliczana, jeśli lewa strona jest fałszywa, a prawa strona or nie jest obliczana, jeśli lewa strona jest prawdziwa. W Zigu przepływ sterowania odbywa się za pomocą słów kluczowych, a zatem używane są and i or.

Ponadto operator porównania, ==, nie działa z wycinkami, takimi jak []const u8, tj. łańcuchami. W większości przypadków należy użyć std.mem.eql(u8, str1, str2), który porówna długość, a następnie bajty dwóch wycinków.

if, else if i else są powszechne w Zigu:

// std.mem.eql porównuje bajt po bajcie
// dla łańcucha będzie rozróżniana wielkość liter
if (std.mem.eql(u8, method, "GET") or std.mem.eql(u8, method, "HEAD")) {
    // obsłuż żądanie GET
} else if (std.mem.eql(u8, method, "POST")) {
    // obsłuż żądanie POST
} else {
    // ...
}

Pierwszym argumentem funkcji std.mem.eql jest typ, w tym przypadku u8. Jest to pierwsza funkcja generyczna, którą widzieliśmy. Omówimy to bardziej szczegółowo w dalszej części.

Powyższy przykład porównuje łańcuchy ASCII i raczej powinien być niewrażliwy na wielkość liter. std.ascii.eqlIgnoreCase(str1, str2) jest prawdopodobnie lepszą opcją.

Nie ma operatora trójargumentowego, ale można użyć if/else w następujący sposób:

const super = if (power > 9000) true else false;

switch jest podobny do if/else, ale ma tę zaletę, że jest wyczerpujący. Oznacza to, że jeśli nie wszystkie przypadki zostaną uwzględnione, wystąpi błąd kompilacji. Ten kod nie zostanie skompilowany:

fn anniversaryName(years_married: u16) []const u8 {
    switch (years_married) {
        1 => return "papier",
        2 => return "bawełna",
        3 => return "skóra",
        4 => return "kwiat",
        5 => return "drewno",
        6 => return "cukier",
    }
}

Powiedziano nam: switch musi obsługiwać wszystkie możliwości. Ponieważ nasze years_married jest 16-bitową liczbą całkowitą, czy oznacza to, że musimy obsłużyć wszystkie 64K przypadków? Tak, ale na szczęście jest else:

// ...
6 => return "sugar",
else => return "no more gifts for you",

Możemy łączyć wiele przypadków lub używać zakresów, a także używać bloków dla złożonych przypadków:

fn arrivalTimeDesc(minutes: u16, is_late: bool) []const u8 {
    switch (minutes) {
        0 => return "arrived",
        1, 2 => return "soon",
        3...5 => return "no more than 5 minutes",
        else => {
            if (!is_late) {
                return "sorry, it'll be a while";
            }
            // todo, something is very wrong
            return "never";
        },
    }
}

Podczas gdy switch jest przydatny w wielu przypadkach, jego wyczerpująca natura naprawdę błyszczy, gdy mamy do czynienia z enumami, które omówimy wkrótce.

Pętla for Ziga służy do iteracji po tablicach, wycinkach i zakresach. Na przykład, aby sprawdzić, czy tablica zawiera wartość, możemy napisać:

fn contains(haystack: []const u32, needle: u32) bool {
    for (haystack) |value| {
        if (needle == value) {
            return true;
        }
    }
    return false;
}

Pętle for mogą działać na wielu sekwencjach jednocześnie, o ile sekwencje te są tej samej długości. Powyżej użyliśmy funkcji std.mem.eql. Oto jak to (prawie) wygląda:

pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
    // jeśli nie mają tej samej długości, nie mogą być równe
    if (a.len != b.len) return false;

    for (a, b) |a_elem, b_elem| {
        if (a_elem != b_elem) return false;
    }

    return true;
}

Początkowe sprawdzenie if to nie tylko miła optymalizacja wydajności, to niezbędny strażnik. Jeśli go usuniemy i przekażemy argumenty o różnych długościach, otrzymamy runtime panic: for loop over objects with non-equal lengths.

Pętle for mogą również iterować po zakresach, takich jak:

for (0..10) |i| {
    std.debug.print("{d}\n", .{i});
}

Nasz zakres switch używał trzech kropek, 3...6, podczas gdy ten zakres używa dwóch, 0..10. Dzieje się tak, ponieważ przypadki switch obejmują obie liczby, podczas gdy for wyklucza górną granicę.

To naprawdę błyszczy w połączeniu z jedną (lub więcej!) sekwencją:

fn indexOf(haystack: []const u32, needle: u32) ?usize {
    for (haystack, 0..) |value, i| {
        if (needle == value) {
            return i;
        }
    }
    return null;
}

To jest tylko krótki rzut oka na typy nullable.

Koniec zakresu jest wywnioskowany z długości haystack, chociaż moglibyśmy się ukarać i napisać: 0..hastack.len. Pętle for nie obsługują bardziej ogólnego idiomu init; compare; step. W tym celu polegamy na pętli while.

Ponieważ while jest prostsze, przyjmując formę while (warunek) { }, mamy większą kontrolę nad iteracją. Na przykład, podczas liczenia ilości sekwencji escape w łańcuchu, musimy zwiększyć nasz iterator o 2, aby uniknąć podwójnego liczenia \:

{
	var i: usize = 0;
	while (i < src.len) {
    // odwrotny ukośnik jest używany jako znak uwolnienia (escape sign), więc musimy go uwolnić...
    // odwrotnym ukośnikiem.
		if (src[i] == '\\') {
			i += 2;
			escape_count += 1;
		} else {
			i += 1;
		}
	}
}

Dodaliśmy jawny blok wokół naszej tymczasowej zmiennej i i pętli while. Zawęża to zakres i. Bloki takie jak ten mogą być przydatne, choć w tym przypadku jest to prawdopodobnie przesada. Jednak powyższy przykład to najbliższe co jest w Zigu tradycyjnej pętli for(init; compare; step).

while może mieć klauzulę else, która jest wykonywana, gdy warunek jest fałszywy. Akceptuje również instrukcję do wykonania po każdej iteracji. Może być wiele instrukcji oddzielonych ;. Ta funkcja była powszechnie używana zanim for obsługiwało wielokrotne sekwencje. Powyższe można zapisać jako:

var i: usize = 0;
var escape_count: usize = 0;

// ta część
while (i < src.len) : (i += 1) {
    if (src[i] == '\\') {
        // +1 tutaj, oraz i +1 powyżej == +2
        i += 1;
        escape_count += 1;
    }
}

break i continue są obsługiwane w celu przerwania wewnętrznej pętli lub przejścia do następnej iteracji.

Bloki mogą być oznaczone etykietami, a break i continue mogą odnosić się do konkretnej etykiety. Wymyślony przykład:

outer: for (1..10) |i| {
    for (i..10) |j| {
        if (i * j > (i+i + j+j)) continue :outer;
        std.debug.print("{d} + {d} >= {d} * {d}\n", .{i+i, j+j, i, j});
    }
}

break ma jeszcze jedno interesujące zachowanie, zwracając wartość z bloku:

const personality_analysis = blk: {
    if (tea_vote > coffee_vote) break :blk "sane";
    if (tea_vote == coffee_vote) break :blk "whatever";
    if (tea_vote < coffee_vote) break :blk "dangerous";
};

Bloki takie jak ten muszą być zakończone średnikiem.

Później, gdy będziemy badać tagowane unie (tagged unions), unie błędów (error unions) i typy opcjonalne, zobaczymy, co jeszcze mają do zaoferowania te struktury przepływu sterowania.

Wyliczenia (enums)

Wyliczenia są stałymi całkowitymi, które otrzymują etykietę. Są one zdefiniowane podobnie jak struktura:

// może to być "pub"
const Status = enum {
    ok,
    bad,
    unknown,
};

I, podobnie jak struktura, enum może zawierać inne definicje, w tym funkcje, które mogą, ale nie muszą, przyjmować enuma jako parametr:

const Stage = enum {
    validate,
    awaiting_confirmation,
    confirmed,
    err,

    fn isComplete(self: Stage) bool {
        return self == .confirmed or self == .err;
    }
};

Jeśli chcesz uzyskać reprezentację łańcuchową enuma, możesz użyć wbudowanej funkcji @tagName(enum).

Przypomnijmy, że typ struktury można wywnioskować na podstawie jej przypisania lub typu zwracanego przy użyciu notacji .{...}. Powyżej widzimy, że typ enuma jest wnioskowany na podstawie porównania z self, który jest typu Stage. Mogliśmy napisać wprost: return self == Stage.confirmed or self == Stage.err;. Jednak w przypadku enumów często można spotkać się z pominięciem typu enuma za pomocą notacji .$value. Nazywa się to literałem enum.

Wyczerpująca natura switch sprawia, że dobrze łączy się z enumami, ponieważ zapewnia obsługę wszystkich możliwych przypadków. Zachowaj jednak ostrożność podczas korzystania z klauzuli else w switch, ponieważ będzie ona pasować do wszystkich nowo dodanych wartości do enuma, co może, ale nie musi być zachowaniem, które chcesz.

Tagowane unie (tagged unions)

Unia definiuje zestaw typów, które dana wartość może mieć. Na przykład, ta unia Number może być integer, float lub nan (not a number - nie liczba):

const std = @import("std");

pub fn main() void {
    const n = Number{.int = 32};
    std.debug.print("{d}\n", .{n.int});
}

const Number = union {
    int: i64,
    float: f64,
    nan: void,
};

Unia może mieć ustawione tylko jedno pole na raz; próba uzyskania dostępu do nieustawionego pola jest błędem. Ponieważ ustawiliśmy pole int, gdybyśmy następnie spróbowali uzyskać dostęp do n.float, otrzymalibyśmy błąd. Jedno z naszych pól, nan, ma typ void. Jak moglibyśmy ustawić jego wartość? Używając {}:

const n = Number{.nan = {}};

Wyzwaniem w przypadku unii jest wiedza, które pole jest ustawione. W tym miejscu do gry wkraczają tagowane unie. Tagowana unia łączy enuma z unią, co może być użyta w instrukcji switch. Rozważmy następujący przykład:

pub fn main() void {
    const ts = Timestamp{.unix = 1693278411};
    std.debug.print("{d}\n", .{ts.seconds()});
}

const TimestampType = enum {
    unix,
    datetime,
};

const Timestamp = union(TimestampType) {
    unix: i32,
    datetime: DateTime,

    const DateTime = struct {
        year: u16,
        month: u8,
        day: u8,
        hour: u8,
        minute: u8,
        second: u8,
    };

    fn seconds(self: Timestamp) u16 {
        switch (self) {
            .datetime => |dt| return dt.second,
            .unix => |ts| {
                const seconds_since_midnight: i32 = @rem(ts, 86400);
                return @intCast(@rem(seconds_since_midnight, 60));
            },
        }
    }
};

Zauważ, że każdy przypadek w naszym switch przechwytuje wpisaną wartość pola. Oznacza to, że dt to Timestamp.DateTime, a ts to i32. Jest to również pierwszy raz, kiedy widzimy strukturę zagnieżdżoną w innym typie. DateTime mógł zostać zdefiniowany poza unią. Widzimy również dwie nowe wbudowane funkcje: @rem, aby uzyskać resztę i @intCast, aby przekonwertować wynik na u16 (@intCast wnioskuje, że chcemy u16 z naszego typu zwracanego, ponieważ zwracana jest wartość).

Jak widać na powyższym przykładzie, tagowane unie mogą być używane w pewnym sensie jak interfejsy, o ile wszystkie możliwe implementacje są znane z wyprzedzeniem i mogą być dodane do tagowanej unii.

Wreszcie, typ enum tagowanej unii może być wywnioskowany. Zamiast definiować TimestampType, mogliśmy zrobić:

const Timestamp = union(enum) {
    unix: i32,
    datetime: DateTime,

    ...

a Zig utworzyłby niejawny enum oparty na polach naszej unii.

Typy i wartości opcjonalne (optionals)

Każda wartość może być zadeklarowana jako typ opcjonalny poprzez dodanie znaku zapytania ? do typu. Typy opcjonalne mogą mieć wartość null lub wartość zdefiniowanego typu:

var home: ?[]const u8 = null;
var name: ?[]const u8 = "Leto";

Potrzeba posiadania wyraźnego typu powinna być jasna: gdybyśmy po prostu zrobili const name = "Leto";, wówczas wnioskowanym typem byłby nie-opcjonalny []const u8.

.? służy do uzyskania dostępu do wartości kryjącej się za typem opcjonalnym:

std.debug.print("{s}\n", .{name.?});

Jeśli jednak użyjemy .? na wartości null, otrzymamy runtime panic. Instrukcja if może bezpiecznie rozpakować typu opcjonalnego:

if (home) |h| {
    // h jest []const u8
    // mamy wartość home
} else {
    // nie mamy wartości home
}

orelse może być używane do rozpakowania typu opcjonalnego lub wykonania kodu. Jest to często używane do określenia wartości domyślnej lub powrotu z funkcji:

const h = home orelse "unknown"
// lub może

// wyjście z funkcji
const h = home orelse return;

Jednak orelse może również otrzymać blok i wykonywać bardziej złożoną logikę. Typy opcjonalne również integrują się z while i są często używane do tworzenia iteratorów. Nie będziemy implementować iteratora, ale miejmy nadzieję, że ten fikcyjny kod ma sens:

while (rows.next()) |row| {
    // zrób coś z naszym wierszem
}

Undefined

Jak dotąd, każda pojedyncza zmienna, którą widzieliśmy, została zainicjowana sensowną wartością. Czasami jednak nie znamy wartości zmiennej w momencie jej deklaracji. Typy opcjonalne są jedną z opcji, ale nie zawsze mają sens. W takich przypadkach możemy ustawić zmienne na undefined, aby pozostawić je niezainicjalizowane.

Jednym z miejsc, w których jest to często wykonywane, jest tworzenie tablicy, która ma zostać wypełniona przez jakąś funkcję:

var pseudo_uuid: [16]u8 = undefined;
std.crypto.random.bytes(&pseudo_uuid);

Powyższe rozwiązanie nadal tworzy tablicę 16 bajtów, ale pozostawia pamięć niezainicjowaną.

Błędy (errors)

Zig posiada proste i pragmatyczne możliwości obsługi błędów. Wszystko zaczyna się od zestawów błędów (error sets), które wyglądają i zachowują się jak enumy:

// Podobnie jak nasza struktura w części 1, OpenError może być oznaczony jako "pub"
// aby był dostępny poza plikiem, w którym jest zdefiniowany
const OpenError = error {
    AccessDenied,
    NotFound,
};

Funkcja, w tym main, może teraz zwrócić ten błąd:

pub fn main() void {
    return OpenError.AccessDenied;
}

const OpenError = error {
    AccessDenied,
    NotFound,
};

Jeśli spróbujesz to uruchomić, otrzymasz błąd: expected type 'void', found 'error{AccessDenied,NotFound}'. Ma to sens: zdefiniowaliśmy main z typem zwracanym void, ale zwracamy coś (błąd, oczywiście, ale to wciąż nie jest void). Aby rozwiązać ten problem, musimy zmienić typ zwracany naszej funkcji.

pub fn main() OpenError!void {
    return OpenError.AccessDenied;
}

Nazywa się to typem unii błędów i wskazuje, że nasza funkcja może zwrócić albo błąd OpenError, albo void (czyli nic). Do tej pory byliśmy dość jednoznaczni: utworzyliśmy zestaw błędów dla możliwych błędów, które może zwrócić nasza funkcja, i użyliśmy tego zestawu błędów w typie zwracanym naszej funkcji. Ale jeśli chodzi o błędy, Zig ma kilka zgrabnych sztuczek w rękawie. Po pierwsze, zamiast określać związek błędów jako zestaw błędów!zwracany typ, możemy pozwolić Zigowi wywnioskować zestaw błędów za pomocą: !zwracany typ. Moglibyśmy więc, i prawdopodobnie byśmy to zrobili, zdefiniować nasz main jako:

pub fn main() !void

Po drugie, Zig jest w stanie niejawnie tworzyć dla nas zestawy błędów. Zamiast tworzyć nasz zestaw błędów, moglibyśmy zrobić:

pub fn main() !void {
    return error.AccessDenied;
}

Nasze całkowicie jawne i niejawne podejścia nie są dokładnie równoważne. Na przykład referencje do funkcji z niejawnymi zestawami błędów wymagają użycia specjalnego typu anyerror. Deweloperzy bibliotek mogą dostrzec zalety bycia bardziej jawnym, takie jak samodokumentujący się kod. Mimo to uważam, że zarówno niejawne zestawy błędów, jak i wywnioskowana unia błędów są pragmatyczne; intensywnie korzystam z obu.

Prawdziwą wartością unii błędów jest wbudowane wsparcie językowe w postaci catch i try. Wywołanie funkcji zwracającej unię błędów może zawierać klauzulę catch. Na przykład, biblioteka serwera http może mieć kod, który wygląda następująco:

action(req, res) catch |err| {
    if (err == error.BrokenPipe or err == error.ConnectionResetByPeer) {
        return;
    } else if (err == error.BodyTooBig) {
        res.status = 431;
        res.body = "Request body is too big";
    } else {
        res.status = 500;
        res.body = "Internal Server Error";
        // todo: log err
    }
};

Wersja ze switch jest bardziej idiomatyczna:

action(req, res) catch |err| switch (err) {
    error.BrokenPipe, error.ConnectionResetByPeer) => return,
    error.BodyTooBig => {
        res.status = 431;
        res.body = "Request body is too big";
    },
    else => {
        res.status = 500;
        res.body = "Internal Server Error";
    }
};

To wszystko jest dość wymyślne, ale bądźmy szczerzy, najbardziej prawdopodobną rzeczą, jaką zamierzasz zrobić w catch, jest przekazanie błędu do wywoływacza:

action(req, res) catch |err| return err;

Jest to tak powszechne, że właśnie to robi try. Zamiast powyższego, robimy:

try action(req, res);

Jest to szczególnie przydatne, gdy błąd musi zostać obsłużony. Najprawdopodobniej zrobisz to za pomocą try lub catch.

Programiści Go zauważą, że try wymaga mniej naciśnięć klawiszy niż if err != nil { return err }.

Przez większość czasu będziesz używać try i catch, ale unie błędów są również obsługiwane przez if i while, podobnie jak typy opcjonalne. W przypadku while, jeśli warunek zwróci błąd, wykonywana jest klauzula else.

Istnieje specjalny typ anyerror, który może przechowywać dowolny błąd. Chociaż możemy zdefiniować funkcję jako zwracającą anyerror!TYPE zamiast !TYPE, te dwa typy nie są równoważne. Wywnioskowany zestaw błędów jest tworzony na podstawie tego, co funkcja może zwrócić. anyerror jest globalnym zestawem błędów, podzbiorem wszystkich zestawów błędów w programie. Dlatego użycie anyerror w sygnaturze funkcji może sygnalizować, że funkcja może zwracać błędy, których w rzeczywistości nie może. anyerror jest używany dla parametrów funkcji lub pól struktury, które mogą działać z dowolnym błędem (wyobraź sobie bibliotekę logowania).

Nierzadko zdarza się, że funkcja zwraca typ opcjonalny unii błędów. Z wywnioskowanym zestawem błędów wygląda to następująco:

// wczytanie ostatnio zapisanej gry
pub fn loadLast() !?Save {
    // TODO
    return null;
}

Istnieją różne sposoby korzystania z takich funkcji, ale najbardziej kompaktowym jest użycie try do rozpakowania naszego błędu, a następnie orelse do rozpakowania typu opcjonalnego. Oto działający szkielet:

const std = @import("std");

pub fn main() void {
    // To jest linia, na której chcesz się skupić
    const save = (try Save.loadLast()) orelse Save.blank();
    std.debug.print("{any}\n", .{save});
}

pub const Save = struct {
    lives: u8,
    level: u16,

    pub fn loadLast() !?Save {
        //todo
        return null;
    }

    pub fn blank() Save {
        return .{
            .lives = 3,
            .level = 1,
        };
    }
};

Podczas gdy Zig ma większą głębię, a niektóre funkcje języka mają większe możliwości, to co widzieliśmy w tych dwóch pierwszych częściach jest znaczącą częścią języka. Będzie to służyć jako podstawa, pozwalając nam odkrywać bardziej złożone tematy bez zbytniego rozpraszania się składnią.

Przewodnik po stylach

W tej krótkiej części omówimy dwie zasady kodowania wymuszane przez kompilator, a także konwencję nazewnictwa biblioteki standardowej.

Nieużywane zmienne

Zig nie zezwala na zostawienie nieużytych zmiennych. Poniższy przykład daje dwa błędy kompilacji:

const std = @import("std");

pub fn main() void {
    const sum = add(8999, 2);
}

fn add(a: i64, b: i64) i64 {
    // zauważ, że to jest a + a, a nie a + b
    return a + a;
}

Pierwszy błąd wynika z faktu, że sum jest nieużywaną stałą lokalną. Drugi błąd wynika z faktu, że b jest nieużywanym parametrem funkcji. W przypadku tego kodu są to oczywiste błędy. Możesz jednak mieć uzasadnione powody, aby mieć nieużywane zmienne i parametry funkcji. W takich przypadkach można przypisać zmienne do podkreślenia (_):

const std = @import("std");

pub fn main() void {
    _ = add(8999, 2);

    // lub

    const sum = add(8999, 2);
    _ = sum;
}

fn add(a: i64, b: i64) i64 {
    _ = b;
    return a + a;
}

Jako alternatywę dla _ = b;, mogliśmy nazwać parametr funkcji _, choć moim zdaniem pozostawia to czytelnikowi domysły, czym jest nieużywany parametr:

fn add(a: i64, _: i64) i64 {

Zauważ, że std jest również nieużywany, ale nie generuje błędu. W pewnym momencie w przyszłości należy oczekiwać, że Zig potraktuje to również jako błąd czasu kompilacji.

Przesłanianie (shadowing)

Zig nie pozwala, by jeden identyfikator "ukrywał" inny, używając tej samej nazwy. Ten kod do odczytu z gniazda jest nieprawidłowy:

fn read(stream: std.net.Stream) ![]const u8 {
    var buf: [512]u8 = undefined;
    const read = try stream.read(&buf);
    if (read == 0) {
        return error.Closed;
    }
    return buf[0..read];
}

Nasza zmienna read przesłania nazwę naszej funkcji. Nie jestem fanem tej zasady, ponieważ zazwyczaj prowadzi ona programistów do używania krótkich, bezsensownych nazw. Na przykład, aby skompilować ten kod, zmieniłbym read na n. Jest to przypadek, w którym moim zdaniem programiści są w znacznie lepszej pozycji, aby wybrać najbardziej czytelną opcję.

Konwencja nazewnictwa

Poza regułami narzuconymi przez kompilator, możesz oczywiście stosować dowolną konwencję nazewnictwa. Pomocne jest jednak zrozumienie konwencji nazewnictwa Ziga, ponieważ większość kodu, z którym będziesz wchodzić w interakcje, od biblioteki standardowej po biblioteki stron trzecich, korzysta z niej.

Kod źródłowy Ziga jest wcięty 4 spacjami. Osobiście używam tabulatora, który jest obiektywnie lepszy dla dostępności.

Nazwy funkcji są camelCase, a zmienne lowercase_with_underscores (zwane snake case). Typy są pisane PascalCase. Istnieje interesujące skrzyżowanie tych trzech reguł. Zmienne, które odwołują się do typu lub funkcje, które zwracają typ, są zgodne z regułą typu i są PascalCase. Już to widzieliśmy, ale mogłeś to przegapić.

std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});

Widzieliśmy już inne wbudowane funkcje: @import, @rem i @intCast. Ponieważ są to funkcje, są one pisane camelCase. @TypeOf jest również wbudowaną funkcją, ale jest to PascalCase, dlaczego? Ponieważ zwraca typ, a zatem używana jest konwencja nazewnictwa typów. Gdybyśmy chcieli przypisać wynik @TypeOf do zmiennej, używając konwencji nazewnictwa Ziga, zmienna ta również powinna być PascalCase:

const T = @TypeOf(3)
std.debug.print("{any}\n", .{T});

Plik wykonywalny zig ma polecenie fmt, które, biorąc pod uwagę plik lub katalog, sformatuje plik w oparciu o własny przewodnik stylu Ziga. Nie obejmuje on jednak wszystkiego, na przykład dostosuje wcięcia i pozycje nawiasów klamrowych, ale nie zmieni rozmiaru znaku identyfikatorów.

Wskaźniki (pointers)

Zig nie zawiera garbage collectora (odśmiecacza pamięci). Ciężar zarządzania pamięcią spoczywa na programiście. To duża odpowiedzialność, ponieważ ma bezpośredni wpływ na wydajność, stabilność i bezpieczeństwo aplikacji.

Zaczniemy od omówienia wskaźników, co jest ważnym tematem do omówienia samym w sobie, ale także do rozpoczęcia szkolenia w zakresie postrzegania danych naszego programu z punktu widzenia pamięci. Jeśli jesteś już zaznajomiony ze wskaźnikami, alokacjami sterty i zwisającymi wskaźnikami, możesz pominąć kilka części do pamięci sterty i alokatorów, które są bardziej specyficzne dla Ziga.


Poniższy kod tworzy użytkownika o mocy (power) 100, a następnie wywołuje funkcję levelUp, która zwiększa moc użytkownika o 1. Czy potrafisz odgadnąć wynik?

const std = @import("std");

pub fn main() void {
    var user = User{
        .id = 1,
        .power = 100,
    };

    // ta linia została dodana
    levelUp(user);
    std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

fn levelUp(user: User) void {
    user.power += 1;
}

pub const User = struct {
    id: u64,
    power: i32,
};

To była niemiła sztuczka; kod się nie skompiluje: local variable is never mutated. Jest to referencja do zmiennej user w main. Zmienna, która nigdy nie jest mutowana, musi być zadeklarowana jako const. Możesz pomyśleć: ale w levelUp mutujemy user, co się dzieje? Załóżmy, że kompilator Ziga jest w błędzie i oszukajmy go. Zmusimy kompilator do zobaczenia, że user jest zmutowany:

const std = @import("std");

pub fn main() void {
    var user = User{
        .id = 1,
        .power = 100,
    };
    user.power += 0;
    // reszta kodu jest taka sama

Teraz otrzymujemy błąd w levelUp: cannot assign to constant. Widzieliśmy w części 1, że parametry funkcji są stałymi, więc user.power += 1; jest nieprawidłowe. Aby naprawić błąd kompilacji, możemy zmienić funkcję levelUp na:

fn levelUp(user: User) void {
    var u = user;
    u.power += 1;
}

Co się skompiluje, ale na wyjściu otrzymamy, że User 1 has power of 100, mimo że intencją naszego kodu jest wyraźnie, aby levelUp zwiększył moc użytkownika do 101. Co się dzieje?

Aby to zrozumieć, warto myśleć o danych w odniesieniu do pamięci, a zmiennych jako etykietach, które kojarzą typ z określoną lokalizacją pamięci. Na przykład w main tworzymy User. Prosta wizualizacja tych danych w pamięci wyglądałaby następująco:

user -> ------------ (id)
        |    1     |
        ------------ (power)
        |   100    |
        ------------

Należy zwrócić uwagę na dwie ważne rzeczy. Po pierwsze, nasza zmienna user wskazuje na początek naszej struktury. Drugą jest to, że pola są ułożone sekwencyjnie. Pamiętaj, że nasz user ma również typ. Ten typ mówi nam, że id jest 64-bitową liczbą całkowitą, a power jest 32-bitową liczbą całkowitą. Uzbrojony w referencję do początku naszych danych i typu, kompilator może przetłumaczyć user.power na: dostęp do 32-bitowej liczby całkowitej znajdującej się 64 bity od początku. Na tym polega moc zmiennych, odwołują się one do pamięci i zawierają informacje o typie niezbędne do zrozumienia i manipulowania pamięcią w znaczący sposób.

Domyślnie Zig nie gwarantuje układu pamięci struktur. Może przechowywać pola w kolejności alfabetycznej, według rosnącego rozmiaru lub z przerwami. Może robić co chce, o ile jest w stanie poprawnie przetłumaczyć nasz kod. Ta swoboda może umożliwić pewne optymalizacje. Tylko jeśli zadeklarujemy packed struct, otrzymamy silne gwarancje dotyczące układu pamięci. Możemy również utworzyć extern struct, która gwarantuje, że układ pamięci będzie zgodny z binarnym interfejsem aplikacji C (ABI). Mimo to, nasza wizualizacja user jest rozsądna i użyteczna.

Oto nieco inna wizualizacja, która zawiera adresy pamięci. Adres pamięci początku tych danych jest losowym adresem, który wymyśliłem. Jest to adres pamięci, do którego odwołuje się zmienna user, która jest również wartością naszego pierwszego pola, id. Jednak biorąc pod uwagę ten początkowy adres, wszystkie kolejne adresy mają znany adres względny. Ponieważ id jest 64-bitową liczbą całkowitą, zajmuje 8 bajtów pamięci. Dlatego power musi znajdować się pod adresem $start_address + 8:

user ->   ------------  (id: 1043368d0)
          |    1     |
          ------------  (power: 1043368d8)
          |   100    |
          ------------

Abyś mógł to sprawdzić, chciałbym przedstawić operator adresu: &. Jak sama nazwa wskazuje, operator adresu zwraca adres zmiennej (może również zwrócić adres funkcji, prawda?!). Zachowując istniejącą definicję User, wypróbuj tą main:

pub fn main() void {
    const user = User{
        .id = 1,
        .power = 100,
    };
    std.debug.print("{*}\n{*}\n{*}\n", .{&user, &user.id, &user.power});
}

Ten kod wypisuje adres user, user.id i user.power. Możesz uzyskać różne wyniki w zależności od platformy i innych czynników, ale mam nadzieję, że zobaczysz, że adresy user i user.id są takie same, podczas gdy user.power jest przesunięty o 8 bajtów. Otrzymałem:

learning.User@1043368d0
u64@1043368d0
i32@1043368d8

Operator adresu zwraca wskaźnik do wartości. Wskaźnik do wartości jest odrębnym typem. Adres wartości typu T to *T. Mówimy, że jest to wskaźnik do T. Dlatego, jeśli weźmiemy adres user, otrzymamy *User lub wskaźnik do User:

pub fn main() void {
    var user = User{
        .id = 1,
        .power = 100,
    };
    user.power += 0;

    const user_p = &user;
    std.debug.print("{any}\n", .{@TypeOf(user_p)});
}

Naszym pierwotnym celem było zwiększenie mocy użytkownika o 1 za pomocą funkcji levelUp. Udało nam się skompilować kod, ale kiedy wypisaliśmy power, wciąż była to oryginalna wartość. To trochę przeskok, ale zmieńmy kod, aby wypisać adres user w main i w levelUp:

pub fn main() void {
    var user = User{
        .id = 1,
        .power = 100,
    };
    user.power += 0;

    // dodano to
    std.debug.print("main: {*}\n", .{&user});

    levelUp(user);
    std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

fn levelUp(user: User) void {
    // dodaj to
    std.debug.print("levelUp: {*}\n", .{&user});
    var u = user;
    u.power += 1;
}

Jeśli to uruchomisz, otrzymasz dwa różne adresy. Oznacza to, że user modyfikowany w levelUp różni się od user w main. Dzieje się tak, ponieważ Zig przekazuje kopię wartości. Może się to wydawać dziwnym domyślnym rozwiązaniem, ale jedną z korzyści jest to, że wywoływacz funkcji może być pewien, że funkcja nie zmodyfikuje parametru (ponieważ nie może). W wielu przypadkach jest to dobra rzecz do zagwarantowania. Oczywiście czasami, tak jak w przypadku levelUp, chcemy, aby funkcja zmodyfikowała parametr. Aby to osiągnąć, levelUp musi działać na rzeczywistym user w main, a nie na jego kopii. Możemy to zrobić, przekazując do funkcji adres naszego użytkownika:

const std = @import("std");

pub fn main() void {
    var user = User{
        .id = 1,
        .power = 100,
    };

    // już niepotrzebne
    // user.power += 1;

    // user -> &user
    levelUp(&user);
    std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

// User -> *User
fn levelUp(user: *User) void {
    user.power += 1;
}

pub const User = struct {
    id: u64,
    power: i32,
};

Musieliśmy wprowadzić dwie zmiany. Pierwszą z nich jest wywołanie levelUp z adresem użytkownika, czyli &user, zamiast user. Oznacza to, że nasza funkcja nie otrzymuje już User. Zamiast tego otrzymuje *User, co było naszą drugą zmianą.

Nie potrzebujemy już tego brzydkiego hacka wymuszającego mutację użytkownika poprzez user.power += 0;. Początkowo nie udało nam się skompilować kodu, ponieważ user był var, ale kompilator powiedział nam, że nigdy nie został zmutowany. Pomyśleliśmy, że może kompilator się mylił i "oszukał" to, wymuszając mutację. Ale, jak teraz wiemy, użytkownik zmutowany w levelUp był inny; kompilator miał rację.

Kod działa teraz zgodnie z przeznaczeniem. Nadal istnieje wiele subtelności związanych z parametrami funkcji i ogólnie naszym modelem pamięci, ale robimy postępy. To może być dobry moment, aby wspomnieć, że poza specyficzną składnią, nic z tego nie jest unikalne dla Ziga. Model, który tutaj badamy, jest najbardziej powszechny, niektóre języki mogą po prostu ukrywać wiele szczegółów, a tym samym elastyczność, przed programistami.

Metody

Najprawdopodobniej napisałbyś levelUp jako metodę struktury User:

pub const User = struct {
    id: u64,
    power: i32,

    fn levelUp(user: *User) void {
        user.power += 1;
    }
};

Nasuwa się pytanie: jak wywołać metodę oczekującą wskaźnika? Może musimy zrobić coś w stylu: &user.levelUp()? Właściwie wystarczy wywołać ją normalnie, tj. user.levelUp(). Zig wie, że metoda oczekuje wskaźnika i przekazuje wartość poprawnie (przez referencję).

Początkowo wybrałem funkcję, ponieważ jest ona jawna, a tym samym łatwiejsza do nauczenia.

Stałe parametry funkcji

Więcej niż sugerowałem, że domyślnie Zig będzie przekazywał kopię wartości (zwane "przekazywaniem przez wartość"). Wkrótce zobaczymy, że rzeczywistość jest nieco bardziej subtelna (podpowiedź: co ze złożonymi wartościami z zagnieżdżonymi obiektami?).

Nawet trzymając się prostych typów, prawda jest taka, że Zig może przekazywać parametry w dowolny sposób, o ile może zagwarantować, że intencja kodu zostanie zachowana. W naszym oryginalnym levelUp, gdzie parametrem był User, Zig mógł przekazać kopię użytkownika lub referencję do main.user, o ile mógł zagwarantować, że funkcja go nie zmutuje. (Wiem, że ostatecznie chcieliśmy go zmutować, ale tworząc typ User, mówiliśmy kompilatorowi, że tego nie chcemy).

Ta swoboda pozwala Zigowi na użycie najbardziej optymalnej strategii opartej na typie parametru. Małe typy, takie jak User, mogą być tanio przekazywane przez wartość (tj. kopiowane). Większe typy mogą być tańsze do przekazania przez referencję. Zig może stosować dowolne podejście, o ile intencje kodu zostaną zachowane. Do pewnego stopnia jest to możliwe dzięki stałym parametrom funkcji.

Teraz znasz już jeden z powodów, dla których parametry funkcji są stałe.

Być może zastanawiasz się, w jaki sposób przekazywanie przez referencję może być wolniejsze, nawet w porównaniu do kopiowania naprawdę małej struktury. Zobaczymy to dokładniej w następnej części, ale sedno tkwi w tym, że wykonywanie user.power, gdy user jest wskaźnikiem, dodaje niewielki narzut. Kompilator musi rozważyć koszt kopiowania w stosunku do kosztu dostępu do pól pośrednio przez wskaźnik.

Wskaźnik do wskaźnika

Poprzednio przyjrzeliśmy się, jak wygląda pamięć user w naszej głównej funkcji. Teraz, gdy zmieniliśmy levelUp, jak wyglądałaby jego pamięć?

main:
user -> ------------  (id: 1043368d0)  <---
        |    1     |                      |
        ------------  (power: 1043368d8)  |
        |   100    |                      |
        ------------                      |
                                          |
        .............  puste miejsce      |
        .............  lub inne dane      |
                                          |
levelUp:                                  |
user -> -------------  (*User)            |
        | 1043368d0 |----------------------
        -------------

W levelUp, user jest wskaźnikiem do User. Jego wartością jest adres. Oczywiście nie byle jaki adres, ale adres main.user. Warto wyraźnie zaznaczyć, że zmienna user w levelUp reprezentuje konkretną wartość. Wartość ta jest adresem. I nie jest to tylko adres, ale także typ, *User. Wszystko to jest bardzo spójne, nie ma znaczenia, czy mówimy o wskaźnikach, czy nie: zmienne wiążą informacje o typie z adresem. Jedyną specjalną rzeczą dotyczącą wskaźników jest to, że gdy używamy składni kropki, np. user.power, Zig, wiedząc, że user jest wskaźnikiem, automatycznie podąży za adresem.

Niektóre języki wymagają innego symbolu podczas uzyskiwania dostępu do pola za pomocą wskaźnika.

Ważne jest, aby zrozumieć, że zmienna user w levelUp sama istnieje w pamięci pod jakimś adresem. Tak jak zrobiliśmy to wcześniej, możemy to zobaczyć na własne oczy:

fn levelUp(user: *User) void {
    std.debug.print("{*}\n{*}\n", .{&user, user});
    user.power += 1;
}

Powyższe wypisuje adres, do którego odwołuje się zmienna user, a także jej wartość, która jest adresem user w main.

Jeśli user jest *User, to czym jest &user? To **User, czyli wskaźnik do wskaźnika na User. Mogę to robić, dopóki jednemu z nas nie skończy się pamięć!

Istnieją przypadki użycia dla wielu poziomów pośrednictwa (indirection), ale nie jest to coś, co teraz potrzebujemy. Celem tej sekcji jest pokazanie, że wskaźniki nie są niczym specjalnym, są po prostu wartością, która jest adresem i typem.

Zagnieżdżone wskaźniki

Do tej pory nasz User był prosty, zawierał dwie liczby całkowite. Łatwo jest zwizualizować jego pamięć, a kiedy mówimy o "kopiowaniu", nie ma żadnych niejasności. Ale co się stanie, gdy User stanie się bardziej złożony i będzie zawierał wskaźnik?

pub const User = struct {
    id: u64,
    power: i32,
    name: []const u8,
};

Dodaliśmy name, które jest wycinkiem. Przypomnijmy, że wycinek to długość i wskaźnik. Gdybyśmy zainicjowali naszego user nazwą "Goku", jak wyglądałby on w pamięci?

user -> -------------  (id: 1043368d0)
        |     1     |
        -------------  (power: 1043368d8)
        |    100    |
        -------------  (name.len: 1043368dc)
        |     4     |
        -------------  (name.ptr: 1043368e4)
  ------| 1182145c0 |
  |     -------------
  |
  |     .............  puste miejsce
  |     .............  lub inne dane
  |
  --->  -------------  (1182145c0)
        |    'G'    |
        -------------
        |    'o'    |
        -------------
        |    'k'    |
        -------------
        |    'u'    |
        -------------

Nowe pole name jest wycinkiem, który składa się z pola len i ptr. Są one ułożone w kolejności wraz ze wszystkimi innymi polami. Na platformie 64-bitowej zarówno len, jak i ptr będą miały 64 bity lub 8 bajtów. Interesującą częścią jest wartość name.ptr: jest to adres do innego miejsca w pamięci.

Ponieważ użyliśmy literału łańcuchowego, user.name.ptr będzie wskazywać na konkretną lokalizację w obszarze, w którym przechowywane są wszystkie stałe w naszym pliku binarnym.

Typy mogą stać się znacznie bardziej złożone dzięki głębokiemu zagnieżdżaniu. Ale proste czy złożone, wszystkie zachowują się tak samo. W szczególności, jeśli wrócimy do naszego oryginalnego kodu, w którym levelUp pobierał zwykłego User, a Zig dostarczał kopię, jak wyglądałoby to teraz, gdy mamy zagnieżdżony wskaźnik?

Odpowiedź jest taka, że tworzona jest tylko płytka kopia wartości. Lub, jak niektórzy to określają, kopiowana jest tylko pamięć bezpośrednio adresowalna przez zmienną. Mogłoby się wydawać, że levelUp otrzyma połowiczną kopię user, być może z nieprawidłową nazwą. Należy jednak pamiętać, że wskaźnik, taki jak nasz user.name.ptr, jest wartością, a ta wartość jest adresem. Kopia adresu to wciąż ten sam adres:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
levelUp: user -> -------------  (id: 1043368ec)       |
                 |     1     |                        |
                 -------------  (power: 1043368f4)    |
                 |    100    |                        |
                 -------------  (name.len: 1043368f8) |
                 |     4     |                        |
                 -------------  (name.ptr: 104336900) |
                 | 1182145c0 |-------------------------
                 -------------                        |
                                                      |
                 .............  puste miejsce         |
                 .............  lub inne dane         |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

Z powyższego widać, że płytkie kopiowanie będzie działać. Ponieważ wartość wskaźnika jest adresem, kopiowanie wartości oznacza, że otrzymamy ten sam adres. Ma to ważne implikacje w odniesieniu do mutowalności. Nasza funkcja nie może zmodyfikować pól bezpośrednio dostępnych dla main.user, ponieważ otrzymała kopię, ale ma dostęp do tej samej name, więc czy może ją zmodyfikować? W tym konkretnym przypadku nie, name jest const. Dodatkowo, nasza wartość "Goku" jest literałem łańcuchowym, który jest zawsze niemutowalny. Ale przy odrobinie pracy możemy zobaczyć implikacje płytkiego kopiowania:

const std = @import("std");

pub fn main() void {
    var name = [4]u8{'G', 'o', 'k', 'u'};
    const user = User{
        .id = 1,
        .power = 100,
        // wytnij to, [4]u8 -> []u8
        .name = name[0..],
    };
    levelUp(user);
    std.debug.print("{s}\n", .{user.name});
}

fn levelUp(user: User) void {
    user.name[2] = '!';
}

pub const User = struct {
    id: u64,
    power: i32,
    // []const u8 -> []u8
    name: []u8
};

Powyższy kod wypisuje "Go!u". Musieliśmy zmienić typ name z []const u8 na []u8 i zamiast literału łańcuchowego, które są zawsze niemutowalne, utworzyć tablicę i pokroić ją. Niektórzy mogą dostrzec tu niekonsekwencję. Przekazywanie przez wartość uniemożliwia funkcji mutowanie bezpośrednich pól, ale nie pól z wartością za wskaźnikiem. Gdybyśmy chcieli, aby nazwa była niezmienna, powinniśmy zadeklarować ją jako []const u8 zamiast []u8.

Niektóre języki mają inną implementację, ale wiele języków działa dokładnie w ten sposób (lub bardzo bliski). Choć wszystko to może wydawać się ezoteryczne, ma to fundamentalne znaczenie dla codziennego programowania. Dobrą wiadomością jest to, że można to opanować za pomocą prostych przykładów i fragmentów; nie staje się to bardziej skomplikowane wraz ze wzrostem złożoności innych części systemu.

Struktury rekurencyjne

Czasami potrzebna jest struktura rekurencyjna. Zachowując nasz istniejący kod, dodajmy opcjonalnego manager typu ?User do naszego User. W tym momencie utworzymy dwóch użytkowników i przypiszemy jednego jako menedżera do drugiego:

const std = @import("std");

pub fn main() void {
    const leto = User{
        .id = 1,
        .power = 9001,
        .manager = null,
    };

    const duncan = User{
        .id = 1,
        .power = 9001,
        // zmieniono z leto -> &leto
        .manager = &leto,
    };

    std.debug.print("{any}\n{any}", .{leto, duncan});
}

pub const User = struct {
    id: u64,
    power: i32,
    // zmieniono z ?const User -> ?*const User
    manager: ?*const User,
};

Ten kod nie skompiluje się: struct 'learning.User' depends on itself. Nie powiedzie się, ponieważ każdy typ musi mieć znany rozmiar w czasie kompilacji.

Nie napotkaliśmy tego problemu, gdy dodaliśmy name, mimo że nazwy mogą mieć różne długości. Problemem nie jest rozmiar wartości, ale rozmiar samych typów. Zig potrzebuje tej wiedzy, aby zrobić wszystko, o czym mówiliśmy powyżej, jak uzyskanie dostępu do pola na podstawie jego pozycji offsetu. name był wycinkiem, []const u8, który ma znany rozmiar: 16 bajtów - 8 bajtów dla len i 8 bajtów dla ptr.

Można by pomyśleć, że będzie to problem z każdą opcją lub unią. Jednak zarówno w przypadku opcjonali, jak i unii, największy możliwy rozmiar jest znany i Zig może go użyć. Struktura rekurencyjna nie ma takiego górnego ograniczenia, struktura może wykonać rekurencję raz, dwa lub miliony razy. Liczba ta będzie się różnić w zależności od User i nie będzie znana w czasie kompilacji.

Widzieliśmy odpowiedź z name: użyj wskaźnika. Wskaźniki zawsze zajmują bajty usize. Na platformie 64-bitowej jest to 8 bajtów. Podobnie jak rzeczywista nazwa "Goku" nie była przechowywana z/wraz z naszym user, użycie wskaźnika oznacza, że nasz menedżer nie jest już powiązany z układem pamięci user.

const std = @import("std");

pub fn main() void {
    const leto = User{
        .id = 1,
        .power = 9001,
        .manager = null,
    };

    const duncan = User{
        .id = 1,
        .power = 9001,
    // zmieniono z leto -> &leto
        .manager = &leto,
    };

    std.debug.print("{any}\n{any}", .{leto, duncan});
}

pub const User = struct {
    id: u64,
    power: i32,
    // zmieniono z ?const User -> ?*const User
    manager: ?*const User,
};

Być może nigdy nie będziesz potrzebował struktury rekurencyjnej, ale tu nie chodzi o modelowanie danych. Chodzi o zrozumienie wskaźników i modeli pamięci oraz lepsze zrozumienie tego, co robi kompilator.


Wielu programistów zmaga się ze wskaźnikami, może być w nich coś nieuchwytnego. Nie są one tak konkretne jak liczba całkowita, łańcuch czy User. Nic z tego nie musi być krystalicznie czyste, abyś mógł iść naprzód. Ale warto je opanować, i to nie tylko dla Ziga. Te szczegóły mogą być ukryte w językach takich jak Ruby, Python i JavaScript, a w mniejszym stopniu C#, Java i Go, ale nadal tam są, wpływając na sposób pisania kodu i jego działania. Nie spiesz się więc, baw się przykładami, dodawaj debugujące instrukcje wypisywania, aby przyjrzeć się zmiennym i ich adresom. Im więcej odkryjesz, tym jaśniejsze stanie się to wszystko.

Pamięć stosu

Zagłębienie się we wskaźniki zapewniło wgląd w relacje między zmiennymi, danymi i pamięcią. Mamy więc pojęcie o tym, jak wygląda pamięć, ale musimy jeszcze porozmawiać o tym, jak dane i, co za tym idzie, pamięć są zarządzane. W przypadku krótkich i prostych skryptów prawdopodobnie nie ma to znaczenia. W erze laptopów o pojemności 32 GB można uruchomić program, użyć kilkuset megabajtów pamięci RAM do odczytania pliku i przeanalizowania odpowiedzi HTTP, zrobić coś niesamowitego i wyjść. Po wyjściu z programu system operacyjny wie, że pamięć, którą dał programowi, może teraz zostać wykorzystana do czegoś innego.

Ale w przypadku programów, które działają przez dni, miesiące, a nawet lata, pamięć staje się ograniczonym i cennym zasobem, prawdopodobnie poszukiwanym przez inne procesy działające na tym samym komputerze. Po prostu nie ma sposobu, aby poczekać, aż program zakończy działanie, aby zwolnić pamięć. Jest to główne zadanie garbage collectora: wiedzieć, które dane nie są już używane i zwalniać pamięć. W Zig to ty jesteś garbage collectorem.

Większość pisanych programów będzie korzystać z trzech "obszarów" pamięci. Pierwszym z nich jest przestrzeń globalna, w której przechowywane są stałe programu, w tym literały łańcuchowe. Wszystkie dane globalne są wbudowane w plik binarny, w pełni znane w czasie kompilacji (a więc i w czasie wykonywania) i niezmienne. Dane te istnieją przez cały czas życia programu, nigdy nie potrzebując więcej lub mniej pamięci. Pomijając wpływ, jaki ma to na rozmiar naszego pliku binarnego, nie jest to coś, o co w ogóle musimy się martwić.

Drugim obszarem pamięci jest stos wywołań, który jest tematem tej części. Trzecim obszarem jest sterta, temat na następną część.

Nie ma fizycznej różnicy między tymi obszarami pamięci, jest to koncepcja stworzona przez system operacyjny i program wykonywalny.

Ramki stosu

Wszystkie dane, które widzieliśmy do tej pory, były stałymi przechowywanymi w sekcji danych globalnych naszych zmiennych binarnych lub lokalnych. "Lokalny" oznacza, że zmienna jest ważna tylko w zakresie, w którym została zadeklarowana. W Zig, zakresy zaczynają się i kończą nawiasami klamrowymi, { ... }. Większość zmiennych ma zakres funkcji, w tym parametry funkcji lub blok przepływu sterowania, taki jak if. Jak jednak widzieliśmy, można tworzyć dowolne bloki, a tym samym dowolne zakresy.

W poprzedniej części zwizualizowaliśmy pamięć naszych funkcji main i levelUp, każda z User:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
levelUp: user -> -------------  (id: 1043368ec)       |
                 |     1     |                        |
                 -------------  (power: 1043368f4)    |
                 |    100    |                        |
                 -------------  (name.len: 1043368f8) |
                 |     4     |                        |
                 -------------  (name.ptr: 104336900) |
                 | 1182145c0 |-------------------------
                 -------------                        |
                                                      |
                 .............  puste miejsce         |
                 .............  lub inne dane         |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

Jest powód, dla którego levelUp znajduje się zaraz po main: jest to nasz [uproszczony] stos wywołań. Kiedy nasz program się uruchamia, main wraz ze swoimi zmiennymi lokalnymi jest wciskany na stos wywołań. Gdy wywoływany jest levelUp, jego parametry i wszelkie zmienne lokalne są wypychane na stos wywołań. Co ważne, gdy levelUp powraca, jest zdejmowany ze stosu. Po powrocie levelUp i powrocie kontroli do main, nasz stos wywołań wygląda następująco:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
                 -------------
                                                      |
                 .............  puste miejsce         |
                 .............  lub inne dane         |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

Gdy funkcja jest wywoływana, cała jej ramka stosu jest umieszczana na stosie wywołań. Jest to jeden z powodów, dla których musimy znać rozmiar każdego typu. Chociaż możemy nie znać długości nazwy naszego użytkownika do momentu wykonania tej konkretnej linii kodu (zakładając, że nie był to stały literał łańcuchowy), wiemy, że nasza funkcja ma User i oprócz innych pól będziemy potrzebować 8 bajtów na name.len i 8 bajtów name.ptr.

Gdy funkcja powraca, jej ramka stosu, która jako ostatnia została wepchnięta na stos wywołań, jest usuwana. Właśnie stało się coś niesamowitego: pamięć używana przez levelUp została automatycznie zwolniona! Chociaż technicznie pamięć ta mogłaby zostać zwrócona do systemu operacyjnego, o ile mi wiadomo, żadna implementacja nie zmniejsza stosu wywołań (choć w razie potrzeby dynamicznie go powiększa). Mimo to pamięć używana do przechowywania ramki stosu levelUp jest teraz wolna do wykorzystania w naszym procesie dla innej ramki stosu.

W normalnym programie stos wywołań może być dość duży. Pomiędzy całym kodem frameworka i bibliotekami, z których korzysta typowy program, pojawiają się głęboko zagnieżdżone funkcje. Zwykle nie stanowi to problemu, ale od czasu do czasu można napotkać pewien rodzaj błędu przepełnienia stosu. Dzieje się tak, gdy na naszym stosie wywołań zabraknie miejsca. Najczęściej dzieje się tak w przypadku funkcji rekurencyjnych - funkcji, która wywołuje samą siebie.

Podobnie jak nasze dane globalne, stos wywołań jest zarządzany przez system operacyjny i program wykonywalny. Po uruchomieniu programu i dla każdego wątku, który uruchamiamy później, tworzony jest stos wywołań (którego rozmiar można zwykle skonfigurować w systemie operacyjnym). Stos wywołań istnieje przez cały czas trwania programu lub, w przypadku wątku, przez cały czas trwania wątku. Po zakończeniu programu lub wątku stos wywołań jest zwalniany. Ale podczas gdy nasze dane globalne zawierają wszystkie globalne dane programu, stos wywołań zawiera tylko ramki stosu dla aktualnie wykonywanej hierarchii funkcji. Jest to wydajne zarówno pod względem wykorzystania pamięci, jak i prostoty wkładania i zdejmowania ramek stosu na i ze stosu.

Zwisające wskaźniki

Stos wywołań jest niesamowity zarówno ze względu na swoją prostotę, jak i wydajność. Ale jest też przerażający: kiedy funkcja powraca, wszystkie jej lokalne dane stają się niedostępne. Może to brzmieć rozsądnie, w końcu są to dane lokalne, ale może to wprowadzić poważne problemy. Rozważmy ten kod:

onst std = @import("std");

pub fn main() void {
    const user1 = User.init(1, 10);
    const user2 = User.init(2, 20);

    std.debug.print("User {d} has power of {d}\n", .{user1.id, user1.power});
    std.debug.print("User {d} has power of {d}\n", .{user2.id, user2.power});
}

pub const User = struct {
    id: u64,
    power: i32,

    fn init(id: u64, power: i32) *User{
        var user = User{
            .id = id,
            .power = power,
        };
        return &user;
    }
};

Na pierwszy rzut oka rozsądne byłoby oczekiwanie następujących danych wyjściowych:

User 1 has power of 10
User 2 has power of 20

Otrzymałem:

User 2 has power of 20
User 9114745905793990681 has power of 0

Możesz uzyskać inne wyniki, ale na podstawie moich danych wyjściowych user1 odziedziczył wartości user2, a wartości user2 są bezsensowne. Kluczowym problemem w tym kodzie jest to, że User.init zwraca adres lokalnego użytkownika, &user. Nazywa się to zwisającym wskaźnikiem, wskaźnikiem, który odwołuje się do nieprawidłowej pamięci. Jest to źródło wielu naruszeń ochrony pamięci (segfaults).

Gdy ramka stosu jest usuwana ze stosu wywołań, wszelkie referencje do tej pamięci są nieważne. Wynik próby uzyskania dostępu do tej pamięci jest niezdefiniowany. Prawdopodobnie otrzymasz bezsensowne dane lub segfault. Moglibyśmy spróbować wyciągnąć jakieś wnioski z moich danych wyjściowych, ale nie jest to zachowanie, na którym chcielibyśmy lub nawet moglibyśmy polegać.

Jednym z wyzwań związanych z tego typu błędami jest to, że w językach z garbage collectorami powyższy kod jest całkowicie w porządku. Na przykład Go wykryłby, że lokalny user przeżyje swój zakres, funkcję init i zapewniłby jej ważność tak długo, jak jest to potrzebne (sposób, w jaki Go to robi, jest szczegółem implementacji, ale ma kilka opcji, w tym przeniesienie danych na stertę, o czym jest następna część).

Inną kwestią, którą muszę z przykrością stwierdzić, jest to, że może to być trudny do wykrycia błąd. W naszym powyższym przykładzie wyraźnie zwracamy adres lokalny. Ale takie zachowanie może ukrywać się wewnątrz funkcji zagnieżdżonych i złożonych typów danych. Czy widzisz jakieś możliwe problemy z poniższym niekompletnym kodem?

fn read() !void {
    const input = try readUserInput();
    return Parser.parse(input);
}

Cokolwiek Parser.parse zwróci, przeżyje input. Jeśli Parser przechowuje referencję do input, będzie to zwisający wskaźnik, który tylko czeka na awarię naszej aplikacji. Idealnie, jeśli Parser potrzebuje input tak długo, jak to robi, utworzy ich kopię, a ta kopia będzie powiązana z jej własnym czasem życia (więcej na ten temat w następnej części). Nie ma tu jednak nic, co pozwoliłoby wyegzekwować ten kontrakt. Dokumentacja Parser może rzucić nieco światła na to, czego oczekuje od input lub co z nim robi. W przeciwnym razie będziemy musieli zagłębić się w kod, aby to rozgryźć.


Prostym sposobem na rozwiązanie naszego początkowego błędu jest zmiana init tak, aby zwracał User, a nie *User (wskaźnik do User). Moglibyśmy wtedy zrobić return user; zamiast return &user;. Ale nie zawsze będzie to możliwe. Dane często muszą żyć poza sztywnymi granicami zakresów funkcji. W tym celu mamy trzeci obszar pamięci, stertę (heap), temat następnej części.

Zanim zagłębimy się w stertę, warto wiedzieć, że przed końcem tego przewodnika zobaczymy ostatni przykład zwisających wskaźników. W tym momencie omówimy już wystarczająco dużo języka, aby podać znacznie mniej zawiły przykład. Chcę powrócić do tego tematu, ponieważ dla programistów wywodzących się z języków z garbage collectorem może to powodować błędy i frustrację. Jest to coś, z czym sobie poradzisz. Sprowadza się to do bycia świadomym tego, gdzie i kiedy istnieją dane.

Pamięć sterty i alokatory

Wszystko, co do tej pory widzieliśmy, było ograniczone przez wymaganie z góry określonego rozmiaru. Tablice zawsze mają znaną w czasie kompilacji długość (w rzeczywistości długość jest częścią typu). Wszystkie nasze ciągi były literałami łańcuchowymi, które mają znaną w czasie kompilacji długość.

Co więcej, dwa rodzaje strategii zarządzania pamięcią, które widzieliśmy, dane globalne i stos wywołań, choć proste i wydajne, są ograniczające. Żadna z nich nie radzi sobie z danymi o dynamicznym rozmiarze i obie są sztywne w odniesieniu do czasu życia danych.

Ta część podzielona jest na dwa tematy. Pierwszym z nich jest ogólny przegląd naszego trzeciego obszaru pamięci, sterty. Drugi to proste, ale unikalne podejście Ziga do zarządzania pamięcią sterty. Nawet jeśli jesteś zaznajomiony z pamięcią sterty, powiedzmy z używania malloc w C, będziesz chciał przeczytać pierwszą część, ponieważ jest ona dość specyficzna dla Ziga.

Sterta (heap)

Sterta jest trzecim i ostatnim obszarem pamięci do naszej dyspozycji. W porównaniu do danych globalnych i stosu wywołań, sterta to trochę dziki zachód: wszystko jest dozwolone. W szczególności, w ramach sterty możemy tworzyć pamięć w czasie wykonywania o znanym rozmiarze i mieć pełną kontrolę nad jej żywotnością.

Stos wywołań jest niesamowity ze względu na prosty i przewidywalny sposób zarządzania danymi (poprzez wypychanie i wyskakiwanie ramek stosu). Zaleta ta jest jednocześnie wadą: czas życia danych jest powiązany z ich miejscem na stosie wywołań. Sterta jest dokładnym przeciwieństwem. Nie ma wbudowanego cyklu życia, więc nasze dane mogą żyć tak długo lub tak krótko, jak to konieczne. Ta zaleta jest również jej wadą: nie ma wbudowanego cyklu życia, więc jeśli nie zwolnimy danych, nikt tego nie zrobi.

Spójrzmy na przykład:

const std = @import("std");

pub fn main() !void {
    // wkrótce porozmawiamy o alokatorach
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    // ** Następne dwie linie są najważniejsze **
    var arr = try allocator.alloc(usize, try getRandomCount());
    defer allocator.free(arr);

    for (0..arr.len) |i| {
        arr[i] = i;
    }
    std.debug.print("{any}\n", .{arr});
}

fn getRandomCount() !u8 {
    var seed: u64 = undefined;
    try std.posix.getrandom(std.mem.asBytes(&seed));
    var random = std.Random.DefaultPrng.init(seed);
    return random.random().uintAtMost(u8, 5) + 5;
}

Wkrótce zajmiemy się alokatorami Zig, na razie wiedz, że allocator to std.mem.Allocator. Używamy dwóch jego metod: alloc i free. Ponieważ wywołujemy allocator.alloc z try, wiemy, że może się to nie udać. Obecnie jedynym możliwym błędem jest OutOfMemory. Jego parametry w większości mówią nam, jak to działa: wymaga typu (T), a także liczby, a po powodzeniu zwraca wycinek []T. Ta alokacja odbywa się w czasie wykonywania - musi, nasza liczba jest znana tylko w czasie wykonywania.

Zgodnie z ogólną zasadą, każdy alloc będzie miał odpowiadający mu free. Tam gdzie alloc alokuje pamięć, free ją zwalnia. Nie pozwól, aby ten prosty kod ograniczał twoją wyobraźnię. Ten wzorzec try alloc + defer free jest powszechny i nie bez powodu: zwalnianie w pobliżu miejsca alokacji jest stosunkowo niezawodne. Ale równie powszechne jest przydzielanie w jednym miejscu i zwalnianie w innym. Jak powiedzieliśmy wcześniej, sterta nie ma wbudowanego zarządzania cyklem życia. Możesz przydzielić pamięć w programie obsługi HTTP i zwolnić ją w wątku tła, dwóch całkowicie oddzielnych częściach kodu.

defer & errdefer

W ramach odskoczni, powyższy kod wprowadził nową funkcję językową: defer, która wykonuje dany kod lub blok po wyjściu z zakresu. "Wyjście z zakresu" obejmuje osiągnięcie końca zakresu lub powrót z zakresu. defer nie jest ściśle związany z alokatorami lub zarządzaniem pamięcią; można go użyć do wykonania dowolnego kodu. Ale powyższe użycie jest powszechne.

defer w Zig jest podobny do defer w Go, z jedną istotną różnicą. W Zig, defer zostanie uruchomiony na końcu zakresu zawierającego. W Go, defer jest uruchamiany na końcu funkcji zawierającej. Podejście Zig jest prawdopodobnie mniej zaskakujące, chyba że jesteś programistą Go.

Krewnym defer jest errdefer, który podobnie wykonuje dany kod lub blok na wyjściu z zakresu, ale tylko wtedy, gdy zwrócony zostanie błąd. Jest to przydatne podczas wykonywania bardziej złożonej konfiguracji i konieczności cofnięcia poprzedniej alokacji z powodu błędu.

Poniższy przykład stanowi skok w złożoność. Pokazuje zarówno errdefer, jak i powszechny wzorzec, w którym init alokuje, a deinit zwalnia:

const std = @import("std");
const Allocator = std.mem.Allocator;

pub const Game = struct {
    players: []Player,
    history: []Move,
    allocator: Allocator,

    fn init(allocator: Allocator, player_count: usize) !Game {
        var players = try allocator.alloc(Player, player_count);
        errdefer allocator.free(players);

        // przechowaj 10 ostatnich ruchów dla każdego gracza
        var history = try allocator.alloc(Move, player_count * 10);

        return .{
            .players = players,
            .history = history,
            .allocator = allocator,
        };
    }

    fn deinit(game: Game) void {
        const allocator = game.allocator;
        allocator.free(game.players);
        allocator.free(game.history);
    }
};

Mam nadzieję, że podkreśla to dwie rzeczy. Po pierwsze, przydatność errdefer. W normalnych warunkach players są alokowani w init i zwalniani w deinit. Istnieje jednak przypadek brzegowy, gdy inicjalizacja history nie powiedzie się. W tym i tylko w tym przypadku musimy cofnąć alokację players.

Drugim godnym uwagi aspektem tego kodu jest to, że cykl życia naszych dwóch dynamicznie alokowanych wycinków, players i history, opiera się na logice naszej aplikacji. Nie ma reguły, która dyktowałaby, kiedy deinit musi zostać wywołany lub kto musi go wywołać. Jest to dobre, ponieważ daje nam arbitralne czasy życia, ale złe, ponieważ możemy to zepsuć, nigdy nie wywołując deinit lub wywołując go więcej niż raz.

Nazwy init i deinit nie są niczym specjalnym. Są po prostu tym, czego używa standardowa biblioteka Zig i tym, co przyjęła społeczność. W niektórych przypadkach, w tym w bibliotece standardowej, używane są nazwy open i close lub inne bardziej odpowiednie nazwy.

Podwójne zwolnienie i wycieki pamięci

Powyżej wspomniałem, że nie ma żadnych zasad określających, kiedy coś musi zostać zwolnione. Ale to nie do końca prawda, istnieje kilka ważnych zasad, po prostu nie są one egzekwowane, z wyjątkiem własnej skrupulatności.

Pierwszą zasadą jest to, że nie można zwolnić tej samej pamięci dwa razy.

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var arr = try allocator.alloc(usize, 4);
    allocator.free(arr);
    allocator.free(arr);

    std.debug.print("This won't get printed\n", .{});
}

Ostatnia linia tego kodu jest prorocza, nie zostanie wypisana. Dzieje się tak, ponieważ dwukrotnie zwalniamy tę samą pamięć. Jest to znane jako podwójne zwolnienie i nie jest prawidłowe. Może się to wydawać dość proste do uniknięcia, ale w dużych projektach o złożonym czasie życia może być trudne do wyśledzenia.

Druga zasada mówi, że nie można zwalniać pamięci, do której nie mamy referencji. Może się to wydawać oczywiste, ale nie zawsze jest jasne, kto jest odpowiedzialny za jej zwolnienie. Poniższa instrukcja tworzy nowy ciąg znaków pisany małymi literami:

const std = @import("std");
const Allocator = std.mem.Allocator;

fn allocLower(allocator: Allocator, str: []const u8) ![]const u8 {
    var dest = try allocator.alloc(u8, str.len);

    for (str, 0..) |c, i| {
        dest[i] = switch (c) {
            'A'...'Z' => c + 32,
            else => c,
        };
    }

    return dest;
}

Powyższy kod jest w porządku. Ale następujące użycie nie jest:

// Dla tego konkretnego kodu, powinniśmy byli użyć std.ascii.eqlIgnoreCase
fn isSpecial(allocator: Allocator, name: [] const u8) !bool {
    const lower = try allocLower(allocator, name);
    return std.mem.eql(u8, lower, "admin");
}

To jest wyciek pamięci. Pamięć utworzona w funkcji allocLower nigdy nie jest zwalniana. Nie tylko to, ale po zwróceniu isSpecial nigdy nie może zostać zwolniona. W językach z garbage collectorami, gdy dane stają się nieosiągalne, zostaną ostatecznie zwolnione przez garbage collector. Ale w powyższym kodzie, gdy zwraca isSpecial, tracimy nasze jedyne referencję do przydzielonej pamięci, zmiennej lower. Pamięć zniknie, dopóki nasz proces nie zakończy działania. Z naszej funkcji może wycieknąć tylko kilka bajtów, ale jeśli jest to długo działający proces i funkcja ta jest wywoływana wielokrotnie, będzie się to sumować i ostatecznie zabraknie nam pamięci.

Przynajmniej w przypadku podwójnego zwolnienia, otrzymamy twardy crash. Wycieki pamięci mogą być podstępne. Nie chodzi tylko o to, że główna przyczyna może być trudna do zidentyfikowania. Naprawdę małe wycieki lub wycieki w rzadko wykonywanym kodzie mogą być jeszcze trudniejsze do wykrycia. Jest to tak powszechny problem, że Zig zapewnia pomoc, którą zobaczymy podczas omawiania alokatorów.

tworzenie i niszczenie

Metoda alloc z std.mem.Allocator zwraca wycinek o długości przekazanej jako drugi parametr. Jeśli chcesz uzyskać pojedynczą wartość, użyj create i destroy zamiast alloc i free. Kilka części temu, gdy uczyliśmy się o wskaźnikach, stworzyliśmy User i próbowaliśmy zwiększyć jego moc. Oto działająca wersja tego kodu oparta na stercie, wykorzystująca create:

const std = @import("std");

pub fn main() !void {
    // ponownie, wkrótce porozmawiamy o alokatorach!
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    // utworzenie User na stercie
    var user = try allocator.create(User);

    // zwolnienie pamięci przydzielonej dla user na końcu tego zakresu
    defer allocator.destroy(user);

    user.id = 1;
    user.power = 100;

    // ta linia została dodana
    levelUp(user);
    std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

fn levelUp(user: *User) void {
    user.power += 1;
}

pub const User = struct {
    id: u64,
    power: i32,
};

Metoda create przyjmuje pojedynczy parametr, typ (T). Zwraca wskaźnik do tego typu lub błąd, czyli !*T. Być może zastanawiasz się, co by się stało, gdybyśmy utworzyli naszego user, ale nie ustawili id i/lub powe. To tak, jakbyśmy ustawili te pola na undefined, a zachowanie jest, cóż, niezdefiniowane.

Kiedy badaliśmy zwisające wskaźniki, mieliśmy funkcję, która niepoprawnie zwracała adres lokalnego użytkownika:

pub const User = struct {
    fn init(id: u64, power: i32) *User{
        var user = User{
            .id = id,
            .power = power,
        };
        // to jest zwisający wskaźnik
        return &user;
    }
};

W tym przypadku bardziej sensowne byłoby zwrócenie User. Ale czasami będziesz chciał, aby funkcja zwróciła wskaźnik do czegoś, co utworzyła. Zrobisz to, gdy chcesz, aby czas życia był wolny od sztywności stosu wywołań. Aby rozwiązać problem zwisającego wskaźnika powyżej, mogliśmy użyć create:

// nasz typ zwracany uległ zmianie, ponieważ init może teraz zawieść
// *User -> !*User
fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{
    const user = try allocator.create(User);
    user.* = .{
        .id = id,
        .power = power,
    };
    return user;
}

Wprowadziłem nową składnię, user.* = .{...}. To trochę dziwne i nie podoba mi się to, ale zobaczysz to. Prawa strona jest czymś, co już widzieliście: jest to inicjalizator struktury z wywnioskowanym typem. Mogliśmy być jawni i użyć: user.* = User{...}. Lewa strona, user.*, jest sposobem na dereferencję wskaźnika. & pobiera T i daje nam *T. .* jest przeciwieństwem, zastosowane do wartości typu *T daje nam T. Pamiętaj, że create zwraca !*User, więc nasz użytkownik jest typu *User.

Alokatory

Jedną z podstawowych zasad Ziga jest brak ukrytych alokacji pamięci. W zależności od twoje praktyki, może to nie brzmieć zbyt szczególnie. Jest to jednak ostry kontrast z tym, co można znaleźć w języku C, gdzie pamięć jest przydzielana za pomocą funkcji malloc biblioteki standardowej. W języku C, jeśli chcesz wiedzieć, czy funkcja alokuje pamięć, czy nie, musisz przeczytać źródło i poszukać wywołań funkcji malloc.

Zig nie posiada domyślnego alokatora. We wszystkich powyższych przykładach funkcje alokujące pamięć przyjmowały parametr std.mem.Allocator. Zgodnie z konwencją, jest to zwykle pierwszy parametr. Cała standardowa biblioteka Ziga i większość bibliotek innych firm wymaga, aby wywołujący podał alokator, jeśli zamierza zaalokować pamięć.

Ta jawność może przybrać jedną z dwóch form. W prostych przypadkach, alokator jest dostarczany przy każdym wywołaniu funkcji. Istnieje wiele takich przykładów, ale std.fmt.allocPrint jest jednym z nich, który prawdopodobnie będzie potrzebny prędzej czy później. Jest on podobny do std.debug.print, którego używaliśmy, ale alokuje i zwraca ciąg znaków zamiast zapisywać go do stderr:

const say = std.fmt.allocPrint(allocator, "It's over {d}!!!", .{user.power});
defer allocator.free(say);

Inną formą jest sytuacja, w której alokator jest przekazywany do init, a następnie używany wewnętrznie przez obiekt. Widzieliśmy to powyżej z naszą strukturą Game. Jest to mniej jednoznaczne, ponieważ przekazałeś obiektowi alokator do użycia, ale nie wiesz, które wywołania metod faktycznie dokonają alokacji. Podejście to jest bardziej praktyczne w przypadku długotrwałych obiektów.

Zaletą wstrzykiwania alokatora jest nie tylko jawność, ale także elastyczność. std.mem.Allocator to interfejs, który udostępnia funkcje alloc, free, create i destroy, a także kilka innych. Do tej pory widzieliśmy tylko std.heap.GeneralPurposeAllocator, ale inne implementacje są dostępne w bibliotece standardowej lub jako biblioteki stron trzecich.

Zig nie ma ładnego cukru składniowego do tworzenia interfejsów. Jednym ze wzorców zachowania podobnego do interfejsu są unie tagowane, choć jest to stosunkowo ograniczone w porównaniu do prawdziwych interfejsów. Inne wzorce pojawiły się i są używane w całej bibliotece standardowej, na przykład w std.mem.Allocator. Jeśli jesteś zainteresowany, napisałem osobny wpis na blogu wyjaśniający interfejsy.

Jeśli tworzysz bibliotekę, najlepiej jest zaakceptować std.mem.Allocator i pozwolić użytkownikom biblioteki zdecydować, której implementacji alokatora użyć. W przeciwnym razie będziesz musiał wybrać odpowiedni alokator, a jak zobaczymy, nie wykluczają się one wzajemnie. Mogą istnieć dobre powody do tworzenia różnych alokatorów w programie.

Alokator ogólnego przeznaczenia (GeneralPurposeAllocator)

Jak sama nazwa wskazuje, std.heap.GeneralPurposeAllocator jest alokatorem "ogólnego przeznaczenia", bezpiecznym dla wątków, który może służyć jako główny alokator aplikacji. Dla wielu programów będzie to jedyny potrzebny alokator. Podczas uruchamiania programu, alokator jest tworzony i przekazywany do funkcji, które go potrzebują. Przykładowy kod z mojej biblioteki serwera HTTP jest dobrym przykładem:

const std = @import("std");
const httpz = @import("httpz");

pub fn main() !void {
    // utworzenie naszego alokatora ogólnego przeznaczenia
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};

    // pobieramy z niego std.mem.Allocator
    const allocator = gpa.allocator();

    // przekazujemy nasz alokator do funkcji i bibliotek, które go wymagają
    var server = try httpz.Server().init(allocator, .{.port = 5882});

    var router = server.router();
    router.get("/api/user/:id", getUser);

    // blokuje bieżący wątek
    try server.listen();
}

Tworzymy alokator GeneralPurposeAllocator, pobieramy z niego std.mem.Allocator i przekazujemy go do funkcji init serwera HTTP. W bardziej złożonym projekcie alocator byłby przekazywany do wielu części kodu, z których każda mogłaby przekazywać go do własnych funkcji, obiektów i zależności.

Można zauważyć, że składnia wokół tworzenia gpa jest nieco dziwna. Co to jest: GeneralPurposeAllocator(.{}){}? To wszystko rzeczy, które widzieliśmy już wcześniej, tylko rozbite razem. std.heap.GeneralPurposeAllocator jest funkcją, a ponieważ używa PascalCase, wiemy, że zwraca typ. (Porozmawiamy więcej o generykach w następnej części). Wiedząc, że zwraca typ, być może ta bardziej jawna wersja będzie łatwiejsza do rozszyfrowania:

const T = std.heap.GeneralPurposeAllocator(.{});
var gpa = T{};

// to to samo co:

var gpa = std.heap.GeneralPurposeAllocator(.{}){};

Być może nadal nie jesteś pewien znaczenia .{}. Jest to również coś, co widzieliśmy wcześniej: jest to inicjalizator struktury z niejawnym typem. Jaki jest typ i gdzie znajdują się pola? Typem jest std.heap.general_purpose_allocator.Config, chociaż nie jest on bezpośrednio eksponowany w ten sposób, co jest jednym z powodów, dla których nie jesteśmy jednoznaczni. Żadne pola nie są ustawione, ponieważ struktura Config definiuje wartości domyślne, których będziemy używać. Jest to powszechny wzorzec konfiguracji / opcji. W rzeczywistości widzimy to ponownie kilka linijek niżej, kiedy przekazujemy .{.port = 5882} do init. W tym przypadku używamy wartości domyślnej dla wszystkich pól z wyjątkiem jednego - portu.

std.testing.allocator

Mam nadzieję, że byłeś wystarczająco zaniepokojony, gdy rozmawialiśmy o wyciekach pamięci, a następnie chętny, aby dowiedzieć się więcej, gdy wspomniałem, że Zig może pomóc. Pomoc ta pochodzi z std.testing.allocator, który jest std.mem.Allocator. Obecnie jest on zaimplementowany przy użyciu GeneralPurposeAllocator z dodatkową integracją z runnerem testowym Ziga, ale to szczegół implementacji. Ważną rzeczą jest to, że jeśli użyjemy std.testing.allocator w naszych testach, możemy wychwycić większość wycieków pamięci.

Prawdopodobnie znasz już tablice dynamiczne, często nazywane ArrayListami. W wielu językach programowania dynamicznego wszystkie tablice są tablicami dynamicznymi. Tablice dynamiczne obsługują zmienną liczbę elementów. Zig ma odpowiednią ogólną ArrayListę, ale stworzymy ją specjalnie do przechowywania liczb całkowitych i zademonstrowania wykrywania wycieków:

pub const IntList = struct {
    pos: usize,
    items: []i64,
    allocator: Allocator,

    fn init(allocator: Allocator) !IntList {
        return .{
            .pos = 0,
            .allocator = allocator,
            .items = try allocator.alloc(i64, 4),
        };
    }

    fn deinit(self: IntList) void {
        self.allocator.free(self.items);
    }

    fn add(self: *IntList, value: i64) !void {
        const pos = self.pos;
        const len = self.items.len;

        if (pos == len) {
            // zabrakło nam miejsca
            // utwórz nowy wycinek, który jest dwa razy większy
            var larger = try self.allocator.alloc(i64, len * 2);

            // skopiuj elementy, które wcześniej dodaliśmy do naszej nowej przestrzeni
            @memcpy(larger[0..len], self.items);

            self.items = larger;
        }

        self.items[pos] = value;
        self.pos = pos + 1;
    }
};

Interesująca część dzieje się w add, gdy pos == len, wskazując, że zapełniliśmy naszą obecną tablicę i musimy utworzyć większą. Możemy użyć IntList w następujący sposób:

const std = @import("std");
const Allocator = std.mem.Allocator;

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var list = try IntList.init(allocator);
    defer list.deinit();

    for (0..10) |i| {
        try list.add(@intCast(i));
    }

    std.debug.print("{any}\n", .{list.items[0..list.pos]});
}

Kod działa i drukuje prawidłowy wynik. Jednakże, mimo że wywołaliśmy deinit na liście, wystąpił wyciek pamięci. Nic się nie stało, że tego nie zauważyłeś, ponieważ zamierzamy napisać test i użyć std.testing.allocator:

const testing = std.testing;
test "IntList: add" {
    // Używamy tutaj testing.allocator!
    var list = try IntList.init(testing.allocator);
    defer list.deinit();

    for (0..5) |i| {
        try list.add(@intCast(i+10));
    }

    try testing.expectEqual(@as(usize, 5), list.pos);
    try testing.expectEqual(@as(i64, 10), list.items[0]);
    try testing.expectEqual(@as(i64, 11), list.items[1]);
    try testing.expectEqual(@as(i64, 12), list.items[2]);
    try testing.expectEqual(@as(i64, 13), list.items[3]);
    try testing.expectEqual(@as(i64, 14), list.items[4]);
}

@as to wbudowana funkcja, która wykonuje wymuszanie typów. Jeśli zastanawiasz się, dlaczego nasz test musiał użyć tak wielu z nich, nie jesteś jedyny. Technicznie rzecz biorąc, dzieje się tak dlatego, że drugi parametr, "rzeczywisty", jest wymuszany na pierwszym, "oczekiwanym". W powyższym przykładzie wszystkie "oczekiwane" to comptime_int, co powoduje problemy. Wiele osób, w tym ja, uważa to za dziwne i niefortunne zachowanie.

Jeśli jesteś na bieżąco, umieść test w tym samym pliku co IntList i main. Testy Ziga są zwykle pisane w tym samym pliku, często w pobliżu kodu, który testują. Kiedy używamy zig test learning.zig do uruchomienia naszego testu, otrzymujemy niesamowitą porażkę:

Test [1/1] test.IntList: add... [gpa] (err): memory address 0x101154000 leaked:
/code/zig/learning.zig:26:32: 0x100f707b7 in init (test)
   .items = try allocator.alloc(i64, 2),
                               ^
/code/zig/learning.zig:55:29: 0x100f711df in test.IntList: add (test)
 var list = try IntList.init(testing.allocator);

... MORE STACK INFO ...

[gpa] (err): memory address 0x101184000 leaked:
/code/test/learning.zig:40:41: 0x100f70c73 in add (test)
   var larger = try self.allocator.alloc(i64, len * 2);
                                        ^
/code/test/learning.zig:59:15: 0x100f7130f in test.IntList: add (test)
  try list.add(@intCast(i+10));

Mamy wiele wycieków pamięci. Na szczęście alokator testowy mówi nam dokładnie, gdzie przydzielono wyciekającą pamięć. Czy jesteś teraz w stanie zauważyć wyciek? Jeśli nie, pamiętaj, że generalnie każda aloc powinna mieć odpowiadające jej zwolnienie. Nasz kod wywołuje free raz, w deinit. Jednak alloc jest wywoływane raz w init, a następnie za każdym razem, gdy wywoływane jest add i potrzebujemy więcej miejsca. Za każdym razem, gdy allocujemy więcej miejsca, musimy free poprzednie self.items:

// istniejący kod
var larger = try self.allocator.alloc(i64, len * 2);
@memcpy(larger[0..len], self.items);

// Dodany kod
// zwolnienie poprzedniej alokacji
self.allocator.free(self.items);

Dodanie tej ostatniej linii, po skopiowaniu elementów do naszego wycinka larger, rozwiązuje problem. Jeśli uruchomisz zig test learning.zig, nie powinien pojawić się żaden błąd.

ArenaAllocator

Alokator GeneralPurposeAllocator jest rozsądnym domyślnym rozwiązaniem, ponieważ działa dobrze we wszystkich możliwych przypadkach. Jednak w ramach programu można napotkać wzorce alokacji, które mogą skorzystać z bardziej wyspecjalizowanych alokatorów. Jednym z przykładów jest potrzeba krótkotrwałego stanu, który można wyrzucić po zakończeniu przetwarzania. Parsery często mają takie wymagania. Szkieletowa funkcja parse może wyglądać następująco:

fn parse(allocator: Allocator, input: []const u8) !Something {
    const state = State{
        .buf = try allocator.alloc(u8, 512),
        .nesting = try allocator.alloc(NestType, 10),
    };
    defer allocator.free(state.buf);
    defer allocator.free(state.nesting);

    return parseInternal(allocator, state, input);
}

Chociaż nie jest to zbyt trudne w zarządzaniu, parseInternal może potrzebować innych krótkotrwałych alokacji, które będą musiały zostać zwolnione. Alternatywnie możemy utworzyć ArenaAllocator, który pozwoli nam zwolnić wszystkie alokacje za jednym razem:

fn parse(allocator: Allocator, input: []const u8) !Something {
    // utworzenie ArenaAllocator z dostarczonego alokatora
    var arena = std.heap.ArenaAllocator.init(allocator);

   // spowoduje to zwolnienie wszystkiego, co zostało utworzone z tej areny
    defer arena.deinit();

    // utworzenie std.mem.Allocator z areny, będzie to
    // alokator, którego użyjemy wewnętrznie
    const aa = arena.allocator();

    const state = State{
        // używamy tutaj aa!
        .buf = try aa.alloc(u8, 512),

        // używamy tutaj aa!
        .nesting = try aa.alloc(NestType, 10),
    };

    // przekazujemy tutaj aa, więc mamy gwarancję, że
    // każda inna alokacja będzie w naszej arenie
    return parseInternal(aa, state, input);
}

ArenaAllocator pobiera alokator potomny, w tym przypadku alokator, który został przekazany do init, i tworzy nowy std.mem.Allocator. Kiedy ten nowy alokator jest używany do alokacji lub tworzenia pamięci, nie musimy wywoływać free ani destroy. Wszystko zostanie zwolnione, gdy wywołamy deinit na arena. W rzeczywistości, free i destroy alokatora ArenaAllocator nie robią nic.

ArenaAllocator musi być używany ostrożnie. Ponieważ nie ma sposobu na zwolnienie poszczególnych alokacji, musisz mieć pewność, że deinit areny zostanie wywołany w rozsądnym przyroście pamięci. Co ciekawe, wiedza ta może być wewnętrzna lub zewnętrzna. Na przykład w naszym powyższym szkielecie wykorzystanie ArenaAllocator ma sens z poziomu Parsera, ponieważ szczegóły dotyczące czasu życia stanu są kwestią wewnętrzną.

Alokatory takie jak ArenaAllocator, które mają mechanizm zwalniania wszystkich poprzednich alokacji, mogą łamać zasadę, że każda aloc powinna mieć odpowiadające jej zwolnienie. Jeśli jednak otrzymasz std.mem.Allocator, nie powinieneś przyjmować żadnych założeń dotyczących jego implementacji.

Tego samego nie można powiedzieć o naszej liście IntList. Może ona służyć do przechowywania 10 lub 10 milionów wartości. Może mieć żywotność mierzoną w milisekundach lub tygodniach. Nie jest w stanie zdecydować, jakiego typu alokatora użyć. To kod korzystający z IntList posiada tę wiedzę. Pierwotnie zarządzaliśmy naszą listą IntList w następujący sposób:

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();

var list = try IntList.init(allocator);
defer list.deinit();

Zamiast tego mogliśmy zdecydować się na dostarczenie ArenaAllocator:

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();

var arena = std.heap.ArenaAllocator.init(allocator);
odroczyć arena.deinit();
const aa = arena.allocator();

var list = try IntList.init(aa);

// Szczerze mówiąc, jestem rozdarty co do tego, czy powinniśmy wywoływać list.deinit.
// Technicznie rzecz biorąc, nie musimy tego robić, ponieważ powyżej wywołaliśmy defer arena.deinit().
defer list.deinit();

...

Nie musimy zmieniać IntList, ponieważ zajmuje się ona tylko std.mem.Allocator. A gdyby IntList wewnętrznie tworzyła własną arenę, to też by działało. Nie ma powodu, dla którego nie można utworzyć areny wewnątrz areny.

Jako ostatni szybki przykład, serwer HTTP, o którym wspomniałem powyżej, udostępnia alokator areny w Response. Po wysłaniu odpowiedzi arena jest czyszczona. Przewidywalny czas życia areny (od początku do końca żądania) sprawia, że jest to efektywna opcja. Efektywną pod względem wydajności i łatwości użytkowania.

FixedBufferAllocator

Ostatnim alokatorem, któremu się przyjrzymy, jest std.heap.FixedBufferAllocator, który alokuje pamięć z dostarczonego przez nas bufora (tj. []u8). Alokator ten ma dwie główne zalety. Po pierwsze, ponieważ cała pamięć, której mógłby użyć, jest tworzona z góry, jest szybki. Po drugie, naturalnie ogranicza ilość pamięci, która może zostać przydzielona. Ten twardy limit może być również postrzegany jako wada. Kolejną wadą jest to, że free i destroy działają tylko na ostatnio przydzielonym/utworzonym elemencie (pomyśl o stosie). Zwolnienie nieostatniej alokacji jest bezpieczne, ale nic nie da.

const std = @import("std");

pub fn main() !void {
    var buf: [150]u8 = undefined;
    var fa = std.heap.FixedBufferAllocator.init(&buf);

    // spowoduje to zwolnienie całej pamięci przydzielonej za pomocą tego alokatora
    defer fa.reset();

    const allocator = fa.allocator();

    const json = try std.json.stringifyAlloc(allocator, .{
        .this_is = "an anonymous struct",
        .above = true,
        .last_param = "are options",
    }, .{.whitespace = .indent_2});

    // Możemy zwolnić tę alokację, ale ponieważ wiemy, że nasz alokator jest
    // FixedBufferAllocator, możemy polegać na powyższym `defer fa.reset()`.
    defer allocator.free(json);

    std.debug.print("{s}\n", .{json});
}

The above prints:

{
  "this_is": "an anonymous struct",
  "above": true,
  "last_param": "are options"
}

Ale zmień nasz buf na [120]u8, a otrzymasz błąd OutOfMemory.

Powszechnym wzorcem dla alokatorów FixedBufferAllocator, i w mniejszym stopniu ArenaAllocator, jest ich resetowanie i ponowne użycie. Zwalnia to wszystkie poprzednie alokacje i pozwala na ponowne użycie alokatora.


Nie posiadając domyślnego alokatora, Zig jest zarówno przejrzysty, jak i elastyczny w odniesieniu do alokacji. Interfejs std.mem.Allocator jest potężny, umożliwiając wyspecjalizowanym alokatorom zawijanie bardziej ogólnych, jak widzieliśmy w przypadku ArenaAllocator.

Ogólnie rzecz biorąc, moc i związane z nią obowiązki alokacji na stercie są, miejmy nadzieję, oczywiste. Możliwość alokacji pamięci o dowolnym rozmiarze i dowolnym czasie życia jest niezbędna dla większości programów.

Jednak ze względu na złożoność związaną z pamięcią dynamiczną, powinieneś mieć oko otwarte na alternatywy. Na przykład powyżej użyliśmy std.fmt.allocPrint, ale biblioteka standardowa ma również std.fmt.bufPrint. Ta ostatnia pobiera bufor zamiast alokatora:

const std = @import("std");

pub fn main() !void {
    const name = "Leto";

    var buf: [100]u8 = undefined;
    const greeting = try std.fmt.bufPrint(&buf, "Hello {s}", .{name});

    std.debug.print("{s}\n", .{greeting});
}

Ten interfejs API przenosi ciężar zarządzania pamięcią na wywołującego. Gdybyśmy mieli dłuższą name lub mniejszy buf, nasz bufPrint mógłby zwrócić błąd NoSpaceLeft. Istnieje jednak wiele scenariuszy, w których aplikacja ma znane ograniczenia, takie jak maksymalna długość nazwy. W takich przypadkach bufPrint jest bezpieczniejszy i szybszy.

Inną możliwą alternatywą dla dynamicznych alokacji jest strumieniowe przesyłanie danych do std.io.Writer. Podobnie jak nasz Allocator, Writer jest interfejsem implementowanym przez wiele typów, takich jak pliki. Powyżej użyliśmy stringifyAlloc do serializacji JSON do dynamicznie alokowanego ciągu znaków. Mogliśmy użyć stringify i dostarczyć Writer:

const std = @import("std");

pub fn main() !void {
    const out = std.io.getStdOut();

    try std.json.stringify(.{
        .this_is = "an anonymous struct",
        .above = true,
        .last_param = "are options",
    }, .{.whitespace = .indent_2}, out.writer());
}

Podczas gdy alokatory są często podawane jako pierwszy parametr funkcji, writer są zwykle ostatnimi. ಠ_ಠ

W wielu przypadkach zawinięcie naszego writera w std.io.BufferedWriter dałoby niezły wzrost wydajności.

Celem nie jest wyeliminowanie wszystkich dynamicznych alokacji. To nie zadziała, ponieważ te alternatywy mają sens tylko w określonych przypadkach. Ale teraz masz do dyspozycji wiele opcji. Od ramek stosu po alokator ogólnego przeznaczenia i wszystko pomiędzy, takie jak bufory statyczne, zapisy strumieniowe i wyspecjalizowane alokatory.

Generyczność (polimorfizm parametryczny)

W poprzedniej części zbudowaliśmy tablicę dynamiczną o nazwie IntList. Celem tej struktury danych było przechowywanie dynamicznej liczby wartości. Chociaż algorytm, którego użyliśmy, działałby dla dowolnego typu danych, nasza implementacja była powiązana z wartościami i64. Z pomocą przychodzą generyki, których celem jest abstrahowanie algorytmów i struktur danych od konkretnych typów.

Wiele języków implementuje generyki ze specjalną składnią i regułami specyficznymi dla generyki. W Zigu generyki są mniej specyficzną cechą, a bardziej wyrazem tego, do czego zdolny jest język. W szczególności, generyki wykorzystują potężne metaprogramowanie Ziga w czasie kompilacji.

Zaczniemy od głupiego przykładu, aby się zorientować:

const std = @import("std");

pub fn main() !void {
    var arr: IntArray(3) = undefined;
    arr[0] = 1;
    arr[1] = 10;
    arr[2] = 100;
    std.debug.print("{any}\n", .{arr});
}

fn IntArray(comptime length: usize) type {
    return [length]i64;
}

Powyższe wypisuje { 1, 10, 100 }. Interesujące jest to, że mamy funkcję, która zwraca type (stąd funkcja jest PascalCase). I to nie byle jaki typ, ale typ oparty na parametrze funkcji. Ten kod działa tylko dlatego, że zadeklarowaliśmy length jako comptime. Oznacza to, że wymagamy, aby każdy, kto wywołuje IntArray, przekazał parametr length znany w czasie kompilacji. Jest to konieczne, ponieważ nasza funkcja zwraca type, a typy muszą być zawsze znane w czasie kompilacji.

Funkcja może zwracać dowolny typ, nie tylko typy podstawowe i tablice. Na przykład, wprowadzając niewielką zmianę, możemy sprawić, że funkcja będzie zwracać strukturę:

const std = @import("std");

pub fn main() !void {
    var arr: IntArray(3) = undefined;
    arr.items[0] = 1;
    arr.items[1] = 10;
    arr.items[2] = 100;
    std.debug.print("{any}\n", .{arr.items});
}

fn IntArray(comptime length: usize) type {
    return struct {
        items: [length]i64,
    };
}

Może się to wydawać dziwne, ale typ arr to tak naprawdę IntArray(3). Jest to typ jak każdy inny typ, a arr jest wartością jak każda inna wartość. Gdybyśmy wywołali IntArray(7), byłby to inny typ. Może uda nam się to zrobić schludniej:

const std = @import("std");

pub fn main() !void {
    var arr = IntArray(3).init();
    arr.items[0] = 1;
    arr.items[1] = 10;
    arr.items[2] = 100;
    std.debug.print("{any}\n", .{arr.items});
}

fn IntArray(comptime length: usize) type {
    return struct {
        items: [length]i64,

        fn init() IntArray(length) {
            return .{
                .items = undefined,
            };
        }
    };
}

Na pierwszy rzut oka może to nie wyglądać schludniej. Ale poza tym, że jest bezimienna i zagnieżdżona w funkcji, nasza struktura wygląda jak każda inna struktura, którą widzieliśmy do tej pory. Ma pola, ma funkcje. Wiesz, co mówią, jeśli wygląda jak kaczka.... Cóż, ta struktura wygląda, pływa i kwacze jak normalna struktura, ponieważ nią jest.

Podjęliśmy tę drogę, aby poczuć się komfortowo z funkcją zwracającą typ i towarzyszącą jej składnią. Aby uzyskać bardziej typowy generyk, musimy wprowadzić ostatnią zmianę: nasza funkcja musi przyjmować type. W rzeczywistości jest to niewielka zmiana, ale type może wydawać się bardziej abstrakcyjny niż usize, więc zrobiliśmy to powoli. Zróbmy krok naprzód i zmodyfikujmy naszą poprzednią funkcję IntList, aby działała z dowolnym typem. Zaczniemy od szkieletu:

fn List(comptime T: type) type {
    return struct {
        pos: usize,
        items: []T,
        allocator: Allocator,

        fn init(allocator: Allocator) !List(T) {
            return .{
                .pos = 0,
                .allocator = allocator,
                .items = try allocator.alloc(T, 4),
            };
        }
    };
}

Powyższa struktura jest prawie identyczna z naszą IntList, z wyjątkiem tego, że i64 zostało zastąpione przez T. To T może wydawać się wyjątkowe, ale to tylko nazwa zmiennej. Mogliśmy ją nazwać item_type. Jednakże, zgodnie z konwencją nazewnictwa Ziga, zmienne typu type są PascalCase.

Na dobre i na złe, używanie pojedynczej litery do reprezentowania parametru typu jest znacznie starsze niż Zig. T jest powszechną wartością domyślną w większości języków, ale można spotkać się z odmianami specyficznymi dla kontekstu, takimi jak hashmapy używające K i V dla klucza i wartości jako typów parametrów.

Jeśli nie jesteś pewien naszego szkieletu, rozważ dwa miejsca, w których używamy T: items: []T i allocator.alloc(T, 4). Gdy będziemy chcieli użyć tego typu generycznego, utworzymy jego instancję przy użyciu:

var list = try List(u32).init(allocator);

Gdy kod zostanie skompilowany, kompilator utworzy nowy typ, znajdując każdy T i zastępując go u32. Jeśli ponownie użyjemy List(u32), kompilator ponownie użyje utworzonego wcześniej typu. Jeśli określimy nową wartość dla T, powiedzmy List(bool) lub List(User), zostaną utworzone nowe typy.

Aby ukończyć naszą generyczną List, możemy dosłownie skopiować i wkleić resztę kodu IntList i zastąpić i64 przez T. Oto w pełni działający przykład:

const std = @import("std");
const Allocator = std.mem.Allocator;

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var list = try List(u32).init(allocator);
    defer list.deinit();

    for (0..10) |i| {
        try list.add(@intCast(i));
    }

    std.debug.print("{any}\n", .{list.items[0..list.pos]});
}

fn List(comptime T: type) type {
    return struct {
        pos: usize,
        items: []T,
        allocator: Allocator,

        fn init(allocator: Allocator) !List(T) {
            return .{
                .pos = 0,
                .allocator = allocator,
                .items = try allocator.alloc(T, 4),
            };
        }

        fn deinit(self: List(T)) void {
            self.allocator.free(self.items);
        }

        fn add(self: *List(T), value: T) !void {
            const pos = self.pos;
            const len = self.items.len;

            if (pos == len) {
        // zabrakło nam miejsca
        // utwórz nowy wycinek, który jest dwa razy większy
                var larger = try self.allocator.alloc(T, len * 2);

        // skopiuj elementy, które wcześniej dodaliśmy do naszej nowej przestrzeni
                @memcpy(larger[0..len], self.items);

                self.allocator.free(self.items);

                self.items = larger;
            }

            self.items[pos] = value;
            self.pos = pos + 1;
        }
    };
}

Nasza funkcja init zwraca List(T), a nasze funkcje deinit i add pobierają List(T) i *List(T). W naszej prostej klasie jest to w porządku, ale w przypadku dużych struktur danych pisanie pełnej nazwy ogólnej może stać się nieco uciążliwe, zwłaszcza jeśli mamy wiele parametrów typu (np. hashmapa, która przyjmuje oddzielny type dla swojego klucza i wartości). Wbudowana funkcja @This() zwraca najbardziej wewnętrzny type, z którego została wywołana. Najprawdopodobniej nasza funkcja List(T) zostałaby zapisana jako:

fn List(comptime T: type) type {
    return struct {
        pos: usize,
        items: []T,
        allocator: Allocator,

        // Dodano
        const Self = @This();

        fn init(allocator: Allocator) !Self {
        // ... ten sam kod
        }

        fn deinit(self: Self) void {
        // ... ten sam kod
        }

        fn add(self: *Self, value: T) !void {
        // ... ten sam kod
        }
    };
}

Self nie jest specjalną nazwą, jest po prostu zmienną i jest PascalCase, ponieważ jej wartość to type. Możemy użyć Self tam, gdzie wcześniej używaliśmy List(T).


Możemy tworzyć bardziej złożone przykłady, z wieloma parametrami typu i bardziej zaawansowanymi algorytmami. Ale ostatecznie podstawowy kod generyczny nie różniłby się od prostych przykładów powyżej. W następnej części ponownie dotkniemy generyczności, gdy przyjrzymy się standardowym bibliotekom ArrayList(T) i StringHashMap(V).

Kodowanie w Zigu

Ponieważ znaczna część języka została już omówiona, zamierzamy zakończyć sprawy, powracając do kilku tematów i przyglądając się kilku bardziej praktycznym aspektom korzystania z Ziga. W ten sposób wprowadzimy więcej standardowej biblioteki i przedstawimy mniej trywialne fragmenty kodu.

Zwisające wskaźniki

Zaczniemy od przyjrzenia się większej liczbie przykładów zwisających wskaźników. Może się to wydawać dziwną rzeczą, na której należy się skupić, ale jeśli przychodzisz z języka z garbage collectorem, jest to prawdopodobnie największe wyzwanie, z jakim będziesz musiał się zmierzyć.

Czy potrafisz odgadnąć, co poniższe wypisze?

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var lookup = std.StringHashMap(User).init(allocator);
    defer lookup.deinit();

    const goku = User{.power = 9001};

    try lookup.put("Goku", goku);

    // zwraca opcjonalne, .? spanikowałoby, gdyby "Goku"
    // nie było w naszej hashmapie
    const entry = lookup.getPtr("Goku").?;

    std.debug.print("Goku's power is: {d}\n", .{entry.power});

    // zwraca prawdę/fałsz w zależności od tego, czy element został usunięty
    _ = lookup.remove("Goku");

    std.debug.print("Goku's power is: {d}\n", .{entry.power});
}

const User = struct {
    power: i32,
};

Kiedy to uruchomiłem, otrzymałem:

Goku's power is: 9001
Goku's power is: -1431655766

Ten kod wprowadza generyczną std.StringHashMap Ziga, która jest wyspecjalizowaną wersją std.AutoHashMap z typem klucza ustawionym na []const u8. Nawet jeśli nie jesteś w 100% pewien, co się dzieje, to dobre odgadnięcie, że moje wyjście odnosi się do faktu, że nasz drugi print ma miejsce po usunięciu wpisu z lookup. Wykreśl wywołanie remove, a wynik będzie normalny.

Kluczem do zrozumienia powyższego kodu jest świadomość tego, gdzie istnieją dane/pamięć lub, mówiąc inaczej, kto jest ich właścicielem. Pamiętaj, że argumenty Ziga są przekazywane przez wartość, to znaczy przekazujemy [płytką] kopię wartości. User w naszym lookup nie jest tą samą pamięcią, do której odwołuje się goku. Nasz powyższy kod ma dwóch użytkowników, każdy z własnym właścicielem. goku jest własnością main, a jego kopia jest własnością lookup.

Metoda getPtr zwraca wskaźnik do wartości w mapie, w naszym przypadku zwraca *User. W tym tkwi problem, remove sprawia, że nasz wskaźnik entry jest nieważny. W tym przykładzie bliskość getPtr i remove sprawia, że problem jest dość oczywisty. Nietrudno jednak wyobrazić sobie kod wywołujący remove bez świadomości, że referencja do wpisu jest przechowywana gdzie indziej.

Kiedy pisałem ten przykład, nie byłem pewien, co się stanie. Możliwe było zaimplementowanie remove poprzez ustawienie wewnętrznej flagi, opóźniając faktyczne usunięcie do późniejszego zdarzenia. Gdyby tak było, powyższy przykład mógłby "zadziałać" w naszych prostych przypadkach, ale zawiódłby przy bardziej skomplikowanym użyciu. Brzmi to przerażająco trudno do debugowania.

Oprócz nie wywoływania remove, możemy to naprawić na kilka różnych sposobów. Pierwszym z nich jest użycie get zamiast getPtr. Zwróciłoby to User zamiast *User, a tym samym zwróciłoby kopię wartości w lookup. Mielibyśmy wtedy trzech User.

  1. Nasz oryginalny goku, powiązany z funkcją.
  2. Kopia w lookup, będącą własnością lookup.
  3. Oraz kopia naszej kopii, entry, również powiązana z funkcją.

Ponieważ entry byłby teraz własną, niezależną kopią użytkownika, usunięcie go z lookup nie spowodowałoby jego unieważnienia.

Inną opcją jest zmiana typu lookup z StringHashMap(User) na StringHashMap(*const User). Ten kod działa:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    // User -> *const User
    var lookup = std.StringHashMap(*const User).init(allocator);
    defer lookup.deinit();

    const goku = User{.power = 9001};

    // goku -> &goku
    try lookup.put("Goku", &goku);

    // getPtr -> get
    const entry = lookup.get("Goku").?;

    std.debug.print("Goku's power is: {d}\n", .{entry.power});
    _ = lookup.remove("Goku");
    std.debug.print("Goku's power is: {d}\n", .{entry.power});
}

const User = struct {
    power: i32,
};

Powyższy kod zawiera kilka subtelności. Po pierwsze, mamy teraz jednego User, goku. Wartość w lookup i entry są obie referencjami do goku. Nasze wywołanie remove nadal usuwa wartość z naszego lookup, ale ta wartość jest tylko adresem user, nie jest samym user. Gdybyśmy pozostali przy getPtr, otrzymalibyśmy nieprawidłowy **User, nieważny z powodu remove. W obu rozwiązaniach musieliśmy użyć get zamiast getPtr, ale w tym przypadku kopiujemy tylko adres, a nie pełnego User. W przypadku dużych obiektów może to być znacząca różnica.

Ze wszystkim w jednej funkcji i małą wartością, taką jak User, nadal wydaje się to sztucznie stworzonym problemem. Potrzebujemy przykładu, który zasadnie sprawi, że własność danych stanie się bezpośrednim problemem.

Własność (ownership)

Uwielbiam hashmapy, ponieważ są one czymś, co wszyscy znają i czego wszyscy używają. Mają one również wiele różnych zastosowań, z których większość prawdopodobnie doświadczyłeś na własnej skórze. Chociaż mogą być używane jako krótkotrwałe wyszukiwania, często są długotrwałe, a zatem wymagają równie długotrwałych wartości.

Ten kod wypełnia nasz lookup nazwami wprowadzanymi w terminalu. Pusta nazwa zatrzymuje pętlę zachęty. Na koniec wykrywa, czy "Leto" było jednym z podanych nazw.

const std = @import("std");
const builtin = @import("builtin");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var lookup = std.StringHashMap(User).init(allocator);
    defer lookup.deinit();

  // stdin to std.io.Reader
  // przeciwieństwo std.io.Writer, które już widzieliśmy
    const stdin = std.io.getStdIn().reader();

  // stdout to std.io.Writer
    const stdout = std.io.getStdOut().writer();

    var i: i32 = 0;
    while (true) : (i += 1) {
        var buf: [30]u8 = undefined;
        try stdout.print("Please enter a name: ", .{});
        if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
            var name = line;
            if (builtin.os.tag == .windows) {
                // W systemie Windows linie są zakończone znakiem \r\n.
                // Musimy usunąć \r
                name = @constCast(std.mem.trimRight(u8, name, "\r"));
            }
            if (name.len == 0) {
                break;
            }
            try lookup.put(name, .{.power = i});
        }
    }

    const has_leto = lookup.contains("Leto");
    std.debug.print("{any}\n", .{has_leto});
}

const User = struct {
    power: i32,
};

Początkowa wersja tego kodu nie kompilowała się w systemie Windows. Konieczne było dodanie funkcji @constCast, którą teraz widzisz. Widzieliśmy już inne wbudowane funkcje, ale ta jest bardziej zaawansowana. Zastanawiałem się nad usunięciem całej linii, ale chciałem, aby ludzie mogli podążać za mną w systemie Windows i dlatego potrzebowałem przycięcia. Istniały prostsze rozwiązania specyficzne dla tego przypadku, ale zamiast tego zdecydowałem się pozostać przy niebezpiecznym @constCast. Napisałem wpis na blogu oparty na tym przykładzie, który wyjaśnia, dlaczego jest to konieczne - ale jest znacznie bardziej zaawansowany. Jest to rodzaj rzeczy, do których możesz chcieć wrócić po spędzeniu więcej czasu z Zigiem.

W kodzie rozróżniana jest wielkość liter, ale bez względu na to, jak idealnie wpiszemy "Leto", contains zawsze zwraca false. Zdebugujmy to, iterując przez lookup i zrzucając klucze i wartości:

// Umieść ten kod po pętli while

var it = lookup.iterator();
while (it.next()) |kv| {
    std.debug.print("{s} == {any}\n", .{kv.key_ptr.*, kv.value_ptr.*});
}

Ten wzorzec iteratora jest powszechny w Zigu i opiera się na synergii między typami while i optional. Nasz iterator zwraca wskaźniki do naszego klucza i wartości, dlatego dereferencjonujemy je za pomocą .*, aby uzyskać dostęp do rzeczywistej wartości, a nie adresu. Wynik będzie zależał od tego, co wprowadzisz, ale mam:

Please enter a name: Paul
Please enter a name: Teg
Please enter a name: Leto
Please enter a name:

�� == learning.User{ .power = 1 }

��� == learning.User{ .power = 0 }

��� == learning.User{ .power = 2 }
false

Wartości wyglądają w porządku, ale nie klucze. Jeśli nie jesteś pewien, co się dzieje, to prawdopodobnie moja wina. Wcześniej celowo źle skierowałem twoją uwagę. Powiedziałem, że mapy hash są często długotrwałe, a zatem wymagają długotrwałych wartości. Prawda jest taka, że wymagają one zarówno długotrwałych wartości, jak i długotrwałych kluczy! Zauważ, że buf jest zdefiniowany wewnątrz naszej pętli while. Kiedy wywołujemy put, dajemy naszej hashmapie klucz, który ma znacznie krótszy czas życia niż sama hashmapa. Przeniesienie buf poza pętlę while rozwiązuje nasz problem z czasem życia, ale ten bufor jest ponownie wykorzystywany w każdej iteracji. Nadal nie będzie działać, ponieważ mutujemy podstawowe dane klucza.

Dla naszego powyższego kodu istnieje tylko jedno rozwiązanie: nasz lookup musi przejąć klucze na własność. Musimy dodać jedną linię i zmienić inną:

// zastąpić istniejący lookup.put tymi dwoma liniami
const owned_name = try allocator.dupe(u8, name);

// name -> owned_name
try lookup.put(owned_name, .{.power = i});

dupe to metoda std.mem.Allocator, której wcześniej nie widzieliśmy. Alokuje ona duplikat podanej wartości. Kod teraz działa, ponieważ nasze klucze, znajdujące się teraz na stercie, przeżywają lookup. W rzeczywistości wykonaliśmy zbyt dobrą robotę, wydłużając czas życia tych łańcuchów: wprowadziliśmy wycieki pamięci.

Można by pomyśleć, że kiedy wywołamy lookup.deinit, nasze klucze i wartości zostaną dla nas zwolnione. Ale nie ma jednego uniwersalnego rozwiązania, którego StringHashMap mógłby użyć. Po pierwsze, klucze mogą być literałami łańcuchowymi, których nie można zwolnić. Po drugie, mogły one zostać utworzone przy użyciu innego alokatora. Wreszcie, choć bardziej zaawansowane, istnieją uzasadnione przypadki, w których klucze mogą nie być własnością hashmapy.

Jedynym rozwiązaniem jest samodzielne zwolnienie kluczy. W tym momencie prawdopodobnie sensowne byłoby utworzenie własnego typu UserLookup i enkapsulacja logiki czyszczenia w naszej funkcji deinit. Zachowamy bałagan:

// zastąpimy istniejące:
//   defer lookup.deinit();
// z:
defer {
    var it = lookup.keyIterator();
    while (it.next()) |key| {
        allocator.free(key.*);
    }
    lookup.deinit();
}

Nasza logika defer, pierwsza, jaką widzieliśmy z blokiem, zwalnia każdy klucz, a następnie deinicjalizuje lookup. Używamy keyIterator tylko do iteracji kluczy. Wartość iteratora jest wskaźnikiem do wpisu klucza w hashmapie, *[]const u8. Chcemy zwolnić rzeczywistą wartość, ponieważ to właśnie ją zaalokowaliśmy za pomocą dupe, więc dereferencjonujemy wartość za pomocą .*.

Obiecuję, że skończyliśmy rozmawiać o zwisających wskaźnikach i zarządzaniu pamięcią. To, co omówiliśmy, może być nadal niejasne lub zbyt abstrakcyjne. Dobrze jest wrócić do tego tematu, gdy będziesz miał bardziej praktyczny problem do rozwiązania. To powiedziawszy, jeśli planujesz napisać coś nietrywialnego, jest to coś, co prawie na pewno będziesz musiał opanować. Kiedy poczujesz się na siłach, zachęcam do skorzystania z przykładu pętli zachęty i pobawienia się nią na własną rękę. Wprowadź typ UserLookup, który enkapsuluje całe zarządzanie pamięcią, które musieliśmy wykonać. Wypróbuj wartości *User zamiast User, tworząc użytkowników na stercie i zwalniając ich tak, jak zrobiliśmy to z kluczami. Napisz testy obejmujące nową strukturę, używając std.testing.allocator, aby upewnić się, że nie wycieka żadna pamięć.

ArrayList

Będziesz zadowolony wiedząc, że możesz zapomnieć o naszej IntList i generycznej alternatywie, którą stworzyliśmy. Zig ma odpowiednią implementację dynamicznej tablicy: std.ArrayList(T).

To dość standardowa rzecz, ale jest to tak powszechnie potrzebna i używana struktura danych, że warto zobaczyć ją w akcji:

const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var arr = std.ArrayList(User).init(allocator);
    defer {
        for (arr.items) |user| {
            user.deinit(allocator);
        }
        arr.deinit();
    }

  // stdin to std.io.Reader
  // przeciwieństwo std.io.Writer, które już widzieliśmy
    const stdin = std.io.getStdIn().reader();

  // stdout to std.io.Writer
    const stdout = std.io.getStdOut().writer();

    var i: i32 = 0;
    while (true) : (i += 1) {
        var buf: [30]u8 = undefined;
        try stdout.print("Please enter a name: ", .{});
        if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
            var name = line;
            if (builtin.os.tag == .windows) {
                // W systemie Windows linie są zakończone znakiem \r\n.
                // Musimy usunąć \r
                name = @constCast(std.mem.trimRight(u8, name, "\r"));
            }
            if (name.len == 0) {
                break;
            }
            const owned_name = try allocator.dupe(u8, name);
            try arr.append(.{.name = owned_name, .power = i});
        }
    }

    var has_leto = false;
    for (arr.items) |user| {
        if (std.mem.eql(u8, "Leto", user.name)) {
            has_leto = true;
            break;
        }
    }

    std.debug.print("{any}\n", .{has_leto});
}

const User = struct {
    name: []const u8,
    power: i32,

    fn deinit(self: User, allocator: Allocator) void {
        allocator.free(self.name);
    }
};

Powyżej znajduje się reprodukcja naszego kodu mapy hash, ale przy użyciu ArrayList(User). Obowiązują te same zasady dotyczące czasu życia i zarządzania pamięcią. Zauważ, że nadal tworzymy duplikat nazwy i nadal zwalniamy każdą nazwę przed deinit ArrayList.

To dobry moment, aby podkreślić, że Zig nie ma właściwości (properties) lub pól prywatnych. Widać to, gdy uzyskujemy dostęp do arr.items, aby iterować po wartościach. Powodem braku właściwości jest wyeliminowanie źródła niespodzianek. W Zigu, jeśli coś wygląda jak dostęp do pola, to jest to dostęp do pola. Osobiście uważam, że brak prywatnych pól jest błędem, ale z pewnością jest to coś, co możemy obejść. Zacząłem poprzedzać pola podkreśleniem, aby zasygnalizować "tylko do użytku wewnętrznego".

Ponieważ "typ" łańcucha to []u8 lub []const u8, ArrayList(u8) jest odpowiednim typem dla konstruktora łańcuchów, takiego jak StringBuilder .NET lub strings.Builder w Go. W rzeczywistości często będziesz go używać, gdy funkcja pobiera Writer i chcesz uzyskać ciąg znaków. Wcześniej widzieliśmy przykład którym używał std.json.stringify do wyprowadzenia JSON na wyjście stdout. Oto jak można użyć ArrayList(u8) do wyprowadzenia go do zmiennej:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var out = std.ArrayList(u8).init(allocator);
    defer out.deinit();

    try std.json.stringify(.{
        .this_is = "an anonymous struct",
        .above = true,
        .last_param = "are options",
    }, .{.whitespace = .indent_2}, out.writer());

    std.debug.print("{s}\n", .{out.items});
}

Anytype

W części 1 krótko omówiliśmy anytype. Jest to całkiem przydatna forma duck-typingu w czasie kompilacji. Oto prosty logger:

pub const Logger = struct {
    level: Level,

    // "błąd" jest zarezerwowany, nazwy wewnątrz @"..." są zawsze
    // traktowane jako identyfikatory
    const Level = enum {
        debug,
        info,
        @"error",
        fatal,
    };

    fn info(logger: Logger, msg: []const u8, out: anytype) !void {
        if (@intFromEnum(logger.level) <= @intFromEnum(Level.info)) {
            try out.writeAll(msg);
        }
    }
};

Parametr out naszej funkcji info ma typ anytype. Oznacza to, że nasz Logger może rejestrować komunikaty do dowolnej struktury, która ma metodę writeAll akceptującą []const u8 i zwracającą !void. Nie jest to funkcja czasu wykonania. Sprawdzanie typu odbywa się w czasie kompilacji i dla każdego używanego typu tworzona jest prawidłowo otypowana funkcja. Jeśli spróbujemy wywołać info z typem, który nie ma wszystkich niezbędnych funkcji (w tym przypadku tylko writeAll), otrzymamy błąd kompilacji:

var l = Logger{.level = .info};
try l.info("sever started", true);

Daje nam: no field or member function named 'writeAll' in 'bool'. Użycie writer na ArrayList(u8) działa:

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var l = Logger{.level = .info};

    var arr = std.ArrayList(u8).init(allocator);
    defer arr.deinit();

    try l.info("sever started", arr.writer());
    std.debug.print("{s}\n", .{arr.items});
}

Ogromną wadą anytype jest dokumentacja. Oto sygnatura funkcji std.json.stringify, której używaliśmy kilka razy:

// **Nienawidzę** wieloliniowych definicji funkcji
// Ale zrobię wyjątek dla przewodnika, który
// możesz czytać na małym ekranie.

fn stringify(
    value: anytype,
    options: StringifyOptions,
    out_stream: anytype
) @TypeOf(out_stream).Error!void

Pierwszy parametr, value: anytype, jest dość oczywisty. Jest to wartość do serializacji i może to być cokolwiek (w rzeczywistości istnieją pewne rzeczy, których serializator JSON Ziga nie może serializować). Możemy zgadywać, że out_stream jest miejscem zapisu JSON, ale równie dobrze można zgadywać, jakie metody musi zaimplementować. Jedynym sposobem, aby się tego dowiedzieć, jest przeczytanie kodu źródłowego lub, alternatywnie, przekazanie fikcyjnej wartości i użycie błędów kompilatora jako naszej dokumentacji. Jest to coś, co może ulec poprawie dzięki lepszym automatycznym generatorom dokumentów. Ale nie po raz pierwszy żałuję, że Zig nie ma interfejsów.

@TypeOf

W poprzednich częściach użyliśmy @TypeOf, aby pomóc nam zbadać typ różnych zmiennych. Na podstawie naszego użycia można by pomyśleć, że zwraca ona nazwę typu jako ciąg znaków. Jednak biorąc pod uwagę, że jest to funkcja PascalCase, powinieneś wiedzieć lepiej: zwraca ona typ.

Jednym z moich ulubionych zastosowań anytype jest sparowanie go z wbudowanymi funkcjami @TypeOf i @hasField do pisania pomocników testowych. Chociaż każdy typ User, który widzieliśmy, był bardzo prosty, poproszę cię o wyobrażenie sobie bardziej złożonej struktury z wieloma polami. W wielu naszych testach potrzebujemy User, ale chcemy określić tylko pola istotne dla testu. Stwórzmy więc userFactory:

fn userFactory(data: anytype) User {
    const T = @TypeOf(data);
    return .{
        .id = if (@hasField(T, "id")) data.id else 0,
        .power = if (@hasField(T, "power")) data.power else 0,
        .active  = if (@hasField(T, "active")) data.active else true,
        .name  = if (@hasField(T, "name")) data.name else "",
    };
}

pub const User = struct {
    id: u64,
    power: u64,
    active: bool,
    name: [] const u8,
};

Domyślny użytkownik może zostać utworzony przez wywołanie userFactory(.{}) lub możemy nadpisać określone pola za pomocą userFactory(.{.id = 100, .active = false}). To mały wzorzec, ale naprawdę mi się podoba. To także miły krok w świat metaprogramowania.

Częściej @TypeOf jest łączone z @typeInfo, które zwraca std.builtin.Type. Jest to potężny tagowany związek, który w pełni opisuje typ. Funkcja std.json.stringify rekurencyjnie używa tego na dostarczonej value, aby dowiedzieć się, jak ją serializować.

Zig Build

Jeśli przeczytałeś cały ten przewodnik, czekając na wgląd w konfigurowanie bardziej złożonych projektów, z wieloma zależnościami i różnymi celami, będziesz rozczarowany. Zig ma potężny system kompilacji, tak bardzo, że coraz więcej projektów innych niż Zig korzysta z niego, takich jak libsodium. Niestety, cała ta moc oznacza, że dla prostszych potrzeb nie jest on najłatwiejszy w użyciu ani zrozumieniu.

Prawda jest taka, że nie rozumiem systemu kompilacji Ziga wystarczająco dobrze, aby go wyjaśnić.

Mimo to możemy przynajmniej uzyskać krótki przegląd. Aby uruchomić nasz kod Zig, użyliśmy zig run learning.zig. Raz użyliśmy również zig test learning.zig, aby uruchomić test. Polecenia run i test są dobre do zabawy, ale to polecenie build będzie potrzebne do wszystkiego, co bardziej złożone. Polecenie build opiera się na pliku build.zig ze specjalnym punktem wejścia build. Oto szkielet:

// build.zig

const std = @import("std");

pub fn build(b: *std.Build) !void {
    _ = b;
}

Każda kompilacja ma domyślny krok "install", który można teraz uruchomić za pomocą zig build install, ale ponieważ nasz plik jest w większości pusty, nie otrzymamy żadnych znaczących artefaktów. Musimy powiedzieć naszemu build o punkcie wejścia naszego programu, który znajduje się w learning.zig:

const std = @import("std");

pub fn build(b: *std.Build) !void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // konfiguracja pliku wykonywalnego
    const exe = b.addExecutable(.{
        .name = "learning",
        .target = target,
        .optimize = optimize,
        .root_source_file = b.path("learning.zig"),
    });
    b.installArtifact(exe);
}

Teraz, jeśli uruchomisz zig build install, otrzymasz plik binarny w ./zig-out/bin/learning. Korzystanie ze standardowych celów i optymalizacji pozwala nam zastąpić wartości domyślne jako argumenty wiersza poleceń. Na przykład, aby zbudować zoptymalizowaną pod kątem rozmiaru wersję naszego programu dla systemu Windows, wykonalibyśmy następujące polecenie:

zig build install -Doptimize=ReleaseSmall -Dtarget=x86_64-windows-gnu

Plik wykonywalny często ma dwa dodatkowe kroki, poza domyślnym "install": "run" i "test". Biblioteka może mieć pojedynczy krok "test". Aby uzyskać podstawowy run bez argumentów, musimy dodać cztery linie na końcu naszej kompilacji:

// dodajemy po: b.installArtifact(exe);

const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());

const run_step = b.step("run", "Start learning!");
run_step.dependOn(&run_cmd.step);

Tworzy to dwie zależności poprzez dwa wywołania dependOn. Pierwsza wiąże nasze nowe polecenie run z wbudowanym krokiem instalacji. Druga wiąże krok "run" z naszym nowo utworzonym poleceniem "run". Być może zastanawiasz się, dlaczego potrzebujesz zarówno polecenia run, jak i kroku run. Uważam, że ta separacja istnieje, aby wspierać bardziej skomplikowane konfiguracje: kroki, które zależą od wielu poleceń lub poleceń, które są używane w wielu krokach. Jeśli uruchomisz zig build --help i przewiniesz do góry, zobaczysz nasz nowy krok "run". Możesz teraz uruchomić program, wykonując polecenie zig build run.

Aby dodać krok "test", zduplikujesz większość kodu run, który właśnie dodaliśmy, ale zamiast b.addExecutable, rozpoczniesz wszystko od b.addTest:

const tests = b.addTest(.{
    .target = target,
    .optimize = optimize,
    .root_source_file = b.path("learning.zig"),
});

const test_cmd = b.addRunArtifact(tests);
test_cmd.step.dependOn(b.getInstallStep());
const test_step = b.step("test", "Uruchom testy");
test_step.dependOn(&test_cmd.step);

Nadaliśmy temu krokowi nazwę "test". Uruchomienie zig build --help powinno teraz pokazać kolejny dostępny krok, "test". Ponieważ nie mamy żadnych testów, trudno powiedzieć, czy to działa, czy nie. W pliku learning.zig dodajemy:

test "dummy build test" {
    try std.testing.expectEqual(false, true);
}

Teraz, po uruchomieniu testu zig build, powinien pojawić się komunikat o niepowodzeniu testu. Jeśli naprawisz test i ponownie uruchomisz zig build test, nie otrzymasz żadnych danych wyjściowych. Domyślnie program uruchamiający testy Ziga generuje dane wyjściowe tylko w przypadku niepowodzenia. Użyj zig build test --summary all jeśli, tak jak ja, zawsze chcesz otrzymać podsumowanie.

Jest to minimalna konfiguracja potrzebna do rozpoczęcia pracy. Możesz jednak spać spokojnie, wiedząc, że jeśli zajdzie potrzeba jej zbudowania, Zig prawdopodobnie sobie z tym poradzi. Wreszcie, możesz i prawdopodobnie powinieneś użyć zig init w katalogu głównym projektu, aby Zig utworzył dla ciebie dobrze udokumentowany plik build.zig.

Zależności od stron trzecich

Wbudowany menedżer pakietów Ziga jest stosunkowo nowy i w związku z tym ma wiele nieoszlifowanych krawędzi. Chociaż jest miejsce na ulepszenia, jest on użyteczny tak jak jest. Istnieją dwie części, którym musimy się przyjrzeć: tworzenie pakietu i korzystanie z pakietów. Przejdziemy przez to w całości.

Najpierw utwórz nowy folder o nazwie calc i utwórz trzy pliki. Pierwszy to add.zig, z następującą zawartością:

// O, ukryta lekcja, spójrz na typ b
// i typ zwracany!!!

pub fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return a + b;
}

const testing = @import("std").testing;
test "add" {
    try testing.expectEqual(@as(i32, 32), add(30, 2));
}

To trochę głupie, cały pakiet tylko po to, by dodać dwie wartości, ale pozwoli nam skupić się na aspekcie pakowania. Następnie dodamy równie głupi pakiet: calc.zig:

pub const add = @import("add.zig").add;

test {
  // Domyślnie, tylko testy w określonym pliku
  // są uwzględniane. Ta magiczna linia kodu
  // spowoduje, że referencja do wszystkich zagnieżdżonych kontenerów
  // do wszystkich zagnieżdżonych kontenerów.
    @import("std").testing.refAllDecls(@This());
}

Rozdzielamy to między calc.zig i add.zig, aby udowodnić, że zig build automatycznie zbuduje i spakuje wszystkie pliki naszego projektu. Na koniec możemy dodać build.zig:

const std = @import("std");

pub fn build(b: *std.Build) !void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const tests = b.addTest(.{
        .target = target,
        .optimize = optimize,
        .root_source_file = b.path("calc.zig"),
    });

    const test_cmd = b.addRunArtifact(tests);
    test_cmd.step.dependOn(b.getInstallStep());
    const test_step = b.step("test", "Run the tests");
    test_step.dependOn(&test_cmd.step);
}

To wszystko jest powtórzeniem tego, co widzieliśmy w poprzedniej sekcji. W ten sposób można uruchomić zig build test --summary all.

Wracamy do naszego projektu learning i wcześniej utworzonego build.zig. Zaczniemy od dodania naszego lokalnego calc jako zależności. Musimy wprowadzić trzy dodatki. Po pierwsze, utworzymy moduł wskazujący na nasz calc.zig:

// Można go umieścić w górnej części funkcji build
// funkcji, przed wywołaniem addExecutable.

const calc_module = b.addModule("calc", .{
  .root_source_file = b.path("PATH_TO_CALC_PROJECT/calc.zig"),
});

Będziesz musiał dostosować ścieżkę do calc.zig. Teraz musimy dodać ten moduł do naszych istniejących zmiennych exe i tests. Ponieważ nasz build.zig staje się coraz bardziej zajęty, postaramy się trochę uporządkować rzeczy:

const std = @import("std");

pub fn build(b: *std.Build) !void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const calc_module = b.addModule("calc", .{
        .root_source_file = b.path("PATH_TO_CALC_PROJECT/calc.zig"),
    });

    {
        // skonfiguruj nasze polecenia "run"

        const exe = b.addExecutable(.{
            .name = "learning",
            .target = target,
            .optimize = optimize,
            .root_source_file = b.path("learning.zig"),
        });
        // dodaj to
        exe.root_module.addImport("calc", calc_module);
        b.installArtifact(exe);

        const run_cmd = b.addRunArtifact(exe);
        run_cmd.step.dependOn(b.getInstallStep());

        const run_step = b.step("run", "Start learning!");
        run_step.dependOn(&run_cmd.step);
    }

    {
        // skonfiguruj nasze polecenie "test"
        const tests = b.addTest(.{
            .target = target,
            .optimize = optimize,
            .root_source_file = b.path("learning.zig"),
        });
        // dodaj to
        tests.root_module.addImport("calc", calc_module);

        const test_cmd = b.addRunArtifact(tests);
        test_cmd.step.dependOn(b.getInstallStep());
        const test_step = b.step("test", "Run the tests");
        test_step.dependOn(&test_cmd.step);
    }
}

Z poziomu projektu możesz teraz @import("calc"):

const calc = @import("calc");
...
calc.add(1, 2);

Dodanie zdalnej zależności wymaga nieco więcej wysiłku. Najpierw musimy wrócić do projektu calc i zdefiniować moduł. Można by pomyśleć, że sam projekt jest modułem, ale projekt może eksponować wiele modułów, więc musimy go jawnie utworzyć. Używamy tego samego addModule, ale odrzucamy wartość zwracaną. Samo wywołanie addModule wystarczy, aby zdefiniować moduł, który inne projekty będą mogły zaimportować.

_ = b.addModule("calc", .{
  .root_source_file = b.path("calc.zig"),
});

To jedyna zmiana, jaką musimy wprowadzić w naszej bibliotece. Ponieważ jest to ćwiczenie polegające na posiadaniu zdalnej zależności, przesłałem ten projekt calc na Github, abyśmy mogli zaimportować go do naszego projektu edukacyjnego. Jest on dostępny pod adresem https://github.com/karlseguin/calc.zig.

W naszym projekcie edukacyjnym potrzebujemy nowego pliku, build.zig.zon. "ZON" oznacza Zig Object Notation i umożliwia wyrażanie danych Ziga w formacie czytelnym dla człowieka oraz przekształcanie tego formatu w kod Ziga. Zawartość build.zig.zon będzie następująca:

.{
  .name = "learning",
  .paths = .{""},
  .version = "0.0.0",
  .dependencies = .{
    .calc = .{
      .url = "https://github.com/karlseguin/calc.zig/archive/d1881b689817264a5644b4d6928c73df8cf2b193.tar.gz",
      .hash = "12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
    },
  },
}

W tym pliku znajdują się dwie wątpliwe wartości, pierwsza to d1881b689817264a5644b4d6928c73df8cf2b193 w adresie url. Jest to po prostu git commit hash. Drugi to wartość hash. O ile mi wiadomo, obecnie nie ma dobrego sposobu na określenie, jaka powinna być ta wartość, więc na razie używamy wartości fikcyjnej.

Aby użyć tej zależności, musimy dokonać jednej zmiany w naszym build.zig:

// zamień to:
const calc_module = b.addModule("calc", .{
  .root_source_file = b.path("calc/calc.zig"),
});

// na to:
const calc_dep = b.dependency("calc", .{ .target = target, .optimize = optimize});
const calc_module = calc_dep.module("calc");

W build.zig.zon nazwaliśmy zależność calc i jest to zależność, którą ładujemy tutaj. Z poziomu tej zależności pobieramy moduł calc, który został nazwany w build.zig w calc.

Jeśli spróbujesz uruchomić zig build test, powinieneś zobaczyć błąd:

hash mismatch: manifest declares
122053da05e0c9348d91218ef015c8307749ef39f8e90c208a186e5f444e818672da

but the fetched package has
122036b1948caa15c2c9054286b3057877f7b152a5102c9262511bf89554dc836ee5

Skopiuj i wklej poprawny hash z powrotem do build.zig.zon i spróbuj ponownie uruchomić zig build test. Wszystko powinno teraz działać.

Wydaje się, że to dużo i mam nadzieję, że wszystko zostanie usprawnione. Ale jest to głównie coś, co można skopiować i wkleić z innych projektów, a po skonfigurowaniu można przejść dalej.

Słowo ostrzeżenia, zauważyłem, że buforowanie zależności w Zigu jest po agresywnej stronie. Jeśli próbujesz zaktualizować zależność, ale Zig wydaje się nie wykrywać zmiany...cóż, wyrzucam folder zig-cache projektu, a także ~/.cache/zig.


Omówiliśmy wiele obszarów, badając kilka podstawowych struktur danych i łącząc ze sobą duże fragmenty poprzednich części. Nasz kod stał się nieco bardziej złożony, skupiając się mniej na konkretnej składni i wyglądając bardziej jak prawdziwy kod. Jestem podekscytowany możliwością, że pomimo tej złożoności, kod w większości miał sens. Jeśli nie, nie poddawaj się. Wybierz przykład i złam go, dodaj instrukcje wypisywania, napisz dla jakieś niego testy. Zajmij się kodem, stwórz własny, a następnie wróć i przeczytaj te części, które nie miały sensu.

Wnioski

Niektórzy czytelnicy mogą rozpoznać mnie jako autora różnych "The Little $TECH Book" i zastanawiać się, dlaczego ta książka nie nazywa się "The Little Zig Book". Prawda jest taka, że nie jestem pewien, czy Zig pasuje do formatu "The Little". Częścią wyzwania jest to, że złożoność i krzywa uczenia się Ziga będą się znacznie różnić w zależności od własnego praktyki i doświadczenia. Jeśli jesteś wytrawnym programistą C lub C++, to zwięzłe podsumowanie języka jest prawdopodobnie w porządku, ale wtedy prawdopodobnie będziesz polegać na Zig Language Reference.

Chociaż w tym przewodniku poruszyliśmy wiele kwestii, to nadal istnieje duża ilość treści, których nie poruszyliśmy. Nie chcę, aby to cię zniechęciło lub przytłoczyło. Wszystkie języki są wielowarstwowe, a teraz masz podstawy i odniesienie, aby ruszyć i rozpocząć swoje mistrzostwo. Szczerze mówiąc, części, których nie omówiłem, po prostu nie rozumiem wystarczająco dobrze, aby je wyjaśnić. Nie powstrzymało mnie to przed używaniem i tworzeniem znaczących rzeczy w Zigu, takich jak popularna biblioteka serwera http.

Chcę podkreślić jedną rzecz, która została całkowicie pominięta. Jest to prawdopodobnie coś, co już wiesz, ale Zig działa szczególnie dobrze z kodem C. Ponieważ ekosystem jest wciąż młody, a standardowa biblioteka niewielka, możesz napotkać przypadki, w których użycie biblioteki C jest najlepszą opcją. Na przykład, w standardowej bibliotece Ziga nie ma modułu wyrażeń regularnych, a jedną rozsądną opcją byłoby użycie biblioteki C. Napisałem biblioteki Ziga dla SQLite i DuckDB i było to proste. Jeśli zastosowałeś się do wszystkich wskazówek zawartych w tym przewodniku, nie powinieneś mieć żadnych problemów.

Mam nadzieję, że ten materiał okaże się pomocny i że programowanie sprawi ci przyjemność.

Dzięki

Dziękuję wszystkim osobom, które wniosły poprawki i sugestie do tej serii. W szczególności dziękuję Gonzalo Diethelmowi za dokładną edycję.