Genéricos (parametrização polimórfica)
No final da parte anterior, construímos uma matriz dinâmica básica chamada IntList
. O objetivo dessa estrutura de dados era armazenar um número dinâmico de valores. Embora o algoritmo que usamos funcionasse para qualquer tipo de dado, nossa implementação estava vinculada a valores i64
. Aí entram os genéricos, cujo objetivo é abstrair algoritmos e estruturas de dados de tipos específicos.
Muitas linguagens implementam genéricos com sintaxe especial e regras específicas para genéricos. No caso do Zig, os genéricos são menos uma característica específica e mais uma expressão do que a linguagem é capaz. Especificamente, os genéricos aproveitam a poderosa metaprogramação em tempo de compilação do Zig.
Vamos começar olhando para um exemplo bobo, apenas para nos situarmos:
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;
}
O código acima imprime { 1, 10, 100 }
. A parte interessante é que temos uma função que retorna um type
(portanto, a função tem PascalCase). E não é qualquer tipo, mas um tipo com base em um parâmetro de função. Esse código só funcionou porque declaramos length
como comptime
. Ou seja, exigimos que quem chama IntArray
forneça um parâmetro de comprimento conhecido em tempo de compilação. Isso é necessário porque nossa função retorna um tipo (type
) e os tipos (types
) devem sempre ser conhecidos em tempo de compilação.
Uma função pode retornar qualquer tipo, não apenas primitivos e arrays. Por exemplo, com uma pequena alteração, podemos fazê-la retornar uma estrutura:
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,
};
}
Pode parecer estranho, mas o tipo de arr
realmente é IntArray(3)
. É um tipo como qualquer outro tipo, e arr
é um valor como qualquer outro valor. Se chamássemos IntArray(7)
, seria um tipo diferente. Talvez possamos organizar melhor as coisas:
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,
};
}
};
}
À primeira vista, pode não parecer mais organizado. Mas além de não ter nome e estar aninhada em uma função, nossa estrutura está se parecendo com qualquer outra estrutura que vimos até agora. Ela tem campos, tem funções. Você sabe o que dizem: se parece com um pato.... Bem, isso se parece, nada e grasna como uma estrutura normal, porque é.
Tomamos esse caminho para nos familiarizarmos com uma função que retorna um tipo e a sintaxe correspondente. Para obter um genérico mais típico, precisamos fazer uma última alteração: nossa função precisa receber um tipo. Na realidade, esta é uma pequena mudança, mas type
pode parecer mais abstrato do que usize
, então fizemos isso lentamente. Vamos dar um salto e modificar nossa IntList
anterior para funcionar com qualquer tipo. Começaremos com um esqueleto:
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),
};
}
}
};
A estrutura (struct
) acima é quase idêntica à nossa IntList
anterior, exceto que i64
foi substituído por T
. Esse T
pode parecer especial, mas é apenas um nome de variável. Poderíamos tê-lo chamado de item_type
. No entanto, seguindo a convenção de nomenclatura do Zig, variáveis do tipo type
são escritas em PascalCase.
Para o bem ou para o mal, usar uma única letra para representar um parâmetro de tipo é muito mais antigo que o Zig.
T
é um padrão comum na maioria das linguagens, mas você verá variações específicas do contexto, como mapas de hash usandoK
eV
para seus tipos de parâmetros de chave e valor.
Se você não tem certeza sobre nosso esqueleto, considere os dois lugares onde usamos T
: items: []T
e allocator.alloc(T, 4)
. Quando queremos usar esse tipo genérico, criaremos uma instância usando:
var list = try List(u32).init(allocator);
Quando o código é compilado, o compilador cria um novo tipo substituindo cada T
por u32
. Se usarmos List(u32)
novamente, o compilador reutilizará o tipo que foi criado anteriormente. Se especificarmos um novo valor para T
, como List(bool)
ou List(User)
, novos tipos serão criados.
Para completar nossa Lista genérica, podemos literalmente copiar e colar o restante do código do IntList
e substituir i64
por T
. Aqui está um exemplo completo funcional:
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) {
// ficamos sem espaço
// cria-se um "slice" duas vezes maior
var larger = try self.allocator.alloc(T, len * 2);
// copia-se os itens que adicionamos previamente ao nosso novo espaço criado
@memcpy(larger[0..len], self.items);
self.allocator.free(self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
}
Nossa função init
retorna uma List(T)
, e nossas funções deinit
e add
recebem uma List(T)
e *List(T)
. Para nossa classe simples, isso está bem, mas para estruturas de dados grandes, escrever o nome genérico completo pode se tornar um pouco tedioso, especialmente se tivermos múltiplos parâmetros de tipo (por exemplo, um mapa hash que aceita um tipo separado para sua chave e valor). A função embutida @This()
retorna o tipo mais interno de onde é chamada. Muito provavelmente, nosso List(T)
seria escrito como:
fn List(comptime T: type) type {
return struct {
pos: usize,
items: []T,
allocator: Allocator,
// Adicionado
const Self = @This();
fn init(allocator: Allocator) !Self {
// mesmo código
}
fn deinit(self: Self) void {
// mesmo código
}
fn add(self: *Self, value: T) !void {
// mesmo código
}
};
}
Self
não é um nome especial, é apenas uma variável, e está em PascalCase porque seu valor é um tipo (type
). Podemos usar Self
onde anteriormente usávamos List(T)
.
Poderíamos criar exemplos mais complexos, com vários parâmetros de tipo e algoritmos mais avançados. No entanto, no final, o código genérico essencial não seria diferente dos exemplos simples acima. Na próxima parte, voltaremos a abordar os genéricos ao analisar a ArrayList(T)
e StringHashMap(V)
da biblioteca padrão.