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ąceK
iV
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)
.